FeatureStartSiblingPublishMojo.java

package network.ike.plugin.ws;

import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.support.GoalReportBuilder;

import network.ike.workspace.Defaults;
import network.ike.workspace.FeatureName;
import network.ike.workspace.ManifestWriter;
import network.ike.workspace.Subproject;
import network.ike.workspace.VersionSupport;
import network.ike.workspace.WorkingSet;
import network.ike.workspace.WorkspaceGraph;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;

import java.io.File;
import java.io.IOException;
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;

/**
 * Start a feature in a <em>sibling workspace clone</em> beside the primary
 * (IKE-Network/ike-issues#201 epic, #207, reshaped into the feature-start
 * 2×2 in #770).
 *
 * <p>This is the <em>sibling</em> column of {@code ws:feature-start}: where
 * {@code ws:feature-start-publish} branches the primary in place — which
 * under Syncthing rewrites the working tree while the other machine's
 * {@code .git/HEAD} stays put, the failure mode that motivates this goal —
 * {@code ws:feature-start-sibling-publish} makes a second clone of the whole
 * workspace alongside the primary, checked out on {@code feature/<name>}
 * from inception. The primary never leaves its current branch; the feature
 * lives in its own directory and is disposable ({@code rm -rf}) after merge.
 * The same isolation makes concurrent same-machine work safe: each sibling
 * has its own working tree, so two lines of work never stage each other's
 * edits.
 *
 * <p><strong>What it does</strong> (workspace mode):
 * <ol>
 *   <li>Validates {@code feature} through {@link FeatureName} and computes
 *       the sibling directory {@code <parent>/<baseName>-<feature>/} from the
 *       resolved working set's {@link WorkingSet#baseName() baseName};
 *       refuses if it already exists.</li>
 *   <li>Resolves the <em>base branch</em> to clone from: {@code -Dfrom} when
 *       given, else the primary's current branch — guarded so a sibling is
 *       not silently cut from an unexpected branch (see
 *       {@link SiblingBaseResolution}).</li>
 *   <li>Clones the workspace root, then each subproject in topological
 *       order, into the sibling with
 *       {@code git clone --reference <primary>/<component> --dissociate -b
 *       <base> <remote> <sibling>/<component>}. {@code --reference} borrows
 *       the object database from the primary's local clone (the
 *       order-of-magnitude win for large histories like tinkar-core's
 *       492 MB); {@code --dissociate} then copies the borrowed objects so
 *       the sibling is fully self-contained.</li>
 *   <li>Creates {@code feature/<name>} in each clone and applies the same
 *       version qualification and BOM/property cascade that
 *       {@code ws:feature-start-publish} produces in-place, via the shared
 *       {@link FeatureStartSupport}.</li>
 *   <li>Rewrites the sibling's {@code workspace.yaml} branch fields and
 *       commits.</li>
 * </ol>
 *
 * <p><strong>No push.</strong> Externally visible side effects stay opt-in
 * (per {@code feedback_workspace_ops_completion}). The clones, branches,
 * version commits, and {@code workspace.yaml} update are all recoverable
 * local effects and happen by default; pushing to origin is not.
 *
 * <pre>{@code
 * mvn ws:feature-start-sibling-publish -Dfeature=jira-456
 * #   creates ../<workspace>-jira-456/ with every component on
 * #   feature/jira-456, then: cd ../<workspace>-jira-456 && work.
 * }</pre>
 *
 * @see FeatureName for the sibling-directory naming rule
 * @see FeatureStartSupport for the shared version/cascade logic
 * @see SiblingBaseResolution for the base-branch resolution + guard
 * @see FeatureStartSiblingDraftMojo for the read-only preview
 */
@Mojo(name = "feature-start-sibling-publish", projectRequired = false, aggregator = true)
public class FeatureStartSiblingPublishMojo extends AbstractWorkspaceMojo {

