#2367·go-git

push: false non-fast-forward rejection with multiple shallow boundaries

Author: YiivgenyCreated Sep 8, 2026Updated Sep 11, 2026

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/target

fetching 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/target

Expected 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 arm64

Steps to Reproduce

Save the three files below, make the shell fixture executable, and run:

bash
chmod +x testdata/setup_git_remote.sh
go mod tidy
go test -run '^TestPushDirectDescendantWithMultipleShallowBoundaries$' -count=1 -v

The test is written as a regression test for the expected behavior, so it fails on affected versions at the final PushContext call.

go.mod
go
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
)
shallow_push_test.go
go
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)
		}
	}
}
testdata/setup_git_remote.sh
bash
#!/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.