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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 send | 31 | // 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 int64 | 33 | 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 backend | 48 | mu sync.Mutex // guards backend | ||
| 44 | backend string // git-http-backend, resolved on first use | 49 | 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. Nothing | 68 | // 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.ExitError | 102 | 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(), nil | 108 | 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) error | 71 | 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. | ||