    /** Feature name. Branch will be {@code feature/<name>}. Prompted if omitted. */
    @Parameter(property = "feature")
    String feature;

    /**
     * Skip POM version qualification. Useful for document workspaces whose
     * subprojects have no versioned Maven artifacts. Matches the
     * {@code ws:feature-start} flag of the same name.
     */
    @Parameter(property = "skipVersion", defaultValue = "false")
    boolean skipVersion;

    /**
     * Explicit base branch to cut the sibling from. When unset, the sibling
     * is based on the primary's current branch — guarded so a sibling is
     * never silently cut from a non-base branch (see
     * {@link SiblingBaseResolution}). Pass {@code -Dfrom=<branch>} to base
     * the sibling on a branch other than the manifest's base deliberately.
     */
    @Parameter(property = "from")
    String from;

    /**
     * Opt in to building the sibling's whole reactor from its root once the
     * clone is made — proving it compiles and populating the local repository
     * with the sibling's {@code -<feature>-SNAPSHOT} artifacts. Off by default
     * (creating the sibling is the goal's effect; building is verification).
     * The dedicated {@code ws:feature-start-sibling-publish-verify} goal turns
     * this on by default. See IKE-Network/ike-issues#777.
     */
    @Parameter(property = "verify", defaultValue = "false")
    boolean verify;

    /**
     * Maven goals run from the sibling root when {@link #verify} is set (or for
     * the {@code -verify} goal). Defaults to {@code clean install -DskipTests
     * -T 1C}: {@code install} (not just {@code verify}) so every subproject's
     * {@code -<feature>-SNAPSHOT} lands in the local repository — which is what
     * lets a later partial ({@code -pl … -am}) or IDE build resolve them, the
     * gap that made an unbuilt sibling look broken (#777). A full-reactor build
     * also sidesteps the trap directly. Override for a lighter or stricter
     * check, e.g. {@code -Dws.sibling.verifyGoals="clean verify -DskipTests"}.
     */
    @Parameter(property = "ws.sibling.verifyGoals",
            defaultValue = "clean install -DskipTests -T 1C")
    String verifyGoals;

    /** Creates this goal instance. */
    public FeatureStartSiblingPublishMojo() {}

    /**
     * Whether to build the sibling reactor after creating it. The base goal
     * returns the {@code -Dverify} flag; the {@code -verify} subclass overrides
     * {@link #verifyByDefault()} so the build is on by default.
     *
     * @return {@code true} when the post-create reactor build should run
     */
    protected final boolean shouldVerify() {
        return verify || verifyByDefault();
    }

    /**
     * Default for the post-create reactor build when {@code -Dverify} is not
     * given. {@code false} here; {@link FeatureStartSiblingPublishVerifyMojo}
     * overrides it to {@code true}.
     *
     * @return {@code false} — the base goal does not build unless asked
     */
    protected boolean verifyByDefault() {
        return false;
    }

    /**
     * The goal identity used for this run's report file. The base goal reports
     * as {@link WsGoal#FEATURE_START_SIBLING_PUBLISH}; the {@code -verify}
     * subclass overrides this so its report lands in its own file.
     *
     * @return the {@link WsGoal} this invocation reports as
     */
    protected WsGoal reportGoal() {
        return WsGoal.FEATURE_START_SIBLING_PUBLISH;
    }

