FeatureAbandonDraftMojo.java

package network.ike.plugin.ws;

import network.ike.plugin.ReleaseSupport;
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.workspace.Subproject;
import network.ike.workspace.ManifestWriter;
import network.ike.workspace.WorkingSet;
import network.ike.workspace.WorkspaceGraph;
import network.ike.plugin.ws.vcs.VcsOperations;
import network.ike.plugin.ws.vcs.VcsState;
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.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;

/**
 * Abandon a feature branch across all workspace subprojects.
 *
 * <p>The draft variant previews what would be abandoned — which components,
 * how many unmerged commits, what would be lost. The publish variant
 * prompts for confirmation then executes the deletion.
 *
 * <p>Components are processed in reverse topological order (downstream
 * first) to avoid transient dependency issues.
 *
 * <pre>{@code
 * mvn ws:feature-abandon-draft                       # preview
 * mvn ws:feature-abandon-publish                     # execute (with confirmation)
 * mvn ws:feature-abandon-publish -Dforce=true        # skip confirmation
 * mvn ws:feature-abandon-publish -DdeleteRemote=true # also delete remote branches
 * }</pre>
 *
 * @see FeatureStartDraftMojo for creating feature branches
 */
@Mojo(name = "feature-abandon-draft", projectRequired = false, aggregator = true)
public class FeatureAbandonDraftMojo extends AbstractWorkspaceMojo {

    @Parameter(property = "feature")
    String feature;

    @Parameter(property = "targetBranch")
    String targetBranch;

    @Parameter(property = "deleteRemote", defaultValue = "false")
    boolean deleteRemote;

    @Parameter(property = "force", defaultValue = "false")
    boolean force;

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

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

    @Override
    protected WorkspaceReportSpec runGoal() throws MojoException {
        if (!isWorkspaceMode()) {
            return executeBareMode();
        }

        return executeWorkspaceMode();
    }

