SiblingFinish.java

package network.ike.plugin.ws;

import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.ws.vcs.VcsOperations;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.MojoException;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
 * The finish half of the local-origin sibling model
 * (IKE-Network/ike-issues#992): a sibling finishes <em>locally to its
 * local origin</em> — the working set it was derived from — and GitHub
 * enters only when that parent later pushes.
 *
 * <p>Under the model, {@code origin} in every sibling member is the
 * parent's member path, so the finish's old "push {@code main} to
 * origin" phase cannot apply: git refuses to update a non-bare
 * repository's checked-out branch ({@code denyCurrentBranch}), and that
 * refusal is the right invariant — a sibling must not mutate the
 * parent's mainline from outside. Instead the mutation executes
 * <em>in the parent</em>:
 *
 * <ol>
 *   <li>the sibling squash- or merge-lands the feature on its own local
 *       target branch, exactly as before (a purely local act);</li>
 *   <li>each parent member fetches that target from its sibling member
 *       and fast-forwards — {@code --ff-only} when the branch is checked
 *       out, a refusing ref update when it is not. The sibling's target
 *       is the parent's target plus the landing commit, so this is a
 *       fast-forward by construction;</li>
 *   <li>nothing is pushed anywhere. Externalization is the parent's own
 *       explicit {@code ws:push}.</li>
 * </ol>
 *
 * <p>A member whose parent side is not a fast-forward (the parent moved
 * on independently) is reported, never forced and never merged: the
 * operator reconciles in the parent. Feature-branch deletion in the
 * sibling is gated on the parent having absorbed the landing — the
 * local-origin analogue of the {@code #858} no-stranded-squash contract,
 * with the parent playing the role the remote played.
 *
 * @see FeatureStartSiblingPublishMojo for the creation half
 */
final class SiblingFinish {

    /** Where the creation half records the derived-from parent (#992). */
    static final String PARENT_RECORD = ".ike/parent-workspace";

    private SiblingFinish() {}

    /**
     * The parent working set this sibling was derived from, when the
     * sibling really does chain to it locally.
     *
     * <p>Resolution order: the {@link #PARENT_RECORD} written at
     * creation, then the {@code <parent>꞉<feature>} naming convention
     * (so siblings created before the record existed still resolve).
     * Either way the result is confirmed against the sibling root's
     * {@code origin}: only when origin resolves to the parent's path is
     * this a local-origin sibling. A sibling still pointing at GitHub —
     * every sibling created before #992 — resolves to empty here and
     * keeps the remote-origin finish path.
     *
     * @param siblingRoot the workspace root the finish is running in
     * @return the parent workspace root, or empty when this is not a
     *         local-origin sibling
     */
    static Optional<File> localParent(File siblingRoot) {
        Optional<File> candidate = recordedParent(siblingRoot)
                .or(() -> FeatureFinishSupport.deriveParentWorkspace(siblingRoot));
        if (candidate.isEmpty()) {
            return Optional.empty();
        }
        File parent = candidate.get();
        if (!new File(parent, ".git").exists()) {
            return Optional.empty();
        }
        return originResolvesTo(siblingRoot, parent)
                ? Optional.of(parent) : Optional.empty();
    }

    /**
     * The parent recorded at creation in {@link #PARENT_RECORD}, resolved
     * against the sibling root. The record holds a relative path, so it
     * stays valid on every machine the sync layer carries it to.
     */
    private static Optional<File> recordedParent(File siblingRoot) {
        Path record = siblingRoot.toPath().resolve(PARENT_RECORD);
        if (!Files.exists(record)) {
            return Optional.empty();
        }
        try {
            String value = Files.readString(record, StandardCharsets.UTF_8).trim();
            if (value.isEmpty()) {
                return Optional.empty();
            }
            return Optional.of(siblingRoot.toPath().resolve(value)
                    .normalize().toFile());
        } catch (IOException e) {
            return Optional.empty();
        }
    }

    /** Whether {@code dir}'s {@code origin} is {@code expected}'s path. */
    private static boolean originResolvesTo(File dir, File expected) {
        if (!ReleaseSupport.hasRemote(dir, "origin")) {
            return false;
        }
        try {
            String url = ReleaseSupport.execCapture(dir,
                    "git", "remote", "get-url", "origin").trim();
            if (url.isEmpty()) {
                return false;
            }
            File origin = url.startsWith("file://")
                    ? new File(java.net.URI.create(url))
                    : new File(url);
            return origin.getAbsoluteFile().toPath().normalize()
                    .equals(expected.getAbsoluteFile().toPath().normalize());
        } catch (MojoException | IllegalArgumentException e) {
            return false;
        }
    }

    /** One member's parent-side outcome. */
    record Absorbed(String member, boolean ok, String detail) {}

    /**
     * Fast-forward every parent member from its sibling member, landing
     * the finished target branch in the parent.
     *
     * <p>Members are addressed by name against both roots, with the
     * workspace root itself included last (mirroring the push phase's
     * member set). A member missing on either side is reported as
     * absorbed-with-nothing-to-do rather than failing the finish: the
     * sibling may legitimately carry members the parent never
     * materialized.
     *
     * @param siblingRoot  the sibling workspace root the finish ran in
     * @param parentRoot   the parent workspace root it chains to
     * @param members      member names finished this run
     * @param targetBranch the branch that was finished into (e.g. main)
     * @param log          Maven logger
     * @return one result per member (root included), in order
     */
    static List<Absorbed> absorbIntoParent(File siblingRoot, File parentRoot,
                                           List<String> members,
                                           String targetBranch, Log log) {
        Map<String, File> siblingDirs = new LinkedHashMap<>();
        for (String name : members) {
            siblingDirs.put(name, new File(siblingRoot, name));
        }
        siblingDirs.put(RefreshMainSupport.ROOT_LABEL, siblingRoot);

        List<Absorbed> results = new ArrayList<>();
        log.info("");
        log.info("  " + Ansi.cyan("→ ") + "Landing " + targetBranch
                + " in the parent working set '" + parentRoot.getName()
                + "' (fast-forward only)...");
        for (Map.Entry<String, File> entry : siblingDirs.entrySet()) {
            String member = entry.getKey();
            File siblingDir = entry.getValue();
            File parentDir = RefreshMainSupport.ROOT_LABEL.equals(member)
                    ? parentRoot : new File(parentRoot, member);
            results.add(absorbMember(member, siblingDir, parentDir,
                    targetBranch, log));
        }
        return results;
    }

    /**
     * Fast-forward one parent member from its sibling counterpart:
     * fetch the sibling's target into {@code FETCH_HEAD}, verify the
     * parent's target is an ancestor of it, then fast-forward — with
     * {@code merge --ff-only} when the parent has the branch checked
     * out, or a refusing {@code fetch <path> <target>:<target>} ref
     * update when it does not (never touching the parent's working
     * tree).
     */
    private static Absorbed absorbMember(String member, File siblingDir,
                                         File parentDir, String targetBranch,
                                         Log log) {
        if (!new File(siblingDir, ".git").exists()
                || !new File(parentDir, ".git").exists()) {
            log.debug("    " + member + " — no counterpart pair; skipped");
            return new Absorbed(member, true, "no counterpart");
        }
        String source = siblingDir.getAbsolutePath();
        try {
            if (!VcsOperations.localBranchExists(siblingDir, targetBranch)) {
                return new Absorbed(member, true,
                        "no " + targetBranch + " in the sibling");
            }
            String siblingSha = VcsOperations.branchSha(siblingDir, targetBranch);

            if (!VcsOperations.localBranchExists(parentDir, targetBranch)) {
                ReleaseSupport.exec(parentDir, log, "git", "fetch", source,
                        targetBranch + ":" + targetBranch);
                log.info("    " + Ansi.green("✓ ") + member + " — "
                        + targetBranch + " created in the parent (" + siblingSha + ")");
                return new Absorbed(member, true, siblingSha);
            }

            String parentSha = VcsOperations.branchSha(parentDir, targetBranch);
            if (parentSha.equals(siblingSha)) {
                log.info("    " + member + " — parent already at " + siblingSha);
                return new Absorbed(member, true, siblingSha);
            }

            // Fetch the sibling's target so ancestry can be tested
            // against a local ref, then fast-forward if and only if the
            // parent has not moved on independently.
            ReleaseSupport.exec(parentDir, log, "git", "fetch", source, targetBranch);
            if (!VcsOperations.isAncestor(parentDir, targetBranch, siblingSha)) {
                log.warn("    " + Ansi.yellow("⚠ ") + member + " — parent "
                        + targetBranch + " is not an ancestor of the sibling's;"
                        + " left alone");
                return new Absorbed(member, false,
                        "parent " + targetBranch + " diverged (" + parentSha
                        + " vs " + siblingSha + ") — reconcile in the parent");
            }

            if (targetBranch.equals(VcsOperations.currentBranch(parentDir))) {
                VcsOperations.mergeFfOnly(parentDir, log, siblingSha);
            } else {
                // Refuses on non-fast-forward rather than discarding work.
                ReleaseSupport.exec(parentDir, log, "git", "fetch", source,
                        targetBranch + ":" + targetBranch);
            }
            int landed = VcsOperations.commitLog(
                    parentDir, parentSha, siblingSha).size();
            log.info("    " + Ansi.green("✓ ") + member + " — fast-forwarded ("
                    + landed + " commit" + (landed == 1 ? "" : "s") + ")");
            return new Absorbed(member, true, siblingSha);
        } catch (MojoException e) {
            log.warn("    " + Ansi.red("✗ ") + member
                    + " — could not land in the parent: " + e.getMessage());
            return new Absorbed(member, false, e.getMessage());
        }
    }

    /**
     * The failure message when the parent could not absorb every member:
     * names each member and its reason, states that no feature branch was
     * deleted, and gives the recovery. The local-origin analogue of
     * {@code FeatureFinishSupport.pushPhaseFailureMessage} — the parent
     * plays the role the remote played in {@code #858}.
     *
     * @param failures     members the parent could not fast-forward
     * @param targetBranch the branch being landed
     * @param parentRoot   the parent working set
     * @return the formatted failure message
     */
    static String absorbFailureMessage(List<Absorbed> failures,
                                       String targetBranch, File parentRoot) {
        StringBuilder sb = new StringBuilder();
        sb.append("Feature-finish stopped: the parent working set '")
                .append(parentRoot.getName()).append("' could not absorb ")
                .append(failures.size()).append(" member(s) — the landing "
                        + "exists on the sibling's ").append(targetBranch)
                .append(" only:\n");
        for (Absorbed f : failures) {
            sb.append("  ").append(f.member()).append(" — ")
                    .append(f.detail()).append("\n");
        }
        sb.append("\nNo feature branch was deleted. Reconcile the parent, "
                + "then re-run the finish:\n")
                .append("  cd ").append(parentRoot.getAbsolutePath())
                .append("\n  mvn ").append(WsGoal.PULL.qualified())
                .append("   # reconcile ").append(targetBranch)
                .append("\n\nThe sibling's work is intact — nothing was forced "
                        + "or discarded.");
        return sb.toString();
    }
}