WsRemoveMojo.java
package network.ike.plugin.ws;
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.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 file-activated profile from the aggregator pom.xml</li>
* <li>Removes the subproject from any group lists in workspace.yaml</li>
* <li>Optionally deletes the cloned directory</li>
* </ol>
*
* <pre>{@code
* mvn ike:ws-remove -Dsubproject=tinkar-core
* mvn ike:ws-remove -Dsubproject=tinkar-core -Dforce=true
* mvn ike: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 delete the cloned subproject directory from disk.
*/
@Parameter(property = "deleteDir", defaultValue = "false")
private boolean deleteDir;
/** 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");
// Remove is main-only — workspace composition changes belong on main,
// not on feature branches. Add is allowed on any branch (discovery
// during development), but removal is a structural decision.
File subDir = wsDir.resolve(subproject).toFile();
if (new File(subDir, ".git").exists()) {
String currentBranch = gitBranch(subDir);
if (!currentBranch.equals("main")) {
throw new MojoException(
"Cannot remove a subproject from a feature branch ('"
+ currentBranch + "'). Switch to 'main' first. "
+ "Workspace composition changes belong on main.");
}
// Verify clean working tree — no uncommitted changes
String status = gitStatus(subDir);
if (!status.isEmpty()) {
throw new MojoException(
"Cannot remove '" + subproject + "' — working tree has "
+ "uncommitted changes. Commit or stash first.");
}
}
// 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.");
}
getLog().info("");
getLog().info(header("Remove Subproject"));
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info(" Subproject: " + subproject);
if (!dependents.isEmpty()) {
getLog().warn(" Dependents (forced): " + dependents);
}
getLog().info("");
try {
// Remove from workspace.yaml
removeSubprojectFromManifest(manifestPath);
getLog().info(Ansi.green(" ✓ ") + "workspace.yaml updated — subproject entry removed");
// Remove profile from pom.xml
removeProfileFromPom(pomPath);
getLog().info(Ansi.green(" ✓ ") + "pom.xml updated — profile with-" + subproject + " removed");
} catch (IOException e) {
throw new MojoException(
"Failed to update workspace files: " + e.getMessage(), e);
}
// Optionally delete the cloned directory
if (deleteDir) {
Path subprojectDir = wsDir.resolve(subproject);
if (Files.isDirectory(subprojectDir)) {
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 + "**."
+ (deleteDir ? " Directory deleted." : "") + "\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 the file-activated profile for this subproject from pom.xml.
*
* <p>Matches the entire {@code <profile>} block whose
* {@code <id>} is {@code with-<subproject>}.
*/
void removeProfileFromPom(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 profileId = "with-" + subproject;
if (!pom.contains(profileId)) {
getLog().info(" - Profile " + profileId + " not found in pom.xml (already removed?)");
return;
}
// Match the entire <profile>...</profile> block containing this profile id.
// Allow flexible whitespace. The profile block ends at </profile>.
String escapedId = Pattern.quote(profileId);
Pattern profilePattern = Pattern.compile(
"\\s*<profile>\\s*\\n"
+ "\\s*<id>" + escapedId + "</id>\\s*\\n"
+ ".*?"
+ "\\s*</profile>\\s*\\n",
Pattern.DOTALL);
Matcher m = profilePattern.matcher(pom);
if (m.find()) {
pom = pom.substring(0, m.start()) + "\n" + pom.substring(m.end());
}
Files.writeString(pomPath, pom, StandardCharsets.UTF_8);
}
// ── Directory deletion ──────────────────────────────────────
/**
* Recursively delete a directory tree.
*/
private void deleteDirectory(Path dir) throws MojoException {
try {
// Walk the tree bottom-up and delete
Files.walk(dir)
.sorted(java.util.Comparator.reverseOrder())
.forEach(path -> {
try {
Files.delete(path);
} catch (IOException e) {
throw new RuntimeException(
"Failed to delete " + path + ": " + e.getMessage(), e);
}
});
} catch (IOException | RuntimeException e) {
throw new MojoException(
"Failed to delete directory " + dir + ": " + e.getMessage(), e);
}
}
}