WsReconcileBranchesDraftMojo.java

package network.ike.plugin.ws;

import network.ike.plugin.ReleaseSupport;
import network.ike.workspace.ManifestWriter;
import network.ike.workspace.Subproject;
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.LinkedHashMap;
import java.util.Map;

/**
 * Reconcile {@code workspace.yaml} branch fields against on-disk git
 * state (preview).
 *
 * <p>This is the {@code ws:reconcile-branches-draft} goal — recovery /
 * rare-use, separated from the {@code ws:align-{draft,publish}}
 * POM-axis daily driver per ike-issues#200's two-axis split (Option B).
 * Each goal name now describes its audience: {@code ws:align-draft} /
 * {@code ws:align-publish} (backed by
 * {@link network.ike.plugin.ws.reconcile.AlignmentReconciler}) is the
 * safe daily POM convergence; {@code ws:reconcile-branches-draft} /
 * {@code -publish} is the branch-state recovery operation that runs
 * when something has already gone wrong.
 *
 * <p>Three directions are supported via {@code -Dfrom=...}:
 *
 * <ul>
 *   <li>{@code repos} (default) — read each subproject's actual branch
 *       and update {@code workspace.yaml} to match.</li>
 *   <li>{@code manifest} — {@code git checkout} each subproject to the
 *       branch declared in {@code workspace.yaml}.</li>
 *   <li>{@code workspace-head} — the workspace repo's HEAD is
 *       authoritative; reconcile both YAML fields <em>and</em> on-disk
 *       branches to that single value (ike-issues#287).</li>
 * </ul>
 *
 * <pre>{@code
 * mvn ws:reconcile-branches-draft                       # report only (from=repos)
 * mvn ws:reconcile-branches-publish                      # apply (from=repos)
 * mvn ws:reconcile-branches-publish -Dfrom=manifest      # checkout repos to declared branches
 * mvn ws:reconcile-branches-publish -Dfrom=workspace-head -Dforce=true
 * }</pre>
 */
@Mojo(name = "reconcile-branches-draft", projectRequired = false, aggregator = true)
public class WsReconcileBranchesDraftMojo extends AbstractWorkspaceMojo {

    /**
     * When true, apply changes; when false, draft (preview only).
     * Package-private so {@link WsReconcileBranchesPublishMojo} can
     * flip it in its {@code execute()} override.
     */
    @Parameter(property = "publish", defaultValue = "false")
    boolean publish;

    /**
     * Legacy scope parameter — retained for compatibility with the
     * pre-ike-issues#200 era. Branch reconciliation is the only valid
     * scope for this goal; any other value is rejected with an error
     * pointing the user at {@code ws:align-draft}.
     *
     * <p>The default is {@code branches} so users do not need to pass
     * it explicitly.
     */
    @Parameter(property = "scope", defaultValue = "branches")
    String scope;

    /**
     * Branch-sync direction.
     *
     * <ul>
     *   <li>{@code repos} (default) — read actual branches and update
     *       {@code workspace.yaml}.</li>
     *   <li>{@code manifest} — run {@code git checkout} per subproject
     *       so repos match the yaml.</li>
     *   <li>{@code workspace-head} — the workspace repo's current git
     *       branch is authoritative; reconcile both the {@code branch:}
     *       fields in {@code workspace.yaml} <em>and</em> each
     *       subproject's on-disk branch to that single value
     *       (ike-issues#287).</li>
     * </ul>
     */
    @Parameter(property = "from", defaultValue = "repos")
    String from;

    /**
     * When true, allow branch checkout against subprojects with
     * uncommitted changes. Consulted with
     * {@code from=manifest|workspace-head}. Default {@code false}.
     */
    @Parameter(property = "force", defaultValue = "false")
    boolean force;

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

