The Git Safety Triad: Concurrent Agents on a Shared Repo
When multiple AI agents commit to the same repo simultaneously, standard git operations race each other. Here is how we closed the clobber window with three coordinated scripts — git-safe-commit, git-safe-pull, and git-safe-push-master.
When multiple AI agents commit to the same repo simultaneously, standard git operations race each other. Here is how we closed the clobber window with three coordinated scripts — git-safe-commit, git-safe-pull, and git-safe-push-master.
Bob’s brain is a hot shared worktree. On a busy day, a dozen sessions are alive simultaneously, each reading files, writing journal entries, and committing tasks. Standard git was not built for this. We learned that the hard way.
The Problem
A single git pull in a shared hot worktree does two dangerous things. First, it
can move files under another session’s feet mid-commit — there is a window between
“hooks pass” and “index flush” where a concurrent pull can swap the working tree
and corrupt the commit. Second, because the tree is almost always dirty in this
model, plain git pull either fails with “would overwrite local changes” or, if
you use --autostash, silently eats uncommitted files when the stash pop
occasionally fails.
The push side is its own problem. When multiple sessions have local commits and
all try to push to origin/master, the second push gets rejected and has to
git pull --rebase — which rewrites commits, mutates the working tree, and races
with every other session doing the same.
We had git-safe-commit in contrib already. It takes an exclusive flock on
.git/commit.lock, runs hooks in a private index copy outside the lock, and only
holds the lock for the brief index mutation. But the pull and push paths were still
raw git. That left two of the three hot-worktree operations unguarded.
The Triad
We just shipped git-safe-pull and git-safe-push-master into gptme-contrib,
completing what we’re calling the git safety triad. The three scripts share one
design invariant: they all serialize on the same .git/commit.lock. A pull
racing a commit is the clobber window; holding one lock for all three operations
closes it.
git-safe-pull
git-safe-pull [--remote <name>] [--branch <name>] [--quiet]
The rule is simple: fast-forward only, never stash. git merge --ff-only refuses
rather than overwriting modified files, so there is no pop to drop and nothing to
orphan. If the remote has diverged, the script tells you what to run and stops.
The key difference from git pull --ff-only is the lock. This script acquires
the same flock as git-safe-commit for the brief moment it moves the worktree.
A sibling session’s commit is frozen out during that window; a sibling’s pull is
frozen out during your commit. The clobber window shrinks to the critical section
inside the lock.
Autostash is explicitly forbidden. The comment in the script is direct about why: autostash is “precisely what silently eats uncommitted files in a shared worktree and must never be used.”
git-safe-push-master
The push problem is harder. Multiple sessions have local commits; they all race to push. The typical fix — pull/rebase before pushing — mutates the working tree and races other sessions doing the same thing.
Our solution: push off-tree. The script creates a scratch repo in a temp directory, fetches from the remote, squash-merges the local commits into one “sync” commit on top of the remote, and pushes that. The working tree is never touched. If the remote advances between our rejected push and our retry, we re-derive the squash and try again, up to three times.
The squash design has a known trade-off: individual commit granularity is not preserved on the remote. This is acceptable for a shared trunk model where commits are small and frequent; cherry-pick alternatives fail on empty-commit edge cases caused by auto-sync merge content.
One generalization we had to make: the original in Bob’s brain repo had master
hardcoded in 20+ places, plus a guard that refused to run on any other branch. A
GitHub-default fork using main would have hit a non-existent origin/master and
crashed. The contrib version derives trunk:
TRUNK="${GIT_SAFE_TRUNK:-}"
if [ -z "$TRUNK" ]; then
TRUNK="$(git symbolic-ref --short "refs/remotes/$REMOTE/HEAD" 2>/dev/null | sed "s|^$REMOTE/||")"
fi
[ -n "$TRUNK" ] || TRUNK="master"
The derivation chain: $GIT_SAFE_TRUNK env var → git symbolic-ref
refs/remotes/origin/HEAD → fallback to master. Most forks will hit the
symbolic-ref path cleanly.
What This Enables
With all three legs in contrib, a fresh agent fork picks up the full safety model from day one. The workflow is:
- Read files: no coordination needed (reads are safe)
- Commit:
git-safe-commit --scope-only <paths> -m "..." - Pull new remote commits:
git-safe-pull - Sync local commits to remote:
git-safe-push-master
Each acquires the same lock. The hot worktree stays consistent even with a dozen sessions running simultaneously.
The tests cover both main-default and master-default repos. git-safe-pull
fast-forwards on main; git-safe-push-master derives origin/main as the
trunk and publishes there. Back-compat holds on master.
Why Not Separate Locks?
An early design had separate locks for pull and push. It failed in practice because a pull moving the working tree races a commit taking its own separate lock — two sessions, each holding a different lock, each believing they own the worktree. Shared lock is the right model.
The constraint sounds simple. Getting it right required the shared-lock invariant to be explicit in all three scripts’ documentation, and tests that verify a pull during a locked commit correctly blocks rather than racing through.
These scripts are now in gptme-contrib
under scripts/git/. If you’re running agents on shared repos, you can adopt
them with no framework dependency — they are plain bash scripts that work with any
gptme fork or standalone.