1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package readertest
4
5import (
6 "context"
7 "encoding/hex"
8 "errors"
9 "fmt"
10 "io"
11 "slices"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/require"
18
19 "bnema.dev/ermite/internal/domain"
20 "bnema.dev/ermite/internal/ports"
21)
22
23// Maker builds a RepoReader serving the repositories below data/repos, and
24// returns it with the data directory. The reader is closed when the test ends.
25type Maker func(t *testing.T) (ports.RepoReader, string)
26
27// Conformance runs the RepoReader contract against the readers mk builds:
28// every adapter that serves repositories must pass it, whatever its route.
29func Conformance(t *testing.T, mk Maker) {
30 for _, tc := range []struct {
31 name string
32 fn func(*testing.T, Maker)
33 }{
34 {"ListRefs", testListRefs},
35 {"ListRefsEmptyAndMissing", testListRefsEmptyAndMissing},
36 {"DetachedHead", testDetachedHead},
37 {"ResolveRevision", testResolveRevision},
38 {"ResolveRefPath", testResolveRefPath},
39 {"GetTree", testGetTree},
40 {"GetTreeLastCommit", testGetTreeLastCommit},
41 {"GetTreeLastCommitBound", testGetTreeLastCommitBound},
42 {"ListCommits", testListCommits},
43 {"OpenBlob", testOpenBlob},
44 {"OpenBlobMediaTypes", testOpenBlobMediaTypes},
45 {"BinaryRuleLooksAtFirst8000Bytes", testBinaryRuleLooksAtFirst8000Bytes},
46 {"OpenBlobLargeThroughStream", testOpenBlobLargeThroughStream},
47 {"CancelledContext", testCancelledContext},
48 {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral},
49 {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs},
50 {"GetTreeIsBounded", testGetTreeIsBounded},
51 {"GetTreeRefusesAGitlinkToATree", testGetTreeRefusesAGitlinkToATree},
52 {"ListPaths", testListPaths},
53 {"CountCommitsAndLatestTag", testCountCommitsAndLatestTag},
54 } {
55 t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) })
56 }
57}
58
59// Rev parses a revision.
60func Rev(t *testing.T, s string) domain.Revision {
61 t.Helper()
62 r, err := domain.ParseRevision(s)
63 require.NoError(t, err)
64 return r
65}
66
67// TreePath parses a tree path.
68func TreePath(t *testing.T, s string) domain.TreePath {
69 t.Helper()
70 p, err := domain.ParseTreePath(s)
71 require.NoError(t, err)
72 return p
73}
74
75func testListRefs(t *testing.T, mk Maker) {
76 ctx := context.Background()
77 g, data := mk(t)
78 h := NewHistory(t, data)
79
80 refs, err := g.ListRefs(ctx, h.Name)
81 require.NoError(t, err)
82
83 type row struct {
84 name string
85 short string
86 kind domain.RefKind
87 id string
88 commi string
89 }
90 var got []row
91 for _, r := range refs {
92 got = append(got, row{r.Name, r.ShortName, r.Kind, r.ID.String(), r.CommitID.String()})
93 }
94 tagID := func(n string) string { return h.Git("rev-parse", "refs/tags/"+n) }
95 assert.Equal(t, []row{
96 {"HEAD", "main", domain.RefKindHead, h.Merge, h.Merge},
97 {"refs/heads/feature/x", "feature/x", domain.RefKindBranch, h.Side, h.Side},
98 {"refs/heads/main", "main", domain.RefKindBranch, h.Merge, h.Merge},
99 {"refs/heads/rel/1", "rel/1", domain.RefKindBranch, h.C1, h.C1},
100 {"refs/heads/release/1.0", "release/1.0", domain.RefKindBranch, h.C2, h.C2},
101 {"refs/tags/rel/1/x", "rel/1/x", domain.RefKindTag, h.C3, h.C3},
102 {"refs/tags/tree-tag", "tree-tag", domain.RefKindTag, tagID("tree-tag"), ""},
103 {"refs/tags/v1", "v1", domain.RefKindTag, h.C1, h.C1},
104 {"refs/tags/v2", "v2", domain.RefKindTag, tagID("v2"), h.C2},
105 {"refs/tags/v2-again", "v2-again", domain.RefKindTag, tagID("v2-again"), h.C2},
106 }, got)
107}
108
109func testCountCommitsAndLatestTag(t *testing.T, mk Maker) {
110 ctx := context.Background()
111 g, data := mk(t)
112 f := NewFixture(t, data, "stats")
113
114 _, ok, err := g.LatestTag(ctx, f.Name)
115 require.NoError(t, err)
116 assert.False(t, ok, "no tag yet")
117
118 var ids []string
119 for i := range 4 { // committed at 12:01, 12:02, 12:03, 12:04
120 ids = append(ids, f.Commit("c"+itoa(i)))
121 }
122 head := MustID(t, ids[3])
123 n, err := g.CountCommits(ctx, f.Name, head)
124 require.NoError(t, err)
125 assert.Equal(t, 4, n)
126 n, err = g.CountCommits(ctx, f.Name, MustID(t, ids[1]))
127 require.NoError(t, err)
128 assert.Equal(t, 2, n, "from another commit")
129 _, err = g.CountCommits(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)))
130 assert.ErrorIs(t, err, domain.ErrNotFound)
131
132 unix := func(ts []time.Time) []int64 {
133 var out []int64
134 for _, x := range ts {
135 out = append(out, x.Unix())
136 }
137 slices.Sort(out)
138 return out
139 }
140 base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
141 times, more, err := g.CommitTimes(ctx, f.Name, head, base.Add(150*time.Second))
142 require.NoError(t, err)
143 assert.False(t, more)
144 assert.Equal(t, []int64{base.Add(3 * time.Minute).Unix(), base.Add(4 * time.Minute).Unix()}, unix(times), "12:03 and 12:04")
145 _, _, err = g.CommitTimes(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)), base)
146 assert.ErrorIs(t, err, domain.ErrNotFound)
147
148 // Dates out of order: a commit dated in the past on top of recent ones
149 // does not hide them.
150 recent := f.When
151 f.When = recent.Add(-48 * time.Hour)
152 old := f.Commit("backdated")
153 f.When = recent
154 times, _, err = g.CommitTimes(ctx, f.Name, MustID(t, old), base.Add(150*time.Second))
155 require.NoError(t, err)
156 assert.Len(t, times, 2, "the recent commits behind an old one count")
157
158 // The latest tag is the one made last: an annotated tag by its own date,
159 // a lightweight one by the date of its commit.
160 f.Git("tag", "light-old", ids[0])
161 f.When = f.When.Add(time.Hour)
162 f.Git("tag", "-a", "-m", "new", "v2", ids[0])
163 f.Git("tag", "light-new", ids[3])
164 tag, ok, err := g.LatestTag(ctx, f.Name)
165 require.NoError(t, err)
166 require.True(t, ok)
167 assert.Equal(t, "v2", tag.Name)
168 assert.True(t, tag.At.Equal(f.When), "%v", tag.At)
169 assert.Equal(t, ids[0], tag.CommitID.String(), "an annotated tag is peeled to its commit")
170
171 f.When = f.When.Add(time.Hour)
172 f.Git("tag", "-a", "-m", "tree", "tree-tag", ids[0]+"^{tree}")
173 tag, ok, err = g.LatestTag(ctx, f.Name)
174 require.NoError(t, err)
175 require.True(t, ok)
176 assert.Equal(t, "tree-tag", tag.Name)
177 assert.True(t, tag.CommitID.IsZero(), "a tag of a tree has no commit")
178
179 _, _, err = g.LatestTag(ctx, RepoName(t, "nope"))
180 assert.ErrorIs(t, err, domain.ErrNotFound)
181}
182
183func testListRefsEmptyAndMissing(t *testing.T, mk Maker) {
184 ctx := context.Background()
185 g, data := mk(t)
186 f := NewFixture(t, data, "empty")
187
188 refs, err := g.ListRefs(ctx, f.Name)
189 require.NoError(t, err)
190 require.Len(t, refs, 1)
191 assert.Equal(t, "HEAD", refs[0].Name)
192 assert.Equal(t, "main", refs[0].ShortName, "unborn default branch")
193 assert.True(t, refs[0].ID.IsZero())
194 assert.True(t, refs[0].CommitID.IsZero())
195
196 _, err = g.ListRefs(ctx, RepoName(t, "nope"))
197 assert.ErrorIs(t, err, domain.ErrNotFound)
198
199 // No commit to resolve in an empty repository.
200 _, err = g.ResolveRevision(ctx, f.Name, domain.Revision{})
201 assert.ErrorIs(t, err, domain.ErrNotFound)
202 _, err = g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, false)
203 assert.ErrorIs(t, err, domain.ErrNotFound)
204 _, err = g.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 10})
205 assert.ErrorIs(t, err, domain.ErrNotFound)
206}
207
208func testDetachedHead(t *testing.T, mk Maker) {
209 ctx := context.Background()
210 g, data := mk(t)
211 h := NewHistory(t, data)
212 h.Git("update-ref", "--no-deref", "HEAD", h.C1)
213
214 refs, err := g.ListRefs(ctx, h.Name)
215 require.NoError(t, err)
216 assert.Equal(t, "HEAD", refs[0].Name)
217 assert.Empty(t, refs[0].ShortName)
218}
219
220func testResolveRevision(t *testing.T, mk Maker) {
221 ctx := context.Background()
222 g, data := mk(t)
223 h := NewHistory(t, data)
224
225 tests := []struct {
226 rev string
227 commit string
228 ref string
229 }{
230 {"", h.Merge, ""},
231 {"HEAD", h.Merge, ""},
232 {"main", h.Merge, "refs/heads/main"},
233 {"feature/x", h.Side, "refs/heads/feature/x"},
234 {"refs/heads/feature/x", h.Side, "refs/heads/feature/x"},
235 {"v1", h.C1, "refs/tags/v1"},
236 {"v2", h.C2, "refs/tags/v2"},
237 {"v2-again", h.C2, "refs/tags/v2-again"},
238 {"refs/tags/v2", h.C2, "refs/tags/v2"},
239 {h.C2, h.C2, ""},
240 {h.C2[:7], h.C2, ""},
241 {h.C2[:12], h.C2, ""},
242 }
243 for _, tt := range tests {
244 t.Run(tt.rev, func(t *testing.T) {
245 got, err := g.ResolveRevision(ctx, h.Name, Rev(t, tt.rev))
246 require.NoError(t, err)
247 assert.Equal(t, tt.commit, got.CommitID.String())
248 assert.Equal(t, tt.ref, got.Ref)
249 })
250 }
251 for _, bad := range []string{"nope", "tree-tag", "deadbeefdeadbeef", "HEAD~1", h.C2[:6], "refs/heads", h.Git("rev-parse", h.Merge+"^{tree}")} {
252 t.Run("missing "+bad, func(t *testing.T) {
253 r, err := domain.ParseRevision(bad)
254 if err != nil {
255 return // not a revision at all, rejected before the reader
256 }
257 _, err = g.ResolveRevision(ctx, h.Name, r)
258 assert.ErrorIs(t, err, domain.ErrNotFound)
259 })
260 }
261
262 t.Run("a branch wins over a tag of the same name", func(t *testing.T) {
263 h.Git("branch", "both", h.C1)
264 h.Git("tag", "both", h.C2)
265 got, err := g.ResolveRevision(ctx, h.Name, Rev(t, "both"))
266 require.NoError(t, err)
267 assert.Equal(t, h.C1, got.CommitID.String())
268 })
269}
270
271func testResolveRefPath(t *testing.T, mk Maker) {
272 ctx := context.Background()
273 g, data := mk(t)
274 h := NewHistory(t, data)
275
276 tests := []struct {
277 path string
278 name string
279 ref string
280 id string
281 rest string
282 }{
283 {"main", "main", "refs/heads/main", h.Merge, ""},
284 {"main/d/f", "main", "refs/heads/main", h.Merge, "d/f"},
285 {"feature/x", "feature/x", "refs/heads/feature/x", h.Side, ""},
286 {"feature/x/d/f", "feature/x", "refs/heads/feature/x", h.Side, "d/f"},
287 {"feature/x/", "feature/x", "refs/heads/feature/x", h.Side, ""},
288 {"release/1.0/a.txt", "release/1.0", "refs/heads/release/1.0", h.C2, "a.txt"},
289 // The longest ref wins: the tag rel/1/x over the branch rel/1.
290 {"rel/1/x/d/f", "rel/1/x", "refs/tags/rel/1/x", h.C3, "d/f"},
291 {"rel/1/d/f", "rel/1", "refs/heads/rel/1", h.C1, "d/f"},
292 {"v2/we:ird*.txt", "v2", "refs/tags/v2", h.C2, "we:ird*.txt"},
293 {h.C1 + "/d", h.C1, "", h.C1, "d"},
294 {h.C1[:8] + "/d/f", h.C1[:8], "", h.C1, "d/f"},
295 }
296 for _, tt := range tests {
297 t.Run(tt.path, func(t *testing.T) {
298 got, err := g.ResolveRefPath(ctx, h.Name, tt.path)
299 require.NoError(t, err)
300 assert.Equal(t, tt.name, got.Name)
301 assert.Equal(t, tt.ref, got.Ref)
302 assert.Equal(t, tt.id, got.CommitID.String())
303 assert.Equal(t, tt.rest, got.Rest.String())
304 })
305 }
306 for _, bad := range []string{"", "/", "nope", "nope/d/f", "a//b", "../x", "d/f"} {
307 t.Run("missing "+bad, func(t *testing.T) {
308 _, err := g.ResolveRefPath(ctx, h.Name, bad)
309 assert.ErrorIs(t, err, domain.ErrNotFound)
310 })
311 }
312 _, err := g.ResolveRefPath(ctx, RepoName(t, "nope"), "main")
313 assert.ErrorIs(t, err, domain.ErrNotFound)
314}
315
316func testGetTree(t *testing.T, mk Maker) {
317 ctx := context.Background()
318 g, data := mk(t)
319 h := NewHistory(t, data)
320
321 t.Run("root at HEAD", func(t *testing.T) {
322 tree, err := g.GetTree(ctx, h.Name, domain.Revision{}, domain.TreePath{}, false)
323 require.NoError(t, err)
324 assert.Equal(t, h.Merge, tree.CommitID.String())
325 assert.Equal(t, h.Git("rev-parse", h.Merge+"^{tree}"), tree.ID.String())
326 type row struct {
327 name, path string
328 kind domain.EntryKind
329 mode uint32
330 size int64
331 }
332 var got []row
333 for _, e := range tree.Entries {
334 got = append(got, row{e.Name, e.Path, e.Kind, e.Mode, e.Size})
335 assert.Nil(t, e.LastCommit)
336 }
337 assert.Equal(t, []row{
338 {"d", "d", domain.EntryDirectory, 0o040000, 0},
339 {"b.txt", "b.txt", domain.EntryFile, 0o100644, 19},
340 {"bin", "bin", domain.EntryFile, 0o100644, 2},
341 {"empty", "empty", domain.EntryFile, 0o100644, 0},
342 {"main.txt", "main.txt", domain.EntryFile, 0o100644, 2},
343 {"n.txt", "n.txt", domain.EntrySymlink, 0o120000, 5},
344 {"side.txt", "side.txt", domain.EntryFile, 0o100644, 2},
345 {"we:ird*.txt", "we:ird*.txt", domain.EntryFile, 0o100644, 1},
346 }, got)
347 assert.False(t, tree.LastCommitsOmitted)
348 })
349
350 t.Run("subdirectory with an executable", func(t *testing.T) {
351 tree, err := g.GetTree(ctx, h.Name, Rev(t, "v2"), TreePath(t, "d"), false)
352 require.NoError(t, err)
353 require.Len(t, tree.Entries, 2)
354 assert.Equal(t, "sub", tree.Entries[0].Name)
355 assert.Equal(t, "d/sub", tree.Entries[0].Path)
356 assert.Equal(t, domain.EntryExecutable, tree.Entries[1].Kind)
357 assert.Equal(t, uint32(0o100755), tree.Entries[1].Mode)
358 assert.Equal(t, "d/f", tree.Entries[1].Path)
359 assert.Equal(t, h.C2, tree.CommitID.String())
360 })
361
362 t.Run("errors", func(t *testing.T) {
363 _, err := g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "nope"), false)
364 assert.ErrorIs(t, err, domain.ErrNotFound)
365 _, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "d/nope/x"), false)
366 assert.ErrorIs(t, err, domain.ErrNotFound)
367 _, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "b.txt"), false)
368 assert.ErrorIs(t, err, domain.ErrNotDirectory)
369 _, err = g.GetTree(ctx, h.Name, Rev(t, "nope"), domain.TreePath{}, false)
370 assert.ErrorIs(t, err, domain.ErrNotFound)
371 })
372
373 t.Run("paths are literal", func(t *testing.T) {
374 tree, err := g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "d"), true)
375 require.NoError(t, err)
376 require.Len(t, tree.Entries, 2)
377 _, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "*"), false)
378 assert.ErrorIs(t, err, domain.ErrNotFound)
379 })
380}
381
382func testGetTreeLastCommit(t *testing.T, mk Maker) {
383 ctx := context.Background()
384 g, data := mk(t)
385 h := NewHistory(t, data)
386
387 tree, err := g.GetTree(ctx, h.Name, Rev(t, "main"), domain.TreePath{}, true)
388 require.NoError(t, err)
389 got := map[string]string{}
390 for _, e := range tree.Entries {
391 require.NotNil(t, e.LastCommit, e.Path)
392 got[e.Path] = e.LastCommit.Summary
393 // The oracle: git log -1 for the same path.
394 assert.Equal(t, h.Git("log", "-1", "--format=%H", h.Merge, "--", e.Path), e.LastCommit.ID.String(), e.Path)
395 }
396 assert.Equal(t, "second", got["d"], "chmod of d/f is the last change of d")
397 assert.Equal(t, "second", got["b.txt"])
398 assert.Equal(t, "first", got["we:ird*.txt"])
399 assert.Equal(t, "third: type change", got["n.txt"])
400 assert.Equal(t, "side work", got["side.txt"])
401 assert.Equal(t, "Ada Lovelace", tree.Entries[0].LastCommit.Author.Name)
402}
403
404func testGetTreeLastCommitBound(t *testing.T, mk Maker) {
405 ctx := context.Background()
406 g, data := mk(t)
407 f := NewFixture(t, data, "wide")
408 n := domain.MaxLastCommitEntries + 5
409 for i := range n {
410 f.Write("f"+strings.Repeat("0", 3-len(itoa(i)))+itoa(i), "x\n")
411 }
412 f.Commit("wide")
413
414 tree, err := g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, true)
415 require.NoError(t, err)
416 require.Len(t, tree.Entries, n)
417 assert.True(t, tree.LastCommitsOmitted)
418 with := 0
419 for _, e := range tree.Entries {
420 if e.LastCommit != nil {
421 with++
422 }
423 }
424 assert.Equal(t, domain.MaxLastCommitEntries, with)
425 assert.NotNil(t, tree.Entries[0].LastCommit)
426 assert.Nil(t, tree.Entries[n-1].LastCommit)
427
428 tree, err = g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, false)
429 require.NoError(t, err)
430 assert.False(t, tree.LastCommitsOmitted)
431}
432
433func itoa(i int) string {
434 if i == 0 {
435 return "0"
436 }
437 var b []byte
438 for ; i > 0; i /= 10 {
439 b = append([]byte{byte('0' + i%10)}, b...)
440 }
441 return string(b)
442}
443
444func testListCommits(t *testing.T, mk Maker) {
445 ctx := context.Background()
446 g, data := mk(t)
447 h := NewHistory(t, data)
448
449 oracle := strings.Split(h.Git("log", "--format=%H", h.Merge), "\n")
450 require.Len(t, oracle, 6)
451
452 t.Run("all", func(t *testing.T) {
453 page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 100})
454 require.NoError(t, err)
455 assert.Equal(t, h.Merge, page.Head.String())
456 assert.False(t, page.HasMore)
457 var ids []string
458 for _, c := range page.Commits {
459 ids = append(ids, c.ID.String())
460 }
461 assert.Equal(t, oracle, ids)
462 })
463
464 t.Run("pages", func(t *testing.T) {
465 var ids []string
466 skip := 0
467 for {
468 page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Skip: skip, Limit: 2})
469 require.NoError(t, err)
470 for _, c := range page.Commits {
471 ids = append(ids, c.ID.String())
472 }
473 skip += len(page.Commits)
474 if !page.HasMore {
475 break
476 }
477 require.Len(t, page.Commits, 2)
478 }
479 assert.Equal(t, oracle, ids)
480 })
481
482 t.Run("path", func(t *testing.T) {
483 page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Path: TreePath(t, "d/f"), Limit: 10})
484 require.NoError(t, err)
485 var ids []string
486 for _, c := range page.Commits {
487 ids = append(ids, c.ID.String())
488 }
489 assert.Equal(t, strings.Split(h.Git("log", "--format=%H", h.Merge, "--", "d/f"), "\n"), ids)
490 assert.Len(t, ids, 2)
491
492 page, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Path: TreePath(t, "we:ird*.txt"), Limit: 10})
493 require.NoError(t, err)
494 require.Len(t, page.Commits, 1, "paths are literal")
495 page, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Path: TreePath(t, "*"), Limit: 10})
496 require.NoError(t, err)
497 assert.Empty(t, page.Commits)
498 })
499
500 t.Run("revision", func(t *testing.T) {
501 page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Revision: Rev(t, "v2"), Limit: 10})
502 require.NoError(t, err)
503 require.Len(t, page.Commits, 2)
504 assert.Equal(t, h.C2, page.Head.String())
505 assert.Equal(t, h.C2, page.Commits[0].ID.String())
506 })
507
508 t.Run("metadata", func(t *testing.T) {
509 page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Revision: domain.RevisionOf(MustID(t, h.C1)), Limit: 10})
510 require.NoError(t, err)
511 require.Len(t, page.Commits, 1)
512 c := page.Commits[0]
513 assert.Empty(t, c.Parents)
514 assert.Equal(t, "first", c.Summary)
515 assert.Equal(t, "first\n\nbody of the first commit", c.Message)
516 assert.Equal(t, domain.Signature{Name: "Ada Lovelace", Email: "ada@example.org", Time: time.Date(2026, 1, 1, 12, 1, 0, 0, time.UTC)}, c.Author)
517 assert.Equal(t, "Grace Hopper", c.Committer.Name)
518 assert.Equal(t, "grace@example.org", c.Committer.Email)
519
520 page, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 1})
521 require.NoError(t, err)
522 require.Len(t, page.Commits, 1)
523 assert.Equal(t, []string{h.Git("rev-parse", h.Merge+"^1"), h.Side}, idStrings(page.Commits[0].Parents))
524 })
525
526 t.Run("bad queries", func(t *testing.T) {
527 for _, q := range []ports.CommitQuery{{Limit: 0}, {Limit: -1}, {Limit: ports.MaxCommitLimit + 1}, {Limit: 1, Skip: -1}} {
528 _, err := g.ListCommits(ctx, h.Name, q)
529 assert.ErrorIs(t, err, domain.ErrInvalidPageSize, "%+v", q)
530 }
531 _, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Revision: Rev(t, "nope"), Limit: 1})
532 assert.ErrorIs(t, err, domain.ErrNotFound)
533 _, err = g.ListCommits(ctx, RepoName(t, "nope"), ports.CommitQuery{Limit: 1})
534 assert.ErrorIs(t, err, domain.ErrNotFound)
535 })
536}
537
538// MustID parses an object id.
539func MustID(t *testing.T, s string) domain.ObjectID {
540 t.Helper()
541 id, err := domain.ParseObjectID(s)
542 require.NoError(t, err)
543 return id
544}
545
546func idStrings(ids []domain.ObjectID) []string {
547 out := make([]string, len(ids))
548 for i, id := range ids {
549 out[i] = id.String()
550 }
551 return out
552}
553
554func testOpenBlob(t *testing.T, mk Maker) {
555 ctx := context.Background()
556 g, data := mk(t)
557 h := NewHistory(t, data)
558
559 read := func(t *testing.T, r string, p string) (domain.BlobInfo, string) {
560 t.Helper()
561 info, rc, err := g.OpenBlob(ctx, h.Name, Rev(t, r), TreePath(t, p))
562 require.NoError(t, err)
563 body, err := io.ReadAll(rc)
564 require.NoError(t, err)
565 require.NoError(t, rc.Close())
566 return info, string(body)
567 }
568
569 info, body := read(t, "main", "b.txt")
570 assert.Equal(t, "one\ntwo\nthree\nfour\n", body)
571 assert.Equal(t, int64(19), info.Size)
572 assert.Equal(t, "b.txt", info.Path)
573 assert.Equal(t, uint32(0o100644), info.Mode)
574 assert.False(t, info.Binary)
575 assert.Equal(t, "text/plain", info.MediaType)
576 assert.Equal(t, h.Git("rev-parse", "main:b.txt"), info.ID.String())
577
578 info, body = read(t, "main", "bin")
579 assert.Equal(t, "\x00\x02", body)
580 assert.True(t, info.Binary)
581 assert.Equal(t, "application/octet-stream", info.MediaType)
582
583 info, body = read(t, "main", "empty")
584 assert.Empty(t, body)
585 assert.Equal(t, int64(0), info.Size)
586 assert.False(t, info.Binary)
587
588 info, body = read(t, "main", "n.txt")
589 assert.Equal(t, "b.txt", body, "a symlink's content is its target")
590 assert.Equal(t, uint32(0o120000), info.Mode)
591
592 info, _ = read(t, "main", "d/f")
593 assert.Equal(t, uint32(0o100755), info.Mode)
594
595 info, _ = read(t, "v1", "a.txt")
596 assert.Equal(t, "text/plain", info.MediaType)
597
598 for p, want := range map[string]error{"d": domain.ErrNotFile, "nope": domain.ErrNotFound, "d/nope": domain.ErrNotFound, "": domain.ErrNotFile} {
599 _, rc, err := g.OpenBlob(ctx, h.Name, domain.Revision{}, TreePath(t, p))
600 assert.ErrorIs(t, err, want, p)
601 assert.Nil(t, rc)
602 }
603 _, _, err := g.OpenBlob(ctx, h.Name, Rev(t, "nope"), TreePath(t, "b.txt"))
604 assert.ErrorIs(t, err, domain.ErrNotFound)
605}
606
607func testOpenBlobMediaTypes(t *testing.T, mk Maker) {
608 ctx := context.Background()
609 g, data := mk(t)
610 f := NewFixture(t, data, "media")
611 png := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89")
612 f.WriteBytes("logo.png", png).
613 WriteBytes("noext", png).
614 Write("page.html", "<!doctype html><html><body>hi</body></html>\n").
615 Write("style.css", "a { color: red }\n").
616 Write("data.json", `{"a":1}`+"\n").
617 Write("notes", "plain words\n").
618 Write("main.go", "package main\n").
619 WriteBytes("blob.dat", []byte{0xff, 0xfe, 0, 1, 2}).
620 WriteBytes("fake.txt", []byte{'a', 0, 'b'}).
621 Write("latin", "caf\xe9\n")
622 f.Commit("media")
623
624 want := map[string]struct {
625 media string
626 binary bool
627 }{
628 "logo.png": {"image/png", true},
629 "noext": {"image/png", true},
630 "page.html": {"text/html", false},
631 "style.css": {"text/css", false},
632 "data.json": {"application/json", false},
633 "notes": {"text/plain", false},
634 "main.go": {"text/plain", false},
635 "blob.dat": {"application/octet-stream", true},
636 "fake.txt": {"application/octet-stream", true},
637 "latin": {"text/plain", false},
638 }
639 for p, w := range want {
640 info, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, TreePath(t, p))
641 require.NoError(t, err, p)
642 require.NoError(t, rc.Close())
643 assert.Equal(t, w.media, info.MediaType, p)
644 assert.Equal(t, w.binary, info.Binary, p)
645 }
646}
647
648func testBinaryRuleLooksAtFirst8000Bytes(t *testing.T, mk Maker) {
649 ctx := context.Background()
650 g, data := mk(t)
651 f := NewFixture(t, data, "sniff")
652 late := append([]byte(strings.Repeat("a", 8000)), 0) // NUL at offset 8000: not seen
653 early := append([]byte(strings.Repeat("a", 7999)), 0)
654 f.WriteBytes("late", late).WriteBytes("early", early)
655 // Over the in-memory threshold, so that the streaming path decides too.
656 f.WriteBytes("biglate", append([]byte(strings.Repeat("a", 300<<10)), 0)).
657 WriteBytes("bigearly", append([]byte(strings.Repeat("a", 7999)), append([]byte{0}, make([]byte, 300<<10)...)...))
658 f.Commit("sniff")
659
660 for p, binary := range map[string]bool{"late": false, "early": true, "biglate": false, "bigearly": true} {
661 info, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, TreePath(t, p))
662 require.NoError(t, err, p)
663 require.NoError(t, rc.Close())
664 assert.Equal(t, binary, info.Binary, p)
665 }
666}
667
668func testOpenBlobLargeThroughStream(t *testing.T, mk Maker) {
669 ctx := context.Background()
670 g, data := mk(t)
671 f := NewFixture(t, data, "large")
672 content := RandomBytes(1<<20+17, 7)
673 f.WriteBytes("big.bin", content).Commit("big")
674
675 info, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, TreePath(t, "big.bin"))
676 require.NoError(t, err)
677 assert.Equal(t, int64(len(content)), info.Size)
678 got, err := io.ReadAll(rc)
679 require.NoError(t, err)
680 assert.Equal(t, content, got)
681 require.NoError(t, rc.Close())
682 require.NoError(t, rc.Close(), "Close is idempotent")
683}
684
685func testCancelledContext(t *testing.T, mk Maker) {
686 g, data := mk(t)
687 h := NewHistory(t, data)
688
689 ctx, cancel := context.WithCancel(context.Background())
690 cancel()
691 _, err := g.ListRefs(ctx, h.Name)
692 assert.ErrorIs(t, err, context.Canceled)
693 _, err = g.ResolveRevision(ctx, h.Name, domain.Revision{})
694 assert.ErrorIs(t, err, context.Canceled)
695 _, err = g.GetCommit(ctx, h.Name, domain.Revision{}, domain.DefaultDiffLimits())
696 assert.ErrorIs(t, err, context.Canceled)
697 _, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 5})
698 assert.ErrorIs(t, err, context.Canceled)
699
700 // The adapter still works with a live context.
701 _, err = g.ResolveRevision(context.Background(), h.Name, domain.Revision{})
702 assert.NoError(t, err)
703}
704
705func testHostileRevisionAndPathStayLiteral(t *testing.T, mk Maker) {
706 ctx := context.Background()
707 g, data := mk(t)
708 h := NewHistory(t, data)
709
710 // Revisions that look like options or git revision syntax are rejected by
711 // the domain; ones that pass it are plain names for the reader.
712 for _, s := range []string{"--output=/tmp/x", "-h", "main^", "main~1", "main:b.txt", "@{u}", "HEAD@{1}"} {
713 _, err := domain.ParseRevision(s)
714 assert.ErrorIs(t, err, domain.ErrInvalidRevision, s)
715 }
716 _, err := g.ResolveRevision(ctx, h.Name, Rev(t, "HEAD.x"))
717 assert.ErrorIs(t, err, domain.ErrNotFound)
718 _, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, ":(top)d"), false)
719 assert.ErrorIs(t, err, domain.ErrNotFound)
720 var pe *domain.InvalidNameError
721 assert.False(t, errors.As(err, &pe))
722}
723
724// A full object id is an object id before it is a ref: a branch or a tag
725// named like a commit must not redirect a link to that commit.
726func testResolveRevisionObjectIDBeatsRefs(t *testing.T, mk Maker) {
727 ctx := context.Background()
728 g, data := mk(t)
729 h := NewHistory(t, data)
730
731 // Git itself warns about these refs and prefers the object id.
732 h.Git("-c", "advice.objectNameWarning=false", "branch", h.C1, h.C3)
733 h.Git("-c", "advice.objectNameWarning=false", "tag", h.C2, h.C3)
734
735 got, err := g.ResolveRevision(ctx, h.Name, Rev(t, h.C1))
736 require.NoError(t, err)
737 assert.Equal(t, h.C1, got.CommitID.String(), "the commit, not the branch of the same name")
738 assert.Empty(t, got.Ref)
739
740 got, err = g.ResolveRevision(ctx, h.Name, Rev(t, h.C2))
741 require.NoError(t, err)
742 assert.Equal(t, h.C2, got.CommitID.String(), "the commit, not the tag of the same name")
743
744 // An abbreviation is still a name first, as before.
745 h.Git("branch", "abc1234", h.C3)
746 if !strings.HasPrefix(h.C1, "abc1234") {
747 got, err = g.ResolveRevision(ctx, h.Name, Rev(t, "abc1234"))
748 require.NoError(t, err)
749 assert.Equal(t, h.C3, got.CommitID.String())
750 assert.Equal(t, "refs/heads/abc1234", got.Ref)
751 }
752
753 // The ref is used when no object has that id.
754 ghost := strings.Repeat("ab", 20)
755 h.Git("-c", "advice.objectNameWarning=false", "branch", ghost, h.C3)
756 got, err = g.ResolveRevision(ctx, h.Name, Rev(t, ghost))
757 require.NoError(t, err)
758 assert.Equal(t, h.C3, got.CommitID.String())
759 assert.Equal(t, "refs/heads/"+ghost, got.Ref)
760}
761
762func testGetTreeIsBounded(t *testing.T, mk Maker) {
763 ctx := context.Background()
764 g, data := mk(t)
765 f := NewFixture(t, data, "wide")
766 n := domain.MaxTreeEntries + 7
767 for i := range n {
768 f.Write(fmt.Sprintf("d/f%05d", i), "x")
769 }
770 f.Commit("wide")
771
772 tree, err := g.GetTree(ctx, f.Name, domain.Revision{}, TreePath(t, "d"), false)
773 require.NoError(t, err)
774 assert.True(t, tree.Truncated)
775 require.Len(t, tree.Entries, domain.MaxTreeEntries)
776 assert.Equal(t, "f00000", tree.Entries[0].Name, "the first entries in order")
777 assert.Equal(t, fmt.Sprintf("f%05d", domain.MaxTreeEntries-1), tree.Entries[domain.MaxTreeEntries-1].Name)
778
779 small, err := g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, false)
780 require.NoError(t, err)
781 assert.False(t, small.Truncated)
782}
783
784func testListPaths(t *testing.T, mk Maker) {
785 ctx := context.Background()
786 g, data := mk(t)
787 f := buildPaths(t, data)
788
789 // git ls-tree -r in order, minus the submodule and the path no address can
790 // carry.
791 var want []string
792 for _, rec := range strings.Split(strings.TrimSuffix(GitOut(t, f.Bare, "ls-tree", "-r", "-z", "HEAD"), "\x00"), "\x00") {
793 meta, p, _ := strings.Cut(rec, "\t")
794 if !strings.Contains(meta, " commit ") && !strings.Contains(p, "\t") {
795 want = append(want, p)
796 }
797 }
798 require.Equal(t, []string{
799 "a-b.txt", "a/b.txt", "a/b/c.txt", "d/e/f/deep.txt", "héllo wörld.txt", "link", "run.sh", "z.txt",
800 }, want, "the fixture")
801
802 got, err := g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
803 require.NoError(t, err)
804 assert.Equal(t, want, got.Paths)
805 assert.False(t, got.Truncated)
806 assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD"), got.CommitID.String())
807 assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD^{tree}"), got.TreeID.String())
808
809 t.Run("revisions", func(t *testing.T) {
810 for _, s := range []string{"main", "v1", "refs/tags/v1", f.Git("rev-parse", "HEAD"), f.Git("rev-parse", "HEAD")[:10]} {
811 list, err := g.ListPaths(ctx, f.Name, Rev(t, s), domain.MaxPathEntries, domain.MaxPathBytes)
812 require.NoError(t, err, s)
813 assert.Equal(t, want, list.Paths, s)
814 }
815 // The first commit has no submodule: the same files.
816 first, err := g.ListPaths(ctx, f.Name, Rev(t, f.Git("rev-parse", "HEAD~1")), domain.MaxPathEntries, domain.MaxPathBytes)
817 require.NoError(t, err)
818 assert.Equal(t, want, first.Paths)
819 assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD~1^{tree}"), first.TreeID.String())
820 assert.NotEqual(t, got.TreeID, first.TreeID)
821 })
822 t.Run("not found", func(t *testing.T) {
823 for _, s := range []string{"nope", "refs/heads/nope", strings.Repeat("ab", 20), "deadbeef"} {
824 _, err := g.ListPaths(ctx, f.Name, Rev(t, s), domain.MaxPathEntries, domain.MaxPathBytes)
825 assert.ErrorIs(t, err, domain.ErrNotFound, s)
826 }
827 _, err := g.ListPaths(ctx, RepoName(t, "no-such-repo"), domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
828 assert.ErrorIs(t, err, domain.ErrNotFound)
829 })
830 t.Run("empty repository", func(t *testing.T) {
831 e := NewFixture(t, data, "empty-paths")
832 _, err := g.ListPaths(ctx, e.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
833 assert.ErrorIs(t, err, domain.ErrNotFound)
834 })
835 t.Run("bounds", func(t *testing.T) {
836 list, err := g.ListPaths(ctx, f.Name, domain.Revision{}, 3, domain.MaxPathBytes)
837 require.NoError(t, err)
838 assert.Equal(t, want[:3], list.Paths)
839 assert.True(t, list.Truncated)
840 assert.Equal(t, got.CommitID, list.CommitID)
841 assert.Equal(t, got.TreeID, list.TreeID)
842
843 list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, len(want), domain.MaxPathBytes)
844 require.NoError(t, err)
845 assert.Equal(t, want, list.Paths)
846 assert.False(t, list.Truncated, "exactly maxPaths paths is not truncated")
847
848 sum := len(want[0]) + len(want[1]) + len(want[2])
849 list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, sum-1)
850 require.NoError(t, err)
851 assert.Equal(t, want[:2], list.Paths)
852 assert.True(t, list.Truncated)
853
854 list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, sum)
855 require.NoError(t, err)
856 assert.Equal(t, want[:3], list.Paths, "bytes up to maxBytes fit")
857 assert.True(t, list.Truncated)
858
859 total := 0
860 for _, p := range want {
861 total += len(p)
862 }
863 list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, total)
864 require.NoError(t, err)
865 assert.Equal(t, want, list.Paths)
866 assert.False(t, list.Truncated, "exactly maxBytes bytes is not truncated")
867 })
868 t.Run("cancelled context", func(t *testing.T) {
869 ctx, cancel := context.WithCancel(ctx)
870 cancel()
871 _, err := g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
872 assert.ErrorIs(t, err, context.Canceled)
873 })
874 t.Run("a large tree", func(t *testing.T) {
875 w := NewFixture(t, data, "paths-wide")
876 for i := range 300 {
877 w.Write(fmt.Sprintf("d%02d/e%d/f%03d.txt", i%17, i%5, i), "x")
878 }
879 w.Commit("wide")
880 order := lines(GitOut(t, w.Bare, "ls-tree", "-r", "--name-only", "HEAD"))
881 list, err := g.ListPaths(ctx, w.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
882 require.NoError(t, err)
883 assert.Equal(t, order, list.Paths)
884 list, err = g.ListPaths(ctx, w.Name, domain.Revision{}, 100, domain.MaxPathBytes)
885 require.NoError(t, err)
886 assert.Equal(t, order[:100], list.Paths)
887 assert.True(t, list.Truncated)
888 })
889}
890
891// A tree entry of mode 160000 (a submodule) names a commit of another
892// repository. When its id is that of a tree this repository has, it still is no
893// directory of it: both readers answer not found, and the same id as a real
894// directory lists.
895func testGetTreeRefusesAGitlinkToATree(t *testing.T, mk Maker) {
896 ctx := context.Background()
897 g, data := mk(t)
898 f := NewFixture(t, data, "gitlink")
899 f.Write("README", "r\n")
900 f.Write("dir/f.txt", "f\n")
901 f.Commit("dir")
902 dirID := f.Git("rev-parse", "HEAD:dir")
903 raw := func(entries ...[3]string) string {
904 var b strings.Builder
905 for _, e := range entries {
906 id, err := hex.DecodeString(e[2])
907 require.NoError(t, err)
908 b.WriteString(e[0] + " " + e[1] + "\x00")
909 b.Write(id)
910 }
911 return f.WriteObject("tree", b.String())
912 }
913 both := raw([3]string{"40000", "asdir", dirID}, [3]string{"160000", "sub", dirID}) // the same id, two modes
914 commit := f.Git("commit-tree", both, "-m", "gitlink")
915 f.Git("update-ref", "refs/heads/main", commit)
916
917 tree, err := g.GetTree(ctx, f.Name, Rev(t, commit), TreePath(t, "asdir"), false)
918 require.NoError(t, err)
919 require.Len(t, tree.Entries, 1)
920 assert.Equal(t, "f.txt", tree.Entries[0].Name)
921 _, err = g.GetTree(ctx, f.Name, Rev(t, commit), TreePath(t, "sub"), false)
922 assert.ErrorIs(t, err, domain.ErrNotFound, "a gitlink is no directory, whatever it names")
923}