    @Override
    protected WorkspaceReportSpec runGoal() throws MojoException {
        // Defensive scope validation: this goal is branch-only. Any
        // other -Dscope= value is almost certainly a user invoking the
        // wrong goal (e.g., they meant ws:align-draft).
        if (!"branches".equals(scope) && !"all".equals(scope)) {
            throw new MojoException(
                    "ws:reconcile-branches only supports -Dscope=branches "
                            + "(default). For POM alignment, use ws:align-"
                            + (publish ? "publish" : "draft") + ".");
        }
        if (!"repos".equals(from)
                && !"manifest".equals(from)
                && !"workspace-head".equals(from)) {
            throw new MojoException(
                    "Invalid from '" + from
                            + "' — expected repos|manifest|workspace-head");
        }

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

        getLog().info("");
        getLog().info("IKE Workspace Reconcile Branches — "
                + describeFromMode());
        getLog().info("══════════════════════════════════════════════════════════════");
        if (draft) {
            getLog().info("  (draft — no files will be modified)");
        }
        getLog().info("");

        int totalChanges = alignBranches(graph, root, manifestPath, draft);

        getLog().info("");
        String summary;
        if (totalChanges == 0) {
            getLog().info("  Nothing to reconcile  ✓");
            summary = "Nothing to reconcile — branches already coherent.\n";
        } else if (draft) {
            getLog().info("  " + totalChanges + " change(s) would be applied");
            getLog().info("  Use ws:reconcile-branches-publish to apply.");
            summary = totalChanges + " branch change(s) would be applied "
                    + "(" + describeFromMode() + ").\n";
        } else {
            getLog().info("  Applied " + totalChanges + " change(s)");
            summary = "Applied " + totalChanges + " branch change(s) "
                    + "(" + describeFromMode() + ").\n";
        }
        getLog().info("");
        return new WorkspaceReportSpec(
                publish ? WsGoal.RECONCILE_BRANCHES_PUBLISH
                        : WsGoal.RECONCILE_BRANCHES_DRAFT,
                summary);
    }

    /**
     * Dispatch on {@code -Dfrom=...} to the right branch-reconcile
     * direction.
     *
     * @return the number of branch changes applied, or that would be
     *         applied in draft mode
     */
    private int alignBranches(WorkspaceGraph graph, File root,
                              Path manifestPath, boolean draft)
            throws MojoException {
        return switch (from) {
            case "manifest" ->
                    alignBranchesFromManifest(graph, root, draft);
            case "workspace-head" ->
                    alignBranchesFromWorkspaceHead(graph, root, manifestPath,
                            draft);
            default ->
                    alignBranchesFromRepos(graph, root, manifestPath, draft);
        };
    }

    /** Human-readable label for the active {@code from=...} mode. */
    private String describeFromMode() {
        return switch (from) {
            case "manifest" -> "manifest → repos (git checkout)";
            case "workspace-head" ->
                    "workspace HEAD → manifest + repos (authoritative branch)";
            default -> "repos → manifest (update yaml)";
        };
    }

    /**
     * Read actual branches from each cloned subproject and update
     * {@code workspace.yaml} so the declared branch fields match
     * reality.
     */
    private int alignBranchesFromRepos(WorkspaceGraph graph, File root,
                                       Path manifestPath, boolean draft)
            throws MojoException {
        Map<String, String> updates = new LinkedHashMap<>();

        for (Map.Entry<String, Subproject> entry
                : graph.manifest().subprojects().entrySet()) {
            String name = entry.getKey();
            Subproject subproject = entry.getValue();
            File dir = new File(root, name);
            if (!new File(dir, ".git").exists()) continue;

            String actual = gitBranch(dir);
            String declared = subproject.branch();
            if (actual.equals(declared)) continue;

            updates.put(name, actual);
            getLog().info("  branch: " + name + ": " + declared
                    + " → " + actual + (draft ? " (draft)" : ""));
        }

        if (updates.isEmpty()) {
            getLog().info("  Branches: yaml already matches repos  ✓");
            return 0;
        }

        if (!draft) {
            try {
                ManifestWriter.updateBranches(manifestPath, updates);
                getLog().info("  Branches: updated workspace.yaml ("
                        + updates.size() + " change(s))");
                File wsRoot = manifestPath.getParent().toFile();
                if (new File(wsRoot, ".git").exists()) {
                    ReleaseSupport.exec(wsRoot, getLog(),
                            "git", "add", "workspace.yaml");
                    ReleaseSupport.exec(wsRoot, getLog(),
                            "git", "commit", "-m",
                            "workspace: align branch fields from repos");
                }
            } catch (IOException e) {
                throw new MojoException(
                        "Failed to update workspace.yaml: " + e.getMessage(), e);
            }
        }

        return updates.size();
    }

