detached 6d7a853

fix(app): record a push when a merge moves the target branch

A merge now sets pushed_at and queues maintenance of the repository, through
the same touchPushedAt helper as Pushes.Record.
commit
6d7a85334461e71e64eca7ccf4a96d529b7bf8c1
author
brice · · 2026-10-10 17:46 UTC
parent
e51c1e8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +54 -10
M cmd/ermite/forge.go +1 -1
@@ -184,7 +184,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
184184 f.SigningKey = app.NewSigningKey(signer, committer)
185185 f.MergeRequests = app.NewMergeRequests(app.MergeRequestsConfig{
186186 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
187- Committer: committer, Bus: f.Bus, Clock: clock,
187+ Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
188188 })
189189 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
190190
@@ -184,7 +184,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
184 f.SigningKey = app.NewSigningKey(signer, committer)184 f.SigningKey = app.NewSigningKey(signer, committer)
185 f.MergeRequests = app.NewMergeRequests(app.MergeRequestsConfig{185 f.MergeRequests = app.NewMergeRequests(app.MergeRequestsConfig{
186 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,186 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
187- Committer: committer, Bus: f.Bus, Clock: clock,187+ Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
188 })188 })
189 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")189 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
190 190
M internal/app/mergerequests.go +18 -1
@@ -42,6 +42,7 @@ type MergeRequests struct {
4242 committer domain.Identity
4343 bus ports.EventBus
4444 clock ports.Clock
45+ maint MaintenanceScheduler
4546
4647 mu sync.Mutex
4748 locks map[domain.RepoName]chan struct{}
@@ -61,13 +62,15 @@ type MergeRequestsConfig struct {
6162 Committer domain.Identity
6263 Bus ports.EventBus
6364 Clock ports.Clock
65+ // Maint queues the maintenance that follows a merge, as it does a push.
66+ Maint MaintenanceScheduler
6467 }
6568
6669 // NewMergeRequests builds the merge request use cases.
6770 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
6871 return &MergeRequests{
6972 meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
70- committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock,
73+ committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint,
7174 locks: make(map[domain.RepoName]chan struct{}),
7275 }
7376 }
@@ -788,6 +791,7 @@ func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name doma
788791 if err != nil {
789792 return domain.MergeRequest{}, err
790793 }
794+ m.recordPush(ctx, name) // the target moved, whatever happens next
791795 mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)
792796 if err != nil {
793797 return domain.MergeRequest{}, err
@@ -805,6 +809,19 @@ func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name doma
805809 return mr, nil
806810 }
807811
812+// recordPush does for a merge what Pushes.Record does for a push, as the merge
813+// moved a branch: it sets pushed_at and queues the maintenance of the
814+// repository. The target already moved, so a failure is logged, not returned,
815+// and the caller giving up does not stop it.
816+func (m *MergeRequests) recordPush(ctx context.Context, name domain.RepoName) {
817+ ctx = context.WithoutCancel(ctx)
818+ if _, err := touchPushedAt(ctx, m.meta, name, m.clock.Now().UTC()); err != nil {
819+ log := zerowrap.FromCtx(ctx)
820+ log.Error().Err(err).Str("repo", name.String()).Msg("record pushed_at after a merge")
821+ }
822+ m.maint.Schedule(ctx, name)
823+}
824+
808825 // moveTarget moves the target branch from tip to the head (fast-forward) or to
809826 // a new signed squash commit, and returns the new tip.
810827 func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID) (domain.ObjectID, error) {
@@ -42,6 +42,7 @@ type MergeRequests struct {
42 committer domain.Identity42 committer domain.Identity
43 bus ports.EventBus43 bus ports.EventBus
44 clock ports.Clock44 clock ports.Clock
45+ maint MaintenanceScheduler
45 46
46 mu sync.Mutex47 mu sync.Mutex
47 locks map[domain.RepoName]chan struct{}48 locks map[domain.RepoName]chan struct{}
@@ -61,13 +62,15 @@ type MergeRequestsConfig struct {
61 Committer domain.Identity62 Committer domain.Identity
62 Bus ports.EventBus63 Bus ports.EventBus
63 Clock ports.Clock64 Clock ports.Clock
65+ // Maint queues the maintenance that follows a merge, as it does a push.
66+ Maint MaintenanceScheduler
64 }67 }
65 68
66 // NewMergeRequests builds the merge request use cases.69 // NewMergeRequests builds the merge request use cases.
67 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {70 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
68 return &MergeRequests{71 return &MergeRequests{
69 meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,72 meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
70- committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock,73+ committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint,
71 locks: make(map[domain.RepoName]chan struct{}),74 locks: make(map[domain.RepoName]chan struct{}),
72 }75 }
73 }76 }
@@ -788,6 +791,7 @@ func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name doma
788 if err != nil {791 if err != nil {
789 return domain.MergeRequest{}, err792 return domain.MergeRequest{}, err
790 }793 }
794+ m.recordPush(ctx, name) // the target moved, whatever happens next
791 mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)795 mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)
792 if err != nil {796 if err != nil {
793 return domain.MergeRequest{}, err797 return domain.MergeRequest{}, err
@@ -805,6 +809,19 @@ func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name doma
805 return mr, nil809 return mr, nil
806 }810 }
807 811
812+// recordPush does for a merge what Pushes.Record does for a push, as the merge
813+// moved a branch: it sets pushed_at and queues the maintenance of the
814+// repository. The target already moved, so a failure is logged, not returned,
815+// and the caller giving up does not stop it.
816+func (m *MergeRequests) recordPush(ctx context.Context, name domain.RepoName) {
817+ ctx = context.WithoutCancel(ctx)
818+ if _, err := touchPushedAt(ctx, m.meta, name, m.clock.Now().UTC()); err != nil {
819+ log := zerowrap.FromCtx(ctx)
820+ log.Error().Err(err).Str("repo", name.String()).Msg("record pushed_at after a merge")
821+ }
822+ m.maint.Schedule(ctx, name)
823+}
824+
808 // moveTarget moves the target branch from tip to the head (fast-forward) or to825 // moveTarget moves the target branch from tip to the head (fast-forward) or to
809 // a new signed squash commit, and returns the new tip.826 // a new signed squash commit, and returns the new tip.
810 func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID) (domain.ObjectID, error) {827 func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID) (domain.ObjectID, error) {
M internal/app/mergerequests_integration_test.go +28 -2
@@ -39,6 +39,7 @@ type mrRig struct {
3939 store *kvstore.Store
4040 git *gitcli.Git
4141 bus *eventbus.Bus
42+ sched *scheduler
4243 events <-chan domain.Event
4344 work string
4445 repo domain.RepoName
@@ -74,7 +75,7 @@ func newMRRig(t *testing.T) *mrRig {
7475 bus := eventbus.NewWithBuffer(1024)
7576 committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}
7677 r := &mrRig{
77- t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, events: bus.Subscribe(ctx),
78+ t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, sched: &scheduler{}, events: bus.Subscribe(ctx),
7879 repos: app.NewRepos(store, git, bus, fixedClock{}),
7980 key: app.NewSigningKey(signer, committer),
8081 work: t.TempDir(),
@@ -82,7 +83,7 @@ func newMRRig(t *testing.T) *mrRig {
8283 }
8384 r.mrs = app.NewMergeRequests(app.MergeRequestsConfig{
8485 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
85- Committer: committer, Bus: bus, Clock: fixedClock{},
86+ Committer: committer, Bus: bus, Clock: fixedClock{}, Maint: r.sched,
8687 })
8788 _, err = r.repos.Create(ctx, root, app.CreateParams{Name: r.repo, Visibility: domain.VisibilityPrivate})
8889 require.NoError(t, err)
@@ -1224,3 +1225,28 @@ func TestAgentPresetWorksOnItsPrivateRepository(t *testing.T) {
12241225 _, err = r.mrs.Create(r.ctx, elsewhere, r.repo, app.CreateMergeRequestParams{Title: "t", Source: "feat"})
12251226 assert.ErrorIs(t, err, domain.ErrNotFound)
12261227 }
1228+
1229+// GO-006: a merge moves the target branch, so it records pushed_at and queues
1230+// the maintenance of the repository, as a push does.
1231+func TestMergeRecordsAPush(t *testing.T) {
1232+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
1233+ t.Run(string(mode), func(t *testing.T) {
1234+ r := newMRRig(t)
1235+ r.seed()
1236+ head := r.feature("feat")
1237+ mr := r.open("feat")
1238+ before, err := r.repos.Get(r.ctx, root, r.repo)
1239+ require.NoError(t, err)
1240+ require.True(t, before.PushedAt.IsZero(), "no push recorded yet")
1241+ assert.Empty(t, r.sched.names)
1242+
1243+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{Mode: mode, HeadID: head})
1244+ require.NoError(t, err)
1245+
1246+ after, err := r.repos.Get(r.ctx, root, r.repo)
1247+ require.NoError(t, err)
1248+ assert.Equal(t, now, after.PushedAt)
1249+ assert.Equal(t, []domain.RepoName{r.repo}, r.sched.names, "maintenance queued")
1250+ })
1251+ }
1252+}
@@ -39,6 +39,7 @@ type mrRig struct {
39 store *kvstore.Store39 store *kvstore.Store
40 git *gitcli.Git40 git *gitcli.Git
41 bus *eventbus.Bus41 bus *eventbus.Bus
42+ sched *scheduler
42 events <-chan domain.Event43 events <-chan domain.Event
43 work string44 work string
44 repo domain.RepoName45 repo domain.RepoName
@@ -74,7 +75,7 @@ func newMRRig(t *testing.T) *mrRig {
74 bus := eventbus.NewWithBuffer(1024)75 bus := eventbus.NewWithBuffer(1024)
75 committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}76 committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}
76 r := &mrRig{77 r := &mrRig{
77- t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, events: bus.Subscribe(ctx),78+ t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, sched: &scheduler{}, events: bus.Subscribe(ctx),
78 repos: app.NewRepos(store, git, bus, fixedClock{}),79 repos: app.NewRepos(store, git, bus, fixedClock{}),
79 key: app.NewSigningKey(signer, committer),80 key: app.NewSigningKey(signer, committer),
80 work: t.TempDir(),81 work: t.TempDir(),
@@ -82,7 +83,7 @@ func newMRRig(t *testing.T) *mrRig {
82 }83 }
83 r.mrs = app.NewMergeRequests(app.MergeRequestsConfig{84 r.mrs = app.NewMergeRequests(app.MergeRequestsConfig{
84 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,85 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
85- Committer: committer, Bus: bus, Clock: fixedClock{},86+ Committer: committer, Bus: bus, Clock: fixedClock{}, Maint: r.sched,
86 })87 })
87 _, err = r.repos.Create(ctx, root, app.CreateParams{Name: r.repo, Visibility: domain.VisibilityPrivate})88 _, err = r.repos.Create(ctx, root, app.CreateParams{Name: r.repo, Visibility: domain.VisibilityPrivate})
88 require.NoError(t, err)89 require.NoError(t, err)
@@ -1224,3 +1225,28 @@ func TestAgentPresetWorksOnItsPrivateRepository(t *testing.T) {
1224 _, err = r.mrs.Create(r.ctx, elsewhere, r.repo, app.CreateMergeRequestParams{Title: "t", Source: "feat"})1225 _, err = r.mrs.Create(r.ctx, elsewhere, r.repo, app.CreateMergeRequestParams{Title: "t", Source: "feat"})
1225 assert.ErrorIs(t, err, domain.ErrNotFound)1226 assert.ErrorIs(t, err, domain.ErrNotFound)
1226 }1227 }
1228+
1229+// GO-006: a merge moves the target branch, so it records pushed_at and queues
1230+// the maintenance of the repository, as a push does.
1231+func TestMergeRecordsAPush(t *testing.T) {
1232+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
1233+ t.Run(string(mode), func(t *testing.T) {
1234+ r := newMRRig(t)
1235+ r.seed()
1236+ head := r.feature("feat")
1237+ mr := r.open("feat")
1238+ before, err := r.repos.Get(r.ctx, root, r.repo)
1239+ require.NoError(t, err)
1240+ require.True(t, before.PushedAt.IsZero(), "no push recorded yet")
1241+ assert.Empty(t, r.sched.names)
1242+
1243+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{Mode: mode, HeadID: head})
1244+ require.NoError(t, err)
1245+
1246+ after, err := r.repos.Get(r.ctx, root, r.repo)
1247+ require.NoError(t, err)
1248+ assert.Equal(t, now, after.PushedAt)
1249+ assert.Equal(t, []domain.RepoName{r.repo}, r.sched.names, "maintenance queued")
1250+ })
1251+ }
1252+}
M internal/app/mergerequests_test.go +1 -1
@@ -38,7 +38,7 @@ func newMRUnit(t *testing.T) *mrUnit {
3838 clock.EXPECT().Now().Return(now).Maybe()
3939 u.mrs = app.NewMergeRequests(app.MergeRequestsConfig{
4040 Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git,
41- Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock,
41+ Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: &scheduler{},
4242 })
4343 return u
4444 }
@@ -38,7 +38,7 @@ func newMRUnit(t *testing.T) *mrUnit {
38 clock.EXPECT().Now().Return(now).Maybe()38 clock.EXPECT().Now().Return(now).Maybe()
39 u.mrs = app.NewMergeRequests(app.MergeRequestsConfig{39 u.mrs = app.NewMergeRequests(app.MergeRequestsConfig{
40 Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git,40 Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git,
41- Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock,41+ Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: &scheduler{},
42 })42 })
43 return u43 return u
44 }44 }
M internal/app/pushes.go +6 -5
@@ -126,7 +126,7 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
126126 now := u.clock.Now().UTC()
127127 var errs []error
128128
129- repo, err := u.touchPushedAt(ctx, name, now)
129+ repo, err := touchPushedAt(ctx, u.meta, name, now)
130130 if err != nil {
131131 errs = append(errs, err)
132132 }
@@ -147,10 +147,11 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
147147
148148 // touchPushedAt stores pushed_at, retrying when another write got in between.
149149 // A repository deleted meanwhile is not an error. It returns the repository
150-// as read, or the zero value when it is unknown.
151-func (u *Pushes) touchPushedAt(ctx context.Context, name domain.RepoName, now time.Time) (domain.Repo, error) {
150+// as read, or the zero value when it is unknown. A push (Pushes.Record) and a
151+// merge, which moves a branch too, both record through it.
152+func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
152153 for range pushRetries {
153- repo, err := u.meta.Get(ctx, name)
154+ repo, err := meta.Get(ctx, name)
154155 if errors.Is(err, domain.ErrNotFound) {
155156 return domain.Repo{}, nil
156157 }
@@ -161,7 +162,7 @@ func (u *Pushes) touchPushedAt(ctx context.Context, name domain.RepoName, now ti
161162 return repo, nil // a later push already recorded
162163 }
163164 repo.PushedAt = now
164- err = u.meta.Update(ctx, repo)
165+ err = meta.Update(ctx, repo)
165166 if errors.Is(err, domain.ErrConflict) {
166167 continue
167168 }
@@ -126,7 +126,7 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
126 now := u.clock.Now().UTC()126 now := u.clock.Now().UTC()
127 var errs []error127 var errs []error
128 128
129- repo, err := u.touchPushedAt(ctx, name, now)129+ repo, err := touchPushedAt(ctx, u.meta, name, now)
130 if err != nil {130 if err != nil {
131 errs = append(errs, err)131 errs = append(errs, err)
132 }132 }
@@ -147,10 +147,11 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
147 147
148 // touchPushedAt stores pushed_at, retrying when another write got in between.148 // touchPushedAt stores pushed_at, retrying when another write got in between.
149 // A repository deleted meanwhile is not an error. It returns the repository149 // A repository deleted meanwhile is not an error. It returns the repository
150-// as read, or the zero value when it is unknown.150+// as read, or the zero value when it is unknown. A push (Pushes.Record) and a
151-func (u *Pushes) touchPushedAt(ctx context.Context, name domain.RepoName, now time.Time) (domain.Repo, error) {151+// merge, which moves a branch too, both record through it.
152+func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
152 for range pushRetries {153 for range pushRetries {
153- repo, err := u.meta.Get(ctx, name)154+ repo, err := meta.Get(ctx, name)
154 if errors.Is(err, domain.ErrNotFound) {155 if errors.Is(err, domain.ErrNotFound) {
155 return domain.Repo{}, nil156 return domain.Repo{}, nil
156 }157 }
@@ -161,7 +162,7 @@ func (u *Pushes) touchPushedAt(ctx context.Context, name domain.RepoName, now ti
161 return repo, nil // a later push already recorded162 return repo, nil // a later push already recorded
162 }163 }
163 repo.PushedAt = now164 repo.PushedAt = now
164- err = u.meta.Update(ctx, repo)165+ err = meta.Update(ctx, repo)
165 if errors.Is(err, domain.ErrConflict) {166 if errors.Is(err, domain.ErrConflict) {
166 continue167 continue
167 }168 }

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 45

↑↓ moveenter openesc close