Vetrix Docs

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:

  1. fetches only the two refs this merge touches — the target branch and the source branch — with explicit destination refspecs;
  2. 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 leftover MERGE_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>/locked admin entry, after which git worktree add / git worktree prune refuse to touch it; or
  • a stale index.lock / HEAD.lock in the worktree's git admin dir, after which reset --hard and 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 *.git bare repos (skipping the *.mergewt worktree 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 stale locked admin entry; the not-locked case is tolerated);
  • removes any stale index.lock / HEAD.lock from 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 / 504 rather 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.

  • internal/git/merge_request.goMergeBranches, ensureMergeWorktree, recoverMergeWorktree, PruneMergeWorktrees, mergeLock, mergeWorktreeDir, mergeWorktreeName, and the worktree_sync stage (mergeStageWorktreeSync).
  • internal/git/repo.go — the per-repo mergeLocks sync.Map field.
  • cmd/server/main.go — the best-effort, time-bounded boot call to gitManager.PruneMergeWorktrees.