    /**
     * Reconcile every subproject's {@code branch:} field <em>and</em>
     * on-disk git branch to the workspace repo's current HEAD.
     *
     * <p>This is the symmetric repair to {@code ws:add}'s
     * branch-coherence rule (ike-issues#286): the workspace repo's
     * branch is authoritative, and this mode brings everything else
     * (YAML state, on-disk state) into agreement with it.
     *
     * <p>Behavior per subproject:
     * <ol>
     *   <li>If the YAML {@code branch:} != workspace HEAD → queue YAML
     *       update.</li>
     *   <li>If the on-disk branch != workspace HEAD → queue checkout.
     *       Subprojects with uncommitted changes are skipped unless
     *       {@code -Dforce=true} (same semantics as
     *       {@code from=manifest}).</li>
     * </ol>
     *
     * <p>If a subproject's local repo doesn't yet have the workspace
     * branch, {@code git checkout} will fall through to creating it
     * from {@code origin/<branch>} (the standard tracking-branch path).
     * If the branch isn't on origin either, the checkout fails — that
     * case is the {@code ws:add}'s territory; this mode does not push
     * new branches to subproject origins.
     *
     * @return the number of changes applied (or that would be in draft mode)
     * @throws MojoException if the workspace dir is not a git repo, or
     *                       any individual checkout fails
     */
    private int alignBranchesFromWorkspaceHead(WorkspaceGraph graph, File root,
                                                Path manifestPath,
                                                boolean draft) {
        File wsRoot = manifestPath.getParent().toFile();
        if (!new File(wsRoot, ".git").exists()) {
            throw new MojoException(
                    "from=workspace-head requires the workspace directory to be "
                            + "a git repository. " + wsRoot.getAbsolutePath()
                            + " has no .git directory.");
        }
        String wsBranch = gitBranch(wsRoot);
        if (wsBranch == null || wsBranch.isBlank() || "unknown".equals(wsBranch)) {
            throw new MojoException(
                    "Could not read the workspace repo's current branch. "
                            + "Ensure HEAD points at a named branch (not a "
                            + "detached HEAD).");
        }

        getLog().info("  Workspace HEAD: " + wsBranch);

        Map<String, String> yamlUpdates = new LinkedHashMap<>();
        int checkoutsPlanned = 0;
        int checkoutsApplied = 0;
        int skippedDirty = 0;

        for (Map.Entry<String, Subproject> entry
                : graph.manifest().subprojects().entrySet()) {
            String name = entry.getKey();
            Subproject subproject = entry.getValue();
            File dir = new File(root, name);

            // YAML reconciliation runs whether or not the repo is cloned.
            String declared = subproject.branch();
            if (declared == null || !declared.equals(wsBranch)) {
                yamlUpdates.put(name, wsBranch);
                getLog().info("  branch: " + name + " (yaml): "
                        + (declared == null ? "(unset)" : declared)
                        + " → " + wsBranch + (draft ? " (draft)" : ""));
            }

            // Checkout reconciliation only applies to cloned subprojects.
            if (!new File(dir, ".git").exists()) continue;
            String actual = gitBranch(dir);
            if (wsBranch.equals(actual)) continue;

            String status = gitStatus(dir);
            if (!status.isEmpty() && !force) {
                getLog().warn("  ⚠ " + name + ": uncommitted changes — skipping"
                        + " checkout (pass -Dforce=true to override)");
                skippedDirty++;
                continue;
            }

            checkoutsPlanned++;
            getLog().info("  branch: " + name + " (repo): " + actual
                    + " → " + wsBranch + (draft ? " (draft)" : ""));

            if (!draft) {
                if (localBranchExists(dir, wsBranch)
                        || originBranchExists(dir, wsBranch)) {
                    ReleaseSupport.exec(dir, getLog(),
                            "git", "checkout", wsBranch);
                } else {
                    getLog().info("    " + name + " has no '" + wsBranch
                            + "' locally or on origin — creating from "
                            + actual + ".");
                    ReleaseSupport.exec(dir, getLog(),
                            "git", "checkout", "-b", wsBranch);
                }
                checkoutsApplied++;
            }
        }

        if (yamlUpdates.isEmpty() && checkoutsPlanned == 0 && skippedDirty == 0) {
            getLog().info("  Branches: workspace, manifest, and repos all agree  ✓");
            return 0;
        }

        if (!draft && !yamlUpdates.isEmpty()) {
            try {
                ManifestWriter.updateBranches(manifestPath, yamlUpdates);
                getLog().info("  Branches: updated workspace.yaml ("
                        + yamlUpdates.size() + " field(s))");
                ReleaseSupport.exec(wsRoot, getLog(),
                        "git", "add", "workspace.yaml");
                ReleaseSupport.exec(wsRoot, getLog(),
                        "git", "commit", "-m",
                        "workspace: align branch fields to " + wsBranch);
            } catch (IOException e) {
                throw new MojoException(
                        "Failed to update workspace.yaml: " + e.getMessage(), e);
            }
        }

        if (draft) {
            getLog().info("  Branches: " + (yamlUpdates.size() + checkoutsPlanned)
                    + " change(s) would be applied"
                    + (skippedDirty > 0
                            ? " (" + skippedDirty + " skipped — uncommitted)"
                            : ""));
            return yamlUpdates.size() + checkoutsPlanned;
        }
        getLog().info("  Branches: " + yamlUpdates.size()
                + " yaml update(s), " + checkoutsApplied + " checkout(s), "
                + skippedDirty + " skipped (uncommitted)");
        return yamlUpdates.size() + checkoutsApplied;
    }