    /**
     * Build the sibling's whole reactor from its root, failing loud if it does
     * not build. Runs {@link #verifyGoals} (default {@code clean install
     * -DskipTests -T 1C}) as a subprocess against the freshly created sibling,
     * mirroring {@code ws:checkpoint-publish}'s pre-tag reactor gate (#689).
     *
     * <p>On failure the clone is left intact — the message says so — so the
     * user can fix the build in place rather than re-clone. Overridable so the
     * #777 coverage can drive the pass/fail outcome without a nested build.
     *
     * @param siblingRoot the sibling workspace root to build from
     * @throws MojoException if the reactor build fails, or the subprocess setup
     *                       itself fails
     */
    protected void verifySibling(File siblingRoot) throws MojoException {
        String mvn = WsReleaseDraftMojo.resolveMvnCommand(siblingRoot);
        getLog().info("");
        getLog().info("  Verifying the sibling reactor builds (" + verifyGoals
                + ") ...");
        List<String> cmd = new ArrayList<>();
        cmd.add(mvn);
        for (String token : verifyGoals.split("\\s+")) {
            if (!token.isBlank()) {
                cmd.add(token);
            }
        }
        cmd.add("-B");
        try {
            ReleaseSupport.exec(siblingRoot, getLog(), cmd.toArray(new String[0]));
            getLog().info(Ansi.green("  ✓ ") + "Sibling reactor build succeeded");
        } catch (MojoException e) {
            throw new MojoException(
                    "Sibling created at " + siblingRoot.getAbsolutePath()
                    + ", but its reactor build failed. The clone is intact — fix "
                    + "the build there, or re-run without verification. Cause: "
                    + e.getMessage(), e);
        }
    }

