CleanupWorkspaceMojo.java

package network.ike.plugin.ws;

import network.ike.workspace.WorkspaceGraph;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.ws.vcs.VcsOperations;
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.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;

/**
 * Scan the working set for finished feature branches and offer deletion.
 *
 * <p>Classifies every local {@code feature/*} branch — across all
 * subprojects AND the workspace root — into three states
 * (IKE-Network/ike-issues#946):
 *
 * <ul>
 *   <li><b>merged</b> — an ancestor of the target (no-ff merges);</li>
 *   <li><b>squash-merged</b> — not an ancestor, but its tip's tree equals
 *       a recent target commit's tree: the content landed via
 *       {@code ws:feature-finish-squash-*}, the recommended strategy,
 *       which ancestry classification can never see;</li>
 *   <li><b>active</b> — genuinely unmerged work.</li>
 * </ul>
 *
 * <p>Classification runs against {@code origin/<targetBranch>} (after a
 * fetch) whenever that ref resolves — the same source-of-truth doctrine
 * as the feature-lifecycle goals (#857). That also makes deletion of the
 * matching <em>remote</em> feature branches safe: squash-merged means the
 * content is already on the remote target.
 *
 * <p>In draft mode (default), only reports. In publish mode, deletes the
 * merged and squash-merged branches — local and remote (remote deletion
 * soft-fails per #532).
 *
 * <pre>{@code
 * mvn ws:cleanup-draft                    # list finished branches
 * mvn ws:cleanup-publish                  # delete merged + squash-merged
 * mvn ws:cleanup-draft -DtargetBranch=develop
 * }</pre>
 */
@Mojo(name = "cleanup-draft", projectRequired = false, aggregator = true)
public class CleanupWorkspaceMojo extends AbstractWorkspaceMojo {

    /**
     * How many recent target commits the squash-merge tree comparison
     * scans. Squash finishes land near the target tip, so a modest
     * window is ample; the cap keeps the scan O(1) per branch.
     */
    private static final int TREE_SCAN_LIMIT = 500;

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

    /** Branch to check merge status against. */
    @Parameter(property = "targetBranch", defaultValue = "main")
    String targetBranch;

    /** Execute deletions (true) or just report (false). */
    @Parameter(property = "publish", defaultValue = "false")
    boolean publish;

    @Override
    protected WorkspaceReportSpec runGoal() throws MojoException {
        WorkspaceGraph graph = loadGraph();
        File root = workspaceRoot();

        boolean draft = !publish;
        List<String> sorted = graph.topologicalSort(
                new LinkedHashSet<>(graph.manifest().subprojects().keySet()));

        getLog().info("");
        getLog().info(header("Cleanup"));
        getLog().info("══════════════════════════════════════════════════════════════");
        getLog().info("  Target: " + targetBranch);
        if (draft) getLog().info("  Mode:   DRAFT — listing only");
        getLog().info("");

        // The working set includes the root repo (#946/#763): a finish
        // with keepBranch leaves the root's feature branch behind too.
        Map<String, File> memberDirs = new LinkedHashMap<>();
        for (String name : sorted) {
            memberDirs.put(name, new File(root, name));
        }
        memberDirs.put(RefreshMainSupport.ROOT_LABEL, root);

        // Collect merged / squash-merged / active feature branches per member.
        Map<String, List<String>> mergedByMember = new LinkedHashMap<>();
        Map<String, List<String>> squashedByMember = new LinkedHashMap<>();
        Map<String, List<String>> activeByMember = new LinkedHashMap<>();
        Set<String> allMerged = new TreeSet<>();
        Set<String> allSquashed = new TreeSet<>();
        Set<String> allActive = new TreeSet<>();

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

            // Assess against origin truth when available (#857): fetch,
            // then compare against origin/<target> so a stale local
            // target neither hides a finished branch nor legitimizes
            // deleting one whose content is not actually on the remote.
            try {
                VcsOperations.fetch(dir, getLog());
            } catch (MojoException e) {
                getLog().warn("  " + name + " — fetch failed ("
                        + e.getMessage() + "); assessing against local "
                        + targetBranch);
            }
            String assessRef = RefreshMainSupport.assessmentRef(dir, targetBranch);

            List<String> merged = VcsOperations.mergedBranches(
                    dir, assessRef, "feature/");
            List<String> allFeature = VcsOperations.localBranches(dir, "feature/");
            List<String> squashed = allFeature.stream()
                    .filter(b -> !merged.contains(b))
                    .filter(b -> VcsOperations.branchTreeLandedOn(
                            dir, b, assessRef, TREE_SCAN_LIMIT))
                    .toList();
            List<String> active = allFeature.stream()
                    .filter(b -> !merged.contains(b))
                    .filter(b -> !squashed.contains(b))
                    .toList();

            if (!merged.isEmpty()) {
                mergedByMember.put(name, merged);
                allMerged.addAll(merged);
            }
            if (!squashed.isEmpty()) {
                squashedByMember.put(name, squashed);
                allSquashed.addAll(squashed);
            }
            if (!active.isEmpty()) {
                activeByMember.put(name, active);
                allActive.addAll(active);
            }
        }

