FeatureStartSiblingDraftMojo.java
package network.ike.plugin.ws;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.workspace.Defaults;
import network.ike.workspace.FeatureName;
import network.ike.workspace.Subproject;
import network.ike.workspace.WorkingSet;
import network.ike.workspace.WorkspaceGraph;
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.util.ArrayList;
import java.util.List;
/**
* Preview a sibling-clone feature start — the read-only {@code -draft}
* counterpart of {@link FeatureStartSiblingPublishMojo}
* (IKE-Network/ike-issues#770).
*
* <p>No cloning, no branching, no mutation. The goal resolves the base
* branch and applies the <em>same guard</em> the publish mojo does (it
* refuses to plan a sibling off a non-base branch unless {@code -Dfrom} is
* given), then writes a {@code ws꞉feature-start-sibling-draft.md} report
* covering:
* <ul>
* <li>the plan — sibling directory {@code <baseName>-<feature>}, the
* feature branch, the base branch, and the members that would be
* cloned and version-qualified;</li>
* <li>preflight checks, each with copy-pasteable remediation when failing:
* the sibling dir must not already exist; the workspace root and every
* member must have an {@code origin} remote; the base branch must exist
* upstream;</li>
* <li>a clone-cost note — objects are borrowed from the primary via
* {@code --reference} (network: delta only) and {@code --dissociate}
* copies them for a self-contained, {@code rm -rf}-safe sibling (disk:
* roughly a full object DB per sibling, not shared).</li>
* </ul>
*
* <pre>{@code
* mvn ws:feature-start-sibling-draft -Dfeature=jira-456
* mvn ws:feature-start-sibling-publish -Dfeature=jira-456
* }</pre>
*
* @see FeatureStartSiblingPublishMojo for the executing counterpart
* @see SiblingBaseResolution for the shared base-branch resolution + guard
*/
@Mojo(name = "feature-start-sibling-draft", projectRequired = false, aggregator = true)
public class FeatureStartSiblingDraftMojo extends AbstractWorkspaceMojo {
/** Feature name. Branch will be {@code feature/<name>}. Prompted if omitted. */
@Parameter(property = "feature")
String feature;
/**
* Skip POM version qualification in the plan. Mirrors the publish flag —
* a document workspace whose subprojects have no versioned artifacts.
*/
@Parameter(property = "skipVersion", defaultValue = "false")
boolean skipVersion;
/**
* Explicit base branch to cut the sibling from. When unset, the sibling
* is based on the primary's current branch — guarded so a sibling is
* never silently planned off a non-base branch (see
* {@link SiblingBaseResolution}).
*/
@Parameter(property = "from")
String from;
/** Creates this goal instance. */
public FeatureStartSiblingDraftMojo() {}
/** A single preflight check row: the check, pass/fail, and remediation. */
private record Preflight(String check, boolean ok, String detail) {}
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
feature = requireParam(feature, "feature",
"Feature name (without feature/ prefix)");
FeatureName featureName = validateFeatureName(feature);
String branchName = "feature/" + feature;
if (!isWorkspaceMode()) {
return previewBareMode(featureName, branchName);
}
WorkspaceGraph graph = loadGraph();
File primaryRoot = workspaceRoot();
Defaults defaults = graph.manifest().defaults();
String manifestBase = (defaults != null && defaults.branch() != null)
? defaults.branch() : "main";
// Resolve base + apply the SAME guard the publish mojo does. A guard
// violation aborts the preview with the remediation message.
String base = SiblingBaseResolution.resolveAndGuard(
from, gitBranch(primaryRoot), manifestBase);
String baseName = resolveWorkingSet().baseName();
File parent = primaryRoot.getParentFile();
if (parent == null) {
throw new MojoException(
"Cannot resolve the parent of the workspace root " + primaryRoot
+ "; sibling clones live alongside the primary.");
}
String siblingName = featureName.siblingDirectoryName(baseName);
File siblingRoot = new File(parent, siblingName);
List<String> sorted = graph.topologicalSort();
getLog().info("");
getLog().info(header("Feature Start (sibling) — DRAFT"));
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info(" Feature: " + feature);
getLog().info(" Branch: " + branchName);
getLog().info(" Base: " + base);
getLog().info(" Sibling: " + siblingRoot.getAbsolutePath());
getLog().info("");
// --- Plan rows: aggregator + each subproject that would be cloned. ---
List<String[]> planRows = new ArrayList<>();
planRows.add(new String[]{baseName + " (aggregator)",
"clone + branch", currentVersionLabel(primaryRoot, branchName)});
// --- Preflight checks. ---
List<Preflight> preflight = new ArrayList<>();
preflight.add(new Preflight(
"Sibling dir does not already exist",
!siblingRoot.exists(),
siblingRoot.exists()
? "`" + siblingRoot.getAbsolutePath() + "` exists — "
+ "remove it: `rm -rf " + siblingRoot.getAbsolutePath() + "`"
: "`" + siblingRoot.getAbsolutePath() + "`"));
String rootRemote = gitOriginUrl(primaryRoot);
preflight.add(new Preflight(
"Workspace root has an `origin` remote",
rootRemote != null,
rootRemote != null ? rootRemote
: "add one: `git -C " + primaryRoot.getAbsolutePath()
+ " remote add origin <url>`"));
// base branch exists upstream on the root
preflight.add(baseBranchPreflight(primaryRoot, base));
for (String name : sorted) {
Subproject sub = graph.manifest().subprojects().get(name);
String remote = sub.repo();
File comp = new File(primaryRoot, name);
String versionLabel = skipVersion
? "—"
: currentVersionLabel(comp, branchName);
if (remote == null || remote.isEmpty()) {
planRows.add(new String[]{name, "skip (no repo URL)", "—"});
continue;
}
// Each member clones from its workspace.yaml `repo:` URL (not its
// local origin), shown in the plan row above; the load-bearing
// preflights are the workspace-root origin + the base-branch check.
planRows.add(new String[]{name, "clone + branch", versionLabel});
}
return new WorkspaceReportSpec(WsGoal.FEATURE_START_SIBLING_DRAFT,
buildReport(siblingName, siblingRoot, branchName, base,
planRows, preflight));
}
/**
* Bare-mode preview: a single-repo working set, base resolved + guarded
* with {@code main} as the manifest base (there is no manifest).
*/
private WorkspaceReportSpec previewBareMode(FeatureName featureName,
String branchName)
throws MojoException {
WorkingSet workingSet = resolveWorkingSet();
File repo = workingSet.members().getFirst().directory().toFile();
String base = SiblingBaseResolution.resolveAndGuard(
from, gitBranch(repo), "main");
File parent = repo.getParentFile();
String siblingName = featureName.siblingDirectoryName(workingSet.baseName());
File siblingRoot = (parent != null)
? new File(parent, siblingName)
: new File(siblingName);
getLog().info("");
getLog().info(header("Feature Start (sibling) — DRAFT"));
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info(" Feature: " + feature);
getLog().info(" Branch: " + branchName);
getLog().info(" Base: " + base);
getLog().info(" Sibling: " + siblingRoot.getAbsolutePath());
getLog().info(" Mode: single repo (no workspace.yaml)");
getLog().info("");
List<String[]> planRows = new ArrayList<>();
planRows.add(new String[]{workingSet.baseName() + " (aggregator)",
"clone + branch",
skipVersion ? "—" : currentVersionLabel(repo, branchName)});
List<Preflight> preflight = new ArrayList<>();
preflight.add(new Preflight(
"Sibling dir does not already exist",
!siblingRoot.exists(),
siblingRoot.exists()
? "`" + siblingRoot.getAbsolutePath() + "` exists — "
+ "remove it: `rm -rf " + siblingRoot.getAbsolutePath() + "`"
: "`" + siblingRoot.getAbsolutePath() + "`"));
String remote = gitOriginUrl(repo);
preflight.add(new Preflight(
"Repo has an `origin` remote",
remote != null,
remote != null ? remote
: "add one: `git -C " + repo.getAbsolutePath()
+ " remote add origin <url>`"));
preflight.add(baseBranchPreflight(repo, base));
return new WorkspaceReportSpec(WsGoal.FEATURE_START_SIBLING_DRAFT,
buildReport(siblingName, siblingRoot, branchName, base,
planRows, preflight));
}
/**
* Preflight that the base branch exists upstream on {@code dir}'s origin,
* via {@code git ls-remote origin <base>}.
*/
private Preflight baseBranchPreflight(File dir, String base) {
boolean exists = false;
String detail;
try {
String out = ReleaseSupport.execCapture(dir,
"git", "ls-remote", "origin", base);
exists = out != null && !out.isBlank();
detail = exists
? "`origin/" + base + "` resolves"
: "`origin` has no branch `" + base + "` — push it, or pass"
+ " `-Dfrom=<existing-branch>`";
} catch (Exception e) {
detail = "could not query `origin` (" + e.getMessage()
+ ") — check connectivity / the remote";
}
return new Preflight(
"Base branch `" + base + "` exists upstream", exists, detail);
}
/**
* Label a member's current POM version and the version it would be
* qualified to on {@code branchName}, for the plan table.
*/
private String currentVersionLabel(File dir, String branchName) {
File pom = new File(dir, "pom.xml");
if (!pom.exists()) {
return "—";
}
try {
String version = ReleaseSupport.readPomVersion(pom);
if (version == null || version.isEmpty()) {
return "—";
}
String qualified = network.ike.workspace.VersionSupport
.branchQualifiedVersion(version, branchName);
return qualified.equals(version)
? version
: version + " → " + qualified;
} catch (MojoException e) {
getLog().debug("Could not read POM version for " + dir + ": "
+ e.getMessage());
return "—";
}
}
/**
* Read the {@code origin} remote URL of a git repository, or {@code null}
* when it is not a git repo or has no {@code origin}.
*/
private String gitOriginUrl(File dir) {
if (!new File(dir, ".git").exists()) {
return null;
}
try {
String url = ReleaseSupport.execCapture(dir,
"git", "remote", "get-url", "origin");
return url.isBlank() ? null : url.trim();
} catch (MojoException e) {
return null;
}
}
/**
* Render the preview report: the plan, the preflight table, the
* clone-cost note, and the next-step commands.
*/
private String buildReport(String siblingName, File siblingRoot,
String branchName, String base,
List<String[]> planRows,
List<Preflight> preflight) {
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph("**Preview — no clone is made.**")
.paragraph("**Sibling:** `" + siblingName + "`")
.paragraph("**Branch:** `" + branchName + "`")
.paragraph("**Base:** `" + base + "`")
.paragraph("**Location:** `" + siblingRoot.getAbsolutePath() + "`");
report.section("Plan")
.table(List.of("Member", "Action", "Version"), planRows);
boolean allOk = true;
List<String[]> checkRows = new ArrayList<>();
for (Preflight p : preflight) {
allOk = allOk && p.ok();
checkRows.add(new String[]{
p.ok() ? "✓" : "✗", p.check(), p.detail()});
}
report.section("Preflight")
.table(List.of("", "Check", "Detail"), checkRows);
report.paragraph(allOk
? "All preflight checks pass — `ws:feature-start-sibling-publish"
+ " -Dfeature=" + feature + "` should proceed."
: "**Resolve the ✗ checks above, then run** "
+ "`ws:feature-start-sibling-publish -Dfeature="
+ feature + "`.");
report.section("Clone cost")
.paragraph("Each component is cloned with `--reference "
+ "<primary>/<component> --dissociate`. `--reference` "
+ "borrows the primary's object database, so over the "
+ "network only the delta transfers; `--dissociate` "
+ "then copies the borrowed objects so the sibling is "
+ "self-contained and `rm -rf`-safe. Disk cost: roughly "
+ "a full object DB per sibling — objects are copied, "
+ "not shared.");
report.section("Next")
.paragraph("```bash\nmvn ws:feature-start-sibling-publish"
+ " -Dfeature=" + feature
+ (from != null && !from.isBlank()
? " -Dfrom=" + from : "")
+ "\n```");
return report.build();
}
}