    @Override
    protected WorkspaceReportSpec runGoal() throws MojoException {
        feature = requireParam(feature, "feature",
                "Feature name (without feature/ prefix)");
        FeatureName featureName = validateFeatureName(feature);
        String branchName = "feature/" + feature;

        if (!isWorkspaceMode()) {
            return executeBareMode(featureName, branchName);
        }

        WorkspaceGraph graph = loadGraph();
        File primaryRoot = workspaceRoot();
        Defaults defaults = graph.manifest().defaults();
        String manifestBase = (defaults != null && defaults.branch() != null)
                ? defaults.branch() : "main";

        // Resolve the base branch to clone from, guarding against an
        // unexpected primary branch (#770).
        String base = SiblingBaseResolution.resolveAndGuard(
                from, gitBranch(primaryRoot), manifestBase);

        // Compute the sibling directory alongside the primary workspace,
        // based on the working set's baseName (the workspace-root
        // artifactId, else the dir name) rather than the raw dir name.
        String baseName = resolveWorkingSet().baseName();
        File parent = primaryRoot.getParentFile();
        if (parent == null) {
            throw new MojoException(
                    "Cannot resolve the parent of the workspace root " + primaryRoot
                    + "; sibling clones live alongside the primary.");
        }
        String siblingName = featureName.siblingDirectoryName(baseName);
        File siblingRoot = new File(parent, siblingName);
        if (siblingRoot.exists()) {
            throw new MojoException(
                    "Sibling '" + siblingName + "' already exists at "
                    + siblingRoot.getAbsolutePath()
                    + ". Remove it (rm -rf) or pick a different feature name.");
        }

        // The workspace root must be a git repo with an origin so the sibling
        // clones from the real upstream (not the primary's local path).
        String rootRemote = gitOriginUrl(primaryRoot);
        if (rootRemote == null) {
            throw new MojoException(
                    "Workspace root '" + baseName + "' has no git 'origin' remote; "
                    + "ws:feature-start-sibling clones the root from its upstream. "
                    + "Add one (git remote add origin <url>) and try again.");
        }

        getLog().info("");
        getLog().info(header("Feature Start (sibling)"));
        getLog().info("══════════════════════════════════════════════════════════════");
        getLog().info("  Feature: " + feature);
        getLog().info("  Branch:  " + branchName);
        getLog().info("  Base:    " + base);
        getLog().info("  Sibling: " + siblingRoot.getAbsolutePath());
        getLog().info("");

        List<String> sorted = graph.topologicalSort();

        // 1. Clone the workspace root into the sibling directory. Cloning the
        //    root first materializes the sibling directory itself. Every clone
        //    is cut from the same resolved base branch.
        getLog().info("  Cloning workspace root → " + siblingName);
        cloneOnFeatureBranch(parent, siblingName, primaryRoot, rootRemote,
                base, branchName);

        // 2. Clone each subproject into <sibling>/<name>, in topological order.
        List<String> branched = new ArrayList<>();
        for (String name : sorted) {
            Subproject sub = graph.manifest().subprojects().get(name);
            String remote = sub.repo();
            if (remote == null || remote.isEmpty()) {
                getLog().warn("  ⚠ " + name
                        + " — no repo URL in workspace.yaml, skipping");
                continue;
            }
            File primaryComp = new File(primaryRoot, name);
            getLog().info("  Cloning " + name + " → " + siblingName + "/" + name);
            cloneOnFeatureBranch(siblingRoot, name, primaryComp, remote,
                    base, branchName);
            branched.add(name);
        }

        // 3. Version qualification + cascade on the sibling, producing the
        //    same POM state ws:feature-start-publish would have in-place.
        FeatureStartSupport support = new FeatureStartSupport(getLog());
        Map<String, String> versionByName = new LinkedHashMap<>();
        String rootQualified = null;
        if (!skipVersion) {
            for (String name : branched) {
                Subproject sub = graph.manifest().subprojects().get(name);
                File dir = new File(siblingRoot, name);
                String effectiveVersion = effectiveVersion(sub, dir);
                if (effectiveVersion == null || effectiveVersion.isEmpty()) {
                    continue;
                }
                String newVersion = VersionSupport.branchQualifiedVersion(
                        effectiveVersion, branchName);
                versionByName.put(name, newVersion);
                support.setPomVersion(dir, effectiveVersion, newVersion);
                ReleaseSupport.exec(dir, getLog(), "git", "add", "pom.xml");
                support.commitIfStaged(dir,
                        "feature: set version " + newVersion + " for " + branchName);
                getLog().info(Ansi.green("  ✓ ") + String.format("%-24s %s → %s",
                        name, effectiveVersion, newVersion));
            }
            support.removeIntraReactorPins(siblingRoot, branched, true);
            support.cascadeVersionProperties(graph, siblingRoot, sorted, branchName);
            support.cascadeBomProperties(graph, siblingRoot, sorted, branchName);
            support.cascadeBomImports(graph, siblingRoot, sorted, branchName);

            // Qualify the aggregator's OWN POM version too — feature-start
            // branches the workspace root, so its version takes the same
            // qualifier as every subproject. The in-place path already does
            // this (FeatureStartDraftMojo.branchWorkspaceRepo, ike-issues#721);
            // the sibling path had not ported it, leaving the root pom at its
            // base version (ike-issues#777). The aggregator parents off
            // ike-parent (not itself) and subprojects parent off ike-parent
            // too, so this is a standalone <version> edit. The cascades above
            // commit their own changes, so this is its own commit, like each
            // subproject's. The merge/squash finish paths already de-qualify
            // the aggregator (FeatureFinishSupport.stripWorkspaceRootPom), so
            // qualifying it here makes the round-trip symmetric.
            File rootPom = new File(siblingRoot, "pom.xml");
            if (rootPom.exists()) {
                try {
                    String rootVersion = ReleaseSupport.readPomVersion(rootPom);
                    String qualified = VersionSupport.branchQualifiedVersion(
                            rootVersion, branchName);
                    if (!qualified.equals(rootVersion)) {
                        support.setPomVersion(siblingRoot, rootVersion, qualified);
                        ReleaseSupport.exec(siblingRoot, getLog(),
                                "git", "add", "pom.xml");
                        support.commitIfStaged(siblingRoot,
                                "feature: set version " + qualified
                                        + " for " + branchName);
                        rootQualified = qualified;
                        getLog().info(Ansi.green("  ✓ ") + String.format(
                                "%-24s %s → %s", baseName, rootVersion, qualified));
                    }
                } catch (MojoException e) {
                    getLog().warn("  Could not qualify aggregator root version: "
                            + e.getMessage());
                }
            }
        }

        // 4. Rewrite the sibling's workspace.yaml branch fields and commit.
        Path siblingManifest = new File(siblingRoot, "workspace.yaml").toPath();
        if (Files.exists(siblingManifest) && !branched.isEmpty()) {
            try {
                Map<String, String> branchUpdates = new LinkedHashMap<>();
                for (String name : branched) {
                    branchUpdates.put(name, branchName);
                }
                ManifestWriter.updateBranches(siblingManifest, branchUpdates);
                ReleaseSupport.exec(siblingRoot, getLog(),
                        "git", "add", "workspace.yaml");
                support.commitIfStaged(siblingRoot,
                        "workspace: update branches for " + branchName);
                getLog().info("  Updated workspace.yaml branches for "
                        + branched.size() + " components");
            } catch (IOException e) {
                getLog().warn("  Could not update sibling workspace.yaml: "
                        + e.getMessage());
            }
        }

        getLog().info("");
        getLog().info(Ansi.green("  ✓ ") + "Sibling ready: "
                + siblingRoot.getAbsolutePath());
        getLog().info("    cd " + siblingRoot.getAbsolutePath());
        getLog().info("    # work, then ws:commit-publish / ws:push; "
                + "rm -rf to discard");
        getLog().info("");

        boolean verified = false;
        if (shouldVerify()) {
            verifySibling(siblingRoot);
            verified = true;
        }

        // Build the report rows over the whole working set — every
        // subproject AND the aggregator (workspace root). The aggregator
        // row makes the sibling root's own clone + branch + qualified
        // version visible, the staleness a subproject-only table hid (#763).
        // Identity and kind come from the resolved working set; each row's
        // version/branch/sha are read from the corresponding SIBLING dir,
        // since the goal's effect lives in the sibling, not the primary.
        List<WorkingSetReportTable.Row> rows = new ArrayList<>();
        for (WorkingSet.Member member : resolveWorkingSet().members()) {
            File dir = member.isAggregator()
                    ? siblingRoot
                    : new File(siblingRoot, member.name());
            String effect;
            if (member.isAggregator()) {
                // The aggregator is cloned + branched, its own POM version
                // qualified like every subproject (#777), and its
                // workspace.yaml rewritten + committed.
                effect = (rootQualified != null)
                        ? "cloned + branched " + branchName + " → " + rootQualified
                        : "cloned + branched " + branchName;
            } else if (branched.contains(member.name())) {
                String qualified = versionByName.get(member.name());
                effect = (qualified != null)
                        ? "cloned + branched " + branchName
                                + " → " + qualified
                        : "cloned + branched " + branchName;
            } else {
                effect = "skipped (no repo URL)";
                rows.add(new WorkingSetReportTable.Row(member,
                        WorkingSetReportTable.NONE, WorkingSetReportTable.NONE,
                        WorkingSetReportTable.NONE, effect));
                continue;
            }
            rows.add(new WorkingSetReportTable.Row(member,
                    siblingVersion(dir), gitBranch(dir), gitShortSha(dir),
                    effect));
        }

        return new WorkspaceReportSpec(reportGoal(),
                buildReport(siblingName, siblingRoot, branchName, base, rows,
                        verified));
    }