        // Report the three classes.
        if (allMerged.isEmpty() && allSquashed.isEmpty()) {
            getLog().info("  No finished feature branches found.");
        }
        if (!allMerged.isEmpty()) {
            getLog().info("  Merged branches (ancestry — safe to delete):");
            logBranchGroup(allMerged, mergedByMember, memberDirs, Ansi.green("    ✓ "));
        }
        if (!allSquashed.isEmpty()) {
            getLog().info("  Squash-merged branches (content landed — safe to delete):");
            logBranchGroup(allSquashed, squashedByMember, memberDirs, Ansi.green("    ✓ "));
        }
        if (!allActive.isEmpty()) {
            getLog().info("");
            getLog().info("  Active branches (not merged):");
            logBranchGroup(allActive, activeByMember, memberDirs, Ansi.yellow("    · "));
        }

        getLog().info("");
        getLog().info("  Summary: " + allMerged.size() + " merged, "
                + allSquashed.size() + " squash-merged, "
                + allActive.size() + " active");

        // In publish mode, delete merged AND squash-merged branches —
        // local and remote (#946; remote deletion soft-fails per #532).
        if (publish && (!allMerged.isEmpty() || !allSquashed.isEmpty())) {
            getLog().info("");
            int deleted = 0;
            Map<String, List<String>> deletable = new LinkedHashMap<>();
            mergedByMember.forEach((m, bs) ->
                    deletable.merge(m, new ArrayList<>(bs), (a, b) -> {
                        a.addAll(b);
                        return a;
                    }));
            squashedByMember.forEach((m, bs) ->
                    deletable.merge(m, new ArrayList<>(bs), (a, b) -> {
                        a.addAll(b);
                        return a;
                    }));
            for (Map.Entry<String, List<String>> entry : deletable.entrySet()) {
                File dir = memberDirs.get(entry.getKey());
                for (String branch : entry.getValue()) {
                    try {
                        FeatureFinishSupport.deleteBranch(
                                dir, getLog(), branch, false);
                        getLog().info(Ansi.green("    ✓ ") + "deleted: "
                                + entry.getKey() + "/" + branch);
                        deleted++;

                        // Also clean up stale remote-tracking refs
                        try {
                            new ProcessBuilder("git", "remote", "prune", "origin")
                                    .directory(dir).start().waitFor();
                        } catch (Exception ignored) {}
                    } catch (MojoException e) {
                        getLog().warn(Ansi.red("    ✗ ") + entry.getKey()
                                + "/" + branch + " — " + e.getMessage());
                    }
                }
            }
            getLog().info("");
            getLog().info("  Deleted " + deleted + " branch reference"
                    + (deleted == 1 ? "" : "s") + ".");
        }

        getLog().info("");

