FeatureFinishMergeDraftMojo.java
package network.ike.plugin.ws;
import network.ike.workspace.Subproject;
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.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* No-fast-forward merge of a feature branch, preserving full history.
*
* <p>Creates a merge commit on the target branch containing the
* complete feature branch history. The feature branch is <b>kept alive</b>
* by default because histories stay connected — the branch can
* continue to receive work and be merged again later.
*
* <p>Before performing the merge, this goal refreshes local
* {@code main} from {@code origin/main} via {@link RefreshMainSupport}
* so the feature is not merged on top of stale main. 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 any feature branch. See ike-issues#284.
*
* <p>When to use: long-lived feature branches that periodically merge
* intermediate work to the target branch. Use when you need
* traceability of individual feature commits on the target branch.
*
* <pre>{@code
* mvn ws:feature-finish-merge-draft -Dfeature=long-running
* mvn ws:feature-finish-merge-publish -Dfeature=long-running
* mvn ws:feature-finish-merge-publish -Dfeature=done -DkeepBranch=false
* }</pre>
*
* @see RefreshMainSupport for the local-main refresh contract
* @see FeatureFinishSquashDraftMojo for clean single-commit merges (default)
*/
@Mojo(name = "feature-finish-merge-draft", projectRequired = false, aggregator = true)
public class FeatureFinishMergeDraftMojo extends AbstractWorkspaceMojo {
/** Creates this goal instance. */
public FeatureFinishMergeDraftMojo() {}
@Parameter(property = "feature")
String feature;
@Parameter(property = "targetBranch", defaultValue = "main")
String targetBranch;
/**
* Keep the feature branch after merge. Default is true because
* no-ff merge preserves history — the branch can continue to
* receive work and be merged again.
*/
@Parameter(property = "keepBranch", defaultValue = "true")
boolean keepBranch = true;
/**
* Skip the remote-branch deletion step entirely (still deletes the
* local branch unless {@code keepBranch=true}). Useful when branch
* protection forbids deletion. Remote-deletion failures are
* <em>soft</em> by default — the goal warns and continues. See
* IKE-Network/ike-issues#532.
*/
@Parameter(property = "keepRemoteBranch", defaultValue = "false")
boolean keepRemoteBranch;
@Parameter(property = "message")
String message;
/**
* Push merged target branch to origin after merge. Default is false
* because checkpoint is the natural CI handoff point, not feature-finish.
*/
@Parameter(property = "push", defaultValue = "false")
boolean push;
@Parameter(property = "publish", defaultValue = "false")
boolean publish;
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
if (!isWorkspaceMode()) {
if (feature == null || feature.isBlank()) {
feature = requireParam(feature, "feature",
"Feature to merge (without feature/ prefix)");
}
validateFeatureName(feature);
return executeBareMode("feature/" + feature);
}
// Auto-detect feature from subproject branches if not specified
if (feature == null || feature.isBlank()) {
WorkspaceGraph g = loadGraph();
List<String> all = g.topologicalSort();
feature = FeatureFinishSupport.detectFeature(
workspaceRoot(), all, this, getLog());
}
validateFeatureName(feature);
String branchName = "feature/" + feature;
// message is optional — auto-generated from subproject history
return executeWorkspaceMode(branchName);
}
private WorkspaceReportSpec executeWorkspaceMode(String branchName)
throws MojoException {
boolean draft = !publish;
WorkspaceGraph graph = loadGraph();
File root = workspaceRoot();
Path manifestPath = resolveManifest();
Set<String> targets = graph.manifest().subprojects().keySet();
List<String> sorted = graph.topologicalSort(new LinkedHashSet<>(targets));
List<String> reversed = new ArrayList<>(sorted);
Collections.reverse(reversed);
getLog().info("");
getLog().info(header("Feature Finish (merge)"));
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info(" Feature: " + feature);
getLog().info(" Branch: " + branchName + " → " + targetBranch);
getLog().info(" Strategy: no-fast-forward merge");
if (draft) getLog().info(" Mode: DRAFT");
getLog().info("");
VcsOperations.catchUp(root, getLog());
List<String> eligible = new ArrayList<>();
List<String> uncommitted = new ArrayList<>();
// #535: see FeatureFinishSquashDraftMojo for the rationale.
List<String> alreadyDone = new ArrayList<>();
for (String name : reversed) {
Subproject subproject = graph.manifest().subprojects().get(name);
String reason = FeatureFinishSupport.validateComponent(
root, name, branchName, targetBranch, subproject, this);
if (reason == null) {
eligible.add(name);
} else if ("MODIFIED".equals(reason)) {
uncommitted.add(name);
} else if (FeatureFinishSupport.ALREADY_DONE.equals(reason)) {
alreadyDone.add(name);
getLog().info(Ansi.green(" ✓ ") + name
+ " — already on " + targetBranch
+ " from a prior run (workspace.yaml will be reconciled)");
} else {
getLog().info(Ansi.yellow(" · ") + name + " — " + reason + ", skipping");
}
}
// Check workspace root for uncommitted changes (#102)
if (new File(root, ".git").exists() && !gitStatus(root).isEmpty()) {
uncommitted.add("workspace root");
}
if (!uncommitted.isEmpty()) {
StringBuilder sb = new StringBuilder();
sb.append("Cannot finish feature — uncommitted changes in:\n");
for (String name : uncommitted) {
sb.append(" ").append(name).append("\n");
}
sb.append("Please commit these changes first (mvn "
+ WsGoal.COMMIT_PUBLISH.qualified() + "), ")
.append("then re-run feature-finish.");
if (draft) {
getLog().warn("");
getLog().warn(sb.toString());
getLog().warn("");
} else {
throw new MojoException(sb.toString());
}
}
if (eligible.isEmpty() && alreadyDone.isEmpty()) {
getLog().info(" No components on " + branchName + " — nothing to do.");
return new WorkspaceReportSpec(
publish ? WsGoal.FEATURE_FINISH_MERGE_PUBLISH
: WsGoal.FEATURE_FINISH_MERGE_DRAFT,
"No components on `" + branchName + "` — nothing to do.\n");
}
// Refresh local main from origin/main before merging the feature
// branch in. Avoids shipping the feature on top of stale main.
// 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());
}
// Auto-generate commit message from per-subproject history
String generatedMessage = FeatureFinishSupport.generateFeatureMessage(
root, eligible, branchName, targetBranch, message, getLog());
getLog().info(" Commit message:");
for (String line : generatedMessage.split("\n")) {
getLog().info(" " + line);
}
getLog().info("");
int merged = 0;
// #532: soft-fail and collect — same behaviour as the squash variant.
java.util.LinkedHashMap<String, String> undeletedRemote =
new java.util.LinkedHashMap<>();
// #544: per-subproject target-branch HEAD after each merge so
// the report can show the resulting SHA next to the status.
java.util.Map<String, String> targetSha =
new java.util.LinkedHashMap<>();
if (draft) {
for (String name : eligible) {
getLog().info(" [draft] " + name + " — would merge → " + targetBranch);
merged++;
}
} else {
// #667: front-load the version strip into its own pass over
// every eligible subproject BEFORE any merge runs. After this
// pass each feature-branch POM is plain -SNAPSHOT, so a crash
// partway through the merge pass leaves a compilable tree
// rather than a mix of stripped and branch-qualified POMs.
for (String name : eligible) {
Subproject subproject = graph.manifest().subprojects().get(name);
File dir = new File(root, name);
getLog().info(Ansi.cyan(" ⤓ ") + name + " — strip version qualifier");
VcsOperations.catchUp(dir, getLog());
FeatureFinishSupport.stripBranchVersion(dir, subproject, branchName, getLog());
}
// #667: merge pass — checkout target + no-ff merge + post-steps
// per subproject. Track what has merged so a failure here can
// tell the user exactly how to resume (re-running this goal
// skips already-merged subprojects via ALREADY_DONE).
List<String> mergedSoFar = new ArrayList<>();
for (int i = 0; i < eligible.size(); i++) {
String name = eligible.get(i);
File dir = new File(root, name);
try {
getLog().info(Ansi.cyan(" → ") + name);
VcsOperations.checkout(dir, getLog(), targetBranch);
VcsOperations.mergeNoFf(dir, getLog(), branchName, generatedMessage);
FeatureFinishSupport.verifyAndFixQualifiers(dir, branchName, getLog());
if (push) {
VcsOperations.pushIfRemoteExists(dir, getLog(), "origin", targetBranch);
}
try {
targetSha.put(name, VcsOperations.headSha(dir));
} catch (MojoException ignored) {}
if (!keepBranch) {
String remoteFailReason = FeatureFinishSupport.deleteBranch(
dir, getLog(), branchName, keepRemoteBranch);
if (remoteFailReason != null) {
undeletedRemote.put(name, remoteFailReason);
}
}
VcsOperations.writeVcsState(dir, VcsState.Action.FEATURE_FINISH);
mergedSoFar.add(name);
merged++;
} catch (MojoException e) {
List<String> remaining =
new ArrayList<>(eligible.subList(i + 1, eligible.size()));
throw new MojoException(
FeatureFinishSupport.resumeGuidance(
WsGoal.FEATURE_FINISH_MERGE_PUBLISH, feature,
mergedSoFar, name, remaining),
e);
}
}
}
// #544 + #535: include already-done subprojects' current target-
// branch HEAD next to their reconciliation row.
for (String name : alreadyDone) {
File dir = new File(root, name);
try {
targetSha.put(name, VcsOperations.headSha(dir));
} catch (MojoException ignored) {}
}
// #535: yaml reconciliation list includes already-done from
// prior partial runs.
List<String> needsYamlReconcile = new ArrayList<>(eligible);
for (String name : alreadyDone) {
if (!needsYamlReconcile.contains(name)) {
needsYamlReconcile.add(name);
}
}
if (merged > 0 && publish) {
FeatureFinishSupport.cleanFeatureSites(root, eligible, branchName, getLog());
FeatureFinishSupport.mergeWorkspaceRepo(
manifestPath, branchName, targetBranch, keepBranch, push, getLog());
}
if (publish && !needsYamlReconcile.isEmpty()) {
FeatureFinishSupport.updateWorkspaceYaml(
manifestPath, needsYamlReconcile, targetBranch, feature, getLog());
}
// Offer stale branch cleanup (#100)
if (publish && merged > 0) {
FeatureFinishSupport.promptStaleBranchCleanup(
root, eligible, branchName, targetBranch,
getPrompter(), getLog());
}
getLog().info("");
getLog().info(" Merged: " + merged + " components (no-ff)");
if (!alreadyDone.isEmpty()) {
getLog().info(" Already-done from prior run: " + alreadyDone.size()
+ " (workspace.yaml reconciled)");
}
getLog().info(" Branch " + (keepBranch ? "kept" : "deleted") + ": " + branchName);
if (!undeletedRemote.isEmpty()) {
getLog().warn("");
getLog().warn(" " + undeletedRemote.size()
+ " remote feature branch(es) could not be deleted "
+ "(soft-fail per #532):");
for (java.util.Map.Entry<String, String> entry : undeletedRemote.entrySet()) {
getLog().warn(" • " + entry.getKey()
+ " — " + entry.getValue());
}
getLog().warn(" To clean up by hand:");
for (String subName : undeletedRemote.keySet()) {
getLog().warn(" (cd " + subName
+ " && git push origin --delete " + branchName + ")");
}
}
getLog().info("");
// Structured markdown report
return new WorkspaceReportSpec(
publish ? WsGoal.FEATURE_FINISH_MERGE_PUBLISH
: WsGoal.FEATURE_FINISH_MERGE_DRAFT,
buildMergeReport(eligible, branchName, targetBranch,
merged, draft, keepBranch, undeletedRemote,
alreadyDone, targetSha));
}
/**
* Build the merge-strategy markdown report. Includes the
* remote-deletion soft-fail summary with copy-pasteable manual
* cleanup commands (#532).
*
* @param components eligible subprojects
* @param branch feature branch name
* @param target target branch name
* @param merged count of subprojects merged (or that would be)
* @param isDraft draft preview?
* @param kept {@code -DkeepBranch=true}?
* @param undeletedRemote subproject → git error for remote branches
* the soft-fail step could not delete
*/
private String buildMergeReport(List<String> components, String branch,
String target, int merged,
boolean isDraft, boolean kept,
java.util.Map<String, String> undeletedRemote,
List<String> alreadyDone,
java.util.Map<String, String> targetSha) {
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph("**Branch:** `" + branch + "` → `" + target + "` \n"
+ "**Strategy:** no-fast-forward merge");
// #764/#763: one row per working-set member — every subproject AND
// the workspace-root aggregator — rendered through the shared
// WorkingSetReportTable. The aggregator's version/branch/SHA are
// gathered the same way as a subproject (the #763 fix); previously
// the workspace repo was no-ff merged but never shown in the table.
// The Effect column carries the per-member merge outcome and the
// resulting target-branch HEAD SHA lands in the SHA column (#544).
WorkingSet workingSet = resolveWorkingSet();
List<WorkingSetReportTable.Row> rows = new ArrayList<>();
for (WorkingSet.Member member : workingSet.members()) {
File dir = member.directory().toFile();
String version = readPomVersion(dir);
String memberBranch = gitBranch(dir);
String memberSha;
String effect;
if (member.isAggregator()) {
// The workspace repo is no-ff merged by mergeWorkspaceRepo
// only when at least one subproject merged and we're
// publishing; the draft previews the same intent (#763 fix:
// the aggregator now appears in the table either way).
memberSha = isDraft ? "—" : gitShortSha(dir);
if (merged > 0) {
effect = isDraft
? "would merge `" + branch + "` → `" + target + "`"
: "merged `" + branch + "` → `" + target + "`";
} else {
effect = "no-op (no subprojects merged)";
}
} else if (components.contains(member.name())) {
String sha = targetSha.getOrDefault(member.name(), "—");
effect = isDraft
? "would merge `" + branch + "` → `" + target + "`"
: "merged `" + branch + "` → `" + target + "`";
memberSha = "—".equals(sha) ? "—" : shorten(sha);
} else if (alreadyDone.contains(member.name())) {
String sha = targetSha.getOrDefault(member.name(), "—");
effect = isDraft
? "would reconcile workspace.yaml only"
: "reconciled workspace.yaml only (already on "
+ target + " from a prior run)";
memberSha = "—".equals(sha) ? "—" : shorten(sha);
} else {
// A subproject not on the feature branch — skipped this run.
effect = "skipped (not on `" + branch + "`)";
memberSha = "—";
}
rows.add(new WorkingSetReportTable.Row(
member, version, memberBranch, memberSha, effect));
}
WorkingSetReportTable.render(report, "Subprojects", rows);
report.paragraph("**" + merged + " subproject(s)** "
+ (isDraft ? "would be merged" : "merged")
+ (alreadyDone.isEmpty()
? ""
: "; **" + alreadyDone.size() + "** already-done "
+ "from a prior run (workspace.yaml "
+ (isDraft ? "would be" : "was")
+ " reconciled)")
+ ". Branch " + (kept ? "kept" : "deleted") + ".");
if (!undeletedRemote.isEmpty()) {
report.section("Remote feature branches not deleted");
report.paragraph("The merge succeeded, but origin refused "
+ "to delete " + undeletedRemote.size()
+ " remote feature branch(es) — typically because "
+ "branch protection forbids deletion. The goal "
+ "soft-failed and continued; clean these up manually:");
for (java.util.Map.Entry<String, String> entry : undeletedRemote.entrySet()) {
report.bullet("**" + entry.getKey() + "** — `"
+ entry.getValue() + "`");
}
StringBuilder cleanup = new StringBuilder();
for (String subName : undeletedRemote.keySet()) {
cleanup.append("(cd ").append(subName)
.append(" && git push origin --delete ")
.append(branch).append(")\n");
}
report.codeBlock("bash", cleanup.toString().stripTrailing());
report.paragraph("Or, to skip the remote-deletion attempt "
+ "next time, pass `-DkeepRemoteBranch=true`.");
}
if (isDraft) {
report.section("What publish will do");
report.bullet("Strip the feature version qualifier across the "
+ merged + " eligible subproject(s) (a `merge-prep` commit).");
report.bullet("No-fast-forward merge `" + branch + "` → `"
+ target + "` in each subproject.");
report.bullet(kept
? "Keep the feature branch `" + branch + "`."
: "Delete the feature branch `" + branch + "` (local"
+ (keepRemoteBranch ? "" : " + remote") + ").");
report.section("To publish");
String publishCmd = "mvn "
+ WsGoal.FEATURE_FINISH_MERGE_PUBLISH.qualified()
+ " -Dfeature=" + (feature == null ? "<name>" : feature);
if (message != null && !message.isBlank()) {
publishCmd += " -Dmessage=\"" + message.replace("\"", "\\\"") + "\"";
}
if (!keepBranch) publishCmd += " -DkeepBranch=false";
if (keepRemoteBranch) publishCmd += " -DkeepRemoteBranch=true";
if (push) publishCmd += " -Dpush=true";
report.codeBlock("bash", publishCmd);
}
return report.build();
}
private WorkspaceReportSpec executeBareMode(String branchName)
throws MojoException {
boolean draft = !publish;
// Bare mode = a working set of one (ike-issues#611).
File dir = resolveWorkingSet().members().getFirst().directory().toFile();
getLog().info("");
getLog().info("IKE Feature Finish — Merge (bare repo)");
getLog().info("══════════════════════════════════════════════════════════════");
VcsOperations.catchUp(dir, getLog());
String currentBranch = gitBranch(dir);
if (!currentBranch.equals(branchName)) {
throw new MojoException(
"Not on " + branchName + " (currently on " + currentBranch + ")");
}
if (!gitStatus(dir).isEmpty()) {
throw new MojoException("Uncommitted changes. Commit or stash first.");
}
if (draft) {
getLog().info(" [draft] Would merge → " + targetBranch);
return new WorkspaceReportSpec(WsGoal.FEATURE_FINISH_MERGE_DRAFT,
"Bare repo: would merge `" + branchName + "` → `"
+ targetBranch + "`.\n");
}
// Auto-generate message for bare mode
String bareMessage = (message != null && !message.isBlank())
? message
: "Merge " + branchName + " into " + targetBranch;
FeatureFinishSupport.stripBranchVersionBare(dir, branchName, getLog());
VcsOperations.checkout(dir, getLog(), targetBranch);
VcsOperations.mergeNoFf(dir, getLog(), branchName, bareMessage);
FeatureFinishSupport.verifyAndFixQualifiers(dir, branchName, getLog());
if (push) {
VcsOperations.pushIfRemoteExists(dir, getLog(), "origin", targetBranch);
}
String remoteFailReason = null;
if (!keepBranch) {
remoteFailReason = FeatureFinishSupport.deleteBranch(
dir, getLog(), branchName, keepRemoteBranch);
}
VcsOperations.writeVcsState(dir, VcsState.Action.FEATURE_FINISH);
if (remoteFailReason != null) {
getLog().warn(" Remote feature branch could not be deleted "
+ "(soft-fail per #532): " + remoteFailReason);
getLog().warn(" Clean up manually: git push origin --delete "
+ branchName);
}
getLog().info(" Done.");
getLog().info("");
StringBuilder body = new StringBuilder();
body.append("Bare repo: merged `").append(branchName).append("` → `")
.append(targetBranch).append("`.\n");
if (remoteFailReason != null) {
body.append("\n**Remote feature branch not deleted** — `")
.append(remoteFailReason).append("`.\n\n")
.append("Clean up manually:\n\n```bash\n")
.append("git push origin --delete ").append(branchName)
.append("\n```\n");
}
return new WorkspaceReportSpec(WsGoal.FEATURE_FINISH_MERGE_PUBLISH,
body.toString());
}
private static String shorten(String sha) {
if (sha == null || sha.length() <= 7) return sha;
return sha.substring(0, 7);
}
/**
* Read a working-set member's POM version (the {@code <version>} of
* {@code <dir>/pom.xml}), returning {@link WorkingSetReportTable#NONE}
* when no POM is present or it cannot be parsed. Applied uniformly to
* subprojects and the workspace-root aggregator — the aggregator's
* version is part of the #763 fix (its row was previously absent).
*
* @param dir the member directory
* @return the POM version, or {@code "—"} when unavailable
*/
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;
}
}
}