    /**
     * Bare mode (no {@code workspace.yaml}): fork the current single repo
     * into {@code <parent>/<repo>-<feature>/} on the feature branch. The
     * single-repo case of the same operation — a working set of one
     * (ike-issues#601). No manifest to rewrite and no cascade; otherwise
     * identical to one component of the workspace flow. The sibling-dir base
     * is the repo's {@code baseName} (its dir name), and the base branch is
     * resolved + guarded the same way (manifest base is {@code main}, there
     * being no manifest).
     *
     * @param featureName the validated feature name
     * @param branchName  the {@code feature/<name>} branch to create
     * @return the goal's report
     * @throws MojoException if the repo has no origin, the sibling exists,
     *                       or a git step fails
     */
    private WorkspaceReportSpec executeBareMode(FeatureName featureName,
                                                String branchName)
            throws MojoException {
        // The single repo to fork is a working set of one (ike-issues#611) —
        // resolve it through the shared resolver, not user.dir directly.
        WorkingSet workingSet = resolveWorkingSet();
        File repo = workingSet.members().getFirst().directory().toFile();
        if (!new File(repo, ".git").exists()) {
            throw new MojoException(
                    "ws:feature-start-sibling: " + repo + " is not a git repository "
                    + "and no workspace.yaml was found — run it inside a repo or a "
                    + "workspace.");
        }
        String remote = gitOriginUrl(repo);
        if (remote == null) {
            throw new MojoException(
                    "Repository '" + repo.getName() + "' has no git 'origin' "
                    + "remote; ws:feature-start-sibling clones from the upstream. "
                    + "Add one (git remote add origin <url>) and try again.");
        }
        File parent = repo.getParentFile();
        if (parent == null) {
            throw new MojoException(
                    "Cannot resolve the parent of " + repo
                    + "; the sibling clone lives alongside it.");
        }
        // Base resolution + guard. With no manifest, the base branch is main.
        String base = SiblingBaseResolution.resolveAndGuard(
                from, gitBranch(repo), "main");

        String siblingName = featureName.siblingDirectoryName(workingSet.baseName());
        File siblingRoot = new File(parent, siblingName);
        if (siblingRoot.exists()) {
            throw new MojoException(
                    "Sibling '" + siblingName + "' already exists at "
                    + siblingRoot.getAbsolutePath()
                    + ". Remove it (rm -rf) or pick a different feature name.");
        }

        getLog().info("");
        getLog().info(header("Feature Start (sibling)"));
        getLog().info("══════════════════════════════════════════════════════════════");
        getLog().info("  Feature: " + feature);
        getLog().info("  Branch:  " + branchName);
        getLog().info("  Base:    " + base);
        getLog().info("  Sibling: " + siblingRoot.getAbsolutePath());
        getLog().info("  Mode:    single repo (no workspace.yaml)");
        getLog().info("");

        getLog().info("  Cloning " + repo.getName() + " → " + siblingName);
        cloneOnFeatureBranch(parent, siblingName, repo, remote, base, branchName);

        String qualified = "—";
        if (!skipVersion) {
            File pom = new File(siblingRoot, "pom.xml");
            String effectiveVersion = null;
            if (pom.exists()) {
                try {
                    effectiveVersion = ReleaseSupport.readPomVersion(pom);
                } catch (MojoException e) {
                    getLog().debug("Could not read POM version: " + e.getMessage());
                }
            }
            if (effectiveVersion != null && !effectiveVersion.isEmpty()) {
                String newVersion = VersionSupport.branchQualifiedVersion(
                        effectiveVersion, branchName);
                qualified = newVersion;
                new FeatureStartSupport(getLog()).setPomVersion(
                        siblingRoot, effectiveVersion, newVersion);
                ReleaseSupport.exec(siblingRoot, getLog(), "git", "add", "pom.xml");
                ReleaseSupport.exec(siblingRoot, getLog(), "git", "commit", "-m",
                        "feature: set version " + newVersion + " for " + branchName);
                getLog().info(Ansi.green("  ✓ ") + String.format("%-24s %s → %s",
                        repo.getName(), effectiveVersion, newVersion));
            }
        }

        getLog().info("");
        getLog().info(Ansi.green("  ✓ ") + "Sibling ready: "
                + siblingRoot.getAbsolutePath());
        getLog().info("    cd " + siblingRoot.getAbsolutePath());
        getLog().info("    # work, then ws:commit-publish / ws:push; "
                + "rm -rf to discard");
        getLog().info("");

        boolean verified = false;
        if (shouldVerify()) {
            verifySibling(siblingRoot);
            verified = true;
        }

        // Single-repo working set: one member (the repo, resolved as the
        // aggregator), mapped to its sibling clone. The aggregator is a row
        // here too, so a bare-mode report is shaped identically to the
        // workspace one (#763/#766).
        WorkingSet.Member member = workingSet.members().getFirst();
        String effect = !"—".equals(qualified)
                ? "cloned + branched " + branchName + " → " + qualified
                : "cloned + branched " + branchName;
        List<WorkingSetReportTable.Row> rows = List.of(
                new WorkingSetReportTable.Row(member, siblingVersion(siblingRoot),
                        gitBranch(siblingRoot), gitShortSha(siblingRoot), effect));
        return new WorkspaceReportSpec(reportGoal(),
                buildReport(siblingName, siblingRoot, branchName, base, rows,
                        verified));
    }

