WsReconcileBranchesDraftMojo.java
package network.ike.plugin.ws;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.ws.vcs.VcsOperations;
import network.ike.workspace.ManifestWriter;
import network.ike.workspace.Subproject;
import network.ike.workspace.WorkingSet;
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.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Reconcile {@code workspace.yaml} branch fields against on-disk git
* state (preview).
*
* <p>This is the {@code ws:reconcile-branches-draft} goal — recovery /
* rare-use, separated from the {@code ws:align-{draft,publish}}
* POM-axis daily driver per ike-issues#200's two-axis split (Option B).
* Each goal name now describes its audience: {@code ws:align-draft} /
* {@code ws:align-publish} (backed by
* {@link network.ike.plugin.ws.reconcile.AlignmentReconciler}) is the
* safe daily POM convergence; {@code ws:reconcile-branches-draft} /
* {@code -publish} is the branch-state recovery operation that runs
* when something has already gone wrong.
*
* <p>Three directions are supported via {@code -Dfrom=...}:
*
* <ul>
* <li>{@code repos} (default) — read each subproject's actual branch
* and update {@code workspace.yaml} to match.</li>
* <li>{@code manifest} — {@code git checkout} each subproject to the
* branch declared in {@code workspace.yaml}.</li>
* <li>{@code workspace-head} — the workspace repo's HEAD is
* authoritative; reconcile both YAML fields <em>and</em> on-disk
* branches to that single value (ike-issues#287).</li>
* </ul>
*
* <pre>{@code
* mvn ws:reconcile-branches-draft # report only (from=repos)
* mvn ws:reconcile-branches-publish # apply (from=repos)
* mvn ws:reconcile-branches-publish -Dfrom=manifest # checkout repos to declared branches
* mvn ws:reconcile-branches-publish -Dfrom=workspace-head -Dforce=true
* }</pre>
*/
@Mojo(name = "reconcile-branches-draft", projectRequired = false, aggregator = true)
public class WsReconcileBranchesDraftMojo extends AbstractWorkspaceMojo {
/**
* When true, apply changes; when false, draft (preview only).
* Package-private so {@link WsReconcileBranchesPublishMojo} can
* flip it in its {@code execute()} override.
*/
@Parameter(property = "publish", defaultValue = "false")
boolean publish;
/**
* Legacy scope parameter — retained for compatibility with the
* pre-ike-issues#200 era. Branch reconciliation is the only valid
* scope for this goal; any other value is rejected with an error
* pointing the user at {@code ws:align-draft}.
*
* <p>The default is {@code branches} so users do not need to pass
* it explicitly.
*/
@Parameter(property = "scope", defaultValue = "branches")
String scope;
/**
* Branch-sync direction.
*
* <ul>
* <li>{@code repos} (default) — read actual branches and update
* {@code workspace.yaml}.</li>
* <li>{@code manifest} — run {@code git checkout} per subproject
* so repos match the yaml.</li>
* <li>{@code workspace-head} — the workspace repo's current git
* branch is authoritative; reconcile both the {@code branch:}
* fields in {@code workspace.yaml} <em>and</em> each
* subproject's on-disk branch to that single value
* (ike-issues#287).</li>
* </ul>
*/
@Parameter(property = "from", defaultValue = "repos")
String from;
/**
* When true, allow branch checkout against subprojects with
* uncommitted changes. Consulted with
* {@code from=manifest|workspace-head}. Default {@code false}.
*/
@Parameter(property = "force", defaultValue = "false")
boolean force;
/** Creates this goal instance. */
public WsReconcileBranchesDraftMojo() {}
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
// Defensive scope validation: this goal is branch-only. Any
// other -Dscope= value is almost certainly a user invoking the
// wrong goal (e.g., they meant ws:align-draft).
if (!"branches".equals(scope) && !"all".equals(scope)) {
throw new MojoException(
"ws:reconcile-branches only supports -Dscope=branches "
+ "(default). For POM alignment, use "
+ (publish ? WsGoal.ALIGN_PUBLISH
: WsGoal.ALIGN_DRAFT).qualified() + ".");
}
if (!"repos".equals(from)
&& !"manifest".equals(from)
&& !"workspace-head".equals(from)) {
throw new MojoException(
"Invalid from '" + from
+ "' — expected repos|manifest|workspace-head");
}
boolean draft = !publish;
WorkspaceGraph graph = loadGraph();
File root = workspaceRoot();
Path manifestPath = resolveManifest();
// COORDINATING preflight (#780): only from=manifest / from=workspace-head
// CHECK OUT the coherent branch in each subproject, so for those modes
// every subproject tree must be unmodified to reconcile onto it. The
// default from=repos only READS each branch and writes the workspace
// root's workspace.yaml — it touches no subproject tree — so it is
// exempt. Two escapes bypass the refusal: -Dforce (check out over the
// modified tree, the documented override) and -Dallow-uncommitted (skip
// the affected subprojects, the per-subproject path below). Draft
// previews, never refuses. The workspace root is deliberately not
// checked — its workspace.yaml is reconcile's own input/output.
if (publish && !allowUncommitted() && !force
&& !"repos".equals(from)) {
List<String> uncommitted = new ArrayList<>();
for (String name : graph.manifest().subprojects().keySet()) {
File dir = new File(root, name);
if (new File(dir, ".git").exists() && !gitStatus(dir).isEmpty()) {
uncommitted.add(name);
}
}
if (!uncommitted.isEmpty()) {
throw new MojoException(WsGoal.RECONCILE_BRANCHES_PUBLISH.qualified()
+ ": " + uncommitted.size() + " subproject(s) have "
+ "uncommitted changes and cannot be reconciled onto the "
+ "coherent branch: " + uncommitted + "\nCommit them, pass "
+ "-Dforce to check out over the changes, or "
+ "-Dallow-uncommitted to skip the affected subprojects.");
}
}
getLog().info("");
getLog().info("IKE Workspace Reconcile Branches — "
+ describeFromMode());
getLog().info("══════════════════════════════════════════════════════════════");
if (draft) {
getLog().info(" (draft — no files will be modified)");
}
getLog().info("");
BranchChanges result = alignBranches(graph, root, manifestPath, draft);
int totalChanges = result.changes().size();
getLog().info("");
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph("**Mode:** " + describeFromMode()
+ (draft ? " _(draft — no changes made)_" : ""));
// Working-set table — one row per member, the aggregator
// (workspace root) included, so the staleness a subproject-only
// list hid (IKE-Network/ike-issues#763, #764) is visible. The
// Effect column phrases the branch reconcile as PLANNED in draft
// mode, APPLIED in publish mode.
WorkingSetReportTable.render(report, "Working set",
workingSetRows(result.effects()));
if (totalChanges == 0 && result.skipped().isEmpty()) {
getLog().info(" Nothing to reconcile ✓");
report.paragraph("Nothing to reconcile — branches already coherent.");
} else {
report.paragraph("**" + totalChanges + "** branch change(s) "
+ (draft ? "would be applied" : "applied") + ".");
if (!result.changes().isEmpty()) {
report.section(draft ? "Would change" : "Changed");
for (String c : result.changes()) report.bullet(c);
}
if (!result.skipped().isEmpty()) {
report.section("Skipped (uncommitted)");
for (String s : result.skipped()) report.bullet(s);
}
if (draft) {
getLog().info(" " + totalChanges + " change(s) would be applied");
getLog().info(" Use "
+ WsGoal.RECONCILE_BRANCHES_PUBLISH.qualified() + " to apply.");
String cmd = "mvn " + WsGoal.RECONCILE_BRANCHES_PUBLISH.qualified();
if (!"repos".equals(from)) cmd += " -Dfrom=" + from;
if (!result.skipped().isEmpty()) cmd += " -Dforce=true";
report.paragraph("Apply with `" + cmd + "`.");
} else {
getLog().info(" Applied " + totalChanges + " change(s)");
}
}
getLog().info("");
return new WorkspaceReportSpec(
publish ? WsGoal.RECONCILE_BRANCHES_PUBLISH
: WsGoal.RECONCILE_BRANCHES_DRAFT,
report.build());
}
/**
* Per-branch reconcile outcome carried into the report: one
* human-readable line per branch that differs, plus any subprojects
* skipped for uncommitted changes (no {@code -Dforce}), plus the
* per-member {@code Effect} cell for the working-set table.
*
* <p>{@code effects} is keyed by working-set member name (subproject
* name, or the aggregator's directory name) and holds what the goal
* did or will do to that member — {@code "branch field → main"},
* {@code "checkout → develop"}, {@code "skipped (uncommitted)"}, etc.
* Members absent from the map were untouched and render as
* {@code "unchanged"}.
*
* @param changes one line per branch change (markdown)
* @param skipped one line per subproject skipped (uncommitted)
* @param effects per-member {@code Effect} cell (member name → effect)
*/
private record BranchChanges(List<String> changes, List<String> skipped,
Map<String, String> effects) {
/** Empty outcome — nothing changed and no per-member effects. */
static BranchChanges empty() {
return new BranchChanges(List.of(), List.of(),
new LinkedHashMap<>());
}
}
/**
* Dispatch on {@code -Dfrom=...} to the right branch-reconcile
* direction.
*
* @return the per-branch changes applied, or that would be applied
* in draft mode, plus any skipped subprojects
*/
private BranchChanges alignBranches(WorkspaceGraph graph, File root,
Path manifestPath, boolean draft)
throws MojoException {
return switch (from) {
case "manifest" ->
alignBranchesFromManifest(graph, root, draft);
case "workspace-head" ->
alignBranchesFromWorkspaceHead(graph, root, manifestPath,
draft);
default ->
alignBranchesFromRepos(graph, root, manifestPath, draft);
};
}
/** Human-readable label for the active {@code from=...} mode. */
private String describeFromMode() {
return switch (from) {
case "manifest" -> "manifest → repos (git checkout)";
case "workspace-head" ->
"workspace HEAD → manifest + repos (authoritative branch)";
default -> "repos → manifest (update yaml)";
};
}
/**
* Read actual branches from each cloned subproject and update
* {@code workspace.yaml} so the declared branch fields match
* reality.
*/
private BranchChanges alignBranchesFromRepos(WorkspaceGraph graph, File root,
Path manifestPath, boolean draft)
throws MojoException {
Map<String, String> updates = new LinkedHashMap<>();
List<String> changes = new ArrayList<>();
Map<String, String> effects = new LinkedHashMap<>();
for (Map.Entry<String, Subproject> entry
: graph.manifest().subprojects().entrySet()) {
String name = entry.getKey();
Subproject subproject = entry.getValue();
File dir = new File(root, name);
if (!new File(dir, ".git").exists()) continue;
String actual = gitBranch(dir);
String declared = subproject.branch();
if (actual.equals(declared)) continue;
updates.put(name, actual);
changes.add("`" + name + "` (yaml): `" + declared
+ "` → `" + actual + "`");
effects.put(name, (draft ? "yaml branch → `" : "yaml branch set → `")
+ actual + "`");
getLog().info(" branch: " + name + ": " + declared
+ " → " + actual + (draft ? " (draft)" : ""));
}
if (updates.isEmpty()) {
getLog().info(" Branches: yaml already matches repos ✓");
return BranchChanges.empty();
}
if (!draft) {
try {
ManifestWriter.updateBranches(manifestPath, updates);
getLog().info(" Branches: updated workspace.yaml ("
+ updates.size() + " change(s))");
File wsRoot = manifestPath.getParent().toFile();
if (new File(wsRoot, ".git").exists()) {
// Commit workspace.yaml in isolation (#780): only the path
// this goal authored, never the whole index.
VcsOperations.commitPaths(wsRoot, getLog(),
"workspace: align branch fields from repos"
+ "\n\nRefs: IKE-Network/ike-issues#780",
"workspace.yaml");
}
} catch (IOException e) {
throw new MojoException(
"Failed to update workspace.yaml: " + e.getMessage(), e);
}
}
return new BranchChanges(changes, List.of(), effects);
}
/**
* Reconcile every subproject's {@code branch:} field <em>and</em>
* on-disk git branch to the workspace repo's current HEAD.
*
* <p>This is the symmetric repair to {@code ws:add}'s
* branch-coherence rule (ike-issues#286): the workspace repo's
* branch is authoritative, and this mode brings everything else
* (YAML state, on-disk state) into agreement with it.
*
* <p>Behavior per subproject:
* <ol>
* <li>If the YAML {@code branch:} != workspace HEAD → queue YAML
* update.</li>
* <li>If the on-disk branch != workspace HEAD → queue checkout.
* Subprojects with uncommitted changes are skipped unless
* {@code -Dforce=true} (same semantics as
* {@code from=manifest}).</li>
* </ol>
*
* <p>If a subproject's local repo doesn't yet have the workspace
* branch, {@code git checkout} will fall through to creating it
* from {@code origin/<branch>} (the standard tracking-branch path).
* If the branch isn't on origin either, the checkout fails — that
* case is the {@code ws:add}'s territory; this mode does not push
* new branches to subproject origins.
*
* @return the per-branch changes (applied, or that would be in draft
* mode) plus any skipped subprojects
* @throws MojoException if the workspace dir is not a git repo, or
* any individual checkout fails
*/
private BranchChanges alignBranchesFromWorkspaceHead(WorkspaceGraph graph,
File root, Path manifestPath,
boolean draft) {
File wsRoot = manifestPath.getParent().toFile();
if (!new File(wsRoot, ".git").exists()) {
throw new MojoException(
"from=workspace-head requires the workspace directory to be "
+ "a git repository. " + wsRoot.getAbsolutePath()
+ " has no .git directory.");
}
String wsBranch = gitBranch(wsRoot);
if (wsBranch == null || wsBranch.isBlank() || "unknown".equals(wsBranch)) {
throw new MojoException(
"Could not read the workspace repo's current branch. "
+ "Ensure HEAD points at a named branch (not a "
+ "detached HEAD).");
}
getLog().info(" Workspace HEAD: " + wsBranch);
Map<String, String> yamlUpdates = new LinkedHashMap<>();
List<String> changes = new ArrayList<>();
List<String> skipped = new ArrayList<>();
Map<String, String> effects = new LinkedHashMap<>();
// The workspace repo (aggregator) is the authority in this mode —
// record that so its working-set row reads as the branch source,
// not a no-op.
effects.put(aggregatorName(root), "authority — HEAD `" + wsBranch + "`");
int checkoutsPlanned = 0;
int checkoutsApplied = 0;
int skippedDirty = 0;
for (Map.Entry<String, Subproject> entry
: graph.manifest().subprojects().entrySet()) {
String name = entry.getKey();
Subproject subproject = entry.getValue();
File dir = new File(root, name);
// YAML reconciliation runs whether or not the repo is cloned.
String declared = subproject.branch();
if (declared == null || !declared.equals(wsBranch)) {
yamlUpdates.put(name, wsBranch);
changes.add("`" + name + "` (yaml): `"
+ (declared == null ? "(unset)" : declared)
+ "` → `" + wsBranch + "`");
effects.put(name, (draft ? "yaml branch → `" : "yaml branch set → `")
+ wsBranch + "`");
getLog().info(" branch: " + name + " (yaml): "
+ (declared == null ? "(unset)" : declared)
+ " → " + wsBranch + (draft ? " (draft)" : ""));
}
// Checkout reconciliation only applies to cloned subprojects.
if (!new File(dir, ".git").exists()) continue;
String actual = gitBranch(dir);
if (wsBranch.equals(actual)) continue;
String status = gitStatus(dir);
if (!status.isEmpty() && !force) {
getLog().warn(" ⚠ " + name + ": uncommitted changes — skipping"
+ " checkout (pass -Dforce=true to override)");
skipped.add("`" + name + "` — uncommitted (use -Dforce=true)");
effects.put(name, "skipped (uncommitted)");
skippedDirty++;
continue;
}
checkoutsPlanned++;
changes.add("`" + name + "` (repo): `" + actual
+ "` → `" + wsBranch + "`");
effects.merge(name,
(draft ? "checkout → `" : "checked out → `") + wsBranch + "`",
(yaml, checkout) -> yaml + ", " + checkout);
getLog().info(" branch: " + name + " (repo): " + actual
+ " → " + wsBranch + (draft ? " (draft)" : ""));
if (!draft) {
if (localBranchExists(dir, wsBranch)
|| originBranchExists(dir, wsBranch)) {
ReleaseSupport.exec(dir, getLog(),
"git", "checkout", wsBranch);
} else {
getLog().info(" " + name + " has no '" + wsBranch
+ "' locally or on origin — creating from "
+ actual + ".");
ReleaseSupport.exec(dir, getLog(),
"git", "checkout", "-b", wsBranch);
}
checkoutsApplied++;
}
}
if (yamlUpdates.isEmpty() && checkoutsPlanned == 0 && skippedDirty == 0) {
getLog().info(" Branches: workspace, manifest, and repos all agree ✓");
return new BranchChanges(List.of(), List.of(), effects);
}
if (!draft && !yamlUpdates.isEmpty()) {
try {
ManifestWriter.updateBranches(manifestPath, yamlUpdates);
getLog().info(" Branches: updated workspace.yaml ("
+ yamlUpdates.size() + " field(s))");
// Commit workspace.yaml in isolation (#780): only the path this
// goal authored, never the whole index.
VcsOperations.commitPaths(wsRoot, getLog(),
"workspace: align branch fields to " + wsBranch
+ "\n\nRefs: IKE-Network/ike-issues#780",
"workspace.yaml");
} catch (IOException e) {
throw new MojoException(
"Failed to update workspace.yaml: " + e.getMessage(), e);
}
}
if (draft) {
getLog().info(" Branches: " + (yamlUpdates.size() + checkoutsPlanned)
+ " change(s) would be applied"
+ (skippedDirty > 0
? " (" + skippedDirty + " skipped — uncommitted)"
: ""));
return new BranchChanges(changes, skipped, effects);
}
getLog().info(" Branches: " + yamlUpdates.size()
+ " yaml update(s), " + checkoutsApplied + " checkout(s), "
+ skippedDirty + " skipped (uncommitted)");
return new BranchChanges(changes, skipped, effects);
}
/**
* Whether {@code refs/heads/<branch>} exists in the local repo.
*/
private static boolean localBranchExists(File dir, String branch) {
try {
String out = ReleaseSupport.execCapture(dir,
"git", "branch", "--list", branch);
return out != null && !out.trim().isEmpty();
} catch (Exception e) {
return false;
}
}
/**
* Whether {@code refs/heads/<branch>} exists on the origin remote.
* Returns false on any failure (offline, no remote, etc.) — the
* caller treats that the same as \"doesn't exist on origin\" and
* creates the branch locally from the current HEAD.
*/
private static boolean originBranchExists(File dir, String branch) {
try {
String out = ReleaseSupport.execCapture(dir,
"git", "ls-remote", "--heads", "origin", branch);
return out != null && !out.trim().isEmpty();
} catch (Exception e) {
return false;
}
}
/**
* Read declared branches from {@code workspace.yaml} and run
* {@code git checkout} in each subproject whose current branch
* differs. Subprojects with uncommitted changes are skipped unless
* {@code -Dforce=true}.
*/
private BranchChanges alignBranchesFromManifest(WorkspaceGraph graph,
File root, boolean draft) {
int switched = 0;
int skippedDirty = 0;
int planned = 0;
List<String> changes = new ArrayList<>();
List<String> skipped = new ArrayList<>();
Map<String, String> effects = new LinkedHashMap<>();
for (Map.Entry<String, Subproject> entry
: graph.manifest().subprojects().entrySet()) {
String name = entry.getKey();
Subproject subproject = entry.getValue();
File dir = new File(root, name);
if (!new File(dir, ".git").exists()) continue;
String declared = subproject.branch();
if (declared == null) continue;
String actual = gitBranch(dir);
if (actual.equals(declared)) continue;
String status = gitStatus(dir);
if (!status.isEmpty() && !force) {
getLog().warn(" ⚠ " + name + ": uncommitted changes — skipping"
+ " (pass -Dforce=true to override)");
skipped.add("`" + name + "` — uncommitted (use -Dforce=true)");
effects.put(name, "skipped (uncommitted)");
skippedDirty++;
continue;
}
planned++;
changes.add("`" + name + "` (checkout): `" + actual
+ "` → `" + declared + "`");
effects.put(name, (draft ? "checkout → `" : "checked out → `")
+ declared + "`");
getLog().info(" branch: " + name + ": " + actual
+ " → " + declared + (draft ? " (draft)" : ""));
if (!draft) {
ReleaseSupport.exec(dir, getLog(),
"git", "checkout", declared);
switched++;
}
}
if (switched == 0 && skippedDirty == 0 && planned == 0) {
getLog().info(" Branches: repos already match yaml ✓");
} else if (draft) {
getLog().info(" Branches: " + planned
+ " subproject(s) would be switched");
} else {
getLog().info(" Branches: switched " + switched
+ ", skipped " + skippedDirty + " (uncommitted)");
}
return new BranchChanges(changes, skipped, effects);
}
/**
* The working-set aggregator's member name — the workspace-root
* directory name, matching what {@link #resolveWorkingSet()} reports
* for the aggregator. Used as the {@code effects} map key for the
* workspace repo in {@code from=workspace-head} mode.
*
* @param root the workspace root directory
* @return the aggregator member name
*/
private static String aggregatorName(File root) {
return root.getName();
}
/**
* Read the POM {@code <version>} for a working-set member directory,
* the aggregator included — surfacing the workspace-root version a
* subproject-only table hid (IKE-Network/ike-issues#763). Missing or
* unreadable POMs render as {@link WorkingSetReportTable#NONE}.
*
* @param dir the member 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;
}
}
/**
* Build the working-set table rows — one per
* {@link WorkingSet.Member}, the aggregator (workspace root)
* included — gathering each member's version, branch, and short SHA
* the same way (the root's {@code readPomVersion} is the #763 fix),
* and pairing it with the {@code Effect} this reconcile computed for
* that member (defaulting to {@code "unchanged"}).
*
* @param effects per-member effect cells, keyed by member name
* @return the rows for {@link WorkingSetReportTable#render}
*/
private List<WorkingSetReportTable.Row> workingSetRows(
Map<String, String> effects) {
List<WorkingSetReportTable.Row> rows = new ArrayList<>();
for (WorkingSet.Member member : resolveWorkingSet().members()) {
File dir = member.directory().toFile();
boolean isRepo = new File(dir, ".git").exists();
String version = readPomVersion(dir);
String branch = isRepo ? gitBranch(dir) : WorkingSetReportTable.NONE;
String sha = isRepo ? gitShortSha(dir) : WorkingSetReportTable.NONE;
String effect = effects.getOrDefault(member.name(), "unchanged");
rows.add(new WorkingSetReportTable.Row(
member, version, branch, sha, effect));
}
return rows;
}
}