package main
import (
"fmt"
"os"
"os/exec"
"strings"
"testing"
"gitbay.org/gitbay/internal/hookd"
"gitbay.org/gitbay/internal/policy"
)
// fastImportRepo builds a repository with n commits on main in one
// process. A loop of `git commit` would be n forks, which is the cost this
// test exists to rule out of the code under test.
func fastImportRepo(t *testing.T, n int) (string, string) {
t.Helper()
dir := t.TempDir()
run := func(args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
}
run("init", "-q", "--initial-branch=main", ".")
var b strings.Builder
for i := 1; i <= n; i++ {
fmt.Fprintf(&b, "commit refs/heads/main\n")
fmt.Fprintf(&b, "mark :%d\n", i)
fmt.Fprintf(&b, "author A U Thor %d +0000\n", 1600000000+i)
fmt.Fprintf(&b, "committer A U Thor %d +0000\n", 1600000000+i)
msg := fmt.Sprintf("commit %d", i)
fmt.Fprintf(&b, "data %d\n%s\n", len(msg), msg)
if i > 1 {
fmt.Fprintf(&b, "from :%d\n", i-1)
}
fmt.Fprintf(&b, "M 644 inline f.txt\ndata %d\n%d\n", len(fmt.Sprint(i))+1, i)
fmt.Fprintf(&b, "\n")
}
cmd := exec.Command("git", "fast-import", "--quiet")
cmd.Dir = dir
cmd.Stdin = strings.NewReader(b.String())
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("fast-import: %v\n%s", err, out)
}
// pre-receive runs before the ref moves, so the incoming objects are
// present but reachable from nothing — which is what makes
// `rev-list --not --all` list them. Dropping the ref reproduces that;
// the objects stay until a gc that never runs here.
head := gitOut(t, dir, "rev-parse", "main")
run("update-ref", "-d", "refs/heads/main")
return dir, head
}
// streamIncomingCommits runs in the hook process, whose working directory
// is the repository, so the test chdirs the same way git would.
func inRepo(t *testing.T, dir string, f func()) {
t.Helper()
prev, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(prev)
f()
}
// A push large enough that its object names do not fit a pipe buffer:
// writing them all before reading git's output deadlocks, which is why
// stdin is fed from its own goroutine. 4000 names is roughly 160 KiB,
// well past the usual 64 KiB pipe.
func TestStreamIncomingCommitsLargePush(t *testing.T) {
const n = 4000
dir, head := fastImportRepo(t, n)
// No ref exists, so every commit is incoming — the shape of a
// first push of an existing history.
updates := []policy.RefUpdate{{Old: strings.Repeat("0", 40), New: head, Ref: "refs/heads/main"}}
var got []hookd.RawCommit
inRepo(t, dir, func() {
if err := streamIncomingCommits(updates, func(rc hookd.RawCommit) error {
got = append(got, rc)
return nil
}); err != nil {
t.Fatalf("streaming: %v", err)
}
})
if len(got) != n {
t.Fatalf("streamed %d commits, want %d", len(got), n)
}
seen := map[string]bool{}
for _, rc := range got {
if seen[rc.SHA] {
t.Fatalf("%s streamed twice", rc.SHA)
}
seen[rc.SHA] = true
// The raw object is what signature verification parses; a record
// misread by a byte would still look plausible here without this.
if !strings.HasPrefix(string(rc.Raw), "tree ") {
t.Fatalf("%s does not look like a raw commit: %.60q", rc.SHA, rc.Raw)
}
if !strings.Contains(string(rc.Raw), "A U Thor ") {
t.Fatalf("%s raw object is truncated: %.200q", rc.SHA, rc.Raw)
}
}
if !seen[head] {
t.Fatal("the pushed tip was not among the streamed commits")
}
}
// A ref that already exists contributes nothing, and a delete contributes
// nothing: neither introduces an object to verify.
func TestStreamIncomingCommitsNothingToDo(t *testing.T) {
dir, head := fastImportRepo(t, 3)
zero := strings.Repeat("0", 40)
inRepo(t, dir, func() {
n := 0
err := streamIncomingCommits([]policy.RefUpdate{
{Old: head, New: zero, Ref: "refs/heads/main", IsDelete: true},
}, func(hookd.RawCommit) error { n++; return nil })
if err != nil || n != 0 {
t.Fatalf("delete streamed %d commits (%v)", n, err)
}
})
// With the ref restored the tip is reachable, so nothing is incoming:
// a push of what the repository already has verifies nothing.
cmd := exec.Command("git", "update-ref", "refs/heads/main", head)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("update-ref: %v\n%s", err, out)
}
inRepo(t, dir, func() {
n := 0
err := streamIncomingCommits([]policy.RefUpdate{
{Old: head, New: head, Ref: "refs/heads/main"},
}, func(hookd.RawCommit) error { n++; return nil })
if err != nil || n != 0 {
t.Fatalf("already-present tip streamed %d commits (%v)", n, err)
}
})
}
// An error from the callback — the socket going away mid-push — stops the
// walk instead of reading the rest of the history into nothing.
func TestStreamIncomingCommitsCallbackError(t *testing.T) {
// Large enough that git is still writing when the callback gives up:
// without killing it, git blocks on a full pipe and Wait blocks on
// git, and this test hangs rather than fails.
dir, head := fastImportRepo(t, 4000)
inRepo(t, dir, func() {
n := 0
err := streamIncomingCommits(
[]policy.RefUpdate{{Old: strings.Repeat("0", 40), New: head, Ref: "refs/heads/main"}},
func(hookd.RawCommit) error {
n++
if n == 5 {
return fmt.Errorf("socket closed")
}
return nil
})
if err == nil {
t.Fatal("callback error did not stop the walk")
}
if n != 5 {
t.Fatalf("kept streaming after the error: %d commits", n)
}
})
}
func gitOut(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
t.Fatalf("git %s: %v", strings.Join(args, " "), err)
}
return strings.TrimSpace(string(out))
}