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 — 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)";
};
}
}