OverviewWorkspaceMojo.java
package network.ike.plugin.ws;
import network.ike.workspace.Subproject;
import network.ike.workspace.Dependency;
import network.ike.workspace.WorkspaceGraph;
import network.ike.plugin.support.GoalReportBuilder;
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.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Consolidated workspace overview — manifest, graph, status, cascade.
*
* <p>Replaces the former separate {@code ws:dashboard}, {@code ws:status},
* and {@code ws:graph} goals with a single command. Loads the manifest
* once and presents four sections:
*
* <ol>
* <li><b>Manifest</b> — subproject count, consistency check</li>
* <li><b>Graph</b> — dependency order with direct dependencies</li>
* <li><b>Status</b> — branch, SHA, clean/uncommitted per subproject</li>
* <li><b>Cascade</b> — downstream rebuild impact of components with
* uncommitted changes</li>
* </ol>
*
* <p>Use {@code -Dformat=dot} to output Graphviz DOT format instead
* of the overview (delegates to graph rendering).
*
* <pre>{@code
* mvn ws:overview
* mvn ws:overview -Dformat=dot
* }</pre>
*/
@Mojo(name = "overview", projectRequired = false, aggregator = true)
public class OverviewWorkspaceMojo extends AbstractWorkspaceMojo {
/** Creates this goal instance. */
public OverviewWorkspaceMojo() {}
/** Output format: "overview" (default) or "dot" (Graphviz DOT). */
@Parameter(property = "format", defaultValue = "overview")
String format;
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
WorkspaceGraph graph = loadGraph();
// DOT mode — delegate to graph-only output
if ("dot".equalsIgnoreCase(format)) {
printDot(graph);
return new WorkspaceReportSpec(WsGoal.OVERVIEW,
"DOT format emitted to console — no report generated.\n");
}
File root = workspaceRoot();
getLog().info("");
getLog().info(header("Overview"));
getLog().info("══════════════════════════════════════════════════════════════");
// ── Section 1: Manifest ─────────────────────────────────────
List<String> errors = graph.verify();
getLog().info("");
if (errors.isEmpty()) {
getLog().info(Ansi.green(" ✓ ") + "Manifest: "
+ graph.manifest().subprojects().size()
+ " components — consistent");
} else {
getLog().warn(Ansi.red(" ✗ ") + "Manifest: "
+ errors.size() + " error(s)");
for (String error : errors) {
getLog().warn(" " + error);
}
}
// ── Section 2: Dependency Graph ─────────────────────────────
List<String> sorted = graph.topologicalSort();
getLog().info("");
getLog().info(" Graph (dependency order)");
getLog().info(" ──────────────────────────────────────────────────────");
List<String[]> graphRows = new ArrayList<>();
for (int i = 0; i < sorted.size(); i++) {
String name = sorted.get(i);
Subproject sub = graph.manifest().subprojects().get(name);
String deps = sub.dependsOn().isEmpty() ? "—"
: sub.dependsOn().stream()
.map(Dependency::subproject)
.collect(Collectors.joining(", "));
getLog().info(String.format(" %2d. %-24s → %s",
i + 1, name, deps));
graphRows.add(new String[]{String.valueOf(i + 1), name, deps});
}
// ── Section 3: Subproject Status ─────────────────────────────
Set<String> targets = graph.manifest().subprojects().keySet();
getLog().info("");
getLog().info(" Status");
getLog().info(" ──────────────────────────────────────────────────────");
getLog().info(String.format(" %-24s %-24s %-8s %s",
"COMPONENT", "BRANCH", "SHA", ""));
List<String> modifiedComponents = new ArrayList<>();
List<String[]> statusRows = new ArrayList<>();
int cloned = 0;
int notCloned = 0;
for (String name : targets) {
Subproject sub = graph.manifest().subprojects().get(name);
File dir = new File(root, name);
if (!dir.exists()) {
notCloned++;
getLog().info(String.format(" %-24s %-24s %-8s %s",
name, "—", "—", "not cloned"));
statusRows.add(new String[]{name, "—", "—", "not cloned"});
continue;
}
cloned++;
String branch = gitBranch(dir);
String sha = gitShortSha(dir);
String status = gitStatus(dir);
String marker;
if (status.isEmpty()) {
marker = Ansi.green("✓");
} else {
long count = status.lines().count();
marker = Ansi.red("✗") + " " + count + " changed";
modifiedComponents.add(name);
}
String branchCol = branch;
if (sub.branch() != null && !branch.equals(sub.branch())) {
branchCol = branch + Ansi.yellow(" ⚠");
}
getLog().info(String.format(" %-24s %-24s %-8s %s",
name, branchCol, sha, marker));
statusRows.add(new String[]{name, branch, sha,
status.isEmpty() ? "clean"
: "uncommitted (" + status.lines().count() + " files)"});
}
getLog().info("");
getLog().info(" " + cloned + " cloned, " + notCloned + " not cloned, "
+ modifiedComponents.size() + " with uncommitted changes");
// ── Section 4: Feature Branch Divergence ────────────────────
// If any subproject is on a feature branch, show how far it has
// diverged from main — helps developers keep long-lived branches
// up to date.
List<String[]> divergenceRows = new ArrayList<>();
boolean anyOnFeature = false;
int warnThreshold = 20;
for (String name : targets) {
File dir = new File(root, name);
if (!dir.exists() || !new File(dir, ".git").exists()) continue;
String branch = gitBranch(dir);
if (!branch.startsWith("feature/")) continue;
anyOnFeature = true;
try {
List<String> behind = VcsOperations.commitLog(dir, branch, "main");
List<String> ahead = VcsOperations.commitLog(dir, "main", branch);
String divergence;
String marker;
if (behind.isEmpty()) {
divergence = "up to date";
marker = Ansi.green("✓");
} else if (behind.size() >= warnThreshold) {
divergence = behind.size() + " behind, "
+ ahead.size() + " ahead";
marker = Ansi.red("⚠") + " consider ws:update-feature";
} else {
divergence = behind.size() + " behind, "
+ ahead.size() + " ahead";
marker = Ansi.yellow("·");
}
divergenceRows.add(new String[]{name, branch,
String.valueOf(behind.size()),
String.valueOf(ahead.size()), divergence});
if (!anyOnFeature) continue; // skip printing header until first
} catch (MojoException e) {
// Can't determine divergence (no main branch, etc.) — skip
divergenceRows.add(new String[]{name, branch,
"?", "?", "unknown"});
}
}
if (anyOnFeature) {
getLog().info("");
getLog().info(" Feature Branch Divergence (from main)");
getLog().info(" ──────────────────────────────────────────────────────");
for (String[] row : divergenceRows) {
String name = row[0];
int behind = "?".equals(row[2]) ? -1 : Integer.parseInt(row[2]);
int ahead = "?".equals(row[3]) ? -1 : Integer.parseInt(row[3]);
String marker;
String detail;
if (behind == 0) {
marker = Ansi.green("✓");
detail = "up to date";
} else if (behind < 0) {
marker = "?";
detail = "unknown";
} else if (behind >= warnThreshold) {
marker = Ansi.red("⚠");
detail = behind + " commit(s) behind main, "
+ ahead + " ahead — consider ws:update-feature";
} else {
marker = Ansi.yellow("·");
detail = behind + " commit(s) behind main, " + ahead + " ahead";
}
getLog().info(String.format(" %-24s %s %s",
name, marker, detail));
}
}
// ── Section 5: Cascade ──────────────────────────────────────
List<String[]> cascadeRows = new ArrayList<>();
if (!modifiedComponents.isEmpty()) {
Set<String> allAffected = new LinkedHashSet<>();
for (String sub : modifiedComponents) {
allAffected.addAll(graph.cascade(sub));
}
allAffected.removeAll(modifiedComponents);
if (!allAffected.isEmpty()) {
getLog().info("");
getLog().info(" Cascade — components needing rebuild:");
getLog().info(" ──────────────────────────────────────────────────────");
List<String> buildOrder = graph.topologicalSort(
new LinkedHashSet<>(allAffected));
for (String name : buildOrder) {
List<String> triggeredBy = new ArrayList<>();
Subproject sub = graph.manifest().subprojects().get(name);
if (sub != null) {
for (Dependency dep : sub.dependsOn()) {
if (modifiedComponents.contains(dep.subproject())
|| allAffected.contains(dep.subproject())) {
triggeredBy.add(dep.subproject());
}
}
}
String triggers = String.join(", ", triggeredBy);
getLog().info(" " + name + " ← " + triggers);
cascadeRows.add(new String[]{name, triggers});
}
}
}
getLog().info("");
// Structured markdown report
return new WorkspaceReportSpec(WsGoal.OVERVIEW, buildMarkdownReport(
errors, graphRows, statusRows, divergenceRows, cascadeRows,
cloned, notCloned, modifiedComponents.size(), graph));
}
// ── Markdown report ─────────────────────────────────────────────
private String buildMarkdownReport(List<String> manifestErrors,
List<String[]> graphRows,
List<String[]> statusRows,
List<String[]> divergenceRows,
List<String[]> cascadeRows,
int cloned, int notCloned,
int modified,
WorkspaceGraph graph) {
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph(manifestErrors.isEmpty()
? "**Manifest:** consistent."
: "**Manifest:** " + manifestErrors.size() + " error(s).");
// GraphViz dependency graph — IKE-DIAGRAMS.md mandates
// GraphViz for dependency graphs (IKE-Network/ike-issues#406).
report.section("Dependency Graph")
.table(List.of("#", "Subproject", "Dependencies"), graphRows)
.raw(DotGraphSupport.buildDotReportBlock(graph));
report.section("Status")
.paragraph(cloned + " cloned, " + notCloned + " not cloned, "
+ modified + " with uncommitted changes.")
.table(List.of("Subproject", "Branch", "SHA", "Status"),
statusRows);
if (!divergenceRows.isEmpty()) {
report.section("Feature Branch Divergence")
.table(List.of("Subproject", "Branch", "Behind", "Ahead",
"Status"), divergenceRows);
}
if (!cascadeRows.isEmpty()) {
report.section("Cascade")
.table(List.of("Subproject", "Triggered By"), cascadeRows);
}
return report.build();
}
// ── DOT output ──────────────────────────────────────────────────
private void printDot(WorkspaceGraph graph) {
for (String line
: DotGraphSupport.dotFromGraph(graph).split("\n")) {
getLog().info(line);
}
}
}