1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package readertest
4
5import (
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/require"
14
15 "bnema.dev/ermite/internal/domain"
16)
17
18// Case is a repository the readers are compared on.
19type Case struct {
20 Name string
21 Build func(t *testing.T, data string) *Fixture
22}
23
24// Maintain runs what ermite runs after a push: a split commit-graph with
25// changed-path Bloom filters.
26func (f *Fixture) Maintain() {
27 f.T.Helper()
28 f.Git("commit-graph", "write", "--reachable", "--changed-paths", "--split")
29}
30
31// Cases are the fixtures of the comparison: everything the gitcli tests use,
32// plus merges, tags, deep paths, a large blob, an unborn HEAD, repositories
33// with and without commit-graph (Bloom filters v1 and v2), and one whose
34// graph is stale because commits were pushed after the last maintenance.
35func Cases() []Case {
36 return []Case{
37 {"history", func(t *testing.T, data string) *Fixture { return NewHistory(t, data).Fixture }},
38 {"history with commit-graph v2", func(t *testing.T, data string) *Fixture {
39 h := NewHistory(t, data)
40 h.Git("config", "commitGraph.changedPathsVersion", "2")
41 h.Maintain()
42 return h.Fixture
43 }},
44 {"history with commit-graph v1", func(t *testing.T, data string) *Fixture {
45 h := NewHistory(t, data)
46 h.Git("config", "commitGraph.changedPathsVersion", "1")
47 h.Maintain()
48 return h.Fixture
49 }},
50 {"history packed", func(t *testing.T, data string) *Fixture {
51 h := NewHistory(t, data)
52 h.Git("repack", "-adf", "--depth=50", "--window=250")
53 h.Git("pack-refs", "--all")
54 h.Maintain()
55 return h.Fixture
56 }},
57 {"unborn HEAD", func(t *testing.T, data string) *Fixture { return NewFixture(t, data, "empty") }},
58 {"stale commit-graph", buildStale},
59 {"merges", buildMerges},
60 {"deep paths", buildDeep},
61 {"large blobs", buildLarge},
62 {"names and messages", buildNames},
63 {"refs", buildRefs},
64 {"dangling ref", buildDangling},
65 {"submodule", buildSubmodule},
66 {"paths", buildPaths},
67 {"wide directory", buildWide},
68 {"detached HEAD", func(t *testing.T, data string) *Fixture {
69 h := NewHistory(t, data)
70 h.Git("update-ref", "--no-deref", "HEAD", h.C2)
71 return h.Fixture
72 }},
73 }
74}
75
76// buildStale writes the graph, then adds commits and refs that it does not
77// know: a push followed by maintenance that has not run yet.
78func buildStale(t *testing.T, data string) *Fixture {
79 f := NewFixture(t, data, "stale")
80 f.Git("config", "commitGraph.changedPathsVersion", "2")
81 for i := range 12 {
82 f.Write(fmt.Sprintf("dir%d/file.txt", i%3), fmt.Sprintf("v%d\n", i)).Write("root.txt", fmt.Sprintf("r%d\n", i/4))
83 f.Commit(fmt.Sprintf("commit %d", i))
84 }
85 f.Maintain()
86 f.Git("config", "commitGraph.changedPathsVersion", "1")
87 f.CheckoutNew("late")
88 for i := range 4 {
89 f.Write("dir1/file.txt", fmt.Sprintf("late%d\n", i)).Write(fmt.Sprintf("late%d.txt", i), "x\n")
90 f.Commit(fmt.Sprintf("late %d", i))
91 }
92 f.Checkout("main")
93 f.Write("dir0/new.txt", "n\n")
94 f.Commit("after maintenance")
95 f.Merge("late")
96 return f
97}
98
99// buildMerges has criss-cross merges, an octopus merge, a merge that takes
100// one side of a file and one that is TREESAME to a parent, so that the path
101// history simplification matters.
102func buildMerges(t *testing.T, data string) *Fixture {
103 f := NewFixture(t, data, "merges")
104 f.Write("shared.txt", "base\n").Write("a/one.txt", "1\n").Write("b/one.txt", "1\n")
105 f.Commit("base")
106 f.CheckoutNew("left")
107 f.Write("a/one.txt", "left\n")
108 f.Commit("left a")
109 f.Checkout("main")
110 f.CheckoutNew("right")
111 f.Write("b/one.txt", "right\n").Write("shared.txt", "right\n")
112 f.Commit("right b")
113 f.Checkout("main")
114 f.Write("main.txt", "m\n")
115 f.Commit("main")
116 f.Merge("left")
117 f.Merge("right")
118 f.CheckoutNew("x1")
119 f.Write("x.txt", "x1\n")
120 f.Commit("x1")
121 f.Checkout("main")
122 f.CheckoutNew("x2")
123 f.Write("y.txt", "y\n")
124 f.Commit("x2")
125 f.Checkout("main")
126 f.Write("main.txt", "m2\n")
127 f.Commit("main again")
128 f.When = f.When.Add(time.Minute)
129 f.Git("merge", "--quiet", "--no-ff", "-m", "octopus", "x1", "x2")
130 // A merge whose result is the same as its first parent for shared.txt.
131 f.CheckoutNew("same")
132 f.Write("shared.txt", "same\n")
133 f.Commit("same 1")
134 f.Checkout("main")
135 f.Git("merge", "--quiet", "--no-ff", "-s", "ours", "-m", "ours merge", "same")
136 f.Write("tail.txt", "t\n")
137 f.Commit("tail")
138 f.Maintain()
139 return f
140}
141
142func buildDeep(t *testing.T, data string) *Fixture {
143 f := NewFixture(t, data, "deep")
144 deep := strings.Repeat("d/", 30) + "leaf"
145 f.Write(deep+"/file.txt", "deep\n").Write("top.txt", "top\n")
146 f.Commit("deep")
147 f.Write(deep+"/file.txt", "deeper\n")
148 f.Commit("change deep")
149 f.Write("top.txt", "top2\n")
150 f.Commit("change top")
151 f.Write("héllo/ünï.txt", "utf8\n").Write("sp ace/a b.txt", "sp\n")
152 f.Commit("odd names")
153 f.Maintain()
154 return f
155}
156
157// buildLarge has blobs on both sides of the in-memory threshold of gitcli
158// (256 KiB), loose, packed as is and packed as deltas.
159func buildLarge(t *testing.T, data string) *Fixture {
160 f := NewFixture(t, data, "large")
161 big := RandomBytes(12<<20, 9)
162 f.WriteBytes("big.bin", big).WriteBytes("mid.bin", RandomBytes(300<<10, 2)).
163 Write("small.txt", "s\n")
164 var text strings.Builder
165 for i := range 20000 {
166 fmt.Fprintf(&text, "line %d of a text that deltifies well\n", i)
167 }
168 f.Write("text.txt", text.String())
169 f.Commit("big")
170 f.Write("text.txt", text.String()+"one more line\n")
171 f.Commit("big text, second version")
172 f.Git("repack", "-adf", "--depth=50", "--window=250")
173 f.Write("loose.bin", string(RandomBytes(400<<10, 4)))
174 f.Commit("loose")
175 f.Maintain()
176 return f
177}
178
179// buildNames has messages and names git must convert or keep: no message,
180// non-UTF-8, an encoding header, trailing blank lines, long summaries, CRLF.
181func buildNames(t *testing.T, data string) *Fixture {
182 f := NewFixture(t, data, "names")
183 f.Write("a.txt", "a\n")
184 f.When = f.When.Add(time.Minute)
185 f.Git("add", "-A")
186 f.Git("commit", "--quiet", "--allow-empty-message", "-m", "")
187 f.Write("a.txt", "b\n")
188 f.Commit("subject\n\n\n\nbody\n\n\n")
189 f.Write("a.txt", "c\n")
190 f.Commit("caf\xe9 latin-1 message")
191 f.Write("a.txt", "d\n")
192 f.Commit("crlf\r\nsecond\r\n")
193 f.Write("a.txt", "e\n")
194 f.Git("-c", "i18n.commitEncoding=ISO-8859-1", "add", "-A")
195 f.When = f.When.Add(time.Minute)
196 f.Git("-c", "i18n.commitEncoding=ISO-8859-1", "commit", "--quiet", "-m", "enc: caf\xe9")
197 f.Write("a.txt", "f\n")
198 f.When = f.When.Add(time.Minute)
199 f.Git("add", "-A")
200 f.Git("-c", "user.name=Zoë <Ünï>", "commit", "--quiet", "--author=Zoë Ünï <zoe@example.org>", "-m", strings.Repeat("long ", 400))
201 f.Write("héllo wörld.txt", "x\n").Write("tab\tname.txt", "t\n").Write("-dash", "d\n")
202 f.Commit("names")
203 // Git trims the trailing whitespace of a name when it writes a commit but
204 // shows one that has some as it is read: the readers must agree on it.
205 head, tree := f.Git("rev-parse", "HEAD"), f.Git("rev-parse", "HEAD^{tree}")
206 f.Git("update-ref", "refs/heads/main", f.WriteObject("commit", "tree "+tree+"\nparent "+head+"\n"+
207 "author Ada\t <a@b> 1767268860 +0000\ncommitter Grace \t\r <g@b> 1767268860 +0000\n\nraw names\n"))
208 f.Maintain()
209 return f
210}
211
212// buildRefs has a branch and a tag with the same name, a branch named like a
213// full commit id, a tag named like an abbreviation, nested tags, tags of
214// non-commits, packed and loose refs, and refs outside heads and tags.
215func buildRefs(t *testing.T, data string) *Fixture {
216 f := NewFixture(t, data, "refs")
217 f.Write("a.txt", "1\n")
218 c1 := f.Commit("one")
219 f.Write("a.txt", "2\n")
220 c2 := f.Commit("two")
221 f.Write("a.txt", "3\n")
222 c3 := f.Commit("three")
223 f.Git("branch", "same", c1)
224 f.Git("tag", "same", c2)
225 f.Git("tag", "-a", "-m", "annotated", "ann", c2)
226 f.Git("tag", "-a", "-m", "nested", "ann2", "ann")
227 f.Git("tag", "-a", "-m", "nested 3", "ann3", "ann2")
228 f.Git("tag", "-a", "-m", "tree", "tree-tag", c3+"^{tree}")
229 f.Git("tag", "-a", "-m", "blob", "blob-tag", c3+":a.txt")
230 f.Git("-c", "advice.objectNameWarning=false", "branch", c1, c3)
231 f.Git("-c", "advice.objectNameWarning=false", "tag", c2, c3)
232 ghost := strings.Repeat("cd", 20)
233 f.Git("-c", "advice.objectNameWarning=false", "branch", ghost, c3)
234 f.Git("-c", "advice.objectNameWarning=false", "tag", strings.Repeat("ef", 20), c1)
235 f.Git("branch", "dir/sub/branch", c2)
236 f.Git("update-ref", "refs/remotes/origin/main", c1)
237 f.Git("update-ref", "refs/notes/x", c1)
238 f.Git("pack-refs", "--all")
239 f.Git("branch", "loose-after-pack", c3)
240 f.Git("tag", "loose-tag", c1)
241 f.Git("branch", "-f", "same", c3) // loose overrides the packed one
242 return f
243}
244
245// buildDangling has a loose branch and a loose tag whose objects do not exist,
246// next to sound refs. git lists them, and fails when it must follow them.
247func buildDangling(t *testing.T, data string) *Fixture {
248 f := NewFixture(t, data, "dangling")
249 f.Write("a.txt", "1\n")
250 f.Commit("one")
251 ghost := strings.Repeat("0123456789", 4)
252 for _, ref := range []string{"refs/heads/ghost", "refs/tags/ghost"} {
253 require.NoError(t, os.WriteFile(filepath.Join(f.Bare, filepath.FromSlash(ref)), []byte(ghost+"\n"), 0o644))
254 }
255 return f
256}
257
258// buildSubmodule has a gitlink entry, a symlink and an executable.
259func buildSubmodule(t *testing.T, data string) *Fixture {
260 f := NewFixture(t, data, "submod")
261 f.Write("a.txt", "a\n").Write("run.sh", "#!/bin/sh\n").Chmod("run.sh", 0o755).Symlink("a.txt", "link")
262 f.Commit("base")
263 f.Git("update-index", "--add", "--cacheinfo", "160000,"+strings.Repeat("ab", 20)+",vendor/mod")
264 f.Git("commit", "--quiet", "-m", "add submodule")
265 f.Maintain()
266 return f
267}
268
269// buildPaths has what ListPaths must order and skip: nested directories whose
270// names sort around "/" (a/b/c.txt, a/b.txt, a-b.txt), an executable, a
271// symlink, a submodule, and a name with a tab that no address can carry. The
272// tag v1 points at the commit.
273func buildPaths(t *testing.T, data string) *Fixture {
274 f := NewFixture(t, data, "paths")
275 f.Write("a/b/c.txt", "c\n").Write("a/b.txt", "b\n").Write("a-b.txt", "ab\n").Write("z.txt", "z\n")
276 f.Write("run.sh", "#!/bin/sh\n").Chmod("run.sh", 0o755).Symlink("z.txt", "link")
277 f.Write("tab\tname.txt", "t\n").Write("d/e/f/deep.txt", "d\n").Write("héllo wörld.txt", "h\n")
278 f.Commit("base")
279 f.Git("update-index", "--add", "--cacheinfo", "160000,"+strings.Repeat("ab", 20)+",vendor/mod")
280 f.Git("commit", "--quiet", "-m", "add submodule")
281 f.Git("tag", "v1")
282 f.Maintain()
283 return f
284}
285
286// buildWide has a directory over MaxTreeEntries entries and one over
287// MaxLastCommitEntries, with commits that touch some of them.
288func buildWide(t *testing.T, data string) *Fixture {
289 f := NewFixture(t, data, "wide")
290 for i := range domain.MaxTreeEntries + 7 {
291 f.Write(fmt.Sprintf("d/f%05d", i), "x")
292 }
293 for i := range domain.MaxLastCommitEntries + 20 {
294 f.Write(fmt.Sprintf("m/e%03d", i), "x\n")
295 if i%7 == 0 {
296 f.Write(fmt.Sprintf("m/sub%03d/x", i), "x\n")
297 }
298 }
299 f.Commit("wide")
300 f.Write("m/e003", "changed\n").Write("m/e250", "changed\n").Write("d/f00010", "y")
301 f.Commit("touch some")
302 f.Maintain()
303 return f
304}