SiblingListMojo.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 java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* List the primary's sibling working sets — first-class, scriptable
* visibility for what {@code ls} and eyeballing used to answer
* (IKE-Network/ike-issues#599).
*
* <p>Read-only: no fetch ever touches the network (origins that are
* remote URLs — the legacy shape — are assessed against their last-fetched
* refs and flagged), nothing acquires a lease, nothing mutates. Each
* sibling row carries what matters when deciding its fate:
* <ul>
* <li>the actual branch, flagged when it is not
* {@code feature/<suffix>};</li>
* <li>member count, bare members called out — a bare tree is
* unverifiable and dies silently with a removal;</li>
* <li>uncommitted paths, <b>stashes</b> (the one thing no upstream
* comparison reveals), and unlanded branches (squash-aware: a
* branch whose tree equals an origin tip's tree has landed);</li>
* <li>origin conformance — local parent ✓, or the legacy remote-remote
* shape awaiting repair (IKE-Network/ike-issues#992, #1057);</li>
* <li>the lease line, when this machine runs the lease protocol.</li>
* </ul>
*
* <pre>{@code mvn ws:sibling-list}</pre>
*
* @see SiblingRemoveDraftMojo for the removal preview these rows feed
*/
@Mojo(name = "sibling-list", projectRequired = false, aggregator = true)
public class SiblingListMojo extends AbstractWorkspaceMojo {
/** Creates this goal instance. */
public SiblingListMojo() {}
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
WorkingSet workingSet = resolveWorkingSet();
String baseName = workingSet.baseName();
File primaryRoot = isWorkspaceMode()
? workspaceRoot()
: workingSet.members().getFirst().directory().toFile();
List<SiblingInventory.Sibling> siblings =
SiblingInventory.discover(primaryRoot, baseName);
getLog().info("");
getLog().info(header("Sibling List"));
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info(" Primary: " + primaryRoot.getAbsolutePath());
getLog().info(" Siblings: " + siblings.size());
getLog().info("");
List<String[]> rows = new ArrayList<>();
for (SiblingInventory.Sibling sibling : siblings) {
List<SiblingInventory.MemberState> states =
SiblingInventory.assess(sibling.root(), true);
rows.add(row(sibling, states));
getLog().info(" " + sibling.name()
+ (sibling.conformant() ? "" : " [legacy origin]"));
}
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph("**Primary:** `" + primaryRoot.getAbsolutePath() + "`");
if (siblings.isEmpty()) {
report.paragraph("No siblings. `ws:feature-start-sibling-publish "
+ "-Dfeature=<name>` creates one beside the primary.");
} else {
report.section("Siblings")
.table(List.of("Sibling", "Feature", "Branch", "Members",
"Uncommitted", "Stashes", "Unlanded",
"Origin", "Lease"),
rows);
report.paragraph("*Unlanded is squash-aware (a branch whose tree "
+ "equals an origin tip has landed) and is measured "
+ "against last-fetched refs — remote-URL origins are "
+ "never fetched by a listing. `?` = not determinable. "
+ "Remove a finished sibling with "
+ "`ws:sibling-remove-draft -Dfeature=<name>`; repair a "
+ "legacy origin via the IDE notification or the "
+ "materializer CLI (ike-issues#1057).*");
}
return new WorkspaceReportSpec(WsGoal.SIBLING_LIST, report.build());
}
private String[] row(SiblingInventory.Sibling sibling,
List<SiblingInventory.MemberState> states) {
long bare = states.stream()
.filter(SiblingInventory.MemberState::bare).count();
int uncommitted = states.stream()
.mapToInt(SiblingInventory.MemberState::uncommitted).sum();
int stashes = states.stream()
.mapToInt(SiblingInventory.MemberState::stashes).sum();
boolean undeterminable = states.stream()
.anyMatch(state -> state.unlanded() == null && !state.bare());
long unlanded = states.stream()
.filter(state -> state.unlanded() != null)
.mapToLong(state -> state.unlanded().size()).sum();
String expectedBranch = "feature/" + sibling.feature();
String rootBranch = states.isEmpty() ? "—" : states.getFirst().branch();
String branch = rootBranch.equals(expectedBranch)
? rootBranch : rootBranch + " (≠ " + expectedBranch + ")";
String members = states.size()
+ (bare > 0 ? " (" + bare + " bare)" : "");
String origin = sibling.conformant() ? "local ✓" : "legacy";
String lease = WorkingSetLease.status(sibling.root().toPath())
.map(WorkingSetLease.Status::line).orElse("—");
return new String[]{sibling.name(), sibling.feature(), branch,
members, String.valueOf(uncommitted),
String.valueOf(stashes),
undeterminable ? unlanded + "?" : String.valueOf(unlanded),
origin, lease};
}
}