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