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)
}
}