WsCommitDraftMojo.java
package network.ike.plugin.ws;
import network.ike.plugin.ReleaseSupport;
import network.ike.workspace.WorkingSet;
import network.ike.workspace.WorkspaceGraph;
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;
/**
* Preview what {@code ws:commit-publish} would commit across the
* workspace — read-only.
*
* <p>The {@code -draft} half of the commit pair. For each repository
* (workspace root plus each cloned subproject) it runs
* {@code git status --porcelain} and reports the uncommitted work that
* {@link WsCommitPublishMojo ws:commit-publish} would stage and commit:
* every changed file with its porcelain status flag
* ({@code M}, {@code ??}, {@code A }, etc.). Repos with nothing to
* commit are reported clean.
*
* <p>Read-only: no VCS bridge catch-up, no {@code git add}, no
* {@code git commit}, no push, and no {@code -Dmessage} required. The
* {@code .mvn/jvm.config} preflight lint still runs as a hard gate — a
* hash-comment'd {@code jvm.config} would block the real commit, so the
* draft surfaces it the same way (ike-issues#217).
*
* <p>Usage:
* <pre>{@code
* mvn ws:commit-draft
* }</pre>
*
* @see WsCommitPublishMojo
*/
@Mojo(name = "commit-draft", projectRequired = false, aggregator = true)
public class WsCommitDraftMojo extends AbstractWorkspaceMojo {
/** Creates this goal instance. */
public WsCommitDraftMojo() {}
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
WorkingSet workingSet = resolveWorkingSet();
// Same pre-commit hygiene gate as ws:commit-publish (workspace
// mode): a #-comment'd .mvn/jvm.config would block the real commit,
// so the draft fails the same way rather than previewing a commit
// that cannot happen (ike-issues#217). Graph-scoped → workspace only.
if (workingSet.isWorkspace()) {
WorkspaceGraph graph = loadGraph();
network.ike.plugin.ws.preflight.Preflight.of(
java.util.List.of(network.ike.plugin.ws.preflight
.PreflightCondition.JVM_CONFIG_NO_HASH_COMMENTS),
network.ike.plugin.ws.preflight.PreflightContext.of(
workingSet.root().toFile(), graph,
graph.topologicalSort()))
.requirePassed(WsGoal.COMMIT_DRAFT);
}
getLog().info("");
getLog().info(header("Commit (draft)"));
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info("");
List<RepoStatus> statuses = new ArrayList<>();
for (WorkingSet.Member member : workingSet.members()) {
File dir = member.directory().toFile();
if (!new File(dir, ".git").exists()) {
getLog().debug(member.name() + " — not cloned, skipping");
continue;
}
String label = workingSet.isWorkspace()
&& member.directory().equals(workingSet.root())
? "(workspace root)" : member.name();
statuses.add(statusOf(dir, label));
}
int pending = 0;
int clean = 0;
for (RepoStatus s : statuses) {
printToConsole(s);
if (s.hasWork()) {
pending++;
} else {
clean++;
}
}
String summary = pending + " repo(s) with uncommitted work, "
+ clean + " clean";
getLog().info("");
getLog().info(" " + summary);
if (pending > 0) {
getLog().info(" Run "
+ WsGoal.COMMIT_PUBLISH.qualified()
+ " -Dmessage=\"...\" to commit.");
}
getLog().info("");
return new WorkspaceReportSpec(WsGoal.COMMIT_DRAFT,
buildReport(workingSet.root().toString(), statuses, summary,
pending));
}
// ── Per-repo status capture ─────────────────────────────────
/**
* Per-repo status snapshot: the repo's label and the raw
* {@code git status --porcelain} output lines (each carries the
* two-character status flag and the path).
*
* @param label the human-readable repo label
* @param porcelainLines one porcelain line per changed file;
* empty list means the repo is clean
*/
private record RepoStatus(String label, List<String> porcelainLines) {
boolean hasWork() { return !porcelainLines.isEmpty(); }
int fileCount() { return porcelainLines.size(); }
}
/**
* Run {@code git status --porcelain} in {@code dir} and capture its
* output as a {@link RepoStatus}. On failure logs a warning and
* returns a clean-looking status — the goal is read-only, so a
* scan failure is reported but does not abort the walk.
*
* @param dir the repo directory
* @param label the repo label for output
* @return the captured status (clean if porcelain is empty or
* the scan failed)
*/
private RepoStatus statusOf(File dir, String label) {
try {
String output = ReleaseSupport.execCapture(dir,
"git", "status", "--porcelain");
if (output == null || output.isBlank()) {
return new RepoStatus(label, List.of());
}
List<String> lines = new ArrayList<>();
for (String line : output.split("\n")) {
if (!line.isBlank()) {
lines.add(line);
}
}
return new RepoStatus(label, lines);
} catch (RuntimeException e) {
getLog().warn(label + " — git status failed: " + e.getMessage());
return new RepoStatus(label, List.of());
}
}
private void printToConsole(RepoStatus s) {
if (!s.hasWork()) {
getLog().info(" · " + s.label + " — clean");
return;
}
getLog().info(Ansi.yellow(" ⟳ ") + s.label + " — "
+ s.fileCount() + " file(s):");
for (String line : s.porcelainLines) {
getLog().info(" " + line);
}
}
/**
* Build the Markdown report body: a leading summary, then a
* section per non-clean repo listing every changed file as a
* bullet with its porcelain status flag.
*
* @param rootLabel the workspace root path (or single-repo path)
* @param statuses per-repo status snapshots
* @param summary the one-line summary used in the console
* @param pending how many repos have uncommitted work
* @return the Markdown report body
*/
private String buildReport(String rootLabel, List<RepoStatus> statuses,
String summary, int pending) {
StringBuilder body = new StringBuilder();
body.append("**Workspace:** `").append(rootLabel).append("`\n\n");
body.append(summary).append(".\n");
if (pending > 0) {
for (RepoStatus s : statuses) {
if (!s.hasWork()) continue;
body.append("\n### ").append(s.label).append(" — ")
.append(s.fileCount()).append(" file(s)\n");
for (String line : s.porcelainLines) {
body.append("- `").append(line).append("`\n");
}
}
body.append("\nRun `")
.append(WsGoal.COMMIT_PUBLISH.qualified())
.append(" -Dmessage=\"...\"` to commit.\n");
}
return body.toString();
}
}