push: false non-fast-forward rejection with multiple shallow boundaries
Bug Description
A depth-one fetch of multiple related refs can cause PushContext to reject an update that is unambiguously fast-forward.
Given this graph:
R -- B -- C refs/heads/flipt/staging-test/blocker
|
+------ refs/heads/staging-test
|
+------ refs/heads/flipt/staging-test/targetfetching staging-test and flipt/staging-test/* with Depth: 1 records both B and C as shallow boundaries. If the client then creates N with B as its direct parent and pushes N over the target branch, go-git rejects the update with:
non-fast-forward update: refs/heads/flipt/staging-test/targetExpected behavior: the push succeeds because N is a direct descendant of the advertised remote head B.
Actual behavior: go-git rejects the update locally as non-fast-forward before sending it to git-receive-pack.
The equivalent operation using native Git with the same depth-one refspecs and commit graph succeeds.
go-git Version
Reproduced with:
github.com/go-git/go-git/v6 v6.0.0-alpha.5- current
main:v6.0.0-alpha.5.0.20260907191556-57ed51864460
Environment used for the reproduction:
go version go1.27.1 darwin/arm64
git version 2.50.1 (Apple Git-155)
Darwin arm64Steps to Reproduce
Save the three files below, make the shell fixture executable, and run:
chmod +x testdata/setup_git_remote.sh
go mod tidy
go test -run '^TestPushDirectDescendantWithMultipleShallowBoundaries$' -count=1 -vThe test is written as a regression test for the expected behavior, so it fails on affected versions at the final PushContext call.
module example.com/go-git-shallow-regression
go 1.25.0
require (
github.com/go-git/go-billy/v6 v6.0.0-alpha.2
github.com/go-git/go-git/v6 v6.0.0-alpha.5
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
)package shallowregression_test
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/go-git/go-billy/v6/osfs"
git "github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/config"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/cache"
"github.com/go-git/go-git/v6/plumbing/object"
gitfilesystem "github.com/go-git/go-git/v6/storage/filesystem"
)
const (
baseBranch = "staging-test"
targetBranch = "flipt/staging-test/target"
)
// TestPushDirectDescendantWithMultipleShallowBoundaries is a black-box
// regression test for go-git's false non-fast-forward rejection.
//
// The shell fixture constructs this graph and exposes it through git daemon:
//
// R -- B -- C flipt/staging-test/blocker
// |
// +------ staging-test and flipt/staging-test/target
//
// A depth-one fetch records B and C as shallow boundaries. The test creates N
// as a direct child of B and pushes N to target. That update is a fast-forward,
// but affected go-git versions reject it before contacting git-receive-pack.
func TestPushDirectDescendantWithMultipleShallowBoundaries(t *testing.T) {
remoteURL, baseHash, blockerHash := setupGitRemote(t)
clientPath := filepath.Join(t.TempDir(), "client")
if err := os.Mkdir(clientPath, 0o755); err != nil {
t.Fatalf("create client repository directory: %v", err)
}
storer := gitfilesystem.NewStorage(
osfs.New(clientPath),
cache.NewObjectLRUDefault(),
)
repository, err := git.Init(
storer,
git.WithDefaultBranch(plumbing.NewBranchReferenceName(targetBranch)),
)
if err != nil {
t.Fatalf("initialize client repository: %v", err)
}
t.Cleanup(func() {
if err := repository.Close(); err != nil {
t.Errorf("close client repository: %v", err)
}
})
if _, err := repository.CreateRemote(&config.RemoteConfig{
Name: "origin",
URLs: []string{remoteURL},
}); err != nil {
t.Fatalf("create origin remote: %v", err)
}
err = repository.FetchContext(t.Context(), &git.FetchOptions{
RemoteName: "origin",
RefSpecs: []config.RefSpec{
"+refs/heads/staging-test:refs/remotes/origin/staging-test",
"+refs/heads/flipt/staging-test/*:refs/remotes/origin/flipt/staging-test/*",
},
Depth: 1,
Tags: plumbing.NoTags,
})
if err != nil {
t.Fatalf("depth-one fetch: %v", err)
}
shallows, err := storer.Shallow()
if err != nil {
t.Fatalf("read shallow boundaries: %v", err)
}
assertShallowBoundaries(t, shallows, baseHash, blockerHash)
remoteTarget, err := repository.Reference(
plumbing.NewRemoteReferenceName("origin", targetBranch),
true,
)
if err != nil {
t.Fatalf("resolve fetched target branch: %v", err)
}
if remoteTarget.Hash() != baseHash {
t.Fatalf("target branch = %s, want base commit %s", remoteTarget.Hash(), baseHash)
}
parent, err := repository.CommitObject(baseHash)
if err != nil {
t.Fatalf("load target parent commit: %v", err)
}
newHash := storeDirectChild(t, storer, parent)
localTarget := plumbing.NewBranchReferenceName(targetBranch)
if err := storer.SetReference(plumbing.NewHashReference(localTarget, newHash)); err != nil {
t.Fatalf("set local target branch: %v", err)
}
// This must succeed because newHash is a direct descendant of the remote
// target. On affected versions this assertion fails with a false
// "non-fast-forward update" error.
err = repository.PushContext(t.Context(), &git.PushOptions{
RemoteName: "origin",
RefSpecs: []config.RefSpec{
config.RefSpec(fmt.Sprintf("%[1]s:%[1]s", localTarget)),
},
})
if err != nil {
t.Fatalf(
"push of direct descendant %s over %s was rejected: %v",
newHash,
baseHash,
err,
)
}
}
func setupGitRemote(t *testing.T) (string, plumbing.Hash, plumbing.Hash) {
t.Helper()
fixtureRoot := t.TempDir()
script, err := filepath.Abs(filepath.Join("testdata", "setup_git_remote.sh"))
if err != nil {
t.Fatalf("resolve fixture script: %v", err)
}
cmd := exec.CommandContext(t.Context(), script, "setup", fixtureRoot)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("set up Git fixture: %v\n%s", err, output)
}
t.Cleanup(func() {
cmd := exec.Command(script, "cleanup", fixtureRoot)
if output, err := cmd.CombinedOutput(); err != nil {
t.Errorf("clean up Git fixture: %v\n%s", err, output)
}
})
return readFixtureValue(t, fixtureRoot, "remote-url"),
plumbing.NewHash(readFixtureValue(t, fixtureRoot, "base-hash")),
plumbing.NewHash(readFixtureValue(t, fixtureRoot, "blocker-hash"))
}
func readFixtureValue(t *testing.T, root, name string) string {
t.Helper()
value, err := os.ReadFile(filepath.Join(root, name))
if err != nil {
t.Fatalf("read fixture value %s: %v", name, err)
}
return strings.TrimSpace(string(value))
}
func storeDirectChild(
t *testing.T,
storer *gitfilesystem.Storage,
parent *object.Commit,
) plumbing.Hash {
t.Helper()
now := time.Now().UTC()
commit := &object.Commit{
Author: object.Signature{Name: "go-git regression test", Email: "repro@localhost", When: now},
Committer: object.Signature{Name: "go-git regression test", Email: "repro@localhost", When: now},
Message: "direct child of target head\n",
TreeHash: parent.TreeHash,
ParentHashes: []plumbing.Hash{parent.Hash},
}
encoded := storer.NewEncodedObject()
encoded.SetType(plumbing.CommitObject)
if err := commit.Encode(encoded); err != nil {
t.Fatalf("encode child commit: %v", err)
}
hash, err := storer.SetEncodedObject(encoded)
if err != nil {
t.Fatalf("store child commit: %v", err)
}
return hash
}
func assertShallowBoundaries(
t *testing.T,
shallows []plumbing.Hash,
want ...plumbing.Hash,
) {
t.Helper()
if len(shallows) != len(want) {
t.Fatalf("shallow boundaries = %v, want %v", shallows, want)
}
for _, expected := range want {
found := false
for _, actual := range shallows {
if actual == expected {
found = true
break
}
}
if !found {
t.Fatalf("shallow boundaries %v do not contain %s", shallows, expected)
}
}
}#!/bin/sh
set -eu
command_name=${1:-}
fixture_root=${2:-}
if [ -z "$command_name" ] || [ -z "$fixture_root" ]; then
echo "usage: $0 setup|cleanup FIXTURE_ROOT" >&2
exit 2
fi
pid_file="$fixture_root/git-daemon.pid"
cleanup() {
if [ ! -f "$pid_file" ]; then
return
fi
daemon_pid=$(cat "$pid_file")
if kill -0 "$daemon_pid" 2>/dev/null; then
kill "$daemon_pid" 2>/dev/null || true
attempt=0
while kill -0 "$daemon_pid" 2>/dev/null && [ "$attempt" -lt 50 ]; do
sleep 0.02
attempt=$((attempt + 1))
done
kill -KILL "$daemon_pid" 2>/dev/null || true
fi
rm -f "$pid_file"
}
if [ "$command_name" = "cleanup" ]; then
cleanup
exit 0
fi
if [ "$command_name" != "setup" ]; then
echo "unknown command: $command_name" >&2
exit 2
fi
command -v git >/dev/null
command -v python3 >/dev/null
remote_repo="$fixture_root/remote.git"
seed_repo="$fixture_root/seed"
base_branch="staging-test"
target_branch="flipt/staging-test/target"
blocker_branch="flipt/staging-test/blocker"
git init --quiet --bare "$remote_repo"
git init --quiet --initial-branch="$base_branch" "$seed_repo"
git -C "$seed_repo" config user.name "go-git regression test"
git -C "$seed_repo" config user.email "repro@localhost"
git -C "$seed_repo" commit --quiet --allow-empty -m "root commit"
git -C "$seed_repo" commit --quiet --allow-empty -m "base commit"
git -C "$seed_repo" remote add origin "$remote_repo"
git -C "$seed_repo" push --quiet origin "HEAD:refs/heads/$base_branch"
git -C "$seed_repo" rev-parse HEAD > "$fixture_root/base-hash"
git -C "$seed_repo" push --quiet origin "HEAD:refs/heads/$target_branch"
git -C "$seed_repo" commit --quiet --allow-empty -m "shallow-boundary blocker"
git -C "$seed_repo" rev-parse HEAD > "$fixture_root/blocker-hash"
git -C "$seed_repo" push --quiet origin "HEAD:refs/heads/$blocker_branch"
daemon_port=$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')
remote_url="git://127.0.0.1:$daemon_port/remote.git"
git daemon \
--detach \
--reuseaddr \
--export-all \
--enable=receive-pack \
--pid-file="$pid_file" \
--base-path="$fixture_root" \
--listen=127.0.0.1 \
--port="$daemon_port" \
"$fixture_root"
attempt=0
while ! git ls-remote "$remote_url" "refs/heads/$base_branch" >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 100 ]; then
cleanup
echo "git daemon did not become ready at $remote_url" >&2
exit 1
fi
sleep 0.02
done
printf '%s\n' "$remote_url" > "$fixture_root/remote-url"Additional Information
The failure appears to come from isFastForward. It builds one global parentsToIgnore list from the parents of every shallow commit. In this graph, C is shallow and parent(C) == B, so B is added to parentsToIgnore. During the ancestry walk from N, the iterator then skips B even though B is the exact old hash being checked.
This requires multiple shallow boundaries with this relationship; a depth-one fetch of only the target ref does not reproduce the failure.
The issue was initially observed through a downstream Git-backed application that fetches a base branch together with a wildcard set of proposal branches, but the reproducer uses only public go-git APIs and a loopback git daemon. It performs no external pushes.
Source: go-git/go-git