    private WorkspaceReportSpec executeWorkspaceMode() throws MojoException {
        boolean draft = !publish;
        WorkspaceGraph graph = loadGraph();
        File root = workspaceRoot();
        Path manifestPath = resolveManifest();

        if (targetBranch == null || targetBranch.isBlank()) {
            targetBranch = graph.manifest().defaults().branch();
            if (targetBranch == null) targetBranch = "main";
        }

        Set<String> targets = graph.manifest().subprojects().keySet();

        List<String> sorted = graph.topologicalSort(new LinkedHashSet<>(targets));
        List<String> reversed = new ArrayList<>(sorted);
        Collections.reverse(reversed);

        // Preflight: all working trees must be clean (#132)
        PreflightResult preflight = Preflight.of(
                List.of(PreflightCondition.WORKING_TREE_CLEAN),
                PreflightContext.of(root, graph, sorted));
        if (draft) {
            preflight.warnIfFailed(getLog(), WsGoal.FEATURE_ABANDON_PUBLISH);
        } else {
            preflight.requirePassed(WsGoal.FEATURE_ABANDON_PUBLISH);
        }

        // Auto-detect feature branch if not specified
        if (feature == null || feature.isBlank()) {
            feature = detectFeatureBranch(root, reversed);
        }
        validateFeatureName(feature);
        String branchName = "feature/" + feature;

        // Capture the aggregator's (workspace root) branch BEFORE any mutation
        // so its report Effect is accurate in publish mode too, where the root
        // has already been switched to the target branch by the time the report
        // is built (#763, under epic #764).
        String aggregatorBranchBefore = new File(root, ".git").exists()
                ? gitBranch(root) : null;

        // Collect eligible components and show preview
        getLog().info("");
        getLog().info(header("Feature Abandon"));
        getLog().info("══════════════════════════════════════════════════════════════");
        getLog().info("  Feature:  " + feature);
        getLog().info("  Branch:   " + branchName + " → " + targetBranch);
        if (deleteRemote) getLog().info("  Remote:   will delete origin/" + branchName);
        if (draft) getLog().info("  Mode:     DRAFT");
        getLog().info("");

        List<String> eligible = new ArrayList<>();
        List<String> skipped = new ArrayList<>();
        // Per-member effect, keyed by member name, used to build the shared
        // working-set report table (#767, under epic #764). The aggregator's
        // effect is computed separately, after the subproject loop.
        Map<String, String> effects = new LinkedHashMap<>();
        int totalUnmerged = 0;

        for (String name : reversed) {
            File dir = new File(root, name);
            File gitDir = new File(dir, ".git");

            if (!gitDir.exists()) {
                getLog().info(Ansi.yellow("  · ") + name + " — not cloned");
                skipped.add(name);
                effects.put(name, "skipped (not cloned)");
                continue;
            }

            String currentBranch = gitBranch(dir);
            if (!currentBranch.equals(branchName)) {
                getLog().info(Ansi.yellow("  · ") + name + " — on "
                        + currentBranch + ", not on feature");
                skipped.add(name);
                effects.put(name, "skipped (not on " + branchName + ")");
                continue;
            }

            // Check for uncommitted changes
            String status = gitStatus(dir);
            if (!status.isEmpty()) {
                throw new MojoException(
                        name + " has uncommitted changes. Commit, stash, or discard before abandoning.");
            }

            // Check for unmerged commits
            int unmergedCount = 0;
            try {
                String unmerged = ReleaseSupport.execCapture(dir,
                        "git", "log", "--oneline",
                        targetBranch + ".." + branchName);
                if (!unmerged.isBlank()) {
                    unmergedCount = (int) unmerged.lines().count();
                }
            } catch (MojoException e) {
                // Target branch may not exist locally
            }

            totalUnmerged += unmergedCount;

            if (unmergedCount > 0) {
                String label = draft ? "would abandon" : "abandon";
                getLog().info(Ansi.yellow("  ⚠ ") + name + " — "
                        + unmergedCount + " unmerged commit(s) — " + label);
            } else {
                String label = draft ? "would abandon (clean)" : "abandon";
                getLog().info(Ansi.cyan("  → ") + name + " — " + label);
            }
            eligible.add(name);
            effects.put(name, abandonEffect(draft, branchName, targetBranch,
                    unmergedCount));
        }

        if (eligible.isEmpty()) {
            getLog().info("  No components on " + branchName + " — nothing to abandon.");
            getLog().info("");
            // Still render the working-set table so the report shows every
            // member (the aggregator included) and why each was skipped (#763).
            return writeAbandonReport(branchName, targetBranch, aggregatorBranchBefore,
                    effects,
                    eligible, skipped, draft);
        }

        getLog().info("");
        getLog().info("  " + eligible.size() + " subproject(s) on " + branchName);
        if (totalUnmerged > 0) {
            getLog().warn("  " + totalUnmerged + " total unmerged commit(s) will be lost");
        }

        if (draft) {
            getLog().info("");
            getLog().info("  Next: mvn "
                    + WsGoal.FEATURE_ABANDON_PUBLISH.qualified()
                    + (force ? "" : " (will prompt for confirmation)"));
            getLog().info("");
            return writeAbandonReport(branchName, targetBranch, aggregatorBranchBefore,
                    effects,
                    eligible, skipped, draft);
        }

        // Publish mode — prompt for confirmation
        if (!force && !confirm("Abandon feature/" + feature + "?", false)) {
            throw new MojoException("Abandon cancelled.");
        }

        // Execute
        for (String name : eligible) {
            Subproject subproject = graph.manifest().subprojects().get(name);
            File dir = new File(root, name);

            // Strip branch-qualified versions before switching
            FeatureFinishSupport.stripBranchVersion(dir, subproject, branchName, getLog());

            VcsOperations.checkout(dir, getLog(), targetBranch);
            VcsOperations.deleteBranch(dir, getLog(), branchName);

            if (deleteRemote) {
                try {
                    VcsOperations.deleteRemoteBranch(dir, getLog(), "origin", branchName);
                } catch (MojoException e) {
                    getLog().warn("    could not delete remote branch: " + e.getMessage());
                }
            }

            VcsOperations.writeVcsState(dir, VcsState.Action.FEATURE_FINISH);
            getLog().info(Ansi.green("  ✓ ") + name + " → " + targetBranch);
        }

        // Update workspace.yaml and workspace repo
        if (!eligible.isEmpty()) {
            abandonWorkspaceRepo(manifestPath, eligible, branchName);
        }

        getLog().info("");
        getLog().info("  Abandoned: " + eligible.size()
                + " | Skipped: " + skipped.size());
        if (!deleteRemote) {
            getLog().info("  Remote branches kept. Use -DdeleteRemote=true to delete them.");
        }
        getLog().info("");

        return writeAbandonReport(branchName, targetBranch, aggregatorBranchBefore,
                    effects,
                eligible, skipped, draft);
    }

