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 all workspace subprojects for merged feature branches and offer
 * interactive deletion.
 *
 * <p>Lists feature branches across all subprojects, classifies each as
 * merged (into the target branch) or active, and displays last-commit
 * timestamps. In draft mode (default), only reports. In publish mode,
 * prompts for deletion.
 *
 * <pre>{@code
 * mvn ws:cleanup                          # list stale branches (draft)
 * mvn ws:cleanup-publish                  # prompt for deletion
 * mvn ws:cleanup -DtargetBranch=develop   # check against develop
 * }</pre>
 */
@Mojo(name = "cleanup-draft", projectRequired = false, aggregator = true)
public class CleanupWorkspaceMojo extends AbstractWorkspaceMojo {

    /** 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("");

        // Collect merged and active feature branches per subproject
        Map<String, List<String>> mergedBySubproject = new LinkedHashMap<>();
        Map<String, List<String>> activeBySubproject = new LinkedHashMap<>();
        Set<String> allMerged = new TreeSet<>();
        Set<String> allActive = new TreeSet<>();

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

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

            if (!merged.isEmpty()) {
                mergedBySubproject.put(name, merged);
                allMerged.addAll(merged);
            }
            if (!active.isEmpty()) {
                activeBySubproject.put(name, active);
                allActive.addAll(active);
            }
        }

        // Report merged branches
        if (allMerged.isEmpty()) {
            getLog().info("  No merged feature branches found.");
        } else {
            getLog().info("  Merged branches (safe to delete):");
            for (String branch : allMerged) {
                int count = (int) mergedBySubproject.values().stream()
                        .filter(list -> list.contains(branch))
                        .count();
                // Get last commit date from first subproject
                String date = "unknown";
                for (var entry : mergedBySubproject.entrySet()) {
                    if (entry.getValue().contains(branch)) {
                        date = VcsOperations.branchLastCommitDate(
                                new File(root, entry.getKey()), branch);
                        break;
                    }
                }
                getLog().info(Ansi.green("    ✓ ") + branch + " (" + count
                        + " subproject" + (count == 1 ? "" : "s")
                        + ", last commit: " + date + ")");
            }
        }

        // Report active branches
        if (!allActive.isEmpty()) {
            getLog().info("");
            getLog().info("  Active branches (not fully merged):");
            for (String branch : allActive) {
                int count = (int) activeBySubproject.values().stream()
                        .filter(list -> list.contains(branch))
                        .count();
                String date = "unknown";
                for (var entry : activeBySubproject.entrySet()) {
                    if (entry.getValue().contains(branch)) {
                        date = VcsOperations.branchLastCommitDate(
                                new File(root, entry.getKey()), branch);
                        break;
                    }
                }
                getLog().info(Ansi.yellow("    · ") + branch + " (" + count
                        + " subproject" + (count == 1 ? "" : "s")
                        + ", last commit: " + date + ")");
            }
        }

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

        // In publish mode, delete merged branches
        if (publish && !allMerged.isEmpty()) {
            getLog().info("");
            int deleted = 0;
            for (var entry : mergedBySubproject.entrySet()) {
                File dir = new File(root, entry.getKey());
                for (String branch : entry.getValue()) {
                    try {
                        VcsOperations.deleteBranch(dir, getLog(), branch);
                        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, allActive, mergedBySubproject, root));
    }

    private String buildCleanupReport(Set<String> merged, Set<String> active,
                                       Map<String, List<String>> mergedBySubproject,
                                       File root) {
        GoalReportBuilder report = new GoalReportBuilder();
        report.paragraph(merged.size() + " merged, "
                + active.size() + " active feature branch"
                + (active.size() == 1 ? "" : "es") + ".");

        if (!merged.isEmpty()) {
            List<String[]> mergedRows = new ArrayList<>();
            for (String branch : merged) {
                int count = (int) mergedBySubproject.values().stream()
                        .filter(list -> list.contains(branch))
                        .count();
                String date = "unknown";
                for (var entry : mergedBySubproject.entrySet()) {
                    if (entry.getValue().contains(branch)) {
                        date = VcsOperations.branchLastCommitDate(
                                new File(root, entry.getKey()), branch);
                        break;
                    }
                }
                mergedRows.add(new String[]{branch,
                        String.valueOf(count), date});
            }
            report.section("Merged (safe to delete)")
                    .table(List.of("Branch", "Subprojects", "Last Commit"),
                            mergedRows);
        }

        if (!active.isEmpty()) {
            List<String[]> activeRows = new ArrayList<>();
            for (String branch : active) {
                int count = (int) mergedBySubproject.values().stream()
                        .filter(list -> list.contains(branch))
                        .count();
                activeRows.add(new String[]{branch, String.valueOf(count)});
            }
            report.section("Active")
                    .table(List.of("Branch", "Subprojects"), activeRows);
        }

        return report.build();
    }
}