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