detached 335d134

fix(mirror): mark mirrors dirty on merges and default branch changes, validate config before saving and keep manual throttling on coalesced syncs

commit
335d134480a771ea99c98e0eb9e5d9b386ae952c
author
brice · · 2026-10-20 06:15 UTC
committer
brice · · 2026-10-20 10:02 UTC
parent
c59b5fa
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +254 -26
M internal/app/forge.go +2 -1
@@ -298,6 +298,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
298298 }
299299 f.Mirrors = usecase.NewMirrors(mirrorCfg)
300300 f.Pushes.WithMirror(f.Mirrors)
301+ f.Repos.WithMirror(f.Mirrors)
301302 f.hooks.WithMirrorWarner(f.Mirrors)
302303
303304 f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock)
@@ -368,7 +369,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
368369 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
369370 })
370371 f.Mergeability.Start()
371- f.MergeRequests.WithVerifier(f.Signatures)
372+ f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)
372373 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
373374 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
374375 })
@@ -298,6 +298,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
298 }298 }
299 f.Mirrors = usecase.NewMirrors(mirrorCfg)299 f.Mirrors = usecase.NewMirrors(mirrorCfg)
300 f.Pushes.WithMirror(f.Mirrors)300 f.Pushes.WithMirror(f.Mirrors)
301+ f.Repos.WithMirror(f.Mirrors)
301 f.hooks.WithMirrorWarner(f.Mirrors)302 f.hooks.WithMirrorWarner(f.Mirrors)
302 303
303 f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock)304 f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock)
@@ -368,7 +369,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
368 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,369 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
369 })370 })
370 f.Mergeability.Start()371 f.Mergeability.Start()
371- f.MergeRequests.WithVerifier(f.Signatures)372+ f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)
372 f.Issues = usecase.NewIssues(usecase.IssuesConfig{373 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
373 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,374 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
374 })375 })
M internal/usecase/mergerequests.go +10 -0
@@ -58,6 +58,7 @@ type MergeRequests struct {
5858 tips ports.CIRefTipStore // nil: who merges is not recorded
5959 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
6060 mergeab MergeabilityHook // nil: no mergeability
61+ mirror ports.MirrorTrigger // nil: no outbound mirror
6162
6263 mu sync.Mutex
6364 locks map[domain.RepoName]chan struct{}
@@ -116,6 +117,12 @@ func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
116117 }
117118 }
118119
120+// WithMirror makes a merge notify outbound mirrors. Call before the first merge.
121+func (m *MergeRequests) WithMirror(t ports.MirrorTrigger) *MergeRequests {
122+ m.mirror = t
123+ return m
124+}
125+
119126 // WithVerifier makes GetMergeRequest fill the signature state of the commits
120127 // it lists (ADR 0030).
121128 func (m *MergeRequests) WithVerifier(v ports.CommitVerifier) *MergeRequests {
@@ -1172,6 +1179,9 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo
11721179 m.bus.Publish(ctx, domain.PushEvent{
11731180 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
11741181 })
1182+ if m.mirror != nil {
1183+ m.mirror.OnPush(ctx, name, updates) // marks dirty only; never blocks
1184+ }
11751185 if m.pulls != nil {
11761186 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: mr.Number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)
11771187 }
@@ -58,6 +58,7 @@ type MergeRequests struct {
58 tips ports.CIRefTipStore // nil: who merges is not recorded58 tips ports.CIRefTipStore // nil: who merges is not recorded
59 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)59 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
60 mergeab MergeabilityHook // nil: no mergeability60 mergeab MergeabilityHook // nil: no mergeability
61+ mirror ports.MirrorTrigger // nil: no outbound mirror
61 62
62 mu sync.Mutex63 mu sync.Mutex
63 locks map[domain.RepoName]chan struct{}64 locks map[domain.RepoName]chan struct{}
@@ -116,6 +117,12 @@ func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
116 }117 }
117 }118 }
118 119
120+// WithMirror makes a merge notify outbound mirrors. Call before the first merge.
121+func (m *MergeRequests) WithMirror(t ports.MirrorTrigger) *MergeRequests {
122+ m.mirror = t
123+ return m
124+}
125+
119 // WithVerifier makes GetMergeRequest fill the signature state of the commits126 // WithVerifier makes GetMergeRequest fill the signature state of the commits
120 // it lists (ADR 0030).127 // it lists (ADR 0030).
121 func (m *MergeRequests) WithVerifier(v ports.CommitVerifier) *MergeRequests {128 func (m *MergeRequests) WithVerifier(v ports.CommitVerifier) *MergeRequests {
@@ -1172,6 +1179,9 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo
1172 m.bus.Publish(ctx, domain.PushEvent{1179 m.bus.Publish(ctx, domain.PushEvent{
1173 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,1180 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
1174 })1181 })
1182+ if m.mirror != nil {
1183+ m.mirror.OnPush(ctx, name, updates) // marks dirty only; never blocks
1184+ }
1175 if m.pulls != nil {1185 if m.pulls != nil {
1176 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: mr.Number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)1186 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: mr.Number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)
1177 }1187 }
M internal/usecase/mirrors_config.go +54 -14
@@ -74,10 +74,11 @@ func (m *Mirrors) SetGlobalConfig(ctx context.Context, p domain.Principal, patch
7474 }
7575 m.configMu.Lock()
7676 defer m.configMu.Unlock()
77- g, err := m.cfg.Store.GetGlobal(ctx)
77+ old, err := m.cfg.Store.GetGlobal(ctx)
7878 if err != nil {
79- return g, err
79+ return old, err
8080 }
81+ g := old
8182 if patch.Enabled != nil {
8283 g.Enabled = *patch.Enabled
8384 }
@@ -91,30 +92,55 @@ func (m *Mirrors) SetGlobalConfig(ctx context.Context, p domain.Principal, patch
9192 g.AllBranches = *patch.AllBranches
9293 }
9394 if err := m.validateGlobal(g); err != nil {
94- return g, err
95- }
96- g.UpdatedAt = m.cfg.Clock.Now().UTC()
97- if err := m.cfg.Store.PutGlobal(ctx, g); err != nil {
98- return g, err
95+ return old, err
9996 }
10097 repos, err := m.cfg.Meta.List(ctx)
10198 if err != nil {
102- return g, err
99+ return old, err
103100 }
101+ // Resolve every effective target before and after, and persist nothing
102+ // unless all of them stay valid.
103+ var changed []domain.RepoName
104104 for _, r := range repos {
105105 c, e := m.cfg.Store.GetRepoConfig(ctx, r.Name)
106- if e != nil && !errors.Is(e, domain.ErrNotFound) {
107- return g, e
106+ var cp *domain.MirrorRepoConfig
107+ if e == nil {
108+ cp = &c
109+ } else if !errors.Is(e, domain.ErrNotFound) {
110+ return old, e
111+ }
112+ before, _ := m.resolve(old, cp, r.Name)
113+ after, e := m.resolve(g, cp, r.Name)
114+ if e != nil {
115+ return old, invalidCron(e)
108116 }
109- if c.URL != "" {
110- continue
117+ if !sameMirrorTarget(before, after) {
118+ changed = append(changed, r.Name)
111119 }
112- if err := m.reset(ctx, r.Name); err != nil {
120+ }
121+ g.UpdatedAt = m.cfg.Clock.Now().UTC()
122+ if err := m.cfg.Store.PutGlobal(ctx, g); err != nil {
123+ return old, err
124+ }
125+ for _, name := range changed {
126+ if err := m.reset(ctx, name); err != nil {
113127 return g, err
114128 }
115129 }
116130 return g, nil
117131 }
132+
133+func invalidCron(e error) error {
134+ var inv *domain.InvalidFieldsError
135+ if errors.As(e, &inv) {
136+ return e
137+ }
138+ return &domain.InvalidFieldsError{Fields: []domain.FieldViolation{{Field: "cron", Reason: e.Error()}}}
139+}
140+
141+func sameMirrorTarget(a, b domain.MirrorTarget) bool {
142+ return a.Enabled == b.Enabled && a.URL == b.URL && a.CronSpec == b.CronSpec && a.AllBranches == b.AllBranches && a.Source == b.Source
143+}
118144 func (m *Mirrors) reset(ctx context.Context, repo domain.RepoName) error {
119145 t, err := m.target(ctx, repo)
120146 if err != nil {
@@ -146,6 +172,18 @@ func (m *Mirrors) SetRepoMirror(ctx context.Context, p domain.Principal, c domai
146172 }
147173 m.configMu.Lock()
148174 defer m.configMu.Unlock()
175+ return m.setRepoLocked(ctx, c)
176+}
177+
178+// setRepoLocked validates the prospective target, then persists. configMu must be held.
179+func (m *Mirrors) setRepoLocked(ctx context.Context, c domain.MirrorRepoConfig) (domain.MirrorTarget, error) {
180+ g, err := m.cfg.Store.GetGlobal(ctx)
181+ if err != nil {
182+ return domain.MirrorTarget{}, err
183+ }
184+ if _, err := m.resolve(g, &c, c.Repo); err != nil {
185+ return domain.MirrorTarget{}, invalidCron(err)
186+ }
149187 c.UpdatedAt = m.cfg.Clock.Now().UTC()
150188 if err := m.cfg.Store.PutRepoConfig(ctx, c); err != nil {
151189 return domain.MirrorTarget{}, err
@@ -174,12 +212,14 @@ func (m *Mirrors) SetRepoExcluded(ctx context.Context, p domain.Principal, repo
174212 if err := m.admin(ctx, p, repo); err != nil {
175213 return domain.MirrorTarget{}, err
176214 }
215+ m.configMu.Lock()
216+ defer m.configMu.Unlock()
177217 c, err := m.cfg.Store.GetRepoConfig(ctx, repo)
178218 if err != nil && !errors.Is(err, domain.ErrNotFound) {
179219 return domain.MirrorTarget{}, err
180220 }
181221 c.Repo, c.Excluded = repo, excluded
182- return m.SetRepoMirror(ctx, p, c)
222+ return m.setRepoLocked(ctx, c)
183223 }
184224
185225 // GetStatus returns a read-authorized status, redacting administrative diagnostics.
@@ -74,10 +74,11 @@ func (m *Mirrors) SetGlobalConfig(ctx context.Context, p domain.Principal, patch
74 }74 }
75 m.configMu.Lock()75 m.configMu.Lock()
76 defer m.configMu.Unlock()76 defer m.configMu.Unlock()
77- g, err := m.cfg.Store.GetGlobal(ctx)77+ old, err := m.cfg.Store.GetGlobal(ctx)
78 if err != nil {78 if err != nil {
79- return g, err79+ return old, err
80 }80 }
81+ g := old
81 if patch.Enabled != nil {82 if patch.Enabled != nil {
82 g.Enabled = *patch.Enabled83 g.Enabled = *patch.Enabled
83 }84 }
@@ -91,30 +92,55 @@ func (m *Mirrors) SetGlobalConfig(ctx context.Context, p domain.Principal, patch
91 g.AllBranches = *patch.AllBranches92 g.AllBranches = *patch.AllBranches
92 }93 }
93 if err := m.validateGlobal(g); err != nil {94 if err := m.validateGlobal(g); err != nil {
94- return g, err95+ return old, err
95- }
96- g.UpdatedAt = m.cfg.Clock.Now().UTC()
97- if err := m.cfg.Store.PutGlobal(ctx, g); err != nil {
98- return g, err
99 }96 }
100 repos, err := m.cfg.Meta.List(ctx)97 repos, err := m.cfg.Meta.List(ctx)
101 if err != nil {98 if err != nil {
102- return g, err99+ return old, err
103 }100 }
101+ // Resolve every effective target before and after, and persist nothing
102+ // unless all of them stay valid.
103+ var changed []domain.RepoName
104 for _, r := range repos {104 for _, r := range repos {
105 c, e := m.cfg.Store.GetRepoConfig(ctx, r.Name)105 c, e := m.cfg.Store.GetRepoConfig(ctx, r.Name)
106- if e != nil && !errors.Is(e, domain.ErrNotFound) {106+ var cp *domain.MirrorRepoConfig
107- return g, e107+ if e == nil {
108+ cp = &c
109+ } else if !errors.Is(e, domain.ErrNotFound) {
110+ return old, e
111+ }
112+ before, _ := m.resolve(old, cp, r.Name)
113+ after, e := m.resolve(g, cp, r.Name)
114+ if e != nil {
115+ return old, invalidCron(e)
108 }116 }
109- if c.URL != "" {117+ if !sameMirrorTarget(before, after) {
110- continue118+ changed = append(changed, r.Name)
111 }119 }
112- if err := m.reset(ctx, r.Name); err != nil {120+ }
121+ g.UpdatedAt = m.cfg.Clock.Now().UTC()
122+ if err := m.cfg.Store.PutGlobal(ctx, g); err != nil {
123+ return old, err
124+ }
125+ for _, name := range changed {
126+ if err := m.reset(ctx, name); err != nil {
113 return g, err127 return g, err
114 }128 }
115 }129 }
116 return g, nil130 return g, nil
117 }131 }
132+
133+func invalidCron(e error) error {
134+ var inv *domain.InvalidFieldsError
135+ if errors.As(e, &inv) {
136+ return e
137+ }
138+ return &domain.InvalidFieldsError{Fields: []domain.FieldViolation{{Field: "cron", Reason: e.Error()}}}
139+}
140+
141+func sameMirrorTarget(a, b domain.MirrorTarget) bool {
142+ return a.Enabled == b.Enabled && a.URL == b.URL && a.CronSpec == b.CronSpec && a.AllBranches == b.AllBranches && a.Source == b.Source
143+}
118 func (m *Mirrors) reset(ctx context.Context, repo domain.RepoName) error {144 func (m *Mirrors) reset(ctx context.Context, repo domain.RepoName) error {
119 t, err := m.target(ctx, repo)145 t, err := m.target(ctx, repo)
120 if err != nil {146 if err != nil {
@@ -146,6 +172,18 @@ func (m *Mirrors) SetRepoMirror(ctx context.Context, p domain.Principal, c domai
146 }172 }
147 m.configMu.Lock()173 m.configMu.Lock()
148 defer m.configMu.Unlock()174 defer m.configMu.Unlock()
175+ return m.setRepoLocked(ctx, c)
176+}
177+
178+// setRepoLocked validates the prospective target, then persists. configMu must be held.
179+func (m *Mirrors) setRepoLocked(ctx context.Context, c domain.MirrorRepoConfig) (domain.MirrorTarget, error) {
180+ g, err := m.cfg.Store.GetGlobal(ctx)
181+ if err != nil {
182+ return domain.MirrorTarget{}, err
183+ }
184+ if _, err := m.resolve(g, &c, c.Repo); err != nil {
185+ return domain.MirrorTarget{}, invalidCron(err)
186+ }
149 c.UpdatedAt = m.cfg.Clock.Now().UTC()187 c.UpdatedAt = m.cfg.Clock.Now().UTC()
150 if err := m.cfg.Store.PutRepoConfig(ctx, c); err != nil {188 if err := m.cfg.Store.PutRepoConfig(ctx, c); err != nil {
151 return domain.MirrorTarget{}, err189 return domain.MirrorTarget{}, err
@@ -174,12 +212,14 @@ func (m *Mirrors) SetRepoExcluded(ctx context.Context, p domain.Principal, repo
174 if err := m.admin(ctx, p, repo); err != nil {212 if err := m.admin(ctx, p, repo); err != nil {
175 return domain.MirrorTarget{}, err213 return domain.MirrorTarget{}, err
176 }214 }
215+ m.configMu.Lock()
216+ defer m.configMu.Unlock()
177 c, err := m.cfg.Store.GetRepoConfig(ctx, repo)217 c, err := m.cfg.Store.GetRepoConfig(ctx, repo)
178 if err != nil && !errors.Is(err, domain.ErrNotFound) {218 if err != nil && !errors.Is(err, domain.ErrNotFound) {
179 return domain.MirrorTarget{}, err219 return domain.MirrorTarget{}, err
180 }220 }
181 c.Repo, c.Excluded = repo, excluded221 c.Repo, c.Excluded = repo, excluded
182- return m.SetRepoMirror(ctx, p, c)222+ return m.setRepoLocked(ctx, c)
183 }223 }
184 224
185 // GetStatus returns a read-authorized status, redacting administrative diagnostics.225 // GetStatus returns a read-authorized status, redacting administrative diagnostics.
M internal/usecase/mirrors_sync.go +10 -6
@@ -14,6 +14,7 @@ import (
1414 type mirrorFlight struct {
1515 pending, force, manual bool
1616 pendingForce bool
17+ pendingManual bool
1718 done chan struct{}
1819 completed bool
1920 result ports.MirrorSyncResult
@@ -77,6 +78,7 @@ func (m *Mirrors) enqueue(repo domain.RepoName, force, manual bool) (*mirrorFlig
7778 if f := m.flights[repo]; f != nil {
7879 f.pending = true
7980 f.pendingForce = f.pendingForce || force
81+ f.pendingManual = f.pendingManual || manual
8082 return f, true, nil
8183 }
8284 f := &mirrorFlight{force: force, manual: manual, done: make(chan struct{})}
@@ -106,15 +108,12 @@ func (m *Mirrors) run(repo domain.RepoName) {
106108 m.mu.Unlock()
107109 return
108110 }
109- f.force, f.manual = f.pendingForce, false
110- f.pending, f.pendingForce = false, false
111+ f.force, f.manual = f.pendingForce, f.pendingManual
112+ f.pending, f.pendingForce, f.pendingManual = false, false, false
111113 m.mu.Unlock()
112114 }
113115 }
114116
115-func (m *Mirrors) syncOnce(ctx context.Context, repo domain.RepoName, force bool) (ports.MirrorSyncResult, error) {
116- return m.attempt(ctx, repo, force, true)
117-}
118117 func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manual bool) (ports.MirrorSyncResult, error) {
119118 m.configMu.RLock()
120119 defer m.configMu.RUnlock()
@@ -130,7 +129,8 @@ func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manu
130129 if err != nil {
131130 return ports.MirrorSyncResult{}, err
132131 }
133- now := m.cfg.Clock.Now().UTC()
132+ start := m.cfg.Clock.Now().UTC()
133+ now := start
134134 if !t.Enabled {
135135 s := domain.MirrorDisable(prev, now)
136136 if prev.State != domain.MirrorDisabled {
@@ -193,7 +193,11 @@ func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manu
193193 runErr = fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, detail)
194194 }
195195 }
196+ // The push may be slow: completion, retry and success times come from the
197+ // clock after it returns; only LastAttemptAt marks when the attempt began.
198+ now = m.cfg.Clock.Now().UTC()
196199 transition := domain.ApplyMirrorOutcome(prev, domain.MirrorOutcome{Err: failure, NoOp: noOp}, now)
200+ transition.Status.LastAttemptAt = start
197201 if failure != nil && m.cfg.Sealer == nil {
198202 transition.Status.Reason = failure.Detail
199203 }
@@ -14,6 +14,7 @@ import (
14 type mirrorFlight struct {14 type mirrorFlight struct {
15 pending, force, manual bool15 pending, force, manual bool
16 pendingForce bool16 pendingForce bool
17+ pendingManual bool
17 done chan struct{}18 done chan struct{}
18 completed bool19 completed bool
19 result ports.MirrorSyncResult20 result ports.MirrorSyncResult
@@ -77,6 +78,7 @@ func (m *Mirrors) enqueue(repo domain.RepoName, force, manual bool) (*mirrorFlig
77 if f := m.flights[repo]; f != nil {78 if f := m.flights[repo]; f != nil {
78 f.pending = true79 f.pending = true
79 f.pendingForce = f.pendingForce || force80 f.pendingForce = f.pendingForce || force
81+ f.pendingManual = f.pendingManual || manual
80 return f, true, nil82 return f, true, nil
81 }83 }
82 f := &mirrorFlight{force: force, manual: manual, done: make(chan struct{})}84 f := &mirrorFlight{force: force, manual: manual, done: make(chan struct{})}
@@ -106,15 +108,12 @@ func (m *Mirrors) run(repo domain.RepoName) {
106 m.mu.Unlock()108 m.mu.Unlock()
107 return109 return
108 }110 }
109- f.force, f.manual = f.pendingForce, false111+ f.force, f.manual = f.pendingForce, f.pendingManual
110- f.pending, f.pendingForce = false, false112+ f.pending, f.pendingForce, f.pendingManual = false, false, false
111 m.mu.Unlock()113 m.mu.Unlock()
112 }114 }
113 }115 }
114 116
115-func (m *Mirrors) syncOnce(ctx context.Context, repo domain.RepoName, force bool) (ports.MirrorSyncResult, error) {
116- return m.attempt(ctx, repo, force, true)
117-}
118 func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manual bool) (ports.MirrorSyncResult, error) {117 func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manual bool) (ports.MirrorSyncResult, error) {
119 m.configMu.RLock()118 m.configMu.RLock()
120 defer m.configMu.RUnlock()119 defer m.configMu.RUnlock()
@@ -130,7 +129,8 @@ func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manu
130 if err != nil {129 if err != nil {
131 return ports.MirrorSyncResult{}, err130 return ports.MirrorSyncResult{}, err
132 }131 }
133- now := m.cfg.Clock.Now().UTC()132+ start := m.cfg.Clock.Now().UTC()
133+ now := start
134 if !t.Enabled {134 if !t.Enabled {
135 s := domain.MirrorDisable(prev, now)135 s := domain.MirrorDisable(prev, now)
136 if prev.State != domain.MirrorDisabled {136 if prev.State != domain.MirrorDisabled {
@@ -193,7 +193,11 @@ func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, force, manu
193 runErr = fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, detail)193 runErr = fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, detail)
194 }194 }
195 }195 }
196+ // The push may be slow: completion, retry and success times come from the
197+ // clock after it returns; only LastAttemptAt marks when the attempt began.
198+ now = m.cfg.Clock.Now().UTC()
196 transition := domain.ApplyMirrorOutcome(prev, domain.MirrorOutcome{Err: failure, NoOp: noOp}, now)199 transition := domain.ApplyMirrorOutcome(prev, domain.MirrorOutcome{Err: failure, NoOp: noOp}, now)
200+ transition.Status.LastAttemptAt = start
197 if failure != nil && m.cfg.Sealer == nil {201 if failure != nil && m.cfg.Sealer == nil {
198 transition.Status.Reason = failure.Detail202 transition.Status.Reason = failure.Detail
199 }203 }
M internal/usecase/mirrors_test.go +162 -0
@@ -432,3 +432,165 @@ func TestMirrorsFileURLTestOption(t *testing.T) {
432432 })
433433 }
434434 }
435+
436+func TestMirrorsMergeNotifiesMirror(t *testing.T) {
437+ r := newMRRig(t)
438+ r.seed()
439+ trigger := mocks.NewMirrorTrigger(t)
440+ notified := make(chan []domain.RefUpdate, 1)
441+ trigger.On("OnPush", mock.Anything, r.repo, mock.Anything).Run(func(a mock.Arguments) { notified <- a.Get(2).([]domain.RefUpdate) }).Return().Once()
442+ r.mrs.WithMirror(trigger)
443+ head := r.feature("f0")
444+ mr := r.open("f0")
445+ _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head})
446+ require.NoError(t, err)
447+ r.mrs.Wait(r.ctx)
448+ select {
449+ case u := <-notified:
450+ require.Equal(t, "refs/heads/main", u[0].Ref)
451+ case <-time.After(time.Second):
452+ t.Fatal("merge did not notify mirrors")
453+ }
454+}
455+
456+func TestMirrorsDefaultBranchChangeNotifiesMirror(t *testing.T) {
457+ f := newFixture(t)
458+ ctx := context.Background()
459+ n := name(t, "tools")
460+ repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
461+ trigger := mocks.NewMirrorTrigger(t)
462+ trigger.On("OnPush", mock.Anything, n, mock.Anything).Return().Once()
463+ f.repos.WithMirror(trigger)
464+ f.meta.EXPECT().Get(ctx, n).Return(repo, nil)
465+ f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
466+ f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(nil)
467+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
468+ _, err := f.repos.Update(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n, ports.UpdateRepoParams{DefaultBranch: ptr("dev")})
469+ require.NoError(t, err)
470+}
471+
472+func TestMirrorsExternalMutationMakesNextDueTickSync(t *testing.T) {
473+ f := newMirrorFixture(t)
474+ f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once()
475+ _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
476+ require.NoError(t, err)
477+ f.mu.Lock()
478+ f.at = f.at.Add(24 * time.Hour).Truncate(time.Hour).Add(time.Minute)
479+ f.mu.Unlock()
480+ f.m.ScheduleNow(context.Background())
481+ f.advance(4 * time.Minute)
482+ f.m.ScheduleNow(context.Background()) // due and clean: skipped
483+ f.reader.AssertNumberOfCalls(t, "ListRefs", 1)
484+ // A merge and a default-branch change both call OnPush (idempotent dirty mark).
485+ f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "refs/heads/main"}})
486+ f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "HEAD"}})
487+ read := make(chan struct{})
488+ f.reader.ExpectedCalls = nil
489+ f.reader.On("ListRefs", mock.Anything, f.repo).Run(func(mock.Arguments) { close(read) }).Return([]domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}}, nil).Once()
490+ f.advance(5 * time.Minute)
491+ f.m.ScheduleNow(context.Background())
492+ select {
493+ case <-read:
494+ case <-time.After(time.Second):
495+ t.Fatal("dirty repository not synced on the next due tick")
496+ }
497+}
498+
499+func setupRepoOverrideFixture(t *testing.T, global domain.MirrorGlobalConfig) *mirrorFixture {
500+ f := newMirrorFixture(t)
501+ f.store.ExpectedCalls = nil
502+ f.store.On("GetGlobal", mock.Anything).Return(global, nil)
503+ f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{}, domain.ErrNotFound).Maybe()
504+ f.store.On("GetStatus", mock.Anything, f.repo).Return(domain.MirrorStatus{Repo: f.repo}, nil).Maybe()
505+ return f
506+}
507+
508+func TestMirrorsOverrideWithoutCronRejectedWhenGlobalCronEmpty(t *testing.T) {
509+ f := setupRepoOverrideFixture(t, domain.MirrorGlobalConfig{})
510+ _, err := f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools"})
511+ var fe *domain.InvalidFieldsError
512+ require.ErrorAs(t, err, &fe)
513+ require.Equal(t, "cron", fe.Fields[0].Field)
514+ // PutRepoConfig/PutStatus unexpected: strict mock fails if called.
515+}
516+
517+func TestMirrorsGlobalChangeInvalidatingOverrideRejected(t *testing.T) {
518+ f := setupRepoOverrideFixture(t, domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"})
519+ f.store.ExpectedCalls = nil
520+ f.store.On("GetGlobal", mock.Anything).Return(domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}, nil)
521+ f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"}, nil)
522+ // Disabling the global with an empty cron leaves the inheriting override with no cron.
523+ _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Enabled: ptr(false), Cron: ptr("")})
524+ var fe *domain.InvalidFieldsError
525+ require.ErrorAs(t, err, &fe)
526+}
527+
528+func TestMirrorsGlobalChangeResetsInheritingURLOverride(t *testing.T) {
529+ f := newMirrorFixture(t)
530+ f.store.ExpectedCalls = nil
531+ f.store.On("GetGlobal", mock.Anything).Return(domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}, nil)
532+ f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"}, nil)
533+ f.store.On("PutGlobal", mock.Anything, mock.Anything).Return(nil).Once()
534+ f.store.On("GetStatus", mock.Anything, f.repo).Return(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged}, nil)
535+ reset := make(chan domain.MirrorStatus, 1)
536+ f.store.On("PutStatus", mock.Anything, mock.Anything).Run(func(a mock.Arguments) { reset <- a.Get(1).(domain.MirrorStatus) }).Return(nil)
537+ _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")})
538+ require.NoError(t, err)
539+ require.Equal(t, domain.MirrorOK, (<-reset).State)
540+}
541+
542+func TestMirrorsSetRepoExcludedConcurrent(t *testing.T) {
543+ f := newMirrorFixture(t)
544+ var mu sync.Mutex
545+ active, maxActive := 0, 0
546+ f.store.On("PutRepoConfig", mock.Anything, mock.Anything).Run(func(mock.Arguments) {
547+ mu.Lock()
548+ active++
549+ if active > maxActive {
550+ maxActive = active
551+ }
552+ mu.Unlock()
553+ time.Sleep(2 * time.Millisecond)
554+ mu.Lock()
555+ active--
556+ mu.Unlock()
557+ }).Return(nil)
558+ var wg sync.WaitGroup
559+ for i := 0; i < 8; i++ {
560+ wg.Add(1)
561+ go func(ex bool) {
562+ defer wg.Done()
563+ _, err := f.m.SetRepoExcluded(context.Background(), domain.Root(), f.repo, ex)
564+ require.NoError(t, err)
565+ }(i%2 == 0)
566+ }
567+ wg.Wait()
568+ require.Equal(t, 1, maxActive, "read-modify-write must be serialized")
569+}
570+
571+func TestMirrorsCoalescedManualAppliesThrottle(t *testing.T) {
572+ f := newMirrorFixture(t)
573+ entered, release := make(chan struct{}), make(chan struct{})
574+ f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(entered); <-release }).Return(ports.MirrorPushResult{}, nil).Once()
575+ done := make(chan error, 1)
576+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
577+ <-entered
578+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
579+ require.NoError(t, err)
580+ require.True(t, v.Queued)
581+ close(release)
582+ require.NoError(t, <-done)
583+ // The rerun is manual and not forced: the throttle (just attempted) stops it, so no second push.
584+ time.Sleep(200 * time.Millisecond)
585+ f.push.AssertNumberOfCalls(t, "Push", 1)
586+}
587+
588+func TestMirrorsSlowPushTimestamps(t *testing.T) {
589+ f := newMirrorFixture(t)
590+ start := f.at
591+ f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { f.advance(10 * time.Minute) }).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Once()
592+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
593+ require.Error(t, err)
594+ require.True(t, v.Status.LastAttemptAt.Equal(start))
595+ require.True(t, v.Status.NextRetryAt.Equal(start.Add(10*time.Minute+domain.MirrorBackoff(1))))
596+}
@@ -432,3 +432,165 @@ func TestMirrorsFileURLTestOption(t *testing.T) {
432 })432 })
433 }433 }
434 }434 }
435+
436+func TestMirrorsMergeNotifiesMirror(t *testing.T) {
437+ r := newMRRig(t)
438+ r.seed()
439+ trigger := mocks.NewMirrorTrigger(t)
440+ notified := make(chan []domain.RefUpdate, 1)
441+ trigger.On("OnPush", mock.Anything, r.repo, mock.Anything).Run(func(a mock.Arguments) { notified <- a.Get(2).([]domain.RefUpdate) }).Return().Once()
442+ r.mrs.WithMirror(trigger)
443+ head := r.feature("f0")
444+ mr := r.open("f0")
445+ _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head})
446+ require.NoError(t, err)
447+ r.mrs.Wait(r.ctx)
448+ select {
449+ case u := <-notified:
450+ require.Equal(t, "refs/heads/main", u[0].Ref)
451+ case <-time.After(time.Second):
452+ t.Fatal("merge did not notify mirrors")
453+ }
454+}
455+
456+func TestMirrorsDefaultBranchChangeNotifiesMirror(t *testing.T) {
457+ f := newFixture(t)
458+ ctx := context.Background()
459+ n := name(t, "tools")
460+ repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
461+ trigger := mocks.NewMirrorTrigger(t)
462+ trigger.On("OnPush", mock.Anything, n, mock.Anything).Return().Once()
463+ f.repos.WithMirror(trigger)
464+ f.meta.EXPECT().Get(ctx, n).Return(repo, nil)
465+ f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
466+ f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(nil)
467+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
468+ _, err := f.repos.Update(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n, ports.UpdateRepoParams{DefaultBranch: ptr("dev")})
469+ require.NoError(t, err)
470+}
471+
472+func TestMirrorsExternalMutationMakesNextDueTickSync(t *testing.T) {
473+ f := newMirrorFixture(t)
474+ f.push.On("Push", mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once()
475+ _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
476+ require.NoError(t, err)
477+ f.mu.Lock()
478+ f.at = f.at.Add(24 * time.Hour).Truncate(time.Hour).Add(time.Minute)
479+ f.mu.Unlock()
480+ f.m.ScheduleNow(context.Background())
481+ f.advance(4 * time.Minute)
482+ f.m.ScheduleNow(context.Background()) // due and clean: skipped
483+ f.reader.AssertNumberOfCalls(t, "ListRefs", 1)
484+ // A merge and a default-branch change both call OnPush (idempotent dirty mark).
485+ f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "refs/heads/main"}})
486+ f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "HEAD"}})
487+ read := make(chan struct{})
488+ f.reader.ExpectedCalls = nil
489+ f.reader.On("ListRefs", mock.Anything, f.repo).Run(func(mock.Arguments) { close(read) }).Return([]domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}}, nil).Once()
490+ f.advance(5 * time.Minute)
491+ f.m.ScheduleNow(context.Background())
492+ select {
493+ case <-read:
494+ case <-time.After(time.Second):
495+ t.Fatal("dirty repository not synced on the next due tick")
496+ }
497+}
498+
499+func setupRepoOverrideFixture(t *testing.T, global domain.MirrorGlobalConfig) *mirrorFixture {
500+ f := newMirrorFixture(t)
501+ f.store.ExpectedCalls = nil
502+ f.store.On("GetGlobal", mock.Anything).Return(global, nil)
503+ f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{}, domain.ErrNotFound).Maybe()
504+ f.store.On("GetStatus", mock.Anything, f.repo).Return(domain.MirrorStatus{Repo: f.repo}, nil).Maybe()
505+ return f
506+}
507+
508+func TestMirrorsOverrideWithoutCronRejectedWhenGlobalCronEmpty(t *testing.T) {
509+ f := setupRepoOverrideFixture(t, domain.MirrorGlobalConfig{})
510+ _, err := f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools"})
511+ var fe *domain.InvalidFieldsError
512+ require.ErrorAs(t, err, &fe)
513+ require.Equal(t, "cron", fe.Fields[0].Field)
514+ // PutRepoConfig/PutStatus unexpected: strict mock fails if called.
515+}
516+
517+func TestMirrorsGlobalChangeInvalidatingOverrideRejected(t *testing.T) {
518+ f := setupRepoOverrideFixture(t, domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"})
519+ f.store.ExpectedCalls = nil
520+ f.store.On("GetGlobal", mock.Anything).Return(domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}, nil)
521+ f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"}, nil)
522+ // Disabling the global with an empty cron leaves the inheriting override with no cron.
523+ _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Enabled: ptr(false), Cron: ptr("")})
524+ var fe *domain.InvalidFieldsError
525+ require.ErrorAs(t, err, &fe)
526+}
527+
528+func TestMirrorsGlobalChangeResetsInheritingURLOverride(t *testing.T) {
529+ f := newMirrorFixture(t)
530+ f.store.ExpectedCalls = nil
531+ f.store.On("GetGlobal", mock.Anything).Return(domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"}, nil)
532+ f.store.On("GetRepoConfig", mock.Anything, f.repo).Return(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"}, nil)
533+ f.store.On("PutGlobal", mock.Anything, mock.Anything).Return(nil).Once()
534+ f.store.On("GetStatus", mock.Anything, f.repo).Return(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged}, nil)
535+ reset := make(chan domain.MirrorStatus, 1)
536+ f.store.On("PutStatus", mock.Anything, mock.Anything).Run(func(a mock.Arguments) { reset <- a.Get(1).(domain.MirrorStatus) }).Return(nil)
537+ _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")})
538+ require.NoError(t, err)
539+ require.Equal(t, domain.MirrorOK, (<-reset).State)
540+}
541+
542+func TestMirrorsSetRepoExcludedConcurrent(t *testing.T) {
543+ f := newMirrorFixture(t)
544+ var mu sync.Mutex
545+ active, maxActive := 0, 0
546+ f.store.On("PutRepoConfig", mock.Anything, mock.Anything).Run(func(mock.Arguments) {
547+ mu.Lock()
548+ active++
549+ if active > maxActive {
550+ maxActive = active
551+ }
552+ mu.Unlock()
553+ time.Sleep(2 * time.Millisecond)
554+ mu.Lock()
555+ active--
556+ mu.Unlock()
557+ }).Return(nil)
558+ var wg sync.WaitGroup
559+ for i := 0; i < 8; i++ {
560+ wg.Add(1)
561+ go func(ex bool) {
562+ defer wg.Done()
563+ _, err := f.m.SetRepoExcluded(context.Background(), domain.Root(), f.repo, ex)
564+ require.NoError(t, err)
565+ }(i%2 == 0)
566+ }
567+ wg.Wait()
568+ require.Equal(t, 1, maxActive, "read-modify-write must be serialized")
569+}
570+
571+func TestMirrorsCoalescedManualAppliesThrottle(t *testing.T) {
572+ f := newMirrorFixture(t)
573+ entered, release := make(chan struct{}), make(chan struct{})
574+ f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { close(entered); <-release }).Return(ports.MirrorPushResult{}, nil).Once()
575+ done := make(chan error, 1)
576+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
577+ <-entered
578+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
579+ require.NoError(t, err)
580+ require.True(t, v.Queued)
581+ close(release)
582+ require.NoError(t, <-done)
583+ // The rerun is manual and not forced: the throttle (just attempted) stops it, so no second push.
584+ time.Sleep(200 * time.Millisecond)
585+ f.push.AssertNumberOfCalls(t, "Push", 1)
586+}
587+
588+func TestMirrorsSlowPushTimestamps(t *testing.T) {
589+ f := newMirrorFixture(t)
590+ start := f.at
591+ f.push.On("Push", mock.Anything, mock.Anything).Run(func(mock.Arguments) { f.advance(10 * time.Minute) }).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Once()
592+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
593+ require.Error(t, err)
594+ require.True(t, v.Status.LastAttemptAt.Equal(start))
595+ require.True(t, v.Status.NextRetryAt.Equal(start.Add(10*time.Minute+domain.MirrorBackoff(1))))
596+}
M internal/usecase/repos.go +13 -5
@@ -17,11 +17,12 @@ import (
1717
1818 // Repos are the repository use cases.
1919 type Repos struct {
20- meta ports.RepoMetaStore
21- git ports.GitRepos
22- bus ports.EventBus
23- clock ports.Clock
24- ci ports.CIRunCleaner // nil: no CI logs to remove
20+ meta ports.RepoMetaStore
21+ git ports.GitRepos
22+ bus ports.EventBus
23+ clock ports.Clock
24+ ci ports.CIRunCleaner // nil: no CI logs to remove
25+ mirror ports.MirrorTrigger // nil: no outbound mirror
2526 }
2627
2728 var _ ports.RepoService = (*Repos)(nil)
@@ -32,6 +33,13 @@ func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, bus ports.EventBus,
3233 return &Repos{meta: meta, git: git, bus: bus, clock: clock}
3334 }
3435
36+// WithMirror makes a default-branch change notify outbound mirrors. Call it
37+// before the first request; it returns r.
38+func (r *Repos) WithMirror(t ports.MirrorTrigger) *Repos {
39+ r.mirror = t
40+ return r
41+}
42+
3543 // WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it
3644 // before the first request; it returns r.
3745 func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos {
@@ -17,11 +17,12 @@ import (
17 17
18 // Repos are the repository use cases.18 // Repos are the repository use cases.
19 type Repos struct {19 type Repos struct {
20- meta ports.RepoMetaStore20+ meta ports.RepoMetaStore
21- git ports.GitRepos21+ git ports.GitRepos
22- bus ports.EventBus22+ bus ports.EventBus
23- clock ports.Clock23+ clock ports.Clock
24- ci ports.CIRunCleaner // nil: no CI logs to remove24+ ci ports.CIRunCleaner // nil: no CI logs to remove
25+ mirror ports.MirrorTrigger // nil: no outbound mirror
25 }26 }
26 27
27 var _ ports.RepoService = (*Repos)(nil)28 var _ ports.RepoService = (*Repos)(nil)
@@ -32,6 +33,13 @@ func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, bus ports.EventBus,
32 return &Repos{meta: meta, git: git, bus: bus, clock: clock}33 return &Repos{meta: meta, git: git, bus: bus, clock: clock}
33 }34 }
34 35
36+// WithMirror makes a default-branch change notify outbound mirrors. Call it
37+// before the first request; it returns r.
38+func (r *Repos) WithMirror(t ports.MirrorTrigger) *Repos {
39+ r.mirror = t
40+ return r
41+}
42+
35 // WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it43 // WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it
36 // before the first request; it returns r.44 // before the first request; it returns r.
37 func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos {45 func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos {
M internal/usecase/repos_write.go +3 -0
@@ -129,6 +129,9 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
129129 if !metaDone {
130130 return domain.Repo{}, headErr
131131 }
132+ } else if r.mirror != nil {
133+ // The default branch decides which refs are mirrored.
134+ r.mirror.OnPush(ctx, name, []domain.RefUpdate{{Ref: "HEAD"}})
132135 }
133136 }
134137
@@ -129,6 +129,9 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
129 if !metaDone {129 if !metaDone {
130 return domain.Repo{}, headErr130 return domain.Repo{}, headErr
131 }131 }
132+ } else if r.mirror != nil {
133+ // The default branch decides which refs are mirrored.
134+ r.mirror.OnPush(ctx, name, []domain.RefUpdate{{Ref: "HEAD"}})
132 }135 }
133 }136 }
134 137

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