    /**
     * Clone {@code remote} into {@code workDir/targetName} on
     * {@code baseBranch}, borrowing objects from {@code referenceDir} when it
     * is a local clone, then create and check out {@code branchName}.
     *
     * <p>When {@code referenceDir} has no {@code .git}, the borrow is skipped
     * and a full clone is performed (with a warning) so a not-yet-cloned
     * primary component doesn't fail the whole goal.
     *
     * @param workDir       directory the clone is created under
     * @param targetName    name of the directory to create under {@code workDir}
     * @param referenceDir  the primary's local clone to borrow objects from
     * @param remote        the upstream URL to clone from
     * @param baseBranch    the mainline branch to clone and branch from
     * @param branchName    the feature branch to create and check out
     * @throws MojoException if a git invocation fails
     */
    private void cloneOnFeatureBranch(File workDir, String targetName,
                                      File referenceDir, String remote,
                                      String baseBranch, String branchName)
            throws MojoException {
        List<String> args = new ArrayList<>();
        args.add("git");
        args.add("clone");
        if (new File(referenceDir, ".git").exists()) {
            args.add("--reference");
            args.add(referenceDir.getAbsolutePath());
            args.add("--dissociate");
        } else {
            getLog().warn("  ⚠ no local clone at " + referenceDir
                    + " to borrow from — full clone of " + targetName);
        }
        args.add("-b");
        args.add(baseBranch);
        args.add(remote);
        args.add(targetName);
        ReleaseSupport.exec(workDir, getLog(), args.toArray(new String[0]));

        File target = new File(workDir, targetName);
        ReleaseSupport.exec(target, getLog(), "git", "checkout", "-b", branchName);
    }

