vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
One metadata branch: refs/heads/vole, linear history, audited rewrites
92b37587e6e8b4e05f95dfc1e555ba36eaaa424d · Keenan Nemetz · 17d ago · parent f9568748
One metadata branch: refs/heads/vole, linear history, audited rewrites
Issues and change requests now share a single branch. Both are human-authored and
low-frequency, and one ref means one push, one fetch and one validation path
instead of per-branch special cases.
refs/heads/vole
issues/<ulid>/...
requests/... reserved for change requests
Rules, enforced in pre-receive rather than documented and hoped for:
- The tree layout is closed: only issues/ and requests/ may appear, so a typo in
a directory name is rejected instead of being silently ignored by the web.
- History must be linear. Merge commits on the metadata branch are refused,
because a merge node says nothing and makes `git log` harder to read than the
change it describes. Rebasing is what happens instead.
- Force-push requires repo admin and writes a line to the audit log. Rewriting
discussion history is sometimes necessary (someone pastes a secret) but must
never be silent, so it is allowed rather than forbidden and recorded.
The CLI no longer fails on a lost race. Because every entity is its own file,
re-applying a change on the new tip cannot conflict, so it re-fetches, redoes the
mutation and retries with jittered backoff. Two things this turned up that a
single-writer test could never have found:
- A lost race has more than one shape. Besides "non-fast-forward" and "fetch
first", a push can arrive with a stale old-oid ("incorrect old value
provided") or lose the creation of the branch itself ("reference already
exists"). All four are retryable; hook rejections are not.
- Retries without jitter keep writers in lockstep: six concurrent writers failed
five rounds in a row with a deterministic backoff, and converged immediately
once the sleep was randomised.
Verified by hand: the CLI writes to the vole branch, `git log vole` is a flat
list, and a merge push is refused with both problems reported at once — the merge
node and a malformed issue id in the same tree.
internal/server/integration_test.go +235−11
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | 17 | "os/exec" | |
| 18 | 18 | "path/filepath" | |
| 19 | 19 | "strings" | |
| 20 | "sync" | ||
| 20 | 21 | "testing" | |
| 21 | 22 | ||
| 22 | 23 | "golang.org/x/crypto/ssh" | |
| @@ -137,6 +138,49 @@ func keypair(t *testing.T, path string) (pubLine, privPath string) { | |||
| 137 | 138 | return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub))), path | |
| 138 | 139 | } | |
| 139 | 140 | ||
| 141 | // addWriter creates a second user with write access and returns their key path. | ||
| 142 | func (f *fixture) addWriter(t *testing.T) string { | ||
| 143 | t.Helper() | ||
| 144 | pub, priv := keypair(t, filepath.Join(f.cfg.DataDir, "id_writer")) | ||
| 145 | if err := f.srv.Identity.Create("wilbur"); err != nil { | ||
| 146 | t.Fatal(err) | ||
| 147 | } | ||
| 148 | if _, err := f.srv.Identity.AddKey("wilbur", pub, "test"); err != nil { | ||
| 149 | t.Fatal(err) | ||
| 150 | } | ||
| 151 | m := mustRepo(t, f) | ||
| 152 | a, err := m.ACL() | ||
| 153 | if err != nil { | ||
| 154 | t.Fatal(err) | ||
| 155 | } | ||
| 156 | if err := a.SetEntry("wilbur", "write"); err != nil { | ||
| 157 | t.Fatal(err) | ||
| 158 | } | ||
| 159 | if err := m.SetACL(a, "test"); err != nil { | ||
| 160 | t.Fatal(err) | ||
| 161 | } | ||
| 162 | return priv | ||
| 163 | } | ||
| 164 | |||
| 165 | // gitWithKey is f.git with a specific identity. | ||
| 166 | func (f *fixture) gitWithKey(key, dir string, args ...string) (string, string, error) { | ||
| 167 | f.t.Helper() | ||
| 168 | cmd := exec.Command("git", args...) | ||
| 169 | cmd.Dir = dir | ||
| 170 | cmd.Env = append(os.Environ(), | ||
| 171 | "GIT_TERMINAL_PROMPT=0", | ||
| 172 | "GIT_CONFIG_GLOBAL=/dev/null", | ||
| 173 | "GIT_CONFIG_SYSTEM=/dev/null", | ||
| 174 | "GIT_SSH_COMMAND=ssh -i "+key+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR", | ||
| 175 | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", | ||
| 176 | "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com", | ||
| 177 | ) | ||
| 178 | var out, errb bytes.Buffer | ||
| 179 | cmd.Stdout, cmd.Stderr = &out, &errb | ||
| 180 | err := cmd.Run() | ||
| 181 | return out.String(), errb.String(), err | ||
| 182 | } | ||
| 183 | |||
| 140 | 184 | func (f *fixture) sshURL(name string) string { | |
| 141 | 185 | return "ssh://git@" + f.sshAddr + "/" + name + ".git" | |
| 142 | 186 | } | |
| @@ -420,7 +464,7 @@ const testIssueID = "01M5JR2JK29KKMAA1T9MDNTPBP" | |||
| 420 | 464 | func (f *fixture) issuesRepo(t *testing.T, files map[string]string) string { | |
| 421 | 465 | t.Helper() | |
| 422 | 466 | dir := t.TempDir() | |
| 423 | if _, errs, err := runGit(t, dir, "init", "-q", "-b", "issues"); err != nil { | ||
| 467 | if _, errs, err := runGit(t, dir, "init", "-q", "-b", "vole"); err != nil { | ||
| 424 | 468 | t.Fatalf("init: %v: %s", err, errs) | |
| 425 | 469 | } | |
| 426 | 470 | for name, content := range files { | |
| @@ -457,7 +501,7 @@ func TestIssuePushedThenRendered(t *testing.T) { | |||
| 457 | 501 | ||
| 458 | 502 | desc := "Steps:\n\n1. push an issue\n2. read it back\n\n**bold claim** and `code`\n" | |
| 459 | 503 | work := f.issuesRepo(t, issueFiles(testIssueID, "Push-based issues work", desc)) | |
| 460 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues"); err != nil { | ||
| 504 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { | ||
| 461 | 505 | t.Fatalf("push issues: %v\n%s", err, errs) | |
| 462 | 506 | } | |
| 463 | 507 | ||
| @@ -495,7 +539,7 @@ func TestIssueMarkdownIsSanitised(t *testing.T) { | |||
| 495 | 539 | f.publish(f.repo) | |
| 496 | 540 | desc := "hello\n\n<script>alert('xss')</script>\n\n[click](javascript:alert(1))\n" | |
| 497 | 541 | work := f.issuesRepo(t, issueFiles(testIssueID, "XSS attempt", desc)) | |
| 498 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues"); err != nil { | ||
| 542 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { | ||
| 499 | 543 | t.Fatalf("push: %s", errs) | |
| 500 | 544 | } | |
| 501 | 545 | code, body := f.get("/r/" + f.repo + "/issues/" + testIssueID) | |
| @@ -517,11 +561,11 @@ func TestInvalidIssuePushRejected(t *testing.T) { | |||
| 517 | 561 | f.publish(f.repo) | |
| 518 | 562 | ||
| 519 | 563 | good := f.issuesRepo(t, issueFiles(testIssueID, "fine", "body\n")) | |
| 520 | if _, errs, err := f.git(good, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues"); err != nil { | ||
| 564 | if _, errs, err := f.git(good, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { | ||
| 521 | 565 | t.Fatalf("good push: %s", errs) | |
| 522 | 566 | } | |
| 523 | 567 | before, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"), | |
| 524 | "rev-parse", "--verify", "--quiet", "refs/heads/issues") | ||
| 568 | "rev-parse", "--verify", "--quiet", "refs/heads/vole") | ||
| 525 | 569 | ||
| 526 | 570 | // Same history, but with a title-less issue added. | |
| 527 | 571 | bad := issueFiles("01M5JR2JK29KKMAA1T9MDNTPBZ", "", "no title\n") | |
| @@ -540,7 +584,7 @@ func TestInvalidIssuePushRejected(t *testing.T) { | |||
| 540 | 584 | if _, errs, err := runGit(t, good, "commit", "-qm", "bad issue"); err != nil { | |
| 541 | 585 | t.Fatalf("commit: %s", errs) | |
| 542 | 586 | } | |
| 543 | _, errs, err := f.git(good, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues") | ||
| 587 | _, errs, err := f.git(good, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole") | ||
| 544 | 588 | if err == nil { | |
| 545 | 589 | t.Fatal("push with a title-less issue was accepted") | |
| 546 | 590 | } | |
| @@ -548,7 +592,7 @@ func TestInvalidIssuePushRejected(t *testing.T) { | |||
| 548 | 592 | t.Fatalf("rejection did not explain why:\n%s", errs) | |
| 549 | 593 | } | |
| 550 | 594 | after, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"), | |
| 551 | "rev-parse", "--verify", "--quiet", "refs/heads/issues") | ||
| 595 | "rev-parse", "--verify", "--quiet", "refs/heads/vole") | ||
| 552 | 596 | if strings.TrimSpace(before) != strings.TrimSpace(after) { | |
| 553 | 597 | t.Fatalf("issues branch moved on a rejected push: %s -> %s", before, after) | |
| 554 | 598 | } | |
| @@ -560,7 +604,7 @@ func TestIssuesSurviveIndexRebuild(t *testing.T) { | |||
| 560 | 604 | f := newFixture(t) | |
| 561 | 605 | f.publish(f.repo) | |
| 562 | 606 | work := f.issuesRepo(t, issueFiles(testIssueID, "persisted", "body\n")) | |
| 563 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues"); err != nil { | ||
| 607 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { | ||
| 564 | 608 | t.Fatalf("push: %s", errs) | |
| 565 | 609 | } | |
| 566 | 610 | if err := f.srv.Index.EnsureIssues(mustRepo(t, f)); err != nil { | |
| @@ -684,7 +728,7 @@ func TestIssueCLIEndToEnd(t *testing.T) { | |||
| 684 | 728 | func (f *fixture) serverIssues(t *testing.T) string { | |
| 685 | 729 | t.Helper() | |
| 686 | 730 | out, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"), | |
| 687 | "rev-parse", "--verify", "--quiet", "refs/heads/issues") | ||
| 731 | "rev-parse", "--verify", "--quiet", "refs/heads/vole") | ||
| 688 | 732 | return strings.TrimSpace(out) | |
| 689 | 733 | } | |
| 690 | 734 | ||
| @@ -704,7 +748,7 @@ func TestIssueFiltersAndSearch(t *testing.T) { | |||
| 704 | 748 | add("01M5JR2JK29KKMAA1T9MDNTPB3", "Slow parser", "open", "throughput", `["bug","perf"]`) | |
| 705 | 749 | ||
| 706 | 750 | work := f.issuesRepo(t, files) | |
| 707 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues"); err != nil { | ||
| 751 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { | ||
| 708 | 752 | t.Fatalf("push: %s", errs) | |
| 709 | 753 | } | |
| 710 | 754 | ||
| @@ -746,7 +790,7 @@ func TestCrossReferencesLinksIssues(t *testing.T) { | |||
| 746 | 790 | files[k] = v | |
| 747 | 791 | } | |
| 748 | 792 | work := f.issuesRepo(t, files) | |
| 749 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues"); err != nil { | ||
| 793 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { | ||
| 750 | 794 | t.Fatalf("push issues: %s", errs) | |
| 751 | 795 | } | |
| 752 | 796 | ||
| @@ -791,6 +835,186 @@ func min(a, b int) int { | |||
| 791 | 835 | return b | |
| 792 | 836 | } | |
| 793 | 837 | ||
| 838 | // TestMetadataRejectsMergeCommits: metadata history is linear by policy, so a | ||
| 839 | // merge node is refused rather than quietly accepted. | ||
| 840 | func TestMetadataRejectsMergeCommits(t *testing.T) { | ||
| 841 | f := newFixture(t) | ||
| 842 | f.publish(f.repo) | ||
| 843 | |||
| 844 | work := f.issuesRepo(t, issueFiles(testIssueID, "first", "body\n")) | ||
| 845 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { | ||
| 846 | t.Fatalf("initial push: %s", errs) | ||
| 847 | } | ||
| 848 | |||
| 849 | // Two diverging commits, then a merge. | ||
| 850 | git := func(args ...string) { | ||
| 851 | t.Helper() | ||
| 852 | if _, errs, err := runGit(t, work, args...); err != nil { | ||
| 853 | t.Fatalf("%v: %v: %s", args, err, errs) | ||
| 854 | } | ||
| 855 | } | ||
| 856 | git("checkout", "-q", "-b", "side") | ||
| 857 | writeIssueFile(t, work, "01M5JR2JK29KKMAA1T9MDNTPE1", "side") | ||
| 858 | git("add", "-A") | ||
| 859 | git("commit", "-qm", "side issue") | ||
| 860 | git("checkout", "-q", "vole") | ||
| 861 | writeIssueFile(t, work, "01M5JR2JK29KKMAA1T9MDNTPE2", "main") | ||
| 862 | git("add", "-A") | ||
| 863 | git("commit", "-qm", "main issue") | ||
| 864 | git("merge", "-q", "--no-ff", "-m", "merge side", "side") | ||
| 865 | |||
| 866 | _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole") | ||
| 867 | if err == nil { | ||
| 868 | t.Fatal("a merge commit was accepted on the metadata branch") | ||
| 869 | } | ||
| 870 | if !strings.Contains(errs, "linear history") { | ||
| 871 | t.Fatalf("rejection did not explain the rule:\n%s", errs) | ||
| 872 | } | ||
| 873 | } | ||
| 874 | |||
| 875 | // TestMetadataForcePushIsAdminOnly: rewriting discussion content is a redaction | ||
| 876 | // tool, not a normal operation. | ||
| 877 | func TestMetadataForcePushIsAdminOnly(t *testing.T) { | ||
| 878 | f := newFixture(t) | ||
| 879 | f.publish(f.repo) | ||
| 880 | writerKey := f.addWriter(t) | ||
| 881 | |||
| 882 | work := f.issuesRepo(t, issueFiles(testIssueID, "first", "body\n")) | ||
| 883 | if _, errs, err := f.gitWithKey(writerKey, work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { | ||
| 884 | t.Fatalf("writer initial push: %s", errs) | ||
| 885 | } | ||
| 886 | |||
| 887 | // Amend in place: a new object for the same parent, which is a rewrite. Adding | ||
| 888 | // and resetting would leave the tip unchanged and the push a no-op that never | ||
| 889 | // reaches the hook. | ||
| 890 | if _, errs, err := runGit(t, work, "commit", "-q", "--amend", "-m", "rewritten"); err != nil { | ||
| 891 | t.Fatalf("amend: %v: %s", err, errs) | ||
| 892 | } | ||
| 893 | |||
| 894 | _, errs, err := f.gitWithKey(writerKey, work, "push", "--force", f.sshURL(f.repo), "HEAD:refs/heads/vole") | ||
| 895 | if err == nil { | ||
| 896 | t.Fatal("a non-admin force-pushed the metadata branch") | ||
| 897 | } | ||
| 898 | if !strings.Contains(errs, "requires admin") { | ||
| 899 | t.Fatalf("rejection did not name the rule:\n%s", errs) | ||
| 900 | } | ||
| 901 | |||
| 902 | // The admin may, and it is recorded. | ||
| 903 | if _, errs, err := f.git(work, "push", "--force", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { | ||
| 904 | t.Fatalf("admin force-push refused: %s", errs) | ||
| 905 | } | ||
| 906 | audit, err := os.ReadFile(f.cfg.AuditLog()) | ||
| 907 | if err != nil { | ||
| 908 | t.Fatal(err) | ||
| 909 | } | ||
| 910 | if !strings.Contains(string(audit), "force-push refs/heads/vole") { | ||
| 911 | t.Fatalf("force-push not audited:\n%s", audit) | ||
| 912 | } | ||
| 913 | } | ||
| 914 | |||
| 915 | // TestMetadataRejectsUnknownTopLevel: the layout is closed, so a typo is caught | ||
| 916 | // rather than silently ignored by the web. | ||
| 917 | func TestMetadataRejectsUnknownTopLevel(t *testing.T) { | ||
| 918 | f := newFixture(t) | ||
| 919 | f.publish(f.repo) | ||
| 920 | work := t.TempDir() | ||
| 921 | if _, errs, err := runGit(t, work, "init", "-q", "-b", "vole"); err != nil { | ||
| 922 | t.Fatalf("init: %v: %s", err, errs) | ||
| 923 | } | ||
| 924 | if err := os.MkdirAll(filepath.Join(work, "notes"), 0o755); err != nil { | ||
| 925 | t.Fatal(err) | ||
| 926 | } | ||
| 927 | if err := os.WriteFile(filepath.Join(work, "notes", "x.md"), []byte("hi\n"), 0o644); err != nil { | ||
| 928 | t.Fatal(err) | ||
| 929 | } | ||
| 930 | if _, errs, err := runGit(t, work, "add", "-A"); err != nil { | ||
| 931 | t.Fatalf("add: %v: %s", err, errs) | ||
| 932 | } | ||
| 933 | if _, errs, err := runGit(t, work, "commit", "-qm", "notes"); err != nil { | ||
| 934 | t.Fatalf("commit: %v: %s", err, errs) | ||
| 935 | } | ||
| 936 | _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole") | ||
| 937 | if err == nil { | ||
| 938 | t.Fatal("an unknown top-level directory was accepted") | ||
| 939 | } | ||
| 940 | if !strings.Contains(errs, "may only contain") { | ||
| 941 | t.Fatalf("rejection did not explain the layout:\n%s", errs) | ||
| 942 | } | ||
| 943 | } | ||
| 944 | |||
| 945 | // TestConcurrentCLIWritesAllLand: two people filing issues at once must both | ||
| 946 | // succeed. The CLI re-applies on a lost race instead of failing. | ||
| 947 | func TestConcurrentCLIWritesAllLand(t *testing.T) { | ||
| 948 | f := newFixture(t) | ||
| 949 | f.publish(f.repo) | ||
| 950 | if _, errs, err := f.git(workRepo(t, map[string]string{"readme.md": "hi\n"}), | ||
| 951 | "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 952 | t.Fatalf("push main: %s", errs) | ||
| 953 | } | ||
| 954 | |||
| 955 | const n = 6 | ||
| 956 | clones := make([]string, n) | ||
| 957 | for i := range clones { | ||
| 958 | dir := filepath.Join(t.TempDir(), fmt.Sprintf("clone%d", i)) | ||
| 959 | if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), dir); err != nil { | ||
| 960 | t.Fatalf("clone: %s", errs) | ||
| 961 | } | ||
| 962 | clones[i] = dir | ||
| 963 | } | ||
| 964 | |||
| 965 | var wg sync.WaitGroup | ||
| 966 | errs := make([]string, n) | ||
| 967 | for i := 0; i < n; i++ { | ||
| 968 | wg.Add(1) | ||
| 969 | go func(i int) { | ||
| 970 | defer wg.Done() | ||
| 971 | cmd := exec.Command(binPath, "issue", "new", "--title", fmt.Sprintf("concurrent %d", i)) | ||
| 972 | cmd.Dir = clones[i] | ||
| 973 | cmd.Env = append(os.Environ(), | ||
| 974 | "GIT_SSH_COMMAND=ssh -i "+f.keyPath+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR", | ||
| 975 | "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", | ||
| 976 | "USER=keenan", "GIT_AUTHOR_NAME=keenan", "GIT_AUTHOR_EMAIL=k@e") | ||
| 977 | var out bytes.Buffer | ||
| 978 | cmd.Stdout, cmd.Stderr = &out, &out | ||
| 979 | if err := cmd.Run(); err != nil { | ||
| 980 | errs[i] = out.String() | ||
| 981 | } | ||
| 982 | }(i) | ||
| 983 | } | ||
| 984 | wg.Wait() | ||
| 985 | for i, e := range errs { | ||
| 986 | if e != "" { | ||
| 987 | t.Fatalf("writer %d failed:\n%s", i, e) | ||
| 988 | } | ||
| 989 | } | ||
| 990 | |||
| 991 | if err := f.srv.Index.EnsureIssues(mustRepo(t, f)); err != nil { | ||
| 992 | t.Fatal(err) | ||
| 993 | } | ||
| 994 | rows, err := f.srv.Index.Issues(repoID(t, f), index.Filter{}) | ||
| 995 | if err != nil { | ||
| 996 | t.Fatal(err) | ||
| 997 | } | ||
| 998 | if len(rows) != n { | ||
| 999 | t.Fatalf("%d issues landed, want %d (a lost race dropped a write)", len(rows), n) | ||
| 1000 | } | ||
| 1001 | } | ||
| 1002 | |||
| 1003 | func writeIssueFile(t *testing.T, dir, id, title string) { | ||
| 1004 | t.Helper() | ||
| 1005 | p := filepath.Join(dir, "issues", id) | ||
| 1006 | if err := os.MkdirAll(p, 0o755); err != nil { | ||
| 1007 | t.Fatal(err) | ||
| 1008 | } | ||
| 1009 | meta := fmt.Sprintf(`{"title":%q,"state":"open","author":"keenan","created":"2026-10-22T21:00:00Z"}`, title) | ||
| 1010 | if err := os.WriteFile(filepath.Join(p, "meta.json"), []byte(meta), 0o644); err != nil { | ||
| 1011 | t.Fatal(err) | ||
| 1012 | } | ||
| 1013 | if err := os.WriteFile(filepath.Join(p, "description.md"), []byte("body\n"), 0o644); err != nil { | ||
| 1014 | t.Fatal(err) | ||
| 1015 | } | ||
| 1016 | } | ||
| 1017 | |||
| 794 | 1018 | func mustRepo(t *testing.T, f *fixture) *repo.Meta { | |
| 795 | 1019 | t.Helper() | |
| 796 | 1020 | m, err := f.srv.Repos.Open(f.repo) | |