Merge path: persistent per-repo merge worktree
Operator note for the merge-path design: server-side branch merges do
not run a per-merge full git clone of the bare repository. Instead
each repo has a single, persistent, lazily-created, reused merge
worktree that shares the bare repo's object store, so a merge does
work proportional to the changed refs rather than the whole
repository.
This is purely an internal git-mechanics change. The HTTP merge API, the ref-update (push) path and its atomicity, and the merge-conflict / approval semantics are all unchanged. There is nothing to configure — this page exists so an operator who sees the new on-disk worktree directories, the new boot-log line, or the renamed merge stage in the logs knows what they are looking at.
All symbols below live in vetrix/ (the Go backend) at
internal/git/merge_request.go unless noted.
What changed
| Before | After |
|---|---|
Every merge ran a full git clone of the bare repo into a fresh temp dir — O(repo) object copy per merge. |
Every merge runs in one reused linked worktree per repo (git worktree add --detach) that shares the bare object store — no object copy. |
The first merge stage was a temp_clone. |
The first merge stage is worktree_sync (see Stage rename below). |
| Concurrent merges on the same repo each had their own clone. | Concurrent merges on the same repo are serialized by a per-repo merge mutex; different repos still merge concurrently. |
| A killed merge left a throwaway temp clone to be GC'd. | A killed merge can leave a wedge on the shared worktree, so there is now boot-time + at-merge-time recovery. |
The persistent per-repo merge worktree
MergeBranches funnels every merge through
Manager.ensureMergeWorktree, which lazily creates (or reuses) the
worktree at mergeWorktreeDir(absBarePath) — a sibling of the bare
repo with a fixed suffix, i.e. <repo>.git.mergewt next to
<repo>.git. On first use the worktree is added off the bare repo
with git worktree add --detach, sharing the bare object store (no
copy). On every use (first or reuse) it:
- fetches only the two refs this merge touches — the target branch and the source branch — with explicit destination refspecs;
- hard-resets the worktree to the freshly-fetched target tip
(
reset --hard origin/<target>), which both lands the worktree on the current target and clears any leftoverMERGE_HEAD/ conflicted-index / dirty state from a prior merge.
Because the fetch + reset touch only the changed refs against a shared
object store, the stage is O(changed) rather than O(repo). The
checkout / merge / rev-parse / push origin <target> body that
follows is unchanged.
On disk: you will see a *.git.mergewt directory beside each bare
repo that has been merged into since the worktree model shipped, plus
a worktrees/<name>/ admin entry inside the bare repo. These are
managed — do not delete them by hand; the lifecycle hygiene below
reconciles them.
Per-repo merge mutex (serialization)
MergeBranches acquires a per-repo merge mutex via
Manager.mergeLock(absPath) and holds it across the entire worktree
critical section — ensureMergeWorktree through the final push —
releasing it on every exit path via defer. The lock is keyed by the
repo's absolute bare path (mergeLocks sync.Map on the Manager, in
internal/git/repo.go), so:
- two merges on the same repo are serialized (they share one worktree; running them concurrently would race on its index/HEAD and corrupt each other);
- two merges on different repos never block one another.
This is same-process merge-vs-merge serialization only. There is no cross-process lock, and the merge-vs-pipeline path is untouched.
Lifecycle hygiene (crash recovery)
A force-killed mid-merge (for example the upstream-timeout SIGKILL described below) can leave a worktree wedged in one of two ways:
- a stale
worktrees/<name>/lockedadmin entry, after whichgit worktree add/git worktree prunerefuse to touch it; or - a stale
index.lock/HEAD.lockin the worktree's git admin dir, after whichreset --hardand every index-mutating op fail.
A plain reset --hard does not clear those lock files, so without
recovery the next merge on that repo would inherit the wedge and
fail forever. Two complementary recovery paths prevent that:
Boot-time sweep — Manager.PruneMergeWorktrees
The server runs a one-time boot sweep, gitManager.PruneMergeWorktrees,
from cmd/server/main.go. It walks every bare repo under the repo
root, runs the per-repo recovery, and git worktree prunes each bare
repo so orphaned worktree admin entries are reclaimed. It is:
- best-effort and time-bounded — the boot call wraps it in a
context.WithTimeout(30s); a slow or failing sweep logs a warning and does not block startup; - cheap and idempotent — it visits only the
*.gitbare repos (skipping the*.mergewtworktree dirs), and a server with no wedged worktrees does a handful of no-op git invocations.
On completion it emits one structured log line:
boot: merge-worktree lifecycle sweep complete repos_swept=<n>
If the sweep hits errors it additionally logs (on the error logger):
vetrix: merge-worktree boot sweep encountered errors err=<...>
That warning is not fatal — it means one or more repos could not be swept; the affected repo's wedge (if any) will instead be cleared at merge time by the recovery below.
At-merge-time recovery — Manager.recoverMergeWorktree
ensureMergeWorktree calls Manager.recoverMergeWorktree first,
before it tries to (re)create or reset the worktree. It idempotently:
git worktree unlocks the path (clearing a stalelockedadmin entry; the not-locked case is tolerated);- removes any stale
index.lock/HEAD.lockfrom the worktree's private admin dir under<bare>/worktrees/<name>/(these live in the git admin area, never in the working tree, so removing them never touches user content); git worktree prunes the bare repo to reclaim an orphaned admin entry.
So even if a repo missed the boot sweep, the next merge on it self-heals. The combined effect: a crash mid-merge cannot permanently wedge a repo's merges.
Stage rename in the merge timing log
Per-stage merge timing (the structured slog line that names each git
stage's duration) is unchanged in shape, but the first stage was
renamed, not dropped: the old temp_clone stage is now
worktree_sync (mergeStageWorktreeSync = "worktree_sync"). It
covers the lazy worktree create/reuse plus the O(changed) fetch +
reset. If you have a dashboard, alert, or log query keyed on the
literal stage name temp_clone, repoint it at worktree_sync.
The full ordered stage list is now:
worktree_sync → checkout → merge → rev_parse → push.
Why: de-contention (the rationale)
A per-merge full git clone makes a merge do O(repo) git I/O
against the bare repo. When a frontend CI pipeline concurrently
hammers the same bare repo with its O(repo) worker clones +
upload-pack, the contending I/O can push a single merge's wall-clock
time past the ~30s upstream frontend-proxy timeout. When that
timeout fires, the proxy drops the connection and the merge's git
process is SIGKILL'd mid-flight — which is exactly the crash that the
lifecycle recovery above exists to clean up after.
The worktree model removes the object copy, so a merge becomes
O(changed) and finishes well inside the proxy timeout even while a
pipeline is saturating the same repo's git I/O — so the timeout (and
the resulting killed merge) no longer fires under that load.
Note: the status mapping of an already-killed merge — surfacing a SIGKILL'd / timed-out merge as a
503/504rather than a misleading success or 500 — is a separate concern handled elsewhere on the merge path. This page covers the worktree mechanism and its de-contention rationale.
Related
internal/git/merge_request.go—MergeBranches,ensureMergeWorktree,recoverMergeWorktree,PruneMergeWorktrees,mergeLock,mergeWorktreeDir,mergeWorktreeName, and theworktree_syncstage (mergeStageWorktreeSync).internal/git/repo.go— the per-repomergeLocks sync.Mapfield.cmd/server/main.go— the best-effort, time-bounded boot call togitManager.PruneMergeWorktrees.