Skip to content

Commit 1d6be60

Browse files
corrierilucanerdeveloperdaniel-ciaglia
authored
fix(repository): replace pull with fetch+reset to avoid go-git non-fast-forward bug (#863)
* fix(repository): replace Pull with Fetch+Reset to avoid go-git non-fast-forward bug The go-git library's Pull() function has a known issue (#358) where it returns 'non-fast-forward update' errors even when a fast-forward merge is possible. This happens when local branches don't have proper upstream tracking configured. This commit replaces Pull() with an explicit Fetch() + Hard Reset approach: 1. Fetch latest changes from remote with Force=true 2. Get the remote reference for the target branch 3. Hard reset the worktree to the remote ref 4. Update the local branch reference This approach: - Avoids the go-git Pull() bug entirely - Is more explicit about what we want (always match remote) - Handles force-push scenarios correctly - Works regardless of tracking configuration Fixes: go-git/go-git#358 * fix: incorrect Pull target on non-default branches --------- Co-authored-by: Obinna Odirionye <odirionye@gmail.com> Co-authored-by: Daniel Ciaglia <daniel@sigterm.de> Co-authored-by: Luca Corrieri <luca.corrieri@theodo.com>
1 parent 8e179ff commit 1d6be60

2 files changed

Lines changed: 236 additions & 14 deletions

File tree

internal/repository/providers/standard/repository.go

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -128,25 +128,39 @@ func (p *GitProvider) Bundle(ref string) ([]byte, error) {
128128
return nil, fmt.Errorf("failed to checkout branch %s: %w", reference.Name(), err)
129129
}
130130

131-
// Pull latest changes
132-
pullOpts := &git.PullOptions{
131+
// Fetch latest changes from remote (instead of Pull which has issues with go-git)
132+
// See: https://github.com/go-git/go-git/issues/358
133+
log.Infof("fetching latest changes for repo %s", p.RepoURL)
134+
err = p.gitRepository.Fetch(&git.FetchOptions{
133135
Auth: p.AuthMethod,
134136
RemoteName: remote,
137+
Force: true,
138+
})
139+
if err != nil && err != git.NoErrAlreadyUpToDate {
140+
log.Warnf("failed to fetch: %v", err)
135141
}
136142

137-
log.Infof("pulling latest changes for repo %s on branch %s", p.RepoURL, reference.Name())
138-
err = worktree.Pull(pullOpts)
143+
// Get the latest remote ref and hard reset to it
144+
remoteRefName := getRemoteReferenceName(ref)
145+
remoteRef, err := p.gitRepository.Reference(remoteRefName, true)
139146
if err != nil {
140-
if err == git.NoErrAlreadyUpToDate {
141-
log.Info("repository is already up-to-date")
142-
} else {
143-
log.Warnf("failed to pull latest changes for ref %s: %v, deleting local repository", reference.Name(), err)
144-
p.gitRepository = nil
145-
if removeErr := os.RemoveAll(p.repositoryPath); removeErr != nil {
146-
return nil, fmt.Errorf("failed to remove repository at %s: %w", p.repositoryPath, removeErr)
147-
}
148-
return nil, fmt.Errorf("failed to pull latest changes for ref %s: %w, likely because of force-push, next run will re-clone", reference.Name(), err)
149-
}
147+
return nil, fmt.Errorf("failed to get remote reference %s: %w", remoteRefName, err)
148+
}
149+
150+
log.Infof("resetting to remote ref %s (%s)", remoteRefName, remoteRef.Hash().String())
151+
err = worktree.Reset(&git.ResetOptions{
152+
Commit: remoteRef.Hash(),
153+
Mode: git.HardReset,
154+
})
155+
if err != nil {
156+
return nil, fmt.Errorf("failed to reset to remote ref %s: %w", remoteRefName, err)
157+
}
158+
159+
// Update local branch reference to match remote
160+
reference = plumbing.NewHashReference(localRefName, remoteRef.Hash())
161+
err = p.gitRepository.Storer.SetReference(reference)
162+
if err != nil {
163+
return nil, fmt.Errorf("failed to update local branch reference: %w", err)
150164
}
151165

152166
// Create git bundle
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package standard
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"testing"
8+
"time"
9+
10+
"github.com/go-git/go-git/v5"
11+
"github.com/go-git/go-git/v5/plumbing"
12+
"github.com/go-git/go-git/v5/plumbing/object"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func testSig() *object.Signature {
18+
return &object.Signature{Name: "test", Email: "test@example.com", When: time.Now()}
19+
}
20+
21+
// TestBundle_NonDefaultBranch_FastForwards is a regression test for the
22+
// "non-fast-forward update" error when Bundle() is called on a non-default
23+
// branch whose Pull was resolved to the wrong remote ref.
24+
//
25+
// Root cause: Pull() without ReferenceName falls back to the remote's HEAD
26+
// (the default branch). When the default branch has commits not in the
27+
// non-default branch, this is a non-fast-forward relative to the local branch.
28+
//
29+
// Fix: always pass ReferenceName in PullOptions so go-git targets the correct
30+
// remote branch rather than the remote's default branch.
31+
func TestBundle_NonDefaultBranch_FastForwards(t *testing.T) {
32+
if _, err := exec.LookPath("git"); err != nil {
33+
t.Skip("git CLI not available")
34+
}
35+
36+
// Set up a "remote" repo with diverged master and feature branches:
37+
// master: A → B (B has a master-only file)
38+
// feature: A → C (C has a feature-only file; diverges from master at A)
39+
remoteDir := t.TempDir()
40+
remoteRepo, err := git.PlainInit(remoteDir, false)
41+
require.NoError(t, err)
42+
43+
wt, err := remoteRepo.Worktree()
44+
require.NoError(t, err)
45+
sig := testSig()
46+
47+
// Commit A — initial commit on master
48+
require.NoError(t, os.WriteFile(filepath.Join(remoteDir, "README.md"), []byte("initial"), 0644))
49+
_, err = wt.Add("README.md")
50+
require.NoError(t, err)
51+
commitA, err := wt.Commit("initial commit", &git.CommitOptions{Author: sig})
52+
require.NoError(t, err)
53+
54+
// Create feature branch pointing at A (before master advances)
55+
require.NoError(t, remoteRepo.Storer.SetReference(
56+
plumbing.NewHashReference("refs/heads/feature", commitA),
57+
))
58+
59+
// Commit B on master (master = A→B, feature = A)
60+
require.NoError(t, os.WriteFile(filepath.Join(remoteDir, "master.txt"), []byte("master-only"), 0644))
61+
_, err = wt.Add("master.txt")
62+
require.NoError(t, err)
63+
_, err = wt.Commit("advance master", &git.CommitOptions{Author: sig})
64+
require.NoError(t, err)
65+
66+
// Commit C on feature (feature = A→C, diverged from master)
67+
require.NoError(t, wt.Checkout(&git.CheckoutOptions{Branch: "refs/heads/feature"}))
68+
require.NoError(t, os.WriteFile(filepath.Join(remoteDir, "feature.txt"), []byte("feature-v1"), 0644))
69+
_, err = wt.Add("feature.txt")
70+
require.NoError(t, err)
71+
_, err = wt.Commit("feature commit", &git.CommitOptions{Author: sig})
72+
require.NoError(t, err)
73+
74+
// Leave remote worktree on master so the default branch is correct
75+
require.NoError(t, wt.Checkout(&git.CheckoutOptions{Branch: "refs/heads/master"}))
76+
77+
// Clone the remote
78+
localDir := t.TempDir()
79+
cloned, err := git.PlainClone(localDir, false, &git.CloneOptions{URL: remoteDir})
80+
require.NoError(t, err)
81+
82+
// Build a GitProvider with the cloned repo injected directly (bypasses
83+
// the clone() call and the hardcoded repositoryDir constant).
84+
workingDir := t.TempDir()
85+
p := &GitProvider{
86+
RepoURL: remoteDir,
87+
gitRepository: cloned,
88+
repositoryPath: localDir,
89+
workingDir: workingDir,
90+
}
91+
92+
// First Bundle("feature") call: local feature branch doesn't exist yet.
93+
// Bundle() creates it from origin/feature (C) and pulls (already up-to-date).
94+
_, err = p.Bundle("feature")
95+
require.NoError(t, err, "first Bundle() on non-default branch should succeed")
96+
97+
// Advance feature on remote: commit D (feature = A→C→D)
98+
require.NoError(t, wt.Checkout(&git.CheckoutOptions{Branch: "refs/heads/feature"}))
99+
require.NoError(t, os.WriteFile(filepath.Join(remoteDir, "feature.txt"), []byte("feature-v2"), 0644))
100+
_, err = wt.Add("feature.txt")
101+
require.NoError(t, err)
102+
_, err = wt.Commit("advance feature", &git.CommitOptions{Author: sig})
103+
require.NoError(t, err)
104+
require.NoError(t, wt.Checkout(&git.CheckoutOptions{Branch: "refs/heads/master"}))
105+
106+
// Second Bundle("feature") call: local feature is at C, remote feature is
107+
// at D (descendant of C), remote master is at B (not a descendant of C).
108+
// Without the fix, Pull targets origin/master (B) → non-fast-forward update.
109+
// With the fix, Pull targets origin/feature (D) → fast-forward C→D.
110+
_, err = p.Bundle("feature")
111+
assert.NoError(t, err, "second Bundle() should fast-forward feature to new remote tip, not fail with non-fast-forward update")
112+
}
113+
114+
// TestBundle_NonDefaultBranch_DirectDescendant covers the scenario from the
115+
// original bug report: a non-default branch that is a direct descendant of the
116+
// default branch (feature = master + extra commits on top).
117+
//
118+
// The error in this case is non-obvious: go-git's Pull without ReferenceName
119+
// targets origin/master (B) and tries to fast-forward local feature (C) to B.
120+
// Since B is an ancestor of C (not a descendant), this is a non-fast-forward
121+
// update — go-git refuses to move the branch pointer backwards.
122+
//
123+
// With the fix (ReferenceName set), Pull correctly targets origin/feature (D)
124+
// and fast-forwards C → D.
125+
func TestBundle_NonDefaultBranch_DirectDescendant(t *testing.T) {
126+
if _, err := exec.LookPath("git"); err != nil {
127+
t.Skip("git CLI not available")
128+
}
129+
130+
// Set up remote with feature as a direct descendant of master:
131+
// master: A → B
132+
// feature: A → B → C (ahead of master, contains all master commits)
133+
remoteDir := t.TempDir()
134+
remoteRepo, err := git.PlainInit(remoteDir, false)
135+
require.NoError(t, err)
136+
137+
wt, err := remoteRepo.Worktree()
138+
require.NoError(t, err)
139+
sig := testSig()
140+
141+
// Commit A on master
142+
require.NoError(t, os.WriteFile(filepath.Join(remoteDir, "README.md"), []byte("initial"), 0644))
143+
_, err = wt.Add("README.md")
144+
require.NoError(t, err)
145+
_, err = wt.Commit("initial commit", &git.CommitOptions{Author: sig})
146+
require.NoError(t, err)
147+
148+
// Commit B on master
149+
require.NoError(t, os.WriteFile(filepath.Join(remoteDir, "base.txt"), []byte("base"), 0644))
150+
_, err = wt.Add("base.txt")
151+
require.NoError(t, err)
152+
commitB, err := wt.Commit("base commit", &git.CommitOptions{Author: sig})
153+
require.NoError(t, err)
154+
155+
// Create feature branch at B (direct descendant starting point)
156+
require.NoError(t, remoteRepo.Storer.SetReference(
157+
plumbing.NewHashReference("refs/heads/feature", commitB),
158+
))
159+
160+
// Commit C on feature (feature = A→B→C, master = A→B)
161+
require.NoError(t, wt.Checkout(&git.CheckoutOptions{Branch: "refs/heads/feature"}))
162+
require.NoError(t, os.WriteFile(filepath.Join(remoteDir, "feature.txt"), []byte("feature-v1"), 0644))
163+
_, err = wt.Add("feature.txt")
164+
require.NoError(t, err)
165+
commitC, err := wt.Commit("feature commit", &git.CommitOptions{Author: sig})
166+
require.NoError(t, err)
167+
require.NoError(t, wt.Checkout(&git.CheckoutOptions{Branch: "refs/heads/master"}))
168+
169+
// Clone the remote
170+
localDir := t.TempDir()
171+
cloned, err := git.PlainClone(localDir, false, &git.CloneOptions{URL: remoteDir})
172+
require.NoError(t, err)
173+
174+
workingDir := t.TempDir()
175+
p := &GitProvider{
176+
RepoURL: remoteDir,
177+
gitRepository: cloned,
178+
repositoryPath: localDir,
179+
workingDir: workingDir,
180+
}
181+
182+
// First Bundle("feature"): creates local feature = C, pulls (already up-to-date)
183+
_, err = p.Bundle("feature")
184+
require.NoError(t, err, "first Bundle() on direct-descendant branch should succeed")
185+
186+
// Advance feature on remote: commit D (feature = A→B→C→D)
187+
require.NoError(t, wt.Checkout(&git.CheckoutOptions{Branch: "refs/heads/feature"}))
188+
require.NoError(t, os.WriteFile(filepath.Join(remoteDir, "feature.txt"), []byte("feature-v2"), 0644))
189+
_, err = wt.Add("feature.txt")
190+
require.NoError(t, err)
191+
commitD, err := wt.Commit("advance feature", &git.CommitOptions{Author: sig})
192+
require.NoError(t, err)
193+
require.NoError(t, wt.Checkout(&git.CheckoutOptions{Branch: "refs/heads/master"}))
194+
195+
// Second Bundle("feature"): local feature is at C, remote feature is at D.
196+
// master is still at B (ancestor of both C and D) — no divergence.
197+
// Without the fix, Pull targets origin/master (B), which is an ancestor of
198+
// C, so it returns "already up-to-date" and the bundle is built from C
199+
// (stale). With the fix, Pull targets origin/feature (D) and fast-forwards.
200+
_, err = p.Bundle("feature")
201+
require.NoError(t, err, "second Bundle() on direct-descendant branch should succeed")
202+
203+
// Verify the local feature branch was advanced to D, not left at C.
204+
ref, err := cloned.Reference(plumbing.NewBranchReferenceName("feature"), true)
205+
require.NoError(t, err)
206+
assert.Equal(t, commitD, ref.Hash(),
207+
"feature branch should point to the new remote tip D after pull, not the stale C (%s)", commitC)
208+
}

0 commit comments

Comments
 (0)