Skip to content

Share a repository across goroutines

go-git is not thread-safe — it mutates internal caches even on reads. So two goroutines reading the same repository can corrupt those caches. ThreadSafeRepo exists for exactly this.

Use ThreadSafeRepo

r, err := repo.NewThreadSafeRepo(repo.Settings{
    Forge: repo.ForgeGitHub,
    Token: repo.StaticToken(token),
    FS:    afero.NewOsFs(),
})

Every method acquires an exclusive mutex for its full duration. It is a single lock, not a read/write lock — because reads mutate too, a shared read lock would not be safe.

For single-goroutine work, plain *Repo is sufficient and has no locking overhead.

Individually atomic, not sequence-atomic

Each operation is atomic. A sequence is not:

// another goroutine's Commit can land between these two writes
_ = afero.WriteFile(fs, "a.txt", a, 0o644)
_ = afero.WriteFile(fs, "b.txt", b, 0o644)

That is a semantic gap, not corruption — go-git never sees concurrent access either way. When a sequence must be atomic relative to Commit/Add, use the callback form WithWorkFS, which holds the lock for its whole duration.

The three rules

Never call a ThreadSafeRepo method from inside a callback

sync.Mutex is not reentrant. Inside WithRepo, WithTree, or WithWorkFS the lock is already held, so calling another method on the same repository deadlocks the goroutine against itself. That includes using a WorkFS() handle inside a WithWorkFS callback.

Never retain a pointer past the callback

The *git.Repository, *git.Worktree and afero.Fs values handed to WithRepo, WithTree and WithWorkFS are valid only for the duration of the closure. Storing one in an outer variable, or passing it to a goroutine that outlives the callback, escapes the lock and puts you back where you started.

Never share raw pointers from Open*/Clone

Those return raw go-git values for setup-time convenience. Acquire on one goroutine during setup, then go concurrent through WithRepo/WithTree — never hand the raw pointers to workers.

Keep callbacks short

A callback holds the exclusive lock, so it blocks all other repository access for its duration. Do the git work inside; do the slow work — network calls, template rendering, compression — outside.

The escaped-handle guarantee

WorkFS() on a ThreadSafeRepo returns an afero.Fs that is safe even after it escapes the call that made it: every operation on it, and on every file opened from it, re-locks the same mutex go-git uses. That is stronger than documenting "the caller must synchronise" — the handle cannot touch the worktree unsynchronised even if the holder knows nothing about the lock.

Two details make it hold:

  • File operations lock too, not just filesystem operations. An open file outlives the Open call; if only Open locked, a later Read would run unsynchronised.
  • No escape hatch. The returned filesystem exposes no accessor for the underlying billy object, so the lock boundary cannot be bypassed.

The same handle from a plain *Repo uses a no-op locker: *Repo is explicitly not safe for concurrent use, and the caller owns synchronisation.