1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli_test
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16
17 "bnema.dev/ermite/internal/domain"
18 "bnema.dev/ermite/internal/ports"
19)
20
21func rev(t *testing.T, s string) domain.Revision {
22 t.Helper()
23 r, err := domain.ParseRevision(s)
24 require.NoError(t, err)
25 return r
26}
27
28func tpath(t *testing.T, s string) domain.TreePath {
29 t.Helper()
30 p, err := domain.ParseTreePath(s)
31 require.NoError(t, err)
32 return p
33}
34
35// history is the repository of the read tests: three commits on main, a
36// branch with a slash, a merge, lightweight and annotated tags.
37type history struct {
38 *fixtureRepo
39 c1, c2, c3, side, merge string
40}
41
42func newHistory(t *testing.T, data string) *history {
43 t.Helper()
44 f := newFixtureRepo(t, data, "hist")
45 h := &history{fixtureRepo: f}
46 f.write("a.txt", "one\ntwo\nthree\n").
47 writeBytes("bin", []byte{0, 1}).
48 write("d/f", "hi\n").
49 write("d/sub/g", "deep\n").
50 write("we:ird*.txt", "x").
51 write("README.md", "# hello\n").
52 write("empty", "")
53 h.c1 = f.commit("first\n\nbody of the first commit\n")
54
55 f.mv("a.txt", "b.txt").write("b.txt", "one\ntwo\nthree\nfour\n").
56 writeBytes("bin", []byte{0, 2}).
57 chmod("d/f", 0o755).
58 write("n.txt", "new\nnonl").
59 remove("README.md")
60 h.c2 = f.commit("second")
61
62 f.remove("n.txt").symlink("b.txt", "n.txt")
63 h.c3 = f.commit("third: type change")
64
65 f.checkoutNew("feature/x")
66 f.write("side.txt", "s\n")
67 h.side = f.commit("side work")
68 f.checkout("main")
69 f.write("main.txt", "m\n")
70 f.commit("main work")
71 h.merge = f.merge("feature/x")
72
73 f.git("tag", "v1", h.c1)
74 f.git("tag", "-a", "-m", "release two", "v2", h.c2)
75 f.git("tag", "-a", "-m", "tag of a tag", "v2-again", "v2")
76 f.git("tag", "-a", "-m", "tag of a tree", "tree-tag", h.merge+"^{tree}")
77 f.git("branch", "release/1.0", h.c2)
78 f.git("branch", "rel/1", h.c1)
79 f.git("tag", "rel/1/x", h.c3)
80 return h
81}
82
83func TestListRefs(t *testing.T) {
84 ctx := context.Background()
85 g, data := newReader(t, nil)
86 h := newHistory(t, data)
87
88 refs, err := g.ListRefs(ctx, h.name)
89 require.NoError(t, err)
90
91 type row struct {
92 name string
93 short string
94 kind domain.RefKind
95 id string
96 commi string
97 }
98 var got []row
99 for _, r := range refs {
100 got = append(got, row{r.Name, r.ShortName, r.Kind, r.ID.String(), r.CommitID.String()})
101 }
102 tagID := func(n string) string { return h.git("rev-parse", "refs/tags/"+n) }
103 assert.Equal(t, []row{
104 {"HEAD", "main", domain.RefKindHead, h.merge, h.merge},
105 {"refs/heads/feature/x", "feature/x", domain.RefKindBranch, h.side, h.side},
106 {"refs/heads/main", "main", domain.RefKindBranch, h.merge, h.merge},
107 {"refs/heads/rel/1", "rel/1", domain.RefKindBranch, h.c1, h.c1},
108 {"refs/heads/release/1.0", "release/1.0", domain.RefKindBranch, h.c2, h.c2},
109 {"refs/tags/rel/1/x", "rel/1/x", domain.RefKindTag, h.c3, h.c3},
110 {"refs/tags/tree-tag", "tree-tag", domain.RefKindTag, tagID("tree-tag"), ""},
111 {"refs/tags/v1", "v1", domain.RefKindTag, h.c1, h.c1},
112 {"refs/tags/v2", "v2", domain.RefKindTag, tagID("v2"), h.c2},
113 {"refs/tags/v2-again", "v2-again", domain.RefKindTag, tagID("v2-again"), h.c2},
114 }, got)
115}
116
117func TestListRefsEmptyAndMissing(t *testing.T) {
118 ctx := context.Background()
119 g, data := newReader(t, nil)
120 f := newFixtureRepo(t, data, "empty")
121
122 refs, err := g.ListRefs(ctx, f.name)
123 require.NoError(t, err)
124 require.Len(t, refs, 1)
125 assert.Equal(t, "HEAD", refs[0].Name)
126 assert.Equal(t, "main", refs[0].ShortName, "unborn default branch")
127 assert.True(t, refs[0].ID.IsZero())
128 assert.True(t, refs[0].CommitID.IsZero())
129
130 _, err = g.ListRefs(ctx, name(t, "nope"))
131 assert.ErrorIs(t, err, domain.ErrNotFound)
132
133 // No commit to resolve in an empty repository.
134 _, err = g.ResolveRevision(ctx, f.name, domain.Revision{})
135 assert.ErrorIs(t, err, domain.ErrNotFound)
136 _, err = g.GetTree(ctx, f.name, domain.Revision{}, domain.TreePath{}, false)
137 assert.ErrorIs(t, err, domain.ErrNotFound)
138 _, err = g.ListCommits(ctx, f.name, ports.CommitQuery{Limit: 10})
139 assert.ErrorIs(t, err, domain.ErrNotFound)
140}
141
142func TestDetachedHead(t *testing.T) {
143 ctx := context.Background()
144 g, data := newReader(t, nil)
145 h := newHistory(t, data)
146 h.git("update-ref", "--no-deref", "HEAD", h.c1)
147
148 refs, err := g.ListRefs(ctx, h.name)
149 require.NoError(t, err)
150 assert.Equal(t, "HEAD", refs[0].Name)
151 assert.Empty(t, refs[0].ShortName)
152}
153
154func TestResolveRevision(t *testing.T) {
155 ctx := context.Background()
156 g, data := newReader(t, nil)
157 h := newHistory(t, data)
158
159 tests := []struct {
160 rev string
161 commit string
162 ref string
163 }{
164 {"", h.merge, ""},
165 {"HEAD", h.merge, ""},
166 {"main", h.merge, "refs/heads/main"},
167 {"feature/x", h.side, "refs/heads/feature/x"},
168 {"refs/heads/feature/x", h.side, "refs/heads/feature/x"},
169 {"v1", h.c1, "refs/tags/v1"},
170 {"v2", h.c2, "refs/tags/v2"},
171 {"v2-again", h.c2, "refs/tags/v2-again"},
172 {"refs/tags/v2", h.c2, "refs/tags/v2"},
173 {h.c2, h.c2, ""},
174 {h.c2[:7], h.c2, ""},
175 {h.c2[:12], h.c2, ""},
176 }
177 for _, tt := range tests {
178 t.Run(tt.rev, func(t *testing.T) {
179 got, err := g.ResolveRevision(ctx, h.name, rev(t, tt.rev))
180 require.NoError(t, err)
181 assert.Equal(t, tt.commit, got.CommitID.String())
182 assert.Equal(t, tt.ref, got.Ref)
183 })
184 }
185 for _, bad := range []string{"nope", "tree-tag", "deadbeefdeadbeef", "HEAD~1", h.c2[:6], "refs/heads", h.git("rev-parse", h.merge+"^{tree}")} {
186 t.Run("missing "+bad, func(t *testing.T) {
187 r, err := domain.ParseRevision(bad)
188 if err != nil {
189 return // not a revision at all, rejected before the reader
190 }
191 _, err = g.ResolveRevision(ctx, h.name, r)
192 assert.ErrorIs(t, err, domain.ErrNotFound)
193 })
194 }
195
196 t.Run("a branch wins over a tag of the same name", func(t *testing.T) {
197 h.git("branch", "both", h.c1)
198 h.git("tag", "both", h.c2)
199 got, err := g.ResolveRevision(ctx, h.name, rev(t, "both"))
200 require.NoError(t, err)
201 assert.Equal(t, h.c1, got.CommitID.String())
202 })
203}
204
205func TestResolveRefPath(t *testing.T) {
206 ctx := context.Background()
207 g, data := newReader(t, nil)
208 h := newHistory(t, data)
209
210 tests := []struct {
211 path string
212 name string
213 ref string
214 id string
215 rest string
216 }{
217 {"main", "main", "refs/heads/main", h.merge, ""},
218 {"main/d/f", "main", "refs/heads/main", h.merge, "d/f"},
219 {"feature/x", "feature/x", "refs/heads/feature/x", h.side, ""},
220 {"feature/x/d/f", "feature/x", "refs/heads/feature/x", h.side, "d/f"},
221 {"feature/x/", "feature/x", "refs/heads/feature/x", h.side, ""},
222 {"release/1.0/a.txt", "release/1.0", "refs/heads/release/1.0", h.c2, "a.txt"},
223 // The longest ref wins: the tag rel/1/x over the branch rel/1.
224 {"rel/1/x/d/f", "rel/1/x", "refs/tags/rel/1/x", h.c3, "d/f"},
225 {"rel/1/d/f", "rel/1", "refs/heads/rel/1", h.c1, "d/f"},
226 {"v2/we:ird*.txt", "v2", "refs/tags/v2", h.c2, "we:ird*.txt"},
227 {h.c1 + "/d", h.c1, "", h.c1, "d"},
228 {h.c1[:8] + "/d/f", h.c1[:8], "", h.c1, "d/f"},
229 }
230 for _, tt := range tests {
231 t.Run(tt.path, func(t *testing.T) {
232 got, err := g.ResolveRefPath(ctx, h.name, tt.path)
233 require.NoError(t, err)
234 assert.Equal(t, tt.name, got.Name)
235 assert.Equal(t, tt.ref, got.Ref)
236 assert.Equal(t, tt.id, got.CommitID.String())
237 assert.Equal(t, tt.rest, got.Rest.String())
238 })
239 }
240 for _, bad := range []string{"", "/", "nope", "nope/d/f", "a//b", "../x", "d/f"} {
241 t.Run("missing "+bad, func(t *testing.T) {
242 _, err := g.ResolveRefPath(ctx, h.name, bad)
243 assert.ErrorIs(t, err, domain.ErrNotFound)
244 })
245 }
246 _, err := g.ResolveRefPath(ctx, name(t, "nope"), "main")
247 assert.ErrorIs(t, err, domain.ErrNotFound)
248}
249
250func TestGetTree(t *testing.T) {
251 ctx := context.Background()
252 g, data := newReader(t, nil)
253 h := newHistory(t, data)
254
255 t.Run("root at HEAD", func(t *testing.T) {
256 tree, err := g.GetTree(ctx, h.name, domain.Revision{}, domain.TreePath{}, false)
257 require.NoError(t, err)
258 assert.Equal(t, h.merge, tree.CommitID.String())
259 assert.Equal(t, h.git("rev-parse", h.merge+"^{tree}"), tree.ID.String())
260 type row struct {
261 name, path string
262 kind domain.EntryKind
263 mode uint32
264 size int64
265 }
266 var got []row
267 for _, e := range tree.Entries {
268 got = append(got, row{e.Name, e.Path, e.Kind, e.Mode, e.Size})
269 assert.Nil(t, e.LastCommit)
270 }
271 assert.Equal(t, []row{
272 {"d", "d", domain.EntryDirectory, 0o040000, 0},
273 {"b.txt", "b.txt", domain.EntryFile, 0o100644, 19},
274 {"bin", "bin", domain.EntryFile, 0o100644, 2},
275 {"empty", "empty", domain.EntryFile, 0o100644, 0},
276 {"main.txt", "main.txt", domain.EntryFile, 0o100644, 2},
277 {"n.txt", "n.txt", domain.EntrySymlink, 0o120000, 5},
278 {"side.txt", "side.txt", domain.EntryFile, 0o100644, 2},
279 {"we:ird*.txt", "we:ird*.txt", domain.EntryFile, 0o100644, 1},
280 }, got)
281 assert.False(t, tree.LastCommitsOmitted)
282 })
283
284 t.Run("subdirectory with an executable", func(t *testing.T) {
285 tree, err := g.GetTree(ctx, h.name, rev(t, "v2"), tpath(t, "d"), false)
286 require.NoError(t, err)
287 require.Len(t, tree.Entries, 2)
288 assert.Equal(t, "sub", tree.Entries[0].Name)
289 assert.Equal(t, "d/sub", tree.Entries[0].Path)
290 assert.Equal(t, domain.EntryExecutable, tree.Entries[1].Kind)
291 assert.Equal(t, uint32(0o100755), tree.Entries[1].Mode)
292 assert.Equal(t, "d/f", tree.Entries[1].Path)
293 assert.Equal(t, h.c2, tree.CommitID.String())
294 })
295
296 t.Run("errors", func(t *testing.T) {
297 _, err := g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "nope"), false)
298 assert.ErrorIs(t, err, domain.ErrNotFound)
299 _, err = g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "d/nope/x"), false)
300 assert.ErrorIs(t, err, domain.ErrNotFound)
301 _, err = g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "b.txt"), false)
302 assert.ErrorIs(t, err, domain.ErrNotDirectory)
303 _, err = g.GetTree(ctx, h.name, rev(t, "nope"), domain.TreePath{}, false)
304 assert.ErrorIs(t, err, domain.ErrNotFound)
305 })
306
307 t.Run("paths are literal", func(t *testing.T) {
308 tree, err := g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "d"), true)
309 require.NoError(t, err)
310 require.Len(t, tree.Entries, 2)
311 _, err = g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "*"), false)
312 assert.ErrorIs(t, err, domain.ErrNotFound)
313 })
314}
315
316func TestGetTreeLastCommit(t *testing.T) {
317 ctx := context.Background()
318 g, data := newReader(t, nil)
319 h := newHistory(t, data)
320
321 tree, err := g.GetTree(ctx, h.name, rev(t, "main"), domain.TreePath{}, true)
322 require.NoError(t, err)
323 got := map[string]string{}
324 for _, e := range tree.Entries {
325 require.NotNil(t, e.LastCommit, e.Path)
326 got[e.Path] = e.LastCommit.Summary
327 // The oracle: git log -1 for the same path.
328 assert.Equal(t, h.git("log", "-1", "--format=%H", h.merge, "--", e.Path), e.LastCommit.ID.String(), e.Path)
329 }
330 assert.Equal(t, "second", got["d"], "chmod of d/f is the last change of d")
331 assert.Equal(t, "second", got["b.txt"])
332 assert.Equal(t, "first", got["we:ird*.txt"])
333 assert.Equal(t, "third: type change", got["n.txt"])
334 assert.Equal(t, "side work", got["side.txt"])
335 assert.Equal(t, "Ada Lovelace", tree.Entries[0].LastCommit.Author.Name)
336}
337
338func TestGetTreeLastCommitBound(t *testing.T) {
339 ctx := context.Background()
340 g, data := newReader(t, nil)
341 f := newFixtureRepo(t, data, "wide")
342 n := domain.MaxLastCommitEntries + 5
343 for i := range n {
344 f.write("f"+strings.Repeat("0", 3-len(itoa(i)))+itoa(i), "x\n")
345 }
346 f.commit("wide")
347
348 tree, err := g.GetTree(ctx, f.name, domain.Revision{}, domain.TreePath{}, true)
349 require.NoError(t, err)
350 require.Len(t, tree.Entries, n)
351 assert.True(t, tree.LastCommitsOmitted)
352 with := 0
353 for _, e := range tree.Entries {
354 if e.LastCommit != nil {
355 with++
356 }
357 }
358 assert.Equal(t, domain.MaxLastCommitEntries, with)
359 assert.NotNil(t, tree.Entries[0].LastCommit)
360 assert.Nil(t, tree.Entries[n-1].LastCommit)
361
362 tree, err = g.GetTree(ctx, f.name, domain.Revision{}, domain.TreePath{}, false)
363 require.NoError(t, err)
364 assert.False(t, tree.LastCommitsOmitted)
365}
366
367func itoa(i int) string {
368 if i == 0 {
369 return "0"
370 }
371 var b []byte
372 for ; i > 0; i /= 10 {
373 b = append([]byte{byte('0' + i%10)}, b...)
374 }
375 return string(b)
376}
377
378func TestListCommits(t *testing.T) {
379 ctx := context.Background()
380 g, data := newReader(t, nil)
381 h := newHistory(t, data)
382
383 oracle := strings.Split(h.git("log", "--format=%H", h.merge), "\n")
384 require.Len(t, oracle, 6)
385
386 t.Run("all", func(t *testing.T) {
387 page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Limit: 100})
388 require.NoError(t, err)
389 assert.Equal(t, h.merge, page.Head.String())
390 assert.False(t, page.HasMore)
391 var ids []string
392 for _, c := range page.Commits {
393 ids = append(ids, c.ID.String())
394 }
395 assert.Equal(t, oracle, ids)
396 })
397
398 t.Run("pages", func(t *testing.T) {
399 var ids []string
400 skip := 0
401 for {
402 page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Skip: skip, Limit: 2})
403 require.NoError(t, err)
404 for _, c := range page.Commits {
405 ids = append(ids, c.ID.String())
406 }
407 skip += len(page.Commits)
408 if !page.HasMore {
409 break
410 }
411 require.Len(t, page.Commits, 2)
412 }
413 assert.Equal(t, oracle, ids)
414 })
415
416 t.Run("path", func(t *testing.T) {
417 page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Path: tpath(t, "d/f"), Limit: 10})
418 require.NoError(t, err)
419 var ids []string
420 for _, c := range page.Commits {
421 ids = append(ids, c.ID.String())
422 }
423 assert.Equal(t, strings.Split(h.git("log", "--format=%H", h.merge, "--", "d/f"), "\n"), ids)
424 assert.Len(t, ids, 2)
425
426 page, err = g.ListCommits(ctx, h.name, ports.CommitQuery{Path: tpath(t, "we:ird*.txt"), Limit: 10})
427 require.NoError(t, err)
428 require.Len(t, page.Commits, 1, "paths are literal")
429 page, err = g.ListCommits(ctx, h.name, ports.CommitQuery{Path: tpath(t, "*"), Limit: 10})
430 require.NoError(t, err)
431 assert.Empty(t, page.Commits)
432 })
433
434 t.Run("revision", func(t *testing.T) {
435 page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Revision: rev(t, "v2"), Limit: 10})
436 require.NoError(t, err)
437 require.Len(t, page.Commits, 2)
438 assert.Equal(t, h.c2, page.Head.String())
439 assert.Equal(t, h.c2, page.Commits[0].ID.String())
440 })
441
442 t.Run("metadata", func(t *testing.T) {
443 page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Revision: domain.RevisionOf(mustID(t, h.c1)), Limit: 10})
444 require.NoError(t, err)
445 require.Len(t, page.Commits, 1)
446 c := page.Commits[0]
447 assert.Empty(t, c.Parents)
448 assert.Equal(t, "first", c.Summary)
449 assert.Equal(t, "first\n\nbody of the first commit", c.Message)
450 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)
451 assert.Equal(t, "Grace Hopper", c.Committer.Name)
452 assert.Equal(t, "grace@example.org", c.Committer.Email)
453
454 page, err = g.ListCommits(ctx, h.name, ports.CommitQuery{Limit: 1})
455 require.NoError(t, err)
456 require.Len(t, page.Commits, 1)
457 assert.Equal(t, []string{h.git("rev-parse", h.merge+"^1"), h.side}, idStrings(page.Commits[0].Parents))
458 })
459
460 t.Run("bad queries", func(t *testing.T) {
461 for _, q := range []ports.CommitQuery{{Limit: 0}, {Limit: -1}, {Limit: ports.MaxCommitLimit + 1}, {Limit: 1, Skip: -1}} {
462 _, err := g.ListCommits(ctx, h.name, q)
463 assert.ErrorIs(t, err, domain.ErrInvalidPageSize, "%+v", q)
464 }
465 _, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Revision: rev(t, "nope"), Limit: 1})
466 assert.ErrorIs(t, err, domain.ErrNotFound)
467 _, err = g.ListCommits(ctx, name(t, "nope"), ports.CommitQuery{Limit: 1})
468 assert.ErrorIs(t, err, domain.ErrNotFound)
469 })
470}
471
472func mustID(t *testing.T, s string) domain.ObjectID {
473 t.Helper()
474 id, err := domain.ParseObjectID(s)
475 require.NoError(t, err)
476 return id
477}
478
479func idStrings(ids []domain.ObjectID) []string {
480 out := make([]string, len(ids))
481 for i, id := range ids {
482 out[i] = id.String()
483 }
484 return out
485}
486
487func TestOpenBlob(t *testing.T) {
488 ctx := context.Background()
489 g, data := newReader(t, nil)
490 h := newHistory(t, data)
491
492 read := func(t *testing.T, r string, p string) (domain.BlobInfo, string) {
493 t.Helper()
494 info, rc, err := g.OpenBlob(ctx, h.name, rev(t, r), tpath(t, p))
495 require.NoError(t, err)
496 body, err := io.ReadAll(rc)
497 require.NoError(t, err)
498 require.NoError(t, rc.Close())
499 return info, string(body)
500 }
501
502 info, body := read(t, "main", "b.txt")
503 assert.Equal(t, "one\ntwo\nthree\nfour\n", body)
504 assert.Equal(t, int64(19), info.Size)
505 assert.Equal(t, "b.txt", info.Path)
506 assert.Equal(t, uint32(0o100644), info.Mode)
507 assert.False(t, info.Binary)
508 assert.Equal(t, "text/plain", info.MediaType)
509 assert.Equal(t, h.git("rev-parse", "main:b.txt"), info.ID.String())
510
511 info, body = read(t, "main", "bin")
512 assert.Equal(t, "\x00\x02", body)
513 assert.True(t, info.Binary)
514 assert.Equal(t, "application/octet-stream", info.MediaType)
515
516 info, body = read(t, "main", "empty")
517 assert.Empty(t, body)
518 assert.Equal(t, int64(0), info.Size)
519 assert.False(t, info.Binary)
520
521 info, body = read(t, "main", "n.txt")
522 assert.Equal(t, "b.txt", body, "a symlink's content is its target")
523 assert.Equal(t, uint32(0o120000), info.Mode)
524
525 info, _ = read(t, "main", "d/f")
526 assert.Equal(t, uint32(0o100755), info.Mode)
527
528 info, _ = read(t, "v1", "a.txt")
529 assert.Equal(t, "text/plain", info.MediaType)
530
531 for p, want := range map[string]error{"d": domain.ErrNotFile, "nope": domain.ErrNotFound, "d/nope": domain.ErrNotFound, "": domain.ErrNotFile} {
532 _, rc, err := g.OpenBlob(ctx, h.name, domain.Revision{}, tpath(t, p))
533 assert.ErrorIs(t, err, want, p)
534 assert.Nil(t, rc)
535 }
536 _, _, err := g.OpenBlob(ctx, h.name, rev(t, "nope"), tpath(t, "b.txt"))
537 assert.ErrorIs(t, err, domain.ErrNotFound)
538}
539
540func TestOpenBlobMediaTypes(t *testing.T) {
541 ctx := context.Background()
542 g, data := newReader(t, nil)
543 f := newFixtureRepo(t, data, "media")
544 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")
545 f.writeBytes("logo.png", png).
546 writeBytes("noext", png).
547 write("page.html", "<!doctype html><html><body>hi</body></html>\n").
548 write("style.css", "a { color: red }\n").
549 write("data.json", `{"a":1}`+"\n").
550 write("notes", "plain words\n").
551 write("main.go", "package main\n").
552 writeBytes("blob.dat", []byte{0xff, 0xfe, 0, 1, 2}).
553 writeBytes("fake.txt", []byte{'a', 0, 'b'}).
554 write("latin", "caf\xe9\n")
555 f.commit("media")
556
557 want := map[string]struct {
558 media string
559 binary bool
560 }{
561 "logo.png": {"image/png", true},
562 "noext": {"image/png", true},
563 "page.html": {"text/html", false},
564 "style.css": {"text/css", false},
565 "data.json": {"application/json", false},
566 "notes": {"text/plain", false},
567 "main.go": {"text/plain", false},
568 "blob.dat": {"application/octet-stream", true},
569 "fake.txt": {"application/octet-stream", true},
570 "latin": {"text/plain", false},
571 }
572 for p, w := range want {
573 info, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, p))
574 require.NoError(t, err, p)
575 require.NoError(t, rc.Close())
576 assert.Equal(t, w.media, info.MediaType, p)
577 assert.Equal(t, w.binary, info.Binary, p)
578 }
579}
580
581func TestBinaryRuleLooksAtFirst8000Bytes(t *testing.T) {
582 ctx := context.Background()
583 g, data := newReader(t, nil)
584 f := newFixtureRepo(t, data, "sniff")
585 late := append([]byte(strings.Repeat("a", 8000)), 0) // NUL at offset 8000: not seen
586 early := append([]byte(strings.Repeat("a", 7999)), 0)
587 f.writeBytes("late", late).writeBytes("early", early)
588 // Over the in-memory threshold, so that the streaming path decides too.
589 f.writeBytes("biglate", append([]byte(strings.Repeat("a", 300<<10)), 0)).
590 writeBytes("bigearly", append([]byte(strings.Repeat("a", 7999)), append([]byte{0}, make([]byte, 300<<10)...)...))
591 f.commit("sniff")
592
593 for p, binary := range map[string]bool{"late": false, "early": true, "biglate": false, "bigearly": true} {
594 info, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, p))
595 require.NoError(t, err, p)
596 require.NoError(t, rc.Close())
597 assert.Equal(t, binary, info.Binary, p)
598 }
599}
600
601func TestOpenBlobLargeThroughStream(t *testing.T) {
602 ctx := context.Background()
603 g, data := newReader(t, nil)
604 f := newFixtureRepo(t, data, "large")
605 content := randomBytes(1<<20+17, 7)
606 f.writeBytes("big.bin", content).commit("big")
607
608 info, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, "big.bin"))
609 require.NoError(t, err)
610 assert.Equal(t, int64(len(content)), info.Size)
611 assert.Equal(t, 1, g.BlobStreams())
612 got, err := io.ReadAll(rc)
613 require.NoError(t, err)
614 assert.Equal(t, content, got)
615 require.NoError(t, rc.Close())
616 require.NoError(t, rc.Close(), "Close is idempotent")
617 assert.Equal(t, 0, g.BlobStreams())
618}
619
620func TestCancelledContext(t *testing.T) {
621 g, data := newReader(t, nil)
622 h := newHistory(t, data)
623
624 ctx, cancel := context.WithCancel(context.Background())
625 cancel()
626 _, err := g.ListRefs(ctx, h.name)
627 assert.ErrorIs(t, err, context.Canceled)
628 _, err = g.ResolveRevision(ctx, h.name, domain.Revision{})
629 assert.ErrorIs(t, err, context.Canceled)
630 _, err = g.GetCommit(ctx, h.name, domain.Revision{}, domain.DefaultDiffLimits())
631 assert.ErrorIs(t, err, context.Canceled)
632 _, err = g.ListCommits(ctx, h.name, ports.CommitQuery{Limit: 5})
633 assert.ErrorIs(t, err, context.Canceled)
634
635 // The adapter still works with a live context.
636 _, err = g.ResolveRevision(context.Background(), h.name, domain.Revision{})
637 assert.NoError(t, err)
638}
639
640func TestHostileRevisionAndPathStayLiteral(t *testing.T) {
641 ctx := context.Background()
642 g, data := newReader(t, nil)
643 h := newHistory(t, data)
644
645 // Revisions that look like options or git revision syntax are rejected by
646 // the domain; ones that pass it are plain names for the reader.
647 for _, s := range []string{"--output=/tmp/x", "-h", "main^", "main~1", "main:b.txt", "@{u}", "HEAD@{1}"} {
648 _, err := domain.ParseRevision(s)
649 assert.ErrorIs(t, err, domain.ErrInvalidRevision, s)
650 }
651 _, err := g.ResolveRevision(ctx, h.name, rev(t, "HEAD.x"))
652 assert.ErrorIs(t, err, domain.ErrNotFound)
653 _, err = g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, ":(top)d"), false)
654 assert.ErrorIs(t, err, domain.ErrNotFound)
655 var pe *domain.InvalidNameError
656 assert.False(t, errors.As(err, &pe))
657}
658
659// A full object id is an object id before it is a ref: a branch or a tag
660// named like a commit must not redirect a link to that commit.
661func TestResolveRevisionObjectIDBeatsRefs(t *testing.T) {
662 ctx := context.Background()
663 g, data := newReader(t, nil)
664 h := newHistory(t, data)
665
666 // Git itself warns about these refs and prefers the object id.
667 h.git("-c", "advice.objectNameWarning=false", "branch", h.c1, h.c3)
668 h.git("-c", "advice.objectNameWarning=false", "tag", h.c2, h.c3)
669
670 got, err := g.ResolveRevision(ctx, h.name, rev(t, h.c1))
671 require.NoError(t, err)
672 assert.Equal(t, h.c1, got.CommitID.String(), "the commit, not the branch of the same name")
673 assert.Empty(t, got.Ref)
674
675 got, err = g.ResolveRevision(ctx, h.name, rev(t, h.c2))
676 require.NoError(t, err)
677 assert.Equal(t, h.c2, got.CommitID.String(), "the commit, not the tag of the same name")
678
679 // An abbreviation is still a name first, as before.
680 h.git("branch", "abc1234", h.c3)
681 if !strings.HasPrefix(h.c1, "abc1234") {
682 got, err = g.ResolveRevision(ctx, h.name, rev(t, "abc1234"))
683 require.NoError(t, err)
684 assert.Equal(t, h.c3, got.CommitID.String())
685 assert.Equal(t, "refs/heads/abc1234", got.Ref)
686 }
687
688 // The ref is used when no object has that id.
689 ghost := strings.Repeat("ab", 20)
690 h.git("-c", "advice.objectNameWarning=false", "branch", ghost, h.c3)
691 got, err = g.ResolveRevision(ctx, h.name, rev(t, ghost))
692 require.NoError(t, err)
693 assert.Equal(t, h.c3, got.CommitID.String())
694 assert.Equal(t, "refs/heads/"+ghost, got.Ref)
695}
696
697func TestGetTreeIsBounded(t *testing.T) {
698 ctx := context.Background()
699 g, data := newReader(t, nil)
700 f := newFixtureRepo(t, data, "wide")
701 n := domain.MaxTreeEntries + 7
702 for i := range n {
703 f.write(fmt.Sprintf("d/f%05d", i), "x")
704 }
705 f.commit("wide")
706
707 tree, err := g.GetTree(ctx, f.name, domain.Revision{}, tpath(t, "d"), false)
708 require.NoError(t, err)
709 assert.True(t, tree.Truncated)
710 require.Len(t, tree.Entries, domain.MaxTreeEntries)
711 assert.Equal(t, "f00000", tree.Entries[0].Name, "the first entries in order")
712 assert.Equal(t, fmt.Sprintf("f%05d", domain.MaxTreeEntries-1), tree.Entries[domain.MaxTreeEntries-1].Name)
713
714 small, err := g.GetTree(ctx, f.name, domain.Revision{}, domain.TreePath{}, false)
715 require.NoError(t, err)
716 assert.False(t, small.Truncated)
717}