CommitMojo.java
package network.ike.plugin.ws;
import network.ike.plugin.ws.vcs.VcsOperations;
import network.ike.plugin.ws.vcs.VcsState;
import network.ike.workspace.WorkspaceGraph;
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.List;
/**
* Commit with a VCS bridge catch-up preamble.
*
* <p>By default stages all tracked-modified and untracked-not-ignored
* files before committing — workspace-wide goals routinely create new
* files (scaffold writes, IDE settings cleanup, generated configs) and
* the previous staged-only default silently dropped them. Pass
* {@code -DstagedOnly} to commit only what is already in the index for
* the rare cases where that is wanted (positive-form flag per the
* compiler-visibility principle).
*
* <p>Each subproject's commit line includes a count of modified vs. new
* files, with the new file paths listed inline so the developer can see
* what was pulled in without running {@code git status} after the fact:
*
* <pre>{@code
* ✓ komet-ws — 7 modified, 1 new (.idea/kotlinc.xml)
* }</pre>
*
* <p>When run from a workspace root (where {@code workspace.yaml} exists),
* iterates all subproject repositories in topological order, staging and
* committing changes in each. When run from a single repository, operates
* on the current directory only.
*
* <p>Usage:
* <pre>{@code
* mvn ws:commit -Dmessage="my commit message" # stage all + commit (default)
* mvn ws:commit -Dmessage="..." -DstagedOnly # commit only what is already staged
* mvn ws:commit -Dmessage="..." -Dpush=true # commit then push
* }</pre>
*
* <p>See issue #195 and the {@code dev-workspace-ops-completion} topic
* in {@code ike-lab-documents} for the design rationale.
*/
@Mojo(name = "commit", projectRequired = false, aggregator = true)
public class CommitMojo extends AbstractWorkspaceMojo {
/** Creates this goal instance. */
public CommitMojo() {}
/**
* Commit message. Required. When omitted on the command line, the
* goal prompts interactively (terminal or IntelliJ Maven runner)
* via {@link AbstractWorkspaceMojo#requireParam}. Throws a clear
* error when running in a non-interactive context (CI, piped
* input). The same resolved message is used for every repo in the
* workspace iteration.
*/
@Parameter(property = "message")
String message;
/**
* Commit only what is already in the index — skip the default
* {@code git add -A} step. Use this when you have hand-staged a
* subset of changes and want only those to land.
*/
@Parameter(property = "stagedOnly", defaultValue = "false")
boolean stagedOnly;
/**
* Push to origin after committing.
*/
@Parameter(property = "push", defaultValue = "false")
boolean push;
/**
* Skip the {@code .mvn/jvm.config} hash-comment lint check that
* runs before commit (ike-issues#217). Default is to run the
* check; pass {@code -Dws.commit.skipLint=true} to opt out (rare —
* the check exists because Maven's own validate phase can't catch
* a {@code #}-comment'd jvm.config in the project that contains it).
*/
@Parameter(property = "ws.commit.skipLint", defaultValue = "false")
boolean skipLint;
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
if (isWorkspaceMode()) {
return executeWorkspace();
}
executeSingleRepo(new File(System.getProperty("user.dir")));
return new WorkspaceReportSpec(WsGoal.COMMIT,
"Committed in single-repo mode (no workspace).\n");
}
private WorkspaceReportSpec executeWorkspace() throws MojoException {
WorkspaceGraph graph = loadGraph();
File root = workspaceRoot();
List<String> sorted = graph.topologicalSort();
// Pre-commit hygiene: catch # comments in .mvn/jvm.config files
// before they reach git or Syncthing. Maven's own validate phase
// can't catch this in the project that contains the bad file
// (the JVM dies before plugin code runs), so the transport
// boundary is the right gate (ike-issues#217). Hard-fail —
// committing the file would brick the affected machine.
if (!skipLint) {
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(
root, graph, sorted))
.requirePassed(WsGoal.COMMIT);
}
// Resolve the message before any work — prompts interactively
// when running in a terminal or IntelliJ's Maven runner, throws
// a clear error in non-interactive contexts (CI, piped input).
// One message applies to every repo in this invocation.
message = requireParam(message, "message", "Commit message");
getLog().info("");
getLog().info(header("Commit"));
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info("");
int committed = 0;
int skippedClean = 0;
int skippedUnstaged = 0;
int failed = 0;
// Include workspace root in commit scan (#102)
if (new File(root, ".git").exists()) {
CommitOutcome outcome = commitOne(root, "workspace root");
committed += outcome.committed;
skippedClean += outcome.skippedClean;
skippedUnstaged += outcome.skippedUnstaged;
failed += outcome.failed;
}
for (String name : sorted) {
File dir = new File(root, name);
File gitDir = new File(dir, ".git");
if (!gitDir.exists()) {
getLog().debug(name + " — not cloned, skipping");
skippedClean++;
continue;
}
CommitOutcome outcome = commitOne(dir, name);
committed += outcome.committed;
skippedClean += outcome.skippedClean;
skippedUnstaged += outcome.skippedUnstaged;
failed += outcome.failed;
}
getLog().info("");
var summary = new StringBuilder();
summary.append(committed).append(" committed");
if (skippedClean > 0) {
summary.append(", ").append(skippedClean).append(" clean");
}
if (skippedUnstaged > 0) {
summary.append(", ").append(skippedUnstaged)
.append(" skipped (uncommitted — drop -DstagedOnly to include)");
}
if (failed > 0) {
summary.append(", ").append(failed).append(" failed");
}
getLog().info(" Done: " + summary);
getLog().info("");
PostMutationSync.refresh(root, getLog());
if (failed > 0) {
throw new MojoException(failed
+ " commit(s) failed — check output above for details.");
}
return new WorkspaceReportSpec(WsGoal.COMMIT, summary + "\n");
}
/**
* Commit a single repository, returning a tally for aggregation. The
* tally always sums to one — exactly one of {committed, skippedClean,
* skippedUnstaged, failed} is set.
*/
private CommitOutcome commitOne(File dir, String label) {
try {
int modCount = VcsOperations.modifiedTrackedCount(dir);
List<String> newFiles = VcsOperations.untrackedFiles(dir);
// catch-up if there's nothing to commit yet — preserves
// the historical behavior where commit also serves as the
// "make sure local is current" step (#132).
boolean hasWork = stagedOnly
? VcsOperations.hasStagedChanges(dir)
: !VcsOperations.isClean(dir) || !newFiles.isEmpty();
if (!hasWork) {
VcsOperations.catchUp(dir, getLog());
}
if (!stagedOnly && !newFiles.isEmpty()) {
VcsOperations.addAll(dir, getLog());
} else if (!stagedOnly && modCount > 0
&& !VcsOperations.hasStagedChanges(dir)) {
// tracked-modified but none staged — still need addAll
VcsOperations.addAll(dir, getLog());
}
if (!VcsOperations.hasStagedChanges(dir)
&& VcsOperations.isClean(dir)) {
getLog().debug(label + " — clean, skipping");
return CommitOutcome.SKIPPED_CLEAN;
}
if (!VcsOperations.hasStagedChanges(dir)) {
// stagedOnly=true and the user didn't stage anything,
// but there are untracked or unstaged changes — surface
// both kinds so the developer sees exactly what would be
// dropped vs. what -DstagedOnly=false would pick up. (#231)
String suffix = formatUncommittedSuffix(
VcsOperations.unstagedFiles(dir), newFiles);
getLog().warn(Ansi.yellow(" ⚠ ") + label
+ " — skipped (" + suffix + ")");
getLog().warn(" Drop -DstagedOnly to stage and commit");
return CommitOutcome.SKIPPED_UNSTAGED;
}
VcsOperations.commit(dir, getLog(), message);
VcsOperations.writeVcsState(dir, VcsState.Action.COMMIT);
if (push) {
String branch = VcsOperations.currentBranch(dir);
VcsOperations.push(dir, getLog(), "origin", branch);
VcsOperations.writeVcsState(dir, VcsState.Action.PUSH);
}
getLog().info(Ansi.green(" ✓ ") + label
+ " — " + previewSummary(modCount, newFiles));
return CommitOutcome.COMMITTED;
} catch (MojoException e) {
getLog().warn(Ansi.red(" ✗ ") + label + " — " + e.getMessage());
return CommitOutcome.FAILED;
}
}
/**
* Build the parenthesized suffix for the {@code stagedOnly}
* skip message. Reports both tracked-unstaged and untracked work
* with file paths so the developer sees exactly what is sitting
* uncommitted (#231).
*
* <p>Format examples:
* <ul>
* <li>{@code "unstaged: a.java, b.java"} — tracked-unstaged only</li>
* <li>{@code "untracked: c.java, d.java"} — untracked only</li>
* <li>{@code "unstaged: a.java; untracked: b.java"} — both</li>
* </ul>
*
* @param unstagedFiles comma-separated list of tracked-modified-but-
* unstaged file paths (from
* {@link VcsOperations#unstagedFiles})
* @param newFiles list of untracked-but-not-ignored file paths
* (from {@link VcsOperations#untrackedFiles})
* @return the suffix to interpolate inside {@code "skipped (...)"}
*/
static String formatUncommittedSuffix(String unstagedFiles, List<String> newFiles) {
StringBuilder sb = new StringBuilder();
if (unstagedFiles != null && !unstagedFiles.isEmpty()) {
sb.append("unstaged: ").append(unstagedFiles);
}
if (newFiles != null && !newFiles.isEmpty()) {
if (sb.length() > 0) sb.append("; ");
sb.append("untracked: ").append(String.join(", ", newFiles));
}
if (sb.length() == 0) {
// Defensive: caller indicated uncommitted work, but no files
// were captured. Emit a placeholder rather than empty parens.
sb.append("uncommitted");
}
return sb.toString();
}
/**
* Format a one-line summary like {@code "7 modified, 1 new
* (.idea/kotlinc.xml)"}. New file paths are listed inline so the
* developer can see at a glance what {@code addAll} pulled in.
*/
private static String previewSummary(int modCount, List<String> newFiles) {
StringBuilder sb = new StringBuilder();
sb.append(modCount).append(" modified");
if (!newFiles.isEmpty()) {
sb.append(", ").append(newFiles.size()).append(" new (");
int max = Math.min(3, newFiles.size());
for (int i = 0; i < max; i++) {
if (i > 0) sb.append(", ");
sb.append(newFiles.get(i));
}
if (newFiles.size() > max) {
sb.append(", +").append(newFiles.size() - max).append(" more");
}
sb.append(")");
}
return sb.toString();
}
private void executeSingleRepo(File dir) throws MojoException {
// Resolve message before doing anything else — prompts when run
// interactively, throws clearly when piped/CI.
message = requireParam(message, "message", "Commit message");
getLog().info("");
getLog().info("IKE VCS Bridge — Commit");
getLog().info("══════════════════════════════════════════════════════════════");
VcsOperations.catchUp(dir, getLog());
if (!stagedOnly) {
getLog().info(" Staging all changes...");
VcsOperations.addAll(dir, getLog());
}
getLog().info(" Committing...");
VcsOperations.commit(dir, getLog(), message);
VcsOperations.writeVcsState(dir, VcsState.Action.COMMIT);
if (push) {
String branch = VcsOperations.currentBranch(dir);
getLog().info(" Pushing to origin/" + branch + "...");
VcsOperations.push(dir, getLog(), "origin", branch);
VcsOperations.writeVcsState(dir, VcsState.Action.PUSH);
}
getLog().info("");
getLog().info(" Done.");
getLog().info("");
}
/** Per-repo outcome tally. Exactly one counter is set per repo. */
private record CommitOutcome(int committed, int skippedClean,
int skippedUnstaged, int failed) {
static final CommitOutcome COMMITTED = new CommitOutcome(1, 0, 0, 0);
static final CommitOutcome SKIPPED_CLEAN = new CommitOutcome(0, 1, 0, 0);
static final CommitOutcome SKIPPED_UNSTAGED = new CommitOutcome(0, 0, 1, 0);
static final CommitOutcome FAILED = new CommitOutcome(0, 0, 0, 1);
}
}