detached 0d9c2a3

feat(gitcli): read refs, trees, blobs, history and diffs with a cat-file pool

Add the RepoReader port and its git CLI adapter: a long-lived
cat-file --batch-command per repository (idle timeout, serialized access,
restart on error, bounded pool, closed with Close), for-each-ref,
ls-tree -z -l, log -z, diff-tree --raw --numstat -p parsed into hunks with
file, line and byte limits, streamed blobs, and ResolveRefPath for refs
containing slashes.
commit
0d9c2a3054d834841ce307f054d7d1ecb9223f7d
author
brice · · 2026-10-10 08:25 UTC
committer
brice · · 2026-10-10 08:25 UTC
parent
1b7c75a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +4314 -3
M .mockery.yml +1 -0
@@ -14,3 +14,4 @@ packages:
14 TokenStore:14 TokenStore:
15 GitBackend:15 GitBackend:
16 EventBus:16 EventBus:
17+ RepoReader:
A internal/adapters/gitcli/blob.go +212 -0
@@ -0,0 +1,212 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "bufio"
7+ "bytes"
8+ "context"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "net/http"
13+ "os"
14+ "os/exec"
15+ "path"
16+ "strings"
17+ "sync"
18+
19+ "bnema.dev/ermite/internal/domain"
20+)
21+
22+// smallBlob is the largest blob read whole, through the long-lived cat-file
23+// process. A larger one streams from a process of its own, so that a slow
24+// reader never holds the shared process.
25+const smallBlob = 256 << 10
26+
27+// OpenBlob describes the file at path and returns its content as a stream.
28+//
29+// A blob up to 256 KiB is read through the repository's long-lived cat-file
30+// process. A larger one gets a `git cat-file blob` process that lives until
31+// the reader is closed, or ctx ends, or the adapter is closed; the memory
32+// used is a few buffers, whatever the size. The reader fails with
33+// io.ErrUnexpectedEOF if git stops before the announced size.
34+//
35+//hexcheck:ignore suspicious-business-logic-in-adapter builds domain values from git output; size and binary rules are the port contract
36+func (g *Git) OpenBlob(ctx context.Context, repo domain.RepoName, rev domain.Revision, p domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
37+ dir, err := g.repoDir(repo)
38+ if err != nil {
39+ return domain.BlobInfo{}, nil, err
40+ }
41+ if p.IsRoot() {
42+ return domain.BlobInfo{}, nil, fmt.Errorf("blob of %q: %w", repo, domain.ErrNotFile)
43+ }
44+ var (
45+ h catInfo
46+ data []byte
47+ )
48+ err = g.cat.do(ctx, dir, func(c *catConn) error {
49+ h, data = catInfo{}, nil
50+ res, err := resolve(c, rev)
51+ if err != nil {
52+ return err
53+ }
54+ h, err = c.info(res.CommitID.String() + ":" + p.String())
55+ if notFound(err) {
56+ return fmt.Errorf("path %q: %w", p, domain.ErrNotFound)
57+ }
58+ if err != nil {
59+ return err
60+ }
61+ if h.Type != "blob" || h.Size > smallBlob {
62+ return nil
63+ }
64+ _, data, err = c.contents(h.ID, smallBlob)
65+ return err
66+ })
67+ if err != nil {
68+ return domain.BlobInfo{}, nil, fmt.Errorf("blob %q of %q: %w", p, repo, err)
69+ }
70+ if h.Type != "blob" {
71+ return domain.BlobInfo{}, nil, fmt.Errorf("blob %q of %q: %w", p, repo, domain.ErrNotFile)
72+ }
73+ id, err := domain.ParseObjectID(h.ID)
74+ if err != nil {
75+ return domain.BlobInfo{}, nil, err
76+ }
77+ info := domain.BlobInfo{ID: id, Path: p.String(), Size: h.Size, Mode: h.Mode}
78+
79+ if data != nil || h.Size == 0 {
80+ fillMedia(&info, data)
81+ return info, io.NopCloser(bytes.NewReader(data)), nil
82+ }
83+
84+ s, err := g.startBlobStream(ctx, dir, id, h.Size)
85+ if err != nil {
86+ return domain.BlobInfo{}, nil, fmt.Errorf("blob %q of %q: %w", p, repo, err)
87+ }
88+ head, err := s.r.Peek(min(int(h.Size), domain.BinarySniffLen))
89+ if err != nil && !errors.Is(err, bufio.ErrBufferFull) {
90+ _ = s.Close()
91+ return domain.BlobInfo{}, nil, fmt.Errorf("blob %q of %q: %w", p, repo, ctxOr(ctx, err))
92+ }
93+ fillMedia(&info, head)
94+ return info, s, nil
95+}
96+
97+func ctxOr(ctx context.Context, err error) error {
98+ if cerr := ctx.Err(); cerr != nil {
99+ return cerr
100+ }
101+ return err
102+}
103+
104+// fillMedia sets Binary and MediaType from the first bytes of the content.
105+func fillMedia(info *domain.BlobInfo, content []byte) {
106+ head := content[:min(len(content), domain.BinarySniffLen)]
107+ info.Binary = bytes.IndexByte(head, 0) >= 0
108+ info.MediaType = mediaType(info.Path, head, info.Binary)
109+}
110+
111+// textTypes maps the extensions whose media type is more precise than what
112+// the content shows. It is fixed here so that the answer does not depend on the
113+// MIME tables of the host.
114+var textTypes = map[string]string{
115+ ".html": "text/html", ".htm": "text/html", ".css": "text/css",
116+ ".js": "text/javascript", ".mjs": "text/javascript",
117+ ".json": "application/json", ".xml": "application/xml",
118+ ".svg": "image/svg+xml", ".md": "text/markdown", ".csv": "text/csv",
119+}
120+
121+// mediaType guesses a media type without parameters. What the content shows
122+// (images, archives, PDF…) wins; for plain text the file name may refine it
123+// from textTypes, but a name never makes binary content text.
124+func mediaType(name string, head []byte, binary bool) string {
125+ sniffed, _, _ := strings.Cut(http.DetectContentType(head), ";")
126+ switch {
127+ case binary && strings.HasPrefix(sniffed, "text/"):
128+ return "application/octet-stream"
129+ case sniffed != "text/plain" && sniffed != "text/xml" && sniffed != "application/octet-stream":
130+ return sniffed
131+ case binary:
132+ return "application/octet-stream"
133+ }
134+ if t, ok := textTypes[strings.ToLower(path.Ext(name))]; ok {
135+ return t
136+ }
137+ return "text/plain"
138+}
139+
140+// blobStream is the content of a blob read from its own git process.
141+type blobStream struct {
142+ pool *catPool
143+ cmd *exec.Cmd
144+ pipe *os.File
145+ r *bufio.Reader
146+ size int64
147+ read int64
148+ stop func() bool // detaches the ctx watcher
149+
150+ done chan struct{} // closed when the process has been collected
151+ once sync.Once
152+}
153+
154+func (g *Git) startBlobStream(ctx context.Context, dir string, id domain.ObjectID, size int64) (*blobStream, error) {
155+ pr, pw, err := os.Pipe()
156+ if err != nil {
157+ return nil, fmt.Errorf("start git cat-file: %w", err)
158+ }
159+ cmd := exec.Command(g.bin, readArgs(dir, "cat-file", "blob", id.String())...)
160+ cmd.Env = g.env
161+ cmd.Dir = g.dataDir
162+ cmd.Stdout = pw
163+ if err := cmd.Start(); err != nil {
164+ _ = pr.Close()
165+ _ = pw.Close()
166+ return nil, fmt.Errorf("start git cat-file: %w", err)
167+ }
168+ _ = pw.Close()
169+ s := &blobStream{
170+ pool: g.cat, cmd: cmd, pipe: pr, r: bufio.NewReaderSize(pr, 64*1024),
171+ size: size, done: make(chan struct{}),
172+ }
173+ go s.reap()
174+ if err := g.cat.addStream(s); err != nil {
175+ _ = s.Close()
176+ return nil, err
177+ }
178+ s.stop = context.AfterFunc(ctx, s.kill)
179+ return s, nil
180+}
181+
182+func (s *blobStream) reap() {
183+ _ = s.cmd.Wait()
184+ close(s.done)
185+}
186+
187+func (s *blobStream) kill() { _ = s.cmd.Process.Kill() }
188+
189+// Read implements io.Reader.
190+func (s *blobStream) Read(p []byte) (int, error) {
191+ n, err := s.r.Read(p)
192+ s.read += int64(n)
193+ if errors.Is(err, io.EOF) && s.read < s.size {
194+ return n, io.ErrUnexpectedEOF
195+ }
196+ return n, err
197+}
198+
199+// Close stops the process, waits for it and releases the pipe. It is safe to
200+// call more than once.
201+func (s *blobStream) Close() error {
202+ s.once.Do(func() {
203+ if s.stop != nil {
204+ s.stop()
205+ }
206+ s.kill()
207+ <-s.done
208+ _ = s.pipe.Close()
209+ s.pool.removeStream(s)
210+ })
211+ return nil
212+}
A internal/adapters/gitcli/catfile.go +453 -0
@@ -0,0 +1,453 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "bufio"
7+ "bytes"
8+ "context"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "os/exec"
13+ "path/filepath"
14+ "strconv"
15+ "strings"
16+ "sync"
17+ "time"
18+
19+ "github.com/bnema/zerowrap"
20+)
21+
22+// ErrClosed is returned by reads after Close.
23+var ErrClosed = errors.New("gitcli: reader is closed")
24+
25+// errObjectMissing is what a cat-file lookup answers for an unknown name.
26+var errObjectMissing = errors.New("object missing")
27+
28+// procError is an I/O or protocol failure of a cat-file process: the process
29+// is dropped and the request may be retried on a fresh one.
30+type procError struct{ err error }
31+
32+func (e *procError) Error() string { return "git cat-file: " + e.err.Error() }
33+func (e *procError) Unwrap() error { return e.err }
34+
35+// Defaults of the process pool.
36+const (
37+ defaultIdleTimeout = 60 * time.Second
38+ defaultMaxProcesses = 16
39+)
40+
41+// catPool keeps one long-lived `git cat-file --batch-command` per repository.
42+//
43+// - Access to one process is serialized: one request in flight at a time.
44+// - A process nobody used for the idle timeout is killed.
45+// - At most max processes are kept. Starting another one first evicts the
46+// least recently used idle process; a process that is in use is never
47+// evicted, so the bound is soft when more than max repositories are read
48+// at the same moment.
49+// - A process that fails, or whose request was cancelled (its output is then
50+// in an unknown state), is killed and dropped; the next request starts a
51+// fresh one, and a request that hit a dead process is retried once.
52+type catPool struct {
53+ bin string
54+ env []string
55+ idle time.Duration
56+ max int
57+
58+ mu sync.Mutex
59+ procs map[string]*catProc
60+ streams map[*blobStream]struct{} // one-shot blob processes, see OpenBlob
61+ closed bool
62+ wg sync.WaitGroup // reapers of started processes
63+}
64+
65+// catProc is one cat-file process. Fields below the line are guarded by
66+// catPool.mu.
67+type catProc struct {
68+ dir string
69+ log zerowrap.Logger // of the request that started the process
70+ cmd *exec.Cmd
71+ in io.WriteCloser
72+ out *bufio.Reader
73+ stderr *tailBuffer
74+ sem chan struct{} // capacity 1: the request in flight
75+
76+ broken bool // set by the holder of sem on a protocol error
77+
78+ users int // requests waiting for or holding sem
79+ lastUsed time.Time
80+ timer *time.Timer
81+ gen uint64
82+ dead bool
83+}
84+
85+func newCatPool(bin string, env []string, idle time.Duration, maxProcs int) *catPool {
86+ if idle <= 0 {
87+ idle = defaultIdleTimeout
88+ }
89+ if maxProcs <= 0 {
90+ maxProcs = defaultMaxProcesses
91+ }
92+ return &catPool{
93+ bin: bin, env: env, idle: idle, max: maxProcs,
94+ procs: make(map[string]*catProc),
95+ streams: make(map[*blobStream]struct{}),
96+ }
97+}
98+
99+// do runs fn with exclusive use of the cat-file process of the repository in
100+// dir, starting it if needed. fn must not keep the connection after returning.
101+func (p *catPool) do(ctx context.Context, dir string, fn func(*catConn) error) error {
102+ for attempt := 0; ; attempt++ {
103+ cp, err := p.acquire(ctx, dir)
104+ if err != nil {
105+ return err
106+ }
107+ err = p.run(ctx, cp, fn)
108+ var pe *procError
109+ if !errors.As(err, &pe) {
110+ return err
111+ }
112+ switch {
113+ case p.isClosed():
114+ return ErrClosed
115+ case attempt == 0 && ctx.Err() == nil:
116+ continue // the process died while idle: retry once on a fresh one
117+ }
118+ return err
119+ }
120+}
121+
122+func (p *catPool) run(ctx context.Context, cp *catProc, fn func(*catConn) error) error {
123+ // A cancelled request leaves the process mid-answer: kill it.
124+ stop := context.AfterFunc(ctx, func() {
125+ p.mu.Lock()
126+ p.dropLocked(cp)
127+ p.mu.Unlock()
128+ })
129+ err := fn(&catConn{cp: cp})
130+ cancelled := !stop()
131+ if cancelled && err != nil {
132+ err = ctx.Err()
133+ }
134+ p.release(cp, cp.broken || cancelled)
135+ <-cp.sem
136+ return err
137+}
138+
139+func (p *catPool) isClosed() bool {
140+ p.mu.Lock()
141+ defer p.mu.Unlock()
142+ return p.closed
143+}
144+
145+func (p *catPool) acquire(ctx context.Context, dir string) (*catProc, error) {
146+ for {
147+ p.mu.Lock()
148+ if p.closed {
149+ p.mu.Unlock()
150+ return nil, ErrClosed
151+ }
152+ cp := p.procs[dir]
153+ if cp == nil {
154+ var err error
155+ if cp, err = p.startLocked(ctx, dir); err != nil {
156+ p.mu.Unlock()
157+ return nil, err
158+ }
159+ }
160+ cp.users++
161+ cp.gen++
162+ if cp.timer != nil {
163+ cp.timer.Stop()
164+ cp.timer = nil
165+ }
166+ p.mu.Unlock()
167+
168+ select {
169+ case cp.sem <- struct{}{}:
170+ case <-ctx.Done():
171+ p.release(cp, false)
172+ return nil, ctx.Err()
173+ }
174+ p.mu.Lock()
175+ dead := cp.dead
176+ p.mu.Unlock()
177+ if !dead {
178+ return cp, nil
179+ }
180+ // Dropped while we waited for our turn.
181+ <-cp.sem
182+ p.release(cp, false)
183+ }
184+}
185+
186+// release ends one use of cp. A broken process is dropped; an unused one
187+// starts its idle countdown.
188+func (p *catPool) release(cp *catProc, broken bool) {
189+ p.mu.Lock()
190+ defer p.mu.Unlock()
191+ cp.users--
192+ if broken {
193+ p.dropLocked(cp)
194+ return
195+ }
196+ if cp.users == 0 && !cp.dead && !p.closed {
197+ cp.lastUsed = time.Now()
198+ gen := cp.gen
199+ cp.timer = time.AfterFunc(p.idle, func() { p.expire(cp, gen) })
200+ }
201+}
202+
203+// expire kills cp if it has not been used since the timer was armed.
204+func (p *catPool) expire(cp *catProc, gen uint64) {
205+ p.mu.Lock()
206+ defer p.mu.Unlock()
207+ if cp.gen != gen || cp.users != 0 || cp.dead {
208+ return
209+ }
210+ cp.log.Debug().Str("dir", cp.dir).Msg("cat-file idle, stopping")
211+ p.dropLocked(cp)
212+}
213+
214+// dropLocked removes cp from the pool and kills it. The reaper goroutine
215+// collects the process.
216+func (p *catPool) dropLocked(cp *catProc) {
217+ if cp.dead {
218+ return
219+ }
220+ cp.dead = true
221+ if p.procs[cp.dir] == cp {
222+ delete(p.procs, cp.dir)
223+ }
224+ if cp.timer != nil {
225+ cp.timer.Stop()
226+ cp.timer = nil
227+ }
228+ _ = cp.in.Close()
229+ _ = cp.cmd.Process.Kill()
230+}
231+
232+func (p *catPool) startLocked(ctx context.Context, dir string) (*catProc, error) {
233+ for len(p.procs) >= p.max {
234+ var victim *catProc
235+ for _, c := range p.procs {
236+ if c.users == 0 && (victim == nil || c.lastUsed.Before(victim.lastUsed)) {
237+ victim = c
238+ }
239+ }
240+ if victim == nil {
241+ break // all in use: exceed the bound rather than block
242+ }
243+ victim.log.Debug().Str("dir", victim.dir).Msg("cat-file pool full, evicting the least recently used")
244+ p.dropLocked(victim)
245+ }
246+ cmd := exec.Command(p.bin, "--git-dir="+dir, "--no-replace-objects", "--no-optional-locks", "cat-file", "--batch-command="+catFormat, "-Z")
247+ cmd.Env = p.env
248+ cmd.Dir = filepath.Dir(dir)
249+ stderr := &tailBuffer{max: 4096}
250+ cmd.Stderr = stderr
251+ in, err := cmd.StdinPipe()
252+ if err != nil {
253+ return nil, fmt.Errorf("start git cat-file: %w", err)
254+ }
255+ stdout, err := cmd.StdoutPipe()
256+ if err != nil {
257+ return nil, fmt.Errorf("start git cat-file: %w", err)
258+ }
259+ if err := cmd.Start(); err != nil {
260+ return nil, fmt.Errorf("start git cat-file: %w", err)
261+ }
262+ cp := &catProc{
263+ dir: dir, log: zerowrap.FromCtx(ctx), cmd: cmd, in: in, out: bufio.NewReaderSize(stdout, 64*1024),
264+ stderr: stderr, sem: make(chan struct{}, 1), lastUsed: time.Now(),
265+ }
266+ p.procs[dir] = cp
267+ p.wg.Add(1)
268+ go func() {
269+ defer p.wg.Done()
270+ _ = cmd.Wait()
271+ }()
272+ cp.log.Debug().Str("dir", dir).Int("pid", cmd.Process.Pid).Msg("cat-file started")
273+ return cp, nil
274+}
275+
276+// close kills every process and waits, until ctx ends, for them to be
277+// collected. Later requests fail with ErrClosed.
278+func (p *catPool) close(ctx context.Context) error {
279+ p.mu.Lock()
280+ p.closed = true
281+ for _, cp := range p.procs {
282+ p.dropLocked(cp)
283+ }
284+ streams := make([]*blobStream, 0, len(p.streams))
285+ for s := range p.streams {
286+ streams = append(streams, s)
287+ }
288+ p.mu.Unlock()
289+ for _, s := range streams {
290+ s.kill()
291+ }
292+
293+ done := make(chan struct{})
294+ go func() { p.wg.Wait(); close(done) }()
295+ select {
296+ case <-done:
297+ return nil
298+ case <-ctx.Done():
299+ return fmt.Errorf("wait for git cat-file processes: %w", ctx.Err())
300+ }
301+}
302+
303+// tailBuffer keeps the first max bytes written to it, for error messages.
304+type tailBuffer struct {
305+ mu sync.Mutex
306+ max int
307+ buf bytes.Buffer
308+}
309+
310+func (b *tailBuffer) Write(p []byte) (int, error) {
311+ b.mu.Lock()
312+ defer b.mu.Unlock()
313+ if room := b.max - b.buf.Len(); room > 0 {
314+ b.buf.Write(p[:min(room, len(p))])
315+ }
316+ return len(p), nil
317+}
318+
319+func (b *tailBuffer) reset() {
320+ b.mu.Lock()
321+ b.buf.Reset()
322+ b.mu.Unlock()
323+}
324+
325+func (b *tailBuffer) String() string {
326+ b.mu.Lock()
327+ defer b.mu.Unlock()
328+ s := strings.TrimSpace(b.buf.String())
329+ if len(s) > 512 {
330+ s = s[:512] + "…"
331+ }
332+ return s
333+}
334+
335+// catInfo is the header git prints for an object.
336+type catInfo struct {
337+ ID string
338+ Type string
339+ Size int64
340+ // Mode is the Git mode of a "<tree-ish>:<path>" object, 0 otherwise.
341+ Mode uint32
342+}
343+
344+// catFormat is the answer format of `info` and `contents`.
345+const catFormat = "%(objectname) %(objecttype) %(objectsize) %(objectmode)"
346+
347+// catConn is exclusive use of one cat-file process. Names are objects
348+// expressions such as "<commit>:<path>" or "<ref>^{commit}"; they must not
349+// contain NUL or line breaks.
350+type catConn struct{ cp *catProc }
351+
352+func (c *catConn) fail(err error) error {
353+ c.cp.broken = true
354+ if s := c.cp.stderr.String(); s != "" {
355+ err = fmt.Errorf("%w: %s", err, s)
356+ }
357+ return &procError{err: err}
358+}
359+
360+func (c *catConn) send(cmd, name string) error {
361+ if strings.ContainsAny(name, "\x00\n") {
362+ return fmt.Errorf("invalid object name %q", name)
363+ }
364+ c.cp.stderr.reset()
365+ if _, err := io.WriteString(c.cp.in, cmd+" "+name+"\x00"); err != nil {
366+ return c.fail(err)
367+ }
368+ return nil
369+}
370+
371+// header reads the answer line of a request: the object header, or
372+// errObjectMissing.
373+func (c *catConn) header(name string) (catInfo, error) {
374+ line, err := c.cp.out.ReadString(0)
375+ if err != nil {
376+ return catInfo{}, c.fail(err)
377+ }
378+ line = strings.TrimSuffix(line, "\x00")
379+ if strings.HasSuffix(line, " missing") || strings.HasSuffix(line, " ambiguous") {
380+ return catInfo{}, fmt.Errorf("%w: %s", errObjectMissing, name)
381+ }
382+ f := strings.Split(line, " ")
383+ if len(f) != 4 || len(f[0]) != 40 {
384+ return catInfo{}, c.fail(fmt.Errorf("unexpected answer %q", truncateText(line)))
385+ }
386+ size, err := strconv.ParseInt(f[2], 10, 64)
387+ if err != nil || size < 0 {
388+ return catInfo{}, c.fail(fmt.Errorf("unexpected size in %q", truncateText(line)))
389+ }
390+ var mode uint64
391+ if f[3] != "" {
392+ if mode, err = strconv.ParseUint(f[3], 8, 32); err != nil {
393+ return catInfo{}, c.fail(fmt.Errorf("unexpected mode in %q", truncateText(line)))
394+ }
395+ }
396+ return catInfo{ID: f[0], Type: f[1], Size: size, Mode: uint32(mode)}, nil
397+}
398+
399+// info returns the header of an object without its content.
400+func (c *catConn) info(name string) (catInfo, error) {
401+ if err := c.send("info", name); err != nil {
402+ return catInfo{}, err
403+ }
404+ return c.header(name)
405+}
406+
407+// contents returns an object with its content. It fails if the object is
408+// larger than max, after which the process is dropped.
409+func (c *catConn) contents(name string, max int64) (catInfo, []byte, error) {
410+ if err := c.send("contents", name); err != nil {
411+ return catInfo{}, nil, err
412+ }
413+ h, err := c.header(name)
414+ if err != nil {
415+ return catInfo{}, nil, err
416+ }
417+ if h.Size > max {
418+ c.cp.broken = true
419+ return catInfo{}, nil, fmt.Errorf("object %s is %d bytes, more than %d", name, h.Size, max)
420+ }
421+ data := make([]byte, h.Size)
422+ if _, err := io.ReadFull(c.cp.out, data); err != nil {
423+ return catInfo{}, nil, c.fail(err)
424+ }
425+ if b, err := c.cp.out.ReadByte(); err != nil || b != 0 {
426+ return catInfo{}, nil, c.fail(fmt.Errorf("missing terminator after %s", name))
427+ }
428+ return h, data, nil
429+}
430+
431+func truncateText(s string) string {
432+ if len(s) > 80 {
433+ return s[:80] + "…"
434+ }
435+ return s
436+}
437+
438+// addStream registers a one-shot blob process so that close kills it.
439+func (p *catPool) addStream(s *blobStream) error {
440+ p.mu.Lock()
441+ defer p.mu.Unlock()
442+ if p.closed {
443+ return ErrClosed
444+ }
445+ p.streams[s] = struct{}{}
446+ return nil
447+}
448+
449+func (p *catPool) removeStream(s *blobStream) {
450+ p.mu.Lock()
451+ delete(p.streams, s)
452+ p.mu.Unlock()
453+}
A internal/adapters/gitcli/diff.go +525 -0
@@ -0,0 +1,525 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "bufio"
7+ "bytes"
8+ "context"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "strconv"
13+ "strings"
14+
15+ "bnema.dev/ermite/internal/domain"
16+)
17+
18+// emptyTreeID is the SHA-1 id of the empty tree, known to Git without the
19+// object being stored. It is the "parent" of a root commit.
20+const emptyTreeID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
21+
22+// maxDiffLine bounds the text of one diff line; a file with a longer line is
23+// reported truncated rather than cut, which also bounds the memory of a read.
24+const maxDiffLine = 64 << 10
25+
26+// GetCommit returns a commit with its diff against its first parent (the empty
27+// tree for a root commit), read in one pass of
28+//
29+// git diff-tree -r -M -z --raw --numstat -p <parent> <commit>
30+//
31+// which prints the changed files, then their line counts, then the patch.
32+// Statistics come from the line counts, binary files from their "-" counts.
33+// The patch is parsed as it streams: nothing beyond the limits is kept, and
34+// git is stopped once nothing more is needed.
35+//
36+//hexcheck:ignore suspicious-business-logic-in-adapter parses git diff-tree output into the domain diff, applying the limits the port passes in
37+func (g *Git) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) {
38+ dir, err := g.repoDir(repo)
39+ if err != nil {
40+ return domain.CommitDetail{}, err
41+ }
42+ var res domain.ResolvedRevision
43+ err = g.cat.do(ctx, dir, func(c *catConn) (err error) {
44+ res, err = resolve(c, rev)
45+ return err
46+ })
47+ if err != nil {
48+ return domain.CommitDetail{}, fmt.Errorf("commit %q of %q: %w", rev, repo, err)
49+ }
50+ commit, err := g.lastCommit(ctx, dir, res.CommitID, "")
51+ if err != nil {
52+ return domain.CommitDetail{}, fmt.Errorf("commit %q of %q: %w", rev, repo, err)
53+ }
54+ base := emptyTreeID
55+ if len(commit.Parents) > 0 {
56+ base = commit.Parents[0].String()
57+ }
58+
59+ detail := domain.CommitDetail{Commit: commit}
60+ args := readArgs(dir, "diff-tree", "-r", "-M", "-z", "--raw", "--numstat", "-p", "-U3",
61+ "--no-ext-diff", "--no-textconv", "--no-color", "--end-of-options", base, commit.ID.String())
62+ err = g.pipe(ctx, args, func(r *bufio.Reader) error {
63+ var err error
64+ detail.Diff, detail.Stats, err = readDiff(r, limits)
65+ return err
66+ })
67+ if err != nil {
68+ return domain.CommitDetail{}, fmt.Errorf("diff of %s in %q: %w", commit.ID, repo, err)
69+ }
70+ return detail, nil
71+}
72+
73+// rawEntry is one file of the diff: a `--raw` record and its `--numstat`.
74+type rawEntry struct {
75+ file domain.FileDiff
76+ typeChange bool
77+ added, del int
78+}
79+
80+// readDiff parses the output of diff-tree --raw --numstat -p.
81+//
82+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values, applying the limits the port passes in
83+func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) {
84+ entries, err := readRaw(r)
85+ if err != nil {
86+ return domain.Diff{}, domain.DiffStats{}, err
87+ }
88+ if err := readNumstat(r, entries); err != nil {
89+ return domain.Diff{}, domain.DiffStats{}, err
90+ }
91+ var stats domain.DiffStats
92+ stats.FilesChanged = len(entries)
93+ for i := range entries {
94+ stats.Additions += entries[i].added
95+ stats.Deletions += entries[i].del
96+ }
97+ var diff domain.Diff
98+ if len(entries) > limits.MaxFiles {
99+ entries = entries[:limits.MaxFiles]
100+ diff.Truncated = true
101+ }
102+
103+ // The patch holds one section per file, two for a type change, in the
104+ // order of the entries. Entries without lines to show (binary, pure
105+ // rename, mode change) have a section without hunks.
106+ lastRead := -1
107+ for i := range entries {
108+ if wantsHunks(&entries[i], limits, limits.MaxTotalLines, limits.MaxTotalBytes) {
109+ lastRead = i
110+ }
111+ }
112+ if lastRead >= 0 {
113+ // A single NUL separates the numstat from the patch.
114+ if b, err := r.Peek(1); err == nil && b[0] == 0 {
115+ _, _ = r.Discard(1)
116+ }
117+ }
118+ pr := &patchReader{lr: newLineReader(r, maxDiffLine), bytesLeft: limits.MaxTotalBytes}
119+ budget := limits.MaxTotalLines
120+ diff.Files = make([]domain.FileDiff, 0, len(entries))
121+ for i := range entries {
122+ e := &entries[i]
123+ sections := 1
124+ if e.typeChange {
125+ sections = 2
126+ }
127+ switch {
128+ case !changedLines(e):
129+ // Nothing to show.
130+ case !wantsHunks(e, limits, budget, pr.bytesLeft):
131+ e.file.Truncated = true
132+ default:
133+ bytesBefore := pr.bytesLeft
134+ hunks, lines, truncated, err := pr.file(sections, min(limits.MaxFileLines, budget))
135+ if err != nil {
136+ return domain.Diff{}, domain.DiffStats{}, err
137+ }
138+ e.file.Hunks, e.file.Truncated = hunks, truncated
139+ if truncated {
140+ pr.bytesLeft = bytesBefore // nothing of this file is kept
141+ } else {
142+ budget -= lines
143+ }
144+ sections = 0
145+ }
146+ if sections > 0 && i < lastRead {
147+ if err := pr.skip(sections); err != nil {
148+ return domain.Diff{}, domain.DiffStats{}, err
149+ }
150+ }
151+ diff.Truncated = diff.Truncated || e.file.Truncated
152+ diff.Files = append(diff.Files, e.file)
153+ }
154+ if lastRead < len(entries)-1 {
155+ // What follows has no hunks to read: stop git instead of draining it.
156+ return diff, stats, errStop
157+ }
158+ return diff, stats, nil
159+}
160+
161+// changedLines reports whether the file has text lines in its patch.
162+func changedLines(e *rawEntry) bool { return !e.file.Binary && e.added+e.del > 0 }
163+
164+// wantsHunks reports whether the lines of e can still be shown: the changed
165+// lines alone, a lower bound of the hunk lines, must fit the per-file limit
166+// and what remains of the total, and some text bytes must remain.
167+func wantsHunks(e *rawEntry, limits domain.DiffLimits, linesLeft, bytesLeft int) bool {
168+ n := e.added + e.del
169+ return changedLines(e) && n <= limits.MaxFileLines && n <= linesLeft && bytesLeft > 0
170+}
171+
172+// readRaw reads the `--raw -z` records, which start with ':'.
173+//
174+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values
175+func readRaw(r *bufio.Reader) ([]rawEntry, error) {
176+ var entries []rawEntry
177+ for {
178+ b, err := r.Peek(1)
179+ if err != nil {
180+ if errors.Is(err, io.EOF) {
181+ return entries, nil
182+ }
183+ return nil, err
184+ }
185+ if b[0] != ':' {
186+ return entries, nil
187+ }
188+ meta, err := readToken(r)
189+ if err != nil {
190+ return nil, unexpectedEOF(err)
191+ }
192+ e, npaths, err := parseRawMeta(meta)
193+ if err != nil {
194+ return nil, err
195+ }
196+ paths := make([]string, npaths)
197+ for i := range paths {
198+ if paths[i], err = readToken(r); err != nil {
199+ return nil, unexpectedEOF(err)
200+ }
201+ }
202+ switch e.file.Status {
203+ case domain.FileAdded:
204+ e.file.NewPath = paths[0]
205+ case domain.FileDeleted:
206+ e.file.OldPath = paths[0]
207+ case domain.FileRenamed, domain.FileCopied:
208+ e.file.OldPath, e.file.NewPath = paths[0], paths[1]
209+ default:
210+ e.file.OldPath, e.file.NewPath = paths[0], paths[0]
211+ }
212+ entries = append(entries, e)
213+ }
214+}
215+
216+func unexpectedEOF(err error) error {
217+ if errors.Is(err, io.EOF) {
218+ return io.ErrUnexpectedEOF
219+ }
220+ return err
221+}
222+
223+// parseRawMeta parses ":<old mode> <new mode> <old id> <new id> <status>" and
224+// returns the number of paths that follow.
225+//
226+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values
227+func parseRawMeta(meta string) (rawEntry, int, error) {
228+ f := strings.Fields(strings.TrimPrefix(meta, ":"))
229+ if len(f) != 5 || f[4] == "" {
230+ return rawEntry{}, 0, fmt.Errorf("git diff-tree: unexpected record %q", truncateText(meta))
231+ }
232+ var e rawEntry
233+ for i, dst := range []*uint32{&e.file.OldMode, &e.file.NewMode} {
234+ m, err := strconv.ParseUint(f[i], 8, 32)
235+ if err != nil {
236+ return rawEntry{}, 0, fmt.Errorf("git diff-tree: mode in %q: %w", truncateText(meta), err)
237+ }
238+ *dst = uint32(m)
239+ }
240+ for i, dst := range []*domain.ObjectID{&e.file.OldID, &e.file.NewID} {
241+ if strings.Trim(f[2+i], "0") == "" {
242+ continue // the null id: no such side
243+ }
244+ id, err := domain.ParseObjectID(f[2+i])
245+ if err != nil {
246+ return rawEntry{}, 0, fmt.Errorf("git diff-tree: %w", err)
247+ }
248+ *dst = id
249+ }
250+ npaths := 1
251+ switch f[4][0] {
252+ case 'A':
253+ e.file.Status = domain.FileAdded
254+ case 'D':
255+ e.file.Status = domain.FileDeleted
256+ case 'R', 'C':
257+ e.file.Status = domain.FileRenamed
258+ if f[4][0] == 'C' {
259+ e.file.Status = domain.FileCopied
260+ }
261+ npaths = 2
262+ if len(f[4]) > 1 {
263+ sim, err := strconv.Atoi(f[4][1:])
264+ if err != nil {
265+ return rawEntry{}, 0, fmt.Errorf("git diff-tree: similarity in %q: %w", truncateText(meta), err)
266+ }
267+ e.file.Similarity = sim
268+ }
269+ case 'T':
270+ e.file.Status = domain.FileTypeChanged
271+ e.typeChange = true
272+ default:
273+ e.file.Status = domain.FileModified
274+ }
275+ return e, npaths, nil
276+}
277+
278+// readNumstat reads one `--numstat -z` record per entry. A binary file has
279+// "-" counts.
280+//
281+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values
282+func readNumstat(r *bufio.Reader, entries []rawEntry) error {
283+ for i := range entries {
284+ tok, err := readToken(r)
285+ if err != nil {
286+ return unexpectedEOF(err)
287+ }
288+ add, rest, ok1 := strings.Cut(tok, "\t")
289+ del, path, ok2 := strings.Cut(rest, "\t")
290+ if !ok1 || !ok2 {
291+ return fmt.Errorf("git diff-tree: unexpected numstat record %q", truncateText(tok))
292+ }
293+ if path == "" { // a rename: the two paths follow
294+ for range 2 {
295+ if _, err := readToken(r); err != nil {
296+ return unexpectedEOF(err)
297+ }
298+ }
299+ }
300+ if add == "-" && del == "-" {
301+ entries[i].file.Binary = true
302+ continue
303+ }
304+ if entries[i].added, err = strconv.Atoi(add); err != nil {
305+ return fmt.Errorf("git diff-tree: numstat %q: %w", truncateText(tok), err)
306+ }
307+ if entries[i].del, err = strconv.Atoi(del); err != nil {
308+ return fmt.Errorf("git diff-tree: numstat %q: %w", truncateText(tok), err)
309+ }
310+ entries[i].file.Additions, entries[i].file.Deletions = entries[i].added, entries[i].del
311+ }
312+ return nil
313+}
314+
315+// patchReader walks the patch section by section, in file order. A section
316+// starts with "diff --git"; hunk lines start with ' ', '+', '-' or '\', so
317+// the marker never appears inside one.
318+type patchReader struct {
319+ lr *lineReader
320+ pending []byte // a "diff --git" line read ahead
321+ hasPend bool
322+ eof bool
323+ cut bool // the last line read was cut
324+
325+ bytesLeft int // text bytes still allowed in the diff
326+}
327+
328+const sectionMarker = "diff --git "
329+
330+// line returns the next line, or ok=false at the end of the patch.
331+func (p *patchReader) line() (line []byte, ok bool, err error) {
332+ if p.hasPend {
333+ p.hasPend = false
334+ return p.pending, true, nil
335+ }
336+ if p.eof {
337+ return nil, false, nil
338+ }
339+ line, cut, err := p.lr.next()
340+ if errors.Is(err, io.EOF) {
341+ p.eof = true
342+ return nil, false, nil
343+ }
344+ p.cut = cut
345+ return line, err == nil, err
346+}
347+
348+func (p *patchReader) unread(line []byte) {
349+ p.pending = append(p.pending[:0], line...)
350+ p.hasPend = true
351+}
352+
353+// enterSection reads up to and including the next "diff --git" line.
354+func (p *patchReader) enterSection() (bool, error) {
355+ for {
356+ line, ok, err := p.line()
357+ if err != nil || !ok {
358+ return false, err
359+ }
360+ if bytes.HasPrefix(line, []byte(sectionMarker)) {
361+ return true, nil
362+ }
363+ }
364+}
365+
366+// skip moves past n sections without keeping anything.
367+func (p *patchReader) skip(n int) error {
368+ for range n {
369+ ok, err := p.enterSection()
370+ if err != nil || !ok {
371+ return err
372+ }
373+ for {
374+ line, ok, err := p.line()
375+ if err != nil {
376+ return err
377+ }
378+ if !ok {
379+ break
380+ }
381+ if bytes.HasPrefix(line, []byte(sectionMarker)) {
382+ p.unread(line)
383+ break
384+ }
385+ }
386+ }
387+ return nil
388+}
389+
390+// file reads the sections of one file and returns its hunks, the number of
391+// hunk lines, and whether it exceeded max lines or had a line over
392+// maxDiffLine. When truncated, the rest of the file is skipped.
393+func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {
394+ for done := range sections {
395+ ok, err := p.enterSection()
396+ if err != nil {
397+ return nil, 0, false, err
398+ }
399+ if !ok {
400+ break
401+ }
402+ h, n, trunc, err := p.section(max - lines)
403+ if err != nil {
404+ return nil, 0, false, err
405+ }
406+ hunks = append(hunks, h...)
407+ lines += n
408+ if trunc {
409+ // Leave the stream at the start of the next file.
410+ return nil, lines, true, p.skip(sections - done - 1)
411+ }
412+ }
413+ return hunks, lines, false, nil
414+}
415+
416+// section reads the hunks of the current section up to the next marker, which
417+// stays pending. It gives up (truncated) after more than max lines.
418+//
419+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values, applying the limits the port passes in
420+func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {
421+ var cur *domain.Hunk
422+ var oldN, newN int
423+ for {
424+ line, ok, err := p.line()
425+ if err != nil {
426+ return nil, 0, false, err
427+ }
428+ if !ok {
429+ return hunks, lines, false, nil
430+ }
431+ if bytes.HasPrefix(line, []byte(sectionMarker)) {
432+ p.unread(line)
433+ return hunks, lines, false, nil
434+ }
435+ if p.cut {
436+ return nil, lines, true, nil
437+ }
438+ if bytes.HasPrefix(line, []byte("@@ ")) {
439+ h, err := parseHunkHeader(string(line))
440+ if err != nil {
441+ return nil, 0, false, err
442+ }
443+ hunks = append(hunks, h)
444+ cur = &hunks[len(hunks)-1]
445+ oldN, newN = h.OldStart, h.NewStart
446+ continue
447+ }
448+ if cur == nil || len(line) == 0 {
449+ continue // header lines of the section
450+ }
451+ if line[0] == '\\' { // "\ No newline at end of file"
452+ if n := len(cur.Lines); n > 0 {
453+ cur.Lines[n-1].NoNewlineAtEOF = true
454+ }
455+ continue
456+ }
457+ var l domain.Line
458+ text := strings.ToValidUTF8(string(line[min(1, len(line)):]), "\uFFFD")
459+ switch line[0] {
460+ case ' ':
461+ l = domain.Line{Kind: domain.LineContext, OldNumber: oldN, NewNumber: newN, Text: text}
462+ oldN++
463+ newN++
464+ case '+':
465+ l = domain.Line{Kind: domain.LineAddition, NewNumber: newN, Text: text}
466+ newN++
467+ case '-':
468+ l = domain.Line{Kind: domain.LineDeletion, OldNumber: oldN, Text: text}
469+ oldN++
470+ default:
471+ continue
472+ }
473+ lines++
474+ p.bytesLeft -= len(text)
475+ if lines > max || p.bytesLeft < 0 {
476+ return nil, lines, true, nil
477+ }
478+ cur.Lines = append(cur.Lines, l)
479+ }
480+}
481+
482+// parseHunkHeader parses "@@ -a[,b] +c[,d] @@[ header]".
483+//
484+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values
485+func parseHunkHeader(s string) (domain.Hunk, error) {
486+ bad := func() (domain.Hunk, error) {
487+ return domain.Hunk{}, fmt.Errorf("git diff-tree: unexpected hunk header %q", truncateText(s))
488+ }
489+ rest, ok := strings.CutPrefix(s, "@@ -")
490+ if !ok {
491+ return bad()
492+ }
493+ ranges, header, ok := strings.Cut(rest, " @@")
494+ if !ok {
495+ return bad()
496+ }
497+ oldR, newR, ok := strings.Cut(ranges, " +")
498+ if !ok {
499+ return bad()
500+ }
501+ var h domain.Hunk
502+ var err1, err2 error
503+ h.OldStart, h.OldLines, err1 = parseRange(oldR)
504+ h.NewStart, h.NewLines, err2 = parseRange(newR)
505+ if err1 != nil || err2 != nil {
506+ return bad()
507+ }
508+ h.Header = strings.ToValidUTF8(strings.TrimPrefix(header, " "), "\uFFFD")
509+ return h, nil
510+}
511+
512+// parseRange parses "start" or "start,count"; a missing count is 1.
513+func parseRange(s string) (start, count int, err error) {
514+ a, b, hasCount := strings.Cut(s, ",")
515+ if start, err = strconv.Atoi(a); err != nil {
516+ return 0, 0, err
517+ }
518+ count = 1
519+ if hasCount {
520+ if count, err = strconv.Atoi(b); err != nil {
521+ return 0, 0, err
522+ }
523+ }
524+ return start, count, nil
525+}
A internal/adapters/gitcli/diff_test.go +485 -0
@@ -0,0 +1,485 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strconv"
9+ "strings"
10+ "testing"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+)
17+
18+// unified renders hunks the way git prints them after the "+++" line.
19+func unified(hunks []domain.Hunk) string {
20+ var b strings.Builder
21+ rng := func(start, n int) string {
22+ if n == 1 {
23+ return strconv.Itoa(start)
24+ }
25+ return fmt.Sprintf("%d,%d", start, n)
26+ }
27+ for _, h := range hunks {
28+ fmt.Fprintf(&b, "@@ -%s +%s @@", rng(h.OldStart, h.OldLines), rng(h.NewStart, h.NewLines))
29+ if h.Header != "" {
30+ b.WriteString(" " + h.Header)
31+ }
32+ b.WriteByte('\n')
33+ for _, l := range h.Lines {
34+ b.WriteString(map[domain.LineKind]string{domain.LineContext: " ", domain.LineAddition: "+", domain.LineDeletion: "-"}[l.Kind])
35+ b.WriteString(l.Text + "\n")
36+ if l.NoNewlineAtEOF {
37+ b.WriteString("\\ No newline at end of file\n")
38+ }
39+ }
40+ }
41+ return b.String()
42+}
43+
44+// gitSections splits the output of `git show -p` into the text each
45+// "diff --git" section has from its first hunk on ("" for a section without
46+// hunk: binary, pure rename, mode change).
47+func gitSections(out string) []string {
48+ var sections []string
49+ for _, s := range strings.Split("\n"+out, "\ndiff --git ")[1:] {
50+ _, hunks, ok := strings.Cut(s, "\n@@ ")
51+ if !ok {
52+ sections = append(sections, "")
53+ continue
54+ }
55+ sections = append(sections, "@@ "+strings.TrimSuffix(hunks, "\n")+"\n")
56+ }
57+ return sections
58+}
59+
60+// showSections is git's own patch text of a commit against its first parent.
61+func (f *fixtureRepo) showSections(id string) []string {
62+ f.t.Helper()
63+ out := f.git("show", "--format=", "--no-color", "-M", "-m", "--first-parent", "-U3", id)
64+ return gitSections(strings.ToValidUTF8(out, "\uFFFD"))
65+}
66+
67+// showNumstat is git's own numstat of a commit against its first parent.
68+func (f *fixtureRepo) showNumstat(id string) []string {
69+ f.t.Helper()
70+ out := f.git("show", "--format=", "-M", "-m", "--first-parent", "--numstat", id)
71+ if out == "" {
72+ return nil
73+ }
74+ return strings.Split(out, "\n")
75+}
76+
77+// assertMatchesGitShow checks the structured diff of a commit against the
78+// text of `git show`: the patch of every file, its counts, and the totals.
79+func assertMatchesGitShow(t *testing.T, f *fixtureRepo, id string, d domain.CommitDetail) {
80+ t.Helper()
81+ sections := f.showSections(id)
82+ var adds, dels int
83+ numstat := f.showNumstat(id)
84+ require.Len(t, numstat, len(d.Diff.Files), "one numstat line per file")
85+ si := 0
86+ for i, file := range d.Diff.Files {
87+ label := file.OldPath + ">" + file.NewPath
88+ fields := strings.SplitN(numstat[i], "\t", 3)
89+ if fields[0] == "-" {
90+ assert.True(t, file.Binary, label)
91+ assert.Zero(t, file.Additions+file.Deletions, label)
92+ } else {
93+ assert.False(t, file.Binary, label)
94+ assert.Equal(t, fields[0], strconv.Itoa(file.Additions), label)
95+ assert.Equal(t, fields[1], strconv.Itoa(file.Deletions), label)
96+ a, _ := strconv.Atoi(fields[0])
97+ del, _ := strconv.Atoi(fields[1])
98+ adds, dels = adds+a, dels+del
99+ }
100+ // A type change is two sections: the deletion, then the addition.
101+ n := 1
102+ if file.Status == domain.FileTypeChanged {
103+ n = 2
104+ }
105+ require.LessOrEqual(t, si+n, len(sections), label)
106+ assert.Equal(t, strings.Join(sections[si:si+n], ""), unified(file.Hunks), "patch of %s", label)
107+ si += n
108+ }
109+ assert.Equal(t, len(sections), si, "every git section is accounted for")
110+ assert.Equal(t, domain.DiffStats{FilesChanged: len(numstat), Additions: adds, Deletions: dels}, d.Stats)
111+}
112+
113+func TestGetCommitMatchesGitShow(t *testing.T) {
114+ ctx := context.Background()
115+ g, data := newReader(t, nil)
116+ h := newHistory(t, data)
117+
118+ for _, tt := range []struct{ name, id string }{
119+ {"root commit: adds, binary, empty file", h.c1},
120+ {"rename with change, binary, mode change, add, delete", h.c2},
121+ {"type change", h.c3},
122+ {"merge, against the first parent", h.merge},
123+ {"side branch", h.side},
124+ } {
125+ t.Run(tt.name, func(t *testing.T) {
126+ d, err := g.GetCommit(ctx, h.name, domain.RevisionOf(mustID(t, tt.id)), domain.DefaultDiffLimits())
127+ require.NoError(t, err)
128+ assert.Equal(t, tt.id, d.Commit.ID.String())
129+ assert.False(t, d.Diff.Truncated)
130+ assertMatchesGitShow(t, h.fixtureRepo, tt.id, d)
131+ })
132+ }
133+}
134+
135+func TestGetCommitFileMetadata(t *testing.T) {
136+ ctx := context.Background()
137+ g, data := newReader(t, nil)
138+ h := newHistory(t, data)
139+
140+ byPath := func(d domain.Diff) map[string]domain.FileDiff {
141+ m := map[string]domain.FileDiff{}
142+ for _, f := range d.Files {
143+ m[f.OldPath+">"+f.NewPath] = f
144+ }
145+ return m
146+ }
147+ id := func(spec string) domain.ObjectID { return mustID(t, h.git("rev-parse", spec)) }
148+
149+ d, err := g.GetCommit(ctx, h.name, rev(t, "v2"), domain.DefaultDiffLimits())
150+ require.NoError(t, err)
151+ assert.Equal(t, h.c2, d.Commit.ID.String())
152+ files := byPath(d.Diff)
153+ require.Len(t, files, 5)
154+
155+ ren := files["a.txt>b.txt"]
156+ assert.Equal(t, domain.FileRenamed, ren.Status)
157+ assert.Equal(t, 73, ren.Similarity)
158+ assert.Equal(t, 1, ren.Additions)
159+ assert.Equal(t, 0, ren.Deletions)
160+ assert.Equal(t, uint32(0o100644), ren.OldMode)
161+ assert.Equal(t, uint32(0o100644), ren.NewMode)
162+ assert.Equal(t, id(h.c1+":a.txt"), ren.OldID)
163+ assert.Equal(t, id(h.c2+":b.txt"), ren.NewID)
164+ require.Len(t, ren.Hunks, 1)
165+ assert.Equal(t, domain.Hunk{
166+ OldStart: 1, OldLines: 3, NewStart: 1, NewLines: 4,
167+ Lines: []domain.Line{
168+ {Kind: domain.LineContext, OldNumber: 1, NewNumber: 1, Text: "one"},
169+ {Kind: domain.LineContext, OldNumber: 2, NewNumber: 2, Text: "two"},
170+ {Kind: domain.LineContext, OldNumber: 3, NewNumber: 3, Text: "three"},
171+ {Kind: domain.LineAddition, NewNumber: 4, Text: "four"},
172+ },
173+ }, ren.Hunks[0])
174+
175+ bin := files["bin>bin"]
176+ assert.Equal(t, domain.FileModified, bin.Status)
177+ assert.True(t, bin.Binary)
178+ assert.Empty(t, bin.Hunks)
179+ assert.False(t, bin.Truncated)
180+
181+ mode := files["d/f>d/f"]
182+ assert.Equal(t, domain.FileModified, mode.Status)
183+ assert.Equal(t, uint32(0o100644), mode.OldMode)
184+ assert.Equal(t, uint32(0o100755), mode.NewMode)
185+ assert.Empty(t, mode.Hunks)
186+ assert.False(t, mode.Binary)
187+ assert.False(t, mode.Truncated)
188+
189+ del := files["README.md>"]
190+ assert.Equal(t, domain.FileDeleted, del.Status)
191+ assert.Equal(t, uint32(0), del.NewMode)
192+ assert.True(t, del.NewID.IsZero())
193+ assert.Equal(t, 1, del.Deletions)
194+ require.Len(t, del.Hunks, 1)
195+ assert.Equal(t, domain.LineDeletion, del.Hunks[0].Lines[0].Kind)
196+ assert.Equal(t, 0, del.Hunks[0].NewStart, "a deleted file has no new side")
197+
198+ add := files[">n.txt"]
199+ assert.Equal(t, domain.FileAdded, add.Status)
200+ assert.Equal(t, uint32(0), add.OldMode)
201+ assert.True(t, add.OldID.IsZero())
202+ require.Len(t, add.Hunks, 1)
203+ last := add.Hunks[0].Lines[1]
204+ assert.Equal(t, domain.Line{Kind: domain.LineAddition, NewNumber: 2, Text: "nonl", NoNewlineAtEOF: true}, last)
205+
206+ d, err = g.GetCommit(ctx, h.name, rev(t, h.c3), domain.DefaultDiffLimits())
207+ require.NoError(t, err)
208+ require.Len(t, d.Diff.Files, 1)
209+ tc := d.Diff.Files[0]
210+ assert.Equal(t, domain.FileTypeChanged, tc.Status)
211+ assert.Equal(t, uint32(0o120000), tc.NewMode)
212+ assert.Equal(t, 1, tc.Additions)
213+ assert.Equal(t, 2, tc.Deletions)
214+}
215+
216+func TestGetCommitMergeDiffIsAgainstFirstParent(t *testing.T) {
217+ ctx := context.Background()
218+ g, data := newReader(t, nil)
219+ h := newHistory(t, data)
220+
221+ d, err := g.GetCommit(ctx, h.name, rev(t, "main"), domain.DefaultDiffLimits())
222+ require.NoError(t, err)
223+ require.Len(t, d.Commit.Parents, 2)
224+ require.Len(t, d.Diff.Files, 1)
225+ assert.Equal(t, "side.txt", d.Diff.Files[0].NewPath)
226+ assert.Equal(t, domain.FileAdded, d.Diff.Files[0].Status)
227+}
228+
229+func TestGetCommitEmptyCommit(t *testing.T) {
230+ ctx := context.Background()
231+ g, data := newReader(t, nil)
232+ f := newFixtureRepo(t, data, "empty-commit")
233+ id := f.commit("nothing")
234+
235+ d, err := g.GetCommit(ctx, f.name, domain.Revision{}, domain.DefaultDiffLimits())
236+ require.NoError(t, err)
237+ assert.Equal(t, id, d.Commit.ID.String())
238+ assert.Empty(t, d.Diff.Files)
239+ assert.False(t, d.Diff.Truncated)
240+ assert.Equal(t, domain.DiffStats{}, d.Stats)
241+}
242+
243+func TestGetCommitErrors(t *testing.T) {
244+ ctx := context.Background()
245+ g, data := newReader(t, nil)
246+ h := newHistory(t, data)
247+
248+ _, err := g.GetCommit(ctx, h.name, rev(t, "nope"), domain.DefaultDiffLimits())
249+ assert.ErrorIs(t, err, domain.ErrNotFound)
250+ _, err = g.GetCommit(ctx, name(t, "nope"), domain.Revision{}, domain.DefaultDiffLimits())
251+ assert.ErrorIs(t, err, domain.ErrNotFound)
252+}
253+
254+func TestGetCommitHunkHeaderAndNonUTF8(t *testing.T) {
255+ ctx := context.Background()
256+ g, data := newReader(t, nil)
257+ f := newFixtureRepo(t, data, "hunks")
258+ var src strings.Builder
259+ src.WriteString("package x\n\nfunc Alpha() {\n")
260+ for i := range 40 {
261+ fmt.Fprintf(&src, "\tline(%d)\n", i)
262+ }
263+ src.WriteString("}\n")
264+ f.write("x.go", src.String()).write("latin", "caf\xe9\nok\n")
265+ f.commit("v1")
266+ f.write("x.go", strings.Replace(src.String(), "\tline(30)", "\tchanged(30)", 1)).write("latin", "caf\xe9\nOK\n")
267+ id := f.commit("v2")
268+
269+ d, err := g.GetCommit(ctx, f.name, domain.Revision{}, domain.DefaultDiffLimits())
270+ require.NoError(t, err)
271+ assertMatchesGitShow(t, f, id, d)
272+ var gofile, latin domain.FileDiff
273+ for _, file := range d.Diff.Files {
274+ switch file.NewPath {
275+ case "x.go":
276+ gofile = file
277+ case "latin":
278+ latin = file
279+ }
280+ }
281+ require.Len(t, gofile.Hunks, 1)
282+ assert.Equal(t, "func Alpha() {", gofile.Hunks[0].Header, "git's default function name rule")
283+ assert.Equal(t, 31, gofile.Hunks[0].OldStart)
284+ require.Len(t, latin.Hunks, 1)
285+ assert.Equal(t, "caf\uFFFD", latin.Hunks[0].Lines[0].Text, "invalid UTF-8 is replaced")
286+}
287+
288+// manyLines commits files and returns the id.
289+func commitFiles(f *fixtureRepo, msg string, files map[string]int) string {
290+ for p, n := range files {
291+ f.lines(p, strings.ReplaceAll(p, "/", "_"), n)
292+ }
293+ return f.commit(msg)
294+}
295+
296+func TestDiffLimits(t *testing.T) {
297+ ctx := context.Background()
298+ g, data := newReader(t, nil)
299+ defaults := domain.DefaultDiffLimits()
300+ find := func(d domain.Diff, p string) domain.FileDiff {
301+ for _, f := range d.Files {
302+ if f.NewPath == p {
303+ return f
304+ }
305+ }
306+ t.Fatalf("file %s not in the diff", p)
307+ return domain.FileDiff{}
308+ }
309+
310+ t.Run("a file at the line limit is kept, one line more is truncated", func(t *testing.T) {
311+ f := newFixtureRepo(t, data, "limit-file")
312+ commitFiles(f, "files", map[string]int{"exact": 2000, "over": 2001, "small": 3})
313+ d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)
314+ require.NoError(t, err)
315+ exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small")
316+
317+ assert.False(t, exact.Truncated)
318+ require.Len(t, exact.Hunks, 1)
319+ assert.Len(t, exact.Hunks[0].Lines, 2000)
320+ assert.True(t, over.Truncated)
321+ assert.Empty(t, over.Hunks)
322+ assert.Equal(t, 2001, over.Additions, "counts stay accurate")
323+ assert.False(t, small.Truncated)
324+ assert.Len(t, small.Hunks, 1)
325+ assert.True(t, d.Diff.Truncated)
326+ assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats)
327+ })
328+
329+ t.Run("context counts towards the file limit", func(t *testing.T) {
330+ f := newFixtureRepo(t, data, "limit-context")
331+ f.lines("big", "l", 3000).commit("v1")
332+ // Every 7th line changes: ~428 changed lines, but the hunks merge
333+ // into one that spans the file with its context.
334+ var b strings.Builder
335+ for i := range 3000 {
336+ if i%7 == 0 {
337+ fmt.Fprintf(&b, "changed %d\n", i)
338+ } else {
339+ fmt.Fprintf(&b, "l %d\n", i)
340+ }
341+ }
342+ f.write("big", b.String())
343+ f.write("tiny", "x\n")
344+ id := f.commit("v2")
345+ d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)
346+ require.NoError(t, err)
347+ big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny")
348+ assert.True(t, big.Truncated)
349+ assert.Empty(t, big.Hunks)
350+ assert.Equal(t, 429, big.Additions)
351+ assert.Equal(t, 429, big.Deletions)
352+ assert.False(t, tiny.Truncated, "the file after a truncated one is still read")
353+ assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+"))
354+ assert.Equal(t, id, d.Commit.ID.String())
355+ })
356+
357+ t.Run("the total limit truncates the file that does not fit", func(t *testing.T) {
358+ f := newFixtureRepo(t, data, "limit-total")
359+ files := map[string]int{}
360+ for i := range 11 {
361+ files[fmt.Sprintf("f%02d", i)] = 1900 // 11 x 1900 = 20,900 > 20,000
362+ }
363+ files["g-small"] = 50 // sorts after f10: still fits in the 1,000 lines left
364+ files["h-big"] = 1500 // does not fit
365+ files["i-small"] = 10 // fits
366+ commitFiles(f, "total", files)
367+ d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)
368+ require.NoError(t, err)
369+ require.Len(t, d.Diff.Files, 14)
370+ total := 0
371+ for _, file := range d.Diff.Files {
372+ for _, h := range file.Hunks {
373+ total += len(h.Lines)
374+ }
375+ }
376+ assert.LessOrEqual(t, total, 20000)
377+ for i := range 10 {
378+ assert.False(t, find(d.Diff, fmt.Sprintf("f%02d", i)).Truncated)
379+ }
380+ assert.True(t, find(d.Diff, "f10").Truncated, "1,900 lines do not fit in the 1,000 left")
381+ assert.False(t, find(d.Diff, "g-small").Truncated)
382+ assert.True(t, find(d.Diff, "h-big").Truncated)
383+ assert.False(t, find(d.Diff, "i-small").Truncated)
384+ assert.True(t, d.Diff.Truncated)
385+ assert.Equal(t, 11*1900+50+1500+10, d.Stats.Additions)
386+ assert.Equal(t, 14, d.Stats.FilesChanged)
387+ })
388+
389+ t.Run("files past the file limit are omitted but counted", func(t *testing.T) {
390+ f := newFixtureRepo(t, data, "limit-files")
391+ for i := range 305 {
392+ f.write(fmt.Sprintf("many/f%03d", i), "x\n")
393+ }
394+ f.commit("many")
395+ d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)
396+ require.NoError(t, err)
397+ assert.Len(t, d.Diff.Files, 300)
398+ assert.True(t, d.Diff.Truncated)
399+ assert.Equal(t, domain.DiffStats{FilesChanged: 305, Additions: 305}, d.Stats)
400+ assert.Equal(t, "many/f000", d.Diff.Files[0].NewPath)
401+ assert.Equal(t, "many/f299", d.Diff.Files[299].NewPath)
402+ for _, file := range d.Diff.Files {
403+ assert.False(t, file.Truncated)
404+ assert.Len(t, file.Hunks, 1)
405+ }
406+ })
407+
408+ t.Run("exactly 300 files is not truncated", func(t *testing.T) {
409+ f := newFixtureRepo(t, data, "limit-300")
410+ for i := range 300 {
411+ f.write(fmt.Sprintf("f%03d", i), "x\n")
412+ }
413+ f.commit("300")
414+ d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)
415+ require.NoError(t, err)
416+ assert.Len(t, d.Diff.Files, 300)
417+ assert.False(t, d.Diff.Truncated)
418+ })
419+
420+ t.Run("custom limits and the byte limit", func(t *testing.T) {
421+ f := newFixtureRepo(t, data, "limit-custom")
422+ commitFiles(f, "c", map[string]int{"a": 10, "b": 10, "c": 10})
423+ d, err := g.GetCommit(ctx, f.name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20})
424+ require.NoError(t, err)
425+ require.Len(t, d.Diff.Files, 2)
426+ assert.False(t, d.Diff.Files[0].Truncated)
427+ assert.True(t, d.Diff.Files[1].Truncated, "10 lines do not fit in the 5 left")
428+ assert.Equal(t, 3, d.Stats.FilesChanged)
429+
430+ d, err = g.GetCommit(ctx, f.name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50})
431+ require.NoError(t, err)
432+ require.Len(t, d.Diff.Files, 3)
433+ assert.False(t, d.Diff.Files[0].Truncated, "30 bytes of text fit in 50")
434+ for _, file := range d.Diff.Files[1:] {
435+ assert.True(t, file.Truncated, "the byte budget has 20 bytes left for 30")
436+ assert.Empty(t, file.Hunks)
437+ }
438+ })
439+
440+ t.Run("a very long line truncates its file instead of being cut", func(t *testing.T) {
441+ f := newFixtureRepo(t, data, "limit-line")
442+ f.write("long", strings.Repeat("x", 200<<10)+"\n").write("short", "ok\n").commit("long")
443+ d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)
444+ require.NoError(t, err)
445+ long, short := find(d.Diff, "long"), find(d.Diff, "short")
446+ assert.True(t, long.Truncated)
447+ assert.Empty(t, long.Hunks)
448+ assert.False(t, short.Truncated)
449+ assert.Len(t, short.Hunks, 1)
450+ })
451+
452+ t.Run("binary files and mode changes in a truncated commit", func(t *testing.T) {
453+ f := newFixtureRepo(t, data, "limit-mixed")
454+ f.lines("a-big", "x", 2500).writeBytes("b-bin", []byte{0, 1, 2}).write("c-mode", "m\n").write("d-text", "t\n")
455+ f.commit("v1")
456+ f.lines("a-big", "y", 2500).writeBytes("b-bin", []byte{0, 3}).chmod("c-mode", 0o755).write("d-text", "t2\n")
457+ id := f.commit("v2")
458+ d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)
459+ require.NoError(t, err)
460+ assert.True(t, find(d.Diff, "a-big").Truncated)
461+ assert.True(t, find(d.Diff, "b-bin").Binary)
462+ assert.False(t, find(d.Diff, "b-bin").Truncated)
463+ assert.False(t, find(d.Diff, "c-mode").Truncated)
464+ assert.Len(t, find(d.Diff, "d-text").Hunks, 1)
465+ // What is kept matches git.
466+ sections := f.showSections(id)
467+ require.Len(t, sections, 4)
468+ assert.Equal(t, sections[3], unified(find(d.Diff, "d-text").Hunks))
469+ })
470+}
471+
472+func TestGetCommitStopsGitEarly(t *testing.T) {
473+ ctx := context.Background()
474+ g, data := newReader(t, nil)
475+ f := newFixtureRepo(t, data, "early")
476+ f.lines("a", "x", 5).lines("z-huge", "y", 100000).commit("c")
477+
478+ // z-huge is truncated by the numstat alone, so nothing of it is read.
479+ d, err := g.GetCommit(ctx, f.name, domain.Revision{}, domain.DefaultDiffLimits())
480+ require.NoError(t, err)
481+ require.Len(t, d.Diff.Files, 2)
482+ assert.False(t, d.Diff.Files[0].Truncated)
483+ assert.True(t, d.Diff.Files[1].Truncated)
484+ assert.Equal(t, 100005, d.Stats.Additions)
485+}
A internal/adapters/gitcli/export_test.go +36 -0
@@ -0,0 +1,36 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import "sort"
6+
7+// CatFilePIDs returns the process ids of the live long-lived cat-file
8+// processes, for the tests of the pool.
9+func (g *Git) CatFilePIDs() []int {
10+ g.cat.mu.Lock()
11+ defer g.cat.mu.Unlock()
12+ var pids []int
13+ for _, cp := range g.cat.procs {
14+ pids = append(pids, cp.cmd.Process.Pid)
15+ }
16+ sort.Ints(pids)
17+ return pids
18+}
19+
20+// BlobStreams returns the number of blob streams that are open.
21+func (g *Git) BlobStreams() int {
22+ g.cat.mu.Lock()
23+ defer g.cat.mu.Unlock()
24+ return len(g.cat.streams)
25+}
26+
27+// BlobStreamPIDs returns the process ids of the open blob streams.
28+func (g *Git) BlobStreamPIDs() []int {
29+ g.cat.mu.Lock()
30+ defer g.cat.mu.Unlock()
31+ var pids []int
32+ for s := range g.cat.streams {
33+ pids = append(pids, s.cmd.Process.Pid)
34+ }
35+ return pids
36+}
A internal/adapters/gitcli/fixture_test.go +158 -0
@@ -0,0 +1,158 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "fmt"
7+ "math/rand/v2"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+)
19+
20+// fixtureRepo builds a repository with real git: a bare repository in the
21+// data directory, and a work tree beside it that the helpers edit and commit.
22+type fixtureRepo struct {
23+ t *testing.T
24+ name domain.RepoName
25+ bare string
26+ work string
27+ when time.Time
28+}
29+
30+// newFixtureRepo creates an empty bare repository repos/<name>.git whose HEAD
31+// is refs/heads/main.
32+func newFixtureRepo(t *testing.T, data, repo string) *fixtureRepo {
33+ t.Helper()
34+ f := &fixtureRepo{
35+ t: t,
36+ name: name(t, repo),
37+ bare: filepath.Join(data, "repos", repo+".git"),
38+ work: t.TempDir(),
39+ when: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC),
40+ }
41+ out, err := exec.Command("git", "init", "--quiet", "--bare", "--initial-branch=main", f.bare).CombinedOutput()
42+ require.NoError(t, err, string(out))
43+ f.git("config", "core.compression", "0")
44+ f.git("config", "core.autocrlf", "false")
45+ return f
46+}
47+
48+// git runs git against the bare repository and the work tree.
49+func (f *fixtureRepo) git(args ...string) string {
50+ f.t.Helper()
51+ cmd := exec.Command("git", args...)
52+ cmd.Dir = f.work
53+ cmd.Env = []string{
54+ "PATH=" + os.Getenv("PATH"),
55+ "HOME=" + f.work,
56+ "GIT_CONFIG_NOSYSTEM=1",
57+ "GIT_CONFIG_GLOBAL=/dev/null",
58+ "GIT_DIR=" + f.bare,
59+ "GIT_WORK_TREE=" + f.work,
60+ "GIT_AUTHOR_NAME=Ada Lovelace", "GIT_AUTHOR_EMAIL=ada@example.org",
61+ "GIT_COMMITTER_NAME=Grace Hopper", "GIT_COMMITTER_EMAIL=grace@example.org",
62+ "GIT_AUTHOR_DATE=" + f.when.Format(time.RFC3339),
63+ "GIT_COMMITTER_DATE=" + f.when.Format(time.RFC3339),
64+ }
65+ out, err := cmd.CombinedOutput()
66+ require.NoError(f.t, err, "git %s: %s", strings.Join(args, " "), out)
67+ return strings.TrimSpace(string(out))
68+}
69+
70+func (f *fixtureRepo) path(p string) string { return filepath.Join(f.work, filepath.FromSlash(p)) }
71+
72+// write creates or replaces a file with the content.
73+func (f *fixtureRepo) write(p, content string) *fixtureRepo {
74+ f.t.Helper()
75+ require.NoError(f.t, os.MkdirAll(filepath.Dir(f.path(p)), 0o755))
76+ require.NoError(f.t, os.WriteFile(f.path(p), []byte(content), 0o644))
77+ return f
78+}
79+
80+// writeBytes is write for binary content.
81+func (f *fixtureRepo) writeBytes(p string, content []byte) *fixtureRepo {
82+ f.t.Helper()
83+ require.NoError(f.t, os.MkdirAll(filepath.Dir(f.path(p)), 0o755))
84+ require.NoError(f.t, os.WriteFile(f.path(p), content, 0o644))
85+ return f
86+}
87+
88+// lines writes n lines "<prefix> <i>" to the file.
89+func (f *fixtureRepo) lines(p, prefix string, n int) *fixtureRepo {
90+ var b strings.Builder
91+ for i := range n {
92+ fmt.Fprintf(&b, "%s %d\n", prefix, i)
93+ }
94+ return f.write(p, b.String())
95+}
96+
97+func (f *fixtureRepo) remove(p string) *fixtureRepo {
98+ f.t.Helper()
99+ require.NoError(f.t, os.Remove(f.path(p)))
100+ return f
101+}
102+
103+func (f *fixtureRepo) chmod(p string, mode os.FileMode) *fixtureRepo {
104+ f.t.Helper()
105+ require.NoError(f.t, os.Chmod(f.path(p), mode))
106+ return f
107+}
108+
109+func (f *fixtureRepo) symlink(target, p string) *fixtureRepo {
110+ f.t.Helper()
111+ require.NoError(f.t, os.Symlink(target, f.path(p)))
112+ return f
113+}
114+
115+func (f *fixtureRepo) mv(from, to string) *fixtureRepo {
116+ f.t.Helper()
117+ require.NoError(f.t, os.MkdirAll(filepath.Dir(f.path(to)), 0o755))
118+ require.NoError(f.t, os.Rename(f.path(from), f.path(to)))
119+ return f
120+}
121+
122+// commit stages everything and commits with the next timestamp; it returns
123+// the commit id.
124+func (f *fixtureRepo) commit(msg string) string {
125+ f.t.Helper()
126+ f.when = f.when.Add(time.Minute)
127+ f.git("add", "-A")
128+ f.git("commit", "--quiet", "--allow-empty", "-m", msg)
129+ return f.git("rev-parse", "HEAD")
130+}
131+
132+// checkoutNew creates a branch from the current HEAD and switches to it.
133+func (f *fixtureRepo) checkoutNew(branch string) {
134+ f.t.Helper()
135+ f.git("checkout", "--quiet", "-b", branch)
136+}
137+
138+func (f *fixtureRepo) checkout(branch string) { f.t.Helper(); f.git("checkout", "--quiet", branch) }
139+
140+// merge merges the branch with a merge commit.
141+func (f *fixtureRepo) merge(branch string) string {
142+ f.t.Helper()
143+ f.when = f.when.Add(time.Minute)
144+ f.git("merge", "--quiet", "--no-ff", "-m", "merge "+branch, branch)
145+ return f.git("rev-parse", "HEAD")
146+}
147+
148+// randomBytes returns n deterministic incompressible bytes.
149+func randomBytes(n int, seed uint64) []byte {
150+ var s [32]byte
151+ for i := range 8 {
152+ s[i] = byte(seed >> (8 * i))
153+ }
154+ r := rand.NewChaCha8(s)
155+ b := make([]byte, n)
156+ _, _ = r.Read(b)
157+ return b
158+}
M internal/adapters/gitcli/git.go +19 -3
@@ -31,6 +31,11 @@ type Config struct {
31 // MaxReceiveBytes is the largest pack a push may send31 // MaxReceiveBytes is the largest pack a push may send
32 // (receive.maxInputSize); zero leaves Git unlimited.32 // (receive.maxInputSize); zero leaves Git unlimited.
33 MaxReceiveBytes int6433 MaxReceiveBytes int64
34+ // CatFileIdle is how long a repository's long-lived `git cat-file`
35+ // process may stay unused before it is stopped. Zero means 60 seconds.
36+ CatFileIdle time.Duration
37+ // MaxCatFiles bounds the long-lived cat-file processes. Zero means 16.
38+ MaxCatFiles int
34 }39 }
35 40
36 // Git is the git CLI adapter for ports.GitRepos.41 // Git is the git CLI adapter for ports.GitRepos.
@@ -42,13 +47,24 @@ type Git struct {
42 47
43 mu sync.Mutex // guards backend48 mu sync.Mutex // guards backend
44 backend string // git-http-backend, resolved on first use49 backend string // git-http-backend, resolved on first use
50+
51+ cat *catPool // long-lived cat-file processes of the read side
45 }52 }
46 53
47 // New builds an adapter. It does not run git: call CheckVersion.54 // New builds an adapter. It does not run git: call CheckVersion.
48 func New(cfg Config) *Git {55 func New(cfg Config) *Git {
49- return &Git{bin: cfg.Bin, dataDir: cfg.DataDir, env: buildEnv(cfg.DataDir, cfg.Path), maxRecv: cfg.MaxReceiveBytes}56+ env := buildEnv(cfg.DataDir, cfg.Path)
57+ return &Git{
58+ bin: cfg.Bin, dataDir: cfg.DataDir, env: env, maxRecv: cfg.MaxReceiveBytes,
59+ cat: newCatPool(cfg.Bin, env, cfg.CatFileIdle, cfg.MaxCatFiles),
60+ }
50 }61 }
51 62
63+// Close stops the long-lived git processes of the read side and waits for
64+// them, until ctx ends. Reads after Close fail with ErrClosed. Close is
65+// idempotent; the adapter's write side keeps working.
66+func (g *Git) Close(ctx context.Context) error { return g.cat.close(ctx) }
67+
52 // buildEnv returns the complete environment of every git invocation. Nothing68 // buildEnv returns the complete environment of every git invocation. Nothing
53 // else is passed: no GIT_* variable, no user HOME, no locale.69 // else is passed: no GIT_* variable, no user HOME, no locale.
54 func buildEnv(dataDir, path string) []string {70 func buildEnv(dataDir, path string) []string {
@@ -85,9 +101,9 @@ func (g *Git) run(ctx context.Context, args ...string) (string, error) {
85 }101 }
86 var exit *exec.ExitError102 var exit *exec.ExitError
87 if errors.As(err, &exit) && msg != "" {103 if errors.As(err, &exit) && msg != "" {
88- return "", fmt.Errorf("git %s: %w: %s", args[0], err, msg)104+ return "", fmt.Errorf("git %s: %w: %s", subcommand(args), err, msg)
89 }105 }
90- return "", fmt.Errorf("git %s: %w", args[0], err)106+ return "", fmt.Errorf("git %s: %w", subcommand(args), err)
91 }107 }
92 return stdout.String(), nil108 return stdout.String(), nil
93 }109 }
A internal/adapters/gitcli/pipe.go +161 -0
@@ -0,0 +1,161 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "bufio"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "os/exec"
12+ "strings"
13+ "time"
14+)
15+
16+// errStop is returned by a pipe consumer that has all it needs: the process is
17+// killed and pipe returns nil.
18+var errStop = errors.New("stop")
19+
20+// readArgs returns the arguments of a read-only git command on the bare
21+// repository in dir: no replace refs, no optional locks, literal pathspecs and
22+// no signature verification, whatever the repository configuration says.
23+func readArgs(dir string, args ...string) []string {
24+ base := []string{
25+ "--no-replace-objects", "--no-optional-locks", "--literal-pathspecs",
26+ "-c", "log.showSignature=false",
27+ "--git-dir=" + dir,
28+ }
29+ return append(base, args...)
30+}
31+
32+// pipe runs git and hands its standard output to fn. When fn returns nil the
33+// rest of the output is discarded and a failing exit status is an error; when
34+// fn returns errStop the process is killed and pipe returns nil; any other
35+// error kills the process and is returned. Without a deadline in ctx the
36+// process gets commandTimeout.
37+func (g *Git) pipe(ctx context.Context, args []string, fn func(*bufio.Reader) error) error {
38+ if _, ok := ctx.Deadline(); !ok {
39+ var cancel context.CancelFunc
40+ ctx, cancel = context.WithTimeout(ctx, commandTimeout)
41+ defer cancel()
42+ }
43+ base := ctx
44+ ctx, cancel := context.WithCancel(base)
45+ defer cancel()
46+
47+ cmd := exec.CommandContext(ctx, g.bin, args...)
48+ cmd.Env = g.env
49+ cmd.Dir = g.dataDir
50+ cmd.WaitDelay = 5 * time.Second
51+ stderr := &tailBuffer{max: 4096}
52+ cmd.Stderr = stderr
53+ stdout, err := cmd.StdoutPipe()
54+ if err != nil {
55+ return fmt.Errorf("git %s: %w", subcommand(args), err)
56+ }
57+ if err := cmd.Start(); err != nil {
58+ return fmt.Errorf("git %s: %w", subcommand(args), err)
59+ }
60+ r := bufio.NewReaderSize(stdout, 64*1024)
61+ ferr := fn(r)
62+ if ferr == nil {
63+ _, ferr = io.Copy(io.Discard, r)
64+ }
65+ if ferr != nil {
66+ cancel()
67+ }
68+ werr := cmd.Wait()
69+
70+ switch {
71+ case errors.Is(ferr, errStop):
72+ return nil
73+ case base.Err() != nil:
74+ return fmt.Errorf("git %s: %w", subcommand(args), base.Err())
75+ case ferr != nil:
76+ return ferr
77+ case werr != nil:
78+ if msg := stderr.String(); msg != "" {
79+ return fmt.Errorf("git %s: %w: %s", subcommand(args), werr, msg)
80+ }
81+ return fmt.Errorf("git %s: %w", subcommand(args), werr)
82+ }
83+ return nil
84+}
85+
86+// subcommand returns the git subcommand of args for error messages: the first
87+// argument that is neither an option nor the value of -c or -C.
88+func subcommand(args []string) string {
89+ for i := 0; i < len(args); i++ {
90+ a := args[i]
91+ switch {
92+ case a == "-c":
93+ i++
94+ case strings.HasPrefix(a, "-"):
95+ default:
96+ return a
97+ }
98+ }
99+ return "?"
100+}
101+
102+// readToken reads up to the next NUL and returns the text before it.
103+func readToken(r *bufio.Reader) (string, error) {
104+ s, err := r.ReadString(0)
105+ if err != nil {
106+ if errors.Is(err, io.EOF) && s != "" {
107+ return "", io.ErrUnexpectedEOF
108+ }
109+ return "", err
110+ }
111+ return s[:len(s)-1], nil
112+}
113+
114+// lineReader reads text lines, keeping at most max bytes of each: the rest of
115+// a longer line is dropped, so a pathological line cannot use unbounded memory.
116+type lineReader struct {
117+ r *bufio.Reader
118+ max int
119+ buf []byte
120+}
121+
122+func newLineReader(r *bufio.Reader, max int) *lineReader {
123+ return &lineReader{r: r, max: max}
124+}
125+
126+// next returns the next line without its "\n", or io.EOF. cut is true when
127+// the line was longer than max and the end of it was dropped. The slice is
128+// valid until the next call.
129+func (l *lineReader) next() (line []byte, cut bool, err error) {
130+ l.buf = l.buf[:0]
131+ got := false
132+ for {
133+ chunk, err := l.r.ReadSlice('\n')
134+ if len(chunk) > 0 {
135+ got = true
136+ }
137+ data := chunk
138+ if err == nil {
139+ data = chunk[:len(chunk)-1]
140+ }
141+ room := l.max - len(l.buf)
142+ if len(data) > room {
143+ cut = true
144+ }
145+ if room > 0 {
146+ l.buf = append(l.buf, data[:min(room, len(data))]...)
147+ }
148+ switch {
149+ case err == nil:
150+ return l.buf, cut, nil
151+ case errors.Is(err, bufio.ErrBufferFull):
152+ case errors.Is(err, io.EOF):
153+ if !got {
154+ return nil, false, io.EOF
155+ }
156+ return l.buf, cut, nil
157+ default:
158+ return nil, false, err
159+ }
160+ }
161+}
A internal/adapters/gitcli/pool_test.go +337 -0
@@ -0,0 +1,337 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "context"
7+ "crypto/sha256"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "runtime"
12+ "sync"
13+ "syscall"
14+ "testing"
15+ "time"
16+
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/require"
19+
20+ "bnema.dev/ermite/internal/adapters/gitcli"
21+ "bnema.dev/ermite/internal/domain"
22+)
23+
24+// alive reports whether a process still exists. The adapter collects its
25+// children, so a stopped one is gone and not a zombie.
26+func alive(pid int) bool { return syscall.Kill(pid, 0) == nil }
27+
28+func requireGone(t *testing.T, pids ...int) {
29+ t.Helper()
30+ require.Eventually(t, func() bool {
31+ for _, p := range pids {
32+ if alive(p) {
33+ return false
34+ }
35+ }
36+ return true
37+ }, 5*time.Second, 10*time.Millisecond, "processes %v are still running", pids)
38+}
39+
40+func TestCatFileProcessIsReusedAndStopsWhenIdle(t *testing.T) {
41+ ctx := context.Background()
42+ g, data := newReader(t, func(c *gitcli.Config) { c.CatFileIdle = 300 * time.Millisecond })
43+ h := newHistory(t, data)
44+
45+ assert.Empty(t, g.CatFilePIDs(), "nothing runs before the first read")
46+ _, err := g.ResolveRevision(ctx, h.name, domain.Revision{})
47+ require.NoError(t, err)
48+ pids := g.CatFilePIDs()
49+ require.Len(t, pids, 1)
50+ require.True(t, alive(pids[0]))
51+
52+ for range 5 {
53+ _, err = g.GetTree(ctx, h.name, domain.Revision{}, domain.TreePath{}, false)
54+ require.NoError(t, err)
55+ }
56+ assert.Equal(t, pids, g.CatFilePIDs(), "one long-lived process serves every request")
57+
58+ // Use keeps it alive past the idle timeout...
59+ for range 4 {
60+ time.Sleep(150 * time.Millisecond)
61+ _, err = g.ResolveRevision(ctx, h.name, domain.Revision{})
62+ require.NoError(t, err)
63+ }
64+ assert.Equal(t, pids, g.CatFilePIDs())
65+
66+ // ...and idleness stops it.
67+ require.Eventually(t, func() bool { return len(g.CatFilePIDs()) == 0 }, 5*time.Second, 10*time.Millisecond)
68+ requireGone(t, pids...)
69+
70+ // The next request starts a new one.
71+ _, err = g.ResolveRevision(ctx, h.name, domain.Revision{})
72+ require.NoError(t, err)
73+ next := g.CatFilePIDs()
74+ require.Len(t, next, 1)
75+ assert.NotEqual(t, pids, next)
76+}
77+
78+func TestCloseStopsEveryProcess(t *testing.T) {
79+ ctx := context.Background()
80+ g, data := newReader(t, nil) // default idle: 60 s, so only Close can stop them
81+ h := newHistory(t, data)
82+ f2 := newFixtureRepo(t, data, "second")
83+ f2.write("x", "x").commit("x")
84+ big := newFixtureRepo(t, data, "bigone")
85+ big.writeBytes("big", randomBytes(600<<10, 1)).commit("big")
86+
87+ _, err := g.ResolveRevision(ctx, h.name, domain.Revision{})
88+ require.NoError(t, err)
89+ _, err = g.ResolveRevision(ctx, f2.name, domain.Revision{})
90+ require.NoError(t, err)
91+ _, rc, err := g.OpenBlob(ctx, big.name, domain.Revision{}, tpath(t, "big"))
92+ require.NoError(t, err)
93+ _ = rc // left open on purpose: Close must stop its process too
94+
95+ pids := append(g.CatFilePIDs(), g.BlobStreamPIDs()...)
96+ require.GreaterOrEqual(t, len(pids), 4, "two cat-file processes, the big repo's one and the blob stream")
97+ for _, p := range pids {
98+ require.True(t, alive(p))
99+ }
100+
101+ cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
102+ defer cancel()
103+ require.NoError(t, g.Close(cctx))
104+ requireGone(t, pids...)
105+ assert.Empty(t, g.CatFilePIDs())
106+
107+ _, err = g.ResolveRevision(ctx, h.name, domain.Revision{})
108+ assert.ErrorIs(t, err, gitcli.ErrClosed)
109+ _, _, err = g.OpenBlob(ctx, big.name, domain.Revision{}, tpath(t, "big"))
110+ assert.ErrorIs(t, err, gitcli.ErrClosed)
111+ assert.NoError(t, g.Close(ctx), "Close is idempotent")
112+ _, err = io.Copy(io.Discard, rc)
113+ assert.ErrorIs(t, err, io.ErrUnexpectedEOF, "a stream cut by Close fails instead of looking complete")
114+ assert.NoError(t, rc.Close())
115+ assert.Equal(t, 0, g.BlobStreams())
116+}
117+
118+func TestPoolIsBounded(t *testing.T) {
119+ ctx := context.Background()
120+ g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = 2 })
121+ var names []domain.RepoName
122+ for i := range 6 {
123+ f := newFixtureRepo(t, data, fmt.Sprintf("r%d", i))
124+ f.write("x", "x").commit("x")
125+ names = append(names, f.name)
126+ }
127+ var seen []int
128+ for _, n := range names {
129+ _, err := g.ResolveRevision(ctx, n, domain.Revision{})
130+ require.NoError(t, err)
131+ pids := g.CatFilePIDs()
132+ assert.LessOrEqual(t, len(pids), 2)
133+ seen = append(seen, pids...)
134+ }
135+ // The evicted processes were stopped, not leaked.
136+ live := map[int]bool{}
137+ for _, p := range g.CatFilePIDs() {
138+ live[p] = true
139+ }
140+ for _, p := range seen {
141+ if !live[p] {
142+ requireGone(t, p)
143+ }
144+ }
145+}
146+
147+func TestPoolRestartsAfterProcessDeath(t *testing.T) {
148+ ctx := context.Background()
149+ g, data := newReader(t, nil)
150+ h := newHistory(t, data)
151+
152+ _, err := g.ResolveRevision(ctx, h.name, domain.Revision{})
153+ require.NoError(t, err)
154+ old := g.CatFilePIDs()
155+ require.Len(t, old, 1)
156+ require.NoError(t, syscall.Kill(old[0], syscall.SIGKILL))
157+ requireGone(t, old[0])
158+
159+ // The request that finds the dead process succeeds on a fresh one.
160+ got, err := g.ResolveRevision(ctx, h.name, domain.Revision{})
161+ require.NoError(t, err)
162+ assert.Equal(t, h.merge, got.CommitID.String())
163+ now := g.CatFilePIDs()
164+ require.Len(t, now, 1)
165+ assert.NotEqual(t, old, now)
166+}
167+
168+func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) {
169+ ctx := context.Background()
170+ g, data := newReader(t, nil)
171+ h := newHistory(t, data)
172+
173+ var wg sync.WaitGroup
174+ errs := make(chan error, 24*10)
175+ for i := range 24 {
176+ wg.Go(func() {
177+ for j := range 10 {
178+ switch (i + j) % 4 {
179+ case 0:
180+ res, err := g.ResolveRevision(ctx, h.name, rev(t, "feature/x"))
181+ if err == nil && res.CommitID.String() != h.side {
182+ err = fmt.Errorf("resolved %s", res.CommitID)
183+ }
184+ errs <- err
185+ case 1:
186+ info, rc, err := g.OpenBlob(ctx, h.name, domain.Revision{}, tpath(t, "b.txt"))
187+ if err == nil {
188+ var body []byte
189+ body, err = io.ReadAll(rc)
190+ _ = rc.Close()
191+ if err == nil && (info.Size != 19 || string(body) != "one\ntwo\nthree\nfour\n") {
192+ err = fmt.Errorf("blob %d %q", info.Size, body)
193+ }
194+ }
195+ errs <- err
196+ case 2:
197+ tree, err := g.GetTree(ctx, h.name, rev(t, "v2"), tpath(t, "d"), false)
198+ if err == nil && len(tree.Entries) != 2 {
199+ err = fmt.Errorf("tree of %d entries", len(tree.Entries))
200+ }
201+ errs <- err
202+ default:
203+ _, err := g.ResolveRefPath(ctx, h.name, "feature/x/d/f")
204+ errs <- err
205+ }
206+ }
207+ })
208+ }
209+ wg.Wait()
210+ close(errs)
211+ for err := range errs {
212+ require.NoError(t, err)
213+ }
214+ assert.Len(t, g.CatFilePIDs(), 1, "one process for one repository")
215+}
216+
217+func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) {
218+ g, data := newReader(t, nil)
219+ h := newHistory(t, data)
220+
221+ // Many requests, some with a context that dies at a random point. Whatever
222+ // state a killed process was in, later requests must get right answers.
223+ var wg sync.WaitGroup
224+ for i := range 16 {
225+ wg.Go(func() {
226+ for j := range 20 {
227+ ctx, cancel := context.WithTimeout(context.Background(), time.Duration((i*7+j*3)%5)*time.Millisecond)
228+ res, err := g.ResolveRevision(ctx, h.name, rev(t, "v2"))
229+ cancel()
230+ if err == nil {
231+ assert.Equal(t, h.c2, res.CommitID.String())
232+ } else {
233+ assert.True(t, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled), "%v", err)
234+ }
235+ }
236+ })
237+ }
238+ wg.Wait()
239+ res, err := g.ResolveRevision(context.Background(), h.name, rev(t, "v2"))
240+ require.NoError(t, err)
241+ assert.Equal(t, h.c2, res.CommitID.String())
242+}
243+
244+func TestBlobStreamStopsWhenContextEndsOrReaderCloses(t *testing.T) {
245+ g, data := newReader(t, nil)
246+ f := newFixtureRepo(t, data, "bigone")
247+ f.writeBytes("big", randomBytes(2<<20, 3)).commit("big")
248+
249+ ctx, cancel := context.WithCancel(context.Background())
250+ _, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, "big"))
251+ require.NoError(t, err)
252+ pids := g.BlobStreamPIDs()
253+ require.Len(t, pids, 1)
254+ _, err = io.ReadFull(rc, make([]byte, 1000))
255+ require.NoError(t, err)
256+
257+ cancel() // the client went away mid-stream
258+ requireGone(t, pids...)
259+ _, err = io.Copy(io.Discard, rc)
260+ assert.Error(t, err, "a cut stream does not look complete")
261+ require.NoError(t, rc.Close())
262+ assert.Equal(t, 0, g.BlobStreams())
263+
264+ // Closing early stops the process as well.
265+ _, rc, err = g.OpenBlob(context.Background(), f.name, domain.Revision{}, tpath(t, "big"))
266+ require.NoError(t, err)
267+ pids = g.BlobStreamPIDs()
268+ require.Len(t, pids, 1)
269+ require.NoError(t, rc.Close())
270+ requireGone(t, pids...)
271+ assert.Equal(t, 0, g.BlobStreams())
272+
273+ // A context that is already cancelled starts nothing.
274+ cctx, cancel2 := context.WithCancel(context.Background())
275+ cancel2()
276+ _, _, err = g.OpenBlob(cctx, f.name, domain.Revision{}, tpath(t, "big"))
277+ assert.ErrorIs(t, err, context.Canceled)
278+ assert.Equal(t, 0, g.BlobStreams())
279+}
280+
281+// TestOpenBlobStreams50MiB is AC3: a 50 MiB file is streamed with memory that
282+// does not depend on its size. The test reads it in 64 KiB chunks, as the
283+// GetBlob RPC does, and bounds both what was allocated while reading and the
284+// peak of the heap.
285+func TestOpenBlobStreams50MiB(t *testing.T) {
286+ if testing.Short() {
287+ t.Skip("50 MiB fixture")
288+ }
289+ ctx := context.Background()
290+ g, data := newReader(t, nil)
291+ f := newFixtureRepo(t, data, "huge")
292+ const size = 50 << 20
293+ content := randomBytes(size, 42)
294+ want := sha256.Sum256(content)
295+ f.writeBytes("huge.bin", content).commit("huge")
296+ content = nil //nolint:ineffassign // let the 50 MiB go before measuring
297+
298+ runtime.GC()
299+ var before, after runtime.MemStats
300+ runtime.ReadMemStats(&before)
301+
302+ info, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, "huge.bin"))
303+ require.NoError(t, err)
304+ assert.Equal(t, int64(size), info.Size)
305+ assert.True(t, info.Binary)
306+ assert.Equal(t, "application/octet-stream", info.MediaType)
307+
308+ h := sha256.New()
309+ buf := make([]byte, 64<<10)
310+ var total int64
311+ var peak uint64
312+ for i := 0; ; i++ {
313+ n, err := rc.Read(buf)
314+ total += int64(n)
315+ h.Write(buf[:n])
316+ if i%64 == 0 {
317+ var m runtime.MemStats
318+ runtime.ReadMemStats(&m)
319+ peak = max(peak, m.HeapAlloc)
320+ }
321+ if errors.Is(err, io.EOF) {
322+ break
323+ }
324+ require.NoError(t, err)
325+ }
326+ require.NoError(t, rc.Close())
327+ runtime.ReadMemStats(&after)
328+
329+ assert.Equal(t, int64(size), total)
330+ assert.Equal(t, want[:], h.Sum(nil), "content is intact")
331+
332+ allocated := after.TotalAlloc - before.TotalAlloc
333+ assert.Less(t, allocated, uint64(8<<20), "bytes allocated while streaming 50 MiB: %d", allocated)
334+ growth := int64(peak) - int64(before.HeapAlloc)
335+ assert.Less(t, growth, int64(8<<20), "heap growth while streaming 50 MiB: %d", growth)
336+ t.Logf("streamed %d MiB: allocated %d KiB, peak heap growth %d KiB", size>>20, allocated>>10, growth>>10)
337+}
A internal/adapters/gitcli/read.go +580 -0
@@ -0,0 +1,580 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "bufio"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "os/exec"
12+ "slices"
13+ "strconv"
14+ "strings"
15+ "sync"
16+ "time"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+var _ ports.RepoReader = (*Git)(nil)
23+
24+const (
25+ headsRefPrefix = "refs/heads/"
26+ tagsRefPrefix = "refs/tags/"
27+ // maxRefLine bounds a line of for-each-ref.
28+ maxRefLine = 16 * 1024
29+ // lastCommitWorkers is how many `git log -1` run at once for the last
30+ // commits of a directory.
31+ lastCommitWorkers = 8
32+)
33+
34+// notFound reports whether err means a missing object.
35+func notFound(err error) bool { return errors.Is(err, errObjectMissing) }
36+
37+// ListRefs lists HEAD, the branches and the tags with `git for-each-ref`.
38+// An annotated tag is followed to its commit; a tag of a tag is followed with
39+// one `cat-file` request each.
40+//
41+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain refs
42+func (g *Git) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) {
43+ dir, err := g.repoDir(repo)
44+ if err != nil {
45+ return nil, err
46+ }
47+ type raw struct {
48+ name, id, typ, derefID, derefType string
49+ }
50+ var raws []raw
51+ args := readArgs(dir, "for-each-ref",
52+ "--format=%(refname)%00%(objectname)%00%(objecttype)%00%(*objectname)%00%(*objecttype)",
53+ "refs/heads/", "refs/tags/")
54+ err = g.pipe(ctx, args, func(r *bufio.Reader) error {
55+ lr := newLineReader(r, maxRefLine)
56+ for {
57+ line, _, err := lr.next()
58+ if errors.Is(err, io.EOF) {
59+ return nil
60+ }
61+ if err != nil {
62+ return err
63+ }
64+ f := strings.Split(string(line), "\x00")
65+ if len(f) != 5 {
66+ return fmt.Errorf("git for-each-ref: unexpected line %q", truncateText(string(line)))
67+ }
68+ raws = append(raws, raw{f[0], f[1], f[2], f[3], f[4]})
69+ }
70+ })
71+ if err != nil {
72+ return nil, fmt.Errorf("list refs of %q: %w", repo, err)
73+ }
74+
75+ refs := make([]domain.Ref, 0, len(raws)+1)
76+ byName := make(map[string]domain.Ref, len(raws))
77+ var nested []int // indices in refs of tags that point to a tag
78+ for _, rw := range raws {
79+ ref := domain.Ref{Name: rw.name}
80+ switch {
81+ case strings.HasPrefix(rw.name, headsRefPrefix):
82+ ref.Kind, ref.ShortName = domain.RefKindBranch, strings.TrimPrefix(rw.name, headsRefPrefix)
83+ case strings.HasPrefix(rw.name, tagsRefPrefix):
84+ ref.Kind, ref.ShortName = domain.RefKindTag, strings.TrimPrefix(rw.name, tagsRefPrefix)
85+ default:
86+ continue
87+ }
88+ if ref.ID, err = domain.ParseObjectID(rw.id); err != nil {
89+ return nil, fmt.Errorf("list refs of %q: %w", repo, err)
90+ }
91+ switch {
92+ case rw.typ == "commit":
93+ ref.CommitID = ref.ID
94+ case rw.typ == "tag" && rw.derefType == "commit":
95+ if ref.CommitID, err = domain.ParseObjectID(rw.derefID); err != nil {
96+ return nil, fmt.Errorf("list refs of %q: %w", repo, err)
97+ }
98+ case rw.typ == "tag" && rw.derefType == "tag":
99+ nested = append(nested, len(refs))
100+ }
101+ refs = append(refs, ref)
102+ byName[ref.Name] = ref
103+ }
104+ if len(nested) > 0 {
105+ err := g.cat.do(ctx, dir, func(c *catConn) error {
106+ for _, i := range nested {
107+ h, err := c.info(refs[i].Name + "^{commit}")
108+ if notFound(err) {
109+ continue
110+ }
111+ if err != nil {
112+ return err
113+ }
114+ if refs[i].CommitID, err = domain.ParseObjectID(h.ID); err != nil {
115+ return err
116+ }
117+ }
118+ return nil
119+ })
120+ if err != nil {
121+ return nil, fmt.Errorf("follow tags of %q: %w", repo, err)
122+ }
123+ for _, i := range nested {
124+ byName[refs[i].Name] = refs[i]
125+ }
126+ }
127+
128+ head := domain.Ref{Name: "HEAD", Kind: domain.RefKindHead}
129+ target, err := g.headTarget(ctx, dir)
130+ if err != nil {
131+ return nil, fmt.Errorf("read HEAD of %q: %w", repo, err)
132+ }
133+ if b, ok := byName[target]; ok && target != "" {
134+ head.ShortName, head.ID, head.CommitID = b.ShortName, b.ID, b.CommitID
135+ } else if short, ok := strings.CutPrefix(target, headsRefPrefix); ok {
136+ head.ShortName = short // unborn branch
137+ }
138+ return append([]domain.Ref{head}, refs...), nil
139+}
140+
141+// headTarget returns the ref HEAD points to, or "" if HEAD is detached.
142+func (g *Git) headTarget(ctx context.Context, dir string) (string, error) {
143+ out, err := g.run(ctx, readArgs(dir, "symbolic-ref", "--quiet", "HEAD")...)
144+ if err != nil {
145+ var exit *exec.ExitError
146+ if errors.As(err, &exit) && exit.ExitCode() == 1 {
147+ return "", nil
148+ }
149+ return "", err
150+ }
151+ return strings.TrimSpace(out), nil
152+}
153+
154+// revCandidate is one way to read a revision as a commit.
155+type revCandidate struct {
156+ spec string // object expression for cat-file
157+ ref string // full ref name it stands for, "" for ids and HEAD
158+}
159+
160+// candidates lists, in order, how a revision is tried: the branch, then the
161+// tag, then (for "HEAD", full ref names, ids and abbreviations) as written.
162+// Other forms Git understands, such as "main~2" or "FETCH_HEAD", are not
163+// revisions of ermite.
164+func candidates(rev domain.Revision) []revCandidate {
165+ s := rev.String()
166+ switch {
167+ case rev.IsHead() || s == "HEAD":
168+ return []revCandidate{{spec: "HEAD^{commit}"}}
169+ }
170+ out := []revCandidate{
171+ {spec: headsRefPrefix + s + "^{commit}", ref: headsRefPrefix + s},
172+ {spec: tagsRefPrefix + s + "^{commit}", ref: tagsRefPrefix + s},
173+ }
174+ switch {
175+ case strings.HasPrefix(s, "refs/"):
176+ out = append(out, revCandidate{spec: s + "^{commit}", ref: s})
177+ case rev.IsObjectID() || rev.IsAbbrevID():
178+ out = append(out, revCandidate{spec: s + "^{commit}"})
179+ }
180+ return out
181+}
182+
183+// resolve resolves rev on an open cat-file connection.
184+//
185+//hexcheck:ignore suspicious-business-logic-in-adapter maps a revision to git object names, a git detail of the reader
186+func resolve(c *catConn, rev domain.Revision) (domain.ResolvedRevision, error) {
187+ for _, cand := range candidates(rev) {
188+ h, err := c.info(cand.spec)
189+ if notFound(err) {
190+ continue
191+ }
192+ if err != nil {
193+ return domain.ResolvedRevision{}, err
194+ }
195+ if h.Type != "commit" {
196+ continue
197+ }
198+ id, err := domain.ParseObjectID(h.ID)
199+ if err != nil {
200+ return domain.ResolvedRevision{}, c.fail(err)
201+ }
202+ return domain.ResolvedRevision{CommitID: id, Ref: cand.ref}, nil
203+ }
204+ return domain.ResolvedRevision{}, fmt.Errorf("revision %q: %w", rev, domain.ErrNotFound)
205+}
206+
207+// ResolveRevision resolves a revision with `cat-file`.
208+//
209+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
210+func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
211+ dir, err := g.repoDir(repo)
212+ if err != nil {
213+ return domain.ResolvedRevision{}, err
214+ }
215+ var res domain.ResolvedRevision
216+ err = g.cat.do(ctx, dir, func(c *catConn) (err error) {
217+ res, err = resolve(c, rev)
218+ return err
219+ })
220+ if err != nil {
221+ return domain.ResolvedRevision{}, fmt.Errorf("resolve %q in %q: %w", rev, repo, err)
222+ }
223+ return res, nil
224+}
225+
226+// maxRefPathTries bounds the number of leading segments tried as a ref name.
227+const maxRefPathTries = 32
228+
229+// ResolveRefPath tries the leading segments of path as a revision (branch,
230+// tag, full ref name, HEAD; a single segment may also be a commit id), longest
231+// first, with up to one `cat-file` request per candidate on the repository's
232+// process. At most 32 segments are tried.
233+//
234+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
235+func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) {
236+ dir, err := g.repoDir(repo)
237+ if err != nil {
238+ return domain.RefPath{}, err
239+ }
240+ path = strings.Trim(path, "/")
241+ segs := strings.Split(path, "/")
242+ if path == "" || slices.Contains(segs, "") {
243+ return domain.RefPath{}, fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
244+ }
245+ var out domain.RefPath
246+ err = g.cat.do(ctx, dir, func(c *catConn) error {
247+ out = domain.RefPath{}
248+ for n := min(len(segs), maxRefPathTries); n >= 1; n-- {
249+ name := strings.Join(segs[:n], "/")
250+ rev, err := domain.ParseRevision(name)
251+ if err != nil {
252+ continue
253+ }
254+ res, err := resolve(c, rev)
255+ if errors.Is(err, domain.ErrNotFound) {
256+ continue
257+ }
258+ if err != nil {
259+ return err
260+ }
261+ rest, err := domain.ParseTreePath(strings.Join(segs[n:], "/"))
262+ if err != nil {
263+ return err
264+ }
265+ out = domain.RefPath{Name: name, Ref: res.Ref, CommitID: res.CommitID, Rest: rest}
266+ return nil
267+ }
268+ return fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
269+ })
270+ if err != nil {
271+ return domain.RefPath{}, fmt.Errorf("resolve path %q in %q: %w", path, repo, err)
272+ }
273+ return out, nil
274+}
275+
276+// object is what lookup finds at a path of a revision.
277+type object struct {
278+ commit domain.ObjectID
279+ info catInfo
280+}
281+
282+// lookup resolves rev and reads the header of the object at path, in one
283+// request series on the repository's process. A missing path is
284+// domain.ErrNotFound.
285+func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path domain.TreePath) (object, error) {
286+ var obj object
287+ err := g.cat.do(ctx, dir, func(c *catConn) error {
288+ res, err := resolve(c, rev)
289+ if err != nil {
290+ return err
291+ }
292+ spec := res.CommitID.String() + "^{tree}"
293+ if !path.IsRoot() {
294+ spec = res.CommitID.String() + ":" + path.String()
295+ }
296+ h, err := c.info(spec)
297+ if notFound(err) {
298+ return fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
299+ }
300+ if err != nil {
301+ return err
302+ }
303+ obj = object{commit: res.CommitID, info: h}
304+ return nil
305+ })
306+ return obj, err
307+}
308+
309+// GetTree lists a directory with `git ls-tree -z -l`.
310+//
311+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
312+func (g *Git) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLast bool) (domain.Tree, error) {
313+ dir, err := g.repoDir(repo)
314+ if err != nil {
315+ return domain.Tree{}, err
316+ }
317+ obj, err := g.lookup(ctx, dir, rev, path)
318+ if err != nil {
319+ return domain.Tree{}, fmt.Errorf("tree %q of %q: %w", path, repo, err)
320+ }
321+ if obj.info.Type != "tree" {
322+ return domain.Tree{}, fmt.Errorf("tree %q of %q: %w", path, repo, domain.ErrNotDirectory)
323+ }
324+ treeID, err := domain.ParseObjectID(obj.info.ID)
325+ if err != nil {
326+ return domain.Tree{}, err
327+ }
328+ tree := domain.Tree{CommitID: obj.commit, ID: treeID}
329+
330+ err = g.pipe(ctx, readArgs(dir, "ls-tree", "-z", "-l", treeID.String()), func(r *bufio.Reader) error {
331+ for {
332+ tok, err := readToken(r)
333+ if errors.Is(err, io.EOF) {
334+ return nil
335+ }
336+ if err != nil {
337+ return err
338+ }
339+ e, err := parseTreeEntry(tok, path)
340+ if err != nil {
341+ return err
342+ }
343+ tree.Entries = append(tree.Entries, e)
344+ }
345+ })
346+ if err != nil {
347+ return domain.Tree{}, fmt.Errorf("list tree %q of %q: %w", path, repo, err)
348+ }
349+ slices.SortStableFunc(tree.Entries, func(a, b domain.TreeEntry) int {
350+ ad, bd := a.Kind == domain.EntryDirectory, b.Kind == domain.EntryDirectory
351+ switch {
352+ case ad && !bd:
353+ return -1
354+ case !ad && bd:
355+ return 1
356+ }
357+ return strings.Compare(a.Name, b.Name)
358+ })
359+
360+ if includeLast {
361+ if err := g.fillLastCommits(ctx, dir, &tree); err != nil {
362+ return domain.Tree{}, fmt.Errorf("last commits of %q in %q: %w", path, repo, err)
363+ }
364+ }
365+ return tree, nil
366+}
367+
368+// parseTreeEntry parses "<mode> <type> <id> <size>\t<name>" of ls-tree -l.
369+//
370+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
371+func parseTreeEntry(rec string, parent domain.TreePath) (domain.TreeEntry, error) {
372+ meta, name, ok := strings.Cut(rec, "\t")
373+ f := strings.Fields(meta)
374+ if !ok || len(f) != 4 || name == "" {
375+ return domain.TreeEntry{}, fmt.Errorf("git ls-tree: unexpected entry %q", truncateText(rec))
376+ }
377+ mode, err := strconv.ParseUint(f[0], 8, 32)
378+ if err != nil {
379+ return domain.TreeEntry{}, fmt.Errorf("git ls-tree: mode of %q: %w", truncateText(rec), err)
380+ }
381+ id, err := domain.ParseObjectID(f[2])
382+ if err != nil {
383+ return domain.TreeEntry{}, fmt.Errorf("git ls-tree: %w", err)
384+ }
385+ var size int64
386+ if f[3] != "-" {
387+ if size, err = strconv.ParseInt(f[3], 10, 64); err != nil {
388+ return domain.TreeEntry{}, fmt.Errorf("git ls-tree: size of %q: %w", truncateText(rec), err)
389+ }
390+ }
391+ kind := domain.EntryKindOf(uint32(mode))
392+ if kind == "" {
393+ return domain.TreeEntry{}, fmt.Errorf("git ls-tree: unknown mode %s of %q", f[0], name)
394+ }
395+ return domain.TreeEntry{
396+ Name: name, Path: parent.Join(name), Kind: kind, Mode: uint32(mode), ID: id, Size: size,
397+ }, nil
398+}
399+
400+// fillLastCommits sets the last commit of the first MaxLastCommitEntries
401+// entries with `git log -1 <commit> -- <path>`, a few at a time. Git answers
402+// each from the commit-graph and its changed-path filters when they exist.
403+// Entries beyond the bound are left without one and the tree says so.
404+func (g *Git) fillLastCommits(ctx context.Context, dir string, tree *domain.Tree) error {
405+ n := len(tree.Entries)
406+ if n > domain.MaxLastCommitEntries {
407+ n = domain.MaxLastCommitEntries
408+ tree.LastCommitsOmitted = true
409+ }
410+ ctx, cancel := context.WithCancel(ctx)
411+ defer cancel()
412+
413+ var (
414+ wg sync.WaitGroup
415+ mu sync.Mutex
416+ firstErr error
417+ jobs = make(chan int)
418+ )
419+ for range min(lastCommitWorkers, n) {
420+ wg.Go(func() {
421+ for i := range jobs {
422+ c, err := g.lastCommit(ctx, dir, tree.CommitID, tree.Entries[i].Path)
423+ mu.Lock()
424+ if err != nil && firstErr == nil {
425+ firstErr = err
426+ cancel()
427+ }
428+ if err == nil {
429+ tree.Entries[i].LastCommit = &c
430+ }
431+ mu.Unlock()
432+ }
433+ })
434+ }
435+feed:
436+ for i := range n {
437+ select {
438+ case jobs <- i:
439+ case <-ctx.Done():
440+ break feed
441+ }
442+ }
443+ close(jobs)
444+ wg.Wait()
445+ if firstErr != nil {
446+ return firstErr
447+ }
448+ return ctx.Err()
449+}
450+
451+// lastCommit returns the newest commit reachable from head that changed path;
452+// an empty path means head itself.
453+//
454+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
455+func (g *Git) lastCommit(ctx context.Context, dir string, head domain.ObjectID, path string) (domain.Commit, error) {
456+ var c domain.Commit
457+ found := false
458+ args := readArgs(dir, "log", "-z", "-n", "1", "--format="+commitFormat, "--end-of-options", head.String())
459+ if path != "" {
460+ args = append(args, "--", path)
461+ }
462+ err := g.pipe(ctx, args, func(r *bufio.Reader) (err error) {
463+ c, err = readCommit(r)
464+ if errors.Is(err, io.EOF) {
465+ return nil
466+ }
467+ found = err == nil
468+ return err
469+ })
470+ if err != nil {
471+ return domain.Commit{}, err
472+ }
473+ if !found {
474+ return domain.Commit{}, fmt.Errorf("no commit touches %q: %w", path, domain.ErrNotFound)
475+ }
476+ return c, nil
477+}
478+
479+// commitFormat is the `git log` format of a commit: NUL separated fields,
480+// the message last. With -z the commits are NUL terminated, so a commit is
481+// nine tokens.
482+const commitFormat = "%H%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%B"
483+
484+// readCommit reads one commit in commitFormat. It returns io.EOF at the end
485+// of the output.
486+//
487+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
488+func readCommit(r *bufio.Reader) (domain.Commit, error) {
489+ var f [9]string
490+ for i := range f {
491+ tok, err := readToken(r)
492+ if err != nil {
493+ if i > 0 && errors.Is(err, io.EOF) {
494+ err = io.ErrUnexpectedEOF
495+ }
496+ return domain.Commit{}, err
497+ }
498+ f[i] = tok
499+ }
500+ id, err := domain.ParseObjectID(f[0])
501+ if err != nil {
502+ return domain.Commit{}, fmt.Errorf("git log: %w", err)
503+ }
504+ c := domain.Commit{ID: id}
505+ for p := range strings.FieldsSeq(f[1]) {
506+ pid, err := domain.ParseObjectID(p)
507+ if err != nil {
508+ return domain.Commit{}, fmt.Errorf("git log: parent of %s: %w", id, err)
509+ }
510+ c.Parents = append(c.Parents, pid)
511+ }
512+ if c.Author, err = signature(f[2], f[3], f[4]); err != nil {
513+ return domain.Commit{}, fmt.Errorf("git log: author of %s: %w", id, err)
514+ }
515+ if c.Committer, err = signature(f[5], f[6], f[7]); err != nil {
516+ return domain.Commit{}, fmt.Errorf("git log: committer of %s: %w", id, err)
517+ }
518+ c.Message = strings.TrimRight(strings.ToValidUTF8(f[8], "\uFFFD"), "\n")
519+ c.Summary, _, _ = strings.Cut(c.Message, "\n")
520+ return c, nil
521+}
522+
523+func signature(name, email, unix string) (domain.Signature, error) {
524+ sec, err := strconv.ParseInt(unix, 10, 64)
525+ if err != nil {
526+ return domain.Signature{}, err
527+ }
528+ return domain.Signature{
529+ Name: strings.ToValidUTF8(name, "\uFFFD"),
530+ Email: strings.ToValidUTF8(email, "\uFFFD"),
531+ Time: time.Unix(sec, 0).UTC(),
532+ }, nil
533+}
534+
535+// ListCommits pages through `git log`, which stops after Skip+Limit+1 commits.
536+//
537+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
538+func (g *Git) ListCommits(ctx context.Context, repo domain.RepoName, q ports.CommitQuery) (ports.CommitPage, error) {
539+ if q.Limit < 1 || q.Limit > ports.MaxCommitLimit || q.Skip < 0 {
540+ return ports.CommitPage{}, fmt.Errorf("list commits: %w: limit %d, skip %d", domain.ErrInvalidPageSize, q.Limit, q.Skip)
541+ }
542+ dir, err := g.repoDir(repo)
543+ if err != nil {
544+ return ports.CommitPage{}, err
545+ }
546+ var head domain.ResolvedRevision
547+ err = g.cat.do(ctx, dir, func(c *catConn) (err error) {
548+ head, err = resolve(c, q.Revision)
549+ return err
550+ })
551+ if err != nil {
552+ return ports.CommitPage{}, fmt.Errorf("history of %q: %w", repo, err)
553+ }
554+ page := ports.CommitPage{Head: head.CommitID}
555+ args := readArgs(dir, "log", "-z", "--skip="+strconv.Itoa(q.Skip), "-n", strconv.Itoa(q.Limit+1),
556+ "--format="+commitFormat, "--end-of-options", head.CommitID.String())
557+ if !q.Path.IsRoot() {
558+ args = append(args, "--", q.Path.String())
559+ }
560+ err = g.pipe(ctx, args, func(r *bufio.Reader) error {
561+ for {
562+ c, err := readCommit(r)
563+ if errors.Is(err, io.EOF) {
564+ return nil
565+ }
566+ if err != nil {
567+ return err
568+ }
569+ if len(page.Commits) == q.Limit {
570+ page.HasMore = true
571+ return errStop
572+ }
573+ page.Commits = append(page.Commits, c)
574+ }
575+ })
576+ if err != nil {
577+ return ports.CommitPage{}, fmt.Errorf("history of %q: %w", repo, err)
578+ }
579+ return page, nil
580+}
A internal/adapters/gitcli/read_helpers_test.go +45 -0
@@ -0,0 +1,45 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "context"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/adapters/gitcli"
16+)
17+
18+// newReader prepares a data dir and an adapter like newGit, with the pool
19+// settings of cfg applied. Without a usable git it skips locally and fails
20+// when CI is set. The adapter is closed when the test ends.
21+func newReader(t *testing.T, tune func(*gitcli.Config)) (*gitcli.Git, string) {
22+ t.Helper()
23+ bin, err := exec.LookPath("git")
24+ if err != nil {
25+ unusable(t, "git not found")
26+ }
27+ data := t.TempDir()
28+ for _, d := range []string{"repos/.staging", "trash", "run/git-home", "kv"} {
29+ require.NoError(t, os.MkdirAll(filepath.Join(data, d), 0o700))
30+ }
31+ cfg := gitcli.Config{Bin: bin, DataDir: data, Path: os.Getenv("PATH")}
32+ if tune != nil {
33+ tune(&cfg)
34+ }
35+ g := gitcli.New(cfg)
36+ if _, err := g.CheckVersion(context.Background()); err != nil {
37+ unusable(t, "git is not usable by ermite: %v", err)
38+ }
39+ t.Cleanup(func() {
40+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
41+ defer cancel()
42+ require.NoError(t, g.Close(ctx))
43+ })
44+ return g, data
45+}
A internal/adapters/gitcli/read_test.go +656 -0
@@ -0,0 +1,656 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "io"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+func rev(t *testing.T, s string) domain.Revision {
21+ t.Helper()
22+ r, err := domain.ParseRevision(s)
23+ require.NoError(t, err)
24+ return r
25+}
26+
27+func tpath(t *testing.T, s string) domain.TreePath {
28+ t.Helper()
29+ p, err := domain.ParseTreePath(s)
30+ require.NoError(t, err)
31+ return p
32+}
33+
34+// history is the repository of the read tests: three commits on main, a
35+// branch with a slash, a merge, lightweight and annotated tags.
36+type history struct {
37+ *fixtureRepo
38+ c1, c2, c3, side, merge string
39+}
40+
41+func newHistory(t *testing.T, data string) *history {
42+ t.Helper()
43+ f := newFixtureRepo(t, data, "hist")
44+ h := &history{fixtureRepo: f}
45+ f.write("a.txt", "one\ntwo\nthree\n").
46+ writeBytes("bin", []byte{0, 1}).
47+ write("d/f", "hi\n").
48+ write("d/sub/g", "deep\n").
49+ write("we:ird*.txt", "x").
50+ write("README.md", "# hello\n").
51+ write("empty", "")
52+ h.c1 = f.commit("first\n\nbody of the first commit\n")
53+
54+ f.mv("a.txt", "b.txt").write("b.txt", "one\ntwo\nthree\nfour\n").
55+ writeBytes("bin", []byte{0, 2}).
56+ chmod("d/f", 0o755).
57+ write("n.txt", "new\nnonl").
58+ remove("README.md")
59+ h.c2 = f.commit("second")
60+
61+ f.remove("n.txt").symlink("b.txt", "n.txt")
62+ h.c3 = f.commit("third: type change")
63+
64+ f.checkoutNew("feature/x")
65+ f.write("side.txt", "s\n")
66+ h.side = f.commit("side work")
67+ f.checkout("main")
68+ f.write("main.txt", "m\n")
69+ f.commit("main work")
70+ h.merge = f.merge("feature/x")
71+
72+ f.git("tag", "v1", h.c1)
73+ f.git("tag", "-a", "-m", "release two", "v2", h.c2)
74+ f.git("tag", "-a", "-m", "tag of a tag", "v2-again", "v2")
75+ f.git("tag", "-a", "-m", "tag of a tree", "tree-tag", h.merge+"^{tree}")
76+ f.git("branch", "release/1.0", h.c2)
77+ f.git("branch", "rel/1", h.c1)
78+ f.git("tag", "rel/1/x", h.c3)
79+ return h
80+}
81+
82+func TestListRefs(t *testing.T) {
83+ ctx := context.Background()
84+ g, data := newReader(t, nil)
85+ h := newHistory(t, data)
86+
87+ refs, err := g.ListRefs(ctx, h.name)
88+ require.NoError(t, err)
89+
90+ type row struct {
91+ name string
92+ short string
93+ kind domain.RefKind
94+ id string
95+ commi string
96+ }
97+ var got []row
98+ for _, r := range refs {
99+ got = append(got, row{r.Name, r.ShortName, r.Kind, r.ID.String(), r.CommitID.String()})
100+ }
101+ tagID := func(n string) string { return h.git("rev-parse", "refs/tags/"+n) }
102+ assert.Equal(t, []row{
103+ {"HEAD", "main", domain.RefKindHead, h.merge, h.merge},
104+ {"refs/heads/feature/x", "feature/x", domain.RefKindBranch, h.side, h.side},
105+ {"refs/heads/main", "main", domain.RefKindBranch, h.merge, h.merge},
106+ {"refs/heads/rel/1", "rel/1", domain.RefKindBranch, h.c1, h.c1},
107+ {"refs/heads/release/1.0", "release/1.0", domain.RefKindBranch, h.c2, h.c2},
108+ {"refs/tags/rel/1/x", "rel/1/x", domain.RefKindTag, h.c3, h.c3},
109+ {"refs/tags/tree-tag", "tree-tag", domain.RefKindTag, tagID("tree-tag"), ""},
110+ {"refs/tags/v1", "v1", domain.RefKindTag, h.c1, h.c1},
111+ {"refs/tags/v2", "v2", domain.RefKindTag, tagID("v2"), h.c2},
112+ {"refs/tags/v2-again", "v2-again", domain.RefKindTag, tagID("v2-again"), h.c2},
113+ }, got)
114+}
115+
116+func TestListRefsEmptyAndMissing(t *testing.T) {
117+ ctx := context.Background()
118+ g, data := newReader(t, nil)
119+ f := newFixtureRepo(t, data, "empty")
120+
121+ refs, err := g.ListRefs(ctx, f.name)
122+ require.NoError(t, err)
123+ require.Len(t, refs, 1)
124+ assert.Equal(t, "HEAD", refs[0].Name)
125+ assert.Equal(t, "main", refs[0].ShortName, "unborn default branch")
126+ assert.True(t, refs[0].ID.IsZero())
127+ assert.True(t, refs[0].CommitID.IsZero())
128+
129+ _, err = g.ListRefs(ctx, name(t, "nope"))
130+ assert.ErrorIs(t, err, domain.ErrNotFound)
131+
132+ // No commit to resolve in an empty repository.
133+ _, err = g.ResolveRevision(ctx, f.name, domain.Revision{})
134+ assert.ErrorIs(t, err, domain.ErrNotFound)
135+ _, err = g.GetTree(ctx, f.name, domain.Revision{}, domain.TreePath{}, false)
136+ assert.ErrorIs(t, err, domain.ErrNotFound)
137+ _, err = g.ListCommits(ctx, f.name, ports.CommitQuery{Limit: 10})
138+ assert.ErrorIs(t, err, domain.ErrNotFound)
139+}
140+
141+func TestDetachedHead(t *testing.T) {
142+ ctx := context.Background()
143+ g, data := newReader(t, nil)
144+ h := newHistory(t, data)
145+ h.git("update-ref", "--no-deref", "HEAD", h.c1)
146+
147+ refs, err := g.ListRefs(ctx, h.name)
148+ require.NoError(t, err)
149+ assert.Equal(t, "HEAD", refs[0].Name)
150+ assert.Empty(t, refs[0].ShortName)
151+}
152+
153+func TestResolveRevision(t *testing.T) {
154+ ctx := context.Background()
155+ g, data := newReader(t, nil)
156+ h := newHistory(t, data)
157+
158+ tests := []struct {
159+ rev string
160+ commit string
161+ ref string
162+ }{
163+ {"", h.merge, ""},
164+ {"HEAD", h.merge, ""},
165+ {"main", h.merge, "refs/heads/main"},
166+ {"feature/x", h.side, "refs/heads/feature/x"},
167+ {"refs/heads/feature/x", h.side, "refs/heads/feature/x"},
168+ {"v1", h.c1, "refs/tags/v1"},
169+ {"v2", h.c2, "refs/tags/v2"},
170+ {"v2-again", h.c2, "refs/tags/v2-again"},
171+ {"refs/tags/v2", h.c2, "refs/tags/v2"},
172+ {h.c2, h.c2, ""},
173+ {h.c2[:7], h.c2, ""},
174+ {h.c2[:12], h.c2, ""},
175+ }
176+ for _, tt := range tests {
177+ t.Run(tt.rev, func(t *testing.T) {
178+ got, err := g.ResolveRevision(ctx, h.name, rev(t, tt.rev))
179+ require.NoError(t, err)
180+ assert.Equal(t, tt.commit, got.CommitID.String())
181+ assert.Equal(t, tt.ref, got.Ref)
182+ })
183+ }
184+ for _, bad := range []string{"nope", "tree-tag", "deadbeefdeadbeef", "HEAD~1", h.c2[:6], "refs/heads", h.git("rev-parse", h.merge+"^{tree}")} {
185+ t.Run("missing "+bad, func(t *testing.T) {
186+ r, err := domain.ParseRevision(bad)
187+ if err != nil {
188+ return // not a revision at all, rejected before the reader
189+ }
190+ _, err = g.ResolveRevision(ctx, h.name, r)
191+ assert.ErrorIs(t, err, domain.ErrNotFound)
192+ })
193+ }
194+
195+ t.Run("a branch wins over a tag of the same name", func(t *testing.T) {
196+ h.git("branch", "both", h.c1)
197+ h.git("tag", "both", h.c2)
198+ got, err := g.ResolveRevision(ctx, h.name, rev(t, "both"))
199+ require.NoError(t, err)
200+ assert.Equal(t, h.c1, got.CommitID.String())
201+ })
202+}
203+
204+func TestResolveRefPath(t *testing.T) {
205+ ctx := context.Background()
206+ g, data := newReader(t, nil)
207+ h := newHistory(t, data)
208+
209+ tests := []struct {
210+ path string
211+ name string
212+ ref string
213+ id string
214+ rest string
215+ }{
216+ {"main", "main", "refs/heads/main", h.merge, ""},
217+ {"main/d/f", "main", "refs/heads/main", h.merge, "d/f"},
218+ {"feature/x", "feature/x", "refs/heads/feature/x", h.side, ""},
219+ {"feature/x/d/f", "feature/x", "refs/heads/feature/x", h.side, "d/f"},
220+ {"feature/x/", "feature/x", "refs/heads/feature/x", h.side, ""},
221+ {"release/1.0/a.txt", "release/1.0", "refs/heads/release/1.0", h.c2, "a.txt"},
222+ // The longest ref wins: the tag rel/1/x over the branch rel/1.
223+ {"rel/1/x/d/f", "rel/1/x", "refs/tags/rel/1/x", h.c3, "d/f"},
224+ {"rel/1/d/f", "rel/1", "refs/heads/rel/1", h.c1, "d/f"},
225+ {"v2/we:ird*.txt", "v2", "refs/tags/v2", h.c2, "we:ird*.txt"},
226+ {h.c1 + "/d", h.c1, "", h.c1, "d"},
227+ {h.c1[:8] + "/d/f", h.c1[:8], "", h.c1, "d/f"},
228+ }
229+ for _, tt := range tests {
230+ t.Run(tt.path, func(t *testing.T) {
231+ got, err := g.ResolveRefPath(ctx, h.name, tt.path)
232+ require.NoError(t, err)
233+ assert.Equal(t, tt.name, got.Name)
234+ assert.Equal(t, tt.ref, got.Ref)
235+ assert.Equal(t, tt.id, got.CommitID.String())
236+ assert.Equal(t, tt.rest, got.Rest.String())
237+ })
238+ }
239+ for _, bad := range []string{"", "/", "nope", "nope/d/f", "a//b", "../x", "d/f"} {
240+ t.Run("missing "+bad, func(t *testing.T) {
241+ _, err := g.ResolveRefPath(ctx, h.name, bad)
242+ assert.ErrorIs(t, err, domain.ErrNotFound)
243+ })
244+ }
245+ _, err := g.ResolveRefPath(ctx, name(t, "nope"), "main")
246+ assert.ErrorIs(t, err, domain.ErrNotFound)
247+}
248+
249+func TestGetTree(t *testing.T) {
250+ ctx := context.Background()
251+ g, data := newReader(t, nil)
252+ h := newHistory(t, data)
253+
254+ t.Run("root at HEAD", func(t *testing.T) {
255+ tree, err := g.GetTree(ctx, h.name, domain.Revision{}, domain.TreePath{}, false)
256+ require.NoError(t, err)
257+ assert.Equal(t, h.merge, tree.CommitID.String())
258+ assert.Equal(t, h.git("rev-parse", h.merge+"^{tree}"), tree.ID.String())
259+ type row struct {
260+ name, path string
261+ kind domain.EntryKind
262+ mode uint32
263+ size int64
264+ }
265+ var got []row
266+ for _, e := range tree.Entries {
267+ got = append(got, row{e.Name, e.Path, e.Kind, e.Mode, e.Size})
268+ assert.Nil(t, e.LastCommit)
269+ }
270+ assert.Equal(t, []row{
271+ {"d", "d", domain.EntryDirectory, 0o040000, 0},
272+ {"b.txt", "b.txt", domain.EntryFile, 0o100644, 19},
273+ {"bin", "bin", domain.EntryFile, 0o100644, 2},
274+ {"empty", "empty", domain.EntryFile, 0o100644, 0},
275+ {"main.txt", "main.txt", domain.EntryFile, 0o100644, 2},
276+ {"n.txt", "n.txt", domain.EntrySymlink, 0o120000, 5},
277+ {"side.txt", "side.txt", domain.EntryFile, 0o100644, 2},
278+ {"we:ird*.txt", "we:ird*.txt", domain.EntryFile, 0o100644, 1},
279+ }, got)
280+ assert.False(t, tree.LastCommitsOmitted)
281+ })
282+
283+ t.Run("subdirectory with an executable", func(t *testing.T) {
284+ tree, err := g.GetTree(ctx, h.name, rev(t, "v2"), tpath(t, "d"), false)
285+ require.NoError(t, err)
286+ require.Len(t, tree.Entries, 2)
287+ assert.Equal(t, "sub", tree.Entries[0].Name)
288+ assert.Equal(t, "d/sub", tree.Entries[0].Path)
289+ assert.Equal(t, domain.EntryExecutable, tree.Entries[1].Kind)
290+ assert.Equal(t, uint32(0o100755), tree.Entries[1].Mode)
291+ assert.Equal(t, "d/f", tree.Entries[1].Path)
292+ assert.Equal(t, h.c2, tree.CommitID.String())
293+ })
294+
295+ t.Run("errors", func(t *testing.T) {
296+ _, err := g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "nope"), false)
297+ assert.ErrorIs(t, err, domain.ErrNotFound)
298+ _, err = g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "d/nope/x"), false)
299+ assert.ErrorIs(t, err, domain.ErrNotFound)
300+ _, err = g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "b.txt"), false)
301+ assert.ErrorIs(t, err, domain.ErrNotDirectory)
302+ _, err = g.GetTree(ctx, h.name, rev(t, "nope"), domain.TreePath{}, false)
303+ assert.ErrorIs(t, err, domain.ErrNotFound)
304+ })
305+
306+ t.Run("paths are literal", func(t *testing.T) {
307+ tree, err := g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "d"), true)
308+ require.NoError(t, err)
309+ require.Len(t, tree.Entries, 2)
310+ _, err = g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "*"), false)
311+ assert.ErrorIs(t, err, domain.ErrNotFound)
312+ })
313+}
314+
315+func TestGetTreeLastCommit(t *testing.T) {
316+ ctx := context.Background()
317+ g, data := newReader(t, nil)
318+ h := newHistory(t, data)
319+
320+ tree, err := g.GetTree(ctx, h.name, rev(t, "main"), domain.TreePath{}, true)
321+ require.NoError(t, err)
322+ got := map[string]string{}
323+ for _, e := range tree.Entries {
324+ require.NotNil(t, e.LastCommit, e.Path)
325+ got[e.Path] = e.LastCommit.Summary
326+ // The oracle: git log -1 for the same path.
327+ assert.Equal(t, h.git("log", "-1", "--format=%H", h.merge, "--", e.Path), e.LastCommit.ID.String(), e.Path)
328+ }
329+ assert.Equal(t, "second", got["d"], "chmod of d/f is the last change of d")
330+ assert.Equal(t, "second", got["b.txt"])
331+ assert.Equal(t, "first", got["we:ird*.txt"])
332+ assert.Equal(t, "third: type change", got["n.txt"])
333+ assert.Equal(t, "side work", got["side.txt"])
334+ assert.Equal(t, "Ada Lovelace", tree.Entries[0].LastCommit.Author.Name)
335+}
336+
337+func TestGetTreeLastCommitBound(t *testing.T) {
338+ ctx := context.Background()
339+ g, data := newReader(t, nil)
340+ f := newFixtureRepo(t, data, "wide")
341+ n := domain.MaxLastCommitEntries + 5
342+ for i := range n {
343+ f.write("f"+strings.Repeat("0", 3-len(itoa(i)))+itoa(i), "x\n")
344+ }
345+ f.commit("wide")
346+
347+ tree, err := g.GetTree(ctx, f.name, domain.Revision{}, domain.TreePath{}, true)
348+ require.NoError(t, err)
349+ require.Len(t, tree.Entries, n)
350+ assert.True(t, tree.LastCommitsOmitted)
351+ with := 0
352+ for _, e := range tree.Entries {
353+ if e.LastCommit != nil {
354+ with++
355+ }
356+ }
357+ assert.Equal(t, domain.MaxLastCommitEntries, with)
358+ assert.NotNil(t, tree.Entries[0].LastCommit)
359+ assert.Nil(t, tree.Entries[n-1].LastCommit)
360+
361+ tree, err = g.GetTree(ctx, f.name, domain.Revision{}, domain.TreePath{}, false)
362+ require.NoError(t, err)
363+ assert.False(t, tree.LastCommitsOmitted)
364+}
365+
366+func itoa(i int) string {
367+ if i == 0 {
368+ return "0"
369+ }
370+ var b []byte
371+ for ; i > 0; i /= 10 {
372+ b = append([]byte{byte('0' + i%10)}, b...)
373+ }
374+ return string(b)
375+}
376+
377+func TestListCommits(t *testing.T) {
378+ ctx := context.Background()
379+ g, data := newReader(t, nil)
380+ h := newHistory(t, data)
381+
382+ oracle := strings.Split(h.git("log", "--format=%H", h.merge), "\n")
383+ require.Len(t, oracle, 6)
384+
385+ t.Run("all", func(t *testing.T) {
386+ page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Limit: 100})
387+ require.NoError(t, err)
388+ assert.Equal(t, h.merge, page.Head.String())
389+ assert.False(t, page.HasMore)
390+ var ids []string
391+ for _, c := range page.Commits {
392+ ids = append(ids, c.ID.String())
393+ }
394+ assert.Equal(t, oracle, ids)
395+ })
396+
397+ t.Run("pages", func(t *testing.T) {
398+ var ids []string
399+ skip := 0
400+ for {
401+ page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Skip: skip, Limit: 2})
402+ require.NoError(t, err)
403+ for _, c := range page.Commits {
404+ ids = append(ids, c.ID.String())
405+ }
406+ skip += len(page.Commits)
407+ if !page.HasMore {
408+ break
409+ }
410+ require.Len(t, page.Commits, 2)
411+ }
412+ assert.Equal(t, oracle, ids)
413+ })
414+
415+ t.Run("path", func(t *testing.T) {
416+ page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Path: tpath(t, "d/f"), Limit: 10})
417+ require.NoError(t, err)
418+ var ids []string
419+ for _, c := range page.Commits {
420+ ids = append(ids, c.ID.String())
421+ }
422+ assert.Equal(t, strings.Split(h.git("log", "--format=%H", h.merge, "--", "d/f"), "\n"), ids)
423+ assert.Len(t, ids, 2)
424+
425+ page, err = g.ListCommits(ctx, h.name, ports.CommitQuery{Path: tpath(t, "we:ird*.txt"), Limit: 10})
426+ require.NoError(t, err)
427+ require.Len(t, page.Commits, 1, "paths are literal")
428+ page, err = g.ListCommits(ctx, h.name, ports.CommitQuery{Path: tpath(t, "*"), Limit: 10})
429+ require.NoError(t, err)
430+ assert.Empty(t, page.Commits)
431+ })
432+
433+ t.Run("revision", func(t *testing.T) {
434+ page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Revision: rev(t, "v2"), Limit: 10})
435+ require.NoError(t, err)
436+ require.Len(t, page.Commits, 2)
437+ assert.Equal(t, h.c2, page.Head.String())
438+ assert.Equal(t, h.c2, page.Commits[0].ID.String())
439+ })
440+
441+ t.Run("metadata", func(t *testing.T) {
442+ page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Revision: domain.RevisionOf(mustID(t, h.c1)), Limit: 10})
443+ require.NoError(t, err)
444+ require.Len(t, page.Commits, 1)
445+ c := page.Commits[0]
446+ assert.Empty(t, c.Parents)
447+ assert.Equal(t, "first", c.Summary)
448+ assert.Equal(t, "first\n\nbody of the first commit", c.Message)
449+ 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)
450+ assert.Equal(t, "Grace Hopper", c.Committer.Name)
451+ assert.Equal(t, "grace@example.org", c.Committer.Email)
452+
453+ page, err = g.ListCommits(ctx, h.name, ports.CommitQuery{Limit: 1})
454+ require.NoError(t, err)
455+ require.Len(t, page.Commits, 1)
456+ assert.Equal(t, []string{h.git("rev-parse", h.merge+"^1"), h.side}, idStrings(page.Commits[0].Parents))
457+ })
458+
459+ t.Run("bad queries", func(t *testing.T) {
460+ for _, q := range []ports.CommitQuery{{Limit: 0}, {Limit: -1}, {Limit: ports.MaxCommitLimit + 1}, {Limit: 1, Skip: -1}} {
461+ _, err := g.ListCommits(ctx, h.name, q)
462+ assert.ErrorIs(t, err, domain.ErrInvalidPageSize, "%+v", q)
463+ }
464+ _, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Revision: rev(t, "nope"), Limit: 1})
465+ assert.ErrorIs(t, err, domain.ErrNotFound)
466+ _, err = g.ListCommits(ctx, name(t, "nope"), ports.CommitQuery{Limit: 1})
467+ assert.ErrorIs(t, err, domain.ErrNotFound)
468+ })
469+}
470+
471+func mustID(t *testing.T, s string) domain.ObjectID {
472+ t.Helper()
473+ id, err := domain.ParseObjectID(s)
474+ require.NoError(t, err)
475+ return id
476+}
477+
478+func idStrings(ids []domain.ObjectID) []string {
479+ out := make([]string, len(ids))
480+ for i, id := range ids {
481+ out[i] = id.String()
482+ }
483+ return out
484+}
485+
486+func TestOpenBlob(t *testing.T) {
487+ ctx := context.Background()
488+ g, data := newReader(t, nil)
489+ h := newHistory(t, data)
490+
491+ read := func(t *testing.T, r string, p string) (domain.BlobInfo, string) {
492+ t.Helper()
493+ info, rc, err := g.OpenBlob(ctx, h.name, rev(t, r), tpath(t, p))
494+ require.NoError(t, err)
495+ body, err := io.ReadAll(rc)
496+ require.NoError(t, err)
497+ require.NoError(t, rc.Close())
498+ return info, string(body)
499+ }
500+
501+ info, body := read(t, "main", "b.txt")
502+ assert.Equal(t, "one\ntwo\nthree\nfour\n", body)
503+ assert.Equal(t, int64(19), info.Size)
504+ assert.Equal(t, "b.txt", info.Path)
505+ assert.Equal(t, uint32(0o100644), info.Mode)
506+ assert.False(t, info.Binary)
507+ assert.Equal(t, "text/plain", info.MediaType)
508+ assert.Equal(t, h.git("rev-parse", "main:b.txt"), info.ID.String())
509+
510+ info, body = read(t, "main", "bin")
511+ assert.Equal(t, "\x00\x02", body)
512+ assert.True(t, info.Binary)
513+ assert.Equal(t, "application/octet-stream", info.MediaType)
514+
515+ info, body = read(t, "main", "empty")
516+ assert.Empty(t, body)
517+ assert.Equal(t, int64(0), info.Size)
518+ assert.False(t, info.Binary)
519+
520+ info, body = read(t, "main", "n.txt")
521+ assert.Equal(t, "b.txt", body, "a symlink's content is its target")
522+ assert.Equal(t, uint32(0o120000), info.Mode)
523+
524+ info, _ = read(t, "main", "d/f")
525+ assert.Equal(t, uint32(0o100755), info.Mode)
526+
527+ info, _ = read(t, "v1", "a.txt")
528+ assert.Equal(t, "text/plain", info.MediaType)
529+
530+ for p, want := range map[string]error{"d": domain.ErrNotFile, "nope": domain.ErrNotFound, "d/nope": domain.ErrNotFound, "": domain.ErrNotFile} {
531+ _, rc, err := g.OpenBlob(ctx, h.name, domain.Revision{}, tpath(t, p))
532+ assert.ErrorIs(t, err, want, p)
533+ assert.Nil(t, rc)
534+ }
535+ _, _, err := g.OpenBlob(ctx, h.name, rev(t, "nope"), tpath(t, "b.txt"))
536+ assert.ErrorIs(t, err, domain.ErrNotFound)
537+}
538+
539+func TestOpenBlobMediaTypes(t *testing.T) {
540+ ctx := context.Background()
541+ g, data := newReader(t, nil)
542+ f := newFixtureRepo(t, data, "media")
543+ 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")
544+ f.writeBytes("logo.png", png).
545+ writeBytes("noext", png).
546+ write("page.html", "<!doctype html><html><body>hi</body></html>\n").
547+ write("style.css", "a { color: red }\n").
548+ write("data.json", `{"a":1}`+"\n").
549+ write("notes", "plain words\n").
550+ write("main.go", "package main\n").
551+ writeBytes("blob.dat", []byte{0xff, 0xfe, 0, 1, 2}).
552+ writeBytes("fake.txt", []byte{'a', 0, 'b'}).
553+ write("latin", "caf\xe9\n")
554+ f.commit("media")
555+
556+ want := map[string]struct {
557+ media string
558+ binary bool
559+ }{
560+ "logo.png": {"image/png", true},
561+ "noext": {"image/png", true},
562+ "page.html": {"text/html", false},
563+ "style.css": {"text/css", false},
564+ "data.json": {"application/json", false},
565+ "notes": {"text/plain", false},
566+ "main.go": {"text/plain", false},
567+ "blob.dat": {"application/octet-stream", true},
568+ "fake.txt": {"application/octet-stream", true},
569+ "latin": {"text/plain", false},
570+ }
571+ for p, w := range want {
572+ info, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, p))
573+ require.NoError(t, err, p)
574+ require.NoError(t, rc.Close())
575+ assert.Equal(t, w.media, info.MediaType, p)
576+ assert.Equal(t, w.binary, info.Binary, p)
577+ }
578+}
579+
580+func TestBinaryRuleLooksAtFirst8000Bytes(t *testing.T) {
581+ ctx := context.Background()
582+ g, data := newReader(t, nil)
583+ f := newFixtureRepo(t, data, "sniff")
584+ late := append([]byte(strings.Repeat("a", 8000)), 0) // NUL at offset 8000: not seen
585+ early := append([]byte(strings.Repeat("a", 7999)), 0)
586+ f.writeBytes("late", late).writeBytes("early", early)
587+ // Over the in-memory threshold, so that the streaming path decides too.
588+ f.writeBytes("biglate", append([]byte(strings.Repeat("a", 300<<10)), 0)).
589+ writeBytes("bigearly", append([]byte(strings.Repeat("a", 7999)), append([]byte{0}, make([]byte, 300<<10)...)...))
590+ f.commit("sniff")
591+
592+ for p, binary := range map[string]bool{"late": false, "early": true, "biglate": false, "bigearly": true} {
593+ info, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, p))
594+ require.NoError(t, err, p)
595+ require.NoError(t, rc.Close())
596+ assert.Equal(t, binary, info.Binary, p)
597+ }
598+}
599+
600+func TestOpenBlobLargeThroughStream(t *testing.T) {
601+ ctx := context.Background()
602+ g, data := newReader(t, nil)
603+ f := newFixtureRepo(t, data, "large")
604+ content := randomBytes(1<<20+17, 7)
605+ f.writeBytes("big.bin", content).commit("big")
606+
607+ info, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, "big.bin"))
608+ require.NoError(t, err)
609+ assert.Equal(t, int64(len(content)), info.Size)
610+ assert.Equal(t, 1, g.BlobStreams())
611+ got, err := io.ReadAll(rc)
612+ require.NoError(t, err)
613+ assert.Equal(t, content, got)
614+ require.NoError(t, rc.Close())
615+ require.NoError(t, rc.Close(), "Close is idempotent")
616+ assert.Equal(t, 0, g.BlobStreams())
617+}
618+
619+func TestCancelledContext(t *testing.T) {
620+ g, data := newReader(t, nil)
621+ h := newHistory(t, data)
622+
623+ ctx, cancel := context.WithCancel(context.Background())
624+ cancel()
625+ _, err := g.ListRefs(ctx, h.name)
626+ assert.ErrorIs(t, err, context.Canceled)
627+ _, err = g.ResolveRevision(ctx, h.name, domain.Revision{})
628+ assert.ErrorIs(t, err, context.Canceled)
629+ _, err = g.GetCommit(ctx, h.name, domain.Revision{}, domain.DefaultDiffLimits())
630+ assert.ErrorIs(t, err, context.Canceled)
631+ _, err = g.ListCommits(ctx, h.name, ports.CommitQuery{Limit: 5})
632+ assert.ErrorIs(t, err, context.Canceled)
633+
634+ // The adapter still works with a live context.
635+ _, err = g.ResolveRevision(context.Background(), h.name, domain.Revision{})
636+ assert.NoError(t, err)
637+}
638+
639+func TestHostileRevisionAndPathStayLiteral(t *testing.T) {
640+ ctx := context.Background()
641+ g, data := newReader(t, nil)
642+ h := newHistory(t, data)
643+
644+ // Revisions that look like options or git revision syntax are rejected by
645+ // the domain; ones that pass it are plain names for the reader.
646+ for _, s := range []string{"--output=/tmp/x", "-h", "main^", "main~1", "main:b.txt", "@{u}", "HEAD@{1}"} {
647+ _, err := domain.ParseRevision(s)
648+ assert.ErrorIs(t, err, domain.ErrInvalidRevision, s)
649+ }
650+ _, err := g.ResolveRevision(ctx, h.name, rev(t, "HEAD.x"))
651+ assert.ErrorIs(t, err, domain.ErrNotFound)
652+ _, err = g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, ":(top)d"), false)
653+ assert.ErrorIs(t, err, domain.ErrNotFound)
654+ var pe *domain.InvalidNameError
655+ assert.False(t, errors.As(err, &pe))
656+}
A internal/ports/mocks/RepoReader.go +582 -0
@@ -0,0 +1,582 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "io"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+ mock "github.com/stretchr/testify/mock"
14+)
15+
16+// NewRepoReader creates a new instance of RepoReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17+// The first argument is typically a *testing.T value.
18+func NewRepoReader(t interface {
19+ mock.TestingT
20+ Cleanup(func())
21+}) *RepoReader {
22+ if helper, ok := t.(interface{ Helper() }); ok {
23+ helper.Helper()
24+ }
25+
26+ mock := &RepoReader{}
27+ mock.Mock.Test(t)
28+
29+ t.Cleanup(func() {
30+ if helper, ok := t.(interface{ Helper() }); ok {
31+ helper.Helper()
32+ }
33+ mock.AssertExpectations(t)
34+ })
35+
36+ return mock
37+}
38+
39+// RepoReader is an autogenerated mock type for the RepoReader type
40+type RepoReader struct {
41+ mock.Mock
42+}
43+
44+type RepoReader_Expecter struct {
45+ mock *mock.Mock
46+}
47+
48+func (_m *RepoReader) EXPECT() *RepoReader_Expecter {
49+ return &RepoReader_Expecter{mock: &_m.Mock}
50+}
51+
52+// GetCommit provides a mock function for the type RepoReader
53+func (_mock *RepoReader) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) {
54+ ret := _mock.Called(ctx, repo, rev, limits)
55+
56+ if len(ret) == 0 {
57+ panic("no return value specified for GetCommit")
58+ }
59+
60+ var r0 domain.CommitDetail
61+ var r1 error
62+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Revision, domain.DiffLimits) (domain.CommitDetail, error)); ok {
63+ return returnFunc(ctx, repo, rev, limits)
64+ }
65+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Revision, domain.DiffLimits) domain.CommitDetail); ok {
66+ r0 = returnFunc(ctx, repo, rev, limits)
67+ } else {
68+ r0 = ret.Get(0).(domain.CommitDetail)
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.Revision, domain.DiffLimits) error); ok {
71+ r1 = returnFunc(ctx, repo, rev, limits)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// RepoReader_GetCommit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCommit'
79+type RepoReader_GetCommit_Call struct {
80+ *mock.Call
81+}
82+
83+// GetCommit is a helper method to define mock.On call
84+// - ctx context.Context
85+// - repo domain.RepoName
86+// - rev domain.Revision
87+// - limits domain.DiffLimits
88+func (_e *RepoReader_Expecter) GetCommit(ctx any, repo any, rev any, limits any) *RepoReader_GetCommit_Call {
89+ return &RepoReader_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, repo, rev, limits)}
90+}
91+
92+func (_c *RepoReader_GetCommit_Call) Run(run func(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits)) *RepoReader_GetCommit_Call {
93+ _c.Call.Run(func(args mock.Arguments) {
94+ var arg0 context.Context
95+ if args[0] != nil {
96+ arg0 = args[0].(context.Context)
97+ }
98+ var arg1 domain.RepoName
99+ if args[1] != nil {
100+ arg1 = args[1].(domain.RepoName)
101+ }
102+ var arg2 domain.Revision
103+ if args[2] != nil {
104+ arg2 = args[2].(domain.Revision)
105+ }
106+ var arg3 domain.DiffLimits
107+ if args[3] != nil {
108+ arg3 = args[3].(domain.DiffLimits)
109+ }
110+ run(
111+ arg0,
112+ arg1,
113+ arg2,
114+ arg3,
115+ )
116+ })
117+ return _c
118+}
119+
120+func (_c *RepoReader_GetCommit_Call) Return(commitDetail domain.CommitDetail, err error) *RepoReader_GetCommit_Call {
121+ _c.Call.Return(commitDetail, err)
122+ return _c
123+}
124+
125+func (_c *RepoReader_GetCommit_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error)) *RepoReader_GetCommit_Call {
126+ _c.Call.Return(run)
127+ return _c
128+}
129+
130+// GetTree provides a mock function for the type RepoReader
131+func (_mock *RepoReader) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) {
132+ ret := _mock.Called(ctx, repo, rev, path, includeLastCommit)
133+
134+ if len(ret) == 0 {
135+ panic("no return value specified for GetTree")
136+ }
137+
138+ var r0 domain.Tree
139+ var r1 error
140+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Revision, domain.TreePath, bool) (domain.Tree, error)); ok {
141+ return returnFunc(ctx, repo, rev, path, includeLastCommit)
142+ }
143+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Revision, domain.TreePath, bool) domain.Tree); ok {
144+ r0 = returnFunc(ctx, repo, rev, path, includeLastCommit)
145+ } else {
146+ r0 = ret.Get(0).(domain.Tree)
147+ }
148+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.Revision, domain.TreePath, bool) error); ok {
149+ r1 = returnFunc(ctx, repo, rev, path, includeLastCommit)
150+ } else {
151+ r1 = ret.Error(1)
152+ }
153+ return r0, r1
154+}
155+
156+// RepoReader_GetTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTree'
157+type RepoReader_GetTree_Call struct {
158+ *mock.Call
159+}
160+
161+// GetTree is a helper method to define mock.On call
162+// - ctx context.Context
163+// - repo domain.RepoName
164+// - rev domain.Revision
165+// - path domain.TreePath
166+// - includeLastCommit bool
167+func (_e *RepoReader_Expecter) GetTree(ctx any, repo any, rev any, path any, includeLastCommit any) *RepoReader_GetTree_Call {
168+ return &RepoReader_GetTree_Call{Call: _e.mock.On("GetTree", ctx, repo, rev, path, includeLastCommit)}
169+}
170+
171+func (_c *RepoReader_GetTree_Call) Run(run func(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool)) *RepoReader_GetTree_Call {
172+ _c.Call.Run(func(args mock.Arguments) {
173+ var arg0 context.Context
174+ if args[0] != nil {
175+ arg0 = args[0].(context.Context)
176+ }
177+ var arg1 domain.RepoName
178+ if args[1] != nil {
179+ arg1 = args[1].(domain.RepoName)
180+ }
181+ var arg2 domain.Revision
182+ if args[2] != nil {
183+ arg2 = args[2].(domain.Revision)
184+ }
185+ var arg3 domain.TreePath
186+ if args[3] != nil {
187+ arg3 = args[3].(domain.TreePath)
188+ }
189+ var arg4 bool
190+ if args[4] != nil {
191+ arg4 = args[4].(bool)
192+ }
193+ run(
194+ arg0,
195+ arg1,
196+ arg2,
197+ arg3,
198+ arg4,
199+ )
200+ })
201+ return _c
202+}
203+
204+func (_c *RepoReader_GetTree_Call) Return(tree domain.Tree, err error) *RepoReader_GetTree_Call {
205+ _c.Call.Return(tree, err)
206+ return _c
207+}
208+
209+func (_c *RepoReader_GetTree_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error)) *RepoReader_GetTree_Call {
210+ _c.Call.Return(run)
211+ return _c
212+}
213+
214+// ListCommits provides a mock function for the type RepoReader
215+func (_mock *RepoReader) ListCommits(ctx context.Context, repo domain.RepoName, q ports.CommitQuery) (ports.CommitPage, error) {
216+ ret := _mock.Called(ctx, repo, q)
217+
218+ if len(ret) == 0 {
219+ panic("no return value specified for ListCommits")
220+ }
221+
222+ var r0 ports.CommitPage
223+ var r1 error
224+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.CommitQuery) (ports.CommitPage, error)); ok {
225+ return returnFunc(ctx, repo, q)
226+ }
227+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.CommitQuery) ports.CommitPage); ok {
228+ r0 = returnFunc(ctx, repo, q)
229+ } else {
230+ r0 = ret.Get(0).(ports.CommitPage)
231+ }
232+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, ports.CommitQuery) error); ok {
233+ r1 = returnFunc(ctx, repo, q)
234+ } else {
235+ r1 = ret.Error(1)
236+ }
237+ return r0, r1
238+}
239+
240+// RepoReader_ListCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListCommits'
241+type RepoReader_ListCommits_Call struct {
242+ *mock.Call
243+}
244+
245+// ListCommits is a helper method to define mock.On call
246+// - ctx context.Context
247+// - repo domain.RepoName
248+// - q ports.CommitQuery
249+func (_e *RepoReader_Expecter) ListCommits(ctx any, repo any, q any) *RepoReader_ListCommits_Call {
250+ return &RepoReader_ListCommits_Call{Call: _e.mock.On("ListCommits", ctx, repo, q)}
251+}
252+
253+func (_c *RepoReader_ListCommits_Call) Run(run func(ctx context.Context, repo domain.RepoName, q ports.CommitQuery)) *RepoReader_ListCommits_Call {
254+ _c.Call.Run(func(args mock.Arguments) {
255+ var arg0 context.Context
256+ if args[0] != nil {
257+ arg0 = args[0].(context.Context)
258+ }
259+ var arg1 domain.RepoName
260+ if args[1] != nil {
261+ arg1 = args[1].(domain.RepoName)
262+ }
263+ var arg2 ports.CommitQuery
264+ if args[2] != nil {
265+ arg2 = args[2].(ports.CommitQuery)
266+ }
267+ run(
268+ arg0,
269+ arg1,
270+ arg2,
271+ )
272+ })
273+ return _c
274+}
275+
276+func (_c *RepoReader_ListCommits_Call) Return(commitPage ports.CommitPage, err error) *RepoReader_ListCommits_Call {
277+ _c.Call.Return(commitPage, err)
278+ return _c
279+}
280+
281+func (_c *RepoReader_ListCommits_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, q ports.CommitQuery) (ports.CommitPage, error)) *RepoReader_ListCommits_Call {
282+ _c.Call.Return(run)
283+ return _c
284+}
285+
286+// ListRefs provides a mock function for the type RepoReader
287+func (_mock *RepoReader) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) {
288+ ret := _mock.Called(ctx, repo)
289+
290+ if len(ret) == 0 {
291+ panic("no return value specified for ListRefs")
292+ }
293+
294+ var r0 []domain.Ref
295+ var r1 error
296+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) ([]domain.Ref, error)); ok {
297+ return returnFunc(ctx, repo)
298+ }
299+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) []domain.Ref); ok {
300+ r0 = returnFunc(ctx, repo)
301+ } else {
302+ if ret.Get(0) != nil {
303+ r0 = ret.Get(0).([]domain.Ref)
304+ }
305+ }
306+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
307+ r1 = returnFunc(ctx, repo)
308+ } else {
309+ r1 = ret.Error(1)
310+ }
311+ return r0, r1
312+}
313+
314+// RepoReader_ListRefs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRefs'
315+type RepoReader_ListRefs_Call struct {
316+ *mock.Call
317+}
318+
319+// ListRefs is a helper method to define mock.On call
320+// - ctx context.Context
321+// - repo domain.RepoName
322+func (_e *RepoReader_Expecter) ListRefs(ctx any, repo any) *RepoReader_ListRefs_Call {
323+ return &RepoReader_ListRefs_Call{Call: _e.mock.On("ListRefs", ctx, repo)}
324+}
325+
326+func (_c *RepoReader_ListRefs_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *RepoReader_ListRefs_Call {
327+ _c.Call.Run(func(args mock.Arguments) {
328+ var arg0 context.Context
329+ if args[0] != nil {
330+ arg0 = args[0].(context.Context)
331+ }
332+ var arg1 domain.RepoName
333+ if args[1] != nil {
334+ arg1 = args[1].(domain.RepoName)
335+ }
336+ run(
337+ arg0,
338+ arg1,
339+ )
340+ })
341+ return _c
342+}
343+
344+func (_c *RepoReader_ListRefs_Call) Return(refs []domain.Ref, err error) *RepoReader_ListRefs_Call {
345+ _c.Call.Return(refs, err)
346+ return _c
347+}
348+
349+func (_c *RepoReader_ListRefs_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error)) *RepoReader_ListRefs_Call {
350+ _c.Call.Return(run)
351+ return _c
352+}
353+
354+// OpenBlob provides a mock function for the type RepoReader
355+func (_mock *RepoReader) OpenBlob(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
356+ ret := _mock.Called(ctx, repo, rev, path)
357+
358+ if len(ret) == 0 {
359+ panic("no return value specified for OpenBlob")
360+ }
361+
362+ var r0 domain.BlobInfo
363+ var r1 io.ReadCloser
364+ var r2 error
365+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Revision, domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)); ok {
366+ return returnFunc(ctx, repo, rev, path)
367+ }
368+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Revision, domain.TreePath) domain.BlobInfo); ok {
369+ r0 = returnFunc(ctx, repo, rev, path)
370+ } else {
371+ r0 = ret.Get(0).(domain.BlobInfo)
372+ }
373+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.Revision, domain.TreePath) io.ReadCloser); ok {
374+ r1 = returnFunc(ctx, repo, rev, path)
375+ } else {
376+ if ret.Get(1) != nil {
377+ r1 = ret.Get(1).(io.ReadCloser)
378+ }
379+ }
380+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, domain.Revision, domain.TreePath) error); ok {
381+ r2 = returnFunc(ctx, repo, rev, path)
382+ } else {
383+ r2 = ret.Error(2)
384+ }
385+ return r0, r1, r2
386+}
387+
388+// RepoReader_OpenBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenBlob'
389+type RepoReader_OpenBlob_Call struct {
390+ *mock.Call
391+}
392+
393+// OpenBlob is a helper method to define mock.On call
394+// - ctx context.Context
395+// - repo domain.RepoName
396+// - rev domain.Revision
397+// - path domain.TreePath
398+func (_e *RepoReader_Expecter) OpenBlob(ctx any, repo any, rev any, path any) *RepoReader_OpenBlob_Call {
399+ return &RepoReader_OpenBlob_Call{Call: _e.mock.On("OpenBlob", ctx, repo, rev, path)}
400+}
401+
402+func (_c *RepoReader_OpenBlob_Call) Run(run func(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath)) *RepoReader_OpenBlob_Call {
403+ _c.Call.Run(func(args mock.Arguments) {
404+ var arg0 context.Context
405+ if args[0] != nil {
406+ arg0 = args[0].(context.Context)
407+ }
408+ var arg1 domain.RepoName
409+ if args[1] != nil {
410+ arg1 = args[1].(domain.RepoName)
411+ }
412+ var arg2 domain.Revision
413+ if args[2] != nil {
414+ arg2 = args[2].(domain.Revision)
415+ }
416+ var arg3 domain.TreePath
417+ if args[3] != nil {
418+ arg3 = args[3].(domain.TreePath)
419+ }
420+ run(
421+ arg0,
422+ arg1,
423+ arg2,
424+ arg3,
425+ )
426+ })
427+ return _c
428+}
429+
430+func (_c *RepoReader_OpenBlob_Call) Return(blobInfo domain.BlobInfo, readCloser io.ReadCloser, err error) *RepoReader_OpenBlob_Call {
431+ _c.Call.Return(blobInfo, readCloser, err)
432+ return _c
433+}
434+
435+func (_c *RepoReader_OpenBlob_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)) *RepoReader_OpenBlob_Call {
436+ _c.Call.Return(run)
437+ return _c
438+}
439+
440+// ResolveRefPath provides a mock function for the type RepoReader
441+func (_mock *RepoReader) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) {
442+ ret := _mock.Called(ctx, repo, path)
443+
444+ if len(ret) == 0 {
445+ panic("no return value specified for ResolveRefPath")
446+ }
447+
448+ var r0 domain.RefPath
449+ var r1 error
450+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) (domain.RefPath, error)); ok {
451+ return returnFunc(ctx, repo, path)
452+ }
453+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) domain.RefPath); ok {
454+ r0 = returnFunc(ctx, repo, path)
455+ } else {
456+ r0 = ret.Get(0).(domain.RefPath)
457+ }
458+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, string) error); ok {
459+ r1 = returnFunc(ctx, repo, path)
460+ } else {
461+ r1 = ret.Error(1)
462+ }
463+ return r0, r1
464+}
465+
466+// RepoReader_ResolveRefPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveRefPath'
467+type RepoReader_ResolveRefPath_Call struct {
468+ *mock.Call
469+}
470+
471+// ResolveRefPath is a helper method to define mock.On call
472+// - ctx context.Context
473+// - repo domain.RepoName
474+// - path string
475+func (_e *RepoReader_Expecter) ResolveRefPath(ctx any, repo any, path any) *RepoReader_ResolveRefPath_Call {
476+ return &RepoReader_ResolveRefPath_Call{Call: _e.mock.On("ResolveRefPath", ctx, repo, path)}
477+}
478+
479+func (_c *RepoReader_ResolveRefPath_Call) Run(run func(ctx context.Context, repo domain.RepoName, path string)) *RepoReader_ResolveRefPath_Call {
480+ _c.Call.Run(func(args mock.Arguments) {
481+ var arg0 context.Context
482+ if args[0] != nil {
483+ arg0 = args[0].(context.Context)
484+ }
485+ var arg1 domain.RepoName
486+ if args[1] != nil {
487+ arg1 = args[1].(domain.RepoName)
488+ }
489+ var arg2 string
490+ if args[2] != nil {
491+ arg2 = args[2].(string)
492+ }
493+ run(
494+ arg0,
495+ arg1,
496+ arg2,
497+ )
498+ })
499+ return _c
500+}
501+
502+func (_c *RepoReader_ResolveRefPath_Call) Return(refPath domain.RefPath, err error) *RepoReader_ResolveRefPath_Call {
503+ _c.Call.Return(refPath, err)
504+ return _c
505+}
506+
507+func (_c *RepoReader_ResolveRefPath_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error)) *RepoReader_ResolveRefPath_Call {
508+ _c.Call.Return(run)
509+ return _c
510+}
511+
512+// ResolveRevision provides a mock function for the type RepoReader
513+func (_mock *RepoReader) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
514+ ret := _mock.Called(ctx, repo, rev)
515+
516+ if len(ret) == 0 {
517+ panic("no return value specified for ResolveRevision")
518+ }
519+
520+ var r0 domain.ResolvedRevision
521+ var r1 error
522+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Revision) (domain.ResolvedRevision, error)); ok {
523+ return returnFunc(ctx, repo, rev)
524+ }
525+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Revision) domain.ResolvedRevision); ok {
526+ r0 = returnFunc(ctx, repo, rev)
527+ } else {
528+ r0 = ret.Get(0).(domain.ResolvedRevision)
529+ }
530+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.Revision) error); ok {
531+ r1 = returnFunc(ctx, repo, rev)
532+ } else {
533+ r1 = ret.Error(1)
534+ }
535+ return r0, r1
536+}
537+
538+// RepoReader_ResolveRevision_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveRevision'
539+type RepoReader_ResolveRevision_Call struct {
540+ *mock.Call
541+}
542+
543+// ResolveRevision is a helper method to define mock.On call
544+// - ctx context.Context
545+// - repo domain.RepoName
546+// - rev domain.Revision
547+func (_e *RepoReader_Expecter) ResolveRevision(ctx any, repo any, rev any) *RepoReader_ResolveRevision_Call {
548+ return &RepoReader_ResolveRevision_Call{Call: _e.mock.On("ResolveRevision", ctx, repo, rev)}
549+}
550+
551+func (_c *RepoReader_ResolveRevision_Call) Run(run func(ctx context.Context, repo domain.RepoName, rev domain.Revision)) *RepoReader_ResolveRevision_Call {
552+ _c.Call.Run(func(args mock.Arguments) {
553+ var arg0 context.Context
554+ if args[0] != nil {
555+ arg0 = args[0].(context.Context)
556+ }
557+ var arg1 domain.RepoName
558+ if args[1] != nil {
559+ arg1 = args[1].(domain.RepoName)
560+ }
561+ var arg2 domain.Revision
562+ if args[2] != nil {
563+ arg2 = args[2].(domain.Revision)
564+ }
565+ run(
566+ arg0,
567+ arg1,
568+ arg2,
569+ )
570+ })
571+ return _c
572+}
573+
574+func (_c *RepoReader_ResolveRevision_Call) Return(resolvedRevision domain.ResolvedRevision, err error) *RepoReader_ResolveRevision_Call {
575+ _c.Call.Return(resolvedRevision, err)
576+ return _c
577+}
578+
579+func (_c *RepoReader_ResolveRevision_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error)) *RepoReader_ResolveRevision_Call {
580+ _c.Call.Return(run)
581+ return _c
582+}
M internal/ports/ports.go +64 -0
@@ -6,6 +6,7 @@ package ports
6 6
7 import (7 import (
8 "context"8 "context"
9+ "io"
9 "time"10 "time"
10 11
11 "bnema.dev/ermite/internal/domain"12 "bnema.dev/ermite/internal/domain"
@@ -70,6 +71,69 @@ type GitRepos interface {
70 Maintain(ctx context.Context, name domain.RepoName) error71 Maintain(ctx context.Context, name domain.RepoName) error
71 }72 }
72 73
74+// CommitQuery selects a page of history.
75+type CommitQuery struct {
76+ // Revision is where the history starts; the zero value is HEAD.
77+ Revision domain.Revision
78+ // Path restricts the history to the commits that changed this file or
79+ // directory; the root means the whole history.
80+ Path domain.TreePath
81+ // Skip is the number of commits of the listing to leave out. A caller that
82+ // pages passes the commit it got in CommitPage.Head as Revision and the
83+ // number of commits already read, so that every page walks the same
84+ // history even when the revision moves.
85+ Skip int
86+ // Limit is the number of commits wanted, 1 to MaxCommitLimit.
87+ Limit int
88+}
89+
90+// MaxCommitLimit is the most commits one CommitQuery may ask for.
91+const MaxCommitLimit = 1000
92+
93+// CommitPage is a page of history, newest first.
94+type CommitPage struct {
95+ // Head is the commit the revision resolved to.
96+ Head domain.ObjectID
97+ // Commits has at most Limit commits.
98+ Commits []domain.Commit
99+ // HasMore tells that commits follow the last one.
100+ HasMore bool
101+}
102+
103+// RepoReader reads the Git data of repositories: refs, trees, blobs, history
104+// and diffs. A repository that has no directory gives domain.ErrNotFound; so
105+// do an unknown revision, an empty repository (which has no commit) and a
106+// path that does not exist at the revision.
107+type RepoReader interface {
108+ // ListRefs returns HEAD, then the branches, then the tags, the last two
109+ // ordered by name. HEAD is always listed: when its branch does not exist
110+ // yet its ShortName and ids are empty.
111+ ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error)
112+ // ResolveRevision turns a revision into the commit it designates. A name
113+ // that is both a branch and a tag means the branch.
114+ ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error)
115+ // ResolveRefPath splits a path such as "feature/x/dir/file" into the
116+ // longest leading part that names a branch or a tag and the rest. When no
117+ // ref matches, the first segment may be a commit id (full, or abbreviated
118+ // to MinAbbrevLen or more). It returns domain.ErrNotFound if nothing
119+ // matches.
120+ ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error)
121+ // GetTree lists the directory at path. With includeLastCommit, the first
122+ // domain.MaxLastCommitEntries entries get their last commit. It returns
123+ // domain.ErrNotDirectory if path names something else.
124+ GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error)
125+ // OpenBlob returns the description of the file at path and a reader of its
126+ // content, which streams without holding the whole file in memory. The
127+ // caller must close the reader. It returns domain.ErrNotFile if path names
128+ // something else.
129+ OpenBlob(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)
130+ // ListCommits returns a page of history.
131+ ListCommits(ctx context.Context, repo domain.RepoName, q CommitQuery) (CommitPage, error)
132+ // GetCommit returns a commit with its statistics and its diff against the
133+ // first parent, bounded by limits.
134+ GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error)
135+}
136+
73 // HTTPBackend tells how to run `git http-backend` for one request.137 // HTTPBackend tells how to run `git http-backend` for one request.
74 type HTTPBackend struct {138 type HTTPBackend struct {
75 // Path is the git-http-backend executable.139 // Path is the git-http-backend executable.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close