SiblingRemoval.java
package network.ike.plugin.ws;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.workspace.FeatureName;
import org.apache.maven.api.plugin.MojoException;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;
/**
* The shared substrate of {@code ws:sibling-remove-draft} and
* {@code -publish} (IKE-Network/ike-issues#600): target resolution,
* the safety preflight, and the deletion itself.
*
* <p>The preflight names exactly what dies with a deleted tree, per
* member repository: uncommitted changes, stashes, and unlanded branches
* (squash-aware — see {@link SiblingInventory}). Bare member trees are
* unverifiable, so they block too. {@code -Dforce=true} overrides those
* content checks; it never overrides a live lease on another machine —
* displacing a live holder stays a human decision on every surface.
*/
final class SiblingRemoval {
private SiblingRemoval() {}
/**
* The resolved removal target and its assessed state.
*
* @param sibling the sibling to remove
* @param states per-member assessment, root first
*/
record Target(SiblingInventory.Sibling sibling,
List<SiblingInventory.MemberState> states) {}
/**
* Resolves the removal target from the goal's parameters.
*
* @param primaryRoot the primary working set's root
* @param baseName the primary's directory name
* @param feature the {@code -Dfeature} value, or {@code null}
* @param name the {@code -Dname} value, or {@code null}
* @return the target, fully assessed (origins fetched first)
* @throws MojoException if neither or both parameters are given, the
* directory is missing, it is not a sibling of
* this primary, or the goal runs inside it
*/
static Target resolve(File primaryRoot, String baseName,
String feature, String name) throws MojoException {
boolean hasFeature = feature != null && !feature.isBlank();
boolean hasName = name != null && !name.isBlank();
if (hasFeature == hasName) {
throw new MojoException("Name the sibling to remove with exactly "
+ "one of -Dfeature=<name> (→ " + baseName
+ FeatureName.SIBLING_SEPARATOR + "<name>) or "
+ "-Dname=<directory>.");
}
String directoryName = hasName ? name.trim()
: AbstractWorkspaceMojo.validateFeatureName(feature.trim())
.siblingDirectoryName(baseName);
List<SiblingInventory.Sibling> siblings =
SiblingInventory.discover(primaryRoot, baseName);
SiblingInventory.Sibling sibling = siblings.stream()
.filter(candidate -> candidate.name().equals(directoryName))
.findFirst()
.orElseThrow(() -> new MojoException("'" + directoryName
+ "' is not a sibling of " + baseName + " (looked "
+ "beside " + primaryRoot.getAbsolutePath() + "). "
+ "`ws:sibling-list` shows what exists."));
if (isSelfOrAncestor(sibling.root(), primaryRoot)) {
throw new MojoException("Refusing: the goal is running inside "
+ "the sibling it would delete. Run it from the primary: "
+ "cd " + primaryRoot.getAbsolutePath());
}
return new Target(sibling,
SiblingInventory.assess(sibling.root(), false));
}
/**
* Assesses a sibling already in hand — the delete-on-finish path
* (IKE-Network/ike-issues#992), where the finish runs inside the
* sibling and needs no discovery or target resolution.
*
* @param siblingRoot the sibling's root directory
* @param conformant whether the local-origin chain is confirmed
* @return the target, fully assessed (origins fetched first)
*/
static Target assessKnown(File siblingRoot, boolean conformant) {
String name = siblingRoot.getName();
int separator = name.indexOf(FeatureName.SIBLING_SEPARATOR);
String feature = separator >= 0
? name.substring(separator + FeatureName.SIBLING_SEPARATOR.length())
: name;
return new Target(
new SiblingInventory.Sibling(name, siblingRoot, feature,
conformant),
SiblingInventory.assess(siblingRoot, false));
}
/**
* Renders the preflight into report rows and an overall verdict.
*
* @param target the resolved target
* @return rows of {@code [mark, member, finding]}; every row marked
* {@code ✗} blocks removal without {@code -Dforce=true}
*/
static List<String[]> preflightRows(Target target) {
List<String[]> rows = new ArrayList<>();
for (SiblingInventory.MemberState state : target.states()) {
List<String> findings = new ArrayList<>();
if (state.bare()) {
findings.add("bare tree — no git state to verify; its "
+ "content dies with the removal");
} else {
if (state.uncommitted() > 0) {
findings.add(state.uncommitted() + " uncommitted");
}
if (state.stashes() > 0) {
findings.add(state.stashes() + " stash(es) — stashes "
+ "live only in this repository");
}
if (state.unlanded() != null && !state.unlanded().isEmpty()) {
findings.add("unlanded: "
+ String.join(", ", state.unlanded()));
}
if (state.unlanded() == null) {
findings.add("landed-state not determinable "
+ "(no origin, or fetch failed)");
}
}
boolean ok = state.removable();
rows.add(new String[]{ok ? "✓" : "✗", state.path(),
ok ? "clean, landed" : String.join("; ", findings)});
}
return rows;
}
/**
* Reports whether every member passed the preflight.
*
* @param target the resolved target
* @return {@code true} when nothing blocks removal
*/
static boolean clean(Target target) {
return target.states().stream()
.allMatch(SiblingInventory.MemberState::removable);
}
/**
* Deletes the sibling tree, then garbage-collects its lease record —
* a record for a working set that no longer exists is the exact thing
* the reconciliation daemon's GC removes (IKE-Network/ike-issues#1006),
* done eagerly here. Removing a whole working set is the supported
* operation; the sync layer propagates the deletion and staggered file
* versioning is the net.
*
* @param target the resolved target
* @return the deleted lease-record path, or {@code null} when none
* existed
* @throws MojoException if the tree cannot be fully deleted
*/
static Path delete(Target target) throws MojoException {
Path root = target.sibling().root().toPath();
try (Stream<Path> tree = Files.walk(root)) {
for (Path path : tree.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
} catch (IOException e) {
throw new MojoException("Could not delete "
+ root + ": " + e.getMessage() + ". The tree may be "
+ "partially removed; re-run to finish.", e);
}
Path record = root.getParent()
.resolve("leases")
.resolve(target.sibling().name() + ".lease");
try {
return Files.deleteIfExists(record) ? record : null;
} catch (IOException e) {
// The record is now garbage either way; the daemon's GC or the
// operator can sweep it. Removal itself has succeeded.
return null;
}
}
/**
* The report preamble both goals share.
*
* @param report the report under construction
* @param target the resolved target
*/
static void describeTarget(GoalReportBuilder report, Target target) {
SiblingInventory.Sibling sibling = target.sibling();
report.paragraph("**Sibling:** `" + sibling.name() + "`")
.paragraph("**Feature:** `" + sibling.feature() + "`")
.paragraph("**Location:** `"
+ sibling.root().getAbsolutePath() + "`")
.paragraph("**Origin chain:** "
+ (sibling.conformant()
? "local parent ✓ (ike-issues#992)"
: "legacy remote-remote — landed-state is "
+ "measured against that remote"));
}
private static boolean isSelfOrAncestor(File sibling, File primaryRoot) {
try {
Path siblingPath = sibling.getCanonicalFile().toPath();
Path cwd = Path.of("").toAbsolutePath();
Path primary = primaryRoot.getCanonicalFile().toPath();
return cwd.startsWith(siblingPath) || primary.startsWith(siblingPath);
} catch (IOException e) {
return false;
}
}
}