    private WorkspaceReportSpec writeAbandonReport(String branchName,
                                     String targetBranch, String aggregatorBranchBefore,
                                     Map<String, String> effects,
                                     List<String> eligible, List<String> skipped,
                                     boolean isDraft) {
        GoalReportBuilder report = new GoalReportBuilder();
        report.paragraph("**Branch:** `" + branchName + "`");

        // One row per working-set member — the aggregator (workspace root)
        // included — so the staleness a subproject-only table hid is visible
        // (#763, under epic #764). The Effect column states what the goal did
        // (publish) or would do (draft) to each member.
        List<WorkingSetReportTable.Row> rows = new ArrayList<>();
        for (WorkingSet.Member member : resolveWorkingSet().members()) {
            File dir = member.directory().toFile();
            String version = readMemberVersion(dir);
            String branch = gitBranch(dir);
            String sha = gitShortSha(dir);
            String effect = member.isAggregator()
                    ? aggregatorEffect(member, aggregatorBranchBefore, branchName,
                            targetBranch, isDraft)
                    : effects.getOrDefault(member.name(), "skipped (no-op)");
            rows.add(new WorkingSetReportTable.Row(member, version, branch, sha,
                    effect));
        }
        WorkingSetReportTable.render(report, "Working set", rows);

        report.paragraph("**" + eligible.size() + "** "
                + (isDraft ? "would be abandoned" : "abandoned")
                + ", **" + skipped.size() + "** skipped.");
        return new WorkspaceReportSpec(
                publish ? WsGoal.FEATURE_ABANDON_PUBLISH : WsGoal.FEATURE_ABANDON_DRAFT,
                report.build());
    }

    /**
     * Read a working-set member's POM version for the report, returning
     * {@code null} (rendered as the table's {@code —} placeholder) when the
     * member has no readable {@code pom.xml}. This gathers the aggregator's
     * version the same way as a subproject — the {@code #763} fix that surfaces
     * a workspace root left on a stale branch-qualified version — without
     * letting a non-Maven member fail the whole report.
     *
     * @param dir the member's directory
     * @return the POM version, or {@code null} if none can be read
     */
    private String readMemberVersion(File dir) {
        File pom = new File(dir, "pom.xml");
        if (!pom.isFile()) {
            return null;
        }
        try {
            return ReleaseSupport.readPomVersion(pom);
        } catch (MojoException e) {
            return null;
        }
    }

    /**
     * The Effect cell for an eligible subproject — phrased as planned for a
     * draft goal, applied for publish.
     *
     * @param draft         whether this is the draft (preview) variant
     * @param branchName    the feature branch being abandoned
     * @param targetBranch  the branch switched to after abandoning
     * @param unmergedCount unmerged commits that would be lost
     * @return the Effect cell text
     */
    private static String abandonEffect(boolean draft, String branchName,
                                        String targetBranch, int unmergedCount) {
        String verb = draft ? "would abandon" : "abandoned";
        String tail = " " + branchName + " → " + targetBranch;
        if (unmergedCount > 0) {
            return verb + tail + " (" + unmergedCount + " unmerged lost)";
        }
        return verb + tail;
    }

