FeatureTrackDraftMojo.java

package network.ike.plugin.ws;

import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.ws.preflight.Preflight;
import network.ike.plugin.ws.preflight.PreflightCondition;
import network.ike.plugin.ws.preflight.PreflightContext;
import network.ike.plugin.ws.preflight.PreflightResult;
import network.ike.plugin.ws.vcs.VcsOperations;
import network.ike.plugin.ws.vcs.VcsState;
import network.ike.workspace.ManifestWriter;
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.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Adopt an <em>existing</em> feature branch in selected subprojects.
 *
 * <p>Where {@link FeatureStartDraftMojo} <em>creates</em> {@code feature/<name>}
 * from the current tip, this goal checks out a branch that already exists —
 * typically pushed from another clone or by another developer — in exactly the
 * subprojects named by {@code -Daffected}, and aligns
 * {@code workspace.yaml}'s {@code branch:} fields with the result.
 *
 * <p>It exists because neither adjacent goal covers this case:
 * <ul>
 *   <li>{@code ws:switch} is workspace-wide (no {@code -Daffected}) and skips
 *       any subproject whose target branch is not already present
 *       <em>locally</em> — so a branch that exists only on the remote is
 *       silently skipped everywhere.</li>
 *   <li>{@code ws:feature-start} creates a new branch, failing when the name
 *       is already taken.</li>
 * </ul>
 *
 * <p><strong>Per subproject, in {@code -Daffected} order:</strong>
 * <ol>
 *   <li>Already on {@code feature/<name>} → confirm, refreshing a stale
 *       {@code .ike/vcs-state} that would otherwise make the bridge switch
 *       the member away again (#903/#904)</li>
 *   <li>Local branch exists → check it out</li>
 *   <li>No local branch, but {@code <remote>/feature/<name>} exists → fetch
 *       and create a tracking branch from the remote tip</li>
 *   <li>Branch nowhere → skip with a warning, naming the subproject</li>
 * </ol>
 *
 * <p>Because the branch-coherence triad is {@code workspace.yaml} /
 * checkout / {@code .ike/vcs-state} (#904), all three are written: the
 * checkout moves, {@code VcsOperations.recordSwitch} stamps the bridge
 * state, and the manifest's {@code branch:} fields are updated and
 * committed in isolation. Leaving the manifest behind is precisely what
 * makes a later {@code ws:feature-finish-*} skip the subproject while
 * reporting success.
 *
 * <p>Unlike {@code ws:switch} this goal does <em>not</em> auto-stash: it
 * requires an unmodified tree so adopting someone else's branch can never
 * bury local work in a stash ref. Commit first, or pass
 * {@code -Dallow-uncommitted}.
 *
 * <pre>{@code
 * mvn ws:feature-track-draft   -Dfeature=grpc_plugin -Daffected=komet,tinkar-service
 * mvn ws:feature-track-publish -Dfeature=grpc_plugin -Daffected=komet,tinkar-service
 * }</pre>
 *
 * @see FeatureStartDraftMojo for creating a new feature branch
 * @see WsSwitchDraftMojo for switching the whole workspace
 * @see FeaturePrDraftMojo for opening review PRs on the tracked branch
 */
@Mojo(name = "feature-track-draft", projectRequired = false, aggregator = true)
public class FeatureTrackDraftMojo extends AbstractWorkspaceMojo {

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

    /**
     * Feature name without the {@code feature/} prefix. Prompted when
     * omitted.
     */
    @Parameter(property = "feature")
    String feature;

    /**
     * Comma-separated subproject names to move onto the branch. Each must
     * match a subproject in {@code workspace.yaml}; an unknown name fails
     * the goal early rather than being silently dropped.
     *
     * <p>Omitted, every subproject that has the branch (locally or on the
     * remote) is adopted — which is rarely what you want when a feature
     * spans only part of the workspace, so prefer naming them.
     */
    @Parameter(property = "affected")
    String affected;

    /** Remote to look for the branch on, and to track. */
    @Parameter(property = "remote", defaultValue = "origin")
    String remote;

    /** Execute the checkout. Default is draft (preview only). */
    @Parameter(property = "publish", defaultValue = "false")
    boolean publish;

    @Override
    protected WorkspaceReportSpec runGoal() throws MojoException {
        if (!isWorkspaceMode()) {
            throw new MojoException(
                    "ws:feature-track requires a workspace (workspace.yaml). "
                    + "For a single repository use "
                    + "'git checkout -b <branch> --track " + remote
                    + "/<branch>'.");
        }

        boolean draft = !publish;
        WorkspaceGraph graph = loadGraph();
        File root = workspaceRoot();

        feature = requireParam(feature, "feature", "Feature name");
        String branch = "feature/" + validateFeatureName(feature).value();

        Set<String> known = graph.manifest().subprojects().keySet();
        Set<String> scope = FeatureScope.resolveAffected(affected, known);
        List<String> sorted = graph.topologicalSort(new LinkedHashSet<>(scope));

        getLog().info("");
        getLog().info(header("Feature Track"));
        getLog().info("══════════════════════════════════════════════════════════════");
        getLog().info("  Feature: " + feature);
        getLog().info("  Branch:  " + branch);
        getLog().info("  Scope:   " + (affected == null || affected.isBlank()
                ? "whole workspace (no -Daffected)"
                : String.join(", ", sorted)));
        if (draft) getLog().info("  Mode:    DRAFT");
        getLog().info("");

        // Adopting a branch must never bury local work. ws:switch can
        // auto-stash because the user chose that branch; here the branch
        // content comes from elsewhere, so require a clean tree instead.
        PreflightResult pre = Preflight.of(
                List.of(PreflightCondition.WORKING_TREE_CLEAN),
                PreflightContext.of(root, graph, sorted));
        if (draft) {
            pre.warnIfFailed(getLog(), WsGoal.FEATURE_TRACK_PUBLISH);
        } else {
            pre.requirePassed(WsGoal.FEATURE_TRACK_PUBLISH);
        }

        Map<String, String> effects = new LinkedHashMap<>();
        Map<String, String> manifestUpdates = new LinkedHashMap<>();
        List<String> adopted = new ArrayList<>();
        List<String> confirmed = new ArrayList<>();
        List<String> absent = new ArrayList<>();
        int skipped = 0;

        for (String name : sorted) {
            File dir = new File(root, name);
            if (!new File(dir, ".git").exists()) {
                effects.put(name, "skipped (not cloned)");
                skipped++;
                continue;
            }

            String current = gitBranch(dir);

            // ── Already there: confirm, and heal a stale bridge state ──
            if (branch.equals(current)) {
                boolean stale = VcsState.readFrom(dir.toPath())
                        .map(s -> !branch.equals(s.branch()))
                        .orElse(false);
                if (stale && !draft) {
                    VcsOperations.refreshStaleBranchState(dir, getLog());
                }
                getLog().info("  " + Ansi.green("✓ ") + name
                        + " — already on " + branch);
                effects.put(name, "already on `" + branch + "`"
                        + (stale
                                ? (draft ? " — would refresh stale vcs-state"
                                         : " — vcs-state refreshed")
                                : ""));
                confirmed.add(name);
                // Still record the manifest field: "already on the branch"
                // with the manifest saying otherwise is exactly the
                // incoherence this goal exists to close.
                manifestUpdates.put(name, branch);
                continue;
            }

            boolean localExists = VcsOperations.localBranchExists(dir, branch);

            // ── Not local: is it on the remote? ──
            if (!localExists) {
                // Fetch in draft mode too. A fetch only updates
                // refs/remotes — it touches no local branch, working tree,
                // or HEAD — and without it a branch pushed since this
                // clone's last fetch reads as absent, so the preview would
                // report "not found" for a branch the publish run then
                // adopts successfully. A draft that disagrees with the run
                // is worse than no draft.
                VcsOperations.fetch(dir, getLog());
                boolean remoteExists =
                        VcsOperations.remoteTrackingExists(dir, remote, branch);
                if (!remoteExists) {
                    getLog().info("  " + Ansi.yellow("⚠ ") + name
                            + " — " + branch + " exists neither locally nor on "
                            + remote + ", skipping");
                    effects.put(name, "skipped (`" + branch + "` not found on "
                            + remote + ")");
                    absent.add(name);
                    skipped++;
                    continue;
                }
            }

            String how = localExists
                    ? "checkout local"
                    : "create tracking branch from " + remote + "/" + branch;

            if (draft) {
                getLog().info("  [draft] " + name + " — would adopt "
                        + current + " → " + branch + " (" + how + ")");
                effects.put(name, "would adopt `" + current + "` → `"
                        + branch + "` (" + how + ")");
                adopted.add(name);
                manifestUpdates.put(name, branch);
                continue;
            }

            getLog().info("  " + Ansi.cyan("→ ") + name + ": " + current
                    + " → " + branch + " (" + how + ")");
            if (localExists) {
                VcsOperations.checkout(dir, getLog(), branch);
            } else {
                VcsOperations.checkoutTracking(dir, getLog(), remote, branch);
            }
            // Without this the bridge sync in the next ws:* goal restores
            // the member's previous state-file branch and undoes the
            // adoption (#903/#904).
            VcsOperations.recordSwitch(dir, getLog());
            adopted.add(name);
            manifestUpdates.put(name, branch);
            effects.put(name, "adopted `" + current + "` → `" + branch + "`");
        }

        // ── Manifest: close the coherence triad ──
        int manifestChanges = applyManifestBranches(graph, manifestUpdates,
                branch, draft);

        getLog().info("");
        getLog().info("  " + (draft
                ? adopted.size() + " to adopt, " + confirmed.size()
                        + " already on branch, " + skipped + " to skip"
                : "Adopted: " + adopted.size() + " | Already on branch: "
                        + confirmed.size() + " | Skipped: " + skipped));
        getLog().info("");

        // ── Report ──
        GoalReportBuilder report = new GoalReportBuilder();
        report.paragraph("**Feature:** `" + feature + "` **Branch:** `" + branch
                + "` **Remote:** `" + remote + "`"
                + (draft ? "  _(draft — no changes made)_" : ""));

        report.section("Working set");
        for (Map.Entry<String, String> e : effects.entrySet()) {
            report.bullet("`" + e.getKey() + "` — " + e.getValue());
        }

        if (!absent.isEmpty()) {
            report.section("Branch not found");
            report.paragraph("`" + branch + "` exists neither locally nor on `"
                    + remote + "` for these subprojects, so they were left "
                    + "untouched. Push the branch, or drop them from "
                    + "`-Daffected`:");
            for (String n : absent) report.bullet("`" + n + "`");
        }

        report.section("Manifest");
        if (manifestChanges == 0) {
            report.paragraph("`workspace.yaml` already records `" + branch
                    + "` for every adopted subproject — nothing to write.");
        } else if (draft) {
            report.paragraph("Would set `branch: " + branch + "` for **"
                    + manifestChanges + "** subproject(s) in `workspace.yaml` "
                    + "and commit that file in isolation.");
        } else {
            report.paragraph("Set `branch: " + branch + "` for **"
                    + manifestChanges + "** subproject(s) in `workspace.yaml` "
                    + "and committed it.");
        }

        if (!adopted.isEmpty()) {
            report.section("Next");
            report.paragraph("Commit work with `ws:commit-publish`, push the "
                    + "branch with `ws:push -Dfeature=" + feature + "`, then "
                    + "open review PRs with `ws:feature-pr-publish -Dfeature="
                    + feature + "`.");
        }

        return new WorkspaceReportSpec(
                publish ? WsGoal.FEATURE_TRACK_PUBLISH
                        : WsGoal.FEATURE_TRACK_DRAFT,
                report.build());
    }

    /**
     * Write {@code branch:} fields for the adopted subprojects and commit
     * {@code workspace.yaml} in isolation, returning the number of fields
     * that actually changed.
     *
     * <p>Only genuine changes are written: a subproject the manifest already
     * records on the branch is dropped, so a re-run is a no-op rather than an
     * empty commit.
     */
    private int applyManifestBranches(WorkspaceGraph graph,
                                      Map<String, String> updates,
                                      String branch,
                                      boolean draft) throws MojoException {
        Map<String, String> changed = new LinkedHashMap<>();
        for (Map.Entry<String, String> e : updates.entrySet()) {
            String declared = graph.manifest().subprojects()
                    .get(e.getKey()).branch();
            if (!e.getValue().equals(declared)) {
                changed.put(e.getKey(), e.getValue());
            }
        }
        if (changed.isEmpty() || draft) {
            return changed.size();
        }

        Path manifestPath = resolveManifest();
        try {
            ManifestWriter.updateBranches(manifestPath, changed);
            getLog().info("  Manifest: set branch: " + branch + " for "
                    + changed.size() + " subproject(s)");
            File wsRoot = manifestPath.getParent().toFile();
            if (new File(wsRoot, ".git").exists()) {
                // Commit only the path this goal authored, never the whole
                // index (#780).
                VcsOperations.commitPaths(wsRoot, getLog(),
                        "workspace: track " + branch + " in "
                        + String.join(", ", changed.keySet())
                        + "\n\nRefs: IKE-Network/ike-issues#904",
                        "workspace.yaml");
            }
        } catch (IOException e) {
            throw new MojoException(
                    "Failed to update workspace.yaml: " + e.getMessage(), e);
        }
        return changed.size();
    }
}