UrlCascadeResolver.java

package network.ike.workspace.cascade;

import network.ike.plugin.ReleaseSupport;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.function.Consumer;

/**
 * A {@link CascadeAssembler.CascadeResolver} that resolves cascade
 * members from their git {@code url} rather than local sibling
 * checkouts (IKE-Network/ike-issues#429).
 *
 * <p>{@code CascadeAssembler} stitches the release cascade from each
 * member's per-project {@code release-cascade.yaml}. The sibling-checkout
 * resolver assumes every member is a directory alongside the repo the
 * goal runs in — true on a developer workstation, false on a CI agent
 * that has only one repo checked out. This resolver closes that gap:
 * it shallow-clones each member from its edge's {@code url} and reads
 * {@code src/main/cascade/release-cascade.yaml} from the clone.
 *
 * <p>Clones land in a caller-supplied directory — one subdirectory per
 * member, named by the edge's {@code repo}. A member already cloned
 * there is refreshed with {@code git pull --ff-only} rather than
 * re-cloned. The resolver is host-agnostic: it passes the {@code url}
 * to {@code git clone} verbatim.
 */
public final class UrlCascadeResolver
        implements CascadeAssembler.CascadeResolver {

    private final Path cloneDir;
    private final Consumer<String> log;

    /**
     * Creates a resolver that clones into {@code cloneDir} and logs
     * nothing.
     *
     * @param cloneDir the directory shallow clones are placed under
     */
    public UrlCascadeResolver(Path cloneDir) {
        this(cloneDir, msg -> { });
    }

    /**
     * Creates a resolver that clones into {@code cloneDir} and reports
     * progress through {@code log}.
     *
     * @param cloneDir the directory shallow clones are placed under
     * @param log      sink for one progress line per member resolved
     */
    public UrlCascadeResolver(Path cloneDir, Consumer<String> log) {
        if (cloneDir == null) {
            throw new IllegalArgumentException("cloneDir is required");
        }
        if (log == null) {
            throw new IllegalArgumentException("log is required");
        }
        this.cloneDir = cloneDir;
        this.log = log;
    }

    /**
     * Shallow-clones the member named by {@code edge} and parses its
     * {@code release-cascade.yaml}.
     *
     * @param edge the edge naming the member; must carry a {@code url}
     * @return the member's parsed manifest
     * @throws IllegalStateException if the edge has no {@code url} or
     *                               the clone has no manifest
     * @throws UncheckedIOException  if the clone directory cannot be
     *                               created
     */
    @Override
    public ProjectCascade resolve(CascadeEdge edge) {
        if (edge.url() == null || edge.url().isBlank()) {
            throw new IllegalStateException("cascade edge " + edge.ga()
                    + " has no url — cannot resolve it without a local"
                    + " checkout");
        }
        try {
            Files.createDirectories(cloneDir);
        } catch (IOException e) {
            throw new UncheckedIOException(
                    "cannot create cascade clone directory "
                    + cloneDir, e);
        }

        Path checkout = cloneDir.resolve(edge.repo());
        if (Files.isDirectory(checkout.resolve(".git"))) {
            log.accept("Refreshing " + edge.repo() + " in " + checkout);
            ReleaseSupport.execCapture(checkout.toFile(),
                    "git", "pull", "--ff-only");
        } else {
            log.accept("Cloning " + edge.repo() + " from " + edge.url());
            ReleaseSupport.execCapture(cloneDir.toFile(),
                    "git", "clone", "--depth", "1",
                    edge.url(), edge.repo());
        }

        Path manifest = checkout.resolve(
                ProjectCascadeIo.MANIFEST_RELATIVE_PATH);
        if (!Files.isRegularFile(manifest)) {
            throw new IllegalStateException("cascade member "
                    + edge.ga() + " (cloned from " + edge.url()
                    + ") has no " + ProjectCascadeIo.MANIFEST_RELATIVE_PATH);
        }
        return ProjectCascadeIo.read(manifest);
    }
}