    /**
     * The Effect cell for the aggregator (workspace root). Mirrors
     * {@link #abandonWorkspaceRepo}: when the workspace repo is itself on the
     * feature branch it is reverted and switched to the target branch;
     * otherwise its branches are reconciled but it stays put. Phrased as
     * planned for a draft goal, applied for publish. Uses the pre-execution
     * branch ({@code branchBefore}) so the cell is accurate even in publish
     * mode, where the report is built after the root has been switched.
     *
     * @param member       the aggregator member
     * @param branchBefore the aggregator's branch before any mutation, or
     *                     {@code null} if the root is not a git working tree
     * @param branchName   the feature branch being abandoned
     * @param targetBranch the branch switched to after abandoning
     * @param draft        whether this is the draft (preview) variant
     * @return the Effect cell text
     */
    private String aggregatorEffect(WorkingSet.Member member, String branchBefore,
                                    String branchName, String targetBranch,
                                    boolean draft) {
        if (branchBefore == null
                || !new File(member.directory().toFile(), ".git").exists()) {
            return "skipped (not cloned)";
        }
        if (branchBefore.equals(branchName)) {
            return draft
                    ? "would revert workspace.yaml + switch → " + targetBranch
                    : "reverted workspace.yaml + switched → " + targetBranch;
        }
        return draft
                ? "would reconcile workspace.yaml branches"
                : "reconciled workspace.yaml branches";
    }

    // ── Auto-detect ─────────────────────────────────────────────────

    /**
     * Scan workspace subprojects for feature branches and return
     * the feature name. If multiple features are found, prompts
     * the user to choose.
     */
    private String detectFeatureBranch(File root, List<String> components)
            throws MojoException {
        Set<String> features = new TreeSet<>();

        for (String name : components) {
            File dir = new File(root, name);
            if (!new File(dir, ".git").exists()) continue;

            String branch = gitBranch(dir);
            if (branch.startsWith("feature/")) {
                features.add(branch.substring("feature/".length()));
            }
        }

        if (features.isEmpty()) {
            throw new MojoException(
                    "No components are on a feature branch. Nothing to abandon.");
        }

        if (features.size() == 1) {
            String detected = features.iterator().next();
            getLog().info("  Detected feature: " + detected);
            return detected;
        }

        // Multiple features — present a numbered selection menu
        List<String> featureList = new ArrayList<>(features);
        String picked = selectFromList("Multiple feature branches detected"
                + " — pick one to abandon", featureList);
        if (picked != null) {
            return picked;
        }

        throw new MojoException(
                "Multiple features found: " + features
                        + ". Specify with -Dfeature=<name>.");
    }

    // ── Bare mode ───────────────────────────────────────────────────

