FeatureFinishSquashDraftMojo.java
package network.ike.plugin.ws;
import network.ike.workspace.Subproject;
import network.ike.workspace.WorkspaceGraph;
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;
/**
* 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;
/**
* Squash commit message. Required — draft warns that publish will
* fail if missing; publish refuses before any mutation (see #160).
*/
@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 {
boolean draft = !publish;
if (!isWorkspaceMode()) {
if (feature == null || feature.isBlank()) {
feature = requireParam(feature, "feature",
"Feature to squash-merge (without feature/ prefix)");
}
validateFeatureName(feature);
validateMessage(draft);
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);
validateMessage(draft);
return executeWorkspaceMode("feature/" + feature);
}
/**
* Ensure {@code -Dmessage=...} is supplied before any mutation path
* runs. In draft mode this emits a warning (so the plan still
* renders); in publish mode it aborts before any VCS operation
* touches a subproject. Fixes #160 — null message previously
* propagated into {@code git commit -m} and NPE'd mid-operation
* on the first subproject, leaving partial state.
*
* @param draft whether we're in draft (warn) or publish (throw) mode
* @throws MojoException in publish mode when message is missing
*/
private void validateMessage(boolean draft) throws MojoException {
if (message != null && !message.isBlank()) return;
String detail = WsGoal.FEATURE_FINISH_SQUASH_PUBLISH.qualified()
+ " requires -Dmessage=\"...\" — the squash commit message "
+ "is not auto-generated.";
if (draft) {
getLog().warn("");
getLog().warn(" ⚠ " + detail);
getLog().warn("");
} else {
throw new MojoException(detail);
}
}
private WorkspaceReportSpec executeWorkspaceMode(String branchName) throws MojoException {
boolean draft = !publish;
WorkspaceGraph graph = loadGraph();
File root = workspaceRoot();
Path manifestPath = resolveManifest();
var 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<>();
for (String name : reversed) {
Subproject subproject = graph.manifest().subprojects().get(name);
String reason = FeatureFinishSupport.validateComponent(
root, name, branchName, subproject, this);
if (reason == null) {
eligible.add(name);
} else if ("MODIFIED".equals(reason)) {
uncommitted.add(name);
} 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()) {
var 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 ws:commit), ")
.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()) {
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. See ike-issues#284.
RefreshMainSupport.refreshOrThrow(root, eligible, targetBranch, getLog());
// Merge each subproject
int merged = 0;
for (String name : eligible) {
Subproject subproject = graph.manifest().subprojects().get(name);
File dir = new File(root, name);
if (draft) {
getLog().info(" [draft] " + name + " — would squash-merge → " + targetBranch);
merged++;
continue;
}
getLog().info(Ansi.cyan(" → ") + name);
VcsOperations.catchUp(dir, getLog());
FeatureFinishSupport.stripBranchVersion(dir, subproject, branchName, getLog());
VcsOperations.checkout(dir, getLog(), targetBranch);
VcsOperations.mergeSquash(dir, getLog(), branchName);
if (VcsOperations.hasStagedChanges(dir)) {
VcsOperations.commit(dir, getLog(), message);
FeatureFinishSupport.verifyAndFixQualifiers(dir, branchName, getLog());
if (push) {
VcsOperations.pushIfRemoteExists(dir, getLog(), "origin", targetBranch);
}
} 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");
}
if (!keepBranch) {
FeatureFinishSupport.deleteBranch(dir, getLog(), branchName);
}
VcsOperations.writeVcsState(dir, VcsState.Action.FEATURE_FINISH);
merged++;
}
// Clean up sites
if (merged > 0 && publish) {
FeatureFinishSupport.cleanFeatureSites(root, eligible, branchName, getLog());
FeatureFinishSupport.updateWorkspaceYaml(
manifestPath, eligible, targetBranch, feature, getLog());
FeatureFinishSupport.mergeWorkspaceRepo(
manifestPath, branchName, targetBranch, keepBranch, push, 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 (!keepBranch) {
getLog().info(" Branch deleted: " + 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));
}
private String buildSquashReport(List<String> components, String branch,
String target, int merged,
boolean isDraft, boolean kept) {
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph("**Branch:** `" + branch + "` → `" + target + "` \n"
+ "**Strategy:** squash-merge");
List<String[]> rows = new ArrayList<>();
for (String name : components) {
rows.add(new String[]{name, isDraft ? "would squash" : "squashed"});
}
report.table(List.of("Subproject", "Status"), rows);
report.paragraph("**" + merged + " subproject(s)** "
+ (isDraft ? "would be squash-merged" : "squash-merged")
+ ". Branch " + (kept ? "kept" : "deleted") + ".");
return report.build();
}
private WorkspaceReportSpec executeBareMode(String branchName) throws MojoException {
boolean draft = !publish;
File dir = new File(System.getProperty("user.dir"));
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.");
}
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");
}
FeatureFinishSupport.stripBranchVersionBare(dir, branchName, getLog());
VcsOperations.checkout(dir, getLog(), targetBranch);
VcsOperations.mergeSquash(dir, getLog(), branchName);
if (VcsOperations.hasStagedChanges(dir)) {
VcsOperations.commit(dir, getLog(), message);
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");
}
if (!keepBranch) {
FeatureFinishSupport.deleteBranch(dir, getLog(), branchName);
}
VcsOperations.writeVcsState(dir, VcsState.Action.FEATURE_FINISH);
getLog().info("");
getLog().info(" Done.");
getLog().info("");
return new WorkspaceReportSpec(WsGoal.FEATURE_FINISH_SQUASH_PUBLISH,
"Bare repo: squash-merged `" + branchName + "` → `"
+ targetBranch + "`.\n");
}
}