package server import ( "fmt" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "gitweb/internal/store" ) // TestIncrementalFetch covers a fetch by a client that already holds most of // the history. Clone never exercises negotiation: it sends "want" lines and // "done" in one request, with no "have" lines at all. A fetch does, and git // splits negotiation across requests — it offers its first batch of haves // (INITIAL_FLUSH, 16) terminated by a flush-pkt and *no* "done", then waits for // the server's ACK/NAK before deciding what to ask for next. A server that // answers that round with a packfile breaks the client, which is still reading // pkt-lines: it reports // // fatal: protocol error: bad line length character: PACK // // The history therefore has to be deep enough that git does not exhaust its // haves (and so append "done") in the first round. func TestIncrementalFetch(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") } env := gitEnv(t) ns := t.TempDir() const owner = "u@d.com" const repoPath = owner + "/deep.git" bare := filepath.Join(ns, filepath.FromSlash(repoPath)) // A history comfortably deeper than git's 16-have first batch. const depth = 24 work := t.TempDir() mustGit(t, env, work, "init", "-q", "-b", "main") for i := range depth { writeTestFile(t, work, "f.txt", fmt.Sprintf("v%d\n", i)) mustGit(t, env, work, "add", "-A") mustGit(t, env, work, "commit", "-qm", fmt.Sprintf("commit %d", i)) } if err := os.MkdirAll(filepath.Dir(bare), 0o755); err != nil { t.Fatal(err) } mustGit(t, env, work, "clone", "-q", "--bare", work, bare) if err := os.WriteFile(filepath.Join(bare, "Access"), []byte("read,list: all\n*: "+owner+"\n"), 0o644); err != nil { t.Fatal(err) } srv, err := New(Config{ Store: store.NewLocalStore(ns), Web: http.FS(os.DirFS("../../web")), CacheDir: t.TempDir(), }) if err != nil { t.Fatal(err) } ts := httptest.NewServer(srv.Handler()) defer ts.Close() // The client clones the full history, so it has every commit to offer. dst := filepath.Join(t.TempDir(), "client") mustGit(t, env, ".", "clone", "-q", cloneURL(ts.URL, repoPath, "", ""), dst) // One more commit lands in the bare repo, out of band. writeTestFile(t, work, "f.txt", "tip\n") mustGit(t, env, work, "add", "-A") mustGit(t, env, work, "commit", "-qm", "tip") mustGit(t, env, work, "push", "-q", bare, "main") wantTip := strings.TrimSpace(mustGit(t, env, work, "rev-parse", "main")) out, err := runGit(t, env, dst, "fetch", "origin") if err != nil { t.Fatalf("incremental fetch failed: %v\n%s", err, out) } if got := strings.TrimSpace(mustGit(t, env, dst, "rev-parse", "origin/main")); got != wantTip { t.Errorf("origin/main = %s, want %s", got, wantTip) } // Acknowledging the common base is what keeps this warning away; without an // ACK git assumes the histories are unrelated and takes far more than the // delta. if strings.Contains(out, "no common commits") { t.Errorf("fetch reported no common commits, so the shared base went unacknowledged:\n%s", out) } // A deep client reaches the session only after a NAK round, so the haves it // cannot share are filtered out before that. See // TestFetchWithUnpushedLocalCommits for the shallow case, where they arrive // alongside "done" and reach the session directly. t.Run("unpushed local commits", func(t *testing.T) { writeTestFile(t, dst, "local.txt", "mine\n") mustGit(t, env, dst, "add", "-A") mustGit(t, env, dst, "commit", "-qm", "local only") writeTestFile(t, work, "f.txt", "newer\n") mustGit(t, env, work, "add", "-A") mustGit(t, env, work, "commit", "-qm", "newer") mustGit(t, env, work, "push", "-q", bare, "main") newTip := strings.TrimSpace(mustGit(t, env, work, "rev-parse", "main")) out, err := runGit(t, env, dst, "fetch", "origin") if err != nil { t.Fatalf("fetch with unpushed local commits failed: %v\n%s", err, out) } if got := strings.TrimSpace(mustGit(t, env, dst, "rev-parse", "origin/main")); got != newTip { t.Errorf("origin/main = %s, want %s", got, newTip) } }) } func writeTestFile(t *testing.T, dir, name, content string) { t.Helper() if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { t.Fatal(err) } } // TestFetchWithUnpushedLocalCommits covers a client holding commits the server // has never seen — the ordinary "some local work, now fetch" case. // // The history is deliberately shallow: with fewer than git's first batch of 16 // haves to offer, the client puts its haves and "done" in a single request, so // they reach the upload-pack session instead of being answered by a NAK round. // The session walks req.Haves to build its exclusion set and returns // ErrObjectNotFound for anything it cannot resolve, which turns the fetch into // a 500 unless the unknown haves are dropped first. func TestFetchWithUnpushedLocalCommits(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") } env := gitEnv(t) ns := t.TempDir() const owner = "u@d.com" const repoPath = owner + "/shallow.git" bare := filepath.Join(ns, filepath.FromSlash(repoPath)) work := t.TempDir() mustGit(t, env, work, "init", "-q", "-b", "main") for i := range 3 { writeTestFile(t, work, "f.txt", fmt.Sprintf("v%d\n", i)) mustGit(t, env, work, "add", "-A") mustGit(t, env, work, "commit", "-qm", fmt.Sprintf("commit %d", i)) } if err := os.MkdirAll(filepath.Dir(bare), 0o755); err != nil { t.Fatal(err) } mustGit(t, env, work, "clone", "-q", "--bare", work, bare) if err := os.WriteFile(filepath.Join(bare, "Access"), []byte("read,list: all\n*: "+owner+"\n"), 0o644); err != nil { t.Fatal(err) } srv, err := New(Config{ Store: store.NewLocalStore(ns), Web: http.FS(os.DirFS("../../web")), CacheDir: t.TempDir(), }) if err != nil { t.Fatal(err) } ts := httptest.NewServer(srv.Handler()) defer ts.Close() dst := filepath.Join(t.TempDir(), "client") mustGit(t, env, ".", "clone", "-q", cloneURL(ts.URL, repoPath, "", ""), dst) // Local work the server will never have. writeTestFile(t, dst, "local.txt", "mine\n") mustGit(t, env, dst, "add", "-A") mustGit(t, env, dst, "commit", "-qm", "local only") // Something new to fetch. writeTestFile(t, work, "f.txt", "newer\n") mustGit(t, env, work, "add", "-A") mustGit(t, env, work, "commit", "-qm", "newer") mustGit(t, env, work, "push", "-q", bare, "main") wantTip := strings.TrimSpace(mustGit(t, env, work, "rev-parse", "main")) out, err := runGit(t, env, dst, "fetch", "origin") if err != nil { t.Fatalf("fetch with unpushed local commits failed: %v\n%s", err, out) } if got := strings.TrimSpace(mustGit(t, env, dst, "rev-parse", "origin/main")); got != wantTip { t.Errorf("origin/main = %s, want %s", got, wantTip) } }