From 356ef5fe0f4f325cb01fee0241233be14aa7be3f Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Fri, 6 Feb 2026 20:05:55 +1100 Subject: [PATCH] fix: don't hold lock for duration of initial clone This prevented the client clone from proceeding, blocking until the background clone completed. --- internal/gitclone/manager.go | 2 + internal/gitclone/manager_test.go | 64 +++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/internal/gitclone/manager.go b/internal/gitclone/manager.go index 4196e05..49f9fa7 100644 --- a/internal/gitclone/manager.go +++ b/internal/gitclone/manager.go @@ -270,9 +270,11 @@ func (r *Repository) Clone(ctx context.Context, config Config) error { return nil } r.state = StateCloning + r.mu.Unlock() err := r.executeClone(ctx, config) + r.mu.Lock() if err != nil { r.state = StateEmpty r.mu.Unlock() diff --git a/internal/gitclone/manager_test.go b/internal/gitclone/manager_test.go index c9fca60..907f6ca 100644 --- a/internal/gitclone/manager_test.go +++ b/internal/gitclone/manager_test.go @@ -215,6 +215,70 @@ func TestState_String(t *testing.T) { assert.Equal(t, "ready", StateReady.String()) } +func TestRepository_Clone_StateVisibleDuringClone(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + + // Create a bare upstream repo to clone from + upstreamPath := filepath.Join(tmpDir, "upstream.git") + workPath := filepath.Join(tmpDir, "work") + assert.NoError(t, os.MkdirAll(workPath, 0o755)) + + cmd := exec.Command("git", "-C", workPath, "init") + assert.NoError(t, cmd.Run()) + cmd = exec.Command("git", "-C", workPath, "config", "user.email", "test@example.com") + assert.NoError(t, cmd.Run()) + cmd = exec.Command("git", "-C", workPath, "config", "user.name", "Test") + assert.NoError(t, cmd.Run()) + assert.NoError(t, os.WriteFile(filepath.Join(workPath, "f.txt"), []byte("x"), 0o644)) + cmd = exec.Command("git", "-C", workPath, "add", ".") + assert.NoError(t, cmd.Run()) + cmd = exec.Command("git", "-C", workPath, "commit", "-m", "init") + assert.NoError(t, cmd.Run()) + cmd = exec.Command("git", "clone", "--bare", workPath, upstreamPath) + assert.NoError(t, cmd.Run()) + + clonePath := filepath.Join(tmpDir, "clone") + repo := &Repository{ + state: StateEmpty, + path: clonePath, + upstreamURL: upstreamPath, + fetchSem: make(chan struct{}, 1), + } + repo.fetchSem <- struct{}{} + + config := Config{ + RootDir: tmpDir, + GitConfig: DefaultGitTuningConfig(), + } + + // Start clone in background + cloneDone := make(chan error, 1) + go func() { + cloneDone <- repo.Clone(ctx, config) + }() + + // Poll until we observe StateCloning (should not block) + deadline := time.After(10 * time.Second) + sawCloning := false + for !sawCloning { + select { + case <-deadline: + t.Fatal("timed out waiting to observe StateCloning — State() likely blocked on the clone lock") + default: + if repo.State() == StateCloning { + sawCloning = true + } + } + } + + assert.True(t, sawCloning) + + // Wait for clone to finish + assert.NoError(t, <-cloneDone) + assert.Equal(t, StateReady, repo.State()) +} + func TestRepository_HasCommit(t *testing.T) { ctx := context.Background() tmpDir := t.TempDir()