Skip to content

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 HEAD with CheckoutCommit, or go through WithRepo and Repository.CommitObject.
  • the staging area. A staged-but-uncommitted file reports as absent.
  • the working directory. A file written through WorkFS() is not visible to FileExists until 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:

unable to find any valid known_hosts file, set SSH_KNOWN_HOSTS env variable

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, Push it.
  • 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 .git symlink. go-git refuses every one with chroot 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: PlainOpen chroots 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.