Skip to content

Commit 16e8a94

Browse files
worstellcodexampagent
committed
fix(git): handle empty delta bundles
Verify an empty delta against fresh upstream refs before returning the no-content outcome. Keep ref inspection and bundle creation consistent so concurrent fetches cannot turn an empty delta into an arbitrary Git failure. Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-01a0a667-aa3c-7519-8f18-52fce84e7724
1 parent cdf2240 commit 16e8a94

6 files changed

Lines changed: 728 additions & 178 deletions

File tree

internal/gitclone/manager.go

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,7 @@ func (r *Repository) FetchVerified(ctx context.Context) error {
634634
}
635635

636636
func (r *Repository) fetchInternal(ctx context.Context, timeout time.Duration, enforceSpeedLimit, coalesce bool) error {
637+
lastFetch := r.LastFetch()
637638
select {
638639
case <-r.fetchSem:
639640
defer func() {
@@ -642,22 +643,14 @@ func (r *Repository) fetchInternal(ctx context.Context, timeout time.Duration, e
642643
case <-ctx.Done():
643644
return errors.Wrap(ctx.Err(), "context cancelled before acquiring fetch semaphore")
644645
default:
645-
// The semaphore is held. Coalescing callers treat the holder's work as
646-
// their fetch; verified callers wait their turn and fetch themselves.
647-
if coalesce {
648-
select {
649-
case <-r.fetchSem:
650-
r.fetchSem <- struct{}{}
651-
return nil
652-
case <-ctx.Done():
653-
return errors.Wrap(ctx.Err(), "context cancelled while waiting for fetch")
654-
}
655-
}
656646
select {
657647
case <-r.fetchSem:
658648
defer func() {
659649
r.fetchSem <- struct{}{}
660650
}()
651+
if coalesce && r.LastFetch().After(lastFetch) {
652+
return nil
653+
}
661654
case <-ctx.Done():
662655
return errors.Wrap(ctx.Err(), "context cancelled before acquiring fetch semaphore")
663656
}

internal/gitclone/manager_test.go

Lines changed: 57 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -296,62 +296,65 @@ func TestRepository_NeedsFetch(t *testing.T) {
296296
assert.False(t, repo.NeedsFetch(15*time.Minute))
297297
}
298298

299-
func TestRepository_FetchVerifiedDoesNotCoalesce(t *testing.T) {
300-
ctx := context.Background()
301-
tmpDir := t.TempDir()
302-
upstreamPath := createBareRepo(t, tmpDir)
303-
304-
clonePath := filepath.Join(tmpDir, "clone")
305-
repo := &Repository{
306-
state: StateEmpty,
307-
config: testRepoConfig(),
308-
path: clonePath,
309-
upstreamURL: upstreamPath,
310-
fetchSem: make(chan struct{}, 1),
311-
}
312-
repo.fetchSem <- struct{}{}
313-
assert.NoError(t, repo.Clone(ctx))
299+
func TestRepository_FetchDoesNotCoalesceWithExclusion(t *testing.T) {
300+
for _, verified := range []bool{false, true} {
301+
t.Run(fmt.Sprintf("verified=%t", verified), func(t *testing.T) {
302+
ctx := context.Background()
303+
tmpDir := t.TempDir()
304+
upstreamPath := createBareRepo(t, tmpDir)
305+
306+
clonePath := filepath.Join(tmpDir, "clone")
307+
repo := &Repository{
308+
state: StateEmpty,
309+
config: testRepoConfig(),
310+
path: clonePath,
311+
upstreamURL: upstreamPath,
312+
fetchSem: make(chan struct{}, 1),
313+
}
314+
repo.fetchSem <- struct{}{}
315+
assert.NoError(t, repo.Clone(ctx))
316+
317+
workPath := filepath.Join(tmpDir, "work")
318+
assert.NoError(t, os.WriteFile(filepath.Join(workPath, "f.txt"), []byte("y"), 0o644))
319+
for _, args := range [][]string{
320+
{"git", "-C", workPath, "commit", "-am", "update"},
321+
{"git", "-C", workPath, "push", upstreamPath, "HEAD"},
322+
} {
323+
assert.NoError(t, exec.Command(args[0], args[1:]...).Run())
324+
}
325+
newSHAOut, err := exec.Command("git", "-C", workPath, "rev-parse", "HEAD").Output()
326+
assert.NoError(t, err)
327+
newSHA := strings.TrimSpace(string(newSHAOut))
328+
329+
holdSem := func(t *testing.T, fetch func() error) error {
330+
t.Helper()
331+
release := make(chan struct{})
332+
entered := make(chan struct{})
333+
holderDone := make(chan error, 1)
334+
go func() {
335+
holderDone <- repo.WithFetchExclusion(ctx, func() error {
336+
close(entered)
337+
<-release
338+
return nil
339+
})
340+
}()
341+
<-entered
342+
fetchDone := make(chan error, 1)
343+
go func() { fetchDone <- fetch() }()
344+
time.Sleep(50 * time.Millisecond)
345+
close(release)
346+
assert.NoError(t, <-holderDone)
347+
return <-fetchDone
348+
}
314349

315-
// Advance upstream so the mirror is behind.
316-
workPath := filepath.Join(tmpDir, "work")
317-
assert.NoError(t, os.WriteFile(filepath.Join(workPath, "f.txt"), []byte("y"), 0o644))
318-
for _, args := range [][]string{
319-
{"git", "-C", workPath, "commit", "-am", "update"},
320-
{"git", "-C", workPath, "push", upstreamPath, "HEAD"},
321-
} {
322-
assert.NoError(t, exec.Command(args[0], args[1:]...).Run())
350+
fetch := repo.Fetch
351+
if verified {
352+
fetch = repo.FetchVerified
353+
}
354+
assert.NoError(t, holdSem(t, func() error { return fetch(ctx) }))
355+
assert.True(t, repo.HasCommit(ctx, newSHA))
356+
})
323357
}
324-
newSHAOut, err := exec.Command("git", "-C", workPath, "rev-parse", "HEAD").Output()
325-
assert.NoError(t, err)
326-
newSHA := strings.TrimSpace(string(newSHAOut))
327-
328-
holdSem := func(t *testing.T, fetch func() error) error {
329-
t.Helper()
330-
release := make(chan struct{})
331-
holderDone := make(chan error, 1)
332-
go func() {
333-
holderDone <- repo.WithFetchExclusion(ctx, func() error {
334-
<-release
335-
return nil
336-
})
337-
}()
338-
time.Sleep(20 * time.Millisecond)
339-
fetchDone := make(chan error, 1)
340-
go func() { fetchDone <- fetch() }()
341-
time.Sleep(50 * time.Millisecond)
342-
close(release)
343-
assert.NoError(t, <-holderDone)
344-
return <-fetchDone
345-
}
346-
347-
// Fetch coalesces with the semaphore holder even though the holder was
348-
// not fetching, so the mirror stays behind.
349-
assert.NoError(t, holdSem(t, func() error { return repo.Fetch(ctx) }))
350-
assert.False(t, repo.HasCommit(ctx, newSHA))
351-
352-
// FetchVerified waits for the holder and then runs its own fetch.
353-
assert.NoError(t, holdSem(t, func() error { return repo.FetchVerified(ctx) }))
354-
assert.True(t, repo.HasCommit(ctx, newSHA))
355358
}
356359

357360
func TestParseGitRefs(t *testing.T) {

0 commit comments

Comments
 (0)