UpdateFeatureDraftMojo.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 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.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

/**
 * Update the current feature branch by incorporating changes from main.
 *
 * <p>For long-lived feature branches, main may advance significantly.
 * This goal brings the feature branch up to date, surfacing merge
 * conflicts incrementally rather than at feature-finish time.
 *
 * <p>Uses merge (not rebase) to incorporate main — this preserves all
 * commit hashes and is safe for branches shared via Syncthing or pushed
 * to a remote.
 *
 * <p>Both variants begin by refreshing local main from
 * {@code origin/main} via {@link RefreshMainSupport} so the merge runs
 * against current main rather than whatever stale state happens to be
 * on the local machine. The refresh fast-forwards local main when
 * possible and auto-resolves divergence with a merge commit when needed.
 * If the refresh would produce file conflicts (the rare "two machines
 * edited the same file on main without push/pull" case), the goal
 * hard-errors before touching the feature branch. See ike-issues#284.
 *
 * <p>Components are processed in topological order. If a conflict occurs
 * during the feature-side merge, the goal stops and reports the
 * conflicting files with instructions for resolving in IntelliJ.
 * Re-running the goal after resolution continues with the remaining
 * components.
 *
 * <p>The draft variant fetches and refreshes local main but does not
 * modify the feature branch or working tree. Conflict prediction uses
 * {@code git merge-tree}.
 *
 * <pre>{@code
 * mvn ws:update-feature-draft    # refresh main + preview + predict conflicts
 * mvn ws:update-feature-publish  # refresh main + merge into feature branch
 * }</pre>
 *
 * @see RefreshMainSupport for the local-main refresh contract
 * @see FeatureStartDraftMojo for creating feature branches
 * @see FeatureFinishSquashDraftMojo for merging back to main
 */
@Mojo(name = "update-feature-draft", projectRequired = false, aggregator = true)
public class UpdateFeatureDraftMojo extends AbstractWorkspaceMojo {

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

    /**
     * Feature name. If omitted, auto-detected from subproject branches.
     */
    @Parameter(property = "feature")
    String feature;

    /** Merge strategy — always merge (rebase is not supported). */
    private final String strategy = "merge";

    /** Target branch to update from. */
    @Parameter(property = "targetBranch", defaultValue = "main")
    String targetBranch;

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

    @Override
    protected WorkspaceReportSpec runGoal() throws MojoException {
        if (!isWorkspaceMode()) {
            throw new MojoException(
                    "ws:update-feature requires a workspace (workspace.yaml).");
        }

        // strategy is always "merge" — see field declaration

        boolean draft = !publish;
        WorkspaceGraph graph = loadGraph();
        File root = workspaceRoot();

        // Auto-detect feature if not specified
        if (feature == null || feature.isBlank()) {
            List<String> all = graph.topologicalSort();
            feature = FeatureFinishSupport.detectFeature(
                    root, all, this, getLog());
        }
        validateFeatureName(feature);
        String branchName = "feature/" + feature;

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

        List<String> sorted = graph.topologicalSort(new LinkedHashSet<>(targets));

        getLog().info("");
        getLog().info(header("Update Feature"));
        getLog().info("══════════════════════════════════════════════════════════════");
        getLog().info("  Feature:   " + feature);
        getLog().info("  Branch:    " + branchName);
        getLog().info("  From:      " + targetBranch);
        getLog().info("  Strategy:  " + strategy);
        if (draft) getLog().info("  Mode:      DRAFT");
        getLog().info("");

        // Validate clean working trees and collect eligible components
        List<String> eligible = new ArrayList<>();
        List<String> uncommitted = new ArrayList<>();
        List<String> skipped = new ArrayList<>();

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

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

            String status = gitStatus(dir);
            if (!status.isEmpty()) {
                uncommitted.add(name);
                continue;
            }

            eligible.add(name);
        }

        // Check workspace root
        if (new File(root, ".git").exists() && !gitStatus(root).isEmpty()) {
            uncommitted.add("workspace root");
        }

        if (!uncommitted.isEmpty()) {
            var sb = new StringBuilder();
            sb.append("Cannot update — uncommitted changes in:\n");
            for (String name : uncommitted) {
                sb.append("  ").append(name).append("\n");
            }
            sb.append("Commit or stash, then try again.");
            throw new MojoException(sb.toString());
        }

        if (eligible.isEmpty()) {
            getLog().info("  No components on " + branchName + " — nothing to update.");
            return new WorkspaceReportSpec(
                    publish ? WsGoal.UPDATE_FEATURE_PUBLISH : WsGoal.UPDATE_FEATURE_DRAFT,
                    "No components on `" + branchName + "` — nothing to update.\n");
        }