    /**
     * Whether {@code refs/heads/<branch>} exists in the local repo.
     */
    private static boolean localBranchExists(File dir, String branch) {
        try {
            String out = ReleaseSupport.execCapture(dir,
                    "git", "branch", "--list", branch);
            return out != null && !out.trim().isEmpty();
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * Whether {@code refs/heads/<branch>} exists on the origin remote.
     * Returns false on any failure (offline, no remote, etc.) — the
     * caller treats that the same as \"doesn't exist on origin\" and
     * creates the branch locally from the current HEAD.
     */
    private static boolean originBranchExists(File dir, String branch) {
        try {
            String out = ReleaseSupport.execCapture(dir,
                    "git", "ls-remote", "--heads", "origin", branch);
            return out != null && !out.trim().isEmpty();
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * Read declared branches from {@code workspace.yaml} and run
     * {@code git checkout} in each subproject whose current branch
     * differs. Subprojects with uncommitted changes are skipped unless
     * {@code -Dforce=true}.
     */
    private int alignBranchesFromManifest(WorkspaceGraph graph, File root,
                                          boolean draft) {
        int switched = 0;
        int skippedDirty = 0;
        int planned = 0;

        for (Map.Entry<String, Subproject> entry
                : graph.manifest().subprojects().entrySet()) {
            String name = entry.getKey();
            Subproject subproject = entry.getValue();
            File dir = new File(root, name);
            if (!new File(dir, ".git").exists()) continue;

            String declared = subproject.branch();
            if (declared == null) continue;
            String actual = gitBranch(dir);
            if (actual.equals(declared)) continue;

            String status = gitStatus(dir);
            if (!status.isEmpty() && !force) {
                getLog().warn("  ⚠ " + name + ": uncommitted changes — skipping"
                        + " (pass -Dforce=true to override)");
                skippedDirty++;
                continue;
            }

            planned++;
            getLog().info("  branch: " + name + ": " + actual
                    + " → " + declared + (draft ? " (draft)" : ""));

            if (!draft) {
                ReleaseSupport.exec(dir, getLog(),
                        "git", "checkout", declared);
                switched++;
            }
        }

        if (switched == 0 && skippedDirty == 0 && planned == 0) {
            getLog().info("  Branches: repos already match yaml  ✓");
        } else if (draft) {
            getLog().info("  Branches: " + planned
                    + " subproject(s) would be switched");
        } else {
            getLog().info("  Branches: switched " + switched
                    + ", skipped " + skippedDirty + " (uncommitted)");
        }

        return draft ? planned : switched;
    }
}