VCS Repo Thread-Safety Wrapper¶
- Authors
- Matt Cockayne, Claude (claude-sonnet-4-6) (AI drafting assistant)
- Date
- 25 March 2026
- Status
- IMPLEMENTED
1. Overview¶
The go-git library (github.com/go-git/go-git/v5) is inherently not thread-safe.
Its internal storage, object caches, and index state mutate freely during reads as well
as writes. The upstream maintainers are aware of this limitation and there is no
remediation on the roadmap.
The RepoLike interface currently exposes GetRepo() *git.Repository and
GetTree() *git.Worktree. These methods return raw pointers that escape any mutex
boundary the moment the method returns, making it structurally impossible to provide a
thread-safety guarantee while they remain on the interface.
This spec makes two related changes:
-
Interface breaking change: Remove
GetRepoandGetTreefromRepoLike. Replace them withWithRepoandWithTreeโ callback-style methods that execute under the caller's chosen locking strategy, keeping the raw pointer inside the critical section. -
New opt-in type: Introduce
ThreadSafeRepo, aRepoLike-satisfying wrapper around*Repothat holds async.Mutexand serialises every method call.WithRepoandWithTreeon this type execute the callback while the lock is held.
The existing Repo type also gains WithRepo and WithTree (without locking), so it
continues to satisfy the updated interface. All other Repo behaviour is unchanged.
Migration cost¶
GetRepo and GetTree have five callers in the repository, all in test files within
pkg/vcs/repo/. No external package declares a RepoLike parameter. Migration is
mechanical and low-risk.
2. Goals & Non-Goals¶
Goals¶
- Remove the structural impossibility of a thread-safe
RepoLikeby eliminating raw pointer getters from the interface. - Provide
WithRepoandWithTreeon bothRepo(no lock) andThreadSafeRepo(mutex held for the duration of the callback). - Provide
ThreadSafeRepoas an opt-in, zero-cost-when-unused type. - Achieve โฅ 90 % test coverage on new code, including race-detector tests.
- Regenerate the
RepoLikemock after the interface change.
Non-Goals¶
- Making
go-gititself thread-safe. - Preventing deliberate misuse (e.g. storing a pointer captured inside a
WithRepocallback and using it after the callback returns) โ this is documented, not enforced. - Modifying the return signatures of
Open*/Clone(the raw pointers returned from those methods are setup-time artefacts, not shared state). - Adding concurrency primitives to
Repoitself.
3. Public API¶
3.1 RepoLike interface changes¶
type RepoLike interface {
SourceIs(int) bool
SetSource(int)
SetRepo(*git.Repository)
// GetRepo and GetTree are removed. Use WithRepo / WithTree instead.
SetKey(*ssh.PublicKeys)
SetBasicAuth(string, string)
GetAuth() transport.AuthMethod
SetTree(*git.Worktree)
Checkout(plumbing.ReferenceName) error
CheckoutCommit(plumbing.Hash) error
CreateBranch(string) error
Push(*git.PushOptions) error
Commit(string, *git.CommitOptions) (plumbing.Hash, error)
OpenInMemory(string, string, ...CloneOption) (*git.Repository, *git.Worktree, error)
OpenLocal(string, string) (*git.Repository, *git.Worktree, error)
Open(RepoType, string, string, ...CloneOption) (*git.Repository, *git.Worktree, error)
Clone(string, string, ...CloneOption) (*git.Repository, *git.Worktree, error)
WalkTree(func(*object.File) error) error
FileExists(string) (bool, error)
DirectoryExists(string) (bool, error)
GetFile(string) (*object.File, error)
AddToFS(afero.Fs, *object.File, string) error
// WithRepo acquires any necessary lock and calls fn with the *git.Repository.
// Implementations must guarantee that no concurrent method call can observe
// the repository while fn is executing.
//
// Returns ErrNoRepository if the repository has not been initialised.
WithRepo(func(*git.Repository) error) error
// WithTree acquires any necessary lock and calls fn with the *git.Worktree.
// Implementations must guarantee that no concurrent method call can observe
// the worktree while fn is executing.
//
// Returns ErrNoWorktree if the worktree has not been initialised.
WithTree(func(*git.Worktree) error) error
}
Removed from interface: GetRepo() *git.Repository, GetTree() *git.Worktree
3.2 Sentinel errors (added to repo.go)¶
var (
// ErrNoRepository is returned by WithRepo when no *git.Repository has been set.
ErrNoRepository = errors.New("repository not initialised; call Open, Clone, or SetRepo first")
// ErrNoWorktree is returned by WithTree when no *git.Worktree has been set.
ErrNoWorktree = errors.New("worktree not initialised; call Open, Clone, or SetTree first")
)
These errors are declared in repo.go so they are shared by both Repo and
ThreadSafeRepo.
3.3 Repo additions (no locking)¶
// WithRepo calls fn with the underlying *git.Repository.
// Repo is not safe for concurrent use; callers are responsible for external
// synchronisation if sharing a *Repo across goroutines.
//
// Returns ErrNoRepository if the repository has not been initialised.
func (r *Repo) WithRepo(fn func(*git.Repository) error) error
// WithTree calls fn with the underlying *git.Worktree.
// Repo is not safe for concurrent use; callers are responsible for external
// synchronisation if sharing a *Repo across goroutines.
//
// Returns ErrNoWorktree if the worktree has not been initialised.
func (r *Repo) WithTree(fn func(*git.Worktree) error) error
Repo.GetRepo() and Repo.GetTree() are removed.
3.4 New type: ThreadSafeRepo¶
// ThreadSafeRepo wraps a *Repo with a mutex so that all RepoLike methods are safe
// to call concurrently from multiple goroutines.
//
// # Thread-safety guarantee
//
// Every method acquires the internal mutex for its full duration. Concurrent callers
// are serialised; no two calls to any method execute simultaneously.
//
// # WithRepo and WithTree
//
// These are the only way to interact with the underlying go-git objects safely.
// The callback executes while the mutex is held. Callers must not:
// - Retain the pointer after the callback returns.
// - Call any ThreadSafeRepo method from inside the callback (deadlock).
// - Spawn goroutines inside the callback that access the pointer after it returns.
//
// # go-git concurrency model
//
// go-git mutates internal caches during read operations. ThreadSafeRepo uses
// sync.Mutex (exclusive) rather than sync.RWMutex; concurrent reads are not permitted.
type ThreadSafeRepo struct {
mu sync.Mutex
repo *Repo
}
3.5 ThreadSafeRepo constructor¶
// NewThreadSafeRepo creates a ThreadSafeRepo backed by a freshly constructed *Repo.
// The props and opts arguments have the same semantics as NewRepo.
func NewThreadSafeRepo(props *props.Props, opts ...RepoOpt) (*ThreadSafeRepo, error)
3.6 ThreadSafeRepo method overview¶
All RepoLike methods acquire mu before delegating to the inner *Repo, releasing on
return. The table below lists every method and its critical-section content.
| Method | Lock | Notes |
|---|---|---|
SourceIs(int) bool |
exclusive | consistent with concurrent mutations |
SetSource(int) |
exclusive | |
SetRepo(*git.Repository) |
exclusive | |
SetKey(*ssh.PublicKeys) |
exclusive | |
SetBasicAuth(string, string) |
exclusive | |
GetAuth() transport.AuthMethod |
exclusive | returns interface value, not a raw go-git pointer |
SetTree(*git.Worktree) |
exclusive | |
Checkout(plumbing.ReferenceName) error |
exclusive | |
CheckoutCommit(plumbing.Hash) error |
exclusive | |
CreateBranch(string) error |
exclusive | |
Push(*git.PushOptions) error |
exclusive | |
Commit(string, *git.CommitOptions) (plumbing.Hash, error) |
exclusive | |
OpenInMemory(string, string, ...CloneOption) (*git.Repository, *git.Worktree, error) |
exclusive | raw pointers returned; safe for setup-time single-goroutine use |
OpenLocal(string, string) (*git.Repository, *git.Worktree, error) |
exclusive | same caveat |
Open(RepoType, string, string, ...CloneOption) (*git.Repository, *git.Worktree, error) |
exclusive | same caveat |
Clone(string, string, ...CloneOption) (*git.Repository, *git.Worktree, error) |
exclusive | same caveat |
WalkTree(func(*object.File) error) error |
exclusive | |
FileExists(string) (bool, error) |
exclusive | |
DirectoryExists(string) (bool, error) |
exclusive | |
GetFile(string) (*object.File, error) |
exclusive | |
AddToFS(afero.Fs, *object.File, string) error |
exclusive | |
WithRepo(func(*git.Repository) error) error |
exclusive | callback runs under lock |
WithTree(func(*git.Worktree) error) error |
exclusive | callback runs under lock |
Note on
Open*/Clonereturn values: These methods return raw pointers as a convenience for single-goroutine setup flows (e.g. open a repo then immediately add files in the same goroutine). Do not share these pointers across goroutines; useWithRepo/WithTreefor subsequent concurrent access.
4. Internal Implementation¶
4.1 Repo.WithRepo / Repo.WithTree (no lock)¶
func (r *Repo) WithRepo(fn func(*git.Repository) error) error {
if r.repo == nil {
return ErrNoRepository
}
return fn(r.repo)
}
func (r *Repo) WithTree(fn func(*git.Worktree) error) error {
if r.tree == nil {
return ErrNoWorktree
}
return fn(r.tree)
}
4.2 ThreadSafeRepo delegation pattern¶
func (r *ThreadSafeRepo) Checkout(branch plumbing.ReferenceName) error {
r.mu.Lock()
defer r.mu.Unlock()
return r.repo.Checkout(branch)
}
4.3 ThreadSafeRepo.WithRepo / WithTree¶
func (r *ThreadSafeRepo) WithRepo(fn func(*git.Repository) error) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.repo.repo == nil {
return ErrNoRepository
}
return fn(r.repo.repo)
}
4.4 No re-entrancy¶
sync.Mutex is not re-entrant. Calling any ThreadSafeRepo method from inside a
WithRepo or WithTree callback will deadlock. This is documented in the type-level
godoc (ยง3.4) and in the method godoc.
5. Project Structure¶
pkg/vcs/repo/
โโโ repo.go MODIFIED โ remove GetRepo/GetTree; add WithRepo/WithTree; add sentinel errors
โโโ repo_test.go MODIFIED โ 1 GetTree call โ WithTree
โโโ repo_unit_test.go MODIFIED โ 4 GetRepo/GetTree calls โ WithRepo/WithTree
โโโ repo_coverage_test.go UNCHANGED
โโโ doc.go UNCHANGED
โโโ safe_repo.go NEW โ ThreadSafeRepo type and constructor
โโโ safe_repo_test.go NEW โ table-driven and race-detector tests
mocks/pkg/vcs/repo/
โโโ RepoLike.go REGENERATED โ run `just mocks` after interface change
6. Error Handling¶
All error creation and wrapping must use github.com/cockroachdb/errors.
| Scenario | Error |
|---|---|
NewRepo fails inside NewThreadSafeRepo |
propagate via errors.WithStack |
WithRepo called before repo initialised |
return ErrNoRepository |
WithTree called before tree initialised |
return ErrNoWorktree |
Callback fn returns an error |
propagate as-is |
Any delegated RepoLike method error |
propagate from inner *Repo as-is |
7. Testing Strategy¶
7.1 Updated tests in repo_test.go and repo_unit_test.go¶
The five existing callers of GetRepo / GetTree are migrated to WithRepo /
WithTree. Example:
// Before
assert.Equal(t, repo, r.GetRepo())
// After
_ = r.WithRepo(func(gr *git.Repository) error {
assert.Equal(t, repo, gr)
return nil
})
7.2 Unit tests for Repo.WithRepo / Repo.WithTree (in repo_unit_test.go)¶
| Test | Scenario |
|---|---|
TestRepo_WithRepo_NoRepo |
returns ErrNoRepository when r.repo == nil |
TestRepo_WithRepo_CallsFn |
fn receives correct pointer; no error |
TestRepo_WithRepo_PropagatesError |
fn error propagated |
TestRepo_WithTree_NoTree |
returns ErrNoWorktree when r.tree == nil |
TestRepo_WithTree_CallsFn |
fn receives correct pointer; no error |
TestRepo_WithTree_PropagatesError |
fn error propagated |
7.3 Unit tests for ThreadSafeRepo (safe_repo_test.go)¶
Table-driven, t.Parallel() throughout. Use logger.NewNoop() for test loggers.
| Test | Scenario |
|---|---|
TestNewThreadSafeRepo_Success |
constructs successfully; inner *Repo not nil |
TestThreadSafeRepo_ImplementsRepoLike |
compile-time: var _ RepoLike = (*ThreadSafeRepo)(nil) |
TestThreadSafeRepo_SourceIs_SetSource |
round-trip under concurrent calls |
TestThreadSafeRepo_SetGetAuth |
SetBasicAuth / GetAuth round-trip |
TestThreadSafeRepo_SetRepo_SetTree |
SetRepo / SetTree store values accessible in WithRepo / WithTree |
TestThreadSafeRepo_WithRepo_NoRepo |
returns ErrNoRepository |
TestThreadSafeRepo_WithRepo_CallsFn |
fn called with correct pointer under lock |
TestThreadSafeRepo_WithRepo_PropagatesError |
fn error propagated |
TestThreadSafeRepo_WithTree_NoTree |
returns ErrNoWorktree |
TestThreadSafeRepo_WithTree_CallsFn |
fn called with correct pointer under lock |
TestThreadSafeRepo_WithTree_PropagatesError |
fn error propagated |
7.4 Concurrency / race tests (safe_repo_test.go)¶
Run with go test -race ./pkg/vcs/repo/....
| Test | Scenario |
|---|---|
TestThreadSafeRepo_ConcurrentSetSource |
N goroutines call SetSource / SourceIs; no race |
TestThreadSafeRepo_ConcurrentSetGetAuth |
N goroutines call SetBasicAuth / GetAuth; no race |
TestThreadSafeRepo_ConcurrentWithRepo |
N goroutines call WithRepo on the same instance; no race |
TestThreadSafeRepo_ConcurrentSetRepo |
goroutines race SetRepo and WithRepo; no race |
All race tests use sync.WaitGroup with a fixed goroutine count (e.g. 10).
7.5 Quality gates¶
- Coverage โฅ 90 % on
safe_repo.go(just coverage) go test -race ./pkg/vcs/repo/...โ zero racesjust lintโ zero warnings, no//nolintdirectives
8. Migration & Compatibility¶
This is a breaking change to the RepoLike interface.
| Change | Impact |
|---|---|
GetRepo() removed from RepoLike |
5 test-only callers in pkg/vcs/repo/ โ migrated to WithRepo |
GetTree() removed from RepoLike |
5 test-only callers in pkg/vcs/repo/ โ migrated to WithTree |
GetRepo() removed from Repo concrete type |
same 5 callers |
GetTree() removed from Repo concrete type |
same 5 callers |
WithRepo / WithTree added to RepoLike |
existing Repo implementation gains two new methods |
RepoLike mock regenerated |
run just mocks |
No external package uses RepoLike as an interface parameter. The mocks/ directory
is auto-generated and will be updated in Phase 1.
Commit strategy¶
Each phase is one conventional commit. Phase 1 (interface + Repo changes) is
committed before ThreadSafeRepo is introduced, so just mocks can be run against the
updated interface immediately.
9. Documentation Maintenance¶
9.1 docs/components/vcs/repo.md¶
- Remove
GetRepo/GetTreefrom the API reference. - Add
WithRepo/WithTreeto the API reference for bothRepoandThreadSafeRepo. - Add a Thread Safety section covering:
- When to use
ThreadSafeRepovsRepo. NewThreadSafeRepoconstructor and example.WithRepo/WithTreeusage examples.- The re-entrancy deadlock caveat.
- A note that
Open*/Clonereturn values are setup-time only.
9.2 docs/concepts/vcs-repositories.md¶
Add a short paragraph linking to the Thread Safety section of the component docs.
9.3 Godoc¶
All new public types, methods, variables, and sentinel errors must have accurate godoc as described in ยง3.
10. Future Considerations¶
- Context-aware locking: a
ctx context.Contextvariant ofWithRepo/WithTreecould let callers set a deadline on lock acquisition. - Read-safe go-git: if go-git ever introduces internal synchronisation, the
sync.MutexinThreadSafeRepocan be relaxed tosync.RWMutexwithout changing the public API. - Repo pool: a
RepoPoolmanaging a fixed set ofThreadSafeRepoinstances could serve high-concurrency scenarios more efficiently than a single shared instance.
11. Implementation Phases¶
Phase 1 โ Interface and Repo changes¶
- Add
ErrNoRepositoryandErrNoWorktreetorepo.go. - Remove
GetRepo()andGetTree()fromRepoLikeand fromRepo. - Add
WithRepo(func(*git.Repository) error) errorandWithTree(...)toRepoLike. - Implement
Repo.WithRepoandRepo.WithTree(no lock). - Migrate the 5 test-only callers in
repo_test.goandrepo_unit_test.go. - Add unit tests for
Repo.WithRepo/Repo.WithTree. - Run
just mocksto regeneratemocks/pkg/vcs/repo/RepoLike.go. - Run
go test ./pkg/vcs/repo/...โ all tests must pass.
Phase 2 โ ThreadSafeRepo skeleton¶
- Create
pkg/vcs/repo/safe_repo.go. - Declare
ThreadSafeRepostruct. - Add compile-time interface check:
var _ RepoLike = (*ThreadSafeRepo)(nil). - Implement
NewThreadSafeRepo. - Write failing tests for the interface check and constructor.
Phase 3 โ ThreadSafeRepo full delegation¶
- Implement all
RepoLikemethods onThreadSafeRepowith the lock/unlock pattern. - Implement
WithRepoandWithTreeonThreadSafeRepo. - Write and pass all unit tests from ยง7.3.
Phase 4 โ Race-detector tests and coverage¶
- Write and pass all concurrency tests from ยง7.4.
- Run
go test -race ./pkg/vcs/repo/.... - Check coverage (
just coverage); reach โฅ 90 % onsafe_repo.go. - Run
just lintโ zero warnings.
Phase 5 โ Documentation¶
- Update
docs/components/vcs/repo.mdas described in ยง9.1. - Update
docs/concepts/vcs-repositories.mdas described in ยง9.2. - Update this spec's status to
IMPLEMENTED. - Run
/gtb-verifyfor final sign-off.
Verification¶
# Unit tests
go test ./pkg/vcs/repo/... -v
# Race detector (critical for this feature)
go test -race ./pkg/vcs/repo/...
# Regenerate mock after interface change
just mocks
# Coverage
just coverage
# Lint
just lint