detached 5925475

feat(app): tell the reader when a push or the maintenance changed a repository

commit
5925475298b435d8c1382e5afaff6fbff09677cf
author
brice · · 2026-10-11 00:46 UTC
parent
dedf508
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +176 -2
M .mockery.yml +1 -0
@@ -19,3 +19,4 @@ packages:
1919 MergeRequestStore:
2020 MergeGit:
2121 CommitSigner:
22+ RepoFreshener:
@@ -19,3 +19,4 @@ packages:
19 MergeRequestStore:19 MergeRequestStore:
20 MergeGit:20 MergeGit:
21 CommitSigner:21 CommitSigner:
22+ RepoFreshener:
M internal/app/maintenance.go +12 -0
@@ -27,6 +27,7 @@ const MaintenanceDelay = 5 * time.Second
2727 // (gitcli applies its command timeout to a context without deadline).
2828 type Maintainer struct {
2929 git ports.GitRepos
30+ fresh ports.RepoFreshener // nil: the reader needs no telling
3031 delay time.Duration
3132 ctx context.Context
3233 stop context.CancelFunc
@@ -53,6 +54,14 @@ func NewMaintainer(ctx context.Context, git ports.GitRepos, delay time.Duration)
5354 return &Maintainer{git: git, delay: delay, ctx: run, stop: stop, entries: make(map[domain.RepoName]*maintEntry)}
5455 }
5556
57+// WithFreshener makes every run tell the reader, when it ends, that the
58+// repository changed: the commit-graph it wrote is for the reader to pick up.
59+// Call it before the first Schedule; it returns m.
60+func (m *Maintainer) WithFreshener(f ports.RepoFreshener) *Maintainer {
61+ m.fresh = f
62+ return m
63+}
64+
5665 // Schedule queues maintenance of the repository.
5766 func (m *Maintainer) Schedule(ctx context.Context, name domain.RepoName) {
5867 m.mu.Lock()
@@ -98,6 +107,9 @@ func (m *Maintainer) run(name domain.RepoName, e *maintEntry) {
98107 } else {
99108 log.Debug().Str("repo", name.String()).Dur("duration", time.Since(start)).Msg("repository maintained")
100109 }
110+ if m.fresh != nil {
111+ m.fresh.Refresh(m.ctx, name) // whatever the outcome: git may have written something
112+ }
101113
102114 m.mu.Lock()
103115 defer m.mu.Unlock()
@@ -27,6 +27,7 @@ const MaintenanceDelay = 5 * time.Second
27 // (gitcli applies its command timeout to a context without deadline).27 // (gitcli applies its command timeout to a context without deadline).
28 type Maintainer struct {28 type Maintainer struct {
29 git ports.GitRepos29 git ports.GitRepos
30+ fresh ports.RepoFreshener // nil: the reader needs no telling
30 delay time.Duration31 delay time.Duration
31 ctx context.Context32 ctx context.Context
32 stop context.CancelFunc33 stop context.CancelFunc
@@ -53,6 +54,14 @@ func NewMaintainer(ctx context.Context, git ports.GitRepos, delay time.Duration)
53 return &Maintainer{git: git, delay: delay, ctx: run, stop: stop, entries: make(map[domain.RepoName]*maintEntry)}54 return &Maintainer{git: git, delay: delay, ctx: run, stop: stop, entries: make(map[domain.RepoName]*maintEntry)}
54 }55 }
55 56
57+// WithFreshener makes every run tell the reader, when it ends, that the
58+// repository changed: the commit-graph it wrote is for the reader to pick up.
59+// Call it before the first Schedule; it returns m.
60+func (m *Maintainer) WithFreshener(f ports.RepoFreshener) *Maintainer {
61+ m.fresh = f
62+ return m
63+}
64+
56 // Schedule queues maintenance of the repository.65 // Schedule queues maintenance of the repository.
57 func (m *Maintainer) Schedule(ctx context.Context, name domain.RepoName) {66 func (m *Maintainer) Schedule(ctx context.Context, name domain.RepoName) {
58 m.mu.Lock()67 m.mu.Lock()
@@ -98,6 +107,9 @@ func (m *Maintainer) run(name domain.RepoName, e *maintEntry) {
98 } else {107 } else {
99 log.Debug().Str("repo", name.String()).Dur("duration", time.Since(start)).Msg("repository maintained")108 log.Debug().Str("repo", name.String()).Dur("duration", time.Since(start)).Msg("repository maintained")
100 }109 }
110+ if m.fresh != nil {
111+ m.fresh.Refresh(m.ctx, name) // whatever the outcome: git may have written something
112+ }
101 113
102 m.mu.Lock()114 m.mu.Lock()
103 defer m.mu.Unlock()115 defer m.mu.Unlock()
M internal/app/maintenance_test.go +15 -0
@@ -97,6 +97,21 @@ func TestMaintainerOneRunAtATimePerRepo(t *testing.T) {
9797 assert.Equal(t, int32(1), maxConcurrent.Load())
9898 }
9999
100+func TestMaintainerTellsTheReaderAfterARun(t *testing.T) {
101+ ctx := context.Background()
102+ n := name(t, "tools")
103+ for _, failure := range []error{nil, errors.New("boom")} {
104+ git := mocks.NewGitRepos(t)
105+ git.EXPECT().Maintain(mockAny, n).Return(failure)
106+ fresh := &freshener{}
107+ m := app.NewMaintainer(ctx, git, time.Millisecond).WithFreshener(fresh)
108+ m.Schedule(ctx, n)
109+ waitFor(t, func() bool { return len(fresh.seen()) == 1 }, "the reader to be told")
110+ m.Close(ctx)
111+ assert.Equal(t, []domain.RepoName{n}, fresh.seen())
112+ }
113+}
114+
100115 func TestMaintainerFailureIsLoggedNotFatal(t *testing.T) {
101116 ctx := context.Background()
102117 n := name(t, "tools")
@@ -97,6 +97,21 @@ func TestMaintainerOneRunAtATimePerRepo(t *testing.T) {
97 assert.Equal(t, int32(1), maxConcurrent.Load())97 assert.Equal(t, int32(1), maxConcurrent.Load())
98 }98 }
99 99
100+func TestMaintainerTellsTheReaderAfterARun(t *testing.T) {
101+ ctx := context.Background()
102+ n := name(t, "tools")
103+ for _, failure := range []error{nil, errors.New("boom")} {
104+ git := mocks.NewGitRepos(t)
105+ git.EXPECT().Maintain(mockAny, n).Return(failure)
106+ fresh := &freshener{}
107+ m := app.NewMaintainer(ctx, git, time.Millisecond).WithFreshener(fresh)
108+ m.Schedule(ctx, n)
109+ waitFor(t, func() bool { return len(fresh.seen()) == 1 }, "the reader to be told")
110+ m.Close(ctx)
111+ assert.Equal(t, []domain.RepoName{n}, fresh.seen())
112+ }
113+}
114+
100 func TestMaintainerFailureIsLoggedNotFatal(t *testing.T) {115 func TestMaintainerFailureIsLoggedNotFatal(t *testing.T) {
101 ctx := context.Background()116 ctx := context.Background()
102 n := name(t, "tools")117 n := name(t, "tools")
M internal/app/pushes.go +13 -2
@@ -38,6 +38,7 @@ type Pushes struct {
3838 clock ports.Clock
3939 maint MaintenanceScheduler
4040 auth Revalidator
41+ fresh ports.RepoFreshener // nil: the reader needs no telling
4142 }
4243
4344 // NewPushes builds the push use cases. Every argument is required: auth
@@ -47,6 +48,13 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p
4748 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
4849 }
4950
51+// WithFreshener makes Record tell the reader that a repository changed, so that
52+// it serves the push at once. Call it before the first push; it returns u.
53+func (u *Pushes) WithFreshener(f ports.RepoFreshener) *Pushes {
54+ u.fresh = f
55+ return u
56+}
57+
5058 // Open decides whether the principal may push to the repository and, with
5159 // create, creates it when it does not exist yet (private, published as
5260 // RepoCreated by Repos.Create). create is for the first request of a push only.
@@ -118,8 +126,8 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
118126 }
119127
120128 // Record is the post-receive step of an accepted push: it sets pushed_at,
121-// repairs HEAD when it points to a missing branch, publishes a PushEvent and
122-// schedules maintenance. Every step runs even when an earlier one fails; the
129+// repairs HEAD when it points to a missing branch, tells the reader that the
130+// repository changed, publishes a PushEvent and schedules maintenance. Every step runs even when an earlier one fails; the
123131 // failures are returned together and the push itself is not affected.
124132 func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
125133 log := zerowrap.FromCtx(ctx)
@@ -133,6 +141,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
133141 if err := u.fixDefaultBranch(ctx, name, updates); err != nil {
134142 errs = append(errs, err)
135143 }
144+ if u.fresh != nil {
145+ u.fresh.Refresh(ctx, name)
146+ }
136147 u.bus.Publish(ctx, domain.PushEvent{
137148 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
138149 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
@@ -38,6 +38,7 @@ type Pushes struct {
38 clock ports.Clock38 clock ports.Clock
39 maint MaintenanceScheduler39 maint MaintenanceScheduler
40 auth Revalidator40 auth Revalidator
41+ fresh ports.RepoFreshener // nil: the reader needs no telling
41 }42 }
42 43
43 // NewPushes builds the push use cases. Every argument is required: auth44 // NewPushes builds the push use cases. Every argument is required: auth
@@ -47,6 +48,13 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p
47 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}48 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
48 }49 }
49 50
51+// WithFreshener makes Record tell the reader that a repository changed, so that
52+// it serves the push at once. Call it before the first push; it returns u.
53+func (u *Pushes) WithFreshener(f ports.RepoFreshener) *Pushes {
54+ u.fresh = f
55+ return u
56+}
57+
50 // Open decides whether the principal may push to the repository and, with58 // Open decides whether the principal may push to the repository and, with
51 // create, creates it when it does not exist yet (private, published as59 // create, creates it when it does not exist yet (private, published as
52 // RepoCreated by Repos.Create). create is for the first request of a push only.60 // RepoCreated by Repos.Create). create is for the first request of a push only.
@@ -118,8 +126,8 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
118 }126 }
119 127
120 // Record is the post-receive step of an accepted push: it sets pushed_at,128 // Record is the post-receive step of an accepted push: it sets pushed_at,
121-// repairs HEAD when it points to a missing branch, publishes a PushEvent and129+// repairs HEAD when it points to a missing branch, tells the reader that the
122-// schedules maintenance. Every step runs even when an earlier one fails; the130+// repository changed, publishes a PushEvent and schedules maintenance. Every step runs even when an earlier one fails; the
123 // failures are returned together and the push itself is not affected.131 // failures are returned together and the push itself is not affected.
124 func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {132 func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
125 log := zerowrap.FromCtx(ctx)133 log := zerowrap.FromCtx(ctx)
@@ -133,6 +141,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
133 if err := u.fixDefaultBranch(ctx, name, updates); err != nil {141 if err := u.fixDefaultBranch(ctx, name, updates); err != nil {
134 errs = append(errs, err)142 errs = append(errs, err)
135 }143 }
144+ if u.fresh != nil {
145+ u.fresh.Refresh(ctx, name)
146+ }
136 u.bus.Publish(ctx, domain.PushEvent{147 u.bus.Publish(ctx, domain.PushEvent{
137 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,148 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
138 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,149 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
M internal/app/pushes_test.go +30 -0
@@ -30,6 +30,24 @@ func (s *scheduler) Schedule(_ context.Context, n domain.RepoName) {
3030 s.names = append(s.names, n)
3131 }
3232
33+// freshener records the repositories it was told changed.
34+type freshener struct {
35+ mu sync.Mutex
36+ names []domain.RepoName
37+}
38+
39+func (f *freshener) Refresh(_ context.Context, n domain.RepoName) {
40+ f.mu.Lock()
41+ defer f.mu.Unlock()
42+ f.names = append(f.names, n)
43+}
44+
45+func (f *freshener) seen() []domain.RepoName {
46+ f.mu.Lock()
47+ defer f.mu.Unlock()
48+ return append([]domain.RepoName(nil), f.names...)
49+}
50+
3351 type pushFixture struct {
3452 meta *mocks.RepoMetaStore
3553 git *mocks.GitRepos
@@ -223,6 +241,18 @@ func TestPushesRecord(t *testing.T) {
223241 assert.Equal(t, []domain.RepoName{n}, f.sched.names)
224242 })
225243
244+ t.Run("tells the reader that the repository changed", func(t *testing.T) {
245+ f := newPushFixture(t)
246+ fresh := &freshener{}
247+ f.pushes.WithFreshener(fresh)
248+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil)
249+ f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
250+ f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
251+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
252+ require.NoError(t, f.pushes.Record(ctx, p, n, ups))
253+ assert.Equal(t, []domain.RepoName{n}, fresh.seen())
254+ })
255+
226256 t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) {
227257 f := newPushFixture(t)
228258 repo := domain.Repo{Name: n}
@@ -30,6 +30,24 @@ func (s *scheduler) Schedule(_ context.Context, n domain.RepoName) {
30 s.names = append(s.names, n)30 s.names = append(s.names, n)
31 }31 }
32 32
33+// freshener records the repositories it was told changed.
34+type freshener struct {
35+ mu sync.Mutex
36+ names []domain.RepoName
37+}
38+
39+func (f *freshener) Refresh(_ context.Context, n domain.RepoName) {
40+ f.mu.Lock()
41+ defer f.mu.Unlock()
42+ f.names = append(f.names, n)
43+}
44+
45+func (f *freshener) seen() []domain.RepoName {
46+ f.mu.Lock()
47+ defer f.mu.Unlock()
48+ return append([]domain.RepoName(nil), f.names...)
49+}
50+
33 type pushFixture struct {51 type pushFixture struct {
34 meta *mocks.RepoMetaStore52 meta *mocks.RepoMetaStore
35 git *mocks.GitRepos53 git *mocks.GitRepos
@@ -223,6 +241,18 @@ func TestPushesRecord(t *testing.T) {
223 assert.Equal(t, []domain.RepoName{n}, f.sched.names)241 assert.Equal(t, []domain.RepoName{n}, f.sched.names)
224 })242 })
225 243
244+ t.Run("tells the reader that the repository changed", func(t *testing.T) {
245+ f := newPushFixture(t)
246+ fresh := &freshener{}
247+ f.pushes.WithFreshener(fresh)
248+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil)
249+ f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
250+ f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
251+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
252+ require.NoError(t, f.pushes.Record(ctx, p, n, ups))
253+ assert.Equal(t, []domain.RepoName{n}, fresh.seen())
254+ })
255+
226 t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) {256 t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) {
227 f := newPushFixture(t)257 f := newPushFixture(t)
228 repo := domain.Repo{Name: n}258 repo := domain.Repo{Name: n}
A internal/ports/mocks/RepoFreshener.go +94 -0
@@ -0,0 +1,94 @@
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/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewRepoFreshener creates a new instance of RepoFreshener. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewRepoFreshener(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *RepoFreshener {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &RepoFreshener{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// RepoFreshener is an autogenerated mock type for the RepoFreshener type
38+type RepoFreshener struct {
39+ mock.Mock
40+}
41+
42+type RepoFreshener_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *RepoFreshener) EXPECT() *RepoFreshener_Expecter {
47+ return &RepoFreshener_Expecter{mock: &_m.Mock}
48+}
49+
50+// Refresh provides a mock function for the type RepoFreshener
51+func (_mock *RepoFreshener) Refresh(ctx context.Context, name domain.RepoName) {
52+ _mock.Called(ctx, name)
53+ return
54+}
55+
56+// RepoFreshener_Refresh_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Refresh'
57+type RepoFreshener_Refresh_Call struct {
58+ *mock.Call
59+}
60+
61+// Refresh is a helper method to define mock.On call
62+// - ctx context.Context
63+// - name domain.RepoName
64+func (_e *RepoFreshener_Expecter) Refresh(ctx any, name any) *RepoFreshener_Refresh_Call {
65+ return &RepoFreshener_Refresh_Call{Call: _e.mock.On("Refresh", ctx, name)}
66+}
67+
68+func (_c *RepoFreshener_Refresh_Call) Run(run func(ctx context.Context, name domain.RepoName)) *RepoFreshener_Refresh_Call {
69+ _c.Call.Run(func(args mock.Arguments) {
70+ var arg0 context.Context
71+ if args[0] != nil {
72+ arg0 = args[0].(context.Context)
73+ }
74+ var arg1 domain.RepoName
75+ if args[1] != nil {
76+ arg1 = args[1].(domain.RepoName)
77+ }
78+ run(
79+ arg0,
80+ arg1,
81+ )
82+ })
83+ return _c
84+}
85+
86+func (_c *RepoFreshener_Refresh_Call) Return() *RepoFreshener_Refresh_Call {
87+ _c.Call.Return()
88+ return _c
89+}
90+
91+func (_c *RepoFreshener_Refresh_Call) RunAndReturn(run func(ctx context.Context, name domain.RepoName)) *RepoFreshener_Refresh_Call {
92+ _c.Run(run)
93+ return _c
94+}
@@ -0,0 +1,94 @@
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/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewRepoFreshener creates a new instance of RepoFreshener. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewRepoFreshener(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *RepoFreshener {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &RepoFreshener{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// RepoFreshener is an autogenerated mock type for the RepoFreshener type
38+type RepoFreshener struct {
39+ mock.Mock
40+}
41+
42+type RepoFreshener_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *RepoFreshener) EXPECT() *RepoFreshener_Expecter {
47+ return &RepoFreshener_Expecter{mock: &_m.Mock}
48+}
49+
50+// Refresh provides a mock function for the type RepoFreshener
51+func (_mock *RepoFreshener) Refresh(ctx context.Context, name domain.RepoName) {
52+ _mock.Called(ctx, name)
53+ return
54+}
55+
56+// RepoFreshener_Refresh_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Refresh'
57+type RepoFreshener_Refresh_Call struct {
58+ *mock.Call
59+}
60+
61+// Refresh is a helper method to define mock.On call
62+// - ctx context.Context
63+// - name domain.RepoName
64+func (_e *RepoFreshener_Expecter) Refresh(ctx any, name any) *RepoFreshener_Refresh_Call {
65+ return &RepoFreshener_Refresh_Call{Call: _e.mock.On("Refresh", ctx, name)}
66+}
67+
68+func (_c *RepoFreshener_Refresh_Call) Run(run func(ctx context.Context, name domain.RepoName)) *RepoFreshener_Refresh_Call {
69+ _c.Call.Run(func(args mock.Arguments) {
70+ var arg0 context.Context
71+ if args[0] != nil {
72+ arg0 = args[0].(context.Context)
73+ }
74+ var arg1 domain.RepoName
75+ if args[1] != nil {
76+ arg1 = args[1].(domain.RepoName)
77+ }
78+ run(
79+ arg0,
80+ arg1,
81+ )
82+ })
83+ return _c
84+}
85+
86+func (_c *RepoFreshener_Refresh_Call) Return() *RepoFreshener_Refresh_Call {
87+ _c.Call.Return()
88+ return _c
89+}
90+
91+func (_c *RepoFreshener_Refresh_Call) RunAndReturn(run func(ctx context.Context, name domain.RepoName)) *RepoFreshener_Refresh_Call {
92+ _c.Run(run)
93+ return _c
94+}
M internal/ports/ports.go +11 -0
@@ -84,6 +84,17 @@ type GitRepos interface {
8484 Maintain(ctx context.Context, name domain.RepoName) error
8585 }
8686
87+// RepoFreshener is implemented by a RepoReader that keeps repositories open
88+// between reads. The use cases that write to a repository (a push, the
89+// maintenance that follows it) call Refresh so that the reader picks the change
90+// up at once instead of when its own timer says so. It is optional: reads
91+// stay correct without it.
92+type RepoFreshener interface {
93+ // Refresh tells the reader that the repository changed on disk. It does
94+ // not fail: a reader that cannot refresh finds out on its next read.
95+ Refresh(ctx context.Context, name domain.RepoName)
96+}
97+
8798 // CommitQuery selects a page of history.
8899 type CommitQuery struct {
89100 // Revision is where the history starts; the zero value is HEAD.
@@ -84,6 +84,17 @@ type GitRepos interface {
84 Maintain(ctx context.Context, name domain.RepoName) error84 Maintain(ctx context.Context, name domain.RepoName) error
85 }85 }
86 86
87+// RepoFreshener is implemented by a RepoReader that keeps repositories open
88+// between reads. The use cases that write to a repository (a push, the
89+// maintenance that follows it) call Refresh so that the reader picks the change
90+// up at once instead of when its own timer says so. It is optional: reads
91+// stay correct without it.
92+type RepoFreshener interface {
93+ // Refresh tells the reader that the repository changed on disk. It does
94+ // not fail: a reader that cannot refresh finds out on its next read.
95+ Refresh(ctx context.Context, name domain.RepoName)
96+}
97+
87 // CommitQuery selects a page of history.98 // CommitQuery selects a page of history.
88 type CommitQuery struct {99 type CommitQuery struct {
89 // Revision is where the history starts; the zero value is HEAD.100 // Revision is where the history starts; the zero value is HEAD.

Keyboard shortcuts

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

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

Branches 2

Tags 42

↑↓ moveenter openesc close