detached f8b1d20

fix(mr): retry failed mergeability checks with backoff

commit
f8b1d20a21727275851151108ddd20cdf9b738d0
author
brice · · 2026-10-19 14:52 UTC
committer
brice · · 2026-10-19 18:30 UTC
parent
f8bb86d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +291 -30
M internal/usecase/mergeability.go +152 -30
@@ -8,6 +8,7 @@ import (
88 "slices"
99 "strings"
1010 "sync"
11+ "time"
1112
1213 "bnema.dev/ermite/internal/domain"
1314 "bnema.dev/ermite/internal/ports"
@@ -20,8 +21,40 @@ const (
2021 // race with a moving branch or a concurrent write; the check is then queued
2122 // again.
2223 mergeabilityAttempts = 3
24+ // Failed checks (Git or store errors) are retried with an exponential
25+ // backoff, up to mergeabilityRetries times, then dropped until the next
26+ // trigger.
27+ mergeabilityRetries = 5
28+ mergeabilityRetryInitial = time.Second
29+ mergeabilityRetryMax = time.Minute
2330 )
2431
32+// retryKind tells what a scheduled retry redoes.
33+type retryKind int
34+
35+const (
36+ retryCheck retryKind = iota // one merge request
37+ retryRescan // one repository
38+ retryRescanAll
39+)
40+
41+type retryKey struct {
42+ kind retryKind
43+ mk mergeKey
44+}
45+
46+// mergeBackoff is the delay before retry number attempt (1-based).
47+func mergeBackoff(attempt int) time.Duration {
48+ d := mergeabilityRetryInitial
49+ for i := 1; i < attempt; i++ {
50+ d *= 2
51+ if d >= mergeabilityRetryMax {
52+ return mergeabilityRetryMax
53+ }
54+ }
55+ return d
56+}
57+
2558 // MergeabilityConfig holds what the mergeability service needs. Workers and
2659 // Queue default to 2 and 1024.
2760 type MergeabilityConfig struct {
@@ -33,6 +66,9 @@ type MergeabilityConfig struct {
3366 // Workers is the number of checks that run at once, Queue the number of
3467 // checks that may wait; past it a repository is rescanned.
3568 Workers, Queue int
69+ // After returns a channel that fires after d; it defaults to time.After.
70+ // Tests inject it to drive the retry backoff.
71+ After func(d time.Duration) <-chan time.Time
3672 }
3773
3874 // Mergeability keeps up to date whether each open merge request merges with
@@ -45,6 +81,11 @@ type Mergeability struct {
4581 ctx context.Context
4682 cancel context.CancelFunc
4783 wg sync.WaitGroup
84+
85+ retryMu sync.Mutex
86+ retries map[retryKey]int // attempts so far of the scheduled or failing work
87+ retrying map[retryKey]bool
88+ closed bool
4889 }
4990
5091 // MergeabilityHook is what the merge request use cases need from the
@@ -67,8 +108,15 @@ func NewMergeability(ctx context.Context, cfg MergeabilityConfig) *Mergeability
67108 if cfg.Queue <= 0 {
68109 cfg.Queue = defaultMergeabilityQueue
69110 }
70- wctx, cancel := context.WithCancel(ctx)
71- return &Mergeability{cfg: cfg, q: newMergeQueue(cfg.Queue), ctx: wctx, cancel: cancel}
111+ if cfg.After == nil {
112+ cfg.After = time.After
113+ }
114+ // The workers outlive the cancellation of ctx: Close controls their life.
115+ wctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
116+ return &Mergeability{
117+ cfg: cfg, q: newMergeQueue(cfg.Queue), ctx: wctx, cancel: cancel,
118+ retries: map[retryKey]int{}, retrying: map[retryKey]bool{},
119+ }
72120 }
73121
74122 // Start rescans the open merge requests of every repository (the records may
@@ -83,16 +131,60 @@ func (s *Mergeability) Start() {
83131
84132 // Close stops the workers and waits for them.
85133 func (s *Mergeability) Close(_ context.Context) {
134+ s.retryMu.Lock()
135+ s.closed = true
136+ s.retryMu.Unlock()
86137 s.cancel()
87138 s.wg.Wait()
88139 }
89140
141+// retry schedules redo after a bounded exponential backoff. It is
142+// deduplicated per key, gives up after mergeabilityRetries attempts and stops
143+// on Close.
144+func (s *Mergeability) retry(key retryKey, redo func()) {
145+ s.retryMu.Lock()
146+ defer s.retryMu.Unlock()
147+ if s.closed || s.retrying[key] {
148+ return
149+ }
150+ s.retries[key]++
151+ n := s.retries[key]
152+ if n > mergeabilityRetries {
153+ delete(s.retries, key)
154+ return
155+ }
156+ s.retrying[key] = true
157+ after := s.cfg.After(mergeBackoff(n))
158+ s.wg.Add(1)
159+ go func() {
160+ defer s.wg.Done()
161+ select {
162+ case <-s.ctx.Done():
163+ return
164+ case <-after:
165+ }
166+ s.retryMu.Lock()
167+ delete(s.retrying, key)
168+ s.retryMu.Unlock()
169+ redo()
170+ }()
171+}
172+
173+// retryDone forgets the failures of key after a success.
174+func (s *Mergeability) retryDone(key retryKey) {
175+ s.retryMu.Lock()
176+ delete(s.retries, key)
177+ s.retryMu.Unlock()
178+}
179+
90180 func (s *Mergeability) rescanAll() {
91181 repos, err := s.cfg.Meta.List(s.ctx)
92182 if err != nil {
93183 ctxLog(s.ctx).Error().Err(err).Msg("mergeability: list the repositories to rescan")
184+ s.retry(retryKey{kind: retryRescanAll}, s.rescanAll)
94185 return
95186 }
187+ s.retryDone(retryKey{kind: retryRescanAll})
96188 for _, r := range repos {
97189 n, err := s.cfg.Store.CountOpen(s.ctx, r.Name)
98190 if err != nil {
@@ -118,6 +210,7 @@ func (s *Mergeability) OnClosed(ctx context.Context, mr domain.MergeRequest) {
118210 if err := s.cfg.Store.DeleteMergeability(context.WithoutCancel(ctx), mr.Repo, mr.Number); err != nil {
119211 ctxLog(ctx).Error().Err(err).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: delete the record of a closed merge request")
120212 }
213+ s.q.takeSeen(mergeKey{mr.Repo, mr.Number})
121214 }
122215
123216 // OnBranchesMoved implements ports.MergeabilityTrigger: the open merge
@@ -192,7 +285,12 @@ func (s *Mergeability) loop() {
192285 // meanwhile.
193286 func (s *Mergeability) runKey(k mergeKey) {
194287 for {
195- s.check(k)
288+ rk := retryKey{kind: retryCheck, mk: k}
289+ if s.check(k) {
290+ s.retryDone(rk)
291+ } else {
292+ s.retry(rk, func() { s.q.add(k) })
293+ }
196294 if !s.q.finish(k) || s.ctx.Err() != nil {
197295 return
198296 }
@@ -205,8 +303,10 @@ func (s *Mergeability) rescan(repo domain.RepoName) {
205303 mrs, err := s.cfg.Store.ListOpen(s.ctx, repo)
206304 if err != nil {
207305 ctxLog(s.ctx).Error().Err(err).Str("repo", repo.String()).Msg("mergeability: list the open merge requests to rescan")
306+ s.retry(retryKey{kind: retryRescan, mk: mergeKey{repo: repo}}, func() { s.q.markDirty(repo) })
208307 return
209308 }
309+ s.retryDone(retryKey{kind: retryRescan, mk: mergeKey{repo: repo}})
210310 for _, mr := range mrs {
211311 if s.ctx.Err() != nil {
212312 return
@@ -227,47 +327,58 @@ func (s *Mergeability) Check(repo domain.RepoName, number int64) {
227327 }
228328 }
229329
230-// check brings the record of k up to date.
231-func (s *Mergeability) check(k mergeKey) {
330+// check brings the record of k up to date. It reports false when a transient
331+// error (store, Git) dropped the work, which is then retried.
332+func (s *Mergeability) check(k mergeKey) (done bool) {
232333 ctx := s.ctx
233334 log := ctxLog(ctx).With().Str("repo", k.repo.String()).Int64("number", k.number).Logger()
234335 for range mergeabilityAttempts {
235336 mr, err := s.cfg.Store.Get(ctx, k.repo, k.number)
236337 if err != nil {
237- if !errors.Is(err, domain.ErrNotFound) && ctx.Err() == nil {
238- log.Error().Err(err).Msg("mergeability: read the merge request")
338+ if errors.Is(err, domain.ErrNotFound) {
339+ s.q.takeSeen(k)
340+ return true
239341 }
240- return
342+ if ctx.Err() != nil {
343+ return true
344+ }
345+ log.Error().Err(err).Msg("mergeability: read the merge request")
346+ return false
241347 }
242348 if !mr.IsOpen() {
243- return // the store dropped the record when it left the open state
349+ s.q.takeSeen(k)
350+ return true // the store dropped the record when it left the open state
244351 }
245- head, tip, ok := s.tips(ctx, mr)
352+ head, tip, ok, transient := s.tips(ctx, mr)
246353 if !ok {
247- return
354+ return !transient
248355 }
249356 prev, err := s.cfg.Store.GetMergeability(ctx, k.repo, k.number)
250357 if err != nil && !errors.Is(err, domain.ErrNotFound) {
358+ if ctx.Err() != nil {
359+ return true
360+ }
251361 log.Error().Err(err).Msg("mergeability: read the record")
252- return
362+ return false
253363 }
254364 if prev.IsFor(tip, head) {
255365 if s.q.takeSeen(k) {
256366 s.publish(ctx, prev) // a reader saw "checking" before this record
257367 }
258- return
368+ return true
259369 }
260370 res, err := s.compute(ctx, k, tip, head)
261371 if err != nil {
262- if ctx.Err() == nil {
263- log.Error().Err(err).Msg("mergeability: compute")
372+ if ctx.Err() != nil {
373+ return true
264374 }
265- return
375+ log.Error().Err(err).Msg("mergeability: compute")
376+ return false
266377 }
267378 // The branches may have moved during the computation: the result is
268379 // then for tips nobody asks about, and is dropped.
269- if h2, t2, ok := s.tips(ctx, mr); !ok {
270- return
380+ if h2, t2, ok, transient := s.tips(ctx, mr); !ok {
381+ return !transient
271382 } else if h2 != head || t2 != tip {
272383 continue
273384 }
@@ -276,38 +387,49 @@ func (s *Mergeability) check(k mergeKey) {
276387 case errors.Is(err, domain.ErrConflict):
277388 continue
278389 case errors.Is(err, domain.ErrNotFound):
279- return // closed meanwhile
390+ s.q.takeSeen(k)
391+ return true // closed meanwhile
280392 case err != nil:
393+ if ctx.Err() != nil {
394+ return true
395+ }
281396 log.Error().Err(err).Msg("mergeability: store the record")
282- return
397+ return false
283398 }
284399 seen := s.q.takeSeen(k)
285400 if seen || prev.State == "" || changedMergeability(prev, res) {
286401 s.publish(ctx, res)
287402 }
288- return
403+ return true
289404 }
290405 s.q.add(k) // kept losing races: try again later
406+ return true
291407 }
292408
293409 // tips reads the heads of the source and the target of mr. ok is false when
294-// the check is over: a branch is gone (the record is deleted) or Git failed.
295-func (s *Mergeability) tips(ctx context.Context, mr domain.MergeRequest) (head, tip domain.ObjectID, ok bool) {
410+// the check is over: a branch is gone (the record is deleted) or Git failed,
411+// which is transient.
412+func (s *Mergeability) tips(ctx context.Context, mr domain.MergeRequest) (head, tip domain.ObjectID, ok, transient bool) {
296413 head, herr := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Source)
297414 tip, terr := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Target)
298415 switch {
299416 case errors.Is(herr, domain.ErrNotFound) || errors.Is(terr, domain.ErrNotFound):
300- if err := s.cfg.Store.DeleteMergeability(ctx, mr.Repo, mr.Number); err != nil && ctx.Err() == nil {
301- ctxLog(ctx).Error().Err(err).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: delete the record of a merge request without a branch")
417+ if err := s.cfg.Store.DeleteMergeability(ctx, mr.Repo, mr.Number); err != nil {
418+ if ctx.Err() == nil {
419+ ctxLog(ctx).Error().Err(err).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: delete the record of a merge request without a branch")
420+ }
421+ transient = ctx.Err() == nil
302422 }
303- return domain.ObjectID{}, domain.ObjectID{}, false
423+ s.q.takeSeen(mergeKey{mr.Repo, mr.Number})
424+ return domain.ObjectID{}, domain.ObjectID{}, false, transient
304425 case herr != nil || terr != nil:
305- if ctx.Err() == nil {
306- ctxLog(ctx).Error().Err(errors.Join(herr, terr)).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: read the branches")
426+ if ctx.Err() != nil {
427+ return domain.ObjectID{}, domain.ObjectID{}, false, false
307428 }
308- return domain.ObjectID{}, domain.ObjectID{}, false
429+ ctxLog(ctx).Error().Err(errors.Join(herr, terr)).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: read the branches")
430+ return domain.ObjectID{}, domain.ObjectID{}, false, true
309431 }
310- return head, tip, true
432+ return head, tip, true, false
311433 }
312434
313435 // compute runs the Git reads that decide the state of head against tip.
@@ -8,6 +8,7 @@ import (
8 "slices"8 "slices"
9 "strings"9 "strings"
10 "sync"10 "sync"
11+ "time"
11 12
12 "bnema.dev/ermite/internal/domain"13 "bnema.dev/ermite/internal/domain"
13 "bnema.dev/ermite/internal/ports"14 "bnema.dev/ermite/internal/ports"
@@ -20,8 +21,40 @@ const (
20 // race with a moving branch or a concurrent write; the check is then queued21 // race with a moving branch or a concurrent write; the check is then queued
21 // again.22 // again.
22 mergeabilityAttempts = 323 mergeabilityAttempts = 3
24+ // Failed checks (Git or store errors) are retried with an exponential
25+ // backoff, up to mergeabilityRetries times, then dropped until the next
26+ // trigger.
27+ mergeabilityRetries = 5
28+ mergeabilityRetryInitial = time.Second
29+ mergeabilityRetryMax = time.Minute
23 )30 )
24 31
32+// retryKind tells what a scheduled retry redoes.
33+type retryKind int
34+
35+const (
36+ retryCheck retryKind = iota // one merge request
37+ retryRescan // one repository
38+ retryRescanAll
39+)
40+
41+type retryKey struct {
42+ kind retryKind
43+ mk mergeKey
44+}
45+
46+// mergeBackoff is the delay before retry number attempt (1-based).
47+func mergeBackoff(attempt int) time.Duration {
48+ d := mergeabilityRetryInitial
49+ for i := 1; i < attempt; i++ {
50+ d *= 2
51+ if d >= mergeabilityRetryMax {
52+ return mergeabilityRetryMax
53+ }
54+ }
55+ return d
56+}
57+
25 // MergeabilityConfig holds what the mergeability service needs. Workers and58 // MergeabilityConfig holds what the mergeability service needs. Workers and
26 // Queue default to 2 and 1024.59 // Queue default to 2 and 1024.
27 type MergeabilityConfig struct {60 type MergeabilityConfig struct {
@@ -33,6 +66,9 @@ type MergeabilityConfig struct {
33 // Workers is the number of checks that run at once, Queue the number of66 // Workers is the number of checks that run at once, Queue the number of
34 // checks that may wait; past it a repository is rescanned.67 // checks that may wait; past it a repository is rescanned.
35 Workers, Queue int68 Workers, Queue int
69+ // After returns a channel that fires after d; it defaults to time.After.
70+ // Tests inject it to drive the retry backoff.
71+ After func(d time.Duration) <-chan time.Time
36 }72 }
37 73
38 // Mergeability keeps up to date whether each open merge request merges with74 // Mergeability keeps up to date whether each open merge request merges with
@@ -45,6 +81,11 @@ type Mergeability struct {
45 ctx context.Context81 ctx context.Context
46 cancel context.CancelFunc82 cancel context.CancelFunc
47 wg sync.WaitGroup83 wg sync.WaitGroup
84+
85+ retryMu sync.Mutex
86+ retries map[retryKey]int // attempts so far of the scheduled or failing work
87+ retrying map[retryKey]bool
88+ closed bool
48 }89 }
49 90
50 // MergeabilityHook is what the merge request use cases need from the91 // MergeabilityHook is what the merge request use cases need from the
@@ -67,8 +108,15 @@ func NewMergeability(ctx context.Context, cfg MergeabilityConfig) *Mergeability
67 if cfg.Queue <= 0 {108 if cfg.Queue <= 0 {
68 cfg.Queue = defaultMergeabilityQueue109 cfg.Queue = defaultMergeabilityQueue
69 }110 }
70- wctx, cancel := context.WithCancel(ctx)111+ if cfg.After == nil {
71- return &Mergeability{cfg: cfg, q: newMergeQueue(cfg.Queue), ctx: wctx, cancel: cancel}112+ cfg.After = time.After
113+ }
114+ // The workers outlive the cancellation of ctx: Close controls their life.
115+ wctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
116+ return &Mergeability{
117+ cfg: cfg, q: newMergeQueue(cfg.Queue), ctx: wctx, cancel: cancel,
118+ retries: map[retryKey]int{}, retrying: map[retryKey]bool{},
119+ }
72 }120 }
73 121
74 // Start rescans the open merge requests of every repository (the records may122 // Start rescans the open merge requests of every repository (the records may
@@ -83,16 +131,60 @@ func (s *Mergeability) Start() {
83 131
84 // Close stops the workers and waits for them.132 // Close stops the workers and waits for them.
85 func (s *Mergeability) Close(_ context.Context) {133 func (s *Mergeability) Close(_ context.Context) {
134+ s.retryMu.Lock()
135+ s.closed = true
136+ s.retryMu.Unlock()
86 s.cancel()137 s.cancel()
87 s.wg.Wait()138 s.wg.Wait()
88 }139 }
89 140
141+// retry schedules redo after a bounded exponential backoff. It is
142+// deduplicated per key, gives up after mergeabilityRetries attempts and stops
143+// on Close.
144+func (s *Mergeability) retry(key retryKey, redo func()) {
145+ s.retryMu.Lock()
146+ defer s.retryMu.Unlock()
147+ if s.closed || s.retrying[key] {
148+ return
149+ }
150+ s.retries[key]++
151+ n := s.retries[key]
152+ if n > mergeabilityRetries {
153+ delete(s.retries, key)
154+ return
155+ }
156+ s.retrying[key] = true
157+ after := s.cfg.After(mergeBackoff(n))
158+ s.wg.Add(1)
159+ go func() {
160+ defer s.wg.Done()
161+ select {
162+ case <-s.ctx.Done():
163+ return
164+ case <-after:
165+ }
166+ s.retryMu.Lock()
167+ delete(s.retrying, key)
168+ s.retryMu.Unlock()
169+ redo()
170+ }()
171+}
172+
173+// retryDone forgets the failures of key after a success.
174+func (s *Mergeability) retryDone(key retryKey) {
175+ s.retryMu.Lock()
176+ delete(s.retries, key)
177+ s.retryMu.Unlock()
178+}
179+
90 func (s *Mergeability) rescanAll() {180 func (s *Mergeability) rescanAll() {
91 repos, err := s.cfg.Meta.List(s.ctx)181 repos, err := s.cfg.Meta.List(s.ctx)
92 if err != nil {182 if err != nil {
93 ctxLog(s.ctx).Error().Err(err).Msg("mergeability: list the repositories to rescan")183 ctxLog(s.ctx).Error().Err(err).Msg("mergeability: list the repositories to rescan")
184+ s.retry(retryKey{kind: retryRescanAll}, s.rescanAll)
94 return185 return
95 }186 }
187+ s.retryDone(retryKey{kind: retryRescanAll})
96 for _, r := range repos {188 for _, r := range repos {
97 n, err := s.cfg.Store.CountOpen(s.ctx, r.Name)189 n, err := s.cfg.Store.CountOpen(s.ctx, r.Name)
98 if err != nil {190 if err != nil {
@@ -118,6 +210,7 @@ func (s *Mergeability) OnClosed(ctx context.Context, mr domain.MergeRequest) {
118 if err := s.cfg.Store.DeleteMergeability(context.WithoutCancel(ctx), mr.Repo, mr.Number); err != nil {210 if err := s.cfg.Store.DeleteMergeability(context.WithoutCancel(ctx), mr.Repo, mr.Number); err != nil {
119 ctxLog(ctx).Error().Err(err).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: delete the record of a closed merge request")211 ctxLog(ctx).Error().Err(err).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: delete the record of a closed merge request")
120 }212 }
213+ s.q.takeSeen(mergeKey{mr.Repo, mr.Number})
121 }214 }
122 215
123 // OnBranchesMoved implements ports.MergeabilityTrigger: the open merge216 // OnBranchesMoved implements ports.MergeabilityTrigger: the open merge
@@ -192,7 +285,12 @@ func (s *Mergeability) loop() {
192 // meanwhile.285 // meanwhile.
193 func (s *Mergeability) runKey(k mergeKey) {286 func (s *Mergeability) runKey(k mergeKey) {
194 for {287 for {
195- s.check(k)288+ rk := retryKey{kind: retryCheck, mk: k}
289+ if s.check(k) {
290+ s.retryDone(rk)
291+ } else {
292+ s.retry(rk, func() { s.q.add(k) })
293+ }
196 if !s.q.finish(k) || s.ctx.Err() != nil {294 if !s.q.finish(k) || s.ctx.Err() != nil {
197 return295 return
198 }296 }
@@ -205,8 +303,10 @@ func (s *Mergeability) rescan(repo domain.RepoName) {
205 mrs, err := s.cfg.Store.ListOpen(s.ctx, repo)303 mrs, err := s.cfg.Store.ListOpen(s.ctx, repo)
206 if err != nil {304 if err != nil {
207 ctxLog(s.ctx).Error().Err(err).Str("repo", repo.String()).Msg("mergeability: list the open merge requests to rescan")305 ctxLog(s.ctx).Error().Err(err).Str("repo", repo.String()).Msg("mergeability: list the open merge requests to rescan")
306+ s.retry(retryKey{kind: retryRescan, mk: mergeKey{repo: repo}}, func() { s.q.markDirty(repo) })
208 return307 return
209 }308 }
309+ s.retryDone(retryKey{kind: retryRescan, mk: mergeKey{repo: repo}})
210 for _, mr := range mrs {310 for _, mr := range mrs {
211 if s.ctx.Err() != nil {311 if s.ctx.Err() != nil {
212 return312 return
@@ -227,47 +327,58 @@ func (s *Mergeability) Check(repo domain.RepoName, number int64) {
227 }327 }
228 }328 }
229 329
230-// check brings the record of k up to date.330+// check brings the record of k up to date. It reports false when a transient
231-func (s *Mergeability) check(k mergeKey) {331+// error (store, Git) dropped the work, which is then retried.
332+func (s *Mergeability) check(k mergeKey) (done bool) {
232 ctx := s.ctx333 ctx := s.ctx
233 log := ctxLog(ctx).With().Str("repo", k.repo.String()).Int64("number", k.number).Logger()334 log := ctxLog(ctx).With().Str("repo", k.repo.String()).Int64("number", k.number).Logger()
234 for range mergeabilityAttempts {335 for range mergeabilityAttempts {
235 mr, err := s.cfg.Store.Get(ctx, k.repo, k.number)336 mr, err := s.cfg.Store.Get(ctx, k.repo, k.number)
236 if err != nil {337 if err != nil {
237- if !errors.Is(err, domain.ErrNotFound) && ctx.Err() == nil {338+ if errors.Is(err, domain.ErrNotFound) {
238- log.Error().Err(err).Msg("mergeability: read the merge request")339+ s.q.takeSeen(k)
340+ return true
239 }341 }
240- return342+ if ctx.Err() != nil {
343+ return true
344+ }
345+ log.Error().Err(err).Msg("mergeability: read the merge request")
346+ return false
241 }347 }
242 if !mr.IsOpen() {348 if !mr.IsOpen() {
243- return // the store dropped the record when it left the open state349+ s.q.takeSeen(k)
350+ return true // the store dropped the record when it left the open state
244 }351 }
245- head, tip, ok := s.tips(ctx, mr)352+ head, tip, ok, transient := s.tips(ctx, mr)
246 if !ok {353 if !ok {
247- return354+ return !transient
248 }355 }
249 prev, err := s.cfg.Store.GetMergeability(ctx, k.repo, k.number)356 prev, err := s.cfg.Store.GetMergeability(ctx, k.repo, k.number)
250 if err != nil && !errors.Is(err, domain.ErrNotFound) {357 if err != nil && !errors.Is(err, domain.ErrNotFound) {
358+ if ctx.Err() != nil {
359+ return true
360+ }
251 log.Error().Err(err).Msg("mergeability: read the record")361 log.Error().Err(err).Msg("mergeability: read the record")
252- return362+ return false
253 }363 }
254 if prev.IsFor(tip, head) {364 if prev.IsFor(tip, head) {
255 if s.q.takeSeen(k) {365 if s.q.takeSeen(k) {
256 s.publish(ctx, prev) // a reader saw "checking" before this record366 s.publish(ctx, prev) // a reader saw "checking" before this record
257 }367 }
258- return368+ return true
259 }369 }
260 res, err := s.compute(ctx, k, tip, head)370 res, err := s.compute(ctx, k, tip, head)
261 if err != nil {371 if err != nil {
262- if ctx.Err() == nil {372+ if ctx.Err() != nil {
263- log.Error().Err(err).Msg("mergeability: compute")373+ return true
264 }374 }
265- return375+ log.Error().Err(err).Msg("mergeability: compute")
376+ return false
266 }377 }
267 // The branches may have moved during the computation: the result is378 // The branches may have moved during the computation: the result is
268 // then for tips nobody asks about, and is dropped.379 // then for tips nobody asks about, and is dropped.
269- if h2, t2, ok := s.tips(ctx, mr); !ok {380+ if h2, t2, ok, transient := s.tips(ctx, mr); !ok {
270- return381+ return !transient
271 } else if h2 != head || t2 != tip {382 } else if h2 != head || t2 != tip {
272 continue383 continue
273 }384 }
@@ -276,38 +387,49 @@ func (s *Mergeability) check(k mergeKey) {
276 case errors.Is(err, domain.ErrConflict):387 case errors.Is(err, domain.ErrConflict):
277 continue388 continue
278 case errors.Is(err, domain.ErrNotFound):389 case errors.Is(err, domain.ErrNotFound):
279- return // closed meanwhile390+ s.q.takeSeen(k)
391+ return true // closed meanwhile
280 case err != nil:392 case err != nil:
393+ if ctx.Err() != nil {
394+ return true
395+ }
281 log.Error().Err(err).Msg("mergeability: store the record")396 log.Error().Err(err).Msg("mergeability: store the record")
282- return397+ return false
283 }398 }
284 seen := s.q.takeSeen(k)399 seen := s.q.takeSeen(k)
285 if seen || prev.State == "" || changedMergeability(prev, res) {400 if seen || prev.State == "" || changedMergeability(prev, res) {
286 s.publish(ctx, res)401 s.publish(ctx, res)
287 }402 }
288- return403+ return true
289 }404 }
290 s.q.add(k) // kept losing races: try again later405 s.q.add(k) // kept losing races: try again later
406+ return true
291 }407 }
292 408
293 // tips reads the heads of the source and the target of mr. ok is false when409 // tips reads the heads of the source and the target of mr. ok is false when
294-// the check is over: a branch is gone (the record is deleted) or Git failed.410+// the check is over: a branch is gone (the record is deleted) or Git failed,
295-func (s *Mergeability) tips(ctx context.Context, mr domain.MergeRequest) (head, tip domain.ObjectID, ok bool) {411+// which is transient.
412+func (s *Mergeability) tips(ctx context.Context, mr domain.MergeRequest) (head, tip domain.ObjectID, ok, transient bool) {
296 head, herr := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Source)413 head, herr := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Source)
297 tip, terr := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Target)414 tip, terr := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Target)
298 switch {415 switch {
299 case errors.Is(herr, domain.ErrNotFound) || errors.Is(terr, domain.ErrNotFound):416 case errors.Is(herr, domain.ErrNotFound) || errors.Is(terr, domain.ErrNotFound):
300- if err := s.cfg.Store.DeleteMergeability(ctx, mr.Repo, mr.Number); err != nil && ctx.Err() == nil {417+ if err := s.cfg.Store.DeleteMergeability(ctx, mr.Repo, mr.Number); err != nil {
301- ctxLog(ctx).Error().Err(err).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: delete the record of a merge request without a branch")418+ if ctx.Err() == nil {
419+ ctxLog(ctx).Error().Err(err).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: delete the record of a merge request without a branch")
420+ }
421+ transient = ctx.Err() == nil
302 }422 }
303- return domain.ObjectID{}, domain.ObjectID{}, false423+ s.q.takeSeen(mergeKey{mr.Repo, mr.Number})
424+ return domain.ObjectID{}, domain.ObjectID{}, false, transient
304 case herr != nil || terr != nil:425 case herr != nil || terr != nil:
305- if ctx.Err() == nil {426+ if ctx.Err() != nil {
306- ctxLog(ctx).Error().Err(errors.Join(herr, terr)).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: read the branches")427+ return domain.ObjectID{}, domain.ObjectID{}, false, false
307 }428 }
308- return domain.ObjectID{}, domain.ObjectID{}, false429+ ctxLog(ctx).Error().Err(errors.Join(herr, terr)).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: read the branches")
430+ return domain.ObjectID{}, domain.ObjectID{}, false, true
309 }431 }
310- return head, tip, true432+ return head, tip, true, false
311 }433 }
312 434
313 // compute runs the Git reads that decide the state of head against tip.435 // compute runs the Git reads that decide the state of head against tip.
A internal/usecase/mergeability_seen_test.go +47 -0
@@ -0,0 +1,47 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+)
17+
18+type zeroClock struct{}
19+
20+func (zeroClock) Now() time.Time { return time.Time{} }
21+
22+func TestMergeabilityBackoffIsBounded(t *testing.T) {
23+ assert.Equal(t, time.Second, mergeBackoff(1))
24+ assert.Equal(t, 2*time.Second, mergeBackoff(2))
25+ assert.Equal(t, 4*time.Second, mergeBackoff(3))
26+ assert.Equal(t, time.Minute, mergeBackoff(20))
27+}
28+
29+func TestMergeabilityClosingForgetsTheSeenKey(t *testing.T) {
30+ repo, err := domain.ParseRepoName("proj")
31+ require.NoError(t, err)
32+ store := mocks.NewMergeRequestStore(t)
33+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
34+ store.EXPECT().DeleteMergeability(mock.Anything, repo, int64(7)).Return(nil)
35+ svc := NewMergeability(context.Background(), MergeabilityConfig{Store: store, Clock: zeroClock{}})
36+ t.Cleanup(func() { svc.Close(context.Background()) })
37+
38+ mr := domain.MergeRequest{Repo: repo, Number: 7, State: domain.MRStateOpen}
39+ oid, err := domain.ParseObjectID("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
40+ require.NoError(t, err)
41+ mr.HeadID = oid
42+ svc.Read(context.Background(), mr, oid)
43+ require.Len(t, svc.q.seen, 1)
44+
45+ svc.OnClosed(context.Background(), mr)
46+ assert.Empty(t, svc.q.seen)
47+}
@@ -0,0 +1,47 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+)
17+
18+type zeroClock struct{}
19+
20+func (zeroClock) Now() time.Time { return time.Time{} }
21+
22+func TestMergeabilityBackoffIsBounded(t *testing.T) {
23+ assert.Equal(t, time.Second, mergeBackoff(1))
24+ assert.Equal(t, 2*time.Second, mergeBackoff(2))
25+ assert.Equal(t, 4*time.Second, mergeBackoff(3))
26+ assert.Equal(t, time.Minute, mergeBackoff(20))
27+}
28+
29+func TestMergeabilityClosingForgetsTheSeenKey(t *testing.T) {
30+ repo, err := domain.ParseRepoName("proj")
31+ require.NoError(t, err)
32+ store := mocks.NewMergeRequestStore(t)
33+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
34+ store.EXPECT().DeleteMergeability(mock.Anything, repo, int64(7)).Return(nil)
35+ svc := NewMergeability(context.Background(), MergeabilityConfig{Store: store, Clock: zeroClock{}})
36+ t.Cleanup(func() { svc.Close(context.Background()) })
37+
38+ mr := domain.MergeRequest{Repo: repo, Number: 7, State: domain.MRStateOpen}
39+ oid, err := domain.ParseObjectID("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
40+ require.NoError(t, err)
41+ mr.HeadID = oid
42+ svc.Read(context.Background(), mr, oid)
43+ require.Len(t, svc.q.seen, 1)
44+
45+ svc.OnClosed(context.Background(), mr)
46+ assert.Empty(t, svc.q.seen)
47+}
M internal/usecase/mergeability_test.go +92 -0
@@ -4,6 +4,7 @@ package usecase_test
44
55 import (
66 "context"
7+ "errors"
78 "strings"
89 "sync/atomic"
910 "testing"
@@ -460,3 +461,94 @@ func TestMergeabilityBoundsConflictPathsAndBehind(t *testing.T) {
460461 assert.True(t, put.BehindCapped)
461462 })
462463 }
464+
465+// A transient Git failure is retried once after the backoff.
466+func TestMergeabilityRetriesATransientFailureWithBackoff(t *testing.T) {
467+ repo := name(t, "proj")
468+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
469+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
470+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
471+ store.EXPECT().Get(mock.Anything, repo, int64(7)).Return(mr, nil)
472+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
473+ store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil)
474+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
475+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil)
476+ var fails atomic.Int32
477+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").RunAndReturn(
478+ func(context.Context, domain.RepoName, string) (domain.ObjectID, error) {
479+ if fails.Add(1) == 1 {
480+ return domain.ObjectID{}, errors.New("transient")
481+ }
482+ return head, nil
483+ })
484+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
485+ git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
486+ published := make(chan struct{})
487+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(context.Context, domain.Event) { close(published) }).Return().Once()
488+
489+ asked, fire := make(chan time.Duration, 4), make(chan time.Time)
490+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
491+ Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{},
492+ After: func(d time.Duration) <-chan time.Time { asked <- d; return fire },
493+ })
494+ t.Cleanup(func() { svc.Close(context.Background()) })
495+ svc.Start()
496+ svc.OnOpened(context.Background(), mr)
497+
498+ assert.Equal(t, time.Second, <-asked)
499+ select {
500+ case <-published:
501+ t.Fatal("published before the backoff elapsed")
502+ default:
503+ }
504+ fire <- time.Time{}
505+ <-published
506+ assert.Empty(t, asked, "one retry was enough and only one was scheduled")
507+}
508+
509+// Close stops a pending retry timer's goroutine.
510+func TestMergeabilityCloseStopsPendingRetries(t *testing.T) {
511+ repo := name(t, "proj")
512+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
513+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
514+ store.EXPECT().Get(mock.Anything, repo, int64(7)).Return(domain.MergeRequest{}, errors.New("db down"))
515+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
516+ asked := make(chan time.Duration, 4)
517+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
518+ Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{},
519+ After: func(d time.Duration) <-chan time.Time { asked <- d; return make(chan time.Time) },
520+ })
521+ svc.Start()
522+ svc.OnOpened(context.Background(), mr)
523+ <-asked
524+ done := make(chan struct{})
525+ go func() { svc.Close(context.Background()); close(done) }()
526+ <-done // Close waits for the timer goroutine: it returns only if it stopped
527+}
528+
529+// Workers are not stopped by the cancellation of the context given to the
530+// constructor; Close stops them.
531+func TestMergeabilityWorkersOutliveTheParentContext(t *testing.T) {
532+ repo := name(t, "proj")
533+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
534+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
535+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
536+ store.EXPECT().Get(mock.Anything, repo, int64(7)).Return(mr, nil)
537+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
538+ store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil)
539+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
540+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil)
541+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
542+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
543+ git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
544+ published := make(chan struct{})
545+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(context.Context, domain.Event) { close(published) }).Return().Once()
546+
547+ parent, cancel := context.WithCancel(context.Background())
548+ svc := usecase.NewMergeability(parent, usecase.MergeabilityConfig{Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{}})
549+ svc.Start()
550+ cancel()
551+ svc.OnOpened(context.Background(), mr)
552+ <-published // computed although the parent is cancelled
553+ svc.Close(context.Background())
554+}
@@ -4,6 +4,7 @@ package usecase_test
4 4
5 import (5 import (
6 "context"6 "context"
7+ "errors"
7 "strings"8 "strings"
8 "sync/atomic"9 "sync/atomic"
9 "testing"10 "testing"
@@ -460,3 +461,94 @@ func TestMergeabilityBoundsConflictPathsAndBehind(t *testing.T) {
460 assert.True(t, put.BehindCapped)461 assert.True(t, put.BehindCapped)
461 })462 })
462 }463 }
464+
465+// A transient Git failure is retried once after the backoff.
466+func TestMergeabilityRetriesATransientFailureWithBackoff(t *testing.T) {
467+ repo := name(t, "proj")
468+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
469+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
470+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
471+ store.EXPECT().Get(mock.Anything, repo, int64(7)).Return(mr, nil)
472+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
473+ store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil)
474+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
475+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil)
476+ var fails atomic.Int32
477+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").RunAndReturn(
478+ func(context.Context, domain.RepoName, string) (domain.ObjectID, error) {
479+ if fails.Add(1) == 1 {
480+ return domain.ObjectID{}, errors.New("transient")
481+ }
482+ return head, nil
483+ })
484+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
485+ git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
486+ published := make(chan struct{})
487+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(context.Context, domain.Event) { close(published) }).Return().Once()
488+
489+ asked, fire := make(chan time.Duration, 4), make(chan time.Time)
490+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
491+ Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{},
492+ After: func(d time.Duration) <-chan time.Time { asked <- d; return fire },
493+ })
494+ t.Cleanup(func() { svc.Close(context.Background()) })
495+ svc.Start()
496+ svc.OnOpened(context.Background(), mr)
497+
498+ assert.Equal(t, time.Second, <-asked)
499+ select {
500+ case <-published:
501+ t.Fatal("published before the backoff elapsed")
502+ default:
503+ }
504+ fire <- time.Time{}
505+ <-published
506+ assert.Empty(t, asked, "one retry was enough and only one was scheduled")
507+}
508+
509+// Close stops a pending retry timer's goroutine.
510+func TestMergeabilityCloseStopsPendingRetries(t *testing.T) {
511+ repo := name(t, "proj")
512+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
513+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
514+ store.EXPECT().Get(mock.Anything, repo, int64(7)).Return(domain.MergeRequest{}, errors.New("db down"))
515+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
516+ asked := make(chan time.Duration, 4)
517+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
518+ Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{},
519+ After: func(d time.Duration) <-chan time.Time { asked <- d; return make(chan time.Time) },
520+ })
521+ svc.Start()
522+ svc.OnOpened(context.Background(), mr)
523+ <-asked
524+ done := make(chan struct{})
525+ go func() { svc.Close(context.Background()); close(done) }()
526+ <-done // Close waits for the timer goroutine: it returns only if it stopped
527+}
528+
529+// Workers are not stopped by the cancellation of the context given to the
530+// constructor; Close stops them.
531+func TestMergeabilityWorkersOutliveTheParentContext(t *testing.T) {
532+ repo := name(t, "proj")
533+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
534+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
535+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
536+ store.EXPECT().Get(mock.Anything, repo, int64(7)).Return(mr, nil)
537+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
538+ store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil)
539+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
540+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil)
541+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
542+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
543+ git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
544+ published := make(chan struct{})
545+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(context.Context, domain.Event) { close(published) }).Return().Once()
546+
547+ parent, cancel := context.WithCancel(context.Background())
548+ svc := usecase.NewMergeability(parent, usecase.MergeabilityConfig{Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{}})
549+ svc.Start()
550+ cancel()
551+ svc.OnOpened(context.Background(), mr)
552+ <-published // computed although the parent is cancelled
553+ svc.Close(context.Background())
554+}

Keyboard shortcuts

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

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

Branches 2

Tags 41

↑↓ moveenter openesc close