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.
Open one file at a time on the commit page
628d45239eb09af4e46c9dc779d30144e96c7e6f · Keenan Nemetz · 11d ago · parent 816cc543
Open one file at a time on the commit page
A commit page rendered every patch it contained, so a broad commit turned one
page load into every blob it touched. It now opens the file tree beside the diff
of a single file, the same shape a change request's Files tab uses: the tree is
built from `git show --name-status`, only the selected path's patch is read, and
a tree click swaps the pane over HTMX. `/commit/{sha}/{path}` deep-links a file
and a 512 KiB cap still bounds one file's patch.
The file tree became one widget for three surfaces rather than two, so its
context gained a Commit field and its href/target/status rules moved onto the
view (Branch, Diff, Href).
The pane's header is now one row: the file's name on the left and its line
counts on the right, with the separate "1 file changed" summary line dropped
when a single file fills the pane. The total-line remains for the multi-file
diff a compare renders.
core/git/commit_test.go +151
| @@ -0,0 +1,151 @@ | |||
| 1 | package git | ||
| 2 | |||
| 3 | // The per-file commit view reads the name list and then one file's patch. These | ||
| 4 | // tests pin the two together, because a list that disagrees with the patch is a | ||
| 5 | // tree row that opens nothing. | ||
| 6 | |||
| 7 | import ( | ||
| 8 | "os" | ||
| 9 | "os/exec" | ||
| 10 | "path/filepath" | ||
| 11 | "strings" | ||
| 12 | "testing" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // changeRepo builds a repository whose second commit adds, modifies, deletes and | ||
| 16 | // renames, with a space and a non-ASCII byte in the renamed path so git's quoting | ||
| 17 | // is exercised. | ||
| 18 | func changeRepo(t *testing.T) (*Repo, string) { | ||
| 19 | t.Helper() | ||
| 20 | dir := t.TempDir() | ||
| 21 | run := func(args ...string) { | ||
| 22 | t.Helper() | ||
| 23 | cmd := exec.Command("git", args...) | ||
| 24 | cmd.Dir = dir | ||
| 25 | cmd.Env = append(os.Environ(), | ||
| 26 | "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", | ||
| 27 | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", | ||
| 28 | "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com", | ||
| 29 | // Without this a platform can quote every non-ASCII path into an | ||
| 30 | // escape sequence, and the test would be reading a different format. | ||
| 31 | "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=core.quotepath", "GIT_CONFIG_VALUE_0=false") | ||
| 32 | if out, err := cmd.CombinedOutput(); err != nil { | ||
| 33 | t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, out) | ||
| 34 | } | ||
| 35 | } | ||
| 36 | write := func(name, content string) { | ||
| 37 | t.Helper() | ||
| 38 | if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { | ||
| 39 | t.Fatal(err) | ||
| 40 | } | ||
| 41 | } | ||
| 42 | run("init", "-q", "-b", "main") | ||
| 43 | write("readme.md", "v1\n") | ||
| 44 | write("keep.txt", "keep\n") | ||
| 45 | write("old name.txt", "moved\n") | ||
| 46 | run("add", "-A") | ||
| 47 | run("commit", "-qm", "first") | ||
| 48 | |||
| 49 | write("readme.md", "v1\nv2\n") | ||
| 50 | if err := os.MkdirAll(filepath.Join(dir, "docs"), 0o755); err != nil { | ||
| 51 | t.Fatal(err) | ||
| 52 | } | ||
| 53 | write("docs/guide.md", "a guide\n") | ||
| 54 | if err := os.Remove(filepath.Join(dir, "keep.txt")); err != nil { | ||
| 55 | t.Fatal(err) | ||
| 56 | } | ||
| 57 | run("mv", "old name.txt", "nëw name.txt") | ||
| 58 | run("add", "-A") | ||
| 59 | run("commit", "-qm", "second") | ||
| 60 | return open(t, dir), "HEAD" | ||
| 61 | } | ||
| 62 | |||
| 63 | func TestShowNameStatusNamesEveryPathThePatchWillPrint(t *testing.T) { | ||
| 64 | r, rev := changeRepo(t) | ||
| 65 | |||
| 66 | changes, err := r.ShowNameStatus(rev) | ||
| 67 | if err != nil { | ||
| 68 | t.Fatalf("ShowNameStatus: %v", err) | ||
| 69 | } | ||
| 70 | got := map[string]PathChange{} | ||
| 71 | for _, c := range changes { | ||
| 72 | got[c.New+c.Old] = c | ||
| 73 | } | ||
| 74 | if len(changes) != 4 { | ||
| 75 | t.Fatalf("ShowNameStatus returned %d changes, want 4: %+v", len(changes), changes) | ||
| 76 | } | ||
| 77 | for _, want := range []PathChange{ | ||
| 78 | {Status: "A", Old: "", New: "docs/guide.md"}, | ||
| 79 | {Status: "D", Old: "keep.txt", New: ""}, | ||
| 80 | {Status: "R", Old: "old name.txt", New: "nëw name.txt"}, | ||
| 81 | {Status: "M", Old: "readme.md", New: "readme.md"}, | ||
| 82 | } { | ||
| 83 | c, ok := got[want.New+want.Old] | ||
| 84 | if !ok { | ||
| 85 | t.Fatalf("ShowNameStatus has no %+v: %+v", want, changes) | ||
| 86 | } | ||
| 87 | if c.Status != want.Status { | ||
| 88 | t.Fatalf("%s status = %q, want %q", want.New, c.Status, want.Status) | ||
| 89 | } | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | func TestShowPatchPathReadsOneFile(t *testing.T) { | ||
| 94 | r, rev := changeRepo(t) | ||
| 95 | |||
| 96 | patch, truncated, err := r.ShowPatchPath(rev, []string{"readme.md"}, 1<<20) | ||
| 97 | if err != nil { | ||
| 98 | t.Fatalf("ShowPatchPath: %v", err) | ||
| 99 | } | ||
| 100 | if truncated { | ||
| 101 | t.Fatal("a small patch was reported as cut") | ||
| 102 | } | ||
| 103 | if !strings.Contains(patch, "+v2") { | ||
| 104 | t.Fatalf("the patch does not hold the change:\n%s", patch) | ||
| 105 | } | ||
| 106 | if strings.Contains(patch, "a guide") { | ||
| 107 | t.Fatalf("the patch holds another file:\n%s", patch) | ||
| 108 | } | ||
| 109 | |||
| 110 | // A rename needs both paths: with only the destination named, git reports what | ||
| 111 | // is left as a new file and the view would lose the rename. | ||
| 112 | onlyNew, _, err := r.ShowPatchPath(rev, []string{"nëw name.txt"}, 1<<20) | ||
| 113 | if err != nil { | ||
| 114 | t.Fatalf("ShowPatchPath (new only): %v", err) | ||
| 115 | } | ||
| 116 | if strings.Contains(onlyNew, "rename from") { | ||
| 117 | t.Fatalf("one path should not have been enough for a rename:\n%s", onlyNew) | ||
| 118 | } | ||
| 119 | both, _, err := r.ShowPatchPath(rev, []string{"old name.txt", "nëw name.txt"}, 1<<20) | ||
| 120 | if err != nil { | ||
| 121 | t.Fatalf("ShowPatchPath (both): %v", err) | ||
| 122 | } | ||
| 123 | if !strings.Contains(both, "rename from old name.txt") || !strings.Contains(both, "similarity index 100%") { | ||
| 124 | t.Fatalf("the patch is not the whole rename:\n%s", both) | ||
| 125 | } | ||
| 126 | |||
| 127 | // A root commit has no parent to diff against; the file is an addition. | ||
| 128 | root := strings.TrimSpace(mustGit(t, r, "rev-list", "--max-parents=0", "HEAD")) | ||
| 129 | changes, err := r.ShowNameStatus(root) | ||
| 130 | if err != nil { | ||
| 131 | t.Fatalf("ShowNameStatus (root): %v", err) | ||
| 132 | } | ||
| 133 | if len(changes) != 3 { | ||
| 134 | t.Fatalf("root commit lists %d files, want 3: %+v", len(changes), changes) | ||
| 135 | } | ||
| 136 | for _, c := range changes { | ||
| 137 | if c.Status != "A" || c.Old != "" { | ||
| 138 | t.Fatalf("root commit change = %+v, want an addition", c) | ||
| 139 | } | ||
| 140 | } | ||
| 141 | } | ||
| 142 | |||
| 143 | // mustGit runs one read command through the repository under test. | ||
| 144 | func mustGit(t *testing.T, r *Repo, args ...string) string { | ||
| 145 | t.Helper() | ||
| 146 | out, errs, err := r.run(nil, args...) | ||
| 147 | if err != nil { | ||
| 148 | t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, errs) | ||
| 149 | } | ||
| 150 | return string(out) | ||
| 151 | } | ||