CheckBranchMojo.java

package network.ike.plugin.ws;

import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.support.GoalReportBuilder;
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.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

/**
 * Defensive git hook — warns when a branch is created or switched
 * outside the workspace tooling.
 *
 * <p>Intended to be called from a {@code post-checkout} git hook:
 * <pre>{@code
 * #!/bin/sh
 * mvn -q ike:check-branch -- "$@"
 * }</pre>
 *
 * <p>In workspace mode, compares the current branch to the expected
 * branch in workspace.yaml and warns on mismatch. Provides
 * copy-pasteable undo commands.
 *
 * <p>In bare mode (no workspace.yaml), silently exits — nothing to check.
 *
 * <p>Never blocks — warnings only. Always exits 0.
 */
@Mojo(name = "check-branch", projectRequired = false, aggregator = true)
public class CheckBranchMojo extends AbstractWorkspaceMojo {

    /**
     * Scope of the check.
     *
     * <ul>
     *   <li>{@code subproject} (default) — check the single subproject the
     *       CWD is inside, against its declared branch in
     *       {@code workspace.yaml}. Intended as a {@code post-checkout}
     *       git-hook gate.</li>
     *   <li>{@code workspace} — survey every subproject's on-disk branch
     *       and YAML {@code branch:} field against the workspace repo's
     *       HEAD (authoritative). Reports drift, suggests
     *       {@code ws:reconcile-branches-publish -Dfrom=workspace-head}.
     *       Read-only; never blocks (ike-issues#287).</li>
     * </ul>
     */
    @Parameter(property = "scope", defaultValue = "subproject")
    String scope;

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

    @Override
    protected WorkspaceReportSpec runGoal() throws MojoException {
        if (!isWorkspaceMode()) {
            return new WorkspaceReportSpec(WsGoal.CHECK_BRANCH,
                    "_Bare mode — not inside a workspace._\n");
        }

        if ("workspace".equals(scope)) {
            return executeWorkspaceScope();
        }
        if (!"subproject".equals(scope)) {
            throw new MojoException(
                    "Invalid scope '" + scope + "' — expected subproject|workspace");
        }

        WorkspaceGraph graph = loadGraph();
        File wsRoot = workspaceRoot();
        File cwd = new File(System.getProperty("user.dir"));

        // Determine which subproject we're in by matching CWD to workspace root + subproject name
        String subprojectName = findSubprojectName(wsRoot, cwd, graph);
        if (subprojectName == null) {
            return new WorkspaceReportSpec(WsGoal.CHECK_BRANCH,
                    "_CWD is not inside a known subproject directory._\n");
        }

        Subproject subproject = graph.manifest().subprojects().get(subprojectName);
        if (subproject == null || subproject.branch() == null) {
            return new WorkspaceReportSpec(WsGoal.CHECK_BRANCH,
                    "**Subproject:** `" + subprojectName + "`\n\n"
                            + "_No expected branch declared in workspace.yaml._\n");
        }

        String expectedBranch = subproject.branch();
        String actualBranch = gitBranch(cwd);

        if (actualBranch.equals(expectedBranch)) {
            return new WorkspaceReportSpec(WsGoal.CHECK_BRANCH,
                    "**Subproject:** `" + subprojectName + "`\n"
                            + "**Branch:** `" + actualBranch
                            + "` (matches expected)  ✓\n");
        }

        // Determine if this was a branch creation (new branch that doesn't match expected)
        boolean isNewBranch = !branchExistsRemotely(cwd, actualBranch);
        String scenario;

        if (isNewBranch && actualBranch.startsWith("feature/")) {
            // Created a feature branch directly — suggest ike:feature-start
            String featureName = actualBranch.substring("feature/".length());
            getLog().warn("");
            getLog().warn("\u26A0 You created branch '" + actualBranch + "' directly in " + subprojectName + ".");
            getLog().warn("");
            getLog().warn("  To fix:");
            getLog().warn("    git checkout " + expectedBranch);
            getLog().warn("    git branch -D " + actualBranch);
            getLog().warn("    mvn ike:feature-start -Dfeature=" + featureName);
            getLog().warn("");
            getLog().warn("  ike:feature-start creates aligned branches across all workspace");
            getLog().warn("  subprojects and sets version-qualified SNAPSHOTs.");
            getLog().warn("");
            scenario = "new feature branch created directly (should use ws:feature-start)";
        } else if (isNewBranch) {
            // Created a non-feature branch directly
            getLog().warn("");
            getLog().warn("\u26A0 You created branch '" + actualBranch + "' directly in " + subprojectName + ".");
            getLog().warn("");
            getLog().warn("  The workspace expects branch '" + expectedBranch + "' for this subproject.");
            getLog().warn("");
            getLog().warn("  To undo:");
            getLog().warn("    git checkout " + expectedBranch);
            getLog().warn("    git branch -D " + actualBranch);
            getLog().warn("");
            scenario = "new non-feature branch created directly";
        } else {
            // Switched to an existing branch that doesn't match workspace.yaml
            getLog().warn("");
            getLog().warn("\u26A0 You switched to branch '" + actualBranch + "' in " + subprojectName + ".");
            getLog().warn("");
            getLog().warn("  The workspace expects branch '" + expectedBranch + "' for this subproject.");
            getLog().warn("  If this is intentional, update the workspace:");
            getLog().warn("    mvn ike:ws-sync");
            getLog().warn("");
            getLog().warn("  If not:");
            getLog().warn("    git checkout " + expectedBranch);
            getLog().warn("");
            scenario = "switched to an existing branch";
        }

        return new WorkspaceReportSpec(WsGoal.CHECK_BRANCH,
                "**Subproject:** `" + subprojectName + "`\n"
                        + "**Expected:** `" + expectedBranch + "`\n"
                        + "**Actual:** `" + actualBranch + "`\n"
                        + "**Scenario:** " + scenario + "\n");
    }

