What this module does not do¶
This module is a deliberately thin veneer over go-git. What it adds is binding, role decomposition, forge-aware authentication and concurrency safety, not a git abstraction of its own.
That shape decides most of what is absent. This page states the absences, because a limitation you can read is worth more than one you discover.
Most git operations are not wrapped, and that is on purpose¶
The wrapped surface is: clone, open, init, checkout, create-branch, stage-everything, commit, push, and read the HEAD tree. go-git offers far more, and none of it is re-exported here:
| Not wrapped | Available on go-git as |
|---|---|
| Fetch | Repository.FetchContext |
Pull (except the one inside CreateBranch) |
Worktree.PullContext |
| Log / history traversal | Repository.Log |
| Tags: create, list, delete | Repository.CreateTag, Tags, DeleteTag |
| Merge | Repository.Merge |
| Reset, restore, clean | Worktree.Reset, Restore, Clean |
| Status | Worktree.Status |
| Remove, move, glob-add, single-path add | Worktree.Remove, Move, AddGlob, Add |
| Delete branch, delete remote | Repository.DeleteBranch, DeleteRemote |
| Submodule inspection and update | Worktree.Submodules |
Revision parsing (HEAD~2, v1.2.3^{}) |
Repository.ResolveRevision |
Nothing is blocked. The GitAccessor role exists precisely so the unwrapped surface
stays reachable:
err := r.WithRepo(func(gr *git.Repository) error {
iter, err := gr.Log(&git.LogOptions{})
if err != nil {
return err
}
defer iter.Close()
return iter.ForEach(func(c *object.Commit) error {
fmt.Println(c.Hash, c.Message)
return nil
})
})
On a ThreadSafeRepo that callback runs under the repository's lock, so reaching through
does not forfeit the concurrency guarantee. What it does forfeit is the narrow role
contract: a function taking GitAccessor can do anything git can.
A new wrapper is only worth adding here when it earns the same three things the existing
ones have: a place in a role interface, sentinel-error behaviour when called too early,
and a ThreadSafeRepo counterpart.
Tree reading sees the HEAD commit only¶
WalkTree, FileExists, DirectoryExists and GetFile all resolve the tree of the
current HEAD commit. They cannot read:
- another revision. No branch, tag, or commit-hash argument. Move
HEADwithCheckoutCommit, or go throughWithRepoandRepository.CommitObject. - the staging area. A staged-but-uncommitted file reports as absent.
- the working directory. A file written through
WorkFS()is not visible toFileExistsuntil it has been committed.
On a repository with no commits, all four fail with
failed to get HEAD reference: reference not found rather than reporting emptiness.
The two filesystem-shaped views are different things and are easy to confuse:
WorkFS() is the live working tree, TreeReader is the committed history. See
read and write the worktree.
Named remotes are not part of the interface¶
CreateRemote and Remote are concrete methods on *Repo only. They are in no role,
not in RepoLike, and not implemented on ThreadSafeRepo, so a value you hold as
RepoLike, or as a ThreadSafeRepo, cannot reach them.
That is honesty rather than oversight: ThreadSafeRepo does not wrap them, so putting
them in an interface both types satisfy would advertise a guarantee one of them does not
provide. There is also no delete-remote and no list-remotes. Reach for *Repo, or go
through WithRepo.
"Reads no environment" is a claim about this module, not about git¶
This module reads no environment variable of its own. A guard test walks its AST to keep
that true, with KeyPath the single deliberate exception, which the module never calls.
Git operations underneath it still do. The claim scopes to construction and configuration, not to the whole call stack, and these are the ones that bite:
| Read by | What | When |
|---|---|---|
| go-git SSH transport | SSH_AUTH_SOCK |
ssh-agent authentication |
| go-git SSH transport | SSH_KNOWN_HOSTS, else ~/.ssh/known_hosts and /etc/ssh/ssh_known_hosts |
host-key verification on every SSH connection |
| go-git | git configuration: system, global (~/.gitconfig) and repository scope |
Commit with nil options, resolving user.name / user.email |
The practical consequence is a container or CI runner with an empty $HOME: it clones
happily over HTTPS, then fails at Commit with author field is required, and fails an
SSH clone before it reaches the network, because go-git will not proceed without a
host-key source:
Neither is something Settings can fix. Supply a *git.CommitOptions with an explicit
author, and provision known_hosts (or point SSH_KNOWN_HOSTS at one) in the
environment you run in.
Settings.FS does not keep work off disk¶
Settings.FS is consulted in exactly one place: reading the SSH private key at
SSH.Path. It exists so a test can supply a key from memory.
Clone creates its target directory with os.MkdirAll on the real filesystem, and
OpenLocal, InitLocal and DiscoverRepository go through go-git's own plain-file
operations. Passing an afero.NewMemMapFs() changes none of that. Use
OpenInMemory if the requirement is "nothing touches disk".
The in-memory backend is not a persistence layer¶
An in-memory repository holds its objects and its worktree in RAM, with no disk anywhere. Two consequences:
- It is not saved. There is no export, no flush, no "write this out to a directory".
A commit made in memory exists until the process drops the reference, and then it is
gone. To publish it,
Pushit. - It is bounded by available memory. Everything fetched stays resident, and a repository with heavy binary history can exhaust it. Past a few hundred megabytes, prefer a shallow local clone.
There is no Close on either backend: the in-memory one is reclaimed by the garbage
collector, and the on-disk one holds no long-lived handle.
One authentication method, decided at construction¶
NewRepo picks SSH or token authentication and installs one transport.AuthMethod. A
single repository cannot use SSH for one remote and a token for another, and there is no
per-operation credential argument on Clone, OpenInMemory or CreateBranch.
Push is the one exception: an explicit opts.Auth on a *git.PushOptions overrides the
configured credential for that call.
The choice can be changed after construction with SetKey or SetBasicAuth, which
replace the installed method wholesale. Beyond that, use a second repository value.
The credential itself is never resolved by this module: there is no keychain access, no
.netrc parsing, no credential.helper support and no environment lookup. Whatever
Settings.Token returns is what is used.
ThreadSafeRepo serialises; it does not parallelise¶
ThreadSafeRepo makes concurrent use safe, not faster. Every method takes an
exclusive sync.Mutex for its whole duration, reads included, because go-git mutates
internal caches while reading. That is why a sync.RWMutex would not be sound.
So two goroutines reading different files from the same repository do not overlap. If throughput matters more than a shared handle, open separate repositories.
The mutex is not reentrant, which is the source of the three rules in
share a repository across goroutines: no
repository call from inside a callback, no retaining a pointer past a callback, no
handing the raw Open*/Clone return values to workers.
Clone options are silently ignored on the local path¶
Open(ctx, repoType, location, branch, opts...) has one signature for both backends, so
a program can pick its backend from configuration without branching. The cost is that
Open(ctx, repo.LocalRepo, …) discards both branch and every CloneOption, because it
calls OpenLocal(ctx, location), which accepts neither.
Nothing warns about this. See the options reference.
No retries, no timeouts, no progress of its own¶
There is no backoff, no retry loop and no default deadline anywhere in this module. Its
non-git imports are context, fmt, io, os, path/filepath, strings and sync,
and none of them are a clock.
A hung remote is bounded by the context.Context you pass to the network methods,
and by nothing else. Wrap it with context.WithTimeout at your call site; this matters
most on a ThreadSafeRepo, where a stalled clone holds the mutex and blocks every other
caller until the context ends.
Likewise, the only progress reporting is the remote server's own sideband stream, written
verbatim to Settings.Progress. This module emits no progress events, no percentages and
no callbacks.
It does not shell out to git¶
There is no git binary invocation and no os/exec import. Everything is go-git, in
process. That is what makes the module usable from a distroless container, and it also
means anything go-git has not implemented is simply unavailable. There is no fallback to
the real client to paper over the gap.
Linked worktrees and other git redirects¶
OpenLocal reads a linked worktree, a directory made by git worktree add whose
.git is a file pointing into <parent>/.git/worktrees/<name>/. HEAD and the index come
from the worktree's own directory; refs and objects come from the common directory beside
them. Committing through one moves that worktree's HEAD and leaves the parent's alone.
Beware the word, because it does two jobs here. Everywhere else in this module
"worktree" means go-git's *git.Worktree, the working tree of an open repository. That
is what WorkFS() and WorktreeController are about. A
linked worktree is the git worktree add sense, and only this section is about it.
--separate-git-dir works the same way and is covered. It puts the same kind of
.git file at the working directory, so the same code opens it. So does a submodule.
All three are one mechanism wearing three names.
When the git directory one of them names has gone (the parent pruned, moved or
re-cloned) the working directory survives on its own and cannot be opened. That is
reported as
git directory is missing or unusable
and nothing is written; it is never initialised over.
Two neighbouring shapes are genuinely not covered:
- bare repositories. No working tree to open. Not blocked, merely unverified: treat as unknown rather than supported.
- a
.gitsymlink. go-git refuses every one withchroot boundary crossed, whatever it points at. A target inside the same working tree fails exactly as one outside it does, so this is not the boundary check refusing an escape:PlainOpenchroots onto the symlink itself, and resolving that root then necessarily leaves it. The error names a security-sounding condition that has not occurred. It is a go-git limitation rather than a judgement about your repository, there is no way round it from here, and it is drafted for reporting upstream in #9. Use the real path, or--separate-git-dir, which does work.
An incomplete .git directory is completed, not refused¶
The paragraph above is about .git being a file. When .git is a directory that
go-git cannot open as a repository (HEAD missing, say) OpenLocal falls through to its
init and writes into it, returning no error.
That is deliberate, and it is what git init does in the same directory: it writes a
fresh HEAD and leaves the existing objects and refs in place. Matching git matters more
here than refusing would. If you need "initialise only if genuinely absent", use
InitLocal, which refuses when DiscoverRepository finds a repository.
Related¶
- Errors reference: the message each of these limits produces
- Role interfaces: why the wrapped surface is decomposed the way it is
- Context and cancellation