FeatureVersionReconciler.java
package network.ike.plugin.ws.reconcile;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.ws.WsGoal;
import network.ike.workspace.ManifestWriter;
import network.ike.workspace.Subproject;
import network.ike.workspace.VersionSupport;
import org.apache.maven.api.plugin.MojoException;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Reconciler that branch-qualifies each subproject's <em>own</em> Maven
* version to match the branch it tracks (IKE-Network/ike-issues#574).
*
* <p>On a feature branch every subproject's version must carry the
* branch slug ({@code <base>-<slug>-SNAPSHOT}) so feature builds are
* isolated from main SNAPSHOTs. {@code ws:feature-start} applies this
* once, at branch-creation; a subproject added <em>later</em> (via
* {@code ws:add}) — or otherwise left un-qualified — stays un-isolated
* and its feature build collides with its own main SNAPSHOT. This
* reconciler self-heals that on {@code scaffold-publish}.
*
* <p>Scope is each subproject's own {@code <version>} (its POM) plus the
* denormalized {@code version} field in {@code workspace.yaml}.
* Propagating the new version into <em>consumers'</em> dependency
* references is left to {@link AlignmentReconciler}, which runs after
* this one in {@link ReconcilerRegistry}.
*
* <p>The target branch is read from the manifest ({@code branch:} per
* subproject), not git, so the reconciler is deterministic and
* branch-coherent. It is a no-op for {@code main}-tracking subprojects
* and for members already qualified —
* {@link VersionSupport#branchQualifiedVersion} strips any existing slug
* before re-applying, so repeated runs converge.
*/
public class FeatureVersionReconciler implements Reconciler {
@Override
public String dimension() {
return "Feature-branch version qualification";
}
@Override
public String optOutFlag() {
return "updateFeatureVersions";
}
@Override
public DriftReport detect(WorkspaceContext ctx) {
List<Change> changes = computeChanges(ctx);
if (changes.isEmpty()) {
return DriftReport.noDrift(dimension());
}
List<String> detail = new ArrayList<>();
for (Change c : changes) {
detail.add(c.name() + ": " + c.before() + " → " + c.after()
+ " (" + c.branch() + ")");
}
String summary = changes.size()
+ " subproject version(s) not branch-qualified";
String action = "qualify each to its branch slug on scaffold-publish";
String optOut = "mvn " + WsGoal.SCAFFOLD_PUBLISH.qualified()
+ " -D" + optOutFlag() + "=false";
return new DriftReport(dimension(), true, summary, detail,
action, optOut);
}
@Override
public void apply(WorkspaceContext ctx) {
if (ctx.options().isOptedOut(optOutFlag())) {
ctx.log().info(" " + dimension() + ": skipped (opted out via -D"
+ optOutFlag() + "=false)");
return;
}
List<Change> changes = computeChanges(ctx);
if (changes.isEmpty()) {
return;
}
try {
// Rewrite each subproject's own POM <version>, then mirror the
// new value into workspace.yaml's denormalized version field in
// a single pass (FieldNormalizationReconciler runs *before* this
// one, so it won't re-sync the manifest from the rewritten POM).
Path manifestPath = ctx.manifestPath();
String yaml = Files.readString(manifestPath, StandardCharsets.UTF_8);
for (Change c : changes) {
File pom = new File(
new File(ctx.workspaceRoot(), c.name()), "pom.xml");
rewriteOwnVersion(pom.toPath(), c.before(), c.after());
yaml = ManifestWriter.updateSubprojectField(
yaml, c.name(), "version", c.after());
}
Files.writeString(manifestPath, yaml, StandardCharsets.UTF_8);
ctx.log().info(" " + dimension() + ": qualified "
+ changes.size() + " subproject version(s)");
} catch (IOException e) {
throw new MojoException(
"Feature-version qualification failed: " + e.getMessage(), e);
}
}
// ── Change computation (shared by detect and apply) ─────────────
/**
* One subproject's version qualification.
*
* @param name the subproject name
* @param branch the branch it tracks (from the manifest)
* @param before its current POM version
* @param after the branch-qualified version
*/
private record Change(String name, String branch,
String before, String after) {}
private List<Change> computeChanges(WorkspaceContext ctx) {
List<Change> changes = new ArrayList<>();
for (Map.Entry<String, Subproject> entry
: ctx.graph().manifest().subprojects().entrySet()) {
String name = entry.getKey();
String branch = entry.getValue().branch();
// Only feature branches qualify; main keeps the plain version.
if (branch == null || branch.isBlank() || "main".equals(branch)) {
continue;
}
File pom = new File(new File(ctx.workspaceRoot(), name), "pom.xml");
if (!pom.exists()) {
continue;
}
String current;
try {
current = ReleaseSupport.readPomVersion(pom);
} catch (MojoException e) {
ctx.log().warn(" " + name + ": cannot read version — "
+ e.getMessage());
continue;
}
if (current == null || current.isBlank()) {
continue;
}
String qualified =
VersionSupport.branchQualifiedVersion(current, branch);
if (!qualified.equals(current)) {
changes.add(new Change(name, branch, current, qualified));
}
}
return changes;
}
/**
* Rewrite a POM's own {@code <version>} from {@code oldVersion} to
* {@code newVersion}. Searches past the {@code <parent>} block first,
* so a parent whose version coincidentally equals {@code oldVersion}
* is not mistaken for the project version (the project {@code <version>}
* precedes {@code <dependencies>}, so the first match after
* {@code </parent>} is the project's own). Public — also reused by
* {@code ws:add}'s add-time qualification (#574).
*
* @param pom the POM path
* @param oldVersion the current project version
* @param newVersion the qualified version
* @throws IOException if the POM cannot be read or written
*/
public static void rewriteOwnVersion(Path pom, String oldVersion,
String newVersion) throws IOException {
String content = Files.readString(pom, StandardCharsets.UTF_8);
int searchFrom = 0;
int parentEnd = content.indexOf("</parent>");
if (parentEnd >= 0) {
searchFrom = parentEnd + "</parent>".length();
}
String needle = "<version>" + oldVersion + "</version>";
int idx = content.indexOf(needle, searchFrom);
if (idx < 0) {
return;
}
String updated = content.substring(0, idx)
+ "<version>" + newVersion + "</version>"
+ content.substring(idx + needle.length());
if (!updated.equals(content)) {
Files.writeString(pom, updated, StandardCharsets.UTF_8);
}
}
}