    /**
     * Workspace-wide branch coherence check. Reads the workspace repo's
     * current branch as the authoritative target, then surveys each
     * subproject — both its on-disk git branch and its
     * {@code workspace.yaml} {@code branch:} field — flagging any that
     * disagree.
     *
     * <p>Read-only. Never modifies state. Intended as a quick \"is my
     * workspace coherent?\" check before running
     * {@code ws:checkpoint} or {@code ws:feature-finish-*}, or to
     * confirm whether {@code ws:reconcile-branches-publish -Dfrom=workspace-head}
     * is needed (ike-issues#287).
     */
    private WorkspaceReportSpec executeWorkspaceScope() throws MojoException {
        WorkspaceGraph graph = loadGraph();
        File wsRoot = workspaceRoot();

        if (!new File(wsRoot, ".git").exists()) {
            return new WorkspaceReportSpec(WsGoal.CHECK_BRANCH,
                    "_Workspace directory has no `.git` — cannot determine "
                            + "the authoritative workspace branch._\n");
        }
        String wsBranch = gitBranch(wsRoot);
        if (wsBranch == null || "unknown".equals(wsBranch)) {
            return new WorkspaceReportSpec(WsGoal.CHECK_BRANCH,
                    "_Workspace HEAD is not on a named branch (detached HEAD?)._\n");
        }

        List<DriftRow> rows = new ArrayList<>();
        int driftCount = 0;
        for (var entry : graph.manifest().subprojects().entrySet()) {
            String name = entry.getKey();
            Subproject subproject = entry.getValue();
            File dir = new File(wsRoot, name);

            String declared = subproject.branch();
            String actual = new File(dir, ".git").exists()
                    ? gitBranch(dir) : null;

            boolean yamlDrift = declared == null || !declared.equals(wsBranch);
            boolean repoDrift = actual != null && !actual.equals(wsBranch);
            if (yamlDrift || repoDrift) driftCount++;

            rows.add(new DriftRow(name,
                    declared == null ? "(unset)" : declared,
                    actual == null ? "(not cloned)" : actual,
                    yamlDrift, repoDrift));
        }

        getLog().info("");
        getLog().info("IKE Workspace — Branch Coherence Check");
        getLog().info("══════════════════════════════════════════════════════════════");
        getLog().info("  Workspace HEAD: " + wsBranch);
        getLog().info("");

        if (driftCount == 0) {
            getLog().info("  All subprojects agree with workspace HEAD  ✓");
        } else {
            for (DriftRow r : rows) {
                if (!r.yamlDrift && !r.repoDrift) continue;
                getLog().warn("  ⚠ " + r.name
                        + " — yaml=" + r.declared + " repo=" + r.actual);
            }
            getLog().warn("");
            getLog().warn("  Repair with:");
            getLog().warn("    mvn ws:reconcile-branches-publish "
                    + "-Dfrom=workspace-head");
            getLog().warn("");
        }

        GoalReportBuilder report = new GoalReportBuilder();
        report.paragraph("**Workspace HEAD:** `" + wsBranch + "`");
        report.paragraph(driftCount == 0
                ? "All subprojects agree with workspace HEAD."
                : driftCount + " subproject(s) drift from workspace HEAD.");

        List<String[]> driftTableRows = new ArrayList<>();
        for (DriftRow r : rows) {
            String status = (!r.yamlDrift && !r.repoDrift)
                    ? "✓ aligned"
                    : (r.yamlDrift && r.repoDrift
                            ? "✗ yaml + repo drift"
                            : (r.yamlDrift ? "✗ yaml drift" : "✗ repo drift"));
            driftTableRows.add(new String[]{r.name,
                    "`" + r.declared + "`", "`" + r.actual + "`", status});
        }
        report.table(List.of("Subproject", "YAML branch", "Repo branch", "Status"),
                driftTableRows);
        if (driftCount > 0) {
            report.paragraph("_Repair: `mvn ws:reconcile-branches-publish "
                    + "-Dfrom=workspace-head`_");
        }
        return new WorkspaceReportSpec(WsGoal.CHECK_BRANCH, report.build());
    }

