Skip to content

Pull an existing clone

Clone once, then call Pull to bring the clone up to date. Treat git.NoErrAlreadyUpToDate as success, and re-clone on any other error:

err := r.Pull(ctx, nil)

switch {
case err == nil:
    // the branch fast-forwarded
case errors.Is(err, git.NoErrAlreadyUpToDate):
    // nothing new: the common case
default:
    // refused, or failed: discard the directory and clone again
}

Pull makes one network round trip, uses the credentials the repository already holds (SSH included), and only ever fast-forwards. Every refusal leaves HEAD, the branch and the working tree as they were, so a refused clone is safe to inspect before you replace it.

Throughout, ctx is a context.Context you supply. See context and cancellation.

Clone once, pull after that

A job that refreshes many repositories on a schedule should not re-clone each one every time. Re-cloning costs a full transfer per repository per run, and on a forge with request limits it is what gets throttled first.

r, err := repo.NewRepo(repo.Settings{
    Forge: repo.ForgeGitLab,
    SSH:   repo.SSHSettings{Configured: true, HasKey: true, Type: "agent"},
})
if err != nil {
    return err
}

if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil {
    _, _, err = r.Clone(ctx, url, dir)

    return err
}

if _, _, err := r.OpenLocal(ctx, dir); err != nil {
    return err
}

err = r.Pull(ctx, nil)
if errors.Is(err, git.NoErrAlreadyUpToDate) {
    return nil
}

return err

OpenLocal initialises an empty repository where there is none, so check for the clone before opening it. Pulling a freshly initialised repository fails with reference not found, because it has no commits.

Re-clone on any refusal

Each refusal is a sentinel. For a clone that nothing edits between pulls, every one of them means the same thing, which is that the clone no longer matches what you expect, and the answer is a fresh clone.

Return When What changed
nil the branch fast-forwarded HEAD, the branch, the tree, the tracking ref
git.NoErrAlreadyUpToDate nothing new, or the clone is ahead of its remote the tracking ref, if the fetch moved it
ErrUnsupportedPullOption an option Pull does not honour nothing
ErrNoRepository, ErrNoWorktree not opened, or no worktree nothing
ErrDetachedHead HEAD is not a branch nothing
ErrWorktreeNotClean any local change, untracked files included nothing, or the tracking ref if found after the fetch
ErrRemoteBranchMissing the remote has no such branch nothing
ErrNotFastForward diverged, or ancestry unprovable on a shallow clone the tracking ref only
wraps plumbing.ErrReferenceNotFound unborn HEAD nothing
wraps git.ErrRemoteNotFound no such remote nothing
a wrapped transport error the fetch failed, including a cancelled context nothing, or the tracking ref
the context's error cancelled after the fetch, before the reset the tracking ref only
a wrapped error from Reset writing the index or a file failed HEAD and the branch; the tree is not trustworthy

The last row is the one exception to "nothing moved". It is a disk failure, not a refusal: read Head() to see where HEAD is, and re-clone. The errors reference explains each sentinel.

A local change refuses the pull

Any modified, staged, deleted or untracked file refuses the pull with ErrWorktreeNotClean, whichever paths upstream changed. Ignored files do not count. That is stricter than git pull --ff-only, deliberately, so the same local state always gets the same answer. Pull is stricter than git has the comparison.

If your program writes into the clone, write build output somewhere .gitignore covers, or outside the clone.

Pull from another remote or branch

RemoteName picks the remote, and ReferenceName the remote branch, given in full:

err := r.Pull(ctx, &git.PullOptions{
    RemoteName:    "upstream",
    ReferenceName: plumbing.NewBranchReferenceName("release"),
})

That is git pull --ff-only upstream release: the checked-out branch fast-forwards to upstream/release, and refs/remotes/upstream/release is written. An empty ReferenceName means the remote branch with the same name as the checked-out branch, which is not go-git's default (the remote's HEAD).

Pull refuses Force, a non-zero Depth, a RemoteURL, RecurseSubmodules, and a ReferenceName outside refs/heads/, rather than ignoring them. To pull from a URL, add a remote for it with CreateRemote on *Repo, or through WithRepo on a ThreadSafeRepo.

Pull does not fetch tags. A clone made with WithNoTags stays without them.

Fetch without pulling

Fetch updates the remote-tracking refs and touches nothing else:

err := r.Fetch(ctx, &git.FetchOptions{Prune: true})

It is a thin wrapper over go-git's fetch: it fills Auth and Progress from the repository when unset, refuses nothing go-git accepts, and fetches tags as go-git does by default. Nothing new is git.NoErrAlreadyUpToDate.

Both Fetch and Pull work on a copy of the options you pass, so one options value is safe to reuse across repositories.