WsRemoveMojo.java
package network.ike.plugin.ws;
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.vcs.VcsOperations;
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.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Remove a subproject from the workspace.
*
* <p>Given a subproject name, this goal:
* <ol>
* <li>Loads the workspace graph and verifies the subproject exists</li>
* <li>Checks for downstream dependents — fails if any exist
* (unless {@code -Dforce=true})</li>
* <li>Removes the subproject entry from workspace.yaml</li>
* <li>Removes the top-level {@code <subprojects>} entry (and any legacy
* {@code with-*} profile) from the aggregator pom.xml
* (IKE-Network/ike-issues#696)</li>
* <li>Removes the subproject from any group lists in workspace.yaml</li>
* <li>Optionally deletes the cloned directory</li>
* </ol>
*
* <p>Removal is branch-scoped (IKE-Network/ike-issues#575): it edits the
* <em>current branch's</em> {@code workspace.yaml} / {@code pom.xml} and works
* on any branch, mirroring {@code ws:add}. With {@code -DdeleteDir=true} the
* clone is <b>parked</b> (its branch pushed to origin, then removed), not
* {@code rm}-ed — uncommitted work is stashed first and the push is a
* precondition for removal, so no work is lost.
*
* <pre>{@code
* mvn ws:remove -Dsubproject=tinkar-core
* mvn ws:remove -Dsubproject=tinkar-core -Dforce=true
* mvn ws:remove -Dsubproject=tinkar-core -DdeleteDir=true
* }</pre>
*
* @see WsAddMojo for adding a subproject
*/
@Mojo(name = "remove", projectRequired = false, aggregator = true)
public class WsRemoveMojo extends AbstractWorkspaceMojo {
/**
* Subproject name to remove (required).
*/
@Parameter(property = "subproject")
private String subproject;
/**
* Skip the downstream-dependent safety check.
*/
@Parameter(property = "force", defaultValue = "false")
private boolean force;
/**
* Also remove the cloned subproject directory from disk. When set, the
* clone is <b>parked</b> (its branch pushed to origin, then the clone
* removed), not {@code rm}-ed — see {@link ParkSupport#parkSubproject}.
*/
@Parameter(property = "deleteDir", defaultValue = "false")
private boolean deleteDir;
/**
* Opt out of auto-stash when parking a clone with uncommitted work
* ({@code -DdeleteDir=true}). When {@code false} (default), uncommitted
* changes are stashed to {@code refs/ws-stash/<user-slug>/<branch>} on
* origin before the clone is parked so the work follows the developer;
* when {@code true}, no stash is taken (the work still survives on the
* pushed branch if committed, but uncommitted changes are discarded with
* the clone). Mirrors {@code ws:switch}'s {@code -DnoStash}.
*/
@Parameter(property = "noStash", defaultValue = "false")
private boolean noStash;
/** Creates this goal instance. */
public WsRemoveMojo() {}
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
subproject = requireParam(subproject, "subproject",
"Subproject name to remove");
validateSubprojectName(subproject);
// Resolve workspace root and paths
Path manifestPath = resolveManifest();
Path wsDir = manifestPath.getParent();
Path pomPath = wsDir.resolve("pom.xml");
// Removal is branch-scoped (#575): it operates on the current branch's
// workspace.yaml / pom.xml and works on any branch, mirroring ws:add —
// no main-only guard. The WORKSPACE-ROOT tree must be unmodified before
// we edit it (the COORDINATING contract, #780; -Dallow-uncommitted
// escapes), so the remove commit is attributable. Subproject CLONE work
// is NOT refused: the preflight is scoped root-only, and under
// -DdeleteDir=true the park path below stashes a clone's WIP and pushes
// its branch before removing it, so nothing is lost.
// Load graph and validate subproject exists
WorkspaceGraph graph = loadGraph();
if (!graph.manifest().subprojects().containsKey(subproject)) {
throw new MojoException(
"Subproject '" + subproject + "' not found in workspace.yaml.");
}
// Check for downstream dependents
List<String> dependents = graph.cascade(subproject);
if (!dependents.isEmpty() && !force) {
throw new MojoException(
"Cannot remove '" + subproject + "' — the following subprojects "
+ "depend on it: " + dependents + "\n"
+ "Use -Dforce=true to remove anyway.");
}
// COORDINATING preflight (#780): the workspace-root tree must be
// unmodified so the remove commit below is attributable solely to this
// goal. Scoped root-only (empty subproject list) — subproject clones may
// carry WIP, which the park path preserves. -Dallow-uncommitted bypasses.
if (!allowUncommitted()) {
Preflight.of(List.of(PreflightCondition.WORKING_TREE_CLEAN),
PreflightContext.of(wsDir.toFile(), null, List.of()))
.requirePassed(WsGoal.REMOVE);
}
getLog().info("");
getLog().info(header("Remove Subproject"));
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info(" Subproject: " + subproject);
if (!dependents.isEmpty()) {
getLog().warn(" Dependents (forced): " + dependents);
}
getLog().info("");
// Snapshot the root files BEFORE editing them, so the commit below is
// scoped to exactly what this goal authored (#780).
GoalAuthoredChanges authored = GoalAuthoredChanges.snapshot(
wsDir.toFile(), getLog(), "workspace.yaml", "pom.xml");
try {
// Remove from workspace.yaml
removeSubprojectFromManifest(manifestPath);
getLog().info(Ansi.green(" ✓ ") + "workspace.yaml updated — subproject entry removed");
// Remove the reactor membership from pom.xml
removeSubprojectFromPom(pomPath);
getLog().info(Ansi.green(" ✓ ") + "pom.xml updated — subproject " + subproject + " removed");
} catch (IOException e) {
throw new MojoException(
"Failed to update workspace files: " + e.getMessage(), e);
}
// Commit the root edits in isolation (COORDINATING, #780): only the
// paths this goal authored, which the preflight guaranteed were clean.
if (authored.commitAuthored("workspace: remove " + subproject
+ "\n\nRefs: IKE-Network/ike-issues#780")) {
getLog().info(Ansi.green(" ✓ ") + "committed workspace.yaml + pom.xml");
}
// Optionally remove the cloned directory — work-preserving (#575).
// A git clone is PARKED, not rm-ed: its branch is pushed to origin
// (and any uncommitted work stashed) first, and the push is a hard
// precondition for removal, so nothing is lost. A non-clone directory
// (nothing to preserve) is plain-deleted.
boolean parked = false;
if (deleteDir) {
Path subprojectDir = wsDir.resolve(subproject);
File subDir = subprojectDir.toFile();
if (new File(subDir, ".git").exists()) {
String compBranch = gitBranch(subDir);
// Preserve uncommitted work before parking: a dirty clone is
// auto-stashed to refs/ws-stash/<slug>/<branch> on origin. The
// slug is derived exactly as ws:switch does — fail-loud on a
// missing git user.email — so WIP is never silently dropped;
// set user.email, or pass -DnoStash to discard it, then re-run.
if (!noStash && !gitStatus(subDir).isEmpty()) {
String slug = VcsOperations.userSlug(
VcsOperations.userEmail(wsDir.toFile()));
ParkSupport.stashLeave(subDir, getLog(), slug, compBranch);
}
ParkSupport.parkSubproject(subDir, getLog(), subproject, compBranch);
getLog().info(Ansi.green(" ✓ ") + "Parked clone: "
+ subprojectDir + " (" + compBranch + " → origin, "
+ "clone removed)");
parked = true;
} else if (Files.isDirectory(subprojectDir)) {
// Not a clone — nothing to preserve, plain delete.
ParkSupport.deleteDirectory(subprojectDir);
getLog().info(Ansi.green(" ✓ ") + "Deleted directory: " + subprojectDir);
} else {
getLog().info(" - Directory not present: " + subprojectDir);
}
}
getLog().info("");
getLog().info(" Subproject '" + subproject + "' removed.");
getLog().info("");
WorkspaceReportSpec spec = new WorkspaceReportSpec(WsGoal.REMOVE,
"Removed subproject **" + subproject + "**."
+ (parked ? " Clone parked (branch pushed to origin, clone "
+ "removed)." : "") + "\n");
PostMutationSync.refresh(workspaceRoot(), getLog());
return spec;
}
// ── YAML removal ────────────────────────────────────────────
/**
* Remove a subproject block from workspace.yaml.
*
* <p>Matches the subproject header at 2-space indent under
* {@code subprojects:} and removes everything until the next
* subproject header or section header (a line at 0 or 2-space
* indent that is not a continuation of this block).
*/
void removeSubprojectFromManifest(Path manifestPath) throws IOException {
String yaml = Files.readString(manifestPath, StandardCharsets.UTF_8);
// Match: " subproject-name:\n" followed by lines at 4+ space indent
// (including multi-line description blocks) until the next 2-space key
// or top-level key or end of file.
String escaped = Pattern.quote(subproject);
Pattern blockPattern = Pattern.compile(
"\\n " + escaped + ":\\s*\\n(?: .*\\n)*",
Pattern.MULTILINE);
Matcher m = blockPattern.matcher(yaml);
if (m.find()) {
yaml = m.replaceFirst("\n");
}
Files.writeString(manifestPath, yaml, StandardCharsets.UTF_8);
}
// ── POM removal ─────────────────────────────────────────────
/**
* Remove this subproject's reactor membership from the aggregator POM
* via the OpenRewrite-LST {@link ReactorPom} editor (no regex on POMs):
* drop its top-level {@code <subprojects>} entry and any legacy
* {@code with-<subproject>} profile.
*
* @param pomPath the aggregator POM path
* @throws IOException if the POM cannot be read or written
*/
void removeSubprojectFromPom(Path pomPath) throws IOException {
if (!Files.exists(pomPath)) {
getLog().warn(" No pom.xml found at " + pomPath);
return;
}
String pom = Files.readString(pomPath, StandardCharsets.UTF_8);
String updated = pom;
List<String> names = ReactorPom.listSubprojects(updated);
if (names.contains(subproject)) {
updated = ReactorPom.setSubprojects(updated,
names.stream().filter(n -> !n.equals(subproject)).toList());
}
// Drop any residual legacy file-activated profile too.
updated = ReactorPom.removeProfile(updated, "with-" + subproject);
if (updated.equals(pom)) {
getLog().info(" - Subproject " + subproject
+ " not found in pom.xml (already removed?)");
return;
}
Files.writeString(pomPath, updated, StandardCharsets.UTF_8);
}
}