    /**
     * Resolve a subproject's effective version: the {@code workspace.yaml}
     * value first, falling back to the cloned POM's {@code <version>}.
     *
     * @param sub the subproject definition
     * @param dir the subproject's directory in the sibling
     * @return the effective version, or {@code null} if none can be resolved
     */
    private String effectiveVersion(Subproject sub, File dir) {
        String version = sub.version();
        if (version != null && !version.isEmpty()) {
            return version;
        }
        File pom = new File(dir, "pom.xml");
        if (pom.exists()) {
            try {
                return ReleaseSupport.readPomVersion(pom);
            } catch (MojoException e) {
                getLog().debug("Could not read POM version for " + sub.name()
                        + ": " + e.getMessage());
            }
        }
        return null;
    }

    /**
     * Read the {@code origin} remote URL of a git repository.
     *
     * @param dir the repository directory
     * @return the origin URL, or {@code null} if {@code dir} is not a git
     *         repository or has no {@code origin} remote
     */
    private String gitOriginUrl(File dir) {
        if (!new File(dir, ".git").exists()) {
            return null;
        }
        try {
            String url = ReleaseSupport.execCapture(dir,
                    "git", "remote", "get-url", "origin");
            return url.isBlank() ? null : url.trim();
        } catch (MojoException e) {
            return null;
        }
    }

