FeatureAbandonDraftMojo.java
package network.ike.plugin.ws;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.ws.preflight.Preflight;
import network.ike.plugin.ws.preflight.PreflightCondition;
import network.ike.plugin.ws.preflight.PreflightContext;
import network.ike.plugin.ws.preflight.PreflightResult;
import network.ike.workspace.Subproject;
import network.ike.workspace.ManifestWriter;
import network.ike.workspace.WorkspaceGraph;
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.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
/**
* Abandon a feature branch across all workspace subprojects.
*
* <p>The draft variant previews what would be abandoned — which components,
* how many unmerged commits, what would be lost. The publish variant
* prompts for confirmation then executes the deletion.
*
* <p>Components are processed in reverse topological order (downstream
* first) to avoid transient dependency issues.
*
* <pre>{@code
* mvn ws:feature-abandon-draft # preview
* mvn ws:feature-abandon-publish # execute (with confirmation)
* mvn ws:feature-abandon-publish -Dforce=true # skip confirmation
* mvn ws:feature-abandon-publish -DdeleteRemote=true # also delete remote branches
* }</pre>
*
* @see FeatureStartDraftMojo for creating feature branches
*/
@Mojo(name = "feature-abandon-draft", projectRequired = false, aggregator = true)
public class FeatureAbandonDraftMojo extends AbstractWorkspaceMojo {
@Parameter(property = "feature")
String feature;
@Parameter(property = "targetBranch")
String targetBranch;
@Parameter(property = "deleteRemote", defaultValue = "false")
boolean deleteRemote;
@Parameter(property = "force", defaultValue = "false")
boolean force;
/** Execute the abandon. Default is draft (preview only). */
@Parameter(property = "publish", defaultValue = "false")
boolean publish;
/** Creates this goal instance. */
public FeatureAbandonDraftMojo() {}
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
if (!isWorkspaceMode()) {
return executeBareMode();
}
return executeWorkspaceMode();
}
private WorkspaceReportSpec executeWorkspaceMode() throws MojoException {
boolean draft = !publish;
WorkspaceGraph graph = loadGraph();
File root = workspaceRoot();
Path manifestPath = resolveManifest();
if (targetBranch == null || targetBranch.isBlank()) {
targetBranch = graph.manifest().defaults().branch();
if (targetBranch == null) targetBranch = "main";
}
Set<String> targets = graph.manifest().subprojects().keySet();
List<String> sorted = graph.topologicalSort(new LinkedHashSet<>(targets));
List<String> reversed = new ArrayList<>(sorted);
Collections.reverse(reversed);
// Preflight: all working trees must be clean (#132)
PreflightResult preflight = Preflight.of(
List.of(PreflightCondition.WORKING_TREE_CLEAN),
PreflightContext.of(root, graph, sorted));
if (draft) {
preflight.warnIfFailed(getLog(), WsGoal.FEATURE_ABANDON_PUBLISH);
} else {
preflight.requirePassed(WsGoal.FEATURE_ABANDON_PUBLISH);
}
// Auto-detect feature branch if not specified
if (feature == null || feature.isBlank()) {
feature = detectFeatureBranch(root, reversed);
}
validateFeatureName(feature);
String branchName = "feature/" + feature;
// Collect eligible components and show preview
getLog().info("");
getLog().info(header("Feature Abandon"));
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info(" Feature: " + feature);
getLog().info(" Branch: " + branchName + " → " + targetBranch);
if (deleteRemote) getLog().info(" Remote: will delete origin/" + branchName);
if (draft) getLog().info(" Mode: DRAFT");
getLog().info("");
List<String> eligible = new ArrayList<>();
List<String> skipped = new ArrayList<>();
List<String[]> reportRows = new ArrayList<>();
int totalUnmerged = 0;
for (String name : reversed) {
File dir = new File(root, name);
File gitDir = new File(dir, ".git");
if (!gitDir.exists()) {
getLog().info(Ansi.yellow(" · ") + name + " — not cloned");
skipped.add(name);
reportRows.add(new String[]{name, "not cloned", "0"});
continue;
}
String currentBranch = gitBranch(dir);
if (!currentBranch.equals(branchName)) {
getLog().info(Ansi.yellow(" · ") + name + " — on "
+ currentBranch + ", not on feature");
skipped.add(name);
reportRows.add(new String[]{name, "not on feature", "0"});
continue;
}
// Check for uncommitted changes
String status = gitStatus(dir);
if (!status.isEmpty()) {
throw new MojoException(
name + " has uncommitted changes. Commit, stash, or discard before abandoning.");
}
// Check for unmerged commits
int unmergedCount = 0;
try {
String unmerged = ReleaseSupport.execCapture(dir,
"git", "log", "--oneline",
targetBranch + ".." + branchName);
if (!unmerged.isBlank()) {
unmergedCount = (int) unmerged.lines().count();
}
} catch (MojoException e) {
// Target branch may not exist locally
}
totalUnmerged += unmergedCount;
if (unmergedCount > 0) {
String label = draft ? "would abandon" : "abandon";
getLog().info(Ansi.yellow(" ⚠ ") + name + " — "
+ unmergedCount + " unmerged commit(s) — " + label);
} else {
String label = draft ? "would abandon (clean)" : "abandon";
getLog().info(Ansi.cyan(" → ") + name + " — " + label);
}
eligible.add(name);
reportRows.add(new String[]{name,
draft ? "would abandon" : "abandoned",
String.valueOf(unmergedCount)});
}
if (eligible.isEmpty()) {
getLog().info(" No components on " + branchName + " — nothing to abandon.");
getLog().info("");
return new WorkspaceReportSpec(
publish ? WsGoal.FEATURE_ABANDON_PUBLISH : WsGoal.FEATURE_ABANDON_DRAFT,
"No components on `" + branchName + "` — nothing to abandon.\n");
}
getLog().info("");
getLog().info(" " + eligible.size() + " subproject(s) on " + branchName);
if (totalUnmerged > 0) {
getLog().warn(" " + totalUnmerged + " total unmerged commit(s) will be lost");
}
if (draft) {
getLog().info("");
getLog().info(" Next: mvn ws:feature-abandon-publish"
+ (force ? "" : " (will prompt for confirmation)"));
getLog().info("");
return writeAbandonReport(branchName, reportRows, eligible, skipped, draft);
}
// Publish mode — prompt for confirmation
if (!force && !confirm("Abandon feature/" + feature + "?", false)) {
throw new MojoException("Abandon cancelled.");
}
// Execute
for (String name : eligible) {
Subproject subproject = graph.manifest().subprojects().get(name);
File dir = new File(root, name);
// Strip branch-qualified versions before switching
FeatureFinishSupport.stripBranchVersion(dir, subproject, branchName, getLog());
VcsOperations.checkout(dir, getLog(), targetBranch);
VcsOperations.deleteBranch(dir, getLog(), branchName);
if (deleteRemote) {
try {
VcsOperations.deleteRemoteBranch(dir, getLog(), "origin", branchName);
} catch (MojoException e) {
getLog().warn(" could not delete remote branch: " + e.getMessage());
}
}
VcsOperations.writeVcsState(dir, VcsState.Action.FEATURE_FINISH);
getLog().info(Ansi.green(" ✓ ") + name + " → " + targetBranch);
}
// Update workspace.yaml and workspace repo
if (!eligible.isEmpty()) {
abandonWorkspaceRepo(manifestPath, eligible, branchName);
}
getLog().info("");
getLog().info(" Abandoned: " + eligible.size()
+ " | Skipped: " + skipped.size());
if (!deleteRemote) {
getLog().info(" Remote branches kept. Use -DdeleteRemote=true to delete them.");
}
getLog().info("");
return writeAbandonReport(branchName, reportRows, eligible, skipped, draft);
}
private WorkspaceReportSpec writeAbandonReport(String branchName, List<String[]> rows,
List<String> eligible, List<String> skipped,
boolean isDraft) {
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph("**Branch:** `" + branchName + "`");
report.table(List.of("Subproject", "Status", "Unmerged Commits"), rows);
report.paragraph("**" + eligible.size() + "** "
+ (isDraft ? "would be abandoned" : "abandoned")
+ ", **" + skipped.size() + "** skipped.");
return new WorkspaceReportSpec(
publish ? WsGoal.FEATURE_ABANDON_PUBLISH : WsGoal.FEATURE_ABANDON_DRAFT,
report.build());
}
// ── Auto-detect ─────────────────────────────────────────────────
/**
* Scan workspace subprojects for feature branches and return
* the feature name. If multiple features are found, prompts
* the user to choose.
*/
private String detectFeatureBranch(File root, List<String> components)
throws MojoException {
Set<String> features = new TreeSet<>();
for (String name : components) {
File dir = new File(root, name);
if (!new File(dir, ".git").exists()) continue;
String branch = gitBranch(dir);
if (branch.startsWith("feature/")) {
features.add(branch.substring("feature/".length()));
}
}
if (features.isEmpty()) {
throw new MojoException(
"No components are on a feature branch. Nothing to abandon.");
}
if (features.size() == 1) {
String detected = features.iterator().next();
getLog().info(" Detected feature: " + detected);
return detected;
}
// Multiple features — present a numbered selection menu
List<String> featureList = new ArrayList<>(features);
String picked = selectFromList("Multiple feature branches detected"
+ " — pick one to abandon", featureList);
if (picked != null) {
return picked;
}
throw new MojoException(
"Multiple features found: " + features
+ ". Specify with -Dfeature=<name>.");
}
// ── Bare mode ───────────────────────────────────────────────────
private WorkspaceReportSpec executeBareMode() throws MojoException {
boolean draft = !publish;
File dir = new File(System.getProperty("user.dir"));
if (targetBranch == null || targetBranch.isBlank()) {
targetBranch = "main";
}
String currentBranch = gitBranch(dir);
if (feature == null || feature.isBlank()) {
if (currentBranch.startsWith("feature/")) {
feature = currentBranch.substring("feature/".length());
} else {
throw new MojoException(
"Not on a feature branch (on " + currentBranch
+ "). Specify with -Dfeature=<name>.");
}
}
validateFeatureName(feature);
String branchName = "feature/" + feature;
getLog().info("");
getLog().info("IKE Feature Abandon (bare repo)");
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info(" Feature: " + feature);
getLog().info(" Branch: " + branchName + " → " + targetBranch);
if (draft) getLog().info(" Mode: DRAFT");
getLog().info("");
if (!currentBranch.equals(branchName)) {
throw new MojoException(
"Not on " + branchName + " (currently on " + currentBranch + ")");
}
if (!gitStatus(dir).isEmpty()) {
throw new MojoException(
"Uncommitted changes. Commit, stash, or discard first.");
}
if (draft) {
getLog().info(" [draft] Would abandon " + branchName
+ " and switch to " + targetBranch);
getLog().info("");
getLog().info(" Next: mvn ws:feature-abandon-publish");
getLog().info("");
return new WorkspaceReportSpec(WsGoal.FEATURE_ABANDON_DRAFT,
"Bare repo: would abandon `" + branchName + "` and switch to `"
+ targetBranch + "`.\n");
}
// Publish mode — prompt for confirmation
if (!force && !confirm("Abandon feature/" + feature + "?", false)) {
throw new MojoException("Abandon cancelled.");
}
FeatureFinishSupport.stripBranchVersionBare(dir, branchName, getLog());
VcsOperations.checkout(dir, getLog(), targetBranch);
VcsOperations.deleteBranch(dir, getLog(), branchName);
getLog().info(Ansi.green(" ✓ ") + "Switched to " + targetBranch
+ ", deleted " + branchName);
if (deleteRemote) {
try {
VcsOperations.deleteRemoteBranch(dir, getLog(), "origin", branchName);
getLog().info(Ansi.green(" ✓ ") + "Deleted remote branch");
} catch (MojoException e) {
getLog().warn(" Could not delete remote branch: " + e.getMessage());
}
}
VcsOperations.writeVcsState(dir, VcsState.Action.FEATURE_FINISH);
getLog().info("");
return new WorkspaceReportSpec(WsGoal.FEATURE_ABANDON_PUBLISH,
"Bare repo: abandoned `" + branchName + "`, switched to `"
+ targetBranch + "`.\n");
}
// ── Workspace repo cleanup ──────────────────────────────────────
private void abandonWorkspaceRepo(Path manifestPath,
List<String> components,
String branchName)
throws MojoException {
try {
Map<String, String> updates = new LinkedHashMap<>();
for (String name : components) {
updates.put(name, targetBranch);
}
ManifestWriter.updateBranches(manifestPath, updates);
File wsRoot = manifestPath.getParent().toFile();
if (!new File(wsRoot, ".git").exists()) return;
String wsBranch = gitBranch(wsRoot);
if (wsBranch.equals(branchName)) {
ReleaseSupport.exec(wsRoot, getLog(), "git", "add", "workspace.yaml");
if (VcsOperations.hasStagedChanges(wsRoot)) {
VcsOperations.commit(wsRoot, getLog(),
"workspace: revert branches for abandon " + branchName);
}
VcsOperations.checkout(wsRoot, getLog(), targetBranch);
try {
ReleaseSupport.exec(wsRoot, getLog(),
"git", "cherry-pick", branchName);
} catch (MojoException e) {
ManifestWriter.updateBranches(manifestPath, updates);
ReleaseSupport.exec(wsRoot, getLog(), "git", "add", "workspace.yaml");
if (VcsOperations.hasStagedChanges(wsRoot)) {
VcsOperations.commit(wsRoot, getLog(),
"workspace: revert branches after abandon " + branchName);
}
}
VcsOperations.deleteBranch(wsRoot, getLog(), branchName);
if (deleteRemote) {
try {
VcsOperations.deleteRemoteBranch(wsRoot, getLog(), "origin", branchName);
} catch (MojoException e) {
getLog().warn(" Could not delete workspace remote branch: "
+ e.getMessage());
}
}
} else {
ReleaseSupport.exec(wsRoot, getLog(), "git", "add", "workspace.yaml");
if (VcsOperations.hasStagedChanges(wsRoot)) {
VcsOperations.commit(wsRoot, getLog(),
"workspace: revert branches after abandon " + branchName);
}
}
VcsOperations.pushIfRemoteExists(wsRoot, getLog(), "origin", targetBranch);
} catch (IOException e) {
getLog().warn(" Could not update workspace.yaml: " + e.getMessage());
}
}
}