AggregatorFeatureUpdate.java
package network.ike.plugin.ws;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.ws.vcs.VcsOperations;
import network.ike.workspace.Manifest;
import network.ike.workspace.ManifestException;
import network.ike.workspace.ManifestReader;
import network.ike.workspace.ManifestWriter;
import network.ike.workspace.Subproject;
import network.ike.workspace.VersionSupport;
import org.apache.maven.api.plugin.Log;
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.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Brings the working-set root (the aggregator) up to date with the target
* branch during {@code ws:update-feature}, the way the goal does for every
* subproject — with the one structural difference the manifest forces.
*
* <p>The root's feature branch carries the manifest with the subproject
* {@code branch:} fields pointing at the feature and, when feature-start
* qualified them, {@code version:} fields carrying the feature qualifier.
* The target branch rewrites the {@code sha:} pins on the adjacent lines at
* every checkpoint. Git sees two sides editing adjacent lines and reports a
* conflict, although the sides own disjoint fields: the feature owns the
* branch fields and the version qualifiers, the target owns everything else
* — the pins, the versions' numeric base, the derived edges, the prose.
*
* <p>This helper resolves that one conflict by construction. The feature
* side's {@code branch:} lines and qualified {@code version:} lines are set
* back to the merge base's, which removes the adjacency, and the manifest is
* then merged three ways as text: every other edit either side made — a
* {@code maven-version}, a {@code repo:}, prose — is kept, where taking the
* target's file whole dropped the feature's (IKE-Network/ike-issues#1159).
* The feature's branch fields and qualifiers are then re-applied onto the
* result. If the text merge still conflicts, both sides changed the same
* field and the root is reported for a human, like any other conflict: the
* merge is aborted, because a manifest left with conflict markers would
* break every later goal.
*
* <p>See IKE-Network/ike-issues#1099 and #1159.
*/
final class AggregatorFeatureUpdate {
private AggregatorFeatureUpdate() {}
/** What updating the root's feature branch from the target involves. */
sealed interface Assessment
permits UpToDate, ConflictFreeMerge, ManifestResolvable, Conflicting {
/** Commits on the target that the feature branch lacks. */
int behind();
/** Commits on the feature branch that the target lacks. */
int ahead();
}
/** Nothing to merge: every target commit is already on the feature branch. */
record UpToDate(int behind, int ahead) implements Assessment {}
/** The merge applies without a conflict. */
record ConflictFreeMerge(int behind, int ahead) implements Assessment {}
/**
* The manifest is the only conflicting file; its conflict is the
* adjacency of feature-owned and target-owned fields and is resolved by
* construction.
*/
record ManifestResolvable(int behind, int ahead, String manifestName)
implements Assessment {}
/** Files beyond the manifest conflict; the update needs a human. */
record Conflicting(int behind, int ahead, List<String> files)
implements Assessment {}
/**
* Assess the root without touching it: how far its feature branch is
* behind and ahead of the target, and whether merging would conflict —
* in the manifest alone (resolvable by construction) or elsewhere.
*
* @param root the working-set root, its own git repository
* @param manifestName the manifest's file name at the root
* @param featureBranch the feature branch the root is on
* @param targetRef the ref to merge in — the local target branch, or
* its remote-tracking ref for a read-only preview
* @return the assessment
* @throws MojoException if git cannot answer
*/
static Assessment assess(File root, String manifestName, String featureBranch,
String targetRef) throws MojoException {
int behind = VcsOperations.commitLog(root, featureBranch, targetRef).size();
int ahead = VcsOperations.commitLog(root, targetRef, featureBranch).size();
if (behind == 0) {
return new UpToDate(0, ahead);
}
List<String> predicted = VcsOperations.predictConflicts(root, featureBranch, targetRef);
if (predicted.isEmpty()) {
return new ConflictFreeMerge(behind, ahead);
}
if (predicted.size() == 1 && predicted.get(0).equals(manifestName)) {
return new ManifestResolvable(behind, ahead, manifestName);
}
return new Conflicting(behind, ahead, predicted);
}
/**
* Merge {@code targetBranch} into the root's checked-out feature branch,
* resolving a manifest-only conflict by construction. A conflict beyond
* the manifest is never started, or is aborted if the prediction missed
* it, so the root is left exactly as found.
*
* @param root the working-set root, its own git repository, on
* {@code featureBranch} with no uncommitted changes
* @param manifestPath the manifest inside {@code root}
* @param featureSide the manifest as the feature branch has it, read
* before the merge
* @param featureBranch the feature branch the root is on
* @param targetBranch the branch merged in, already refreshed
* @param log Maven logger
* @return what was done
* @throws MojoException on a git or manifest failure other than the
* merge conflict itself
*/
static Assessment apply(File root, Path manifestPath, Manifest featureSide,
String featureBranch, String targetBranch, Log log)
throws MojoException {
String manifestName = manifestPath.getFileName().toString();
Assessment assessment = assess(root, manifestName, featureBranch, targetBranch);
if (assessment instanceof UpToDate || assessment instanceof Conflicting) {
return assessment;
}
String message = "update: merge " + targetBranch + " into " + featureBranch;
try {
VcsOperations.mergeNoFf(root, log, targetBranch, message);
return new ConflictFreeMerge(assessment.behind(), assessment.ahead());
} catch (MojoException mergeStopped) {
List<String> conflicts = VcsOperations.conflictingFiles(root);
if (conflicts.size() != 1 || !conflicts.get(0).equals(manifestName)) {
// The prediction missed something, or git failed outright.
// Leave the root as found: a half-merged root — its manifest
// holding conflict markers — would break every later goal.
VcsOperations.mergeAbortQuiet(root, log);
if (conflicts.isEmpty()) {
throw mergeStopped;
}
return new Conflicting(assessment.behind(), assessment.ahead(), conflicts);
}
try {
if (!resolveManifest(root, manifestPath, featureSide, featureBranch, log)) {
// Both sides changed the same manifest field: a real
// conflict, reported like any other (#1159).
VcsOperations.mergeAbortQuiet(root, log);
return new Conflicting(assessment.behind(), assessment.ahead(),
List.of(manifestName));
}
ReleaseSupport.exec(root, log, "git", "add", "--", manifestName);
if (!VcsOperations.conflictingFiles(root).isEmpty()) {
throw new MojoException(manifestName
+ " still reads as conflicted after the resolution");
}
VcsOperations.commit(root, log, message);
} catch (IOException | ManifestException | MojoException e) {
VcsOperations.mergeAbortQuiet(root, log);
throw new MojoException("Could not resolve the " + manifestName
+ " conflict in the workspace root: " + e.getMessage(), e);
}
return new ManifestResolvable(assessment.behind(), assessment.ahead(),
manifestName);
}
}
/**
* Resolve the manifest conflict by construction (IKE-Network/ike-issues#1099,
* #1159).
*
* <p>The merge's own three versions of the manifest are read from the
* index: base, feature (ours) and target (theirs). On the feature side,
* each subproject's {@code branch:} line that names the feature branch,
* and each {@code version:} line carrying the feature qualifier, is set
* back to the base's line, so the feature-owned edits no longer sit next
* to the target's {@code sha:} pins. The three are then merged as text
* with {@code git merge-file}, which keeps every other edit from either
* side. Onto the result, the feature's {@code branch:} fields are
* re-applied for every subproject the feature had on the feature branch,
* and the feature qualifier onto the merged version for every subproject
* whose feature-side version carried it. A subproject the target no
* longer declares contributes nothing; one the target added keeps the
* target's fields.
*
* @return {@code true} when resolved; {@code false} when the text merge
* still conflicts, meaning both sides changed the same field
*/
private static boolean resolveManifest(File root, Path manifestPath, Manifest featureSide,
String featureBranch, Log log)
throws IOException, MojoException {
String manifestName = manifestPath.getFileName().toString();
String base = indexStage(root, 1, manifestName);
String ours = indexStage(root, 2, manifestName);
String theirs = indexStage(root, 3, manifestName);
String neutral = withBaseFeatureFields(ours, base, featureSide, featureBranch);
Optional<String> merged = mergeText(root, neutral, base, theirs);
if (merged.isEmpty()) {
log.info(" " + manifestName + " — both sides changed the same field;"
+ " left for a human");
return false;
}
Files.writeString(manifestPath, merged.get(), StandardCharsets.UTF_8);
Manifest targetSide = ManifestReader.read(manifestPath);
Map<String, String> branches = new LinkedHashMap<>();
Map<String, String> versions = new LinkedHashMap<>();
for (Subproject feature : featureSide.subprojects().values()) {
Subproject target = targetSide.subprojects().get(feature.name());
if (target == null) {
continue;
}
if (featureBranch.equals(feature.branch())) {
branches.put(feature.name(), featureBranch);
}
if (feature.version() != null && target.version() != null
&& VersionSupport.isBranchQualified(feature.version())) {
String qualified = VersionSupport.branchQualifiedVersion(
target.version(), featureBranch);
if (!qualified.equals(target.version())) {
versions.put(feature.name(), qualified);
}
}
}
ManifestWriter.updateBranches(manifestPath, branches);
if (!versions.isEmpty()) {
String content = Files.readString(manifestPath, StandardCharsets.UTF_8);
for (Map.Entry<String, String> entry : versions.entrySet()) {
content = ManifestWriter.updateSubprojectField(
content, entry.getKey(), "version", entry.getValue());
}
Files.writeString(manifestPath, content, StandardCharsets.UTF_8);
}
log.info(" " + manifestName + " — resolved by construction: three-way merge, "
+ branches.size() + " branch field" + (branches.size() == 1 ? "" : "s")
+ " kept on " + featureBranch
+ (versions.isEmpty() ? "" : ", " + versions.size()
+ " version qualifier" + (versions.size() == 1 ? "" : "s")
+ " re-applied"));
return true;
}
/**
* The feature side of the manifest with its feature-owned lines set back
* to the base's: for each subproject, the {@code branch:} line when the
* feature side names {@code featureBranch}, and the {@code version:} line
* when the feature side's version carries the feature qualifier. Lines
* are copied from the base verbatim, so they match it byte for byte and
* a text merge sees no feature-side change there. A field the base does
* not have is left as it is.
*
* @param ours the feature side's manifest text
* @param base the merge base's manifest text
* @param featureSide the feature side, parsed
* @param featureBranch the feature branch, such as {@code feature/x}
* @return the feature side with those lines set to the base's
*/
static String withBaseFeatureFields(String ours, String base, Manifest featureSide,
String featureBranch) {
Map<String, String> baseLines = subprojectFieldLines(base);
String[] lines = ours.split("\n", -1);
String subproject = null;
boolean inSubprojects = false;
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
if (line.isBlank() || line.stripLeading().startsWith("#")) {
continue;
}
if (!line.startsWith(" ")) {
inSubprojects = line.startsWith("subprojects:");
subproject = null;
continue;
}
if (!inSubprojects) {
continue;
}
java.util.regex.Matcher key = SUBPROJECT_KEY.matcher(line);
if (key.matches()) {
subproject = key.group(1);
continue;
}
java.util.regex.Matcher field = SUBPROJECT_FIELD.matcher(line);
if (subproject == null || !field.matches()) {
continue;
}
Subproject feature = featureSide.subprojects().get(subproject);
if (feature == null) {
continue;
}
boolean featureOwned = switch (field.group(1)) {
case "branch" -> featureBranch.equals(feature.branch());
case "version" -> feature.version() != null
&& VersionSupport.isBranchQualified(feature.version());
default -> false;
};
String baseLine = baseLines.get(subproject + "\u0000" + field.group(1));
if (featureOwned && baseLine != null) {
lines[i] = baseLine;
}
}
return String.join("\n", lines);
}
/** A subproject key under {@code subprojects:}: two spaces, the name, a colon. */
private static final java.util.regex.Pattern SUBPROJECT_KEY =
java.util.regex.Pattern.compile("^ ([A-Za-z0-9._-]+):\\s*$");
/** A subproject's own field: four spaces, the field name, a colon. */
private static final java.util.regex.Pattern SUBPROJECT_FIELD =
java.util.regex.Pattern.compile("^ ([A-Za-z][A-Za-z0-9_-]*):.*$");
/**
* Each subproject field line in a manifest, keyed by subproject name and
* field name joined with a NUL.
*/
private static Map<String, String> subprojectFieldLines(String yaml) {
Map<String, String> fields = new LinkedHashMap<>();
String subproject = null;
boolean inSubprojects = false;
for (String line : yaml.split("\n", -1)) {
if (line.isBlank() || line.stripLeading().startsWith("#")) {
continue;
}
if (!line.startsWith(" ")) {
inSubprojects = line.startsWith("subprojects:");
subproject = null;
continue;
}
if (!inSubprojects) {
continue;
}
java.util.regex.Matcher key = SUBPROJECT_KEY.matcher(line);
if (key.matches()) {
subproject = key.group(1);
continue;
}
java.util.regex.Matcher field = SUBPROJECT_FIELD.matcher(line);
if (subproject != null && field.matches()) {
fields.putIfAbsent(subproject + "\u0000" + field.group(1), line);
}
}
return fields;
}
/**
* One version of a conflicted file from the index: stage 1 is the merge
* base, 2 the current branch (ours), 3 the branch being merged (theirs).
*/
private static String indexStage(File root, int stage, String file)
throws IOException, MojoException {
GitOutput shown = git(root, "show", ":" + stage + ":" + file);
if (shown.exit() != 0) {
throw new MojoException("No stage " + stage + " of " + file + " in the index");
}
return shown.stdout();
}
/**
* A three-way text merge with {@code git merge-file}.
*
* @return the merged text, or empty when it conflicts
*/
private static Optional<String> mergeText(File root, String ours, String base,
String theirs) throws IOException {
Path dir = Files.createTempDirectory("ws-manifest-merge");
try {
Path oursFile = Files.writeString(dir.resolve("ours"), ours, StandardCharsets.UTF_8);
Path baseFile = Files.writeString(dir.resolve("base"), base, StandardCharsets.UTF_8);
Path theirsFile = Files.writeString(dir.resolve("theirs"), theirs,
StandardCharsets.UTF_8);
GitOutput merged = git(root, "merge-file", "-p",
oursFile.toString(), baseFile.toString(), theirsFile.toString());
return merged.exit() == 0 ? Optional.of(merged.stdout()) : Optional.empty();
} finally {
try (java.util.stream.Stream<Path> files = Files.list(dir)) {
for (Path file : files.toList()) {
Files.deleteIfExists(file);
}
}
Files.deleteIfExists(dir);
}
}
/** A git command's exit code and its standard output, byte-exact. */
private record GitOutput(int exit, String stdout) {}
private static GitOutput git(File root, String... args) throws IOException {
List<String> command = new java.util.ArrayList<>();
command.add("git");
command.addAll(List.of(args));
Process process = new ProcessBuilder(command)
.directory(root)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start();
String stdout = new String(process.getInputStream().readAllBytes(),
StandardCharsets.UTF_8);
try {
return new GitOutput(process.waitFor(), stdout);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted running git " + String.join(" ", args), e);
}
}
/**
* The one-line effect for the working-set report and the goal log.
*
* @param assessment the assessment
* @param preview {@code true} for a draft, which phrases the effect
* as planned rather than applied
* @param targetBranch the target branch name, for the wording
* @return the effect text
*/
static String describe(Assessment assessment, boolean preview, String targetBranch) {
return switch (assessment) {
case UpToDate u -> "up to date with `" + targetBranch + "`";
case ConflictFreeMerge c -> preview
? "conflict-free update expected (" + c.behind() + " behind, " + c.ahead() + " ahead)"
: "merged `" + targetBranch + "` (" + c.behind() + " behind, "
+ c.ahead() + " ahead)";
case ManifestResolvable m -> preview
? "update expected; `" + m.manifestName() + "` conflicts by adjacency"
+ " and is resolved by construction, branch fields kept ("
+ m.behind() + " behind, " + m.ahead() + " ahead)"
: "merged `" + targetBranch + "`; `" + m.manifestName()
+ "` resolved by construction, branch fields kept ("
+ m.behind() + " behind, " + m.ahead() + " ahead)";
case Conflicting x -> (preview ? "" : "merge not applied — ")
+ x.files().size() + " conflict" + (x.files().size() == 1 ? "" : "s")
+ (preview ? " expected: " : ": ") + String.join(", ", x.files());
};
}
}