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:
mhoennig
2026-08-14 11:29:42 +02:00
co-authored by Claude
parent 129f38143d
commit 9b992c71ed
8 changed files with 204 additions and 6 deletions
@@ -4,6 +4,7 @@ import de.hoennig.gittally.config.ConfigLoader
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldNotContain
import io.kotest.matchers.nulls.shouldBeNull
@@ -168,6 +169,56 @@ class GitServiceTest : FunSpec() {
service.hasNewCommits("no-such-branch", fixture.work).shouldBeFalse()
}
test("fastForwardLocalBranches advances the checked-out branch and its working tree") {
val fixture = Fixture()
fixture.commitFile(fixture.seed, "change.txt", "change")
fixture.git(fixture.seed, "push", "origin", "main")
service.fetchOrigin(fixture.work)
service.fastForwardLocalBranches(fixture.work) shouldContainExactly listOf("main")
service.localHeadCommit("main", fixture.work) shouldBe service.originHeadCommit("main", fixture.work)
Files.readString(fixture.work.resolve("change.txt")) shouldBe "change"
service.hasNewCommits("main", fixture.work).shouldBeFalse()
}
test("fastForwardLocalBranches advances a branch that is not checked out") {
val fixture = Fixture()
fixture.pushNewSeedBranch("feature/x")
service.fetchOrigin(fixture.work)
fixture.git(fixture.work, "branch", "feature/x", "refs/remotes/origin/feature/x")
fixture.git(fixture.seed, "switch", "feature/x")
fixture.commitFile(fixture.seed, "more.txt", "more")
fixture.git(fixture.seed, "push", "origin", "feature/x")
fixture.git(fixture.seed, "switch", "main")
service.fetchOrigin(fixture.work)
service.fastForwardLocalBranches(fixture.work) shouldContainExactly listOf("feature/x")
service.localHeadCommit("feature/x", fixture.work) shouldBe
service.originHeadCommit("feature/x", fixture.work)
}
test("fastForwardLocalBranches leaves a diverged local branch untouched") {
val fixture = Fixture()
fixture.commitFile(fixture.work, "local.txt", "local only")
val localHead = service.localHeadCommit("main", fixture.work)
fixture.commitFile(fixture.seed, "remote.txt", "remote only")
fixture.git(fixture.seed, "push", "origin", "main")
service.fetchOrigin(fixture.work)
service.fastForwardLocalBranches(fixture.work).shouldBeEmpty()
service.localHeadCommit("main", fixture.work) shouldBe localHead
}
test("fastForwardLocalBranches ignores branches without an origin counterpart") {
val fixture = Fixture()
fixture.git(fixture.work, "branch", "local-only")
service.fastForwardLocalBranches(fixture.work).shouldBeEmpty()
}
test("newOriginBranches lists recent origin-only branches") {
val fixture = Fixture()
fixture.pushNewSeedBranch("feature/x")
@@ -29,6 +29,7 @@ import io.kotest.matchers.string.shouldContain
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import io.mockk.verifyOrder
import java.nio.file.Files
import java.nio.file.Path
import java.time.Clock
@@ -75,6 +76,7 @@ class WatcherTest : FunSpec() {
every { gitService.originHeadCommit(any(), any()) } returns null
every { gitService.pullRequestHeads(any()) } returns emptySet()
every { gitService.worktreePrune(any()) } returns Unit
every { gitService.fastForwardLocalBranches(any()) } returns emptyList()
every { buildExecutor.currentBuilds() } returns emptyList()
every { buildExecutor.startBuild(any(), any(), any()) } answers {
val branch = firstArg<String>()
@@ -177,6 +179,47 @@ class WatcherTest : FunSpec() {
listOf("main" to "commit-main", "feature/new" to "commit-feature")
}
test("poll fast-forwards local branch refs only after the enqueue decision was made") {
val harness = Harness()
every { harness.gitService.originBranches(any()) } returns listOf("main")
every { harness.gitService.localBranches(any()) } returns listOf("main")
every { harness.gitService.hasNewCommits("main", any()) } returns true
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
every { harness.gitService.fastForwardLocalBranches(any()) } returns listOf("main")
harness.watcher.poll(harness.workingDir)
// syncing the ref before the decision would hide the very commit being enqueued here
harness.startedBuilds shouldContainExactly listOf("main" to "commit-main")
verifyOrder {
harness.gitService.hasNewCommits("main", any())
harness.gitService.fastForwardLocalBranches(any())
}
}
test("a failing fast-forward does not abort the poll cycle") {
val harness = Harness()
every { harness.gitService.originBranches(any()) } returns listOf("main")
every { harness.gitService.fastForwardLocalBranches(any()) } throws RuntimeException("ref locked")
harness.watcher.poll(harness.workingDir)
harness.watcher
.state()
.lastPollError
.shouldBeNull()
verify { harness.artifactStore.prune(any()) }
}
test("poll leaves local branch refs alone when fastForwardLocalRefs is disabled") {
val harness = Harness(GitTallyConfig(watcher = WatcherConfig(fastForwardLocalRefs = false)))
every { harness.gitService.originBranches(any()) } returns listOf("main")
harness.watcher.poll(harness.workingDir)
verify(exactly = 0) { harness.gitService.fastForwardLocalBranches(any()) }
}
test("poll skips a branch whose build is already pending or running") {
val harness = Harness()
harness.seed("main", BuildStatus.PENDING, commit = "commit-old")