RefreshMainSupport.java
package network.ike.plugin.ws;
import network.ike.plugin.ws.vcs.VcsOperations;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.MojoException;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* Refresh local main from origin/main as a workspace invariant.
*
* <p>Goals that read or write local main (update-feature, feature-finish,
* feature-start, release goals) call this helper first so the operation
* runs against current main rather than whatever stale state happens to
* be on the local machine. Particularly relevant in the workspace's
* Syncthing + independent-{@code .git} architecture, where each machine
* evolves its local {@code main} ref independently between push/pull
* cycles.
*
* <p>For each subproject, the helper:
* <ol>
* <li>Fetches origin to update remote-tracking refs.</li>
* <li>Compares local main to {@code origin/main}:
* <ul>
* <li><b>Up to date</b> — no-op.</li>
* <li><b>Behind only</b> — fast-forward local main.</li>
* <li><b>Ahead only</b> — leave local main alone (unpushed
* commits remain intact); the caller's downstream merge will
* include them.</li>
* <li><b>Diverged</b> — auto-resolve by merging
* {@code origin/main} into local main with {@code --no-ff}.
* The Syncthing architecture means working-tree content is
* usually pre-aligned, so the merge is usually conflict-free.
* If file conflicts <i>are</i> predicted, return a
* {@link Conflicts} outcome without touching the working
* tree — the caller decides whether to hard-error.</li>
* </ul>
* </li>
* </ol>
*
* <p>Auto-resolve merge commits are local-only. They are never pushed
* to the remote by this helper; the user publishes via
* {@code ws:push} / {@code ws:sync}.
*
* <p>The helper restores the caller's checked-out branch when it
* temporarily switches to main to perform an auto-resolve merge.
*
* <p>See ike-issues#284.
*/
final class RefreshMainSupport {
/** Default git remote name used for the {@code origin/main} comparison. */
static final String DEFAULT_REMOTE = "origin";
private RefreshMainSupport() {}
// ── Outcome model ───────────────────────────────────────────
/**
* Outcome of refreshing local main in a single subproject.
*/
sealed interface Outcome
permits Skipped, UpToDate, FastForwarded, CreatedFromRemote,
AheadOnly, AutoResolved, Conflicts {
/** Subproject name this outcome applies to. */
String component();
}
/** Subproject was skipped (not initialized as a git repo). */
record Skipped(String component, String reason) implements Outcome {}
/** Local main equals {@code origin/main}. No work performed. */
record UpToDate(String component) implements Outcome {}
/** Local main was fast-forwarded {@code commits} commit(s) to {@code origin/main}. */
record FastForwarded(String component, int commits) implements Outcome {}
/** Local main did not exist; created by tracking {@code origin/main}. */
record CreatedFromRemote(String component) implements Outcome {}
/**
* Local main is purely ahead of {@code origin/main}: it contains
* {@code unpushed} commit(s) that the remote does not. The local
* ref is intentionally left alone — the caller's merge will
* use it, and the user publishes via {@code ws:push}.
*/
record AheadOnly(String component, int unpushed) implements Outcome {}
/**
* Local main and {@code origin/main} had diverged; resolved by
* merging {@code origin/main} into local main with no file
* conflicts. {@code localCommits} is the count of unpushed local
* commits preserved in the merge; {@code remoteCommits} is the
* count brought in from {@code origin/main}.
*/
record AutoResolved(String component,
int localCommits,
int remoteCommits) implements Outcome {}
/**
* Local main and {@code origin/main} had diverged AND merging
* {@code origin/main} into local main would produce file conflicts.
* The working tree was not touched — the caller decides
* whether to hard-error or report and continue.
*/
record Conflicts(String component, List<String> files) implements Outcome {}
// ── Operations ──────────────────────────────────────────────
/**
* Refresh local main in a single subproject.
*
* @param subprojectDir the subproject root directory
* @param component subproject name (used in outcome labelling)
* @param mainBranch the conceptual main branch (e.g. {@code "main"})
* @param remote remote name (e.g. {@code "origin"})
* @param log Maven logger
* @return outcome describing what was done
* @throws MojoException if a git operation fails for an unexpected reason
*/
static Outcome refresh(File subprojectDir, String component,
String mainBranch, String remote, Log log)
throws MojoException {
File gitDir = new File(subprojectDir, ".git");
if (!gitDir.exists()) {
return new Skipped(component, "not initialized");
}
if (!network.ike.plugin.ReleaseSupport.hasRemote(subprojectDir, remote)) {
return new Skipped(component, "no '" + remote + "' remote");
}
VcsOperations.fetch(subprojectDir, log);
String remoteRef = remote + "/" + mainBranch;
// Fresh-clone case: local main does not exist yet.
if (!VcsOperations.localBranchExists(subprojectDir, mainBranch)) {
VcsOperations.fetchRef(subprojectDir, log, remote, mainBranch);
return new CreatedFromRemote(component);
}
boolean localIsAncestor = VcsOperations.isAncestor(
subprojectDir, mainBranch, remoteRef);
boolean remoteIsAncestor = VcsOperations.isAncestor(
subprojectDir, remoteRef, mainBranch);
if (localIsAncestor && remoteIsAncestor) {
return new UpToDate(component);
}
if (localIsAncestor) {
int behind = VcsOperations.commitLog(
subprojectDir, mainBranch, remoteRef).size();
fastForwardLocalMain(subprojectDir, log, mainBranch, remote, remoteRef);
return new FastForwarded(component, behind);
}
if (remoteIsAncestor) {
int ahead = VcsOperations.commitLog(
subprojectDir, remoteRef, mainBranch).size();
return new AheadOnly(component, ahead);
}
// Diverged.
List<String> predicted = VcsOperations.predictConflicts(
subprojectDir, mainBranch, remoteRef);
if (!predicted.isEmpty()) {
return new Conflicts(component, predicted);
}
int localCommits = VcsOperations.commitLog(
subprojectDir, remoteRef, mainBranch).size();
int remoteCommits = VcsOperations.commitLog(
subprojectDir, mainBranch, remoteRef).size();
autoResolveDiverged(subprojectDir, log, mainBranch, remoteRef);
return new AutoResolved(component, localCommits, remoteCommits);
}
/**
* Refresh local main across a list of subprojects, in the given
* order. Returns one outcome per subproject. The caller decides
* how to surface conflict outcomes — typically by raising
* a {@link MojoException} that stops the orchestrating goal with
* a clear instruction.
*
* @param workspaceRoot workspace root directory
* @param components subproject names to refresh, in order
* @param mainBranch the conceptual main branch (e.g. {@code "main"})
* @param remote remote name (e.g. {@code "origin"})
* @param log Maven logger
* @return outcomes in the same order as {@code components}
* @throws MojoException if a git operation fails for an unexpected reason
*/
static List<Outcome> refreshAll(File workspaceRoot,
List<String> components,
String mainBranch,
String remote,
Log log) throws MojoException {
List<Outcome> results = new ArrayList<>(components.size());
for (String name : components) {
File dir = new File(workspaceRoot, name);
results.add(refresh(dir, name, mainBranch, remote, log));
}
return results;
}
/** Filter outcomes to just the {@link Conflicts} entries. */
static List<Conflicts> conflictsIn(List<Outcome> outcomes) {
List<Conflicts> result = new ArrayList<>();
for (Outcome o : outcomes) {
if (o instanceof Conflicts c) result.add(c);
}
return result;
}
/**
* Orchestrate the standard refresh-or-stop flow: log a header,
* call {@link #refreshAll}, log each per-subproject outcome,
* and throw a {@link MojoException} if any subproject produced a
* {@link Conflicts} outcome (the genuine "two machines edited the
* same file on main without push/pull" case). Returns the outcome
* list when the refresh is conflict-free, so callers can include
* it in their reports if desired.
*
* @param workspaceRoot workspace root directory
* @param components subproject names to refresh, in order
* @param mainBranch the conceptual main branch (e.g. {@code "main"})
* @param log Maven logger
* @return outcomes (caller may inspect for non-conflict variants)
* @throws MojoException if any conflicts arise, with file-level detail
* and resolution instructions
*/
static List<Outcome> refreshOrThrow(File workspaceRoot,
List<String> components,
String mainBranch,
Log log) throws MojoException {
log.info(" " + Ansi.cyan("→ ") + "Refreshing local " + mainBranch
+ " from " + DEFAULT_REMOTE + "/" + mainBranch + "...");
List<Outcome> outcomes = refreshAll(workspaceRoot, components,
mainBranch, DEFAULT_REMOTE, log);
for (Outcome o : outcomes) {
log.info(" " + describe(o));
}
List<Conflicts> conflicts = conflictsIn(outcomes);
if (!conflicts.isEmpty()) {
var sb = new StringBuilder();
sb.append("Cannot proceed — refreshing local ").append(mainBranch)
.append(" from ").append(DEFAULT_REMOTE).append("/")
.append(mainBranch).append(" would conflict in:\n");
for (Conflicts c : conflicts) {
sb.append(" ").append(c.component()).append(":\n");
for (String f : c.files()) {
sb.append(" • ").append(f).append("\n");
}
}
sb.append("Resolve the divergence on ").append(mainBranch)
.append(" first (e.g., open the affected subproject and ")
.append("merge ").append(DEFAULT_REMOTE).append("/")
.append(mainBranch).append(" into ").append(mainBranch)
.append(" by hand), then re-run.");
throw new MojoException(sb.toString());
}
log.info("");
return outcomes;
}
/**
* Format a one-line user-facing summary of an outcome for log output.
*/
static String describe(Outcome outcome) {
return switch (outcome) {
case Skipped(var c, var r) ->
c + " — skipped (" + r + ")";
case UpToDate(var c) ->
c + " — main up to date";
case FastForwarded(var c, var n) ->
c + " — main fast-forwarded (" + n + " commit"
+ (n == 1 ? "" : "s") + ")";
case CreatedFromRemote(var c) ->
c + " — main created from origin/main";
case AheadOnly(var c, var n) ->
c + " — local main has " + n + " unpushed commit"
+ (n == 1 ? "" : "s") + "; left as-is";
case AutoResolved(var c, var local, var remote) ->
c + " — auto-resolved divergent main (kept " + local
+ " local, merged " + remote + " from origin)";
case Conflicts(var c, var files) ->
c + " — divergent main, " + files.size() + " file conflict"
+ (files.size() == 1 ? "" : "s");
};
}
// ── Internal git mechanics ──────────────────────────────────
/**
* Fast-forward local main to {@code origin/main}. Handles both the
* case where the caller is on main (uses {@code git merge --ff-only})
* and the case where they are not (uses
* {@code git fetch origin main:main}, which fails on non-FF rather
* than discarding work).
*/
private static void fastForwardLocalMain(File subprojectDir, Log log,
String mainBranch, String remote,
String remoteRef)
throws MojoException {
String currentBranch = VcsOperations.currentBranch(subprojectDir);
if (currentBranch.equals(mainBranch)) {
VcsOperations.mergeFfOnly(subprojectDir, log, remoteRef);
} else {
VcsOperations.fetchRef(subprojectDir, log, remote, mainBranch);
}
}
/**
* Merge {@code origin/main} into local main with a merge commit.
* If the caller is not already on main, switch to main, merge, and
* switch back. If the merge fails, abort it before propagating so
* the working tree is not left with conflict markers from a
* mispredicted merge.
*/
private static void autoResolveDiverged(File subprojectDir, Log log,
String mainBranch, String remoteRef)
throws MojoException {
String originalBranch = VcsOperations.currentBranch(subprojectDir);
boolean switched = !originalBranch.equals(mainBranch);
if (switched) {
VcsOperations.checkout(subprojectDir, log, mainBranch);
}
try {
VcsOperations.mergeNoFf(subprojectDir, log, remoteRef,
"refresh: merge " + remoteRef + " into " + mainBranch);
} catch (MojoException e) {
VcsOperations.mergeAbortQuiet(subprojectDir, log);
throw e;
} finally {
if (switched) {
VcsOperations.checkout(subprojectDir, log, originalBranch);
}
}
}
}