package e2e import ( "net/http" "os/exec" "path/filepath" "strings" "sync" "testing" ) // seed makes a repository with one commit and returns the working copy. func seed(t *testing.T, in *instance, token, owner, name string) string { t.Helper() work := t.TempDir() run(t, work, "git", "init", "-q", "-b", "master") write(t, work, "config.go", "package main\n") write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n") run(t, work, "git", "add", "-A") run(t, work, "git", "commit", "-qm", "first") run(t, work, "git", "push", "-q", in.url(token, "/"+owner+"/"+name), "master") return work } // clone checks out a repository as somebody else. func clone(t *testing.T, in *instance, token, path string) string { t.Helper() dir := filepath.Join(t.TempDir(), "c") run(t, "", "git", "clone", "-q", in.url(token, path), dir) return dir } func commit(t *testing.T, dir, body, message string) { t.Helper() write(t, dir, "config.go", body) run(t, dir, "git", "commit", "-qam", message) } // Chapter 45.2. Each case is one of the hook tests it lists. func TestHooks(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not installed") } in := newInstance(t) john := in.account("john") lisa := in.account("lisa") mark := in.account("mark") seed(t, in, john, "john", "johnbot") t.Run("push to master without access", func(t *testing.T) { c := clone(t, in, lisa, "/john/johnbot") commit(t, c, "package main // lisa\n", "lisa edits") out, err := try(t, c, "git", "push", in.url(lisa, "/john/johnbot"), "master") if err == nil { t.Fatal("the push was accepted") } // Name the way forward, because a rejected push is where a contributor decides to stop. if !strings.Contains(out, "refs/proposals/new") { t.Errorf("the rejection does not name the proposal command:\n%s", out) } if !strings.Contains(out, "[access] push") { t.Errorf("the rejection does not name the rule that refused it:\n%s", out) } }) t.Run("push to refs/proposals/new", func(t *testing.T) { c := clone(t, in, lisa, "/john/johnbot") commit(t, c, "package main // proposal\n", "a proposal") out := run(t, c, "git", "push", in.url(lisa, "/john/johnbot"), "HEAD:refs/proposals/new") if !strings.Contains(out, "refs/proposals/") { t.Fatalf("no number was allocated:\n%s", out) } if !strings.Contains(out, in.http.URL) { t.Errorf("the push output has no url to open:\n%s", out) } if strings.Contains(out, "refs/proposals/new\n") { t.Error("refs/proposals/new was created; it is reserved") } }) t.Run("two pushes at the same moment", func(t *testing.T) { // Two different numbers, which is the whole reason the counter is a compare-and-swap. var wg sync.WaitGroup outs := make([]string, 2) start := make(chan struct{}) for i := range outs { c := clone(t, in, mark, "/john/johnbot") commit(t, c, "package main // race "+string(rune('a'+i))+"\n", "race") wg.Add(1) go func(i int, dir string) { defer wg.Done() <-start out, _ := try(t, dir, "git", "push", in.url(mark, "/john/johnbot"), "HEAD:refs/proposals/new") outs[i] = out }(i, c) } close(start) wg.Wait() got := map[string]bool{} for i, out := range outs { ref := proposalRef(out) if ref == "" { t.Fatalf("push %d allocated nothing:\n%s", i, out) } if got[ref] { t.Fatalf("both pushes got %s", ref) } got[ref] = true } }) t.Run("push to another account's proposal", func(t *testing.T) { c := clone(t, in, lisa, "/john/johnbot") commit(t, c, "package main // mine\n", "mine") out := run(t, c, "git", "push", in.url(lisa, "/john/johnbot"), "HEAD:refs/proposals/new") ref := proposalRef(out) other := clone(t, in, mark, "/john/johnbot") commit(t, other, "package main // yours\n", "taking it over") out, err := try(t, other, "git", "push", "-f", in.url(mark, "/john/johnbot"), "HEAD:"+ref) if err == nil { t.Fatal("somebody else's proposal was overwritten") } if !strings.Contains(out, "belongs to lisa") { t.Errorf("the rejection does not say whose it is:\n%s", out) } }) t.Run("merging closes the thread", func(t *testing.T) { c := clone(t, in, lisa, "/john/johnbot") commit(t, c, "package main // to merge\n", "worth merging") out := run(t, c, "git", "push", in.url(lisa, "/john/johnbot"), "HEAD:refs/proposals/new") ref := proposalRef(out) n := strings.TrimPrefix(ref, "refs/proposals/") owner := clone(t, in, john, "/john/johnbot") run(t, owner, "git", "fetch", "-q", in.url(john, "/john/johnbot"), ref+":prop") run(t, owner, "git", "merge", "-q", "--no-edit", "prop") out = run(t, owner, "git", "push", in.url(john, "/john/johnbot"), "master") if !strings.Contains(out, "closed as merged") { t.Errorf("the thread did not close itself:\n%s", out) } dir := repoDir(t, in, "john", "johnbot") meta := run(t, dir, "git", "cat-file", "blob", "refs/notes/threads/"+n+":meta") if !strings.Contains(meta, "state: merged") { t.Errorf("thread %s is not merged:\n%s", n, meta) } }) t.Run("squash merging leaves it open", func(t *testing.T) { // A squash changes the hashes, so the server says so rather than guessing. 36.7. c := clone(t, in, lisa, "/john/johnbot") commit(t, c, "package main // squash me\n", "squash me") out := run(t, c, "git", "push", in.url(lisa, "/john/johnbot"), "HEAD:refs/proposals/new") n := strings.TrimPrefix(proposalRef(out), "refs/proposals/") owner := clone(t, in, john, "/john/johnbot") run(t, owner, "git", "fetch", "-q", in.url(john, "/john/johnbot"), "refs/proposals/"+n+":prop") run(t, owner, "git", "merge", "-q", "--squash", "prop") run(t, owner, "git", "commit", "-qm", "squashed") run(t, owner, "git", "push", in.url(john, "/john/johnbot"), "master") dir := repoDir(t, in, "john", "johnbot") meta := run(t, dir, "git", "cat-file", "blob", "refs/notes/threads/"+n+":meta") if !strings.Contains(meta, "state: open") { t.Errorf("a squash merge closed thread %s, which the server cannot know:\n%s", n, meta) } }) } // proposalRef reads the allocated ref out of git's push output. func proposalRef(out string) string { for _, line := range strings.Split(out, "\n") { i := strings.Index(line, "refs/proposals/") if i < 0 { continue } ref := strings.Fields(line[i:])[0] if ref != "refs/proposals/new" { return ref } } return "" } // A commit author is whatever the pusher typed, so it never decides who writes a proposal ref. func TestCommitAuthorIsNotIdentity(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not installed") } in := newInstance(t) john := in.account("john") lisa := in.account("lisa") mark := in.account("mark") seed(t, in, john, "john", "johnbot") c := clone(t, in, lisa, "/john/johnbot") commit(t, c, "package main // lisa's work\n", "lisa's work") ref := proposalRef(run(t, c, "git", "push", in.url(lisa, "/john/johnbot"), "HEAD:refs/proposals/new")) // mark claims to be lisa in the only place a commit records a name. thief := clone(t, in, mark, "/john/johnbot") run(t, thief, "git", "config", "user.name", "lisa") run(t, thief, "git", "config", "user.email", "lisa@localhost") write(t, thief, "config.go", "package main // taken\n") run(t, thief, "git", "commit", "-qam", "taken") out, err := try(t, thief, "git", "push", "-f", in.url(mark, "/john/johnbot"), "HEAD:"+ref) if err == nil { t.Fatal("a commit signed with somebody else's name took over their proposal") } if !strings.Contains(out, "belongs to lisa") { t.Errorf("unexpected rejection:\n%s", out) } } // Chapter 24: terminals scroll, so a rejected push must print a url to a page that explains it. func TestARejectedPushPrintsAUrlToAPageThatWorks(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not installed") } in := newInstance(t) john := in.account("john") mark := in.account("mark") work := seed(t, in, john, "john", "johnbot") _ = work mine := clone(t, in, mark, "/john/johnbot") commit(t, mine, "package main\n\nfunc main() {}\n", "not mine to push") out, err := try(t, mine, "git", "push", in.url(mark, "/john/johnbot"), "master") if err == nil { t.Fatal("a stranger pushed to master, so there is no rejection to explain") } if !strings.Contains(out, "/rejected?ref=") { t.Fatalf("the rejection printed no url, so the page is unreachable:\n%s", out) } i := strings.Index(out, in.http.URL+"/john/johnbot/rejected?ref=") if i < 0 { t.Fatalf("the url is not this server's:\n%s", out) } line := strings.Fields(out[i:])[0] code, _, body := get(t, line) if code != http.StatusOK { t.Fatalf("%s answered %d", line, code) } for _, want := range []string{"you pushed to refs/heads/master", "[access] push", "git push origin HEAD:refs/proposals/new", "printed in your terminal"} { if !strings.Contains(body, want) { t.Errorf("the page does not say %q:\n%s", want, body) } } }