detached ed6aa64

perf(gitcli): run up to 4 cat-file processes per repo, skip blob streams on raw 304

- cat-file pool hands busy repos extra processes under a global cap, with a
  FIFO wait queue that honours cancellation
- raw route answers If-None-Match from the tree entry id without opening the blob
- KV health check probes Apply with a cancelled context instead of queueing a write
commit
ed6aa6402bc67285e59ae50b7b65713c4f3bff6b
author
brice · · 2026-10-11 18:49 UTC
parent
806d83d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +1436 -87
M .mockery.yml +10 -0
@@ -15,3 +15,13 @@ packages:
1515 config: *mocks
1616 bnema.dev/ermite/internal/app:
1717 config: *mocks
18+ bnema.dev/ermite-api/gen/ermite/ermiteconnect:
19+ config:
20+ include-auto-generated: true
21+ dir: internal/adapters/web/mocks
22+ pkgname: mocks
23+ filename: "{{.InterfaceName}}.go"
24+ structname: "{{.InterfaceName}}"
25+ interfaces:
26+ GitServiceClient:
27+ RepoServiceClient:
@@ -15,3 +15,13 @@ packages:
15 config: *mocks15 config: *mocks
16 bnema.dev/ermite/internal/app:16 bnema.dev/ermite/internal/app:
17 config: *mocks17 config: *mocks
18+ bnema.dev/ermite-api/gen/ermite/ermiteconnect:
19+ config:
20+ include-auto-generated: true
21+ dir: internal/adapters/web/mocks
22+ pkgname: mocks
23+ filename: "{{.InterfaceName}}.go"
24+ structname: "{{.InterfaceName}}"
25+ interfaces:
26+ GitServiceClient:
27+ RepoServiceClient:
M internal/adapters/gitcli/catfile.go +175 -67
@@ -11,6 +11,7 @@ import (
1111 "io"
1212 "os/exec"
1313 "path/filepath"
14+ "slices"
1415 "strconv"
1516 "strings"
1617 "sync"
@@ -36,27 +37,40 @@ func (e *procError) Unwrap() error { return e.err }
3637 const (
3738 defaultIdleTimeout = 60 * time.Second
3839 defaultMaxProcesses = 16
40+ // maxProcsPerRepo bounds the processes one repository may hold. A
41+ // popular repository spawns more than one only under contention.
42+ maxProcsPerRepo = 4
3943 )
4044
41-// catPool keeps one long-lived `git cat-file --batch-command` per repository.
45+// catPool keeps long-lived `git cat-file --batch-command` processes, a few per
46+// repository.
4247 //
43-// - Access to one process is serialized: one request in flight at a time.
48+// - A process serves one request at a time. A request takes an idle process
49+// of the repository; if all are busy and the repository holds fewer than
50+// perRepo, a new one is started (so a repository nobody reads
51+// concurrently keeps a single process); otherwise the request waits, in
52+// order, for a process to be released.
4453 // - 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.
54+// - At most max processes are kept, all repositories together. Starting
55+// another one first evicts the least recently used idle process; a
56+// process that is in use is never evicted. When none can be evicted, a
57+// repository that has a process waits for it, and one that has none starts
58+// one anyway, so the bound is soft only when more than max repositories
59+// are read at the same moment.
4960 // - A process that fails, or whose request was cancelled (its output is then
5061 // in an unknown state), is killed and dropped; the next request starts a
5162 // fresh one, and a request that hit a dead process is retried once.
5263 type catPool struct {
53- bin string
54- env []string
55- idle time.Duration
56- max int
64+ bin string
65+ env []string
66+ idle time.Duration
67+ max int // processes, all repositories together
68+ perRepo int // processes of one repository
5769
5870 mu sync.Mutex
59- procs map[string]*catProc
71+ procs map[string][]*catProc // by repository directory
72+ total int // live processes in procs
73+ waiters map[string][]chan *catProc
6074 streams map[*blobStream]struct{} // one-shot blob processes, see OpenBlob
6175 closed bool
6276 wg sync.WaitGroup // reapers of started processes
@@ -71,11 +85,10 @@ type catProc struct {
7185 in io.WriteCloser
7286 out *bufio.Reader
7387 stderr *tailBuffer
74- sem chan struct{} // capacity 1: the request in flight
7588
76- broken bool // set by the holder of sem on a protocol error
89+ broken bool // set by the holder on a protocol error
7790
78- users int // requests waiting for or holding sem
91+ busy bool // a request holds the process
7992 lastUsed time.Time
8093 timer *time.Timer
8194 gen uint64
@@ -90,13 +103,14 @@ func newCatPool(bin string, env []string, idle time.Duration, maxProcs int) *cat
90103 maxProcs = defaultMaxProcesses
91104 }
92105 return &catPool{
93- bin: bin, env: env, idle: idle, max: maxProcs,
94- procs: make(map[string]*catProc),
106+ bin: bin, env: env, idle: idle, max: maxProcs, perRepo: min(maxProcsPerRepo, maxProcs),
107+ procs: make(map[string][]*catProc),
108+ waiters: make(map[string][]chan *catProc),
95109 streams: make(map[*blobStream]struct{}),
96110 }
97111 }
98112
99-// do runs fn with exclusive use of the cat-file process of the repository in
113+// do runs fn with exclusive use of a cat-file process of the repository in
100114 // dir, starting it if needed. fn must not keep the connection after returning.
101115 func (p *catPool) do(ctx context.Context, dir string, fn func(*catConn) error) error {
102116 for attempt := 0; ; attempt++ {
@@ -132,7 +146,6 @@ func (p *catPool) run(ctx context.Context, cp *catProc, fn func(*catConn) error)
132146 err = ctx.Err()
133147 }
134148 p.release(cp, cp.broken || cancelled)
135- <-cp.sem
136149 return err
137150 }
138151
@@ -142,58 +155,149 @@ func (p *catPool) isClosed() bool {
142155 return p.closed
143156 }
144157
158+// acquire returns a process of dir that no other request uses.
145159 func (p *catPool) acquire(ctx context.Context, dir string) (*catProc, error) {
146160 for {
161+ if err := ctx.Err(); err != nil {
162+ return nil, err
163+ }
147164 p.mu.Lock()
148165 if p.closed {
149166 p.mu.Unlock()
150167 return nil, ErrClosed
151168 }
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- }
169+ cp, wait, err := p.takeLocked(ctx, dir)
170+ if err != nil {
171+ p.mu.Unlock()
172+ return nil, err
159173 }
160- cp.users++
161- cp.gen++
162- if cp.timer != nil {
163- cp.timer.Stop()
164- cp.timer = nil
174+ if cp != nil {
175+ p.mu.Unlock()
176+ return cp, nil
165177 }
178+ p.waiters[dir] = append(p.waiters[dir], wait)
166179 p.mu.Unlock()
167180
168181 select {
169- case cp.sem <- struct{}{}:
182+ case cp := <-wait:
183+ if cp == nil {
184+ continue // the processes changed: look again
185+ }
186+ p.mu.Lock()
187+ dead := cp.dead
188+ p.mu.Unlock()
189+ if !dead {
190+ return cp, nil
191+ }
192+ p.release(cp, false) // dropped between the hand-over and now
170193 case <-ctx.Done():
171- p.release(cp, false)
194+ p.mu.Lock()
195+ if !p.unqueueLocked(dir, wait) {
196+ // A process was handed over meanwhile: pass it on.
197+ p.mu.Unlock()
198+ if cp := <-wait; cp != nil {
199+ p.release(cp, false)
200+ }
201+ } else {
202+ p.mu.Unlock()
203+ }
172204 return nil, ctx.Err()
173205 }
174- p.mu.Lock()
175- dead := cp.dead
176- p.mu.Unlock()
177- if !dead {
178- return cp, nil
206+ }
207+}
208+
209+// takeLocked marks an idle process of dir busy and returns it, starting one
210+// when none is idle and the repository may have more. Otherwise it returns the
211+// channel to wait on.
212+func (p *catPool) takeLocked(ctx context.Context, dir string) (*catProc, chan *catProc, error) {
213+ var best *catProc // the most recently used idle one, so that the others idle out
214+ for _, c := range p.procs[dir] {
215+ if !c.busy && (best == nil || c.lastUsed.After(best.lastUsed)) {
216+ best = c
179217 }
180- // Dropped while we waited for our turn.
181- <-cp.sem
182- p.release(cp, false)
183218 }
219+ if best == nil {
220+ own := len(p.procs[dir])
221+ if own < p.perRepo && (p.makeRoomLocked() || own == 0) {
222+ var err error
223+ if best, err = p.startLocked(ctx, dir); err != nil {
224+ return nil, nil, err
225+ }
226+ }
227+ }
228+ if best == nil {
229+ return nil, make(chan *catProc, 1), nil
230+ }
231+ best.busy = true
232+ best.gen++
233+ if best.timer != nil {
234+ best.timer.Stop()
235+ best.timer = nil
236+ }
237+ return best, nil, nil
184238 }
185239
186-// release ends one use of cp. A broken process is dropped; an unused one
187-// starts its idle countdown.
240+// makeRoomLocked evicts least recently used idle processes until another one
241+// fits, and reports whether it does.
242+func (p *catPool) makeRoomLocked() bool {
243+ for p.total >= p.max {
244+ var victim *catProc
245+ for _, procs := range p.procs {
246+ for _, c := range procs {
247+ if !c.busy && (victim == nil || c.lastUsed.Before(victim.lastUsed)) {
248+ victim = c
249+ }
250+ }
251+ }
252+ if victim == nil {
253+ return false
254+ }
255+ victim.log.Debug().Str("dir", victim.dir).Msg("cat-file pool full, evicting the least recently used")
256+ p.dropLocked(victim)
257+ }
258+ return true
259+}
260+
261+// unqueueLocked removes a waiter and reports whether it was still queued.
262+func (p *catPool) unqueueLocked(dir string, wait chan *catProc) bool {
263+ q := p.waiters[dir]
264+ for i, w := range q {
265+ if w == wait {
266+ q = append(q[:i:i], q[i+1:]...)
267+ if len(q) == 0 {
268+ delete(p.waiters, dir)
269+ } else {
270+ p.waiters[dir] = q
271+ }
272+ return true
273+ }
274+ }
275+ return false
276+}
277+
278+// release ends one use of cp. A broken process is dropped. A working one goes
279+// to the request that waited longest, or else starts its idle countdown.
188280 func (p *catPool) release(cp *catProc, broken bool) {
189281 p.mu.Lock()
190282 defer p.mu.Unlock()
191- cp.users--
192- if broken {
283+ if broken || cp.dead {
284+ cp.busy = false
193285 p.dropLocked(cp)
194286 return
195287 }
196- if cp.users == 0 && !cp.dead && !p.closed {
288+ if q := p.waiters[cp.dir]; len(q) > 0 && !p.closed {
289+ w := q[0]
290+ if len(q) == 1 {
291+ delete(p.waiters, cp.dir)
292+ } else {
293+ p.waiters[cp.dir] = q[1:]
294+ }
295+ cp.gen++
296+ w <- cp // stays busy: handed over
297+ return
298+ }
299+ cp.busy = false
300+ if !p.closed {
197301 cp.lastUsed = time.Now()
198302 gen := cp.gen
199303 cp.timer = time.AfterFunc(p.idle, func() { p.expire(cp, gen) })
@@ -204,7 +308,7 @@ func (p *catPool) release(cp *catProc, broken bool) {
204308 func (p *catPool) expire(cp *catProc, gen uint64) {
205309 p.mu.Lock()
206310 defer p.mu.Unlock()
207- if cp.gen != gen || cp.users != 0 || cp.dead {
311+ if cp.gen != gen || cp.busy || cp.dead {
208312 return
209313 }
210314 cp.log.Debug().Str("dir", cp.dir).Msg("cat-file idle, stopping")
@@ -212,14 +316,24 @@ func (p *catPool) expire(cp *catProc, gen uint64) {
212316 }
213317
214318 // dropLocked removes cp from the pool and kills it. The reaper goroutine
215-// collects the process.
319+// collects the process. Requests waiting for the repository look again.
216320 func (p *catPool) dropLocked(cp *catProc) {
217321 if cp.dead {
218322 return
219323 }
220324 cp.dead = true
221- if p.procs[cp.dir] == cp {
325+ procs := p.procs[cp.dir]
326+ for i, c := range procs {
327+ if c == cp {
328+ procs = append(procs[:i:i], procs[i+1:]...)
329+ p.total--
330+ break
331+ }
332+ }
333+ if len(procs) == 0 {
222334 delete(p.procs, cp.dir)
335+ } else {
336+ p.procs[cp.dir] = procs
223337 }
224338 if cp.timer != nil {
225339 cp.timer.Stop()
@@ -227,22 +341,13 @@ func (p *catPool) dropLocked(cp *catProc) {
227341 }
228342 _ = cp.in.Close()
229343 _ = cp.cmd.Process.Kill()
344+ for _, w := range p.waiters[cp.dir] {
345+ w <- nil
346+ }
347+ delete(p.waiters, cp.dir)
230348 }
231349
232350 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- }
246351 cmd := exec.Command(p.bin, "--git-dir="+dir, "--no-replace-objects", "--no-optional-locks", "cat-file", "--batch-command="+catFormat, "-Z")
247352 cmd.Env = p.env
248353 cmd.Dir = filepath.Dir(dir)
@@ -261,9 +366,10 @@ func (p *catPool) startLocked(ctx context.Context, dir string) (*catProc, error)
261366 }
262367 cp := &catProc{
263368 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(),
369+ stderr: stderr, lastUsed: time.Now(),
265370 }
266- p.procs[dir] = cp
371+ p.procs[dir] = append(p.procs[dir], cp)
372+ p.total++
267373 p.wg.Add(1)
268374 go func() {
269375 defer p.wg.Done()
@@ -273,11 +379,11 @@ func (p *catPool) startLocked(ctx context.Context, dir string) (*catProc, error)
273379 return cp, nil
274380 }
275381
276-// forget kills the process of the repository in dir, if any.
382+// forget kills the processes of the repository in dir, if any.
277383 func (p *catPool) forget(dir string) {
278384 p.mu.Lock()
279385 defer p.mu.Unlock()
280- if cp := p.procs[dir]; cp != nil {
386+ for _, cp := range slices.Clone(p.procs[dir]) {
281387 p.dropLocked(cp)
282388 }
283389 }
@@ -287,8 +393,10 @@ func (p *catPool) forget(dir string) {
287393 func (p *catPool) close(ctx context.Context) error {
288394 p.mu.Lock()
289395 p.closed = true
290- for _, cp := range p.procs {
291- p.dropLocked(cp)
396+ for _, procs := range p.procs {
397+ for _, cp := range slices.Clone(procs) {
398+ p.dropLocked(cp)
399+ }
292400 }
293401 streams := make([]*blobStream, 0, len(p.streams))
294402 for s := range p.streams {
@@ -11,6 +11,7 @@ import (
11 "io"11 "io"
12 "os/exec"12 "os/exec"
13 "path/filepath"13 "path/filepath"
14+ "slices"
14 "strconv"15 "strconv"
15 "strings"16 "strings"
16 "sync"17 "sync"
@@ -36,27 +37,40 @@ func (e *procError) Unwrap() error { return e.err }
36 const (37 const (
37 defaultIdleTimeout = 60 * time.Second38 defaultIdleTimeout = 60 * time.Second
38 defaultMaxProcesses = 1639 defaultMaxProcesses = 16
40+ // maxProcsPerRepo bounds the processes one repository may hold. A
41+ // popular repository spawns more than one only under contention.
42+ maxProcsPerRepo = 4
39 )43 )
40 44
41-// catPool keeps one long-lived `git cat-file --batch-command` per repository.45+// catPool keeps long-lived `git cat-file --batch-command` processes, a few per
46+// repository.
42 //47 //
43-// - Access to one process is serialized: one request in flight at a time.48+// - A process serves one request at a time. A request takes an idle process
49+// of the repository; if all are busy and the repository holds fewer than
50+// perRepo, a new one is started (so a repository nobody reads
51+// concurrently keeps a single process); otherwise the request waits, in
52+// order, for a process to be released.
44 // - A process nobody used for the idle timeout is killed.53 // - A process nobody used for the idle timeout is killed.
45-// - At most max processes are kept. Starting another one first evicts the54+// - At most max processes are kept, all repositories together. Starting
46-// least recently used idle process; a process that is in use is never55+// another one first evicts the least recently used idle process; a
47-// evicted, so the bound is soft when more than max repositories are read56+// process that is in use is never evicted. When none can be evicted, a
48-// at the same moment.57+// repository that has a process waits for it, and one that has none starts
58+// one anyway, so the bound is soft only when more than max repositories
59+// are read at the same moment.
49 // - A process that fails, or whose request was cancelled (its output is then60 // - 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 a61 // 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.62 // fresh one, and a request that hit a dead process is retried once.
52 type catPool struct {63 type catPool struct {
53- bin string64+ bin string
54- env []string65+ env []string
55- idle time.Duration66+ idle time.Duration
56- max int67+ max int // processes, all repositories together
68+ perRepo int // processes of one repository
57 69
58 mu sync.Mutex70 mu sync.Mutex
59- procs map[string]*catProc71+ procs map[string][]*catProc // by repository directory
72+ total int // live processes in procs
73+ waiters map[string][]chan *catProc
60 streams map[*blobStream]struct{} // one-shot blob processes, see OpenBlob74 streams map[*blobStream]struct{} // one-shot blob processes, see OpenBlob
61 closed bool75 closed bool
62 wg sync.WaitGroup // reapers of started processes76 wg sync.WaitGroup // reapers of started processes
@@ -71,11 +85,10 @@ type catProc struct {
71 in io.WriteCloser85 in io.WriteCloser
72 out *bufio.Reader86 out *bufio.Reader
73 stderr *tailBuffer87 stderr *tailBuffer
74- sem chan struct{} // capacity 1: the request in flight
75 88
76- broken bool // set by the holder of sem on a protocol error89+ broken bool // set by the holder on a protocol error
77 90
78- users int // requests waiting for or holding sem91+ busy bool // a request holds the process
79 lastUsed time.Time92 lastUsed time.Time
80 timer *time.Timer93 timer *time.Timer
81 gen uint6494 gen uint64
@@ -90,13 +103,14 @@ func newCatPool(bin string, env []string, idle time.Duration, maxProcs int) *cat
90 maxProcs = defaultMaxProcesses103 maxProcs = defaultMaxProcesses
91 }104 }
92 return &catPool{105 return &catPool{
93- bin: bin, env: env, idle: idle, max: maxProcs,106+ bin: bin, env: env, idle: idle, max: maxProcs, perRepo: min(maxProcsPerRepo, maxProcs),
94- procs: make(map[string]*catProc),107+ procs: make(map[string][]*catProc),
108+ waiters: make(map[string][]chan *catProc),
95 streams: make(map[*blobStream]struct{}),109 streams: make(map[*blobStream]struct{}),
96 }110 }
97 }111 }
98 112
99-// do runs fn with exclusive use of the cat-file process of the repository in113+// do runs fn with exclusive use of a cat-file process of the repository in
100 // dir, starting it if needed. fn must not keep the connection after returning.114 // 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 {115 func (p *catPool) do(ctx context.Context, dir string, fn func(*catConn) error) error {
102 for attempt := 0; ; attempt++ {116 for attempt := 0; ; attempt++ {
@@ -132,7 +146,6 @@ func (p *catPool) run(ctx context.Context, cp *catProc, fn func(*catConn) error)
132 err = ctx.Err()146 err = ctx.Err()
133 }147 }
134 p.release(cp, cp.broken || cancelled)148 p.release(cp, cp.broken || cancelled)
135- <-cp.sem
136 return err149 return err
137 }150 }
138 151
@@ -142,58 +155,149 @@ func (p *catPool) isClosed() bool {
142 return p.closed155 return p.closed
143 }156 }
144 157
158+// acquire returns a process of dir that no other request uses.
145 func (p *catPool) acquire(ctx context.Context, dir string) (*catProc, error) {159 func (p *catPool) acquire(ctx context.Context, dir string) (*catProc, error) {
146 for {160 for {
161+ if err := ctx.Err(); err != nil {
162+ return nil, err
163+ }
147 p.mu.Lock()164 p.mu.Lock()
148 if p.closed {165 if p.closed {
149 p.mu.Unlock()166 p.mu.Unlock()
150 return nil, ErrClosed167 return nil, ErrClosed
151 }168 }
152- cp := p.procs[dir]169+ cp, wait, err := p.takeLocked(ctx, dir)
153- if cp == nil {170+ if err != nil {
154- var err error171+ p.mu.Unlock()
155- if cp, err = p.startLocked(ctx, dir); err != nil {172+ return nil, err
156- p.mu.Unlock()
157- return nil, err
158- }
159 }173 }
160- cp.users++174+ if cp != nil {
161- cp.gen++175+ p.mu.Unlock()
162- if cp.timer != nil {176+ return cp, nil
163- cp.timer.Stop()
164- cp.timer = nil
165 }177 }
178+ p.waiters[dir] = append(p.waiters[dir], wait)
166 p.mu.Unlock()179 p.mu.Unlock()
167 180
168 select {181 select {
169- case cp.sem <- struct{}{}:182+ case cp := <-wait:
183+ if cp == nil {
184+ continue // the processes changed: look again
185+ }
186+ p.mu.Lock()
187+ dead := cp.dead
188+ p.mu.Unlock()
189+ if !dead {
190+ return cp, nil
191+ }
192+ p.release(cp, false) // dropped between the hand-over and now
170 case <-ctx.Done():193 case <-ctx.Done():
171- p.release(cp, false)194+ p.mu.Lock()
195+ if !p.unqueueLocked(dir, wait) {
196+ // A process was handed over meanwhile: pass it on.
197+ p.mu.Unlock()
198+ if cp := <-wait; cp != nil {
199+ p.release(cp, false)
200+ }
201+ } else {
202+ p.mu.Unlock()
203+ }
172 return nil, ctx.Err()204 return nil, ctx.Err()
173 }205 }
174- p.mu.Lock()206+ }
175- dead := cp.dead207+}
176- p.mu.Unlock()208+
177- if !dead {209+// takeLocked marks an idle process of dir busy and returns it, starting one
178- return cp, nil210+// when none is idle and the repository may have more. Otherwise it returns the
211+// channel to wait on.
212+func (p *catPool) takeLocked(ctx context.Context, dir string) (*catProc, chan *catProc, error) {
213+ var best *catProc // the most recently used idle one, so that the others idle out
214+ for _, c := range p.procs[dir] {
215+ if !c.busy && (best == nil || c.lastUsed.After(best.lastUsed)) {
216+ best = c
179 }217 }
180- // Dropped while we waited for our turn.
181- <-cp.sem
182- p.release(cp, false)
183 }218 }
219+ if best == nil {
220+ own := len(p.procs[dir])
221+ if own < p.perRepo && (p.makeRoomLocked() || own == 0) {
222+ var err error
223+ if best, err = p.startLocked(ctx, dir); err != nil {
224+ return nil, nil, err
225+ }
226+ }
227+ }
228+ if best == nil {
229+ return nil, make(chan *catProc, 1), nil
230+ }
231+ best.busy = true
232+ best.gen++
233+ if best.timer != nil {
234+ best.timer.Stop()
235+ best.timer = nil
236+ }
237+ return best, nil, nil
184 }238 }
185 239
186-// release ends one use of cp. A broken process is dropped; an unused one240+// makeRoomLocked evicts least recently used idle processes until another one
187-// starts its idle countdown.241+// fits, and reports whether it does.
242+func (p *catPool) makeRoomLocked() bool {
243+ for p.total >= p.max {
244+ var victim *catProc
245+ for _, procs := range p.procs {
246+ for _, c := range procs {
247+ if !c.busy && (victim == nil || c.lastUsed.Before(victim.lastUsed)) {
248+ victim = c
249+ }
250+ }
251+ }
252+ if victim == nil {
253+ return false
254+ }
255+ victim.log.Debug().Str("dir", victim.dir).Msg("cat-file pool full, evicting the least recently used")
256+ p.dropLocked(victim)
257+ }
258+ return true
259+}
260+
261+// unqueueLocked removes a waiter and reports whether it was still queued.
262+func (p *catPool) unqueueLocked(dir string, wait chan *catProc) bool {
263+ q := p.waiters[dir]
264+ for i, w := range q {
265+ if w == wait {
266+ q = append(q[:i:i], q[i+1:]...)
267+ if len(q) == 0 {
268+ delete(p.waiters, dir)
269+ } else {
270+ p.waiters[dir] = q
271+ }
272+ return true
273+ }
274+ }
275+ return false
276+}
277+
278+// release ends one use of cp. A broken process is dropped. A working one goes
279+// to the request that waited longest, or else starts its idle countdown.
188 func (p *catPool) release(cp *catProc, broken bool) {280 func (p *catPool) release(cp *catProc, broken bool) {
189 p.mu.Lock()281 p.mu.Lock()
190 defer p.mu.Unlock()282 defer p.mu.Unlock()
191- cp.users--283+ if broken || cp.dead {
192- if broken {284+ cp.busy = false
193 p.dropLocked(cp)285 p.dropLocked(cp)
194 return286 return
195 }287 }
196- if cp.users == 0 && !cp.dead && !p.closed {288+ if q := p.waiters[cp.dir]; len(q) > 0 && !p.closed {
289+ w := q[0]
290+ if len(q) == 1 {
291+ delete(p.waiters, cp.dir)
292+ } else {
293+ p.waiters[cp.dir] = q[1:]
294+ }
295+ cp.gen++
296+ w <- cp // stays busy: handed over
297+ return
298+ }
299+ cp.busy = false
300+ if !p.closed {
197 cp.lastUsed = time.Now()301 cp.lastUsed = time.Now()
198 gen := cp.gen302 gen := cp.gen
199 cp.timer = time.AfterFunc(p.idle, func() { p.expire(cp, gen) })303 cp.timer = time.AfterFunc(p.idle, func() { p.expire(cp, gen) })
@@ -204,7 +308,7 @@ func (p *catPool) release(cp *catProc, broken bool) {
204 func (p *catPool) expire(cp *catProc, gen uint64) {308 func (p *catPool) expire(cp *catProc, gen uint64) {
205 p.mu.Lock()309 p.mu.Lock()
206 defer p.mu.Unlock()310 defer p.mu.Unlock()
207- if cp.gen != gen || cp.users != 0 || cp.dead {311+ if cp.gen != gen || cp.busy || cp.dead {
208 return312 return
209 }313 }
210 cp.log.Debug().Str("dir", cp.dir).Msg("cat-file idle, stopping")314 cp.log.Debug().Str("dir", cp.dir).Msg("cat-file idle, stopping")
@@ -212,14 +316,24 @@ func (p *catPool) expire(cp *catProc, gen uint64) {
212 }316 }
213 317
214 // dropLocked removes cp from the pool and kills it. The reaper goroutine318 // dropLocked removes cp from the pool and kills it. The reaper goroutine
215-// collects the process.319+// collects the process. Requests waiting for the repository look again.
216 func (p *catPool) dropLocked(cp *catProc) {320 func (p *catPool) dropLocked(cp *catProc) {
217 if cp.dead {321 if cp.dead {
218 return322 return
219 }323 }
220 cp.dead = true324 cp.dead = true
221- if p.procs[cp.dir] == cp {325+ procs := p.procs[cp.dir]
326+ for i, c := range procs {
327+ if c == cp {
328+ procs = append(procs[:i:i], procs[i+1:]...)
329+ p.total--
330+ break
331+ }
332+ }
333+ if len(procs) == 0 {
222 delete(p.procs, cp.dir)334 delete(p.procs, cp.dir)
335+ } else {
336+ p.procs[cp.dir] = procs
223 }337 }
224 if cp.timer != nil {338 if cp.timer != nil {
225 cp.timer.Stop()339 cp.timer.Stop()
@@ -227,22 +341,13 @@ func (p *catPool) dropLocked(cp *catProc) {
227 }341 }
228 _ = cp.in.Close()342 _ = cp.in.Close()
229 _ = cp.cmd.Process.Kill()343 _ = cp.cmd.Process.Kill()
344+ for _, w := range p.waiters[cp.dir] {
345+ w <- nil
346+ }
347+ delete(p.waiters, cp.dir)
230 }348 }
231 349
232 func (p *catPool) startLocked(ctx context.Context, dir string) (*catProc, error) {350 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")351 cmd := exec.Command(p.bin, "--git-dir="+dir, "--no-replace-objects", "--no-optional-locks", "cat-file", "--batch-command="+catFormat, "-Z")
247 cmd.Env = p.env352 cmd.Env = p.env
248 cmd.Dir = filepath.Dir(dir)353 cmd.Dir = filepath.Dir(dir)
@@ -261,9 +366,10 @@ func (p *catPool) startLocked(ctx context.Context, dir string) (*catProc, error)
261 }366 }
262 cp := &catProc{367 cp := &catProc{
263 dir: dir, log: zerowrap.FromCtx(ctx), cmd: cmd, in: in, out: bufio.NewReaderSize(stdout, 64*1024),368 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(),369+ stderr: stderr, lastUsed: time.Now(),
265 }370 }
266- p.procs[dir] = cp371+ p.procs[dir] = append(p.procs[dir], cp)
372+ p.total++
267 p.wg.Add(1)373 p.wg.Add(1)
268 go func() {374 go func() {
269 defer p.wg.Done()375 defer p.wg.Done()
@@ -273,11 +379,11 @@ func (p *catPool) startLocked(ctx context.Context, dir string) (*catProc, error)
273 return cp, nil379 return cp, nil
274 }380 }
275 381
276-// forget kills the process of the repository in dir, if any.382+// forget kills the processes of the repository in dir, if any.
277 func (p *catPool) forget(dir string) {383 func (p *catPool) forget(dir string) {
278 p.mu.Lock()384 p.mu.Lock()
279 defer p.mu.Unlock()385 defer p.mu.Unlock()
280- if cp := p.procs[dir]; cp != nil {386+ for _, cp := range slices.Clone(p.procs[dir]) {
281 p.dropLocked(cp)387 p.dropLocked(cp)
282 }388 }
283 }389 }
@@ -287,8 +393,10 @@ func (p *catPool) forget(dir string) {
287 func (p *catPool) close(ctx context.Context) error {393 func (p *catPool) close(ctx context.Context) error {
288 p.mu.Lock()394 p.mu.Lock()
289 p.closed = true395 p.closed = true
290- for _, cp := range p.procs {396+ for _, procs := range p.procs {
291- p.dropLocked(cp)397+ for _, cp := range slices.Clone(procs) {
398+ p.dropLocked(cp)
399+ }
292 }400 }
293 streams := make([]*blobStream, 0, len(p.streams))401 streams := make([]*blobStream, 0, len(p.streams))
294 for s := range p.streams {402 for s := range p.streams {
M internal/adapters/gitcli/export_test.go +40 -7
@@ -2,7 +2,12 @@
22
33 package gitcli
44
5-import "sort"
5+import (
6+ "context"
7+ "sort"
8+
9+ "bnema.dev/ermite/internal/domain"
10+)
611
712 // CatFilePIDs returns the process ids of the live long-lived cat-file
813 // processes, for the tests of the pool.
@@ -10,8 +15,10 @@ func (g *Git) CatFilePIDs() []int {
1015 g.cat.mu.Lock()
1116 defer g.cat.mu.Unlock()
1217 var pids []int
13- for _, cp := range g.cat.procs {
14- pids = append(pids, cp.cmd.Process.Pid)
18+ for _, procs := range g.cat.procs {
19+ for _, cp := range procs {
20+ pids = append(pids, cp.cmd.Process.Pid)
21+ }
1522 }
1623 sort.Ints(pids)
1724 return pids
@@ -44,8 +51,10 @@ func (g *Git) FireIdleTimers() {
4451 gen uint64
4552 }
4653 var all []armed
47- for _, cp := range g.cat.procs {
48- all = append(all, armed{cp, cp.gen})
54+ for _, procs := range g.cat.procs {
55+ for _, cp := range procs {
56+ all = append(all, armed{cp, cp.gen})
57+ }
4958 }
5059 g.cat.mu.Unlock()
5160 for _, a := range all {
@@ -62,8 +71,10 @@ func (g *Git) FireStaleIdleTimers() {
6271 gen uint64
6372 }
6473 var all []armed
65- for _, cp := range g.cat.procs {
66- all = append(all, armed{cp, cp.gen - 1})
74+ for _, procs := range g.cat.procs {
75+ for _, cp := range procs {
76+ all = append(all, armed{cp, cp.gen - 1})
77+ }
6778 }
6879 g.cat.mu.Unlock()
6980 for _, a := range all {
@@ -74,3 +85,25 @@ func (g *Git) FireStaleIdleTimers() {
7485 // ExecSlots returns the size of the one-shot process semaphore and how many
7586 // slots are taken.
7687 func (g *Git) ExecSlots() (size, taken int) { return cap(g.exec), len(g.exec) }
88+
89+// HoldCatFile runs fn while holding a cat-file process of the repository, as
90+// a request in flight would.
91+func (g *Git) HoldCatFile(ctx context.Context, repo domain.RepoName, fn func()) error {
92+ dir, err := g.repoDir(repo)
93+ if err != nil {
94+ return err
95+ }
96+ return g.cat.do(ctx, dir, func(*catConn) error { fn(); return nil })
97+}
98+
99+// CatFileWaiting returns the number of requests that wait for a cat-file
100+// process.
101+func (g *Git) CatFileWaiting() int {
102+ g.cat.mu.Lock()
103+ defer g.cat.mu.Unlock()
104+ n := 0
105+ for _, q := range g.cat.waiters {
106+ n += len(q)
107+ }
108+ return n
109+}
@@ -2,7 +2,12 @@
2 2
3 package gitcli3 package gitcli
4 4
5-import "sort"5+import (
6+ "context"
7+ "sort"
8+
9+ "bnema.dev/ermite/internal/domain"
10+)
6 11
7 // CatFilePIDs returns the process ids of the live long-lived cat-file12 // CatFilePIDs returns the process ids of the live long-lived cat-file
8 // processes, for the tests of the pool.13 // processes, for the tests of the pool.
@@ -10,8 +15,10 @@ func (g *Git) CatFilePIDs() []int {
10 g.cat.mu.Lock()15 g.cat.mu.Lock()
11 defer g.cat.mu.Unlock()16 defer g.cat.mu.Unlock()
12 var pids []int17 var pids []int
13- for _, cp := range g.cat.procs {18+ for _, procs := range g.cat.procs {
14- pids = append(pids, cp.cmd.Process.Pid)19+ for _, cp := range procs {
20+ pids = append(pids, cp.cmd.Process.Pid)
21+ }
15 }22 }
16 sort.Ints(pids)23 sort.Ints(pids)
17 return pids24 return pids
@@ -44,8 +51,10 @@ func (g *Git) FireIdleTimers() {
44 gen uint6451 gen uint64
45 }52 }
46 var all []armed53 var all []armed
47- for _, cp := range g.cat.procs {54+ for _, procs := range g.cat.procs {
48- all = append(all, armed{cp, cp.gen})55+ for _, cp := range procs {
56+ all = append(all, armed{cp, cp.gen})
57+ }
49 }58 }
50 g.cat.mu.Unlock()59 g.cat.mu.Unlock()
51 for _, a := range all {60 for _, a := range all {
@@ -62,8 +71,10 @@ func (g *Git) FireStaleIdleTimers() {
62 gen uint6471 gen uint64
63 }72 }
64 var all []armed73 var all []armed
65- for _, cp := range g.cat.procs {74+ for _, procs := range g.cat.procs {
66- all = append(all, armed{cp, cp.gen - 1})75+ for _, cp := range procs {
76+ all = append(all, armed{cp, cp.gen - 1})
77+ }
67 }78 }
68 g.cat.mu.Unlock()79 g.cat.mu.Unlock()
69 for _, a := range all {80 for _, a := range all {
@@ -74,3 +85,25 @@ func (g *Git) FireStaleIdleTimers() {
74 // ExecSlots returns the size of the one-shot process semaphore and how many85 // ExecSlots returns the size of the one-shot process semaphore and how many
75 // slots are taken.86 // slots are taken.
76 func (g *Git) ExecSlots() (size, taken int) { return cap(g.exec), len(g.exec) }87 func (g *Git) ExecSlots() (size, taken int) { return cap(g.exec), len(g.exec) }
88+
89+// HoldCatFile runs fn while holding a cat-file process of the repository, as
90+// a request in flight would.
91+func (g *Git) HoldCatFile(ctx context.Context, repo domain.RepoName, fn func()) error {
92+ dir, err := g.repoDir(repo)
93+ if err != nil {
94+ return err
95+ }
96+ return g.cat.do(ctx, dir, func(*catConn) error { fn(); return nil })
97+}
98+
99+// CatFileWaiting returns the number of requests that wait for a cat-file
100+// process.
101+func (g *Git) CatFileWaiting() int {
102+ g.cat.mu.Lock()
103+ defer g.cat.mu.Unlock()
104+ n := 0
105+ for _, q := range g.cat.waiters {
106+ n += len(q)
107+ }
108+ return n
109+}
M internal/adapters/gitcli/pool_test.go +166 -2
@@ -177,7 +177,7 @@ func TestPoolRestartsAfterProcessDeath(t *testing.T) {
177177 assert.NotEqual(t, old, now)
178178 }
179179
180-func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) {
180+func TestConcurrentReadsAreCorrectAndBounded(t *testing.T) {
181181 ctx := context.Background()
182182 g, data := newReader(t, nil)
183183 h := readertest.NewHistory(t, data)
@@ -223,7 +223,8 @@ func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) {
223223 for err := range errs {
224224 require.NoError(t, err)
225225 }
226- assert.Len(t, g.CatFilePIDs(), 1, "one process for one repository")
226+ assert.NotEmpty(t, g.CatFilePIDs())
227+ assert.LessOrEqual(t, len(g.CatFilePIDs()), 4, "a repository holds a bounded number of processes")
227228 }
228229
229230 func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) {
@@ -406,3 +407,166 @@ func TestBlobStreamsAreBounded(t *testing.T) {
406407 require.NoError(t, c.Close())
407408 assert.Zero(t, g.BlobStreams())
408409 }
410+
411+// hold runs HoldCatFile in the background and returns once the process is
412+// held. The returned func releases it and waits for the request to end.
413+func hold(t *testing.T, g *gitcli.Git, repo domain.RepoName) (release func()) {
414+ t.Helper()
415+ held, free, done := make(chan struct{}), make(chan struct{}), make(chan error, 1)
416+ go func() {
417+ done <- g.HoldCatFile(context.Background(), repo, func() { close(held); <-free })
418+ }()
419+ select {
420+ case <-held:
421+ case err := <-done:
422+ require.NoError(t, err)
423+ t.Fatal("the request ended before it held a process")
424+ }
425+ var once sync.Once
426+ release = func() {
427+ once.Do(func() { close(free); require.NoError(t, <-done) })
428+ }
429+ t.Cleanup(release)
430+ return release
431+}
432+
433+// A request in flight does not make the others of the repository wait: they
434+// get a process of their own, started only because of the contention.
435+func TestConcurrentReadsOnOneRepoDoNotSerialize(t *testing.T) {
436+ ctx := context.Background()
437+ g, data := newReader(t, nil)
438+ h := readertest.NewHistory(t, data)
439+
440+ _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{})
441+ require.NoError(t, err)
442+ require.Len(t, g.CatFilePIDs(), 1, "no contention, one process")
443+
444+ release := hold(t, g, h.Name)
445+ // The slow request holds the only process; this one must not wait for it
446+ // (it would block forever: the holder is released after).
447+ res, err := g.ResolveRevision(ctx, h.Name, domain.Revision{})
448+ require.NoError(t, err)
449+ assert.Equal(t, h.Merge, res.CommitID.String())
450+ pids := g.CatFilePIDs()
451+ assert.Len(t, pids, 2, "the contention started a second process")
452+ assert.Zero(t, g.CatFileWaiting())
453+
454+ release()
455+ // Both stay for reuse until they idle out.
456+ assert.Equal(t, pids, g.CatFilePIDs())
457+ g.FireIdleTimers()
458+ assert.Empty(t, g.CatFilePIDs())
459+ requireGone(t, pids...)
460+}
461+
462+func TestCatFileProcessBounds(t *testing.T) {
463+ tests := []struct {
464+ name string
465+ max int // Config.MaxCatFiles
466+ // holds are the repositories, by index, whose process a request holds.
467+ holds []int
468+ // extra is the repository of one more request, which must wait.
469+ extra int
470+ wantProcs int
471+ wantWaiting int
472+ }{
473+ {name: "per repository cap", max: 16, holds: []int{0, 0, 0, 0}, extra: 0, wantProcs: 4, wantWaiting: 1},
474+ {name: "global cap, repository with a process waits", max: 3, holds: []int{0, 0, 1}, extra: 1, wantProcs: 3, wantWaiting: 1},
475+ {name: "global cap lower than the per repository cap", max: 2, holds: []int{0, 0}, extra: 0, wantProcs: 2, wantWaiting: 1},
476+ }
477+ for _, tt := range tests {
478+ t.Run(tt.name, func(t *testing.T) {
479+ g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = tt.max })
480+ var names []domain.RepoName
481+ for i := range 2 {
482+ f := readertest.NewFixture(t, data, fmt.Sprintf("r%d", i))
483+ f.Write("x", "x").Commit("x")
484+ names = append(names, f.Name)
485+ }
486+ var releases []func()
487+ for _, i := range tt.holds {
488+ releases = append(releases, hold(t, g, names[i]))
489+ }
490+ assert.Len(t, g.CatFilePIDs(), tt.wantProcs)
491+
492+ done := make(chan error, 1)
493+ go func() {
494+ _, err := g.ResolveRevision(context.Background(), names[tt.extra], domain.Revision{})
495+ done <- err
496+ }()
497+ require.Eventually(t, func() bool { return g.CatFileWaiting() == tt.wantWaiting }, 5*time.Second, time.Millisecond)
498+ assert.Len(t, g.CatFilePIDs(), tt.wantProcs, "a waiting request starts nothing")
499+ select {
500+ case err := <-done:
501+ t.Fatalf("the request did not wait: %v", err)
502+ default:
503+ }
504+
505+ // A process released to a waiter serves it, without a new process.
506+ for k, i := range tt.holds {
507+ if i == tt.extra {
508+ releases[k]()
509+ break
510+ }
511+ }
512+ require.NoError(t, <-done)
513+ assert.LessOrEqual(t, len(g.CatFilePIDs()), tt.wantProcs)
514+ assert.Zero(t, g.CatFileWaiting())
515+ })
516+ }
517+}
518+
519+// A waiting request gives up with its context and leaves nothing queued.
520+func TestWaitingForAProcessEndsWithTheContext(t *testing.T) {
521+ g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = 1 })
522+ h := readertest.NewHistory(t, data)
523+ release := hold(t, g, h.Name)
524+
525+ ctx, cancel := context.WithCancel(context.Background())
526+ done := make(chan error, 1)
527+ go func() { _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{}); done <- err }()
528+ require.Eventually(t, func() bool { return g.CatFileWaiting() == 1 }, 5*time.Second, time.Millisecond)
529+ cancel()
530+ assert.ErrorIs(t, <-done, context.Canceled)
531+ assert.Zero(t, g.CatFileWaiting())
532+ assert.Len(t, g.CatFilePIDs(), 1)
533+
534+ release()
535+ _, err := g.ResolveRevision(context.Background(), h.Name, domain.Revision{})
536+ require.NoError(t, err, "the process survived the abandoned wait")
537+}
538+
539+// The bound counts processes, not repositories: idle processes of several
540+// repositories are evicted to make room.
541+func TestPoolBoundCountsProcessesAcrossRepositories(t *testing.T) {
542+ ctx := context.Background()
543+ g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = 3 })
544+ var names []domain.RepoName
545+ for i := range 4 {
546+ f := readertest.NewFixture(t, data, fmt.Sprintf("r%d", i))
547+ f.Write("x", "x").Commit("x")
548+ names = append(names, f.Name)
549+ }
550+ // Repository 0 grows to three processes.
551+ releases := []func(){hold(t, g, names[0]), hold(t, g, names[0]), hold(t, g, names[0])}
552+ require.Len(t, g.CatFilePIDs(), 3)
553+ for _, r := range releases {
554+ r()
555+ }
556+ before := g.CatFilePIDs()
557+
558+ for _, n := range names[1:] {
559+ _, err := g.ResolveRevision(ctx, n, domain.Revision{})
560+ require.NoError(t, err)
561+ assert.LessOrEqual(t, len(g.CatFilePIDs()), 3)
562+ }
563+ live := map[int]bool{}
564+ for _, p := range g.CatFilePIDs() {
565+ live[p] = true
566+ }
567+ for _, p := range before {
568+ if !live[p] {
569+ requireGone(t, p)
570+ }
571+ }
572+}
@@ -177,7 +177,7 @@ func TestPoolRestartsAfterProcessDeath(t *testing.T) {
177 assert.NotEqual(t, old, now)177 assert.NotEqual(t, old, now)
178 }178 }
179 179
180-func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) {180+func TestConcurrentReadsAreCorrectAndBounded(t *testing.T) {
181 ctx := context.Background()181 ctx := context.Background()
182 g, data := newReader(t, nil)182 g, data := newReader(t, nil)
183 h := readertest.NewHistory(t, data)183 h := readertest.NewHistory(t, data)
@@ -223,7 +223,8 @@ func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) {
223 for err := range errs {223 for err := range errs {
224 require.NoError(t, err)224 require.NoError(t, err)
225 }225 }
226- assert.Len(t, g.CatFilePIDs(), 1, "one process for one repository")226+ assert.NotEmpty(t, g.CatFilePIDs())
227+ assert.LessOrEqual(t, len(g.CatFilePIDs()), 4, "a repository holds a bounded number of processes")
227 }228 }
228 229
229 func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) {230 func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) {
@@ -406,3 +407,166 @@ func TestBlobStreamsAreBounded(t *testing.T) {
406 require.NoError(t, c.Close())407 require.NoError(t, c.Close())
407 assert.Zero(t, g.BlobStreams())408 assert.Zero(t, g.BlobStreams())
408 }409 }
410+
411+// hold runs HoldCatFile in the background and returns once the process is
412+// held. The returned func releases it and waits for the request to end.
413+func hold(t *testing.T, g *gitcli.Git, repo domain.RepoName) (release func()) {
414+ t.Helper()
415+ held, free, done := make(chan struct{}), make(chan struct{}), make(chan error, 1)
416+ go func() {
417+ done <- g.HoldCatFile(context.Background(), repo, func() { close(held); <-free })
418+ }()
419+ select {
420+ case <-held:
421+ case err := <-done:
422+ require.NoError(t, err)
423+ t.Fatal("the request ended before it held a process")
424+ }
425+ var once sync.Once
426+ release = func() {
427+ once.Do(func() { close(free); require.NoError(t, <-done) })
428+ }
429+ t.Cleanup(release)
430+ return release
431+}
432+
433+// A request in flight does not make the others of the repository wait: they
434+// get a process of their own, started only because of the contention.
435+func TestConcurrentReadsOnOneRepoDoNotSerialize(t *testing.T) {
436+ ctx := context.Background()
437+ g, data := newReader(t, nil)
438+ h := readertest.NewHistory(t, data)
439+
440+ _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{})
441+ require.NoError(t, err)
442+ require.Len(t, g.CatFilePIDs(), 1, "no contention, one process")
443+
444+ release := hold(t, g, h.Name)
445+ // The slow request holds the only process; this one must not wait for it
446+ // (it would block forever: the holder is released after).
447+ res, err := g.ResolveRevision(ctx, h.Name, domain.Revision{})
448+ require.NoError(t, err)
449+ assert.Equal(t, h.Merge, res.CommitID.String())
450+ pids := g.CatFilePIDs()
451+ assert.Len(t, pids, 2, "the contention started a second process")
452+ assert.Zero(t, g.CatFileWaiting())
453+
454+ release()
455+ // Both stay for reuse until they idle out.
456+ assert.Equal(t, pids, g.CatFilePIDs())
457+ g.FireIdleTimers()
458+ assert.Empty(t, g.CatFilePIDs())
459+ requireGone(t, pids...)
460+}
461+
462+func TestCatFileProcessBounds(t *testing.T) {
463+ tests := []struct {
464+ name string
465+ max int // Config.MaxCatFiles
466+ // holds are the repositories, by index, whose process a request holds.
467+ holds []int
468+ // extra is the repository of one more request, which must wait.
469+ extra int
470+ wantProcs int
471+ wantWaiting int
472+ }{
473+ {name: "per repository cap", max: 16, holds: []int{0, 0, 0, 0}, extra: 0, wantProcs: 4, wantWaiting: 1},
474+ {name: "global cap, repository with a process waits", max: 3, holds: []int{0, 0, 1}, extra: 1, wantProcs: 3, wantWaiting: 1},
475+ {name: "global cap lower than the per repository cap", max: 2, holds: []int{0, 0}, extra: 0, wantProcs: 2, wantWaiting: 1},
476+ }
477+ for _, tt := range tests {
478+ t.Run(tt.name, func(t *testing.T) {
479+ g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = tt.max })
480+ var names []domain.RepoName
481+ for i := range 2 {
482+ f := readertest.NewFixture(t, data, fmt.Sprintf("r%d", i))
483+ f.Write("x", "x").Commit("x")
484+ names = append(names, f.Name)
485+ }
486+ var releases []func()
487+ for _, i := range tt.holds {
488+ releases = append(releases, hold(t, g, names[i]))
489+ }
490+ assert.Len(t, g.CatFilePIDs(), tt.wantProcs)
491+
492+ done := make(chan error, 1)
493+ go func() {
494+ _, err := g.ResolveRevision(context.Background(), names[tt.extra], domain.Revision{})
495+ done <- err
496+ }()
497+ require.Eventually(t, func() bool { return g.CatFileWaiting() == tt.wantWaiting }, 5*time.Second, time.Millisecond)
498+ assert.Len(t, g.CatFilePIDs(), tt.wantProcs, "a waiting request starts nothing")
499+ select {
500+ case err := <-done:
501+ t.Fatalf("the request did not wait: %v", err)
502+ default:
503+ }
504+
505+ // A process released to a waiter serves it, without a new process.
506+ for k, i := range tt.holds {
507+ if i == tt.extra {
508+ releases[k]()
509+ break
510+ }
511+ }
512+ require.NoError(t, <-done)
513+ assert.LessOrEqual(t, len(g.CatFilePIDs()), tt.wantProcs)
514+ assert.Zero(t, g.CatFileWaiting())
515+ })
516+ }
517+}
518+
519+// A waiting request gives up with its context and leaves nothing queued.
520+func TestWaitingForAProcessEndsWithTheContext(t *testing.T) {
521+ g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = 1 })
522+ h := readertest.NewHistory(t, data)
523+ release := hold(t, g, h.Name)
524+
525+ ctx, cancel := context.WithCancel(context.Background())
526+ done := make(chan error, 1)
527+ go func() { _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{}); done <- err }()
528+ require.Eventually(t, func() bool { return g.CatFileWaiting() == 1 }, 5*time.Second, time.Millisecond)
529+ cancel()
530+ assert.ErrorIs(t, <-done, context.Canceled)
531+ assert.Zero(t, g.CatFileWaiting())
532+ assert.Len(t, g.CatFilePIDs(), 1)
533+
534+ release()
535+ _, err := g.ResolveRevision(context.Background(), h.Name, domain.Revision{})
536+ require.NoError(t, err, "the process survived the abandoned wait")
537+}
538+
539+// The bound counts processes, not repositories: idle processes of several
540+// repositories are evicted to make room.
541+func TestPoolBoundCountsProcessesAcrossRepositories(t *testing.T) {
542+ ctx := context.Background()
543+ g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = 3 })
544+ var names []domain.RepoName
545+ for i := range 4 {
546+ f := readertest.NewFixture(t, data, fmt.Sprintf("r%d", i))
547+ f.Write("x", "x").Commit("x")
548+ names = append(names, f.Name)
549+ }
550+ // Repository 0 grows to three processes.
551+ releases := []func(){hold(t, g, names[0]), hold(t, g, names[0]), hold(t, g, names[0])}
552+ require.Len(t, g.CatFilePIDs(), 3)
553+ for _, r := range releases {
554+ r()
555+ }
556+ before := g.CatFilePIDs()
557+
558+ for _, n := range names[1:] {
559+ _, err := g.ResolveRevision(ctx, n, domain.Revision{})
560+ require.NoError(t, err)
561+ assert.LessOrEqual(t, len(g.CatFilePIDs()), 3)
562+ }
563+ live := map[int]bool{}
564+ for _, p := range g.CatFilePIDs() {
565+ live[p] = true
566+ }
567+ for _, p := range before {
568+ if !live[p] {
569+ requireGone(t, p)
570+ }
571+ }
572+}
M internal/adapters/kvstore/store.go +14 -2
@@ -58,9 +58,21 @@ func (s *Store) Close(ctx context.Context) error {
5858
5959 // Check reports whether the database accepts writes. It returns the failure
6060 // once ermite-kv is closed or failed, and writes nothing.
61+//
62+// ermite-kv has no state accessor, and an empty batch sent through Apply would
63+// queue behind the writes in progress. Apply looks at the state of the database
64+// before it looks at its context, though, and an ended context stops it before
65+// the writer channel: Apply on a cancelled context therefore answers ErrClosed
66+// or ErrFailed when the database is stopped, and context.Canceled when it is
67+// healthy, without waiting for the writer.
6168 func (s *Store) Check(ctx context.Context) error {
62- var b kv.Batch // no ops: only the state of the database is checked
63- if _, err := s.db.Apply(ctx, &b); err != nil {
69+ if err := ctx.Err(); err != nil {
70+ return err
71+ }
72+ probe, cancel := context.WithCancel(context.WithoutCancel(ctx))
73+ cancel()
74+ var b kv.Batch
75+ if _, err := s.db.Apply(probe, &b); err != nil && !errors.Is(err, context.Canceled) {
6476 return fmt.Errorf("ermite-kv: %w", err)
6577 }
6678 return nil
@@ -58,9 +58,21 @@ func (s *Store) Close(ctx context.Context) error {
58 58
59 // Check reports whether the database accepts writes. It returns the failure59 // Check reports whether the database accepts writes. It returns the failure
60 // once ermite-kv is closed or failed, and writes nothing.60 // once ermite-kv is closed or failed, and writes nothing.
61+//
62+// ermite-kv has no state accessor, and an empty batch sent through Apply would
63+// queue behind the writes in progress. Apply looks at the state of the database
64+// before it looks at its context, though, and an ended context stops it before
65+// the writer channel: Apply on a cancelled context therefore answers ErrClosed
66+// or ErrFailed when the database is stopped, and context.Canceled when it is
67+// healthy, without waiting for the writer.
61 func (s *Store) Check(ctx context.Context) error {68 func (s *Store) Check(ctx context.Context) error {
62- var b kv.Batch // no ops: only the state of the database is checked69+ if err := ctx.Err(); err != nil {
63- if _, err := s.db.Apply(ctx, &b); err != nil {70+ return err
71+ }
72+ probe, cancel := context.WithCancel(context.WithoutCancel(ctx))
73+ cancel()
74+ var b kv.Batch
75+ if _, err := s.db.Apply(probe, &b); err != nil && !errors.Is(err, context.Canceled) {
64 return fmt.Errorf("ermite-kv: %w", err)76 return fmt.Errorf("ermite-kv: %w", err)
65 }77 }
66 return nil78 return nil
M internal/adapters/kvstore/store_test.go +6 -0
@@ -144,6 +144,12 @@ func TestCheck(t *testing.T) {
144144 s, err := kvstore.Open(ctx, t.TempDir())
145145 require.NoError(t, err)
146146 require.NoError(t, s.Check(ctx))
147+
148+ // The caller's own cancellation is reported, not mistaken for health.
149+ cancelled, cancel := context.WithCancel(ctx)
150+ cancel()
151+ assert.ErrorIs(t, s.Check(cancelled), context.Canceled)
152+
147153 require.NoError(t, s.Close(ctx))
148154 assert.ErrorIs(t, s.Check(ctx), kv.ErrClosed)
149155 }
@@ -144,6 +144,12 @@ func TestCheck(t *testing.T) {
144 s, err := kvstore.Open(ctx, t.TempDir())144 s, err := kvstore.Open(ctx, t.TempDir())
145 require.NoError(t, err)145 require.NoError(t, err)
146 require.NoError(t, s.Check(ctx))146 require.NoError(t, s.Check(ctx))
147+
148+ // The caller's own cancellation is reported, not mistaken for health.
149+ cancelled, cancel := context.WithCancel(ctx)
150+ cancel()
151+ assert.ErrorIs(t, s.Check(cancelled), context.Canceled)
152+
147 require.NoError(t, s.Close(ctx))153 require.NoError(t, s.Close(ctx))
148 assert.ErrorIs(t, s.Check(ctx), kv.ErrClosed)154 assert.ErrorIs(t, s.Check(ctx), kv.ErrClosed)
149 }155 }
A internal/adapters/web/mocks/GitServiceClient.go +457 -0
@@ -0,0 +1,457 @@
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+
10+ "bnema.dev/ermite-api/gen/ermite"
11+ "connectrpc.com/connect"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewGitServiceClient creates a new instance of GitServiceClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewGitServiceClient(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *GitServiceClient {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &GitServiceClient{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// GitServiceClient is an autogenerated mock type for the GitServiceClient type
39+type GitServiceClient struct {
40+ mock.Mock
41+}
42+
43+type GitServiceClient_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *GitServiceClient) EXPECT() *GitServiceClient_Expecter {
48+ return &GitServiceClient_Expecter{mock: &_m.Mock}
49+}
50+
51+// GetBlob provides a mock function for the type GitServiceClient
52+func (_mock *GitServiceClient) GetBlob(context1 context.Context, request *connect.Request[ermite.GetBlobRequest]) (*connect.ServerStreamForClient[ermite.GetBlobResponse], error) {
53+ ret := _mock.Called(context1, request)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for GetBlob")
57+ }
58+
59+ var r0 *connect.ServerStreamForClient[ermite.GetBlobResponse]
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetBlobRequest]) (*connect.ServerStreamForClient[ermite.GetBlobResponse], error)); ok {
62+ return returnFunc(context1, request)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetBlobRequest]) *connect.ServerStreamForClient[ermite.GetBlobResponse]); ok {
65+ r0 = returnFunc(context1, request)
66+ } else {
67+ if ret.Get(0) != nil {
68+ r0 = ret.Get(0).(*connect.ServerStreamForClient[ermite.GetBlobResponse])
69+ }
70+ }
71+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.GetBlobRequest]) error); ok {
72+ r1 = returnFunc(context1, request)
73+ } else {
74+ r1 = ret.Error(1)
75+ }
76+ return r0, r1
77+}
78+
79+// GitServiceClient_GetBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBlob'
80+type GitServiceClient_GetBlob_Call struct {
81+ *mock.Call
82+}
83+
84+// GetBlob is a helper method to define mock.On call
85+// - context1 context.Context
86+// - request *connect.Request[ermite.GetBlobRequest]
87+func (_e *GitServiceClient_Expecter) GetBlob(context1 any, request any) *GitServiceClient_GetBlob_Call {
88+ return &GitServiceClient_GetBlob_Call{Call: _e.mock.On("GetBlob", context1, request)}
89+}
90+
91+func (_c *GitServiceClient_GetBlob_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.GetBlobRequest])) *GitServiceClient_GetBlob_Call {
92+ _c.Call.Run(func(args mock.Arguments) {
93+ var arg0 context.Context
94+ if args[0] != nil {
95+ arg0 = args[0].(context.Context)
96+ }
97+ var arg1 *connect.Request[ermite.GetBlobRequest]
98+ if args[1] != nil {
99+ arg1 = args[1].(*connect.Request[ermite.GetBlobRequest])
100+ }
101+ run(
102+ arg0,
103+ arg1,
104+ )
105+ })
106+ return _c
107+}
108+
109+func (_c *GitServiceClient_GetBlob_Call) Return(serverStreamForClient *connect.ServerStreamForClient[ermite.GetBlobResponse], err error) *GitServiceClient_GetBlob_Call {
110+ _c.Call.Return(serverStreamForClient, err)
111+ return _c
112+}
113+
114+func (_c *GitServiceClient_GetBlob_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.GetBlobRequest]) (*connect.ServerStreamForClient[ermite.GetBlobResponse], error)) *GitServiceClient_GetBlob_Call {
115+ _c.Call.Return(run)
116+ return _c
117+}
118+
119+// GetCommit provides a mock function for the type GitServiceClient
120+func (_mock *GitServiceClient) GetCommit(context1 context.Context, request *connect.Request[ermite.GetCommitRequest]) (*connect.Response[ermite.GetCommitResponse], error) {
121+ ret := _mock.Called(context1, request)
122+
123+ if len(ret) == 0 {
124+ panic("no return value specified for GetCommit")
125+ }
126+
127+ var r0 *connect.Response[ermite.GetCommitResponse]
128+ var r1 error
129+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetCommitRequest]) (*connect.Response[ermite.GetCommitResponse], error)); ok {
130+ return returnFunc(context1, request)
131+ }
132+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetCommitRequest]) *connect.Response[ermite.GetCommitResponse]); ok {
133+ r0 = returnFunc(context1, request)
134+ } else {
135+ if ret.Get(0) != nil {
136+ r0 = ret.Get(0).(*connect.Response[ermite.GetCommitResponse])
137+ }
138+ }
139+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.GetCommitRequest]) error); ok {
140+ r1 = returnFunc(context1, request)
141+ } else {
142+ r1 = ret.Error(1)
143+ }
144+ return r0, r1
145+}
146+
147+// GitServiceClient_GetCommit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCommit'
148+type GitServiceClient_GetCommit_Call struct {
149+ *mock.Call
150+}
151+
152+// GetCommit is a helper method to define mock.On call
153+// - context1 context.Context
154+// - request *connect.Request[ermite.GetCommitRequest]
155+func (_e *GitServiceClient_Expecter) GetCommit(context1 any, request any) *GitServiceClient_GetCommit_Call {
156+ return &GitServiceClient_GetCommit_Call{Call: _e.mock.On("GetCommit", context1, request)}
157+}
158+
159+func (_c *GitServiceClient_GetCommit_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.GetCommitRequest])) *GitServiceClient_GetCommit_Call {
160+ _c.Call.Run(func(args mock.Arguments) {
161+ var arg0 context.Context
162+ if args[0] != nil {
163+ arg0 = args[0].(context.Context)
164+ }
165+ var arg1 *connect.Request[ermite.GetCommitRequest]
166+ if args[1] != nil {
167+ arg1 = args[1].(*connect.Request[ermite.GetCommitRequest])
168+ }
169+ run(
170+ arg0,
171+ arg1,
172+ )
173+ })
174+ return _c
175+}
176+
177+func (_c *GitServiceClient_GetCommit_Call) Return(response *connect.Response[ermite.GetCommitResponse], err error) *GitServiceClient_GetCommit_Call {
178+ _c.Call.Return(response, err)
179+ return _c
180+}
181+
182+func (_c *GitServiceClient_GetCommit_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.GetCommitRequest]) (*connect.Response[ermite.GetCommitResponse], error)) *GitServiceClient_GetCommit_Call {
183+ _c.Call.Return(run)
184+ return _c
185+}
186+
187+// GetTree provides a mock function for the type GitServiceClient
188+func (_mock *GitServiceClient) GetTree(context1 context.Context, request *connect.Request[ermite.GetTreeRequest]) (*connect.Response[ermite.GetTreeResponse], error) {
189+ ret := _mock.Called(context1, request)
190+
191+ if len(ret) == 0 {
192+ panic("no return value specified for GetTree")
193+ }
194+
195+ var r0 *connect.Response[ermite.GetTreeResponse]
196+ var r1 error
197+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetTreeRequest]) (*connect.Response[ermite.GetTreeResponse], error)); ok {
198+ return returnFunc(context1, request)
199+ }
200+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetTreeRequest]) *connect.Response[ermite.GetTreeResponse]); ok {
201+ r0 = returnFunc(context1, request)
202+ } else {
203+ if ret.Get(0) != nil {
204+ r0 = ret.Get(0).(*connect.Response[ermite.GetTreeResponse])
205+ }
206+ }
207+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.GetTreeRequest]) error); ok {
208+ r1 = returnFunc(context1, request)
209+ } else {
210+ r1 = ret.Error(1)
211+ }
212+ return r0, r1
213+}
214+
215+// GitServiceClient_GetTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTree'
216+type GitServiceClient_GetTree_Call struct {
217+ *mock.Call
218+}
219+
220+// GetTree is a helper method to define mock.On call
221+// - context1 context.Context
222+// - request *connect.Request[ermite.GetTreeRequest]
223+func (_e *GitServiceClient_Expecter) GetTree(context1 any, request any) *GitServiceClient_GetTree_Call {
224+ return &GitServiceClient_GetTree_Call{Call: _e.mock.On("GetTree", context1, request)}
225+}
226+
227+func (_c *GitServiceClient_GetTree_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.GetTreeRequest])) *GitServiceClient_GetTree_Call {
228+ _c.Call.Run(func(args mock.Arguments) {
229+ var arg0 context.Context
230+ if args[0] != nil {
231+ arg0 = args[0].(context.Context)
232+ }
233+ var arg1 *connect.Request[ermite.GetTreeRequest]
234+ if args[1] != nil {
235+ arg1 = args[1].(*connect.Request[ermite.GetTreeRequest])
236+ }
237+ run(
238+ arg0,
239+ arg1,
240+ )
241+ })
242+ return _c
243+}
244+
245+func (_c *GitServiceClient_GetTree_Call) Return(response *connect.Response[ermite.GetTreeResponse], err error) *GitServiceClient_GetTree_Call {
246+ _c.Call.Return(response, err)
247+ return _c
248+}
249+
250+func (_c *GitServiceClient_GetTree_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.GetTreeRequest]) (*connect.Response[ermite.GetTreeResponse], error)) *GitServiceClient_GetTree_Call {
251+ _c.Call.Return(run)
252+ return _c
253+}
254+
255+// ListCommits provides a mock function for the type GitServiceClient
256+func (_mock *GitServiceClient) ListCommits(context1 context.Context, request *connect.Request[ermite.ListCommitsRequest]) (*connect.Response[ermite.ListCommitsResponse], error) {
257+ ret := _mock.Called(context1, request)
258+
259+ if len(ret) == 0 {
260+ panic("no return value specified for ListCommits")
261+ }
262+
263+ var r0 *connect.Response[ermite.ListCommitsResponse]
264+ var r1 error
265+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ListCommitsRequest]) (*connect.Response[ermite.ListCommitsResponse], error)); ok {
266+ return returnFunc(context1, request)
267+ }
268+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ListCommitsRequest]) *connect.Response[ermite.ListCommitsResponse]); ok {
269+ r0 = returnFunc(context1, request)
270+ } else {
271+ if ret.Get(0) != nil {
272+ r0 = ret.Get(0).(*connect.Response[ermite.ListCommitsResponse])
273+ }
274+ }
275+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.ListCommitsRequest]) error); ok {
276+ r1 = returnFunc(context1, request)
277+ } else {
278+ r1 = ret.Error(1)
279+ }
280+ return r0, r1
281+}
282+
283+// GitServiceClient_ListCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListCommits'
284+type GitServiceClient_ListCommits_Call struct {
285+ *mock.Call
286+}
287+
288+// ListCommits is a helper method to define mock.On call
289+// - context1 context.Context
290+// - request *connect.Request[ermite.ListCommitsRequest]
291+func (_e *GitServiceClient_Expecter) ListCommits(context1 any, request any) *GitServiceClient_ListCommits_Call {
292+ return &GitServiceClient_ListCommits_Call{Call: _e.mock.On("ListCommits", context1, request)}
293+}
294+
295+func (_c *GitServiceClient_ListCommits_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.ListCommitsRequest])) *GitServiceClient_ListCommits_Call {
296+ _c.Call.Run(func(args mock.Arguments) {
297+ var arg0 context.Context
298+ if args[0] != nil {
299+ arg0 = args[0].(context.Context)
300+ }
301+ var arg1 *connect.Request[ermite.ListCommitsRequest]
302+ if args[1] != nil {
303+ arg1 = args[1].(*connect.Request[ermite.ListCommitsRequest])
304+ }
305+ run(
306+ arg0,
307+ arg1,
308+ )
309+ })
310+ return _c
311+}
312+
313+func (_c *GitServiceClient_ListCommits_Call) Return(response *connect.Response[ermite.ListCommitsResponse], err error) *GitServiceClient_ListCommits_Call {
314+ _c.Call.Return(response, err)
315+ return _c
316+}
317+
318+func (_c *GitServiceClient_ListCommits_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.ListCommitsRequest]) (*connect.Response[ermite.ListCommitsResponse], error)) *GitServiceClient_ListCommits_Call {
319+ _c.Call.Return(run)
320+ return _c
321+}
322+
323+// ListRefs provides a mock function for the type GitServiceClient
324+func (_mock *GitServiceClient) ListRefs(context1 context.Context, request *connect.Request[ermite.ListRefsRequest]) (*connect.Response[ermite.ListRefsResponse], error) {
325+ ret := _mock.Called(context1, request)
326+
327+ if len(ret) == 0 {
328+ panic("no return value specified for ListRefs")
329+ }
330+
331+ var r0 *connect.Response[ermite.ListRefsResponse]
332+ var r1 error
333+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ListRefsRequest]) (*connect.Response[ermite.ListRefsResponse], error)); ok {
334+ return returnFunc(context1, request)
335+ }
336+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ListRefsRequest]) *connect.Response[ermite.ListRefsResponse]); ok {
337+ r0 = returnFunc(context1, request)
338+ } else {
339+ if ret.Get(0) != nil {
340+ r0 = ret.Get(0).(*connect.Response[ermite.ListRefsResponse])
341+ }
342+ }
343+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.ListRefsRequest]) error); ok {
344+ r1 = returnFunc(context1, request)
345+ } else {
346+ r1 = ret.Error(1)
347+ }
348+ return r0, r1
349+}
350+
351+// GitServiceClient_ListRefs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRefs'
352+type GitServiceClient_ListRefs_Call struct {
353+ *mock.Call
354+}
355+
356+// ListRefs is a helper method to define mock.On call
357+// - context1 context.Context
358+// - request *connect.Request[ermite.ListRefsRequest]
359+func (_e *GitServiceClient_Expecter) ListRefs(context1 any, request any) *GitServiceClient_ListRefs_Call {
360+ return &GitServiceClient_ListRefs_Call{Call: _e.mock.On("ListRefs", context1, request)}
361+}
362+
363+func (_c *GitServiceClient_ListRefs_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.ListRefsRequest])) *GitServiceClient_ListRefs_Call {
364+ _c.Call.Run(func(args mock.Arguments) {
365+ var arg0 context.Context
366+ if args[0] != nil {
367+ arg0 = args[0].(context.Context)
368+ }
369+ var arg1 *connect.Request[ermite.ListRefsRequest]
370+ if args[1] != nil {
371+ arg1 = args[1].(*connect.Request[ermite.ListRefsRequest])
372+ }
373+ run(
374+ arg0,
375+ arg1,
376+ )
377+ })
378+ return _c
379+}
380+
381+func (_c *GitServiceClient_ListRefs_Call) Return(response *connect.Response[ermite.ListRefsResponse], err error) *GitServiceClient_ListRefs_Call {
382+ _c.Call.Return(response, err)
383+ return _c
384+}
385+
386+func (_c *GitServiceClient_ListRefs_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.ListRefsRequest]) (*connect.Response[ermite.ListRefsResponse], error)) *GitServiceClient_ListRefs_Call {
387+ _c.Call.Return(run)
388+ return _c
389+}
390+
391+// ResolveRevision provides a mock function for the type GitServiceClient
392+func (_mock *GitServiceClient) ResolveRevision(context1 context.Context, request *connect.Request[ermite.ResolveRevisionRequest]) (*connect.Response[ermite.ResolveRevisionResponse], error) {
393+ ret := _mock.Called(context1, request)
394+
395+ if len(ret) == 0 {
396+ panic("no return value specified for ResolveRevision")
397+ }
398+
399+ var r0 *connect.Response[ermite.ResolveRevisionResponse]
400+ var r1 error
401+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ResolveRevisionRequest]) (*connect.Response[ermite.ResolveRevisionResponse], error)); ok {
402+ return returnFunc(context1, request)
403+ }
404+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ResolveRevisionRequest]) *connect.Response[ermite.ResolveRevisionResponse]); ok {
405+ r0 = returnFunc(context1, request)
406+ } else {
407+ if ret.Get(0) != nil {
408+ r0 = ret.Get(0).(*connect.Response[ermite.ResolveRevisionResponse])
409+ }
410+ }
411+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.ResolveRevisionRequest]) error); ok {
412+ r1 = returnFunc(context1, request)
413+ } else {
414+ r1 = ret.Error(1)
415+ }
416+ return r0, r1
417+}
418+
419+// GitServiceClient_ResolveRevision_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveRevision'
420+type GitServiceClient_ResolveRevision_Call struct {
421+ *mock.Call
422+}
423+
424+// ResolveRevision is a helper method to define mock.On call
425+// - context1 context.Context
426+// - request *connect.Request[ermite.ResolveRevisionRequest]
427+func (_e *GitServiceClient_Expecter) ResolveRevision(context1 any, request any) *GitServiceClient_ResolveRevision_Call {
428+ return &GitServiceClient_ResolveRevision_Call{Call: _e.mock.On("ResolveRevision", context1, request)}
429+}
430+
431+func (_c *GitServiceClient_ResolveRevision_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.ResolveRevisionRequest])) *GitServiceClient_ResolveRevision_Call {
432+ _c.Call.Run(func(args mock.Arguments) {
433+ var arg0 context.Context
434+ if args[0] != nil {
435+ arg0 = args[0].(context.Context)
436+ }
437+ var arg1 *connect.Request[ermite.ResolveRevisionRequest]
438+ if args[1] != nil {
439+ arg1 = args[1].(*connect.Request[ermite.ResolveRevisionRequest])
440+ }
441+ run(
442+ arg0,
443+ arg1,
444+ )
445+ })
446+ return _c
447+}
448+
449+func (_c *GitServiceClient_ResolveRevision_Call) Return(response *connect.Response[ermite.ResolveRevisionResponse], err error) *GitServiceClient_ResolveRevision_Call {
450+ _c.Call.Return(response, err)
451+ return _c
452+}
453+
454+func (_c *GitServiceClient_ResolveRevision_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.ResolveRevisionRequest]) (*connect.Response[ermite.ResolveRevisionResponse], error)) *GitServiceClient_ResolveRevision_Call {
455+ _c.Call.Return(run)
456+ return _c
457+}
@@ -0,0 +1,457 @@
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+
10+ "bnema.dev/ermite-api/gen/ermite"
11+ "connectrpc.com/connect"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewGitServiceClient creates a new instance of GitServiceClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewGitServiceClient(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *GitServiceClient {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &GitServiceClient{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// GitServiceClient is an autogenerated mock type for the GitServiceClient type
39+type GitServiceClient struct {
40+ mock.Mock
41+}
42+
43+type GitServiceClient_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *GitServiceClient) EXPECT() *GitServiceClient_Expecter {
48+ return &GitServiceClient_Expecter{mock: &_m.Mock}
49+}
50+
51+// GetBlob provides a mock function for the type GitServiceClient
52+func (_mock *GitServiceClient) GetBlob(context1 context.Context, request *connect.Request[ermite.GetBlobRequest]) (*connect.ServerStreamForClient[ermite.GetBlobResponse], error) {
53+ ret := _mock.Called(context1, request)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for GetBlob")
57+ }
58+
59+ var r0 *connect.ServerStreamForClient[ermite.GetBlobResponse]
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetBlobRequest]) (*connect.ServerStreamForClient[ermite.GetBlobResponse], error)); ok {
62+ return returnFunc(context1, request)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetBlobRequest]) *connect.ServerStreamForClient[ermite.GetBlobResponse]); ok {
65+ r0 = returnFunc(context1, request)
66+ } else {
67+ if ret.Get(0) != nil {
68+ r0 = ret.Get(0).(*connect.ServerStreamForClient[ermite.GetBlobResponse])
69+ }
70+ }
71+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.GetBlobRequest]) error); ok {
72+ r1 = returnFunc(context1, request)
73+ } else {
74+ r1 = ret.Error(1)
75+ }
76+ return r0, r1
77+}
78+
79+// GitServiceClient_GetBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBlob'
80+type GitServiceClient_GetBlob_Call struct {
81+ *mock.Call
82+}
83+
84+// GetBlob is a helper method to define mock.On call
85+// - context1 context.Context
86+// - request *connect.Request[ermite.GetBlobRequest]
87+func (_e *GitServiceClient_Expecter) GetBlob(context1 any, request any) *GitServiceClient_GetBlob_Call {
88+ return &GitServiceClient_GetBlob_Call{Call: _e.mock.On("GetBlob", context1, request)}
89+}
90+
91+func (_c *GitServiceClient_GetBlob_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.GetBlobRequest])) *GitServiceClient_GetBlob_Call {
92+ _c.Call.Run(func(args mock.Arguments) {
93+ var arg0 context.Context
94+ if args[0] != nil {
95+ arg0 = args[0].(context.Context)
96+ }
97+ var arg1 *connect.Request[ermite.GetBlobRequest]
98+ if args[1] != nil {
99+ arg1 = args[1].(*connect.Request[ermite.GetBlobRequest])
100+ }
101+ run(
102+ arg0,
103+ arg1,
104+ )
105+ })
106+ return _c
107+}
108+
109+func (_c *GitServiceClient_GetBlob_Call) Return(serverStreamForClient *connect.ServerStreamForClient[ermite.GetBlobResponse], err error) *GitServiceClient_GetBlob_Call {
110+ _c.Call.Return(serverStreamForClient, err)
111+ return _c
112+}
113+
114+func (_c *GitServiceClient_GetBlob_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.GetBlobRequest]) (*connect.ServerStreamForClient[ermite.GetBlobResponse], error)) *GitServiceClient_GetBlob_Call {
115+ _c.Call.Return(run)
116+ return _c
117+}
118+
119+// GetCommit provides a mock function for the type GitServiceClient
120+func (_mock *GitServiceClient) GetCommit(context1 context.Context, request *connect.Request[ermite.GetCommitRequest]) (*connect.Response[ermite.GetCommitResponse], error) {
121+ ret := _mock.Called(context1, request)
122+
123+ if len(ret) == 0 {
124+ panic("no return value specified for GetCommit")
125+ }
126+
127+ var r0 *connect.Response[ermite.GetCommitResponse]
128+ var r1 error
129+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetCommitRequest]) (*connect.Response[ermite.GetCommitResponse], error)); ok {
130+ return returnFunc(context1, request)
131+ }
132+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetCommitRequest]) *connect.Response[ermite.GetCommitResponse]); ok {
133+ r0 = returnFunc(context1, request)
134+ } else {
135+ if ret.Get(0) != nil {
136+ r0 = ret.Get(0).(*connect.Response[ermite.GetCommitResponse])
137+ }
138+ }
139+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.GetCommitRequest]) error); ok {
140+ r1 = returnFunc(context1, request)
141+ } else {
142+ r1 = ret.Error(1)
143+ }
144+ return r0, r1
145+}
146+
147+// GitServiceClient_GetCommit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCommit'
148+type GitServiceClient_GetCommit_Call struct {
149+ *mock.Call
150+}
151+
152+// GetCommit is a helper method to define mock.On call
153+// - context1 context.Context
154+// - request *connect.Request[ermite.GetCommitRequest]
155+func (_e *GitServiceClient_Expecter) GetCommit(context1 any, request any) *GitServiceClient_GetCommit_Call {
156+ return &GitServiceClient_GetCommit_Call{Call: _e.mock.On("GetCommit", context1, request)}
157+}
158+
159+func (_c *GitServiceClient_GetCommit_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.GetCommitRequest])) *GitServiceClient_GetCommit_Call {
160+ _c.Call.Run(func(args mock.Arguments) {
161+ var arg0 context.Context
162+ if args[0] != nil {
163+ arg0 = args[0].(context.Context)
164+ }
165+ var arg1 *connect.Request[ermite.GetCommitRequest]
166+ if args[1] != nil {
167+ arg1 = args[1].(*connect.Request[ermite.GetCommitRequest])
168+ }
169+ run(
170+ arg0,
171+ arg1,
172+ )
173+ })
174+ return _c
175+}
176+
177+func (_c *GitServiceClient_GetCommit_Call) Return(response *connect.Response[ermite.GetCommitResponse], err error) *GitServiceClient_GetCommit_Call {
178+ _c.Call.Return(response, err)
179+ return _c
180+}
181+
182+func (_c *GitServiceClient_GetCommit_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.GetCommitRequest]) (*connect.Response[ermite.GetCommitResponse], error)) *GitServiceClient_GetCommit_Call {
183+ _c.Call.Return(run)
184+ return _c
185+}
186+
187+// GetTree provides a mock function for the type GitServiceClient
188+func (_mock *GitServiceClient) GetTree(context1 context.Context, request *connect.Request[ermite.GetTreeRequest]) (*connect.Response[ermite.GetTreeResponse], error) {
189+ ret := _mock.Called(context1, request)
190+
191+ if len(ret) == 0 {
192+ panic("no return value specified for GetTree")
193+ }
194+
195+ var r0 *connect.Response[ermite.GetTreeResponse]
196+ var r1 error
197+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetTreeRequest]) (*connect.Response[ermite.GetTreeResponse], error)); ok {
198+ return returnFunc(context1, request)
199+ }
200+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetTreeRequest]) *connect.Response[ermite.GetTreeResponse]); ok {
201+ r0 = returnFunc(context1, request)
202+ } else {
203+ if ret.Get(0) != nil {
204+ r0 = ret.Get(0).(*connect.Response[ermite.GetTreeResponse])
205+ }
206+ }
207+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.GetTreeRequest]) error); ok {
208+ r1 = returnFunc(context1, request)
209+ } else {
210+ r1 = ret.Error(1)
211+ }
212+ return r0, r1
213+}
214+
215+// GitServiceClient_GetTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTree'
216+type GitServiceClient_GetTree_Call struct {
217+ *mock.Call
218+}
219+
220+// GetTree is a helper method to define mock.On call
221+// - context1 context.Context
222+// - request *connect.Request[ermite.GetTreeRequest]
223+func (_e *GitServiceClient_Expecter) GetTree(context1 any, request any) *GitServiceClient_GetTree_Call {
224+ return &GitServiceClient_GetTree_Call{Call: _e.mock.On("GetTree", context1, request)}
225+}
226+
227+func (_c *GitServiceClient_GetTree_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.GetTreeRequest])) *GitServiceClient_GetTree_Call {
228+ _c.Call.Run(func(args mock.Arguments) {
229+ var arg0 context.Context
230+ if args[0] != nil {
231+ arg0 = args[0].(context.Context)
232+ }
233+ var arg1 *connect.Request[ermite.GetTreeRequest]
234+ if args[1] != nil {
235+ arg1 = args[1].(*connect.Request[ermite.GetTreeRequest])
236+ }
237+ run(
238+ arg0,
239+ arg1,
240+ )
241+ })
242+ return _c
243+}
244+
245+func (_c *GitServiceClient_GetTree_Call) Return(response *connect.Response[ermite.GetTreeResponse], err error) *GitServiceClient_GetTree_Call {
246+ _c.Call.Return(response, err)
247+ return _c
248+}
249+
250+func (_c *GitServiceClient_GetTree_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.GetTreeRequest]) (*connect.Response[ermite.GetTreeResponse], error)) *GitServiceClient_GetTree_Call {
251+ _c.Call.Return(run)
252+ return _c
253+}
254+
255+// ListCommits provides a mock function for the type GitServiceClient
256+func (_mock *GitServiceClient) ListCommits(context1 context.Context, request *connect.Request[ermite.ListCommitsRequest]) (*connect.Response[ermite.ListCommitsResponse], error) {
257+ ret := _mock.Called(context1, request)
258+
259+ if len(ret) == 0 {
260+ panic("no return value specified for ListCommits")
261+ }
262+
263+ var r0 *connect.Response[ermite.ListCommitsResponse]
264+ var r1 error
265+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ListCommitsRequest]) (*connect.Response[ermite.ListCommitsResponse], error)); ok {
266+ return returnFunc(context1, request)
267+ }
268+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ListCommitsRequest]) *connect.Response[ermite.ListCommitsResponse]); ok {
269+ r0 = returnFunc(context1, request)
270+ } else {
271+ if ret.Get(0) != nil {
272+ r0 = ret.Get(0).(*connect.Response[ermite.ListCommitsResponse])
273+ }
274+ }
275+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.ListCommitsRequest]) error); ok {
276+ r1 = returnFunc(context1, request)
277+ } else {
278+ r1 = ret.Error(1)
279+ }
280+ return r0, r1
281+}
282+
283+// GitServiceClient_ListCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListCommits'
284+type GitServiceClient_ListCommits_Call struct {
285+ *mock.Call
286+}
287+
288+// ListCommits is a helper method to define mock.On call
289+// - context1 context.Context
290+// - request *connect.Request[ermite.ListCommitsRequest]
291+func (_e *GitServiceClient_Expecter) ListCommits(context1 any, request any) *GitServiceClient_ListCommits_Call {
292+ return &GitServiceClient_ListCommits_Call{Call: _e.mock.On("ListCommits", context1, request)}
293+}
294+
295+func (_c *GitServiceClient_ListCommits_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.ListCommitsRequest])) *GitServiceClient_ListCommits_Call {
296+ _c.Call.Run(func(args mock.Arguments) {
297+ var arg0 context.Context
298+ if args[0] != nil {
299+ arg0 = args[0].(context.Context)
300+ }
301+ var arg1 *connect.Request[ermite.ListCommitsRequest]
302+ if args[1] != nil {
303+ arg1 = args[1].(*connect.Request[ermite.ListCommitsRequest])
304+ }
305+ run(
306+ arg0,
307+ arg1,
308+ )
309+ })
310+ return _c
311+}
312+
313+func (_c *GitServiceClient_ListCommits_Call) Return(response *connect.Response[ermite.ListCommitsResponse], err error) *GitServiceClient_ListCommits_Call {
314+ _c.Call.Return(response, err)
315+ return _c
316+}
317+
318+func (_c *GitServiceClient_ListCommits_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.ListCommitsRequest]) (*connect.Response[ermite.ListCommitsResponse], error)) *GitServiceClient_ListCommits_Call {
319+ _c.Call.Return(run)
320+ return _c
321+}
322+
323+// ListRefs provides a mock function for the type GitServiceClient
324+func (_mock *GitServiceClient) ListRefs(context1 context.Context, request *connect.Request[ermite.ListRefsRequest]) (*connect.Response[ermite.ListRefsResponse], error) {
325+ ret := _mock.Called(context1, request)
326+
327+ if len(ret) == 0 {
328+ panic("no return value specified for ListRefs")
329+ }
330+
331+ var r0 *connect.Response[ermite.ListRefsResponse]
332+ var r1 error
333+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ListRefsRequest]) (*connect.Response[ermite.ListRefsResponse], error)); ok {
334+ return returnFunc(context1, request)
335+ }
336+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ListRefsRequest]) *connect.Response[ermite.ListRefsResponse]); ok {
337+ r0 = returnFunc(context1, request)
338+ } else {
339+ if ret.Get(0) != nil {
340+ r0 = ret.Get(0).(*connect.Response[ermite.ListRefsResponse])
341+ }
342+ }
343+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.ListRefsRequest]) error); ok {
344+ r1 = returnFunc(context1, request)
345+ } else {
346+ r1 = ret.Error(1)
347+ }
348+ return r0, r1
349+}
350+
351+// GitServiceClient_ListRefs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRefs'
352+type GitServiceClient_ListRefs_Call struct {
353+ *mock.Call
354+}
355+
356+// ListRefs is a helper method to define mock.On call
357+// - context1 context.Context
358+// - request *connect.Request[ermite.ListRefsRequest]
359+func (_e *GitServiceClient_Expecter) ListRefs(context1 any, request any) *GitServiceClient_ListRefs_Call {
360+ return &GitServiceClient_ListRefs_Call{Call: _e.mock.On("ListRefs", context1, request)}
361+}
362+
363+func (_c *GitServiceClient_ListRefs_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.ListRefsRequest])) *GitServiceClient_ListRefs_Call {
364+ _c.Call.Run(func(args mock.Arguments) {
365+ var arg0 context.Context
366+ if args[0] != nil {
367+ arg0 = args[0].(context.Context)
368+ }
369+ var arg1 *connect.Request[ermite.ListRefsRequest]
370+ if args[1] != nil {
371+ arg1 = args[1].(*connect.Request[ermite.ListRefsRequest])
372+ }
373+ run(
374+ arg0,
375+ arg1,
376+ )
377+ })
378+ return _c
379+}
380+
381+func (_c *GitServiceClient_ListRefs_Call) Return(response *connect.Response[ermite.ListRefsResponse], err error) *GitServiceClient_ListRefs_Call {
382+ _c.Call.Return(response, err)
383+ return _c
384+}
385+
386+func (_c *GitServiceClient_ListRefs_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.ListRefsRequest]) (*connect.Response[ermite.ListRefsResponse], error)) *GitServiceClient_ListRefs_Call {
387+ _c.Call.Return(run)
388+ return _c
389+}
390+
391+// ResolveRevision provides a mock function for the type GitServiceClient
392+func (_mock *GitServiceClient) ResolveRevision(context1 context.Context, request *connect.Request[ermite.ResolveRevisionRequest]) (*connect.Response[ermite.ResolveRevisionResponse], error) {
393+ ret := _mock.Called(context1, request)
394+
395+ if len(ret) == 0 {
396+ panic("no return value specified for ResolveRevision")
397+ }
398+
399+ var r0 *connect.Response[ermite.ResolveRevisionResponse]
400+ var r1 error
401+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ResolveRevisionRequest]) (*connect.Response[ermite.ResolveRevisionResponse], error)); ok {
402+ return returnFunc(context1, request)
403+ }
404+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ResolveRevisionRequest]) *connect.Response[ermite.ResolveRevisionResponse]); ok {
405+ r0 = returnFunc(context1, request)
406+ } else {
407+ if ret.Get(0) != nil {
408+ r0 = ret.Get(0).(*connect.Response[ermite.ResolveRevisionResponse])
409+ }
410+ }
411+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.ResolveRevisionRequest]) error); ok {
412+ r1 = returnFunc(context1, request)
413+ } else {
414+ r1 = ret.Error(1)
415+ }
416+ return r0, r1
417+}
418+
419+// GitServiceClient_ResolveRevision_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveRevision'
420+type GitServiceClient_ResolveRevision_Call struct {
421+ *mock.Call
422+}
423+
424+// ResolveRevision is a helper method to define mock.On call
425+// - context1 context.Context
426+// - request *connect.Request[ermite.ResolveRevisionRequest]
427+func (_e *GitServiceClient_Expecter) ResolveRevision(context1 any, request any) *GitServiceClient_ResolveRevision_Call {
428+ return &GitServiceClient_ResolveRevision_Call{Call: _e.mock.On("ResolveRevision", context1, request)}
429+}
430+
431+func (_c *GitServiceClient_ResolveRevision_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.ResolveRevisionRequest])) *GitServiceClient_ResolveRevision_Call {
432+ _c.Call.Run(func(args mock.Arguments) {
433+ var arg0 context.Context
434+ if args[0] != nil {
435+ arg0 = args[0].(context.Context)
436+ }
437+ var arg1 *connect.Request[ermite.ResolveRevisionRequest]
438+ if args[1] != nil {
439+ arg1 = args[1].(*connect.Request[ermite.ResolveRevisionRequest])
440+ }
441+ run(
442+ arg0,
443+ arg1,
444+ )
445+ })
446+ return _c
447+}
448+
449+func (_c *GitServiceClient_ResolveRevision_Call) Return(response *connect.Response[ermite.ResolveRevisionResponse], err error) *GitServiceClient_ResolveRevision_Call {
450+ _c.Call.Return(response, err)
451+ return _c
452+}
453+
454+func (_c *GitServiceClient_ResolveRevision_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.ResolveRevisionRequest]) (*connect.Response[ermite.ResolveRevisionResponse], error)) *GitServiceClient_ResolveRevision_Call {
455+ _c.Call.Return(run)
456+ return _c
457+}
A internal/adapters/web/mocks/RepoServiceClient.go +389 -0
@@ -0,0 +1,389 @@
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+
10+ "bnema.dev/ermite-api/gen/ermite"
11+ "connectrpc.com/connect"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewRepoServiceClient creates a new instance of RepoServiceClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewRepoServiceClient(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *RepoServiceClient {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &RepoServiceClient{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// RepoServiceClient is an autogenerated mock type for the RepoServiceClient type
39+type RepoServiceClient struct {
40+ mock.Mock
41+}
42+
43+type RepoServiceClient_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *RepoServiceClient) EXPECT() *RepoServiceClient_Expecter {
48+ return &RepoServiceClient_Expecter{mock: &_m.Mock}
49+}
50+
51+// CreateRepo provides a mock function for the type RepoServiceClient
52+func (_mock *RepoServiceClient) CreateRepo(context1 context.Context, request *connect.Request[ermite.CreateRepoRequest]) (*connect.Response[ermite.CreateRepoResponse], error) {
53+ ret := _mock.Called(context1, request)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for CreateRepo")
57+ }
58+
59+ var r0 *connect.Response[ermite.CreateRepoResponse]
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.CreateRepoRequest]) (*connect.Response[ermite.CreateRepoResponse], error)); ok {
62+ return returnFunc(context1, request)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.CreateRepoRequest]) *connect.Response[ermite.CreateRepoResponse]); ok {
65+ r0 = returnFunc(context1, request)
66+ } else {
67+ if ret.Get(0) != nil {
68+ r0 = ret.Get(0).(*connect.Response[ermite.CreateRepoResponse])
69+ }
70+ }
71+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.CreateRepoRequest]) error); ok {
72+ r1 = returnFunc(context1, request)
73+ } else {
74+ r1 = ret.Error(1)
75+ }
76+ return r0, r1
77+}
78+
79+// RepoServiceClient_CreateRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRepo'
80+type RepoServiceClient_CreateRepo_Call struct {
81+ *mock.Call
82+}
83+
84+// CreateRepo is a helper method to define mock.On call
85+// - context1 context.Context
86+// - request *connect.Request[ermite.CreateRepoRequest]
87+func (_e *RepoServiceClient_Expecter) CreateRepo(context1 any, request any) *RepoServiceClient_CreateRepo_Call {
88+ return &RepoServiceClient_CreateRepo_Call{Call: _e.mock.On("CreateRepo", context1, request)}
89+}
90+
91+func (_c *RepoServiceClient_CreateRepo_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.CreateRepoRequest])) *RepoServiceClient_CreateRepo_Call {
92+ _c.Call.Run(func(args mock.Arguments) {
93+ var arg0 context.Context
94+ if args[0] != nil {
95+ arg0 = args[0].(context.Context)
96+ }
97+ var arg1 *connect.Request[ermite.CreateRepoRequest]
98+ if args[1] != nil {
99+ arg1 = args[1].(*connect.Request[ermite.CreateRepoRequest])
100+ }
101+ run(
102+ arg0,
103+ arg1,
104+ )
105+ })
106+ return _c
107+}
108+
109+func (_c *RepoServiceClient_CreateRepo_Call) Return(response *connect.Response[ermite.CreateRepoResponse], err error) *RepoServiceClient_CreateRepo_Call {
110+ _c.Call.Return(response, err)
111+ return _c
112+}
113+
114+func (_c *RepoServiceClient_CreateRepo_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.CreateRepoRequest]) (*connect.Response[ermite.CreateRepoResponse], error)) *RepoServiceClient_CreateRepo_Call {
115+ _c.Call.Return(run)
116+ return _c
117+}
118+
119+// DeleteRepo provides a mock function for the type RepoServiceClient
120+func (_mock *RepoServiceClient) DeleteRepo(context1 context.Context, request *connect.Request[ermite.DeleteRepoRequest]) (*connect.Response[ermite.DeleteRepoResponse], error) {
121+ ret := _mock.Called(context1, request)
122+
123+ if len(ret) == 0 {
124+ panic("no return value specified for DeleteRepo")
125+ }
126+
127+ var r0 *connect.Response[ermite.DeleteRepoResponse]
128+ var r1 error
129+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.DeleteRepoRequest]) (*connect.Response[ermite.DeleteRepoResponse], error)); ok {
130+ return returnFunc(context1, request)
131+ }
132+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.DeleteRepoRequest]) *connect.Response[ermite.DeleteRepoResponse]); ok {
133+ r0 = returnFunc(context1, request)
134+ } else {
135+ if ret.Get(0) != nil {
136+ r0 = ret.Get(0).(*connect.Response[ermite.DeleteRepoResponse])
137+ }
138+ }
139+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.DeleteRepoRequest]) error); ok {
140+ r1 = returnFunc(context1, request)
141+ } else {
142+ r1 = ret.Error(1)
143+ }
144+ return r0, r1
145+}
146+
147+// RepoServiceClient_DeleteRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRepo'
148+type RepoServiceClient_DeleteRepo_Call struct {
149+ *mock.Call
150+}
151+
152+// DeleteRepo is a helper method to define mock.On call
153+// - context1 context.Context
154+// - request *connect.Request[ermite.DeleteRepoRequest]
155+func (_e *RepoServiceClient_Expecter) DeleteRepo(context1 any, request any) *RepoServiceClient_DeleteRepo_Call {
156+ return &RepoServiceClient_DeleteRepo_Call{Call: _e.mock.On("DeleteRepo", context1, request)}
157+}
158+
159+func (_c *RepoServiceClient_DeleteRepo_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.DeleteRepoRequest])) *RepoServiceClient_DeleteRepo_Call {
160+ _c.Call.Run(func(args mock.Arguments) {
161+ var arg0 context.Context
162+ if args[0] != nil {
163+ arg0 = args[0].(context.Context)
164+ }
165+ var arg1 *connect.Request[ermite.DeleteRepoRequest]
166+ if args[1] != nil {
167+ arg1 = args[1].(*connect.Request[ermite.DeleteRepoRequest])
168+ }
169+ run(
170+ arg0,
171+ arg1,
172+ )
173+ })
174+ return _c
175+}
176+
177+func (_c *RepoServiceClient_DeleteRepo_Call) Return(response *connect.Response[ermite.DeleteRepoResponse], err error) *RepoServiceClient_DeleteRepo_Call {
178+ _c.Call.Return(response, err)
179+ return _c
180+}
181+
182+func (_c *RepoServiceClient_DeleteRepo_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.DeleteRepoRequest]) (*connect.Response[ermite.DeleteRepoResponse], error)) *RepoServiceClient_DeleteRepo_Call {
183+ _c.Call.Return(run)
184+ return _c
185+}
186+
187+// GetRepo provides a mock function for the type RepoServiceClient
188+func (_mock *RepoServiceClient) GetRepo(context1 context.Context, request *connect.Request[ermite.GetRepoRequest]) (*connect.Response[ermite.GetRepoResponse], error) {
189+ ret := _mock.Called(context1, request)
190+
191+ if len(ret) == 0 {
192+ panic("no return value specified for GetRepo")
193+ }
194+
195+ var r0 *connect.Response[ermite.GetRepoResponse]
196+ var r1 error
197+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetRepoRequest]) (*connect.Response[ermite.GetRepoResponse], error)); ok {
198+ return returnFunc(context1, request)
199+ }
200+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetRepoRequest]) *connect.Response[ermite.GetRepoResponse]); ok {
201+ r0 = returnFunc(context1, request)
202+ } else {
203+ if ret.Get(0) != nil {
204+ r0 = ret.Get(0).(*connect.Response[ermite.GetRepoResponse])
205+ }
206+ }
207+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.GetRepoRequest]) error); ok {
208+ r1 = returnFunc(context1, request)
209+ } else {
210+ r1 = ret.Error(1)
211+ }
212+ return r0, r1
213+}
214+
215+// RepoServiceClient_GetRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepo'
216+type RepoServiceClient_GetRepo_Call struct {
217+ *mock.Call
218+}
219+
220+// GetRepo is a helper method to define mock.On call
221+// - context1 context.Context
222+// - request *connect.Request[ermite.GetRepoRequest]
223+func (_e *RepoServiceClient_Expecter) GetRepo(context1 any, request any) *RepoServiceClient_GetRepo_Call {
224+ return &RepoServiceClient_GetRepo_Call{Call: _e.mock.On("GetRepo", context1, request)}
225+}
226+
227+func (_c *RepoServiceClient_GetRepo_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.GetRepoRequest])) *RepoServiceClient_GetRepo_Call {
228+ _c.Call.Run(func(args mock.Arguments) {
229+ var arg0 context.Context
230+ if args[0] != nil {
231+ arg0 = args[0].(context.Context)
232+ }
233+ var arg1 *connect.Request[ermite.GetRepoRequest]
234+ if args[1] != nil {
235+ arg1 = args[1].(*connect.Request[ermite.GetRepoRequest])
236+ }
237+ run(
238+ arg0,
239+ arg1,
240+ )
241+ })
242+ return _c
243+}
244+
245+func (_c *RepoServiceClient_GetRepo_Call) Return(response *connect.Response[ermite.GetRepoResponse], err error) *RepoServiceClient_GetRepo_Call {
246+ _c.Call.Return(response, err)
247+ return _c
248+}
249+
250+func (_c *RepoServiceClient_GetRepo_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.GetRepoRequest]) (*connect.Response[ermite.GetRepoResponse], error)) *RepoServiceClient_GetRepo_Call {
251+ _c.Call.Return(run)
252+ return _c
253+}
254+
255+// ListRepos provides a mock function for the type RepoServiceClient
256+func (_mock *RepoServiceClient) ListRepos(context1 context.Context, request *connect.Request[ermite.ListReposRequest]) (*connect.Response[ermite.ListReposResponse], error) {
257+ ret := _mock.Called(context1, request)
258+
259+ if len(ret) == 0 {
260+ panic("no return value specified for ListRepos")
261+ }
262+
263+ var r0 *connect.Response[ermite.ListReposResponse]
264+ var r1 error
265+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ListReposRequest]) (*connect.Response[ermite.ListReposResponse], error)); ok {
266+ return returnFunc(context1, request)
267+ }
268+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ListReposRequest]) *connect.Response[ermite.ListReposResponse]); ok {
269+ r0 = returnFunc(context1, request)
270+ } else {
271+ if ret.Get(0) != nil {
272+ r0 = ret.Get(0).(*connect.Response[ermite.ListReposResponse])
273+ }
274+ }
275+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.ListReposRequest]) error); ok {
276+ r1 = returnFunc(context1, request)
277+ } else {
278+ r1 = ret.Error(1)
279+ }
280+ return r0, r1
281+}
282+
283+// RepoServiceClient_ListRepos_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRepos'
284+type RepoServiceClient_ListRepos_Call struct {
285+ *mock.Call
286+}
287+
288+// ListRepos is a helper method to define mock.On call
289+// - context1 context.Context
290+// - request *connect.Request[ermite.ListReposRequest]
291+func (_e *RepoServiceClient_Expecter) ListRepos(context1 any, request any) *RepoServiceClient_ListRepos_Call {
292+ return &RepoServiceClient_ListRepos_Call{Call: _e.mock.On("ListRepos", context1, request)}
293+}
294+
295+func (_c *RepoServiceClient_ListRepos_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.ListReposRequest])) *RepoServiceClient_ListRepos_Call {
296+ _c.Call.Run(func(args mock.Arguments) {
297+ var arg0 context.Context
298+ if args[0] != nil {
299+ arg0 = args[0].(context.Context)
300+ }
301+ var arg1 *connect.Request[ermite.ListReposRequest]
302+ if args[1] != nil {
303+ arg1 = args[1].(*connect.Request[ermite.ListReposRequest])
304+ }
305+ run(
306+ arg0,
307+ arg1,
308+ )
309+ })
310+ return _c
311+}
312+
313+func (_c *RepoServiceClient_ListRepos_Call) Return(response *connect.Response[ermite.ListReposResponse], err error) *RepoServiceClient_ListRepos_Call {
314+ _c.Call.Return(response, err)
315+ return _c
316+}
317+
318+func (_c *RepoServiceClient_ListRepos_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.ListReposRequest]) (*connect.Response[ermite.ListReposResponse], error)) *RepoServiceClient_ListRepos_Call {
319+ _c.Call.Return(run)
320+ return _c
321+}
322+
323+// UpdateRepo provides a mock function for the type RepoServiceClient
324+func (_mock *RepoServiceClient) UpdateRepo(context1 context.Context, request *connect.Request[ermite.UpdateRepoRequest]) (*connect.Response[ermite.UpdateRepoResponse], error) {
325+ ret := _mock.Called(context1, request)
326+
327+ if len(ret) == 0 {
328+ panic("no return value specified for UpdateRepo")
329+ }
330+
331+ var r0 *connect.Response[ermite.UpdateRepoResponse]
332+ var r1 error
333+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.UpdateRepoRequest]) (*connect.Response[ermite.UpdateRepoResponse], error)); ok {
334+ return returnFunc(context1, request)
335+ }
336+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.UpdateRepoRequest]) *connect.Response[ermite.UpdateRepoResponse]); ok {
337+ r0 = returnFunc(context1, request)
338+ } else {
339+ if ret.Get(0) != nil {
340+ r0 = ret.Get(0).(*connect.Response[ermite.UpdateRepoResponse])
341+ }
342+ }
343+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.UpdateRepoRequest]) error); ok {
344+ r1 = returnFunc(context1, request)
345+ } else {
346+ r1 = ret.Error(1)
347+ }
348+ return r0, r1
349+}
350+
351+// RepoServiceClient_UpdateRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRepo'
352+type RepoServiceClient_UpdateRepo_Call struct {
353+ *mock.Call
354+}
355+
356+// UpdateRepo is a helper method to define mock.On call
357+// - context1 context.Context
358+// - request *connect.Request[ermite.UpdateRepoRequest]
359+func (_e *RepoServiceClient_Expecter) UpdateRepo(context1 any, request any) *RepoServiceClient_UpdateRepo_Call {
360+ return &RepoServiceClient_UpdateRepo_Call{Call: _e.mock.On("UpdateRepo", context1, request)}
361+}
362+
363+func (_c *RepoServiceClient_UpdateRepo_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.UpdateRepoRequest])) *RepoServiceClient_UpdateRepo_Call {
364+ _c.Call.Run(func(args mock.Arguments) {
365+ var arg0 context.Context
366+ if args[0] != nil {
367+ arg0 = args[0].(context.Context)
368+ }
369+ var arg1 *connect.Request[ermite.UpdateRepoRequest]
370+ if args[1] != nil {
371+ arg1 = args[1].(*connect.Request[ermite.UpdateRepoRequest])
372+ }
373+ run(
374+ arg0,
375+ arg1,
376+ )
377+ })
378+ return _c
379+}
380+
381+func (_c *RepoServiceClient_UpdateRepo_Call) Return(response *connect.Response[ermite.UpdateRepoResponse], err error) *RepoServiceClient_UpdateRepo_Call {
382+ _c.Call.Return(response, err)
383+ return _c
384+}
385+
386+func (_c *RepoServiceClient_UpdateRepo_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.UpdateRepoRequest]) (*connect.Response[ermite.UpdateRepoResponse], error)) *RepoServiceClient_UpdateRepo_Call {
387+ _c.Call.Return(run)
388+ return _c
389+}
@@ -0,0 +1,389 @@
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+
10+ "bnema.dev/ermite-api/gen/ermite"
11+ "connectrpc.com/connect"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewRepoServiceClient creates a new instance of RepoServiceClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewRepoServiceClient(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *RepoServiceClient {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &RepoServiceClient{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// RepoServiceClient is an autogenerated mock type for the RepoServiceClient type
39+type RepoServiceClient struct {
40+ mock.Mock
41+}
42+
43+type RepoServiceClient_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *RepoServiceClient) EXPECT() *RepoServiceClient_Expecter {
48+ return &RepoServiceClient_Expecter{mock: &_m.Mock}
49+}
50+
51+// CreateRepo provides a mock function for the type RepoServiceClient
52+func (_mock *RepoServiceClient) CreateRepo(context1 context.Context, request *connect.Request[ermite.CreateRepoRequest]) (*connect.Response[ermite.CreateRepoResponse], error) {
53+ ret := _mock.Called(context1, request)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for CreateRepo")
57+ }
58+
59+ var r0 *connect.Response[ermite.CreateRepoResponse]
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.CreateRepoRequest]) (*connect.Response[ermite.CreateRepoResponse], error)); ok {
62+ return returnFunc(context1, request)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.CreateRepoRequest]) *connect.Response[ermite.CreateRepoResponse]); ok {
65+ r0 = returnFunc(context1, request)
66+ } else {
67+ if ret.Get(0) != nil {
68+ r0 = ret.Get(0).(*connect.Response[ermite.CreateRepoResponse])
69+ }
70+ }
71+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.CreateRepoRequest]) error); ok {
72+ r1 = returnFunc(context1, request)
73+ } else {
74+ r1 = ret.Error(1)
75+ }
76+ return r0, r1
77+}
78+
79+// RepoServiceClient_CreateRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRepo'
80+type RepoServiceClient_CreateRepo_Call struct {
81+ *mock.Call
82+}
83+
84+// CreateRepo is a helper method to define mock.On call
85+// - context1 context.Context
86+// - request *connect.Request[ermite.CreateRepoRequest]
87+func (_e *RepoServiceClient_Expecter) CreateRepo(context1 any, request any) *RepoServiceClient_CreateRepo_Call {
88+ return &RepoServiceClient_CreateRepo_Call{Call: _e.mock.On("CreateRepo", context1, request)}
89+}
90+
91+func (_c *RepoServiceClient_CreateRepo_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.CreateRepoRequest])) *RepoServiceClient_CreateRepo_Call {
92+ _c.Call.Run(func(args mock.Arguments) {
93+ var arg0 context.Context
94+ if args[0] != nil {
95+ arg0 = args[0].(context.Context)
96+ }
97+ var arg1 *connect.Request[ermite.CreateRepoRequest]
98+ if args[1] != nil {
99+ arg1 = args[1].(*connect.Request[ermite.CreateRepoRequest])
100+ }
101+ run(
102+ arg0,
103+ arg1,
104+ )
105+ })
106+ return _c
107+}
108+
109+func (_c *RepoServiceClient_CreateRepo_Call) Return(response *connect.Response[ermite.CreateRepoResponse], err error) *RepoServiceClient_CreateRepo_Call {
110+ _c.Call.Return(response, err)
111+ return _c
112+}
113+
114+func (_c *RepoServiceClient_CreateRepo_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.CreateRepoRequest]) (*connect.Response[ermite.CreateRepoResponse], error)) *RepoServiceClient_CreateRepo_Call {
115+ _c.Call.Return(run)
116+ return _c
117+}
118+
119+// DeleteRepo provides a mock function for the type RepoServiceClient
120+func (_mock *RepoServiceClient) DeleteRepo(context1 context.Context, request *connect.Request[ermite.DeleteRepoRequest]) (*connect.Response[ermite.DeleteRepoResponse], error) {
121+ ret := _mock.Called(context1, request)
122+
123+ if len(ret) == 0 {
124+ panic("no return value specified for DeleteRepo")
125+ }
126+
127+ var r0 *connect.Response[ermite.DeleteRepoResponse]
128+ var r1 error
129+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.DeleteRepoRequest]) (*connect.Response[ermite.DeleteRepoResponse], error)); ok {
130+ return returnFunc(context1, request)
131+ }
132+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.DeleteRepoRequest]) *connect.Response[ermite.DeleteRepoResponse]); ok {
133+ r0 = returnFunc(context1, request)
134+ } else {
135+ if ret.Get(0) != nil {
136+ r0 = ret.Get(0).(*connect.Response[ermite.DeleteRepoResponse])
137+ }
138+ }
139+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.DeleteRepoRequest]) error); ok {
140+ r1 = returnFunc(context1, request)
141+ } else {
142+ r1 = ret.Error(1)
143+ }
144+ return r0, r1
145+}
146+
147+// RepoServiceClient_DeleteRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRepo'
148+type RepoServiceClient_DeleteRepo_Call struct {
149+ *mock.Call
150+}
151+
152+// DeleteRepo is a helper method to define mock.On call
153+// - context1 context.Context
154+// - request *connect.Request[ermite.DeleteRepoRequest]
155+func (_e *RepoServiceClient_Expecter) DeleteRepo(context1 any, request any) *RepoServiceClient_DeleteRepo_Call {
156+ return &RepoServiceClient_DeleteRepo_Call{Call: _e.mock.On("DeleteRepo", context1, request)}
157+}
158+
159+func (_c *RepoServiceClient_DeleteRepo_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.DeleteRepoRequest])) *RepoServiceClient_DeleteRepo_Call {
160+ _c.Call.Run(func(args mock.Arguments) {
161+ var arg0 context.Context
162+ if args[0] != nil {
163+ arg0 = args[0].(context.Context)
164+ }
165+ var arg1 *connect.Request[ermite.DeleteRepoRequest]
166+ if args[1] != nil {
167+ arg1 = args[1].(*connect.Request[ermite.DeleteRepoRequest])
168+ }
169+ run(
170+ arg0,
171+ arg1,
172+ )
173+ })
174+ return _c
175+}
176+
177+func (_c *RepoServiceClient_DeleteRepo_Call) Return(response *connect.Response[ermite.DeleteRepoResponse], err error) *RepoServiceClient_DeleteRepo_Call {
178+ _c.Call.Return(response, err)
179+ return _c
180+}
181+
182+func (_c *RepoServiceClient_DeleteRepo_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.DeleteRepoRequest]) (*connect.Response[ermite.DeleteRepoResponse], error)) *RepoServiceClient_DeleteRepo_Call {
183+ _c.Call.Return(run)
184+ return _c
185+}
186+
187+// GetRepo provides a mock function for the type RepoServiceClient
188+func (_mock *RepoServiceClient) GetRepo(context1 context.Context, request *connect.Request[ermite.GetRepoRequest]) (*connect.Response[ermite.GetRepoResponse], error) {
189+ ret := _mock.Called(context1, request)
190+
191+ if len(ret) == 0 {
192+ panic("no return value specified for GetRepo")
193+ }
194+
195+ var r0 *connect.Response[ermite.GetRepoResponse]
196+ var r1 error
197+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetRepoRequest]) (*connect.Response[ermite.GetRepoResponse], error)); ok {
198+ return returnFunc(context1, request)
199+ }
200+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.GetRepoRequest]) *connect.Response[ermite.GetRepoResponse]); ok {
201+ r0 = returnFunc(context1, request)
202+ } else {
203+ if ret.Get(0) != nil {
204+ r0 = ret.Get(0).(*connect.Response[ermite.GetRepoResponse])
205+ }
206+ }
207+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.GetRepoRequest]) error); ok {
208+ r1 = returnFunc(context1, request)
209+ } else {
210+ r1 = ret.Error(1)
211+ }
212+ return r0, r1
213+}
214+
215+// RepoServiceClient_GetRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepo'
216+type RepoServiceClient_GetRepo_Call struct {
217+ *mock.Call
218+}
219+
220+// GetRepo is a helper method to define mock.On call
221+// - context1 context.Context
222+// - request *connect.Request[ermite.GetRepoRequest]
223+func (_e *RepoServiceClient_Expecter) GetRepo(context1 any, request any) *RepoServiceClient_GetRepo_Call {
224+ return &RepoServiceClient_GetRepo_Call{Call: _e.mock.On("GetRepo", context1, request)}
225+}
226+
227+func (_c *RepoServiceClient_GetRepo_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.GetRepoRequest])) *RepoServiceClient_GetRepo_Call {
228+ _c.Call.Run(func(args mock.Arguments) {
229+ var arg0 context.Context
230+ if args[0] != nil {
231+ arg0 = args[0].(context.Context)
232+ }
233+ var arg1 *connect.Request[ermite.GetRepoRequest]
234+ if args[1] != nil {
235+ arg1 = args[1].(*connect.Request[ermite.GetRepoRequest])
236+ }
237+ run(
238+ arg0,
239+ arg1,
240+ )
241+ })
242+ return _c
243+}
244+
245+func (_c *RepoServiceClient_GetRepo_Call) Return(response *connect.Response[ermite.GetRepoResponse], err error) *RepoServiceClient_GetRepo_Call {
246+ _c.Call.Return(response, err)
247+ return _c
248+}
249+
250+func (_c *RepoServiceClient_GetRepo_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.GetRepoRequest]) (*connect.Response[ermite.GetRepoResponse], error)) *RepoServiceClient_GetRepo_Call {
251+ _c.Call.Return(run)
252+ return _c
253+}
254+
255+// ListRepos provides a mock function for the type RepoServiceClient
256+func (_mock *RepoServiceClient) ListRepos(context1 context.Context, request *connect.Request[ermite.ListReposRequest]) (*connect.Response[ermite.ListReposResponse], error) {
257+ ret := _mock.Called(context1, request)
258+
259+ if len(ret) == 0 {
260+ panic("no return value specified for ListRepos")
261+ }
262+
263+ var r0 *connect.Response[ermite.ListReposResponse]
264+ var r1 error
265+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ListReposRequest]) (*connect.Response[ermite.ListReposResponse], error)); ok {
266+ return returnFunc(context1, request)
267+ }
268+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.ListReposRequest]) *connect.Response[ermite.ListReposResponse]); ok {
269+ r0 = returnFunc(context1, request)
270+ } else {
271+ if ret.Get(0) != nil {
272+ r0 = ret.Get(0).(*connect.Response[ermite.ListReposResponse])
273+ }
274+ }
275+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.ListReposRequest]) error); ok {
276+ r1 = returnFunc(context1, request)
277+ } else {
278+ r1 = ret.Error(1)
279+ }
280+ return r0, r1
281+}
282+
283+// RepoServiceClient_ListRepos_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRepos'
284+type RepoServiceClient_ListRepos_Call struct {
285+ *mock.Call
286+}
287+
288+// ListRepos is a helper method to define mock.On call
289+// - context1 context.Context
290+// - request *connect.Request[ermite.ListReposRequest]
291+func (_e *RepoServiceClient_Expecter) ListRepos(context1 any, request any) *RepoServiceClient_ListRepos_Call {
292+ return &RepoServiceClient_ListRepos_Call{Call: _e.mock.On("ListRepos", context1, request)}
293+}
294+
295+func (_c *RepoServiceClient_ListRepos_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.ListReposRequest])) *RepoServiceClient_ListRepos_Call {
296+ _c.Call.Run(func(args mock.Arguments) {
297+ var arg0 context.Context
298+ if args[0] != nil {
299+ arg0 = args[0].(context.Context)
300+ }
301+ var arg1 *connect.Request[ermite.ListReposRequest]
302+ if args[1] != nil {
303+ arg1 = args[1].(*connect.Request[ermite.ListReposRequest])
304+ }
305+ run(
306+ arg0,
307+ arg1,
308+ )
309+ })
310+ return _c
311+}
312+
313+func (_c *RepoServiceClient_ListRepos_Call) Return(response *connect.Response[ermite.ListReposResponse], err error) *RepoServiceClient_ListRepos_Call {
314+ _c.Call.Return(response, err)
315+ return _c
316+}
317+
318+func (_c *RepoServiceClient_ListRepos_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.ListReposRequest]) (*connect.Response[ermite.ListReposResponse], error)) *RepoServiceClient_ListRepos_Call {
319+ _c.Call.Return(run)
320+ return _c
321+}
322+
323+// UpdateRepo provides a mock function for the type RepoServiceClient
324+func (_mock *RepoServiceClient) UpdateRepo(context1 context.Context, request *connect.Request[ermite.UpdateRepoRequest]) (*connect.Response[ermite.UpdateRepoResponse], error) {
325+ ret := _mock.Called(context1, request)
326+
327+ if len(ret) == 0 {
328+ panic("no return value specified for UpdateRepo")
329+ }
330+
331+ var r0 *connect.Response[ermite.UpdateRepoResponse]
332+ var r1 error
333+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.UpdateRepoRequest]) (*connect.Response[ermite.UpdateRepoResponse], error)); ok {
334+ return returnFunc(context1, request)
335+ }
336+ if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[ermite.UpdateRepoRequest]) *connect.Response[ermite.UpdateRepoResponse]); ok {
337+ r0 = returnFunc(context1, request)
338+ } else {
339+ if ret.Get(0) != nil {
340+ r0 = ret.Get(0).(*connect.Response[ermite.UpdateRepoResponse])
341+ }
342+ }
343+ if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[ermite.UpdateRepoRequest]) error); ok {
344+ r1 = returnFunc(context1, request)
345+ } else {
346+ r1 = ret.Error(1)
347+ }
348+ return r0, r1
349+}
350+
351+// RepoServiceClient_UpdateRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRepo'
352+type RepoServiceClient_UpdateRepo_Call struct {
353+ *mock.Call
354+}
355+
356+// UpdateRepo is a helper method to define mock.On call
357+// - context1 context.Context
358+// - request *connect.Request[ermite.UpdateRepoRequest]
359+func (_e *RepoServiceClient_Expecter) UpdateRepo(context1 any, request any) *RepoServiceClient_UpdateRepo_Call {
360+ return &RepoServiceClient_UpdateRepo_Call{Call: _e.mock.On("UpdateRepo", context1, request)}
361+}
362+
363+func (_c *RepoServiceClient_UpdateRepo_Call) Run(run func(context1 context.Context, request *connect.Request[ermite.UpdateRepoRequest])) *RepoServiceClient_UpdateRepo_Call {
364+ _c.Call.Run(func(args mock.Arguments) {
365+ var arg0 context.Context
366+ if args[0] != nil {
367+ arg0 = args[0].(context.Context)
368+ }
369+ var arg1 *connect.Request[ermite.UpdateRepoRequest]
370+ if args[1] != nil {
371+ arg1 = args[1].(*connect.Request[ermite.UpdateRepoRequest])
372+ }
373+ run(
374+ arg0,
375+ arg1,
376+ )
377+ })
378+ return _c
379+}
380+
381+func (_c *RepoServiceClient_UpdateRepo_Call) Return(response *connect.Response[ermite.UpdateRepoResponse], err error) *RepoServiceClient_UpdateRepo_Call {
382+ _c.Call.Return(response, err)
383+ return _c
384+}
385+
386+func (_c *RepoServiceClient_UpdateRepo_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[ermite.UpdateRepoRequest]) (*connect.Response[ermite.UpdateRepoResponse], error)) *RepoServiceClient_UpdateRepo_Call {
387+ _c.Call.Return(run)
388+ return _c
389+}
M internal/adapters/web/raw.go +51 -9
@@ -8,9 +8,13 @@ import (
88 "net/http"
99 "path"
1010 "strconv"
11+ "strings"
1112 "time"
1213
14+ "connectrpc.com/connect"
1315 "github.com/bnema/zerowrap"
16+
17+ pb "bnema.dev/ermite-api/gen/ermite"
1418 )
1519
1620 // rawTimeout bounds the stream of one file to the visitor.
@@ -27,7 +31,23 @@ const rawCSP = "default-src 'none'; sandbox"
2731 func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
2832 ctx, cancel := context.WithTimeout(r.Context(), rawTimeout)
2933 defer cancel()
30- b, t, repo, err := s.rawOpen(ctx, r)
34+ t, repo, err := s.rawTarget(ctx, r)
35+ if err != nil {
36+ s.fail(ctx, w, r, err)
37+ return
38+ }
39+ // A revalidation of an unchanged file ends here, before the content is
40+ // opened: for a large blob, opening starts a Git process.
41+ if match := r.Header.Get("If-None-Match"); match != "" && s.rawUnchanged(ctx, repo, t, match) {
42+ h := w.Header()
43+ h.Set("ETag", match)
44+ h.Set("X-Content-Type-Options", "nosniff")
45+ h.Set("Content-Security-Policy", rawCSP)
46+ cacheHeaders(ctx, h, "no-cache", sessionCache)
47+ w.WriteHeader(http.StatusNotModified)
48+ return
49+ }
50+ b, err := s.openBlob(ctx, repo, t)
3151 if err != nil {
3252 s.fail(ctx, w, r, err)
3353 return
@@ -86,21 +106,43 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
86106 }
87107 }
88108
89-// rawOpen finds the file a raw URL designates and opens its stream.
90-func (s *Server) rawOpen(ctx context.Context, r *http.Request) (*blobStream, target, string, error) {
109+// rawTarget finds the file a raw URL designates and the name of its repository.
110+func (s *Server) rawTarget(ctx context.Context, r *http.Request) (target, string, error) {
91111 d, err := s.loadRepo(ctx, r.PathValue("repo"))
92112 if err != nil {
93- return nil, target{}, "", err
113+ return target{}, "", err
94114 }
95115 t, ok := d.locate(r.PathValue("rest"))
96- if !ok {
97- return nil, target{}, "", errNotFound
116+ if !ok || t.path == "" {
117+ return target{}, "", errNotFound
98118 }
99- b, err := s.openBlob(ctx, d.name(), t)
119+ return t, d.name(), nil
120+}
121+
122+// rawUnchanged reports whether match is the ETag of the file, which is the id
123+// of its blob. The id comes from the entry of the file in its directory, so
124+// nothing of the content is read. Any doubt (the entry is not found, the
125+// listing is cut, the call fails) answers false: the file is then opened and
126+// the real outcome, error included, follows.
127+func (s *Server) rawUnchanged(ctx context.Context, repo string, t target, match string) bool {
128+ dir, _ := path.Split(t.path)
129+ res, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{
130+ Repo: repo, Revision: t.rev, Path: strings.TrimSuffix(dir, "/"),
131+ }))
100132 if err != nil {
101- return nil, target{}, "", err
133+ return false
134+ }
135+ for _, e := range res.Msg.GetEntries() {
136+ if e.GetPath() != t.path {
137+ continue
138+ }
139+ switch e.GetKind() {
140+ case pb.EntryKind_ENTRY_KIND_FILE, pb.EntryKind_ENTRY_KIND_EXECUTABLE, pb.EntryKind_ENTRY_KIND_SYMLINK:
141+ return e.GetId() != "" && match == `"`+e.GetId()+`"`
142+ }
143+ return false
102144 }
103- return b, t, d.name(), nil
145+ return false
104146 }
105147
106148 // rawType is the Content-Type of a file and whether it is an attachment.
@@ -8,9 +8,13 @@ import (
8 "net/http"8 "net/http"
9 "path"9 "path"
10 "strconv"10 "strconv"
11+ "strings"
11 "time"12 "time"
12 13
14+ "connectrpc.com/connect"
13 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
16+
17+ pb "bnema.dev/ermite-api/gen/ermite"
14 )18 )
15 19
16 // rawTimeout bounds the stream of one file to the visitor.20 // rawTimeout bounds the stream of one file to the visitor.
@@ -27,7 +31,23 @@ const rawCSP = "default-src 'none'; sandbox"
27 func (s *Server) raw(w http.ResponseWriter, r *http.Request) {31 func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
28 ctx, cancel := context.WithTimeout(r.Context(), rawTimeout)32 ctx, cancel := context.WithTimeout(r.Context(), rawTimeout)
29 defer cancel()33 defer cancel()
30- b, t, repo, err := s.rawOpen(ctx, r)34+ t, repo, err := s.rawTarget(ctx, r)
35+ if err != nil {
36+ s.fail(ctx, w, r, err)
37+ return
38+ }
39+ // A revalidation of an unchanged file ends here, before the content is
40+ // opened: for a large blob, opening starts a Git process.
41+ if match := r.Header.Get("If-None-Match"); match != "" && s.rawUnchanged(ctx, repo, t, match) {
42+ h := w.Header()
43+ h.Set("ETag", match)
44+ h.Set("X-Content-Type-Options", "nosniff")
45+ h.Set("Content-Security-Policy", rawCSP)
46+ cacheHeaders(ctx, h, "no-cache", sessionCache)
47+ w.WriteHeader(http.StatusNotModified)
48+ return
49+ }
50+ b, err := s.openBlob(ctx, repo, t)
31 if err != nil {51 if err != nil {
32 s.fail(ctx, w, r, err)52 s.fail(ctx, w, r, err)
33 return53 return
@@ -86,21 +106,43 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
86 }106 }
87 }107 }
88 108
89-// rawOpen finds the file a raw URL designates and opens its stream.109+// rawTarget finds the file a raw URL designates and the name of its repository.
90-func (s *Server) rawOpen(ctx context.Context, r *http.Request) (*blobStream, target, string, error) {110+func (s *Server) rawTarget(ctx context.Context, r *http.Request) (target, string, error) {
91 d, err := s.loadRepo(ctx, r.PathValue("repo"))111 d, err := s.loadRepo(ctx, r.PathValue("repo"))
92 if err != nil {112 if err != nil {
93- return nil, target{}, "", err113+ return target{}, "", err
94 }114 }
95 t, ok := d.locate(r.PathValue("rest"))115 t, ok := d.locate(r.PathValue("rest"))
96- if !ok {116+ if !ok || t.path == "" {
97- return nil, target{}, "", errNotFound117+ return target{}, "", errNotFound
98 }118 }
99- b, err := s.openBlob(ctx, d.name(), t)119+ return t, d.name(), nil
120+}
121+
122+// rawUnchanged reports whether match is the ETag of the file, which is the id
123+// of its blob. The id comes from the entry of the file in its directory, so
124+// nothing of the content is read. Any doubt (the entry is not found, the
125+// listing is cut, the call fails) answers false: the file is then opened and
126+// the real outcome, error included, follows.
127+func (s *Server) rawUnchanged(ctx context.Context, repo string, t target, match string) bool {
128+ dir, _ := path.Split(t.path)
129+ res, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{
130+ Repo: repo, Revision: t.rev, Path: strings.TrimSuffix(dir, "/"),
131+ }))
100 if err != nil {132 if err != nil {
101- return nil, target{}, "", err133+ return false
134+ }
135+ for _, e := range res.Msg.GetEntries() {
136+ if e.GetPath() != t.path {
137+ continue
138+ }
139+ switch e.GetKind() {
140+ case pb.EntryKind_ENTRY_KIND_FILE, pb.EntryKind_ENTRY_KIND_EXECUTABLE, pb.EntryKind_ENTRY_KIND_SYMLINK:
141+ return e.GetId() != "" && match == `"`+e.GetId()+`"`
142+ }
143+ return false
102 }144 }
103- return b, t, d.name(), nil145+ return false
104 }146 }
105 147
106 // rawType is the Content-Type of a file and whether it is an attachment.148 // rawType is the Content-Type of a file and whether it is an attachment.
A internal/adapters/web/raw_test.go +128 -0
@@ -0,0 +1,128 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "net/http/httptest"
9+ "testing"
10+
11+ "connectrpc.com/connect"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ pb "bnema.dev/ermite-api/gen/ermite"
17+ "bnema.dev/ermite/internal/adapters/web/mocks"
18+)
19+
20+const blobID = "0123456789abcdef0123456789abcdef01234567"
21+
22+// A revalidation of an unchanged file is answered from the tree entry, before
23+// the content is opened; anything else opens the file.
24+func TestRawChecksETagBeforeOpeningTheBlob(t *testing.T) {
25+ entry := func(kind pb.EntryKind, id string) *pb.TreeEntry {
26+ return &pb.TreeEntry{Name: "big.bin", Path: "docs/big.bin", Kind: kind, Id: id}
27+ }
28+ tests := []struct {
29+ name string
30+ url string
31+ ifNone string
32+ wantDir string
33+ tree *pb.GetTreeResponse
34+ treeErr error
35+ want int
36+ wantOpens int // GetBlob calls
37+ }{
38+ {
39+ name: "unchanged file", url: "/r/raw/main/docs/big.bin", ifNone: `"` + blobID + `"`, wantDir: "docs",
40+ tree: &pb.GetTreeResponse{Entries: []*pb.TreeEntry{entry(pb.EntryKind_ENTRY_KIND_FILE, blobID)}},
41+ want: http.StatusNotModified,
42+ },
43+ {
44+ name: "unchanged executable", url: "/r/raw/main/docs/big.bin", ifNone: `"` + blobID + `"`, wantDir: "docs",
45+ tree: &pb.GetTreeResponse{Entries: []*pb.TreeEntry{entry(pb.EntryKind_ENTRY_KIND_EXECUTABLE, blobID)}},
46+ want: http.StatusNotModified,
47+ },
48+ {
49+ name: "file at the root", url: "/r/raw/main/big.bin", ifNone: `"` + blobID + `"`, wantDir: "",
50+ tree: &pb.GetTreeResponse{Entries: []*pb.TreeEntry{{Name: "big.bin", Path: "big.bin", Kind: pb.EntryKind_ENTRY_KIND_FILE, Id: blobID}}},
51+ want: http.StatusNotModified,
52+ },
53+ {
54+ name: "changed file", url: "/r/raw/main/docs/big.bin", ifNone: `"` + blobID + `"`, wantDir: "docs",
55+ tree: &pb.GetTreeResponse{Entries: []*pb.TreeEntry{entry(pb.EntryKind_ENTRY_KIND_FILE, "ffffffffffffffffffffffffffffffffffffffff")}},
56+ want: http.StatusNotFound, wantOpens: 1,
57+ },
58+ {
59+ name: "directory with that id", url: "/r/raw/main/docs/big.bin", ifNone: `"` + blobID + `"`, wantDir: "docs",
60+ tree: &pb.GetTreeResponse{Entries: []*pb.TreeEntry{entry(pb.EntryKind_ENTRY_KIND_DIRECTORY, blobID)}},
61+ want: http.StatusNotFound, wantOpens: 1,
62+ },
63+ {
64+ name: "entry not listed", url: "/r/raw/main/docs/big.bin", ifNone: `"` + blobID + `"`, wantDir: "docs",
65+ tree: &pb.GetTreeResponse{Truncated: true},
66+ want: http.StatusNotFound, wantOpens: 1,
67+ },
68+ {
69+ name: "tree lookup fails", url: "/r/raw/main/docs/big.bin", ifNone: `"` + blobID + `"`, wantDir: "docs",
70+ treeErr: connect.NewError(connect.CodeNotFound, assert.AnError),
71+ want: http.StatusNotFound, wantOpens: 1,
72+ },
73+ {
74+ name: "weak validator is not a match", url: "/r/raw/main/docs/big.bin", ifNone: `W/"` + blobID + `"`, wantDir: "docs",
75+ tree: &pb.GetTreeResponse{Entries: []*pb.TreeEntry{entry(pb.EntryKind_ENTRY_KIND_FILE, blobID)}},
76+ want: http.StatusNotFound, wantOpens: 1,
77+ },
78+ {
79+ name: "no validator", url: "/r/raw/main/docs/big.bin", ifNone: "",
80+ want: http.StatusNotFound, wantOpens: 1,
81+ },
82+ }
83+ for _, tt := range tests {
84+ t.Run(tt.name, func(t *testing.T) {
85+ repos := mocks.NewRepoServiceClient(t)
86+ git := mocks.NewGitServiceClient(t)
87+ repos.EXPECT().GetRepo(mock.Anything, mock.Anything).
88+ Return(connect.NewResponse(&pb.GetRepoResponse{Repo: &pb.Repo{Name: "r", DefaultBranch: "main"}}), nil)
89+ git.EXPECT().ListRefs(mock.Anything, mock.Anything).
90+ Return(connect.NewResponse(&pb.ListRefsResponse{Refs: []*pb.Ref{
91+ {Name: "refs/heads/main", ShortName: "main", Kind: pb.RefKind_REF_KIND_BRANCH, CommitId: "c0ffee0"},
92+ }}), nil)
93+ if tt.ifNone != "" {
94+ git.EXPECT().GetTree(mock.Anything, mock.MatchedBy(func(req *connect.Request[pb.GetTreeRequest]) bool {
95+ m := req.Msg
96+ return m.GetRepo() == "r" && m.GetRevision() == "c0ffee0" && m.GetPath() == tt.wantDir && !m.GetIncludeLastCommit()
97+ })).Return(connect.NewResponse(tt.tree), tt.treeErr).Once()
98+ }
99+ opens := 0
100+ git.EXPECT().GetBlob(mock.Anything, mock.Anything).RunAndReturn(
101+ func(_ context.Context, _ *connect.Request[pb.GetBlobRequest]) (*connect.ServerStreamForClient[pb.GetBlobResponse], error) {
102+ opens++
103+ return nil, connect.NewError(connect.CodeNotFound, assert.AnError)
104+ }).Maybe()
105+
106+ srv, err := New(Config{
107+ Repos: repos, Git: git,
108+ PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1",
109+ })
110+ require.NoError(t, err)
111+ req := httptest.NewRequest(http.MethodGet, tt.url, nil)
112+ if tt.ifNone != "" {
113+ req.Header.Set("If-None-Match", tt.ifNone)
114+ }
115+ rec := httptest.NewRecorder()
116+ srv.ServeHTTP(rec, req)
117+
118+ assert.Equal(t, tt.want, rec.Code)
119+ assert.Equal(t, tt.wantOpens, opens, "GetBlob calls")
120+ if tt.want == http.StatusNotModified {
121+ assert.Equal(t, tt.ifNone, rec.Header().Get("ETag"))
122+ assert.Equal(t, rawCSP, rec.Header().Get("Content-Security-Policy"))
123+ assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
124+ assert.Empty(t, rec.Body.Bytes())
125+ }
126+ })
127+ }
128+}
@@ -0,0 +1,128 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "net/http/httptest"
9+ "testing"
10+
11+ "connectrpc.com/connect"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ pb "bnema.dev/ermite-api/gen/ermite"
17+ "bnema.dev/ermite/internal/adapters/web/mocks"
18+)
19+
20+const blobID = "0123456789abcdef0123456789abcdef01234567"
21+
22+// A revalidation of an unchanged file is answered from the tree entry, before
23+// the content is opened; anything else opens the file.
24+func TestRawChecksETagBeforeOpeningTheBlob(t *testing.T) {
25+ entry := func(kind pb.EntryKind, id string) *pb.TreeEntry {
26+ return &pb.TreeEntry{Name: "big.bin", Path: "docs/big.bin", Kind: kind, Id: id}
27+ }
28+ tests := []struct {
29+ name string
30+ url string
31+ ifNone string
32+ wantDir string
33+ tree *pb.GetTreeResponse
34+ treeErr error
35+ want int
36+ wantOpens int // GetBlob calls
37+ }{
38+ {
39+ name: "unchanged file", url: "/r/raw/main/docs/big.bin", ifNone: `"` + blobID + `"`, wantDir: "docs",
40+ tree: &pb.GetTreeResponse{Entries: []*pb.TreeEntry{entry(pb.EntryKind_ENTRY_KIND_FILE, blobID)}},
41+ want: http.StatusNotModified,
42+ },
43+ {
44+ name: "unchanged executable", url: "/r/raw/main/docs/big.bin", ifNone: `"` + blobID + `"`, wantDir: "docs",
45+ tree: &pb.GetTreeResponse{Entries: []*pb.TreeEntry{entry(pb.EntryKind_ENTRY_KIND_EXECUTABLE, blobID)}},
46+ want: http.StatusNotModified,
47+ },
48+ {
49+ name: "file at the root", url: "/r/raw/main/big.bin", ifNone: `"` + blobID + `"`, wantDir: "",
50+ tree: &pb.GetTreeResponse{Entries: []*pb.TreeEntry{{Name: "big.bin", Path: "big.bin", Kind: pb.EntryKind_ENTRY_KIND_FILE, Id: blobID}}},
51+ want: http.StatusNotModified,
52+ },
53+ {
54+ name: "changed file", url: "/r/raw/main/docs/big.bin", ifNone: `"` + blobID + `"`, wantDir: "docs",
55+ tree: &pb.GetTreeResponse{Entries: []*pb.TreeEntry{entry(pb.EntryKind_ENTRY_KIND_FILE, "ffffffffffffffffffffffffffffffffffffffff")}},
56+ want: http.StatusNotFound, wantOpens: 1,
57+ },
58+ {
59+ name: "directory with that id", url: "/r/raw/main/docs/big.bin", ifNone: `"` + blobID + `"`, wantDir: "docs",
60+ tree: &pb.GetTreeResponse{Entries: []*pb.TreeEntry{entry(pb.EntryKind_ENTRY_KIND_DIRECTORY, blobID)}},
61+ want: http.StatusNotFound, wantOpens: 1,
62+ },
63+ {
64+ name: "entry not listed", url: "/r/raw/main/docs/big.bin", ifNone: `"` + blobID + `"`, wantDir: "docs",
65+ tree: &pb.GetTreeResponse{Truncated: true},
66+ want: http.StatusNotFound, wantOpens: 1,
67+ },
68+ {
69+ name: "tree lookup fails", url: "/r/raw/main/docs/big.bin", ifNone: `"` + blobID + `"`, wantDir: "docs",
70+ treeErr: connect.NewError(connect.CodeNotFound, assert.AnError),
71+ want: http.StatusNotFound, wantOpens: 1,
72+ },
73+ {
74+ name: "weak validator is not a match", url: "/r/raw/main/docs/big.bin", ifNone: `W/"` + blobID + `"`, wantDir: "docs",
75+ tree: &pb.GetTreeResponse{Entries: []*pb.TreeEntry{entry(pb.EntryKind_ENTRY_KIND_FILE, blobID)}},
76+ want: http.StatusNotFound, wantOpens: 1,
77+ },
78+ {
79+ name: "no validator", url: "/r/raw/main/docs/big.bin", ifNone: "",
80+ want: http.StatusNotFound, wantOpens: 1,
81+ },
82+ }
83+ for _, tt := range tests {
84+ t.Run(tt.name, func(t *testing.T) {
85+ repos := mocks.NewRepoServiceClient(t)
86+ git := mocks.NewGitServiceClient(t)
87+ repos.EXPECT().GetRepo(mock.Anything, mock.Anything).
88+ Return(connect.NewResponse(&pb.GetRepoResponse{Repo: &pb.Repo{Name: "r", DefaultBranch: "main"}}), nil)
89+ git.EXPECT().ListRefs(mock.Anything, mock.Anything).
90+ Return(connect.NewResponse(&pb.ListRefsResponse{Refs: []*pb.Ref{
91+ {Name: "refs/heads/main", ShortName: "main", Kind: pb.RefKind_REF_KIND_BRANCH, CommitId: "c0ffee0"},
92+ }}), nil)
93+ if tt.ifNone != "" {
94+ git.EXPECT().GetTree(mock.Anything, mock.MatchedBy(func(req *connect.Request[pb.GetTreeRequest]) bool {
95+ m := req.Msg
96+ return m.GetRepo() == "r" && m.GetRevision() == "c0ffee0" && m.GetPath() == tt.wantDir && !m.GetIncludeLastCommit()
97+ })).Return(connect.NewResponse(tt.tree), tt.treeErr).Once()
98+ }
99+ opens := 0
100+ git.EXPECT().GetBlob(mock.Anything, mock.Anything).RunAndReturn(
101+ func(_ context.Context, _ *connect.Request[pb.GetBlobRequest]) (*connect.ServerStreamForClient[pb.GetBlobResponse], error) {
102+ opens++
103+ return nil, connect.NewError(connect.CodeNotFound, assert.AnError)
104+ }).Maybe()
105+
106+ srv, err := New(Config{
107+ Repos: repos, Git: git,
108+ PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1",
109+ })
110+ require.NoError(t, err)
111+ req := httptest.NewRequest(http.MethodGet, tt.url, nil)
112+ if tt.ifNone != "" {
113+ req.Header.Set("If-None-Match", tt.ifNone)
114+ }
115+ rec := httptest.NewRecorder()
116+ srv.ServeHTTP(rec, req)
117+
118+ assert.Equal(t, tt.want, rec.Code)
119+ assert.Equal(t, tt.wantOpens, opens, "GetBlob calls")
120+ if tt.want == http.StatusNotModified {
121+ assert.Equal(t, tt.ifNone, rec.Header().Get("ETag"))
122+ assert.Equal(t, rawCSP, rec.Header().Get("Content-Security-Policy"))
123+ assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
124+ assert.Empty(t, rec.Body.Bytes())
125+ }
126+ })
127+ }
128+}

Keyboard shortcuts

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

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

Branches 4

Tags 45

↑↓ moveenter openesc close