PushMojo.java
package network.ike.plugin.ws;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.ws.vcs.VcsOperations;
import network.ike.plugin.ws.vcs.VcsState;
import network.ike.workspace.WorkingSet;
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.List;
import java.util.Optional;
/**
* Push with a VCS bridge catch-up preamble.
*
* <p>When run from a workspace root (where {@code workspace.yaml} exists),
* iterates the workspace's subprojects and root and pushes each. When run
* from a single repository, operates on that repository only. The scope is
* resolved via {@link #resolveWorkingSet()} (ike-issues#611).
*
* <p>Usage:
* <pre>{@code
* mvn ws:push
* }</pre>
*/
@Mojo(name = "push", projectRequired = false, aggregator = true)
public class PushMojo extends AbstractWorkspaceMojo {
/** Creates this goal instance. */
public PushMojo() {}
/**
* Remote name to push to.
*/
@Parameter(property = "remote", defaultValue = "origin")
String remote;
/**
* When {@code true}, the first push failure (e.g. non-fast-forward,
* authentication error) halts the goal with a {@link MojoException}
* instead of warning and continuing. Default {@code false} preserves
* the per-subproject best-effort behavior; {@link WsSyncMojo} sets
* this to {@code true} so a non-fast-forward leaves the workspace
* in a known state for the user to resolve, rather than appearing
* to succeed for some subprojects and silently failing for others.
*/
@Parameter(property = "failFast", defaultValue = "false")
boolean failFast;
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
WorkingSet workingSet = resolveWorkingSet();
getLog().info("");
getLog().info(header("Push"));
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info("");
List<PushRow> rows = 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");
rows.add(PushRow.notCloned(member.name()));
continue;
}
String label = workingSet.isWorkspace()
&& member.directory().equals(workingSet.root())
? "workspace root" : member.name();
rows.add(pushOne(dir, label));
}
int pushed = (int) rows.stream().filter(r -> r.outcome == Outcome.PUSHED).count();
int upToDate = (int) rows.stream().filter(r -> r.outcome == Outcome.UP_TO_DATE).count();
int skipped = (int) rows.stream().filter(r -> r.outcome == Outcome.NOT_CLONED).count();
int failed = (int) rows.stream().filter(r -> r.outcome == Outcome.FAILED).count();
int uncommitted = (int) rows.stream().filter(r -> r.uncommittedWorkLeft).count();
getLog().info("");
StringBuilder summary = new StringBuilder();
summary.append(pushed).append(" pushed");
if (upToDate > 0) summary.append(", ").append(upToDate).append(" up-to-date");
if (skipped > 0) summary.append(", ").append(skipped).append(" skipped");
if (uncommitted > 0) {
summary.append(", ").append(uncommitted)
.append(" with uncommitted changes");
}
if (failed > 0) summary.append(", ").append(failed).append(" failed");
getLog().info(" Done: " + summary);
getLog().info("");
if (failed > 0) {
getLog().warn(" Some pushes failed — check output above for details.");
if (failFast) {
// failFast hard-fails inside pushOne already; defensive guard.
throw new MojoException(failed + " push(es) failed.");
}
}
return new WorkspaceReportSpec(WsGoal.PUSH,
buildPushReport(rows, summary.toString()));
}
/**
* Push a single git directory and capture enough state to render
* an audit-quality row in the markdown report (#540): old/new SHA
* range, commit count + subjects, remote URL, and any failure
* message and retry command. Per-subproject failures are
* captured into the row rather than thrown, unless
* {@code failFast=true}.
*
* @param dir git directory to push
* @param label name to use in the report ("workspace root" or
* subproject name)
* @return populated {@link PushRow}
*/
private PushRow pushOne(File dir, String label) {
String branch;
try {
branch = VcsOperations.currentBranch(dir);
} catch (MojoException e) {
getLog().warn(Ansi.red(" ✗ ") + label + " — "
+ e.getMessage());
return PushRow.failure(label, "?", null, e.getMessage(),
"git push " + remote, null);
}
String bridgeNote = null;
try {
bridgeNote = VcsOperations.catchUp(dir, getLog()).note();
// Capture the pre-push state so we can express the SHA delta
// even after the push has moved origin/<branch> forward.
String preHead = VcsOperations.headSha(dir);
Optional<String> preRemote = VcsOperations.remoteSha(dir, remote, branch);
// No-op detection — if the remote already points at our HEAD,
// there is genuinely nothing to push. The original code
// ran the push regardless, which works (git says "Everything
// up-to-date") but loses the signal in the report.
if (preRemote.isPresent() && preRemote.get().equals(preHead)) {
String url = VcsOperations.remoteUrl(dir, remote).orElse(remote);
getLog().info(Ansi.green(" = ") + label + " → "
+ remote + "/" + branch
+ " (already up-to-date)");
return PushRow.upToDate(label, branch, preHead, url,
!VcsOperations.isClean(dir), bridgeNote);
}
// Snapshot commits about to land BEFORE pushing so we can
// include subject lines in the report. After push, @{u}
// converges with HEAD and the range disappears.
List<String> subjectsToPush;
int commitCount;
if (preRemote.isPresent()) {
List<String> commits = safeCommitLog(dir, preRemote.get(), preHead);
commitCount = commits.size();
subjectsToPush = capCommitSubjects(commits);
} else {
// New ref — count everything reachable from HEAD that
// isn't already on the remote's default. Cap subjects
// either way.
List<String> commits = safeCommitLog(dir, "HEAD~10", preHead);
commitCount = commits.size();
subjectsToPush = capCommitSubjects(commits);
}
try {
VcsOperations.push(dir, getLog(), remote, branch);
} catch (MojoException e) {
if (e.getMessage() != null
&& e.getMessage().contains("has no upstream")) {
getLog().info(" " + label
+ " — setting upstream and pushing...");
VcsOperations.pushWithUpstream(
dir, getLog(), remote, branch);
} else {
throw e;
}
}
VcsOperations.writeVcsState(dir, VcsState.Action.PUSH);
String url = VcsOperations.remoteUrl(dir, remote).orElse(remote);
getLog().info(Ansi.green(" ✓ ") + label + " → "
+ remote + "/" + branch
+ (preRemote.isPresent()
? " (" + shorten(preRemote.get())
+ ".." + shorten(preHead) + ", "
+ commitCount + " commit"
+ (commitCount == 1 ? "" : "s") + ")"
: " (new ref)"));
boolean uncommitted = !VcsOperations.isClean(dir);
if (uncommitted) {
getLog().warn(Ansi.yellow(" ⚠ ") + label
+ " — has uncommitted changes (not pushed)");
}
return PushRow.pushed(label, branch, preRemote.orElse(null),
preHead, commitCount, subjectsToPush, url, uncommitted, bridgeNote);
} catch (MojoException e) {
String retry = buildRetryCommand(label, branch);
if (failFast) {
throw new MojoException("push failed at " + label + ": "
+ e.getMessage(), e);
}
getLog().warn(Ansi.red(" ✗ ") + label + " — " + e.getMessage());
return PushRow.failure(label, branch, null, e.getMessage(), retry, bridgeNote);
}
}
/**
* Compose a copy-pasteable retry command for the per-subproject
* failure section. {@code workspace root} resolves to the root
* directory; subprojects get a {@code cd} prefix so the user can
* paste from anywhere under the workspace.
*/
private String buildRetryCommand(String label, String branch) {
if ("workspace root".equals(label)) {
return "git push " + remote + " " + branch;
}
return "(cd " + label + " && git push " + remote + " " + branch + ")";
}
/**
* {@code git rev-list a..b} returns commits *reachable from b but
* not from a*. We swallow MojoException here because the SHA
* range is a best-effort enrichment for the report — a failure
* to compute it must not mask the push outcome.
*/
private List<String> safeCommitLog(File dir, String base, String head) {
try {
return VcsOperations.commitLog(dir, base, head);
} catch (MojoException e) {
getLog().debug("Could not compute commit log " + base
+ ".." + head + ": " + e.getMessage());
return List.of();
}
}
private static String shorten(String sha) {
if (sha == null || sha.length() <= 7) return sha;
return sha.substring(0, 7);
}
private static List<String> capCommitSubjects(List<String> commits) {
if (commits.isEmpty()) return List.of();
int max = Math.min(5, commits.size());
List<String> capped = new ArrayList<>(max);
for (int i = 0; i < max; i++) {
String line = commits.get(i);
int sp = line.indexOf(' ');
capped.add(sp >= 0 ? line.substring(sp + 1) : line);
}
return capped;
}
/**
* Render the push markdown report — per-subproject SHA delta + commit
* subjects table, plus a Failures section with copy-pasteable retry
* commands when anything failed (#540 + drafts-actionable-remediation).
*/
private String buildPushReport(List<PushRow> rows, String summary) {
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph("**Result:** " + summary + ".");
List<String[]> tableRows = new ArrayList<>();
for (PushRow r : rows) {
String shaDelta;
String commits;
switch (r.outcome) {
case PUSHED -> {
shaDelta = (r.oldSha == null)
? "(new ref) → `" + shorten(r.newSha) + "`"
: "`" + shorten(r.oldSha) + "`..`" + shorten(r.newSha) + "`";
commits = String.valueOf(r.commitCount);
}
case UP_TO_DATE -> {
shaDelta = "`" + shorten(r.newSha) + "`";
commits = "0";
}
default -> {
shaDelta = "—";
commits = "—";
}
}
String status = switch (r.outcome) {
case PUSHED -> "✓ pushed";
case UP_TO_DATE -> "= up-to-date";
case NOT_CLONED -> "not cloned";
case FAILED -> "✗ failed";
};
if (r.uncommittedWorkLeft) {
status += " (uncommitted changes left behind)";
}
tableRows.add(new String[]{r.label, r.branch, shaDelta, commits, status});
}
report.section("Subprojects")
.table(List.of("Subproject", "Branch", "SHA delta",
"Commits", "Status"), tableRows);
// Detail: per-subproject commit subjects for the actually-pushed ones.
List<PushRow> withSubjects = rows.stream()
.filter(r -> r.outcome == Outcome.PUSHED
&& !r.subjects.isEmpty())
.toList();
if (!withSubjects.isEmpty()) {
report.section("Pushed commits");
for (PushRow r : withSubjects) {
report.bullet("**" + r.label + "** → `" + r.remoteUrl + "`");
for (String s : r.subjects) {
report.bullet(" • " + s);
}
if (r.commitCount > r.subjects.size()) {
report.bullet(" • +" + (r.commitCount - r.subjects.size())
+ " more");
}
}
}
// Bridge-state notes (ike-issues#819) — e.g. a stale .ike/vcs-state
// checkpoint that was self-healed because origin-parity was already
// confirmed. Informational, not actionable, so it sits ahead of
// Failures rather than reading as alarming.
List<PushRow> withNotes = rows.stream()
.filter(r -> r.bridgeNote != null && !r.bridgeNote.isBlank())
.toList();
if (!withNotes.isEmpty()) {
report.section("Notes");
for (PushRow r : withNotes) {
report.bullet("**" + r.label + "** — " + r.bridgeNote);
}
}
// Failures section — surface git stderr AND a copy-pasteable
// retry block. The user shouldn't have to re-derive the
// command from the failing subproject's name + branch.
List<PushRow> failures = rows.stream()
.filter(r -> r.outcome == Outcome.FAILED).toList();
if (!failures.isEmpty()) {
report.section("Failures");
for (PushRow r : failures) {
report.bullet("**" + r.label + "** — `" + r.failureMessage + "`");
}
StringBuilder retry = new StringBuilder();
for (PushRow r : failures) {
retry.append(r.retryCommand).append("\n");
}
report.paragraph("Retry the failed pushes individually:");
report.codeBlock("bash", retry.toString().stripTrailing());
}
return report.build();
}
/** Outcome state for a single subproject's push attempt. */
enum Outcome {PUSHED, UP_TO_DATE, NOT_CLONED, FAILED}
/**
* One row of per-subproject push detail. Capture-once, render-many:
* the CLI log emits a one-line summary as work progresses; the
* markdown report consumes the full record at the end of the goal.
*/
record PushRow(
String label,
String branch,
Outcome outcome,
String oldSha,
String newSha,
int commitCount,
List<String> subjects,
String remoteUrl,
boolean uncommittedWorkLeft,
String failureMessage,
String retryCommand,
String bridgeNote) {
static PushRow pushed(String label, String branch, String oldSha,
String newSha, int commits,
List<String> subjects, String remoteUrl,
boolean uncommitted, String bridgeNote) {
return new PushRow(label, branch, Outcome.PUSHED, oldSha, newSha,
commits, subjects, remoteUrl, uncommitted, null, null, bridgeNote);
}
static PushRow upToDate(String label, String branch, String headSha,
String remoteUrl, boolean uncommitted, String bridgeNote) {
return new PushRow(label, branch, Outcome.UP_TO_DATE, headSha,
headSha, 0, List.of(), remoteUrl, uncommitted, null, null, bridgeNote);
}
static PushRow notCloned(String name) {
return new PushRow(name, "—", Outcome.NOT_CLONED, null, null, 0,
List.of(), "—", false, null, null, null);
}
static PushRow failure(String label, String branch, String oldSha,
String message, String retry, String bridgeNote) {
return new PushRow(label, branch, Outcome.FAILED, oldSha, null, 0,
List.of(), "—", false, message, retry, bridgeNote);
}
}
}