WsRefreshMainMojo.java

package network.ike.plugin.ws;

import network.ike.plugin.support.GoalReportBuilder;
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.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

/**
 * Refresh local main from {@code origin/main} across the workspace.
 *
 * <p>For each subproject, this goal fetches origin and reconciles local
 * main with {@code origin/main}: fast-forward when behind, leave alone
 * when purely ahead (unpushed work), auto-resolve via merge when
 * diverged. The auto-resolve merge stays local until the user pushes
 * via {@code ws:push} or {@code ws:sync}.
 *
 * <p>Use this when you want main current across the workspace without
 * doing anything else &mdash; before review, before starting a feature,
 * after returning to a machine. The same logic runs as a precondition
 * inside {@code ws:update-feature-*}, {@code ws:feature-finish-*},
 * {@code ws:feature-start-*}, and {@code ws:sync}, so this goal is
 * mostly a convenience wrapper.
 *
 * <p>If the auto-resolve merge would produce file conflicts (the rare
 * "two machines edited the same file on main without push/pull" case),
 * the goal hard-errors with the conflict list. The working tree is not
 * touched.
 *
 * <pre>{@code
 * mvn ws:refresh-main                       # refresh "main" from origin
 * mvn ws:refresh-main -DmainBranch=trunk    # alternative branch name
 * mvn ws:refresh-main -Dremote=upstream     # alternative remote name
 * }</pre>
 *
 * <p>See ike-issues#284.
 */
@Mojo(name = "refresh-main", projectRequired = false, aggregator = true)
public class WsRefreshMainMojo extends AbstractWorkspaceMojo {

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

    /** The conceptual main branch to refresh. */
    @Parameter(property = "mainBranch", defaultValue = "main")
    String mainBranch;

    /** The remote to refresh from. */
    @Parameter(property = "remote",
            defaultValue = RefreshMainSupport.DEFAULT_REMOTE)
    String remote;

    @Override
    protected WorkspaceReportSpec runGoal() throws MojoException {
        if (!isWorkspaceMode()) {
            throw new MojoException(
                    WsGoal.REFRESH_MAIN.qualified()
                            + " requires a workspace (workspace.yaml).");
        }

        WorkspaceGraph graph = loadGraph();
        File root = workspaceRoot();
        Set<String> targets = graph.manifest().subprojects().keySet();
        List<String> sorted = graph.topologicalSort(new LinkedHashSet<>(targets));

        getLog().info("");
        getLog().info(header("Refresh Main"));
        getLog().info("══════════════════════════════════════════════════════════════");
        getLog().info("  Branch:  " + mainBranch);
        getLog().info("  Remote:  " + remote);
        getLog().info("  Scope:   " + sorted.size() + " components");
        getLog().info("");

        // SYNC goal (#780): auto-stash each cloned subproject's WIP so this goal
        // — which checks out + fast-forwards main in every clone — runs against
        // clean trees instead of failing mid-walk on an uncommitted one; the
        // work is restored after. The release path calls
        // RefreshMainSupport.refreshOrThrow directly (no goal wrapper), so this
        // relaxation is scoped to the ws:refresh-main goal alone.
        List<Boolean> stashed = new ArrayList<>();
        List<String> restoreFailures = new ArrayList<>();
        List<RefreshMainSupport.Outcome> outcomes;
        try {
            for (String name : sorted) {
                File dir = new File(root, name);
                stashed.add(new File(dir, ".git").exists()
                        && AutoStashGuard.stashIfDirty(dir, getLog()));
            }
            outcomes = RefreshMainSupport.refreshOrThrow(
                    root, sorted, mainBranch, getLog());
        } finally {
            for (int i = 0; i < stashed.size(); i++) {
                File dir = new File(root, sorted.get(i));
                try {
                    AutoStashGuard.restoreIfStashed(dir, getLog(), stashed.get(i));
                } catch (MojoException e) {
                    restoreFailures.add(sorted.get(i)
                            + " (" + e.getMessage() + ")");
                    getLog().warn("  could not restore stashed WIP in "
                            + sorted.get(i) + " — recover it from refs/ws-stash. "
                            + e.getMessage());
                }
            }
        }

        // A restore that conflicts leaves that subproject's tree mid-apply, so
        // fail loudly rather than report success over an inconsistent tree
        // (IKE-Network/ike-issues#781). The work survives on refs/ws-stash, so
        // the user can resolve and re-apply.
        if (!restoreFailures.isEmpty()) {
            throw new MojoException("refresh-main advanced main, but could not "
                    + "restore auto-stashed work in: " + restoreFailures
                    + "\nThe work survives on refs/ws-stash/<you>/<branch>; "
                    + "resolve the conflict there and re-apply.");
        }

        WorkspaceReportSpec spec = new WorkspaceReportSpec(
                WsGoal.REFRESH_MAIN, buildReport(outcomes));

        PostMutationSync.refresh(root, getLog());
        return spec;
    }

    private String buildReport(List<RefreshMainSupport.Outcome> outcomes) {
        List<String[]> rows = new ArrayList<>();
        for (RefreshMainSupport.Outcome o : outcomes) {
            rows.add(new String[]{o.component(), outcomeLabel(o)});
        }
        GoalReportBuilder report = new GoalReportBuilder();
        report.paragraph("**Branch:** `" + mainBranch + "` ← `"
                        + remote + "/" + mainBranch + "`")
                .table(List.of("Subproject", "Outcome"), rows)
                .paragraph("**" + outcomes.size()
                        + "** subproject(s) refreshed.");
        return report.build();
    }

    private static String outcomeLabel(RefreshMainSupport.Outcome o) {
        return switch (o) {
            case RefreshMainSupport.Skipped(String c, String r) -> "skipped (" + r + ")";
            case RefreshMainSupport.UpToDate(String c) -> "up to date";
            case RefreshMainSupport.FastForwarded(String c, int n) ->
                    "fast-forwarded " + n + " commit" + (n == 1 ? "" : "s");
            case RefreshMainSupport.CreatedFromRemote(String c) -> "created from remote";
            case RefreshMainSupport.AheadOnly(String c, int n) ->
                    n + " unpushed commit" + (n == 1 ? "" : "s") + ", left as-is";
            case RefreshMainSupport.AutoResolved(String c, int local, int remoteCount) ->
                    "auto-resolved (kept " + local + " local, merged "
                            + remoteCount + " from remote)";
            case RefreshMainSupport.Conflicts(String c, List<String> files) ->
                    files.size() + " conflict(s)";
        };
    }
}