    /**
     * Read a sibling directory's POM {@code <version>}: the #763 fix when
     * applied to the aggregator's own clone, whose staleness a
     * subproject-only table hid.
     *
     * @param dir the sibling directory (a clone) to read
     * @return the POM version, or {@link WorkingSetReportTable#NONE} if there
     *         is no readable {@code pom.xml}
     */
    private String siblingVersion(File dir) {
        File pom = new File(dir, "pom.xml");
        if (!pom.exists()) {
            return WorkingSetReportTable.NONE;
        }
        try {
            String version = ReleaseSupport.readPomVersion(pom);
            return (version == null || version.isEmpty())
                    ? WorkingSetReportTable.NONE : version;
        } catch (MojoException e) {
            getLog().debug("Could not read sibling POM version for " + dir
                    + ": " + e.getMessage());
            return WorkingSetReportTable.NONE;
        }
    }

    /**
     * Render the sibling feature-start result as the goal's Markdown report.
     *
     * <p>The working-set table carries one row per member, the aggregator
     * (workspace root) included, so the sibling root's own clone, branch and
     * qualified version are visible (#763/#766). The final column is
     * {@code Effect} — this is a mutating goal and the clones are made on the
     * spot, so each effect is stated as <em>applied</em>.
     *
     * @param siblingName the sibling directory name
     * @param siblingRoot the sibling directory
     * @param branchName  the feature branch created in each clone
     * @param base        the base branch each clone was cut from
     * @param rows        one working-set row per member, aggregator included
     * @param verified    whether the sibling reactor was built and verified
     *                    during this run (the {@code -Dverify} flag / the
     *                    {@code -verify} goal)
     * @return the Markdown report body
     */
    private String buildReport(String siblingName, File siblingRoot,
                               String branchName, String base,
                               List<WorkingSetReportTable.Row> rows,
                               boolean verified) {
        GoalReportBuilder report = new GoalReportBuilder();
        report.paragraph("**Sibling:** `" + siblingName + "`")
                .paragraph("**Branch:** `" + branchName + "`")
                .paragraph("**Base:** `" + base + "`")
                .paragraph("**Location:** `" + siblingRoot.getAbsolutePath() + "`");

        WorkingSetReportTable.render(report, "Working set", rows);

        report.paragraph("Each component is a self-contained clone on `"
                + branchName + "`, cut from `" + base
                + "` with `--reference --dissociate` against the primary."
                + " The primary stays on its current branch.");

        if (verified) {
            report.paragraph("**Verified** — `" + verifyGoals
                    + "` from the sibling root succeeded, so the reactor builds"
                    + " and every `-" + branchName.substring("feature/".length())
                    + "-SNAPSHOT` artifact is now installed in the local"
                    + " repository (a later `-pl … -am` or IDE build will resolve"
                    + " them).");
        }

        report.paragraph("**No push** — clones and branches stay local."
                + " Next steps:");
        // The bootstrap build is the first step (#777): a sibling's
        // -<feature>-SNAPSHOT artifacts exist nowhere else, so a partial
        // (-pl … -am) or IDE build cannot resolve them until the whole reactor
        // has been installed once. When already verified this run, it is noted
        // as done but kept for re-running after later edits.
        String buildStep = verified
                ? "./mvnw clean install -DskipTests   "
                        + "# already run by verification — re-run after edits"
                : "./mvnw clean install -DskipTests   "
                        + "# build the whole reactor first — a sibling's\n"
                        + "                                   "
                        + "# -" + branchName.substring("feature/".length())
                        + "-SNAPSHOT artifacts exist nowhere else";
        report.paragraph("```bash\ncd " + siblingRoot.getAbsolutePath()
                + "\n" + buildStep
                + "\n# then work, then:\nmvn ws:commit-publish -Dmessage=\"…\"\n"
                + "mvn ws:push                       # publish the branch\n"
                + "mvn ws:feature-finish-merge-publish -Dfeature="
                + branchName.substring("feature/".length())
                + "   # merge back\nrm -rf " + siblingRoot.getAbsolutePath()
                + "   # discard the sibling\n```");
        return report.build();
    }
}