Fast-forward local branch refs at the end of each poll cycle
Build worktrees share the primary checkout's .git, so build tools can read refs/heads there. Since the rewrite never moved those refs, they stayed frozen at the last checkout: hs.hsadmin.ng's prQuickCheck compares master with origin/master and therefore failed every build once origin moved on. The sync runs after the enqueue decision on purpose — a local ref lagging behind origin is exactly how the watcher recognizes new commits, so keeping the refs in sync earlier (cron job, mirroring refspec, or this step moved up) would silence the branch instead of building it. Fast-forward only, as a compare-and-swap against the commit just read: diverged or ahead branches stay untouched, and the checked-out branch is advanced with merge --ff-only, which refuses to overwrite conflicting uncommitted changes. Switched off with watcher.fastForwardLocalRefs: false. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -177,6 +177,9 @@ class InitCommand(
|
||||
# honor branches.<name>.requirePullRequest; set false for a plain git origin
|
||||
# without pull-request refs (refs/pull/*/head) — gated branches then build on new commits
|
||||
pullRequestGate: true
|
||||
# after enqueueing, fast-forward the primary checkout's local branch refs to origin,
|
||||
# so build tools reading the shared .git see the same refs (diverged branches stay untouched)
|
||||
fastForwardLocalRefs: true
|
||||
|
||||
# Per-branch build configuration.
|
||||
# Use "default" as the fallback for all branches not listed explicitly.
|
||||
|
||||
@@ -107,6 +107,13 @@ data class WatcherConfig(
|
||||
* `.git/gittally/.gittally.yml` when the committed config enables the gates.
|
||||
*/
|
||||
val pullRequestGate: Boolean = true,
|
||||
/**
|
||||
* At the end of each poll cycle, fast-forward the primary checkout's local branch
|
||||
* refs to their origin counterparts, so build tools reading the shared `.git` see
|
||||
* the same refs as origin. Fast-forward only: diverged or ahead local branches are
|
||||
* never touched. Set false to leave the local branch refs alone entirely.
|
||||
*/
|
||||
val fastForwardLocalRefs: Boolean = true,
|
||||
)
|
||||
|
||||
data class BranchConfig(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.hoennig.gittally.git
|
||||
|
||||
import de.hoennig.gittally.config.ConfigLoader
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
@@ -13,6 +14,8 @@ class GitService(
|
||||
private val runner: GitCommandRunner,
|
||||
private val configLoader: ConfigLoader,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(GitService::class.java)
|
||||
|
||||
fun getTopLevel(workingDir: Path = Paths.get(".")): Path {
|
||||
val result = runner.run(listOf("git", "rev-parse", "--show-toplevel"), workingDir)
|
||||
if (!result.isSuccess) {
|
||||
@@ -149,6 +152,53 @@ class GitService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast-forwards local branch refs to their origin counterparts and returns the
|
||||
* branches whose ref moved. Build worktrees share the primary checkout's `.git`,
|
||||
* so tools running inside a build see these refs — checks that compare a local
|
||||
* branch against its origin counterpart (hs.hsadmin.ng's `prQuickCheck` compares
|
||||
* `master` with `origin/master`) otherwise fail on every build once origin moves on.
|
||||
*
|
||||
* Strictly non-destructive: a local ref that is not an ancestor of its origin
|
||||
* counterpart (diverged, or ahead) is left alone, the ref update is a
|
||||
* compare-and-swap against the commit just read, and the branch checked out in
|
||||
* [workingDir] is advanced with `merge --ff-only`, which refuses to run over
|
||||
* conflicting uncommitted changes.
|
||||
*
|
||||
* Call this only *after* the poll cycle picked its due branches: a local ref lagging
|
||||
* behind origin is the watcher's change signal ([hasNewCommits], `newOriginBranches`),
|
||||
* so syncing beforehand would silence the branch instead of building it.
|
||||
*/
|
||||
fun fastForwardLocalBranches(workingDir: Path = Paths.get(".")): List<String> {
|
||||
val originHeads = originBranchHeads(workingDir)
|
||||
val checkedOut = currentBranch(workingDir)
|
||||
return localBranches(workingDir).filter { branch ->
|
||||
val origin = originHeads[branch] ?: return@filter false
|
||||
val local = localHeadCommit(branch, workingDir) ?: return@filter false
|
||||
if (local == origin || !isAncestor(local, origin, workingDir)) {
|
||||
return@filter false
|
||||
}
|
||||
// the `refs/` prefix keeps the refname from being read as a git option
|
||||
val result =
|
||||
if (branch == checkedOut) {
|
||||
runner.run(listOf("git", "merge", "--ff-only", "refs/remotes/origin/$branch"), workingDir)
|
||||
} else {
|
||||
runner.run(listOf("git", "update-ref", "refs/heads/$branch", origin, local), workingDir)
|
||||
}
|
||||
if (!result.isSuccess) {
|
||||
log.warn("not fast-forwarding local branch {}: {}", branch, result.stderr.trim())
|
||||
}
|
||||
result.isSuccess
|
||||
}
|
||||
}
|
||||
|
||||
/** True when [ancestor] is reachable from [descendant]; false for diverged or unrelated commits. */
|
||||
private fun isAncestor(
|
||||
ancestor: String,
|
||||
descendant: String,
|
||||
workingDir: Path,
|
||||
): Boolean = runner.run(listOf("git", "merge-base", "--is-ancestor", ancestor, descendant), workingDir).isSuccess
|
||||
|
||||
fun resetHardToOrigin(
|
||||
branch: String,
|
||||
workingDir: Path = Paths.get("."),
|
||||
|
||||
@@ -27,7 +27,8 @@ import java.util.concurrent.TimeUnit
|
||||
/**
|
||||
* Replaces the legacy blocking main loop: a non-blocking fixed-delay poll cycle that
|
||||
* fetches origin, enqueues due branches via the async [BuildExecutor], and prunes
|
||||
* retention — it never waits for a build and never touches the primary checkout.
|
||||
* retention — it never waits for a build and never builds in the primary checkout
|
||||
* (whose branch refs it does fast-forward, see [fastForwardLocalRefs]).
|
||||
* The loop only runs after an explicit [start] (server/watch mode, step 07);
|
||||
* nothing is scheduled during CLI commands or tests.
|
||||
*/
|
||||
@@ -111,8 +112,9 @@ class Watcher(
|
||||
/**
|
||||
* One poll cycle, never blocking on a build: fetch origin (on failure: log, expose
|
||||
* in [state], retry next cycle), enqueue due branches — changed local branches
|
||||
* first, then recent new origin branches, then due auto-build slots — and finally
|
||||
* prune results, artifacts, and worktrees of branches gone from origin.
|
||||
* first, then recent new origin branches, then due auto-build slots — then
|
||||
* fast-forward the local branch refs, and finally prune results, artifacts, and
|
||||
* worktrees of branches gone from origin.
|
||||
*/
|
||||
fun poll(workingDir: Path = Paths.get(".")) {
|
||||
val startedAt = clock.instant()
|
||||
@@ -126,6 +128,9 @@ class Watcher(
|
||||
val config = configLoader.load(workingDir)
|
||||
val originBranches = gitService.originBranches(workingDir)
|
||||
enqueueDueBranches(config, originBranches.toSet(), workingDir)
|
||||
if (config.watcher.fastForwardLocalRefs) {
|
||||
fastForwardLocalRefs(workingDir)
|
||||
}
|
||||
prune(config, originBranches, workingDir)
|
||||
state =
|
||||
state.copy(
|
||||
@@ -149,6 +154,26 @@ class Watcher(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the primary checkout's local branch refs up to origin, because a build worktree
|
||||
* shares that `.git`: a build tool comparing a local branch with its origin counterpart
|
||||
* would otherwise see a ref frozen at the state of the last checkout.
|
||||
*
|
||||
* Deliberately the last step before pruning — the enqueue decision above reads a
|
||||
* lagging local ref as "this branch has new commits", so syncing earlier in the cycle
|
||||
* would suppress the very build it prepares. Never fatal for the poll cycle.
|
||||
*/
|
||||
private fun fastForwardLocalRefs(workingDir: Path) {
|
||||
try {
|
||||
val moved = gitService.fastForwardLocalBranches(workingDir)
|
||||
if (moved.isNotEmpty()) {
|
||||
log.info("fast-forwarded local branch refs to origin: {}", moved.joinToString(", "))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.warn("fast-forwarding local branch refs failed: {}", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun enqueueDueBranches(
|
||||
config: GitTallyConfig,
|
||||
originBranches: Set<String>,
|
||||
@@ -171,8 +196,9 @@ class Watcher(
|
||||
/**
|
||||
* Enqueues a build of the branch's origin head unless one is already pending or
|
||||
* running, or that commit was already built. Builds run detached in worktrees and
|
||||
* never move local branch refs, so "already built" is tracked via the result
|
||||
* repository, not by resetting the local ref like legacy. A new commit for a
|
||||
* move no branch ref themselves, so "already built" is tracked via the result
|
||||
* repository, not by resetting the local ref like legacy; the cycle's
|
||||
* [fastForwardLocalRefs] runs only after this decision. A new commit for a
|
||||
* branch that is still pending/running waits for a later cycle (queue-behind).
|
||||
* With `requirePullRequest`, the branch head must match a pull-request head on
|
||||
* origin (`refs/pull/<n>/head`); manual `build` commands bypass this gate, and
|
||||
|
||||
Reference in New Issue
Block a user