UpdateFeatureDraftMojo.java
package network.ike.plugin.ws;
import network.ike.workspace.WorkingSet;
import network.ike.workspace.WorkspaceGraph;
import network.ike.plugin.ReleaseSupport;
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.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
/**
* 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><strong>Single repo (no {@code workspace.yaml})</strong>: updates the
* current repository only — a working set of one. The repo's own current
* branch is taken as the feature branch (unless {@code -Dfeature} is given),
* and {@code main} is merged in exactly as the workspace path does per
* subproject (IKE-Network/ike-issues#703).
*
* <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 {
// strategy is always "merge" — see field declaration
boolean draft = !publish;
// Resolve scope: a workspace updates every subproject on the feature
// branch; a bare repo is a working set of one (no workspace.yaml,
// IKE-Network/ike-issues#703). Both drive the same eligibility +
// refresh-main + merge loop below over (root, components).
WorkingSet workingSet = resolveWorkingSet();
File root;
List<String> sorted;
if (workingSet.isWorkspace()) {
WorkspaceGraph graph = loadGraph();
root = workspaceRoot();
// Auto-detect feature if not specified
if (feature == null || feature.isBlank()) {
feature = FeatureFinishSupport.detectFeature(
root, graph.topologicalSort(), this, getLog());
}
sorted = graph.topologicalSort(new LinkedHashSet<>(
graph.manifest().subprojects().keySet()));
} else {
File repo = workingSet.root().toFile();
if (!new File(repo, ".git").exists()) {
throw new MojoException("ws:update-feature: " + repo
+ " is not a git repository.");
}
// The repo's own current branch is the feature branch unless
// -Dfeature pins one explicitly.
if (feature == null || feature.isBlank()) {
String current = gitBranch(repo);
if (!current.startsWith("feature/")) {
throw new MojoException("ws:update-feature: "
+ repo.getName() + " is on '" + current
+ "', not a feature/* branch. Check out the feature"
+ " branch, or pass -Dfeature=<name>.");
}
feature = current.substring("feature/".length());
}
// Drive the shared loop with the repo's parent as root so
// new File(root, name) resolves back to the repo.
root = repo.getParentFile();
sorted = List.of(repo.getName());
}
validateFeatureName(feature);
String branchName = "feature/" + feature;
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.
// Per-member effect keyed by member name, so the working-set report
// can name every member's planned/applied effect — the aggregator
// included (the #763 gap), via WorkingSetReportTable below.
List<String> eligible = new ArrayList<>();
List<String> uncommitted = new ArrayList<>();
List<String> skipped = new ArrayList<>();
Map<String, String> effects = new LinkedHashMap<>();
for (String name : sorted) {
File dir = new File(root, name);
if (!new File(dir, ".git").exists()) {
skipped.add(name);
effects.put(name, "skipped (no git repo)");
continue;
}
String currentBranch = gitBranch(dir);
if (!currentBranch.equals(branchName)) {
skipped.add(name);
effects.put(name, "skipped (not on `" + branchName + "`)");
getLog().info(" " + Ansi.yellow("· ") + name
+ " — not on " + branchName + ", skipping");
continue;
}
String status = gitStatus(dir);
if (!status.isEmpty()) {
uncommitted.add(name);
effects.put(name, "uncommitted changes");
continue;
}
eligible.add(name);
}
// Check workspace root (workspace mode only — a bare repo's parent
// directory is not part of the working set).
if (workingSet.isWorkspace()
&& new File(root, ".git").exists()
&& !gitStatus(root).isEmpty()) {
uncommitted.add("workspace root");
}
// COORDINATING preflight (#780): the publish path refuses on an
// uncommitted working set so the merge it commits is attributable;
// -Dallow-uncommitted escapes. The draft is a preview (WARN) — it
// proceeds and only notes the uncommitted state.
if (!uncommitted.isEmpty()) {
if (publish && !allowUncommitted()) {
StringBuilder 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"
+ " (or pass -Dallow-uncommitted).");
throw new MojoException(sb.toString());
}
getLog().warn(" Uncommitted changes in " + uncommitted
+ " — preview only; commit before ws:update-feature-publish.");
}
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. In draft, preview
// read-only — never mutate local main (#570). See ike-issues#284.
if (publish) {
RefreshMainSupport.refreshOrThrow(root, eligible, targetBranch, getLog());
} else {
RefreshMainSupport.previewRefresh(root, eligible, targetBranch, getLog());
}
// Show how far behind each subproject is + record its effect. The
// working-set report (below) reads these effects per member; the
// -draft variant phrases them as PLANNED, -publish as APPLIED.
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);
effects.put(name, "up to date with `" + targetBranch + "`");
} 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");
effects.put(name, "clean update expected ("
+ behind.size() + " behind, " + ahead.size()
+ " ahead)");
} 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"
+ " " + WsGoal.UPDATE_FEATURE_PUBLISH.qualified());
effects.put(name, predicted.size()
+ " conflict(s) expected: "
+ String.join(", ", predicted));
}
} 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");
effects.put(name, "merged `" + targetBranch + "` ("
+ behind.size() + " behind, " + ahead.size()
+ " ahead)");
}
} 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 "
+ WsGoal.UPDATE_FEATURE_PUBLISH.qualified());
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 "
+ WsGoal.UPDATE_FEATURE_PUBLISH.qualified());
} else {
getLog().info(" Updated: " + eligible.size() + " subproject(s)"
+ " | Skipped: " + skipped.size());
}
getLog().info("");
// Write report. One WorkingSetReportTable.Row per working-set member —
// the aggregator (workspace root) included — so the table shows the
// root's version/branch/SHA that a subproject-only table hid (#763).
// Each member's Effect comes from the eligibility/merge loops above;
// a member with no recorded effect (e.g. the aggregator, which this
// goal does not merge) is reported as skipped.
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph("**Branch:** `" + branchName
+ "` ← `" + targetBranch + "`");
List<WorkingSetReportTable.Row> rows = new ArrayList<>();
for (WorkingSet.Member member : resolveWorkingSet().members()) {
File dir = member.directory().toFile();
String version = readPomVersion(dir); // the aggregator too (#763)
String branch = gitBranch(dir);
String sha = gitShortSha(dir);
String effect = effects.get(member.name());
if (effect == null) {
// No effect recorded for this member. The aggregator is not on
// the feature branch (this goal merges only the subprojects),
// so it is skipped; same for any member outside the eligible
// loop.
effect = member.isAggregator()
? "skipped (aggregator)"
: "skipped (not on `" + branchName + "`)";
}
rows.add(new WorkingSetReportTable.Row(
member, version, branch, sha, effect));
}
WorkingSetReportTable.render(report, "Working set", rows);
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());
}
/**
* Read a member's POM {@code <version>}, returning the
* {@link WorkingSetReportTable#NONE} placeholder when the POM is absent or
* unreadable. Applied uniformly to every working-set member — the
* aggregator (workspace root) included — which is the #763 fix: a
* subproject-only table never surfaced the root's version.
*
* @param dir the member's working-tree directory
* @return the POM version, or {@link WorkingSetReportTable#NONE}
*/
private String readPomVersion(File dir) {
File pom = new File(dir, "pom.xml");
if (!pom.isFile()) {
return WorkingSetReportTable.NONE;
}
try {
return ReleaseSupport.readPomVersion(pom);
} catch (MojoException e) {
return WorkingSetReportTable.NONE;
}
}
}