PullWorkspaceMojo.java
package network.ike.plugin.ws;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.ws.vcs.VcsOperations;
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.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Pull latest changes across the workspace.
*
* <p>When the workspace root is itself a git repository (i.e., it has a
* {@code .git} directory), it is pulled first so any changes to the root
* POM or {@code workspace.yaml} land before subproject operations run.
* Runs {@code git pull --rebase} in each cloned subproject directory in
* topological order (dependencies first). Uninitialized components are
* skipped with a warning.
*
* <p><strong>Single repo (no {@code workspace.yaml})</strong>: operates on
* the current repository only — a working set of one — pulling its tracked
* branch with {@code git pull --rebase}. The scope is resolved via
* {@link #resolveWorkingSet()} (IKE-Network/ike-issues#703, completing the
* #611 working-set migration so {@code ws:pull} matches {@code ws:commit} /
* {@code ws:push}).
*
* <pre>{@code
* mvn ws:pull
* }</pre>
*/
@Mojo(name = "pull", projectRequired = false, aggregator = true)
public class PullWorkspaceMojo extends AbstractWorkspaceMojo {
/** Creates this goal instance. */
public PullWorkspaceMojo() {}
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
// Resolve the scope once: a workspace pulls its subprojects + root;
// a bare repo is a working set of one (IKE-Network/ike-issues#703,
// completing the #611 working-set migration).
WorkingSet workingSet = resolveWorkingSet();
if (!workingSet.isWorkspace()) {
return pullSingleRepo(workingSet.root().toFile(),
workingSet.members().getFirst().name());
}
return pullWorkspace();
}
/**
* Pull every member of a workspace: the workspace root first (so any
* update to the root POM or {@code workspace.yaml} is observed before
* subproject pulls, #179), then each cloned subproject in topological
* order. Gated by a workspace-wide working-tree-clean preflight
* (#132/#154).
*
* @return the pull report for the workspace
*/
private WorkspaceReportSpec pullWorkspace() throws MojoException {
WorkspaceGraph graph = loadGraph();
File root = workspaceRoot();
Set<String> targets = graph.manifest().subprojects().keySet();
List<String> sorted = graph.topologicalSort(new LinkedHashSet<>(targets));
// Sync goal (#780): rather than refuse on uncommitted work — the
// normal state of a Syncthing-bridged workspace — each repo's WIP is
// auto-stashed before its pull and re-applied after (see pullStashed).
printPullBanner();
List<PullRow> rows = new ArrayList<>();
// Pull workspace root if it has a .git directory (#179). Must run
// before subproject pulls so any update to the root POM or
// workspace.yaml is observed by downstream steps.
if (new File(root, ".git").exists()) {
rows.add(pullStashed(root, "workspace root", true));
}
for (String name : sorted) {
File dir = new File(root, name);
File gitDir = new File(dir, ".git");
if (!gitDir.exists()) {
getLog().info(Ansi.yellow(" ⚠ ") + name + " — not cloned, skipping");
rows.add(PullRow.notCloned(name));
continue;
}
rows.add(pullStashed(dir, name, false));
}
WorkspaceReportSpec spec = pullReport(rows);
PostMutationSync.refresh(root, getLog());
return spec;
}
/**
* Pull a single repository — a working set of one (no
* {@code workspace.yaml}, IKE-Network/ike-issues#703). Applies the same
* {@code git pull --rebase} per-repo logic the workspace path uses for
* each member, with no workspace preflight ({@code git}'s own rebase
* guard rejects a dirty tree) and no {@link PostMutationSync}
* (workspace-only).
*
* @param dir the single repository's directory
* @param name the repository's directory name, used as the report label
* @return the pull report for the one repository
*/
private WorkspaceReportSpec pullSingleRepo(File dir, String name) {
printPullBanner();
if (!new File(dir, ".git").exists()) {
throw new MojoException("ws:pull: " + dir
+ " is not a git repository.");
}
return pullReport(List.of(pullStashed(dir, name, true)));
}
/**
* Pull {@code dir}, auto-stashing any uncommitted work first and restoring
* it after (#780) — so a routine pull never refuses on the in-flight edits
* that are the normal state of a Syncthing-bridged workspace. Restore runs
* in a {@code finally}; a restore failure is reported (the WIP survives on
* {@code refs/ws-stash}) rather than masking the pull result.
*
* @param dir the repository directory
* @param label the report label
* @param atCwd whether {@code dir} is the invocation directory
* @return the pull report row for {@code dir}
* @throws MojoException if the tree is dirty but cannot be stashed (e.g.
* {@code git user.email} is unset)
*/
private PullRow pullStashed(File dir, String label, boolean atCwd)
throws MojoException {
boolean stashed = AutoStashGuard.stashIfDirty(dir, getLog());
try {
return pullOne(dir, label, atCwd);
} finally {
try {
AutoStashGuard.restoreIfStashed(dir, getLog(), stashed);
} catch (MojoException e) {
getLog().warn(" could not restore stashed WIP in " + label
+ " — recover it from refs/ws-stash. " + e.getMessage());
}
}
}
/** Print the goal banner, shared by the workspace and bare paths. */
private void printPullBanner() {
getLog().info("");
getLog().info(header("Pull"));
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info("");
}
/**
* Tally the per-repo outcomes, log the one-line summary, and build the
* {@link WorkspaceReportSpec}. Shared by the workspace and single-repo
* paths so both render an identical report shape.
*
* @param rows the per-repository pull outcomes
* @return the pull report
*/
private WorkspaceReportSpec pullReport(List<PullRow> rows) {
int pulled = (int) rows.stream().filter(r -> r.outcome == Outcome.PULLED).count();
int upToDate = (int) rows.stream().filter(r -> r.outcome == Outcome.UP_TO_DATE).count();
int noUpstream = (int) rows.stream().filter(r -> r.outcome == Outcome.NO_UPSTREAM).count();
int notCloned = (int) rows.stream().filter(r -> r.outcome == Outcome.NOT_CLONED).count();
int failed = (int) rows.stream().filter(r -> r.outcome == Outcome.FAILED).count();
getLog().info("");
StringBuilder summary = new StringBuilder();
summary.append(pulled).append(" pulled");
if (upToDate > 0) summary.append(", ").append(upToDate).append(" up-to-date");
if (noUpstream > 0) summary.append(", ").append(noUpstream).append(" without upstream");
if (notCloned > 0) summary.append(", ").append(notCloned).append(" not cloned");
if (failed > 0) summary.append(", ").append(failed).append(" failed");
getLog().info(" Done: " + summary);
getLog().info("");
if (failed > 0) {
getLog().warn(" Some pulls failed — check output above for details.");
}
return new WorkspaceReportSpec(WsGoal.PULL,
buildPullReport(rows, summary.toString()));
}
/**
* Pull a single git directory and capture enough state to render
* an audit-quality row in the markdown report (#541): pre/post
* HEAD SHA, commit count + subjects merged in, explicit
* up-to-date detection, no-upstream detection with a copy-paste
* recovery command, and failure detail with a retry command.
*
* @param dir the git directory to pull
* @param label the report label ("workspace root", a subproject name,
* or a single repository's directory name)
* @param atCwd {@code true} when {@code dir} is the invocation directory
* (the workspace root, or the lone repo of a working set of
* one) — recovery commands are then emitted plain; {@code
* false} for a subproject, which gets a {@code cd} prefix
* @return the populated row
*/
private PullRow pullOne(File dir, String label, boolean atCwd) {
String branch;
try {
branch = VcsOperations.currentBranch(dir);
} catch (MojoException e) {
getLog().warn(Ansi.red(" ✗ ") + label + " — "
+ e.getMessage());
return PullRow.failure(label, "?", e.getMessage(),
buildRetryCommand(label, atCwd));
}
// Detect "no upstream" up front. ReleaseSupport.exec routes
// git's stderr through the Maven logger but the resulting
// MojoException only carries "Command failed (exit N)", so
// post-failure string-matching on the exception message can't
// distinguish a missing-upstream case from a generic network
// error. Pre-check is reliable and surfaces the right
// recovery command (drafts-actionable-remediation).
if (VcsOperations.aheadBehindUpstream(dir).isEmpty()) {
getLog().warn(Ansi.yellow(" ⚠ ") + label
+ " — no upstream configured");
return PullRow.noUpstream(label, branch,
buildSetUpstreamCommand(label, branch, atCwd));
}
String preHead;
try {
preHead = VcsOperations.headSha(dir);
} catch (MojoException e) {
getLog().warn(Ansi.red(" ✗ ") + label + " — "
+ e.getMessage());
return PullRow.failure(label, branch, e.getMessage(),
buildRetryCommand(label, atCwd));
}
getLog().info(Ansi.cyan(" ↓ ") + label);
try {
ReleaseSupport.exec(dir, getLog(),
"git", "pull", "--rebase");
} catch (MojoException e) {
String msg = String.valueOf(e.getMessage());
getLog().warn(Ansi.red(" ✗ ") + label + " — pull failed: "
+ msg);
return PullRow.failure(label, branch, msg,
buildRetryCommand(label, atCwd));
}
String postHead;
try {
postHead = VcsOperations.headSha(dir);
} catch (MojoException e) {
// The pull succeeded but we can't read HEAD — treat as
// pulled with unknown delta rather than fabricating one.
getLog().debug("Could not read HEAD after pull in " + label
+ ": " + e.getMessage());
return PullRow.pulledUnknown(label, branch);
}
if (preHead.equals(postHead)) {
getLog().info(Ansi.green(" = ") + label
+ " — already up-to-date (" + shorten(preHead) + ")");
return PullRow.upToDate(label, branch, preHead);
}
List<String> commits = safeCommitLog(dir, preHead, postHead);
int commitCount = commits.size();
List<String> subjects = capCommitSubjects(commits);
getLog().info(Ansi.green(" ✓ ") + label + " — "
+ shorten(preHead) + ".." + shorten(postHead)
+ " (" + commitCount + " commit"
+ (commitCount == 1 ? "" : "s") + ")");
return PullRow.pulled(label, branch, preHead, postHead,
commitCount, subjects);
}
private static String buildRetryCommand(String label, boolean atCwd) {
if (atCwd) {
return "git pull --rebase";
}
return "(cd " + label + " && git pull --rebase)";
}
private static String buildSetUpstreamCommand(String label, String branch,
boolean atCwd) {
if (atCwd) {
return "git push -u origin " + branch;
}
return "(cd " + label + " && git push -u origin " + branch + ")";
}
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 pull markdown report — Subprojects table with the
* per-subproject SHA delta and a Merged commits section listing
* subjects (#541). Failures and no-upstream cases both get a
* copy-pasteable recovery command (drafts-actionable-remediation).
*/
private String buildPullReport(List<PullRow> rows, String summary) {
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph("**Result:** " + summary + ".");
List<String[]> tableRows = new ArrayList<>();
for (PullRow r : rows) {
String shaDelta;
String commits;
switch (r.outcome) {
case PULLED -> {
shaDelta = "`" + shorten(r.preHead) + "`..`"
+ shorten(r.postHead) + "`";
commits = String.valueOf(r.commitCount);
}
case UP_TO_DATE -> {
shaDelta = "`" + shorten(r.preHead) + "`";
commits = "0";
}
default -> {
shaDelta = "—";
commits = "—";
}
}
String status = switch (r.outcome) {
case PULLED -> "✓ pulled (rebase)";
case UP_TO_DATE -> "= up-to-date";
case NO_UPSTREAM -> "⚠ no upstream";
case NOT_CLONED -> "not cloned";
case FAILED -> "✗ failed";
};
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 subject lines for the actually-pulled ones.
List<PullRow> withSubjects = rows.stream()
.filter(r -> r.outcome == Outcome.PULLED
&& !r.subjects.isEmpty()).toList();
if (!withSubjects.isEmpty()) {
report.section("Merged commits");
for (PullRow r : withSubjects) {
report.bullet("**" + r.label + "** — `"
+ shorten(r.preHead) + "`..`"
+ shorten(r.postHead) + "`");
for (String s : r.subjects) {
report.bullet(" • " + s);
}
if (r.commitCount > r.subjects.size()) {
report.bullet(" • +" + (r.commitCount - r.subjects.size())
+ " more");
}
}
}
// Subprojects whose branch has no upstream — copy-paste recovery.
List<PullRow> noUpstream = rows.stream()
.filter(r -> r.outcome == Outcome.NO_UPSTREAM).toList();
if (!noUpstream.isEmpty()) {
report.section("No upstream configured");
report.paragraph("These subprojects' current branch has no "
+ "tracking branch on `origin`. To set one and "
+ "establish a baseline:");
StringBuilder fix = new StringBuilder();
for (PullRow r : noUpstream) {
fix.append(r.retryCommand).append("\n");
}
report.codeBlock("bash", fix.toString().stripTrailing());
}
List<PullRow> failures = rows.stream()
.filter(r -> r.outcome == Outcome.FAILED).toList();
if (!failures.isEmpty()) {
report.section("Failures");
for (PullRow r : failures) {
report.bullet("**" + r.label + "** — `" + r.failureMessage + "`");
}
StringBuilder retry = new StringBuilder();
for (PullRow r : failures) {
retry.append(r.retryCommand).append("\n");
}
report.paragraph("Retry the failed pulls individually:");
report.codeBlock("bash", retry.toString().stripTrailing());
}
return report.build();
}
/** Outcome state for a single subproject's pull attempt. */
enum Outcome {PULLED, UP_TO_DATE, NO_UPSTREAM, NOT_CLONED, FAILED}
/**
* One row of per-subproject pull 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 PullRow(
String label,
String branch,
Outcome outcome,
String preHead,
String postHead,
int commitCount,
List<String> subjects,
String failureMessage,
String retryCommand) {
static PullRow pulled(String label, String branch, String preHead,
String postHead, int commits,
List<String> subjects) {
return new PullRow(label, branch, Outcome.PULLED, preHead, postHead,
commits, subjects, null, null);
}
static PullRow pulledUnknown(String label, String branch) {
return new PullRow(label, branch, Outcome.PULLED, null, null,
0, List.of(), null, null);
}
static PullRow upToDate(String label, String branch, String headSha) {
return new PullRow(label, branch, Outcome.UP_TO_DATE,
headSha, headSha, 0, List.of(), null, null);
}
// A NO_UPSTREAM row never also FAILS, so it reuses the
// retryCommand slot to carry its set-upstream recovery command.
static PullRow noUpstream(String label, String branch,
String recoveryCommand) {
return new PullRow(label, branch, Outcome.NO_UPSTREAM,
null, null, 0, List.of(), null, recoveryCommand);
}
static PullRow notCloned(String name) {
return new PullRow(name, "—", Outcome.NOT_CLONED,
null, null, 0, List.of(), null, null);
}
static PullRow failure(String label, String branch, String message,
String retry) {
return new PullRow(label, branch, Outcome.FAILED,
null, null, 0, List.of(), message, retry);
}
}
}