SiblingRemovePublishMojo.java
package network.ike.plugin.ws;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.workspace.WorkingSet;
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.nio.file.Path;
import java.util.List;
/**
* Remove a sibling working set, safely (IKE-Network/ike-issues#600).
*
* <p>Three gates, in order:
* <ol>
* <li><b>The sibling's lease.</b> Removal is the last write a working
* set ever sees, so this machine confirms it holds the sibling's
* lease first ({@link WorkingSetLease}): free or expired acquires
* silently, live on another machine refuses — and {@code -Dforce}
* does <em>not</em> override that, because displacing a live holder
* is a human decision on every surface. Inert on machines without
* the lease protocol.</li>
* <li><b>The content preflight.</b> Uncommitted changes, stashes,
* unlanded branches (squash-aware), bare member trees — anything
* that dies with the tree refuses unless {@code -Dforce=true}
* discards it deliberately, with the findings persisted in the
* report either way.</li>
* <li><b>The deletion.</b> The whole sibling directory — whole-working-set
* removal is the supported operation; the sync layer propagates it
* and staggered file versioning is the net — followed by the lease
* record it leaves behind ({@code leases/<name>.lease}), the
* reconciliation daemon's GC action done eagerly
* (IKE-Network/ike-issues#1006).</li>
* </ol>
*
* <pre>{@code
* mvn ws:sibling-remove-draft -Dfeature=jira-456
* mvn ws:sibling-remove-publish -Dfeature=jira-456
* mvn ws:sibling-remove-publish -Dfeature=jira-456 -Dforce=true
* }</pre>
*
* @see SiblingRemoveDraftMojo for the read-only preview
*/
@Mojo(name = "sibling-remove-publish", projectRequired = false, aggregator = true)
public class SiblingRemovePublishMojo extends AbstractWorkspaceMojo {
/** Feature whose sibling ({@code <primary>꞉<feature>}) to remove. */
@Parameter(property = "feature")
String feature;
/** Explicit sibling directory name; alternative to {@code -Dfeature}. */
@Parameter(property = "name")
String name;
/**
* Discard uncommitted work, stashes, unlanded branches and bare trees
* deliberately. Never overrides a lease held live on another machine.
*/
@Parameter(property = "force", defaultValue = "false")
boolean force;
/** Creates this goal instance. */
public SiblingRemovePublishMojo() {}
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
WorkingSet workingSet = resolveWorkingSet();
File primaryRoot = isWorkspaceMode()
? workspaceRoot()
: workingSet.members().getFirst().directory().toFile();
SiblingRemoval.Target target = SiblingRemoval.resolve(
primaryRoot, workingSet.baseName(), feature, name);
getLog().info("");
getLog().info(header("Sibling Remove"));
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info(" Sibling: " + target.sibling().root().getAbsolutePath());
getLog().info("");
// Gate 1 — the sibling's lease. Removal is a write; force never
// reaches past a live holder on another machine.
WorkingSetLease.Decision decision = WorkingSetLease.confirm(
target.sibling().root().toPath());
switch (decision.verdict()) {
case NOT_APPLICABLE -> { }
case HELD -> getLog().info(" Working-set lease: confirmed for "
+ decision.detail());
case FENCED -> throw new MojoException("Another machine holds '"
+ target.sibling().name() + "' live; removing it from "
+ "here would delete a working set someone is in. Have "
+ "that machine release it (close the project / "
+ "lease.sh release), then re-run.\n" + decision.detail());
}
// Gate 2 — the content preflight, persisted whichever way it goes.
List<String[]> preflightRows = SiblingRemoval.preflightRows(target);
boolean clean = SiblingRemoval.clean(target);
GoalReportBuilder report = new GoalReportBuilder();
SiblingRemoval.describeTarget(report, target);
report.section("Preflight")
.table(List.of("", "Member", "Finding"), preflightRows);
if (!clean && !force) {
report.paragraph("**Refused.** The ✗ rows above would be lost. "
+ "Finish or commit them, or pass `-Dforce=true` to "
+ "discard them deliberately.");
throw new WorkspaceReportException("Sibling '"
+ target.sibling().name() + "' holds work that dies "
+ "with the tree (see the "
+ WsGoal.SIBLING_REMOVE_PUBLISH.qualified()
+ " report). Finish it, or -Dforce=true.",
new WorkspaceReportSpec(WsGoal.SIBLING_REMOVE_PUBLISH,
report.build()));
}
if (!clean) {
getLog().warn(" -Dforce=true: discarding the ✗ findings above.");
}
// Gate 3 — delete, then GC the lease record.
Path gcRecord = SiblingRemoval.delete(target);
getLog().info(" Removed " + target.sibling().root().getAbsolutePath());
if (gcRecord != null) {
getLog().info(" Lease record GC'd: " + gcRecord.getFileName());
}
report.section("Removed")
.paragraph("`" + target.sibling().root().getAbsolutePath()
+ "` (" + target.states().size() + " member "
+ "director" + (target.states().size() == 1 ? "y" : "ies")
+ (clean ? "" : ", force-discarded findings above")
+ "). The sync layer propagates the deletion to "
+ "every machine; staggered file versioning is the "
+ "recovery net."
+ (gcRecord == null ? ""
: " Lease record `" + gcRecord.getFileName()
+ "` garbage-collected."));
return new WorkspaceReportSpec(WsGoal.SIBLING_REMOVE_PUBLISH,
report.build());
}
}