FeatureFinishSquashDraftMojo.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.Map;
import java.util.Set;
/**
* Squash-merge a feature branch back to the target branch.
*
* <p>This is the <b>default and recommended</b> strategy for finishing
* features. The feature branch's full commit history is compressed into
* a single commit on the target branch. The feature branch is deleted
* after merge because squash creates divergent history — continuing
* on the branch would cause conflicts.
*
* <p>Use {@code -DkeepBranch=true} only if you understand that the
* branch can no longer be cleanly merged again.
*
* <p>Before performing the squash-merge, this goal refreshes local
* {@code main} from {@code origin/main} via {@link RefreshMainSupport}
* so the feature is not landed on top of stale main. If the refresh
* would produce file conflicts, the goal hard-errors before touching
* any feature branch. See ike-issues#284.
*
* <p>When to use: most features. Feature branch history is disposable.
* Target branch gets one clean commit.
*
* <pre>{@code
* mvn ws:feature-finish-squash-draft -Dfeature=my-feature -Dmessage="Add widget"
* mvn ws:feature-finish-squash-publish -Dfeature=my-feature -Dmessage="Add widget"
* }</pre>
*
* @see RefreshMainSupport for the local-main refresh contract
* @see FeatureFinishMergeDraftMojo for long-lived branches
*/
@Mojo(name = "feature-finish-squash-draft", projectRequired = false, aggregator = true)
public class FeatureFinishSquashDraftMojo extends AbstractWorkspaceMojo {
/** Creates this goal instance. */
public FeatureFinishSquashDraftMojo() {}
/** Feature name. Expects branch {@code feature/<name>}. Prompted if omitted. */
@Parameter(property = "feature")
String feature;
/** Target branch to merge into. */
@Parameter(property = "targetBranch", defaultValue = "main")
String targetBranch;
/**
* Keep the feature branch after squash-merge. Default is false because
* squash creates divergent history — the branch cannot be cleanly merged
* again.
*/
@Parameter(property = "keepBranch", defaultValue = "false")
boolean keepBranch;
/**
* Skip the remote-branch deletion step entirely (still deletes the
* local branch unless {@code keepBranch=true}). Useful when branch
* protection forbids deletion and you don't want the goal to even
* try. Remote-deletion failures are <em>soft</em> by default — the
* goal warns and continues; this flag suppresses the warning by
* not attempting the delete at all. IKE-Network/ike-issues#532.
*/
@Parameter(property = "keepRemoteBranch", defaultValue = "false")
boolean keepRemoteBranch;
/**
* Squash commit message. Optional — when omitted, an auto-generated
* message is built from the feature-branch commit history of every
* eligible subproject (see {@link FeatureFinishSupport#generateFeatureMessage},
* matching the merge-variant behaviour and the {@code git merge
* --squash} convention). Pass {@code -Dmessage="..."} to override.
* Fixes #160 (pre-validation) and #531 (auto-generation).
*/
@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;
/** Show plan without executing. */
@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 squash-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);
// No pre-validation of message: per #531 the squash commit
// message is now auto-generated from feature-branch commit
// history when -Dmessage is missing, matching the merge variant
// and git's own `git merge --squash` ergonomics. The #160 NPE
// it used to protect is gone — generateFeatureMessage always
// returns a non-blank string.
return executeWorkspaceMode("feature/" + feature);
}
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 (squash)"));
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info(" Feature: " + feature);
getLog().info(" Branch: " + branchName + " → " + targetBranch);
getLog().info(" Strategy: squash-merge");
if (draft) getLog().info(" Mode: DRAFT");
getLog().info("");
// Catch-up
VcsOperations.catchUp(root, getLog());
// Validate and collect eligible components
List<String> eligible = new ArrayList<>();
List<String> uncommitted = new ArrayList<>();
// #535: subprojects whose local checkout is on the target branch
// but whose workspace.yaml branch field still points at the
// feature branch — fallout from a prior partially-failed
// feature-finish run. They don't need a re-squash; they only
// need workspace.yaml reconciliation, which happens below.
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_SQUASH_PUBLISH
: WsGoal.FEATURE_FINISH_SQUASH_DRAFT,
"No components on `" + branchName + "` — nothing to do.\n");
}
// Refresh local main from origin/main before squash-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());
}
// #531: auto-generate the squash commit message from per-subproject
// feature-branch history when -Dmessage was not supplied. When the
// user did supply -Dmessage, generateFeatureMessage prepends it
// and appends the per-subproject sections below.
String effectiveMessage = FeatureFinishSupport.generateFeatureMessage(
root, eligible, branchName, targetBranch, message, getLog());
getLog().info(" Commit message:");
for (String line : effectiveMessage.split("\n")) {
getLog().info(" " + line);
}
getLog().info("");
// Merge each subproject
int merged = 0;
// #532: undeleted remote feature branches surface in the summary
// rather than aborting the goal mid-flight.
java.util.LinkedHashMap<String, String> undeletedRemote =
new java.util.LinkedHashMap<>();
// #544: per-subproject outcome capture so the report can
// distinguish real-content squashes from version-only no-ops
// and surface the resulting target-branch HEAD.
java.util.Map<String, SquashKind> squashKind =
new java.util.LinkedHashMap<>();
java.util.Map<String, String> targetSha =
new java.util.LinkedHashMap<>();
if (draft) {
for (String name : eligible) {
File dir = new File(root, name);
// Predict the version-only no-op (read-only): a feature
// branch that changes only pom.xml carries just the
// version bump, which publish strips before merging —
// leaving nothing to commit. The publish path remains
// authoritative. Empty diff → also a no-op.
List<String> changed = VcsOperations.changedFiles(
dir, targetBranch, branchName);
boolean versionOnly = changed.stream().allMatch(
p -> p.equals("pom.xml") || p.endsWith("/pom.xml"));
squashKind.put(name, versionOnly
? SquashKind.VERSION_ONLY_NOOP
: SquashKind.CONTENT_SQUASHED);
getLog().info(" [draft] " + name + " — would squash-merge → "
+ targetBranch
+ (versionOnly ? " (version-only — no commit expected)" : ""));
merged++;
}
} else {
// #667: front-load the version strip into its own pass over
// every eligible subproject BEFORE any squash-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 + squash-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.mergeSquash(dir, getLog(), branchName);
SquashKind kind;
if (VcsOperations.hasStagedChanges(dir)) {
VcsOperations.commit(dir, getLog(), effectiveMessage);
FeatureFinishSupport.verifyAndFixQualifiers(dir, branchName, getLog());
if (push) {
VcsOperations.pushIfRemoteExists(dir, getLog(), "origin", targetBranch);
}
kind = SquashKind.CONTENT_SQUASHED;
} else {
getLog().info(" no changes after squash (version-only branch) — skipping commit");
// #162: clear .git/SQUASH_MSG & .git/MERGE_MSG so a later
// git commit doesn't pick up the template and land an
// empty "Squashed commit of the following:" on main.
VcsOperations.resetHard(dir, getLog(), "HEAD");
kind = SquashKind.VERSION_ONLY_NOOP;
}
squashKind.put(name, kind);
try {
targetSha.put(name, VcsOperations.headSha(dir));
} catch (MojoException ignored) {
// Best-effort enrichment; report tolerates absence.
}
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_SQUASH_PUBLISH, feature,
mergedSoFar, name, remaining),
e);
}
}
}
// #544: include already-done subprojects' current target-branch
// SHA so the report's table row can carry it next to the
// reconciliation status.
for (String name : alreadyDone) {
File dir = new File(root, name);
try {
targetSha.put(name, VcsOperations.headSha(dir));
} catch (MojoException ignored) {}
}
// #535: workspace.yaml branch reconciliation runs for both
// subprojects squashed in this invocation AND those that a
// prior partial run already moved onto the target branch but
// never got around to recording. Without this second group,
// the manifest stays pinned to a dead feature branch and the
// next scaffold/init/clone fails.
List<String> needsYamlReconcile = new ArrayList<>(eligible);
for (String name : alreadyDone) {
if (!needsYamlReconcile.contains(name)) {
needsYamlReconcile.add(name);
}
}
// Clean up sites (only for what we actually touched this run)
if (merged > 0 && publish) {
FeatureFinishSupport.cleanFeatureSites(root, eligible, branchName, getLog());
FeatureFinishSupport.mergeWorkspaceRepo(
manifestPath, branchName, targetBranch, keepBranch, push, getLog());
}
// YAML reconciliation runs regardless of whether anything was
// squashed THIS invocation — a re-run after a partial failure
// may have nothing to squash but still need to clear stale
// branch fields for the already-done subprojects.
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(" Squash-merged: " + merged + " components");
if (!alreadyDone.isEmpty()) {
getLog().info(" Already-done from prior run: " + alreadyDone.size()
+ " (workspace.yaml reconciled)");
}
if (!keepBranch) {
getLog().info(" Branch deleted: " + branchName);
}
if (!undeletedRemote.isEmpty()) {
getLog().warn("");
getLog().warn(" " + undeletedRemote.size()
+ " remote feature branch(es) could not be deleted "
+ "(soft-fail per #532):");
for (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_SQUASH_PUBLISH
: WsGoal.FEATURE_FINISH_SQUASH_DRAFT,
buildSquashReport(
eligible, branchName, targetBranch, merged, draft,
keepBranch, effectiveMessage,
message == null || message.isBlank(),
undeletedRemote, alreadyDone, squashKind, targetSha));
}
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;
}
}
/**
* Outcome kind for a per-subproject squash row in the report (#544).
* {@code CONTENT_SQUASHED} is the normal path; {@code VERSION_ONLY_NOOP}
* captures the case the goal already detected internally ("no
* changes after squash (version-only branch)") and silently
* collapsed into the count.
*/
enum SquashKind {CONTENT_SQUASHED, VERSION_ONLY_NOOP}
/**
* Build the markdown report. When {@code messageAutoGenerated} is
* true the report flags the message as generated and shows the
* exact override command — covering the {@code -draft} actionable-
* remediation principle.
*
* @param components subprojects participating in the squash
* @param branch feature branch name
* @param target target branch name
* @param merged count of subprojects squashed (or that would be)
* @param isDraft whether this is a draft preview
* @param kept whether {@code -DkeepBranch=true}
* @param effectiveMessage the message that will be / was used
* @param messageAutoGenerated whether the message was auto-built
* (no user-supplied {@code -Dmessage})
* @param undeletedRemote subproject → git error for any remote
* feature branch that the soft-fail step
* could not delete (#532)
*/
private String buildSquashReport(List<String> components, String branch,
String target, int merged,
boolean isDraft, boolean kept,
String effectiveMessage,
boolean messageAutoGenerated,
java.util.Map<String, String> undeletedRemote,
List<String> alreadyDone,
java.util.Map<String, SquashKind> squashKind,
java.util.Map<String, String> targetSha) {
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph("**Branch:** `" + branch + "` → `" + target + "` \n"
+ "**Strategy:** squash-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 squash-merged but never shown in the table.
// The Effect column carries the per-member squash outcome (#544: real
// content vs version-only no-op) and the resulting target-branch HEAD
// SHA lands in the SHA column so the reviewer can audit which members
// shipped code without running git log by hand.
WorkingSet workingSet = resolveWorkingSet();
List<String> eligibleNames = components;
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 squash-merged by mergeWorkspaceRepo
// only when at least one subproject was squashed 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 squash-merge `" + branch + "` → `" + target + "`"
: "squash-merged `" + branch + "` → `" + target + "`";
} else {
effect = "no-op (no subprojects squashed)";
}
} else if (eligibleNames.contains(member.name())) {
SquashKind kind = squashKind.get(member.name());
if (isDraft) {
effect = kind == SquashKind.VERSION_ONLY_NOOP
? "would squash (version-only — no commit expected)"
: "would squash-merge `" + branch + "` → `" + target + "`";
} else if (kind == SquashKind.VERSION_ONLY_NOOP) {
effect = "squashed (version-only — no commit)";
} else {
effect = "squash-merged `" + branch + "` → `" + target + "`";
}
String sha = targetSha.getOrDefault(member.name(), "—");
memberSha = "—".equals(sha) ? "—" : shorten(sha);
} else if (alreadyDone.contains(member.name())) {
// #535: NOT being squashed again; only workspace.yaml is
// being brought into line from a prior partial run.
effect = isDraft
? "would reconcile workspace.yaml only"
: "reconciled workspace.yaml only (already on "
+ target + " from a prior run)";
String sha = targetSha.getOrDefault(member.name(), "—");
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 squash-merged" : "squash-merged")
+ (alreadyDone.isEmpty()
? ""
: "; **" + alreadyDone.size() + "** already-done "
+ "from a prior run (workspace.yaml "
+ (isDraft ? "would be" : "was")
+ " reconciled)")
+ ". Branch " + (kept ? "kept" : "deleted") + ".");
report.section("Commit message");
report.paragraph(messageAutoGenerated
? "Auto-generated from feature-branch history. Override "
+ "with `-Dmessage=\"...\"` if you'd prefer a different "
+ "subject."
: "Supplied via `-Dmessage`.");
report.codeBlock("", effectiveMessage);
// #532: surface remote-deletion failures with a copy-pasteable
// manual cleanup block, per the drafts-actionable-remediation
// principle. Caller still reports merged > 0 even when this
// section is populated — the squash succeeded; only the remote
// branch cleanup is incomplete.
if (!undeletedRemote.isEmpty()) {
report.section("Remote feature branches not deleted");
report.paragraph("The squash 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 (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("To publish");
String publishCmd = "mvn " + WsGoal.FEATURE_FINISH_SQUASH_PUBLISH.qualified()
+ " -Dfeature=" + (feature == null ? "<name>" : feature);
if (!messageAutoGenerated) {
publishCmd += " -Dmessage=\"" + message.replace("\"", "\\\"") + "\"";
}
if (keepBranch) publishCmd += " -DkeepBranch=true";
if (keepRemoteBranch) publishCmd += " -DkeepRemoteBranch=true";
if (push) publishCmd += " -Dpush=true";
report.codeBlock("bash", publishCmd);
}
return report.build();
}
/**
* Build the bare-mode squash commit message. With a user-supplied
* {@code -Dmessage} we prepend it; otherwise we build a default
* from the feature-branch commit subjects (matching {@code git
* merge --squash}'s {@code SQUASH_MSG} format). #531.
*
* @param dir repository root
* @param branchName feature branch name
* @param targetBranch target branch (commits are listed in
* {@code targetBranch..branchName} range)
* @param userMessage the user-supplied {@code -Dmessage} or null/blank
* @param log Maven logger
* @return non-blank commit message ready for {@code git commit -m}
*/
static String buildBareSquashMessage(File dir, String branchName,
String targetBranch,
String userMessage,
org.apache.maven.api.plugin.Log log) {
StringBuilder sb = new StringBuilder();
if (userMessage != null && !userMessage.isBlank()) {
sb.append(userMessage).append("\n\n");
}
sb.append("Squash ").append(branchName).append(" into ")
.append(targetBranch).append("\n");
try {
List<String> commits = VcsOperations.commitLog(
dir, targetBranch, branchName);
if (!commits.isEmpty()) {
sb.append("\n* ").append(branchName).append(" commits (")
.append(commits.size()).append("):\n");
for (String line : commits) {
String msg = line.contains(" ")
? line.substring(line.indexOf(' ') + 1) : line;
sb.append(" - ").append(msg).append("\n");
}
}
} catch (MojoException e) {
log.debug("Could not collect bare-mode commit log: " + e.getMessage());
}
return sb.toString().stripTrailing();
}
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 — Squash (bare repo)");
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info(" Feature: " + feature);
getLog().info(" Branch: " + branchName + " → " + targetBranch);
if (draft) getLog().info(" Mode: DRAFT");
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.");
}
// #531: auto-generate the squash commit message from this repo's
// feature-branch commit history when -Dmessage was not supplied.
String effectiveMessage = buildBareSquashMessage(
dir, branchName, targetBranch, message, getLog());
getLog().info(" Commit message:");
for (String line : effectiveMessage.split("\n")) {
getLog().info(" " + line);
}
getLog().info("");
if (draft) {
getLog().info(" [draft] Would squash-merge → " + targetBranch);
return new WorkspaceReportSpec(WsGoal.FEATURE_FINISH_SQUASH_DRAFT,
"Bare repo: would squash-merge `" + branchName + "` → `"
+ targetBranch + "`.\n\n"
+ "**Commit message** "
+ (message == null || message.isBlank()
? "(auto-generated; override with `-Dmessage=\"...\"`)"
: "(supplied via `-Dmessage`)") + ":\n\n"
+ "```\n" + effectiveMessage + "\n```\n");
}
FeatureFinishSupport.stripBranchVersionBare(dir, branchName, getLog());
VcsOperations.checkout(dir, getLog(), targetBranch);
VcsOperations.mergeSquash(dir, getLog(), branchName);
if (VcsOperations.hasStagedChanges(dir)) {
VcsOperations.commit(dir, getLog(), effectiveMessage);
FeatureFinishSupport.verifyAndFixQualifiers(dir, branchName, getLog());
if (push) {
VcsOperations.pushIfRemoteExists(dir, getLog(), "origin", targetBranch);
}
} else {
getLog().info(" No changes after squash — skipping commit");
// #162: see executeWorkspaceMode for rationale.
VcsOperations.resetHard(dir, getLog(), "HEAD");
}
String remoteFailReason = null;
if (!keepBranch) {
remoteFailReason = FeatureFinishSupport.deleteBranch(
dir, getLog(), branchName, keepRemoteBranch);
}
VcsOperations.writeVcsState(dir, VcsState.Action.FEATURE_FINISH);
getLog().info("");
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: squash-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_SQUASH_PUBLISH,
body.toString());
}
}