WsSwitchDraftMojo.java
package network.ike.plugin.ws;
import network.ike.workspace.Manifest;
import network.ike.workspace.ManifestReader;
import network.ike.workspace.WorkingSet;
import network.ike.workspace.WorkspaceGraph;
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.plugin.support.GoalReportBuilder;
import network.ike.plugin.ws.bootstrap.SubprojectInitializer;
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.StringReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
/**
* Switch all workspace subprojects to a different branch with optional
* auto-stash.
*
* <p>Discovers all local feature branches across subprojects and presents
* an interactive menu. The selected branch is checked out in every
* subproject that has it locally; subprojects without the branch are
* skipped with a warning.
*
* <p>By default, uncommitted work is automatically stashed to a pushable
* custom ref ({@code refs/ws-stash/<user-slug>/<branch>}) on origin
* before switching, and any pre-existing stash on the target branch is
* fetched and applied after switching — work follows you across
* branches and machines (see #153). The stash ref is per-user
* (keyed by {@code git config user.email}) so multiple developers on
* the same repository have isolated stashes.
*
* <p>Pass {@code -DnoStash=true} to restore the pre-feature behavior:
* uncommitted changes fail the goal with the working-tree-clean
* preflight.
*
* <p>After switching, updates workspace.yaml branch fields and commits
* the change.
*
* <pre>{@code
* mvn ws:switch # interactive menu + auto-stash
* mvn ws:switch -Dbranch=feature/foo # non-interactive
* mvn ws:switch -Dbranch=main # switch all to main
* mvn ws:switch -DnoStash=true # fail on uncommitted work
* }</pre>
*
* @see FeatureStartDraftMojo for creating feature branches
*/
@Mojo(name = "switch-draft", projectRequired = false, aggregator = true)
public class WsSwitchDraftMojo extends AbstractWorkspaceMojo {
/** Full ref prefix for auto-stash refs (see #153). */
static final String STASH_REF_PREFIX = ParkSupport.STASH_REF_PREFIX;
/** Remote name for auto-stash push/fetch/delete. */
static final String STASH_REMOTE = ParkSupport.STASH_REMOTE;
/** Creates this goal instance. */
public WsSwitchDraftMojo() {}
/**
* Target branch to switch to. If omitted, presents an interactive
* menu of available branches.
*/
@Parameter(property = "branch")
String branch;
/** Execute the switch. Default is draft (preview only). */
@Parameter(property = "publish", defaultValue = "false")
boolean publish;
/**
* Opt out of auto-stash. When {@code true}, uncommitted changes
* fail the goal (pre-#153 behavior); when {@code false} (default),
* uncommitted work is stashed to {@code refs/ws-stash/...} on
* origin before switching and re-applied on return.
*/
@Parameter(property = "noStash", defaultValue = "false")
boolean noStash;
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
if (!isWorkspaceMode()) {
throw new MojoException(
"ws:switch requires a workspace (workspace.yaml). "
+ "Use 'git checkout <branch>' for single-repo switching.");
}
boolean draft = !publish;
WorkspaceGraph graph = loadGraph();
File root = workspaceRoot();
Set<String> targets = graph.manifest().subprojects().keySet();
List<String> sorted = graph.topologicalSort(new LinkedHashSet<>(targets));
// ── Discover branches ────────────────────────────────────
// Map: branch name → set of subprojects that have it locally
Map<String, Set<String>> branchSubprojects = new TreeMap<>();
branchSubprojects.put("main", new TreeSet<>());
String currentBranch;
Map<String, Integer> branchCounts = new TreeMap<>();
for (String name : sorted) {
File dir = new File(root, name);
if (!new File(dir, ".git").exists()) continue;
String compBranch = gitBranch(dir);
branchCounts.merge(compBranch, 1, Integer::sum);
// Add main for every cloned subproject
branchSubprojects.get("main").add(name);
// Discover all local feature branches
List<String> localFeatures = VcsOperations.localBranches(dir, "feature/");
for (String fb : localFeatures) {
branchSubprojects.computeIfAbsent(fb, _ -> new TreeSet<>()).add(name);
}
}
// Determine current branch (majority vote)
currentBranch = branchCounts.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse("main");
// ── Resolve target branch ────────────────────────────────
if (branch == null || branch.isBlank()) {
branch = promptForBranch(branchSubprojects, currentBranch);
}
if (branch.equals(currentBranch)) {
getLog().info("Already on " + currentBranch + " — nothing to do.");
return new WorkspaceReportSpec(
publish ? WsGoal.SWITCH_PUBLISH : WsGoal.SWITCH_DRAFT,
"Already on `" + currentBranch + "` — nothing to do.\n");
}
// Validate the target branch exists somewhere (or is main)
if (!branch.equals("main") && !branchSubprojects.containsKey(branch)) {
throw new MojoException(
"Branch '" + branch + "' does not exist in any subproject. "
+ "Available: " + branchSubprojects.keySet());
}
getLog().info("");
getLog().info(header("Switch"));
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info(" From: " + currentBranch);
getLog().info(" To: " + branch);
if (draft) getLog().info(" Mode: DRAFT");
if (noStash) getLog().info(" Stash: disabled (-DnoStash=true)");
getLog().info("");
// ── Preflight ─────────────────────────────────────────────
if (noStash) {
// Old behavior: hard-fail on uncommitted changes (#154).
PreflightResult switchPreflight = Preflight.of(
List.of(PreflightCondition.WORKING_TREE_CLEAN),
PreflightContext.of(root, graph, sorted));
if (draft) {
switchPreflight.warnIfFailed(getLog(), WsGoal.SWITCH_PUBLISH);
} else {
switchPreflight.requirePassed(WsGoal.SWITCH_PUBLISH);
}
} else {
// Auto-stash mode: run the #153 read-only preflight.
runAutoStashPreflight(root, sorted, currentBranch, branch, draft);
}
// ── Per-subproject switch (with auto-stash) ──────────────
String slug = noStash ? null : VcsOperations.userSlug(
VcsOperations.userEmail(root));
int switched = 0;
int skipped = 0;
int stashed = 0;
int applied = 0;
int parked = 0;
int restored = 0;
// Draft-mode preview tallies (publish uses the live counters above).
List<String> wouldStash = new ArrayList<>();
List<String> wouldRestore = new ArrayList<>();
List<String> wouldPark = new ArrayList<>();
// Per-member effect for the working-set table (#764/#767): keyed by
// subproject name; the aggregator's effect is recorded after the loop.
Map<String, String> effects = new java.util.LinkedHashMap<>();
// Subprojects declared on the target branch (#573). A current
// subproject absent here is parked rather than force-switched; null
// means membership is indeterminate (treat all as members).
Set<String> targetMembers = targetBranchMembers(root, branch);
for (String name : sorted) {
File dir = new File(root, name);
if (!new File(dir, ".git").exists()) {
effects.put(name, "skipped (not cloned)");
skipped++;
continue;
}
String compBranch = gitBranch(dir);
// ── Park: <name> is not a member of the target branch (#573) ─
// Set it aside (origin is the park) rather than force-switch it
// onto a branch whose workspace.yaml doesn't declare it.
boolean targetMember = targetMembers == null
|| targetMembers.contains(name);
if (!targetMember) {
if (draft) {
boolean willStash = !noStash && !VcsOperations.isClean(dir);
if (willStash) wouldStash.add(name);
wouldPark.add(name);
getLog().info(" [draft] " + name + " — would PARK (not a "
+ "member of " + branch + ")"
+ (willStash ? " (stash first)" : ""));
effects.put(name, "would park (not on `" + branch + "`)"
+ (willStash ? " — stash first" : ""));
parked++;
continue;
}
if (!noStash && !VcsOperations.isClean(dir)) {
ParkSupport.stashLeave(dir, getLog(), slug, compBranch);
stashed++;
}
parkSubproject(dir, name, compBranch);
effects.put(name, "parked — branch pushed to " + STASH_REMOTE
+ ", clone removed");
parked++;
continue;
}
if (compBranch.equals(branch)) {
getLog().info(" " + Ansi.green("✓ ") + name + " — already on " + branch);
effects.put(name, "already on `" + branch + "`");
switched++;
continue;
}
// Target-branch existence check: main is always valid; for
// feature branches, the branch must exist locally.
if (!branch.equals("main")) {
List<String> localBranches = VcsOperations.localBranches(dir, "");
if (!localBranches.contains(branch)) {
getLog().info(" " + Ansi.yellow("⚠ ") + name
+ " — branch " + branch + " does not exist locally, skipping");
effects.put(name, "skipped (branch `" + branch
+ "` absent locally)");
skipped++;
continue;
}
}
if (draft) {
// Mirror the publish branch's decisions without executing,
// so the report previews exactly what would be stashed and
// restored (post-skip, per subproject).
boolean willStash = !noStash && !VcsOperations.isClean(dir);
boolean willRestore = !noStash
&& draftHasTargetStash(dir, slug, branch);
if (willStash) wouldStash.add(name);
if (willRestore) wouldRestore.add(name);
String note = willStash && willRestore ? " (would stash + restore)"
: willStash ? " (would stash)"
: willRestore ? " (would restore)"
: "";
getLog().info(" [draft] " + name + " — would switch "
+ compBranch + " → " + branch + note);
effects.put(name, "would switch `" + compBranch + "` → `"
+ branch + "`" + note);
switched++;
continue;
}
// ── Leave flow: stash work on source branch ──────────
boolean didStash = false;
if (!noStash && !VcsOperations.isClean(dir)) {
ParkSupport.stashLeave(dir, getLog(), slug, compBranch);
stashed++;
didStash = true;
}
// ── Checkout target ──────────────────────────────────
getLog().info(" " + Ansi.cyan("→ ") + name + ": " + compBranch + " → " + branch);
VcsOperations.checkout(dir, getLog(), branch);
switched++;
// ── Arrive flow: apply stash on target branch if any ─
boolean didApply = false;
if (!noStash) {
if (ParkSupport.stashArrive(dir, getLog(), slug, branch)) {
applied++;
didApply = true;
}
}
String note = didStash && didApply ? " (stashed + applied)"
: didStash ? " (stashed)"
: didApply ? " (applied)"
: "";
effects.put(name, "switched `" + compBranch + "` → `" + branch
+ "`" + note);
}
// ── Switch the workspace root, then restore target-only members ──
// The target branch's committed workspace.yaml is authoritative
// (#573): switch the root to it (no source-side pre-edit, so the
// checkout doesn't conflict on a branch-divergent workspace), then
// clone any declared-but-missing member that was previously parked.
if (!draft && (switched > 0 || parked > 0)) {
switchWorkspaceRepo(branch);
restored = restoreMembers(root, slug);
}
// ── Aggregator (workspace root) effect for the working-set table ──
// The root is a first-class member (#764); record what the switch did
// (or would do) to it so the table never hides a stale aggregator left
// on the source branch (#763). The root's directory always exists.
String rootName = root.getName();
if (draft) {
effects.put(rootName, currentBranch.equals(branch)
? "would stay on `" + currentBranch + "`"
: "would switch `" + currentBranch + "` → `" + branch + "`");
} else {
String rootBranch = gitBranch(root);
String rootEffect = rootBranch.equals(branch)
? "switched to `" + branch + "`"
: "stayed on `" + rootBranch + "`";
if (restored > 0) {
rootEffect += "; restored " + restored + " parked member"
+ (restored == 1 ? "" : "s");
}
effects.put(rootName, rootEffect);
}
// ── Console summary ──────────────────────────────────────
getLog().info("");
if (draft) {
StringBuilder s = new StringBuilder();
s.append(switched).append(" to switch, ")
.append(skipped).append(" to skip");
if (!wouldPark.isEmpty()) {
s.append(", ").append(wouldPark.size()).append(" to park");
}
if (!noStash) {
s.append(" | would stash: ").append(wouldStash.size())
.append(" | would restore: ").append(wouldRestore.size());
}
getLog().info(" " + s);
} else {
StringBuilder summaryParts = new StringBuilder();
summaryParts.append("Switched: ").append(switched)
.append(" | Skipped: ").append(skipped);
if (parked > 0) summaryParts.append(" | Parked: ").append(parked);
if (restored > 0) summaryParts.append(" | Restored: ").append(restored);
if (stashed > 0) summaryParts.append(" | Stashed: ").append(stashed);
if (applied > 0) summaryParts.append(" | Applied: ").append(applied);
getLog().info(" " + summaryParts);
}
getLog().info("");
// ── Report ───────────────────────────────────────────────
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph("**From:** `" + currentBranch + "` **To:** `" + branch
+ "`" + (draft ? " _(draft — no changes made)_" : ""));
// Working-set table: one row per member, the aggregator included
// (#764/#767). The Effect column states what the switch did (publish)
// or would do (draft) to each member. The aggregator row makes a stale
// workspace root visible (#763) rather than hiding it as a
// subproject-only table did.
renderWorkingSet(report, root, sorted, effects);
if (draft) {
report.paragraph("**" + switched + "** to switch, **"
+ skipped + "** to skip.");
if (!wouldPark.isEmpty()) {
report.section("Park plan");
report.paragraph("**" + wouldPark.size() + "** subproject(s) "
+ "not declared on `" + branch + "` would be parked "
+ "(branch pushed to " + STASH_REMOTE + ", clone "
+ "removed) and restored on switch-back:");
for (String n : wouldPark) report.bullet("`" + n + "`");
}
if (noStash) {
report.paragraph("Auto-stash disabled (`-DnoStash=true`) — "
+ "uncommitted work will fail the switch.");
} else {
report.section("Stash plan");
if (wouldStash.isEmpty()) {
report.paragraph("Working trees clean — nothing to stash.");
} else {
report.paragraph("**" + wouldStash.size()
+ "** subproject(s) with uncommitted work would be "
+ "stashed to `" + ParkSupport.stashRef(slug, currentBranch)
+ "` on `" + STASH_REMOTE + "`:");
for (String n : wouldStash) report.bullet("`" + n + "`");
report.paragraph("Restored automatically when you return to `"
+ currentBranch + "`. Inspect: `git ls-remote "
+ STASH_REMOTE + " 'refs/ws-stash/*'`");
}
if (wouldRestore.isEmpty()) {
report.paragraph("No parked stash on `" + branch
+ "` — nothing to restore.");
} else {
report.paragraph("**" + wouldRestore.size()
+ "** subproject(s) have a parked stash on `" + branch
+ "` that would be restored from `"
+ ParkSupport.stashRef(slug, branch) + "`:");
for (String n : wouldRestore) report.bullet("`" + n + "`");
}
}
} else {
StringBuilder counts = new StringBuilder();
counts.append("**").append(switched).append("** switched, **")
.append(skipped).append("** skipped");
if (stashed > 0) counts.append(", **").append(stashed)
.append("** stashed");
if (applied > 0) counts.append(", **").append(applied)
.append("** applied");
counts.append(".");
report.paragraph(counts.toString());
}
return new WorkspaceReportSpec(
publish ? WsGoal.SWITCH_PUBLISH : WsGoal.SWITCH_DRAFT,
report.build());
}
// ── Working-set table (#764/#767) ────────────────────────────────
/**
* Render the switch's effect on the working set as the shared table —
* one {@link WorkingSetReportTable.Row} per member, the aggregator
* (workspace root) included. The members are the subprojects the goal
* processed (in topological order) followed by the workspace root, each
* with its current version/branch/SHA gathered the same way; the root's
* version is read too (the #763 fix — a stale aggregator is no longer
* hidden). The {@code Effect} cell comes from the per-member map built
* during the switch loop; a member parked (clone removed) shows
* {@code "—"} for branch/SHA since its tree is gone.
*
* @param report the report builder to append the table to
* @param root the workspace root directory
* @param sorted the subprojects in topological order (loop order)
* @param effects per-member effect, keyed by member name
*/
private void renderWorkingSet(GoalReportBuilder report, File root,
List<String> sorted,
Map<String, String> effects) {
List<WorkingSetReportTable.Row> rows = new ArrayList<>();
for (String name : sorted) {
File dir = new File(root, name);
boolean cloned = new File(dir, ".git").exists();
String version = cloned ? readPomVersion(dir) : null;
String branchName = cloned ? gitBranch(dir) : null;
String sha = cloned ? gitShortSha(dir) : null;
rows.add(new WorkingSetReportTable.Row(
WorkingSet.Member.subproject(name, dir.toPath()),
version, branchName, sha, effects.get(name)));
}
// The aggregator (workspace root) — its tree always exists, so its
// version is gathered the same way (the #763 fix).
rows.add(new WorkingSetReportTable.Row(
WorkingSet.Member.aggregator(root.getName(), root.toPath()),
readPomVersion(root), gitBranch(root), gitShortSha(root),
effects.get(root.getName())));
WorkingSetReportTable.render(report, "Working set", rows);
}
/**
* Read a member's POM {@code <version>}, or {@code null} when there is no
* {@code pom.xml} (a POM-less aggregator root) or it cannot be parsed.
*
* @param dir the member directory
* @return the POM version, or {@code null} when unavailable
*/
private String readPomVersion(File dir) {
File pom = new File(dir, "pom.xml");
if (!pom.isFile()) return null;
try {
return network.ike.plugin.ReleaseSupport.readPomVersion(pom);
} catch (MojoException e) {
return null;
}
}
// ── #573 park / restore: branch-scoped membership ────────────────
/**
* Read the subprojects declared on the target branch's committed
* {@code workspace.yaml} (via {@code git show <branch>:workspace.yaml}) —
* the authoritative membership for that branch (#573). A subproject
* present on the current branch but absent here is parked rather than
* force-switched. Returns {@code null} when membership can't be
* determined (root is not a git repo, or the branch has no committed
* {@code workspace.yaml}); callers then treat every current subproject
* as a member, preserving pre-#573 behavior.
*
* @param root the workspace root
* @param branch the target branch
* @return declared subproject names on the target branch, or {@code null}
*/
private Set<String> targetBranchMembers(File root, String branch) {
try {
Process proc = new ProcessBuilder(
"git", "show", branch + ":workspace.yaml")
.directory(root)
.start();
byte[] out = proc.getInputStream().readAllBytes();
if (proc.waitFor() != 0) {
return null;
}
Manifest m = ManifestReader.read(
new StringReader(new String(out, StandardCharsets.UTF_8)));
return new LinkedHashSet<>(m.subprojects().keySet());
} catch (Exception e) {
getLog().debug("Could not read " + branch + ":workspace.yaml — "
+ "treating all subprojects as members: " + e.getMessage());
return null;
}
}
/**
* Park a subproject that is not a member of the target branch (#573):
* push its branch to {@value #STASH_REMOTE} so no work is lost, then
* remove the local clone. Aborts in place (no deletion) if the push
* fails — the working tree is never reduced below what is on origin.
*
* <p>Delegates to {@link ParkSupport#parkSubproject} (the shared
* primitive, ike-issues#575).
*
* @param dir the subproject directory
* @param name the subproject name
* @param compBranch the branch the subproject is currently on
* @throws MojoException if the branch cannot be pushed (park aborts)
*/
private void parkSubproject(File dir, String name, String compBranch)
throws MojoException {
ParkSupport.parkSubproject(dir, getLog(), name, compBranch);
}
/**
* After the workspace root is on the target branch, materialize any
* declared-but-missing member (a previously parked subproject) by
* reusing the {@code scaffold-init} clone path, then re-apply its
* parked stash (#573).
*
* @param root the workspace root
* @param slug the user stash slug, or {@code null} under {@code -DnoStash}
* @return the number of subprojects restored
* @throws MojoException if cloning fails
*/
private int restoreMembers(File root, String slug) throws MojoException {
WorkspaceGraph graph = loadGraph();
List<String> missing = new ArrayList<>();
for (String name : graph.manifest().subprojects().keySet()) {
if (!new File(new File(root, name), ".git").exists()) {
missing.add(name);
}
}
if (missing.isEmpty()) {
return 0;
}
new SubprojectInitializer(graph, root, root.getName(), getLog()).run();
int restored = 0;
for (String name : missing) {
File dir = new File(root, name);
if (!new File(dir, ".git").exists()) {
continue; // clone failed — surfaced by the initializer
}
if (slug != null) {
String memberBranch =
graph.manifest().subprojects().get(name).branch();
if (memberBranch != null && !memberBranch.isBlank()) {
try {
ParkSupport.stashArrive(dir, getLog(), slug, memberBranch);
} catch (MojoException e) {
getLog().warn(" could not restore stash for " + name
+ ": " + e.getMessage());
}
}
}
getLog().info(" " + Ansi.green("⇱ ") + name + " — restored");
restored++;
}
return restored;
}
/**
* Probe whether a parked stash exists on the target branch for this
* user, for draft-mode preview only. An unreachable origin is treated
* as "no stash" — {@link #runAutoStashPreflight} already warns about
* unreachable remotes, so draft stays read-only and never fails on a
* network blip.
*
* @param dir the subproject directory
* @param slug user slug
* @param targetBranch the branch we would switch to
* @return {@code true} if a stash ref is present on the remote,
* {@code false} if absent or the remote is unreachable
*/
private boolean draftHasTargetStash(File dir, String slug,
String targetBranch) {
if (slug == null) return false;
try {
return VcsOperations.remoteRefExists(dir, STASH_REMOTE,
ParkSupport.stashRef(slug, targetBranch));
} catch (MojoException e) {
return false;
}
}
/**
* Read-only preflight for the auto-stash switch flow. Verifies:
* user.email is configured, the workspace root is reachable at
* origin, source-branch stash collisions are absent, and per-
* subproject target-branch stash presence is reported (informational).
*
* <p>On any hard failure (missing user.email, source-stash
* collision), draft mode throws — matching the #154 contract that
* draft exits non-zero when publish would fail.
*
* @param root workspace root directory
* @param sorted subprojects in topological order
* @param sourceBranch the branch we're leaving
* @param targetBranch the branch we're arriving at
* @param draft whether this is a draft (report) or publish run
* @throws MojoException on preflight failure
*/
private void runAutoStashPreflight(File root, List<String> sorted,
String sourceBranch, String targetBranch,
boolean draft) throws MojoException {
// 1) user.email configured (workspace root is the reference point)
String email;
try {
email = VcsOperations.userEmail(root);
} catch (MojoException e) {
throw new MojoException(
"ws:switch requires git user.email. Set it with "
+ "`git config --global user.email <your-email>`.");
}
String slug = VcsOperations.userSlug(email);
// 2) Per-subproject checks: source-stash collision + target-stash probe
List<String> collisions = new ArrayList<>();
List<String> targetStashes = new ArrayList<>();
List<String> unreachable = new ArrayList<>();
for (String name : sorted) {
File dir = new File(root, name);
if (!new File(dir, ".git").exists()) continue;
boolean hasWip = !VcsOperations.isClean(dir);
if (hasWip) {
String sourceRef = ParkSupport.stashRef(slug, sourceBranch);
try {
if (VcsOperations.remoteRefExists(dir, STASH_REMOTE, sourceRef)) {
collisions.add(name + " (" + sourceRef + ")");
}
} catch (MojoException e) {
unreachable.add(name);
}
}
String targetRef = ParkSupport.stashRef(slug, targetBranch);
try {
if (VcsOperations.remoteRefExists(dir, STASH_REMOTE, targetRef)) {
targetStashes.add(name);
}
} catch (MojoException e) {
if (!unreachable.contains(name)) unreachable.add(name);
}
}
// 3) Report findings
if (!targetStashes.isEmpty()) {
getLog().info(" Target-branch stashes found (will be applied): "
+ targetStashes.size() + " subproject(s)");
}
if (!unreachable.isEmpty()) {
getLog().warn(" " + Ansi.yellow("⚠ ") + "Could not reach "
+ STASH_REMOTE + " for: " + unreachable);
getLog().warn(" The switch will proceed but stash operations "
+ "may fail mid-flight.");
}
if (!collisions.isEmpty()) {
String msg = "Refusing to switch: pre-existing stash ref(s) would "
+ "be overwritten:\n"
+ collisions.stream()
.reduce((a, b) -> a + "\n " + b)
.map(s -> " " + s)
.orElse("")
+ "\n\nResolve manually (per subproject):\n"
+ " # recover the old stash:\n"
+ " git fetch origin " + ParkSupport.stashRef(slug, sourceBranch) + ":"
+ ParkSupport.stashRef(slug, sourceBranch) + "\n"
+ " git stash apply " + ParkSupport.stashRef(slug, sourceBranch) + "\n"
+ " # OR, if the old stash is obsolete:\n"
+ " git push origin :" + ParkSupport.stashRef(slug, sourceBranch) + "\n"
+ "\nThen retry ws:switch.";
if (draft) {
getLog().warn("");
getLog().warn(msg);
getLog().warn("");
throw new MojoException(
"ws:switch preflight failed — source-branch stash "
+ "collision. See warnings above.");
} else {
throw new MojoException(msg);
}
}
}
/**
* Present an interactive menu of available branches and prompt for selection.
*
* @param branchSubprojects map of branch name to subprojects that have it
* @param currentBranch the current majority branch
* @return the selected branch name
* @throws MojoException if no console or invalid selection
*/
private String promptForBranch(Map<String, Set<String>> branchSubprojects,
String currentBranch)
throws MojoException {
List<String> branches = new ArrayList<>(branchSubprojects.keySet());
getLog().info("");
getLog().info(header("Switch"));
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info("");
getLog().info(" Available branches:");
getLog().info("");
for (int i = 0; i < branches.size(); i++) {
String b = branches.get(i);
int count = branchSubprojects.get(b).size();
String current = b.equals(currentBranch) ? " (current)" : "";
getLog().info(" " + (i + 1) + ". " + b
+ " (" + count + " subproject" + (count == 1 ? "" : "s") + ")"
+ current);
}
getLog().info("");
// Resolve via requireParam — accepts either an index (1..N) or a
// typed branch name. The prompt label becomes the property hint
// a non-interactive caller would see.
String input = requireParam(null, "branch",
"Select branch [1-" + branches.size() + "] or branch name");
try {
int idx = Integer.parseInt(input.trim()) - 1;
if (idx < 0 || idx >= branches.size()) {
throw new MojoException(
"Invalid selection: " + input + ". Expected 1-" + branches.size());
}
return branches.get(idx);
} catch (NumberFormatException e) {
// Allow typing the branch name directly
String typed = input.trim();
if (branchSubprojects.containsKey(typed) || "main".equals(typed)) {
return typed;
}
throw new MojoException(
"Unknown branch: " + typed + ". Available: " + branchSubprojects.keySet());
}
}
/**
* Switch the workspace repo itself to the target branch and commit
* the workspace.yaml update.
*/
private void switchWorkspaceRepo(String targetBranch) throws MojoException {
try {
Path manifestPath = resolveManifest();
File wsRoot = manifestPath.getParent().toFile();
if (!new File(wsRoot, ".git").exists()) return;
String wsBranch = VcsOperations.currentBranch(wsRoot);
if (!wsBranch.equals(targetBranch)) {
// Check if target branch exists locally in workspace repo
List<String> localBranches = VcsOperations.localBranches(wsRoot, "");
if (localBranches.contains(targetBranch) || "main".equals(targetBranch)) {
getLog().info(" Workspace repo: " + wsBranch + " → " + targetBranch);
VcsOperations.checkout(wsRoot, getLog(), targetBranch);
}
}
// Stage and commit workspace.yaml if changed
network.ike.plugin.ReleaseSupport.exec(
wsRoot, getLog(), "git", "add", "workspace.yaml");
if (VcsOperations.hasStagedChanges(wsRoot)) {
VcsOperations.commit(wsRoot, getLog(),
"workspace: switch branches to " + targetBranch);
}
VcsOperations.writeVcsState(wsRoot, VcsState.Action.SWITCH);
} catch (MojoException e) {
getLog().warn(" Could not switch workspace repo: " + e.getMessage());
}
}
}