    private WorkspaceReportSpec executeBareMode() throws MojoException {
        boolean draft = !publish;
        // Bare mode = a working set of one (ike-issues#611).
        File dir = resolveWorkingSet().members().getFirst().directory().toFile();

        if (targetBranch == null || targetBranch.isBlank()) {
            targetBranch = "main";
        }

        String currentBranch = gitBranch(dir);
        if (feature == null || feature.isBlank()) {
            if (currentBranch.startsWith("feature/")) {
                feature = currentBranch.substring("feature/".length());
            } else {
                throw new MojoException(
                        "Not on a feature branch (on " + currentBranch
                                + "). Specify with -Dfeature=<name>.");
            }
        }
        validateFeatureName(feature);
        String branchName = "feature/" + feature;

        getLog().info("");
        getLog().info("IKE Feature Abandon (bare repo)");
        getLog().info("══════════════════════════════════════════════════════════════");
        getLog().info("  Feature: " + feature);
        getLog().info("  Branch:  " + branchName + " → " + targetBranch);
        if (draft) getLog().info("  Mode:    DRAFT");
        getLog().info("");

        if (!currentBranch.equals(branchName)) {
            throw new MojoException(
                    "Not on " + branchName + " (currently on " + currentBranch + ")");
        }
        if (!gitStatus(dir).isEmpty()) {
            throw new MojoException(
                    "Uncommitted changes. Commit, stash, or discard first.");
        }

        if (draft) {
            getLog().info("  [draft] Would abandon " + branchName
                    + " and switch to " + targetBranch);
            getLog().info("");
            getLog().info("  Next: mvn "
                    + WsGoal.FEATURE_ABANDON_PUBLISH.qualified());
            getLog().info("");
            return new WorkspaceReportSpec(WsGoal.FEATURE_ABANDON_DRAFT,
                    "Bare repo: would abandon `" + branchName + "` and switch to `"
                            + targetBranch + "`.\n");
        }

        // Publish mode — prompt for confirmation
        if (!force && !confirm("Abandon feature/" + feature + "?", false)) {
            throw new MojoException("Abandon cancelled.");
        }

        FeatureFinishSupport.stripBranchVersionBare(dir, branchName, getLog());

        VcsOperations.checkout(dir, getLog(), targetBranch);
        VcsOperations.deleteBranch(dir, getLog(), branchName);
        getLog().info(Ansi.green("  ✓ ") + "Switched to " + targetBranch
                + ", deleted " + branchName);

        if (deleteRemote) {
            try {
                VcsOperations.deleteRemoteBranch(dir, getLog(), "origin", branchName);
                getLog().info(Ansi.green("  ✓ ") + "Deleted remote branch");
            } catch (MojoException e) {
                getLog().warn("  Could not delete remote branch: " + e.getMessage());
            }
        }

        VcsOperations.writeVcsState(dir, VcsState.Action.FEATURE_FINISH);
        getLog().info("");
        return new WorkspaceReportSpec(WsGoal.FEATURE_ABANDON_PUBLISH,
                "Bare repo: abandoned `" + branchName + "`, switched to `"
                        + targetBranch + "`.\n");
    }

    // ── Workspace repo cleanup ──────────────────────────────────────

    private void abandonWorkspaceRepo(Path manifestPath,
                                       List<String> components,
                                       String branchName)
            throws MojoException {
        try {
            Map<String, String> updates = new LinkedHashMap<>();
            for (String name : components) {
                updates.put(name, targetBranch);
            }
            ManifestWriter.updateBranches(manifestPath, updates);

            File wsRoot = manifestPath.getParent().toFile();
            if (!new File(wsRoot, ".git").exists()) return;

            String wsBranch = gitBranch(wsRoot);
            if (wsBranch.equals(branchName)) {
                ReleaseSupport.exec(wsRoot, getLog(), "git", "add", "workspace.yaml");
                if (VcsOperations.hasStagedChanges(wsRoot)) {
                    VcsOperations.commit(wsRoot, getLog(),
                            "workspace: revert branches for abandon " + branchName);
                }

                VcsOperations.checkout(wsRoot, getLog(), targetBranch);

                try {
                    ReleaseSupport.exec(wsRoot, getLog(),
                            "git", "cherry-pick", branchName);
                } catch (MojoException e) {
                    ManifestWriter.updateBranches(manifestPath, updates);
                    ReleaseSupport.exec(wsRoot, getLog(), "git", "add", "workspace.yaml");
                    if (VcsOperations.hasStagedChanges(wsRoot)) {
                        VcsOperations.commit(wsRoot, getLog(),
                                "workspace: revert branches after abandon " + branchName);
                    }
                }

                VcsOperations.deleteBranch(wsRoot, getLog(), branchName);

                if (deleteRemote) {
                    try {
                        VcsOperations.deleteRemoteBranch(wsRoot, getLog(), "origin", branchName);
                    } catch (MojoException e) {
                        getLog().warn("  Could not delete workspace remote branch: "
                                + e.getMessage());
                    }
                }
            } else {
                ReleaseSupport.exec(wsRoot, getLog(), "git", "add", "workspace.yaml");
                if (VcsOperations.hasStagedChanges(wsRoot)) {
                    VcsOperations.commit(wsRoot, getLog(),
                            "workspace: revert branches after abandon " + branchName);
                }
            }

            VcsOperations.pushIfRemoteExists(wsRoot, getLog(), "origin", targetBranch);

        } catch (IOException e) {
            getLog().warn("  Could not update workspace.yaml: " + e.getMessage());
        }
    }
}