WsAlignDraftMojo.java
package network.ike.plugin.ws;
import network.ike.plugin.ws.preflight.Preflight;
import network.ike.plugin.ws.preflight.PreflightCondition;
import network.ike.plugin.ws.preflight.PreflightContext;
import network.ike.plugin.ws.preflight.PreflightResult;
import network.ike.plugin.ws.reconcile.AlignmentReconciler;
import network.ike.plugin.ws.reconcile.DriftReport;
import network.ike.plugin.ws.reconcile.ReconcilerOptions;
import network.ike.plugin.ws.reconcile.WorkspaceContext;
import network.ike.plugin.ws.vcs.VcsOperations;
import network.ike.workspace.ManifestReader;
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.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Preview inter-subproject dependency, plugin, and parent version
* alignment across every cloned subproject.
*
* <p>This is the {@code ws:align-draft} goal — a thin wrapper over
* {@link AlignmentReconciler}. The same reconciler is iterated from
* {@code ws:scaffold-{draft,publish}} (#393); this standalone goal
* exists for the "I detected misalignment, run only that" use case
* where iterating the full convergence pass would be needlessly
* broad.
*
* <p>This goal also serves as the designated entry point for the
* legacy schema migration ({@code components:} → {@code subprojects:},
* #150): it calls
* {@link ManifestReader#migrateLegacySchemaIfNeeded} before reading
* the graph so old manifests are rewritten in place.
*
* <p>Branch reconciliation (the rare "manifest ↔ git state" recovery
* operation that used to share this goal as
* {@code -Dscope=branches}) moved to
* {@link WsReconcileBranchesDraftMojo} ({@code ws:reconcile-branches-draft})
* per the ike-issues#200 split. The {@code scope} parameter on this
* goal is retained only to emit a clear migration error for users
* with muscle memory.
*
* <pre>{@code
* mvn ws:align-draft # preview POM version updates
* mvn ws:align-publish # apply
* }</pre>
*
* @see AlignmentReconciler
* @see WsReconcileBranchesDraftMojo
*/
@Mojo(name = "align-draft", projectRequired = false, aggregator = true)
public class WsAlignDraftMojo extends AbstractWorkspaceMojo {
/**
* Scope note prepended to the align report. The align goals are
* by-nature subproject-scoped: they reconcile inter-subproject
* dependency, plugin, and parent versions, and the aggregator
* (workspace root) is not a convergence node — it has no inbound
* inter-subproject coordinate to align. Stating this explicitly
* (rather than silently omitting the aggregator row that mutating
* working-set reports carry per epic #764/#767) keeps the omission
* visible to a reader of the report.
*/
static final String WORKING_SET_NOTE =
"_Working set: subprojects only — the aggregator is not a "
+ "dependency-convergence node._\n\n";
/**
* When true, apply changes; when false (default), preview only.
* Package-private so {@link WsAlignPublishMojo} can flip it.
*/
@Parameter(property = "publish", defaultValue = "false")
boolean publish;
/**
* Migration aid for the ike-issues#200 two-axis split. The only
* accepted value is {@code poms} (the new default). Passing
* {@code branches} or {@code all} throws with a pointer to
* {@code ws:reconcile-branches-{draft,publish}}, so users with
* muscle memory get a clear error rather than silent breakage.
*/
@Parameter(property = "scope", defaultValue = "poms")
String scope;
/** Creates this goal instance. */
public WsAlignDraftMojo() {}
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
boolean draft = !publish;
// Migration gate (ike-issues#200): the align goals are POM-only.
// Branch reconciliation moved to ws:reconcile-branches-{draft,publish}.
if ("branches".equals(scope) || "all".equals(scope)) {
String invokedGoal = (publish ? WsGoal.ALIGN_PUBLISH
: WsGoal.ALIGN_DRAFT).qualified();
throw new MojoException(
invokedGoal + " no longer supports -Dscope=" + scope
+ " (ike-issues#200). Branch reconciliation moved to:\n"
+ " mvn " + (publish ? WsGoal.RECONCILE_BRANCHES_PUBLISH
: WsGoal.RECONCILE_BRANCHES_DRAFT).qualified()
+ "\n " + invokedGoal
+ " is now POM-only — drop -Dscope=.");
}
if (!"poms".equals(scope)) {
throw new MojoException(
"Invalid scope '" + scope + "' — expected poms");
}
// #150 migration: ws:align-publish (and the draft variant) is the
// designated entry point that rewrites legacy schemas in place.
// Every other reader hits the hard-cut in ManifestReader.read, so
// users on an old workspace see a message pointing them at
// ws:align-publish first. Do this BEFORE loadGraph() so graph
// construction sees the migrated content.
Path manifestPath = resolveManifest();
ManifestReader.migrateLegacySchemaIfNeeded(
manifestPath, msg -> getLog().info(" " + msg));
WorkspaceGraph graph = loadGraph();
File root = workspaceRoot();
getLog().info("");
getLog().info("IKE Workspace Align — reconcile inter-subproject versions");
getLog().info("══════════════════════════════════════════════════════════════");
if (draft) {
getLog().info(" (draft — no files will be modified)");
}
getLog().info("");
// POM alignment requires clean working trees (#132) so the
// rewrite commit doesn't bundle unrelated edits.
List<String> sorted = graph.topologicalSort();
PreflightResult preflight = Preflight.of(
List.of(PreflightCondition.WORKING_TREE_CLEAN),
PreflightContext.of(root, graph, sorted));
// Gate the REQUIRE_UNMODIFIED preflight (#780): draft previews;
// -Dallow-uncommitted relaxes it; -Ddefer-commit hands the clean-state
// guarantee to the cascade caller (which preflights + commits itself).
WsGoal alignGoal = publish ? WsGoal.ALIGN_PUBLISH : WsGoal.ALIGN_DRAFT;
if (draft || allowUncommitted() || deferCommit()) {
preflight.warnIfFailed(getLog(), alignGoal);
} else {
preflight.requirePassed(alignGoal);
}
WorkspaceContext ctx = new WorkspaceContext(
root, manifestPath, graph,
readReconcilerOptions(), getLog());
AlignmentReconciler reconciler = new AlignmentReconciler();
String content;
if (draft) {
DriftReport report = reconciler.detect(ctx);
printDriftReport(report);
content = WORKING_SET_NOTE + report.toMarkdown();
} else {
// #543: capture the drift plan BEFORE applying so the
// publish report can list per-POM version changes (and
// distinguish "no-op, workspace already aligned" from a
// 50-file rewrite). detect() is a pure read; apply()
// re-runs the same computePlan internally — same numbers,
// some duplicated work, but the alternative would be a
// bigger refactor of AlignmentReconciler.
DriftReport report = reconciler.detect(ctx);
// Capture the exact POMs the plan will rewrite BEFORE applying, so
// the commit below touches only align's own output (#780,
// IN_ISOLATION) — never a concurrently-modified file. After apply
// the drift is resolved and the plan would be empty. Skipped under
// -Ddefer-commit, where the cascade caller owns the commit.
Map<String, List<String>> authoredPoms = deferCommit()
? Map.of()
: reconciler.changedPomsBySubproject(ctx);
reconciler.apply(ctx);
if (!deferCommit()) {
commitAlignedPoms(root, authoredPoms);
}
content = WORKING_SET_NOTE + report.toAppliedMarkdown();
}
getLog().info("");
return new WorkspaceReportSpec(
publish ? WsGoal.ALIGN_PUBLISH : WsGoal.ALIGN_DRAFT, content);
}
/**
* Commit the POMs the reconciler rewrote, in isolation (#780,
* {@link AuthoredCommit#IN_ISOLATION}). Each owning subproject is its own
* git repo, so this commits per subproject, passing the EXACT relative POM
* paths the plan changed (captured before {@code apply}) to
* {@link VcsOperations#commitPaths} — never a {@code git status} re-scan, so
* a concurrently-modified file is never swept in. Per-repo failures warn
* (one bad repo must not strand the others). Under {@code -Ddefer-commit}
* the caller owns the commit and {@code authoredPoms} is empty.
*
* @param root the workspace root
* @param authoredPoms subproject name → its changed POM relative paths
*/
private void commitAlignedPoms(File root,
Map<String, List<String>> authoredPoms) {
for (Map.Entry<String, List<String>> entry : authoredPoms.entrySet()) {
String subproject = entry.getKey();
File repo = new File(root, subproject);
if (!new File(repo, ".git").exists()) {
continue;
}
List<String> poms = entry.getValue();
try {
VcsOperations.commitPaths(repo, getLog(),
"workspace: align inter-subproject versions"
+ "\n\nRefs: IKE-Network/ike-issues#780",
poms.toArray(new String[0]));
getLog().info(" ✓ committed " + poms.size()
+ " aligned pom(s) in " + subproject);
} catch (MojoException e) {
getLog().warn(" could not commit aligned poms in " + subproject
+ " — " + e.getMessage());
}
}
}
/**
* Collect Maven system properties into a {@link ReconcilerOptions}
* bag so the reconciler can query its opt-out flag
* ({@code -DupdateAlignment=false}).
*/
private static ReconcilerOptions readReconcilerOptions() {
Map<String, String> flags = new HashMap<>();
for (String name : System.getProperties().stringPropertyNames()) {
flags.put(name, System.getProperty(name));
}
return new ReconcilerOptions(flags);
}
/**
* Render a {@link DriftReport} for {@code ws:align-draft} output
* with the copy-paste opt-out command inline. Mirrors the format
* used by {@code ws:scaffold-draft}'s reconciler loop.
*/
private void printDriftReport(DriftReport report) {
if (!report.hasDrift()) {
getLog().info(" ✓ " + report.dimension());
return;
}
getLog().info(" ⚠ " + report.dimension());
if (!report.summary().isEmpty()) {
getLog().info(" " + report.summary());
}
for (String line : report.detailLines()) {
getLog().info(" " + line);
}
if (!report.defaultAction().isEmpty()) {
getLog().info(" Default: " + report.defaultAction());
}
if (!report.optOutCommand().isEmpty()) {
getLog().info(" Opt out: " + report.optOutCommand());
}
}
}