    /** One row of the workspace-scope drift table. */
    private record DriftRow(String name, String declared, String actual,
                            boolean yamlDrift, boolean repoDrift) {}

    /**
     * Find which workspace subproject the CWD belongs to.
     * Handles being in a subdirectory of a multi-module reactor.
     *
     * @param wsRoot workspace root directory
     * @param cwd    current working directory
     * @param graph  workspace dependency graph
     * @return the subproject name, or null if CWD is not inside a known subproject
     */
    static String findSubprojectName(File wsRoot, File cwd, WorkspaceGraph graph) {
        // Walk up from CWD toward wsRoot, checking each directory name
        Path wsPath = wsRoot.toPath().toAbsolutePath().normalize();
        Path cwdPath = cwd.toPath().toAbsolutePath().normalize();

        while (cwdPath != null && cwdPath.startsWith(wsPath) && !cwdPath.equals(wsPath)) {
            // The directory immediately under wsRoot is the subproject name
            Path relative = wsPath.relativize(cwdPath);
            String topDir = relative.getName(0).toString();
            if (graph.manifest().subprojects().containsKey(topDir)) {
                return topDir;
            }
            cwdPath = cwdPath.getParent();
        }
        return null;
    }

    /**
     * Check if a branch exists on the remote (origin).
     * Returns false if the check fails (no remote, offline, etc.).
     */
    private boolean branchExistsRemotely(File dir, String branch) {
        try {
            String result = ReleaseSupport.execCapture(dir,
                    "git", "ls-remote", "--heads", "origin", branch);
            return result != null && !result.isEmpty();
        } catch (Exception e) {
            return false; // Assume new if we can't check
        }
    }
}