FeatureStartDraftMojo.java
package network.ike.plugin.ws;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.workspace.BomAnalysis;
import network.ike.workspace.Subproject;
import network.ike.workspace.ManifestWriter;
import network.ike.workspace.PublishedArtifactSet;
import network.ike.workspace.VersionSupport;
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.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Start a coordinated feature branch across workspace subprojects.
*
* <p>Creates a feature branch with a consistent name across all
* workspace subprojects, optionally setting branch-qualified
* SNAPSHOT versions in each POM.
*
* <p>Before branching, this goal refreshes local {@code main} from
* {@code origin/main} via {@link RefreshMainSupport} so the new
* feature branch starts from current main rather than whatever stale
* state happens to be on the local machine. If the refresh would
* produce file conflicts, the goal hard-errors before any branch is
* created. See ike-issues#284.
*
* <p><strong>Workspace mode</strong> (workspace.yaml found):</p>
* <ol>
* <li>Refreshes local main from {@code origin/main}</li>
* <li>Validates the working tree is clean</li>
* <li>Creates branch {@code feature/<name>} from the current HEAD</li>
* <li>If the subproject has a Maven version, sets a branch-qualified
* version (e.g., {@code 1.2.0-my-feature-SNAPSHOT})</li>
* <li>Commits the version change</li>
* <li>Updates workspace.yaml branch fields for all branched components</li>
* <li>Commits the workspace.yaml change</li>
* </ol>
*
* <p><strong>Bare mode</strong> (no workspace.yaml):</p>
* <ol>
* <li>Creates the feature branch in the current repo only</li>
* <li>Sets version-qualified SNAPSHOT in the current repo's POMs</li>
* </ol>
*
* <p>Components are processed in topological order so that upstream
* dependencies get their new versions first.
*
* <pre>{@code
* mvn ws:feature-start-draft -Dfeature=shield-terminology
* mvn ws:feature-start-publish -Dfeature=shield-terminology
* mvn ws:feature-start-publish -Dfeature=doc-refresh -DskipVersion=true
* }</pre>
*
* @see RefreshMainSupport for the local-main refresh contract
*/
@Mojo(name = "feature-start-draft", projectRequired = false, aggregator = true)
public class FeatureStartDraftMojo extends AbstractWorkspaceMojo {
/** Feature name. Branch will be {@code feature/<name>}. Prompted if omitted. */
@Parameter(property = "feature")
String feature;
/**
* Skip POM version qualification. Useful for document projects
* that don't have versioned artifacts.
*/
@Parameter(property = "skipVersion", defaultValue = "false")
boolean skipVersion;
/** Show plan without executing. */
@Parameter(property = "publish", defaultValue = "false")
boolean publish;
/** Creates this goal instance. */
public FeatureStartDraftMojo() {}
/**
* Reusable cascade / version-qualification helpers, lazily
* instantiated in {@link #execute()} so the injected logger is
* available. Extracted from this class in ike-issues#204 so that
* sibling-clone work (#201) can share the same logic.
*/
private FeatureStartSupport support;
/** A row in the feature-start summary table. */
private record BranchRow(String subproject, String branch,
String snapshotVersion, String status) {}
/** A row in the BOM cascade gaps table. */
private record CascadeGapRow(String consumer, String dependency,
String issue) {}
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
support = new FeatureStartSupport(getLog());
feature = requireParam(feature, "feature", "Feature name (without feature/ prefix)");
validateFeatureName(feature);
String branchName = "feature/" + feature;
if (!isWorkspaceMode()) {
return executeBareMode(branchName);
}
// --- Workspace mode ---
boolean draft = !publish;
WorkspaceGraph graph = loadGraph();
File root = workspaceRoot();
// VCS bridge: catch-up before branching
VcsOperations.catchUp(root, getLog());
Set<String> targets = graph.manifest().subprojects().keySet();
List<String> sorted = graph.topologicalSort(new LinkedHashSet<>(targets));
getLog().info("");
getLog().info(header("Feature Start"));
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info(" Feature: " + feature);
getLog().info(" Branch: " + branchName);
getLog().info(" Scope: " + sorted.size() + " components");
if (draft) {
getLog().info(" Mode: DRAFT");
}
getLog().info("");
// Refresh local main from origin/main before branching, so the new
// feature branch starts from current main. See ike-issues#284.
RefreshMainSupport.refreshOrThrow(root, sorted, "main", getLog());
// Analyze BOM cascade issues and prompt for confirmation
List<CascadeGapRow> cascadeGaps = new ArrayList<>();
if (!skipVersion) {
cascadeGaps = checkBomCascadeAndConfirm(graph, root);
}
List<String> created = new ArrayList<>();
List<String> skippedNotCloned = new ArrayList<>();
List<String> skippedAlreadyOnBranch = new ArrayList<>();
List<BranchRow> branchRows = new ArrayList<>();
for (String name : sorted) {
Subproject subproject = graph.manifest().subprojects().get(name);
File dir = new File(root, name);
File gitDir = new File(dir, ".git");
if (!gitDir.exists()) {
skippedNotCloned.add(name);
getLog().info(" \u26A0 " + name + " \u2014 not cloned, skipping");
branchRows.add(new BranchRow(name, "—", "—", "not cloned"));
continue;
}
String currentBranch = gitBranch(dir);
if (currentBranch.equals(branchName)) {
skippedAlreadyOnBranch.add(name);
getLog().info(" \u2713 " + name + " \u2014 already on " + branchName);
branchRows.add(new BranchRow(
name, branchName, "—", "already on branch"));
continue;
}
String status = gitStatus(dir);
if (!status.isEmpty()) {
throw new MojoException(
name + " has uncommitted changes — commit or stash, then try again.");
}
// If on a different feature branch, switch to main first.
// New features always derive from main.
if (currentBranch.startsWith("feature/") && !currentBranch.equals(branchName)) {
if (draft) {
getLog().info(" [draft] " + name + " — would switch "
+ currentBranch + " → main → " + branchName);
} else {
getLog().info(" " + name + ": switching " + currentBranch + " → main");
VcsOperations.checkout(dir, getLog(), "main");
}
}
// Resolve effective version: workspace.yaml first, POM fallback
String effectiveVersion = subproject.version();
if (effectiveVersion == null || effectiveVersion.isEmpty()) {
File pom = new File(dir, "pom.xml");
if (pom.exists()) {
try {
effectiveVersion = ReleaseSupport.readPomVersion(pom);
} catch (MojoException e) {
getLog().debug("Could not read POM version for "
+ name + ": " + e.getMessage());
}
}
}
String newVersion = (!skipVersion && effectiveVersion != null)
? VersionSupport.branchQualifiedVersion(effectiveVersion, branchName)
: "—";
if (draft) {
String versionInfo = "—".equals(newVersion)
? "" : " \u2192 " + newVersion;
getLog().info(" [draft] " + name + " \u2014 would create "
+ branchName + versionInfo);
created.add(name);
branchRows.add(new BranchRow(
name, branchName, newVersion, "would create"));
continue;
}
// Auto-unshallow if this is a shallow clone — feature
// branches need full history for merge-base operations
support.ensureFullClone(dir, name);
ReleaseSupport.exec(dir, getLog(),
"git", "checkout", "-b", branchName);
if (!skipVersion && effectiveVersion != null
&& !effectiveVersion.isEmpty()) {
support.setPomVersion(dir, effectiveVersion, newVersion);
ReleaseSupport.exec(dir, getLog(),
"git", "add", "pom.xml");
ReleaseSupport.exec(dir, getLog(),
"git", "commit", "-m",
"feature: set version " + newVersion
+ " for " + branchName);
}
getLog().info(Ansi.green(" ✓ ") + String.format("%-24s %s → %s",
name, effectiveVersion != null ? effectiveVersion : "—",
newVersion));
created.add(name);
branchRows.add(new BranchRow(
name, branchName, newVersion, "✓ created"));
}
// Remove intra-reactor version pins (draft reports, publish removes)
if (!created.isEmpty()) {
support.removeIntraReactorPins(root, created, publish);
}
// Cascade version-property updates to downstream components
if (!created.isEmpty() && publish && !skipVersion) {
support.cascadeVersionProperties(graph, root, sorted, branchName);
support.cascadeBomProperties(graph, root, sorted, branchName);
support.cascadeBomImports(graph, root, sorted, branchName);
}
// Write VCS state for each branched subproject (no push — branches stay local)
if (!created.isEmpty() && publish) {
for (String name : created) {
File dir = new File(root, name);
VcsOperations.writeVcsState(dir, VcsState.Action.FEATURE_START);
}
}
// Branch the workspace repo and update workspace.yaml on the feature branch
if (!created.isEmpty() && publish) {
branchWorkspaceRepo(branchName, created);
}
getLog().info("");
getLog().info(" Created: " + created.size()
+ " | Already on branch: " + skippedAlreadyOnBranch.size()
+ " | Not cloned: " + skippedNotCloned.size());
getLog().info("");
// Structured markdown report
return new WorkspaceReportSpec(
publish ? WsGoal.FEATURE_START_PUBLISH : WsGoal.FEATURE_START_DRAFT,
buildMarkdownReport(branchName, branchRows, cascadeGaps));
}
private String buildMarkdownReport(String branchName,
List<BranchRow> branchRows,
List<CascadeGapRow> cascadeGaps) {
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph("**Branch:** `" + branchName + "`");
List<String[]> rows = new ArrayList<>();
for (BranchRow row : branchRows) {
rows.add(new String[]{row.subproject(), row.branch(),
row.snapshotVersion(), row.status()});
}
report.table(
List.of("Subproject", "Branch", "Snapshot Version", "Status"),
rows);
if (!cascadeGaps.isEmpty()) {
List<String[]> gapRows = new ArrayList<>();
for (CascadeGapRow row : cascadeGaps) {
gapRows.add(new String[]{row.consumer(),
row.dependency(), row.issue()});
}
report.paragraph("**BOM cascade gaps:**")
.table(List.of("Consumer", "Dependency", "Issue"), gapRows);
}
return report.build();
}
/**
* Bare-mode: create feature branch in the current repo only.
*/
private WorkspaceReportSpec executeBareMode(String branchName) throws MojoException {
boolean draft = !publish;
File dir = new File(System.getProperty("user.dir"));
getLog().info("");
getLog().info("IKE Feature Start (bare repo)");
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info(" Feature: " + feature);
getLog().info(" Branch: " + branchName);
getLog().info(" Repo: " + dir.getName());
if (draft) {
getLog().info(" Mode: DRAFT");
}
getLog().info("");
// VCS bridge: catch-up before branching
VcsOperations.catchUp(dir, getLog());
// Validate clean worktree
String status = gitStatus(dir);
if (!status.isEmpty()) {
throw new MojoException(
"Uncommitted changes. Commit or stash before starting a feature.");
}
// Read current version from POM
String currentVersion = null;
File pom = new File(dir, "pom.xml");
if (pom.exists() && !skipVersion) {
try {
currentVersion = ReleaseSupport.readPomVersion(pom);
} catch (MojoException e) {
getLog().debug("Could not read POM version: " + e.getMessage());
}
}
if (draft) {
String versionInfo = "";
if (currentVersion != null) {
versionInfo = " \u2192 " + VersionSupport.branchQualifiedVersion(
currentVersion, branchName);
}
getLog().info(" [draft] Would create " + branchName + versionInfo);
getLog().info("");
return new WorkspaceReportSpec(
publish ? WsGoal.FEATURE_START_PUBLISH : WsGoal.FEATURE_START_DRAFT,
"[draft] Would create `" + branchName + "` in `"
+ dir.getName() + "`.\n");
}
// Auto-unshallow if needed
support.ensureFullClone(dir, dir.getName());
// Create branch
ReleaseSupport.exec(dir, getLog(),
"git", "checkout", "-b", branchName);
getLog().info(" Created " + branchName);
// Set branch-qualified version
if (currentVersion != null && !currentVersion.isEmpty()) {
String newVersion = VersionSupport.branchQualifiedVersion(
currentVersion, branchName);
getLog().info(" Version: " + currentVersion + " \u2192 " + newVersion);
support.setPomVersion(dir, currentVersion, newVersion);
ReleaseSupport.exec(dir, getLog(), "git", "add", "pom.xml");
// Also stage any updated submodule POMs
try {
List<File> allPoms = ReleaseSupport.findPomFiles(dir);
for (File subPom : allPoms) {
if (!subPom.equals(pom)) {
String rel = dir.toPath().relativize(subPom.toPath()).toString();
ReleaseSupport.exec(dir, getLog(), "git", "add", rel);
}
}
} catch (MojoException e) {
getLog().debug("Could not scan submodule POMs: " + e.getMessage());
}
ReleaseSupport.exec(dir, getLog(),
"git", "commit", "-m",
"feature: set version " + newVersion + " for " + branchName);
}
// Write VCS state (no push — branch stays local)
VcsOperations.writeVcsState(dir, VcsState.Action.FEATURE_START);
getLog().info("");
return new WorkspaceReportSpec(
publish ? WsGoal.FEATURE_START_PUBLISH : WsGoal.FEATURE_START_DRAFT,
"Created `" + branchName + "` in `" + dir.getName() + "`.\n");
}
/**
* Branch the workspace repo, update workspace.yaml on the feature branch,
* and push with IKE_VCS_CONTEXT.
*/
private void branchWorkspaceRepo(String branchName, List<String> components)
throws MojoException {
try {
Path manifestPath = resolveManifest();
File wsRoot = manifestPath.getParent().toFile();
File wsGit = new File(wsRoot, ".git");
if (!wsGit.exists()) return;
// If workspace repo is on a different feature branch, switch to main first
String wsBranch = VcsOperations.currentBranch(wsRoot);
if (wsBranch.startsWith("feature/") && !wsBranch.equals(branchName)) {
getLog().info(" Workspace repo: switching " + wsBranch + " → main");
VcsOperations.checkout(wsRoot, getLog(), "main");
}
// Branch the workspace repo
getLog().info(" Branching workspace repo → " + branchName);
VcsOperations.checkoutNew(wsRoot, getLog(), branchName);
// Update workspace.yaml on the feature branch
Map<String, String> updates = new LinkedHashMap<>();
for (String name : components) {
updates.put(name, branchName);
}
ManifestWriter.updateBranches(manifestPath, updates);
getLog().info(" Updated workspace.yaml branches for "
+ components.size() + " components");
ReleaseSupport.exec(wsRoot, getLog(), "git", "add", "workspace.yaml");
if (VcsOperations.hasStagedChanges(wsRoot)) {
VcsOperations.commit(wsRoot, getLog(),
"workspace: update branches for " + branchName);
} else {
getLog().info(" workspace.yaml already up to date — nothing to commit");
}
// Write VCS state (no push — branch stays local)
VcsOperations.writeVcsState(wsRoot, VcsState.Action.FEATURE_START);
} catch (IOException e) {
getLog().warn(" Could not update workspace.yaml: " + e.getMessage());
}
}
/**
* Analyze BOM cascade issues before starting the feature.
* If issues are found, prompt the developer for confirmation.
* In headless mode (no console), log warnings and proceed.
*
* @return cascade gap rows for the markdown report
*/
private List<CascadeGapRow> checkBomCascadeAndConfirm(WorkspaceGraph graph, File root)
throws MojoException {
// Build published artifact sets
java.util.Map<String, java.util.Set<PublishedArtifactSet.Artifact>>
workspaceArtifacts = new java.util.LinkedHashMap<>();
for (String name : graph.manifest().subprojects().keySet()) {
java.nio.file.Path subDir = root.toPath().resolve(name);
if (java.nio.file.Files.exists(subDir.resolve("pom.xml"))) {
try {
workspaceArtifacts.put(name,
PublishedArtifactSet.scan(subDir));
} catch (java.io.IOException e) {
// Skip
}
}
}
java.util.List<BomAnalysis.CascadeIssue> issues;
try {
issues = BomAnalysis.analyzeCascadeIssues(
root.toPath(), graph.manifest(), workspaceArtifacts);
} catch (java.io.IOException e) {
getLog().warn(" BOM cascade check failed: " + e.getMessage());
return List.of();
}
// Filter out gaps that cascadeBomProperties() can resolve (#82).
// Check if the affected subproject's own POM (or any POM in its
// module tree) has a <upstream.version> property — if so, the
// cascade will update it automatically and the gap is handled.
issues.removeIf(issue -> {
String propertyName = issue.dependsOn() + ".version";
// Check the affected subproject's POM tree
java.nio.file.Path subDir = root.toPath().resolve(issue.subprojectName());
if (java.nio.file.Files.exists(subDir.resolve("pom.xml"))) {
try {
java.util.List<java.io.File> poms = network.ike.plugin.ReleaseSupport
.findPomFiles(subDir.toFile());
for (java.io.File pom : poms) {
String content = java.nio.file.Files.readString(
pom.toPath(), java.nio.charset.StandardCharsets.UTF_8);
if (content.contains("<" + propertyName + ">")) {
return true; // Gap handled by cascadeBomProperties
}
}
} catch (Exception _) { /* skip */ }
}
// Also check any workspace subproject's root POM for the
// convention property — a BOM subproject that manages the
// upstream's artifacts indicates the cascade path exists
// through the BOM import chain. We scan all root POMs since
// the per-subproject type distinction was removed.
for (String otherName : graph.manifest().subprojects().keySet()) {
if (otherName.equals(issue.subprojectName())) continue;
java.nio.file.Path otherPom = root.toPath()
.resolve(otherName).resolve("pom.xml");
if (java.nio.file.Files.exists(otherPom)) {
try {
String content = java.nio.file.Files.readString(
otherPom, java.nio.charset.StandardCharsets.UTF_8);
if (content.contains("<" + propertyName + ">")) {
return true; // Another subproject has the convention property
}
} catch (java.io.IOException _) { /* skip */ }
}
}
return false;
});
if (issues.isEmpty()) return List.of();
// Collect structured gap rows for the report
List<CascadeGapRow> gaps = new ArrayList<>();
for (var issue : issues) {
String issueDesc = "no version-property or BOM import";
if (!issue.externalBomPins().isEmpty()) {
var bom = issue.externalBomPins().getFirst();
issueDesc = "pinned by " + bom.groupId()
+ ":" + bom.artifactId() + ":" + bom.version();
}
gaps.add(new CascadeGapRow(
issue.subprojectName(), issue.dependsOn(), issueDesc));
}
// Report issues to console
getLog().warn("");
getLog().warn(" ╔══════════════════════════════════════════════════════════╗");
getLog().warn(" ║ BOM Cascade Gaps Detected ║");
getLog().warn(" ╚══════════════════════════════════════════════════════════╝");
getLog().warn("");
getLog().warn(" The following dependency edges have no version-property or");
getLog().warn(" workspace-internal BOM import. Feature-start CANNOT cascade");
getLog().warn(" version changes for these automatically:");
getLog().warn("");
for (var issue : issues) {
getLog().warn(" " + issue.subprojectName() + " → " + issue.dependsOn());
for (var bom : issue.externalBomPins()) {
getLog().warn(" external BOM: " + bom.groupId()
+ ":" + bom.artifactId() + ":" + bom.version());
}
}
getLog().warn("");
getLog().warn(" These components may resolve stale versions from external BOMs");
getLog().warn(" instead of the feature branch versions.");
getLog().warn("");
// Prompt for confirmation. In batch mode (no Prompter — e.g.
// unit tests, headless CI), the helper returns the default.
// Default true here so unattended runs proceed past BOM
// cascade warnings; the warnings above are already visible in
// the build log for human review.
if (!confirm("Proceed with feature-start?", true)) {
throw new MojoException(
"Feature-start aborted. Fix BOM cascade gaps first.");
}
return gaps;
}
}