        return new WorkspaceReportSpec(
                publish ? WsGoal.CLEANUP_PUBLISH : WsGoal.CLEANUP_DRAFT,
                buildCleanupReport(allMerged, allSquashed, allActive,
                        mergedByMember, squashedByMember, memberDirs));
    }

    /**
     * Log one line per branch in a classification group: the branch, how
     * many members carry it, and its last-commit date.
     *
     * @param branches   the group's branch names
     * @param byMember   member → branches in this group
     * @param memberDirs member → repository directory
     * @param bullet     the (colored) line prefix
     */
    private void logBranchGroup(Set<String> branches,
                                Map<String, List<String>> byMember,
                                Map<String, File> memberDirs,
                                String bullet) {
        for (String branch : branches) {
            int count = (int) byMember.values().stream()
                    .filter(list -> list.contains(branch))
                    .count();
            String date = lastCommitDate(branch, byMember, memberDirs);
            getLog().info(bullet + branch + " (" + count
                    + " member" + (count == 1 ? "" : "s")
                    + ", last commit: " + date + ")");
        }
    }

    /**
     * Last-commit date of a branch, read from the first member that
     * carries it.
     *
     * @param branch     the branch name
     * @param byMember   member → branches
     * @param memberDirs member → repository directory
     * @return the date string, or {@code "unknown"}
     */
    private String lastCommitDate(String branch,
                                  Map<String, List<String>> byMember,
                                  Map<String, File> memberDirs) {
        for (Map.Entry<String, List<String>> entry : byMember.entrySet()) {
            if (entry.getValue().contains(branch)) {
                return VcsOperations.branchLastCommitDate(
                        memberDirs.get(entry.getKey()), branch);
            }
        }
        return "unknown";
    }

    /**
     * Build the markdown report: merged and squash-merged sections (both
     * deletable) plus the active section.
     *
     * @param merged           ancestry-merged branch names
     * @param squashed         squash-merged (content-landed) branch names
     * @param active           active branch names
     * @param mergedByMember   member → ancestry-merged branches
     * @param squashedByMember member → squash-merged branches
     * @param memberDirs       member → repository directory
     * @return the rendered report body
     */
    private String buildCleanupReport(Set<String> merged, Set<String> squashed,
                                      Set<String> active,
                                      Map<String, List<String>> mergedByMember,
                                      Map<String, List<String>> squashedByMember,
                                      Map<String, File> memberDirs) {
        GoalReportBuilder report = new GoalReportBuilder();
        report.paragraph(merged.size() + " merged, " + squashed.size()
                + " squash-merged, " + active.size()
                + " active feature branch"
                + (active.size() == 1 ? "" : "es") + ".");

        if (!merged.isEmpty()) {
            report.section("Merged — ancestry (safe to delete)")
                    .table(List.of("Branch", "Members", "Last Commit"),
                            branchRows(merged, mergedByMember, memberDirs));
        }
        if (!squashed.isEmpty()) {
            report.section("Squash-merged — content landed (safe to delete)")
                    .table(List.of("Branch", "Members", "Last Commit"),
                            branchRows(squashed, squashedByMember, memberDirs));
        }
        if (!active.isEmpty()) {
            List<String[]> activeRows = new ArrayList<>();
            for (String branch : active) {
                activeRows.add(new String[]{branch});
            }
            report.section("Active")
                    .table(List.of("Branch"), activeRows);
        }

        return report.build();
    }

    /**
     * Rows for a deletable-branch table: branch, member count, last date.
     *
     * @param branches   the group's branch names
     * @param byMember   member → branches in this group
     * @param memberDirs member → repository directory
     * @return one row per branch
     */
    private List<String[]> branchRows(Set<String> branches,
                                      Map<String, List<String>> byMember,
                                      Map<String, File> memberDirs) {
        List<String[]> rows = new ArrayList<>();
        for (String branch : branches) {
            int count = (int) byMember.values().stream()
                    .filter(list -> list.contains(branch))
                    .count();
            rows.add(new String[]{branch, String.valueOf(count),
                    lastCommitDate(branch, byMember, memberDirs)});
        }
        return rows;
    }
}