        // Refresh local main from origin/main across eligible components
        // before any feature-side comparison or merge. See ike-issues#284.
        RefreshMainSupport.refreshOrThrow(root, eligible, targetBranch, getLog());

        // Show how far behind each subproject is + collect report data
        List<String[]> reportRows = new ArrayList<>();
        for (String name : eligible) {
            File dir = new File(root, name);
            try {
                List<String> behind = VcsOperations.commitLog(
                        dir, branchName, targetBranch);
                List<String> ahead = VcsOperations.commitLog(
                        dir, targetBranch, branchName);

                if (behind.isEmpty()) {
                    getLog().info("  " + Ansi.green("✓ ") + name
                            + " — up to date with " + targetBranch);
                    reportRows.add(new String[]{name, "0", "0", "", "up to date"});
                } else if (draft) {
                    // Predict conflicts without touching working tree
                    List<String> predicted = VcsOperations.predictConflicts(
                            dir, branchName, targetBranch);

                    if (predicted.isEmpty()) {
                        getLog().info("  " + Ansi.green("✓ ") + name + " — "
                                + behind.size() + " commit(s) behind "
                                + targetBranch + ", " + ahead.size()
                                + " ahead — clean update expected");
                        reportRows.add(new String[]{name,
                                String.valueOf(behind.size()),
                                String.valueOf(ahead.size()), "", "clean"});
                    } else {
                        getLog().warn("  " + Ansi.red("⚠ ") + name + " — "
                                + behind.size() + " commit(s) behind "
                                + targetBranch + ", " + ahead.size()
                                + " ahead — " + predicted.size()
                                + " conflict(s) expected:");
                        for (String file : predicted) {
                            getLog().warn("      • " + file);
                        }
                        getLog().warn("      Resolve in IntelliJ after running"
                                + " ws:update-feature-publish");
                        reportRows.add(new String[]{name,
                                String.valueOf(behind.size()),
                                String.valueOf(ahead.size()),
                                String.join(", ", predicted),
                                predicted.size() + " conflict(s)"});
                    }
                } else {
                    getLog().info("  " + Ansi.cyan("→ ") + name + " — "
                            + behind.size() + " commit(s) behind, "
                            + strategy + "...");

                    VcsOperations.mergeNoFf(dir, getLog(), targetBranch,
                            "update: merge " + targetBranch
                                    + " into " + branchName);

                    getLog().info("    " + Ansi.green("✓ ") + "updated");
                    reportRows.add(new String[]{name,
                            String.valueOf(behind.size()),
                            String.valueOf(ahead.size()), "", "merged"});
                }
            } catch (MojoException e) {
                List<String> conflicts = VcsOperations.conflictingFiles(dir);

                getLog().error("");
                getLog().error("  " + Ansi.red("✗ ") + name
                        + " — " + strategy + " failed");
                getLog().error("");

                if (!conflicts.isEmpty()) {
                    getLog().error("  Conflicting files in " + name + ":");
                    for (String file : conflicts) {
                        getLog().error("    • " + file);
                    }
                    getLog().error("");
                }

                getLog().error("  To resolve in IntelliJ:");
                getLog().error("    1. Open the " + name + " project");
                getLog().error("    2. IntelliJ will detect the conflicts automatically");
                getLog().error("    3. Git → Resolve Conflicts → resolve each file"
                        + " with the 3-way merge editor");
                getLog().error("    4. Commit the merge resolution");
                getLog().error("    5. Re-run: mvn ws:update-feature-publish");
                getLog().error("       (already-updated components will be skipped)");
                getLog().error("");
                throw new MojoException(
                        strategy + " failed for " + name
                                + " (" + conflicts.size() + " conflicting file"
                                + (conflicts.size() == 1 ? "" : "s")
                                + "). See above for resolution steps.", e);
            }
        }

        getLog().info("");
        if (draft) {
            getLog().info("  Components to update: " + eligible.size()
                    + " | Skipped: " + skipped.size());
            getLog().info("");
            getLog().info("  Next: mvn ws:update-feature-publish");
        } else {
            getLog().info("  Updated: " + eligible.size() + " subproject(s)"
                    + " | Skipped: " + skipped.size());
        }
        getLog().info("");

        // Write report
        GoalReportBuilder report = new GoalReportBuilder();
        report.paragraph("**Branch:** `" + branchName
                + "` ← `" + targetBranch + "`");
        report.table(
                List.of("Subproject", "Behind", "Ahead", "Conflicts", "Status"),
                reportRows);
        report.paragraph("**" + eligible.size() + "** subproject(s)"
                + (draft ? " to update" : " updated")
                + ", **" + skipped.size() + "** skipped.");
        return new WorkspaceReportSpec(
                publish ? WsGoal.UPDATE_FEATURE_PUBLISH : WsGoal.UPDATE_FEATURE_DRAFT,
                report.build());
    }
}