detached 6793112

fix(mirror): skip non-UTF-8 ref names, keep SyncAll errors to the failure class and document the Wait barrier

commit
67931125ad0be91134792f0d0362f76e8b69f022
author
brice · · 2026-10-20 07:24 UTC
committer
brice · · 2026-10-20 10:02 UTC
parent
1427755
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +12 -9
M internal/adapters/rpc/mirror.go +3 -3
@@ -146,12 +146,12 @@ func (s *mirrorService) SyncMirror(ctx context.Context, req *connect.Request[pb.
146146 return connect.NewResponse(&pb.SyncMirrorResponse{Results: out}), nil
147147 }
148148
149-// syncErrorText is the human reason a repository failed in SyncAll. The caller is an owner;
150-// a mirror error is already redacted by the transport and carries no URL.
149+// syncErrorText is the short reason a repository failed in SyncAll: the failure class only.
150+// The remote's output stays in GetMirrorStatus, so the response stays small and URL-free.
151151 func syncErrorText(err error) string {
152152 var me *domain.MirrorError
153153 if errors.As(err, &me) {
154- return "mirror " + string(me.Class) + ": " + me.Detail
154+ return "mirror " + string(me.Class) + ": see ermite mirror status"
155155 }
156156 switch {
157157 case errors.Is(err, domain.ErrResourceExhausted):
@@ -146,12 +146,12 @@ func (s *mirrorService) SyncMirror(ctx context.Context, req *connect.Request[pb.
146 return connect.NewResponse(&pb.SyncMirrorResponse{Results: out}), nil146 return connect.NewResponse(&pb.SyncMirrorResponse{Results: out}), nil
147 }147 }
148 148
149-// syncErrorText is the human reason a repository failed in SyncAll. The caller is an owner;149+// syncErrorText is the short reason a repository failed in SyncAll: the failure class only.
150-// a mirror error is already redacted by the transport and carries no URL.150+// The remote's output stays in GetMirrorStatus, so the response stays small and URL-free.
151 func syncErrorText(err error) string {151 func syncErrorText(err error) string {
152 var me *domain.MirrorError152 var me *domain.MirrorError
153 if errors.As(err, &me) {153 if errors.As(err, &me) {
154- return "mirror " + string(me.Class) + ": " + me.Detail154+ return "mirror " + string(me.Class) + ": see ermite mirror status"
155 }155 }
156 switch {156 switch {
157 case errors.Is(err, domain.ErrResourceExhausted):157 case errors.Is(err, domain.ErrResourceExhausted):
M internal/adapters/rpc/mirror_test.go +1 -1
@@ -85,7 +85,7 @@ func TestMirrorSyncAllReportsPerRepositoryErrors(t *testing.T) {
8585 require.NoError(t, err)
8686 require.Len(t, res.Msg.Results, 2)
8787 assert.Empty(t, res.Msg.Results[0].Error)
88- assert.Equal(t, "mirror diverged: rejected", res.Msg.Results[1].Error)
88+ assert.Equal(t, "mirror diverged: see ermite mirror status", res.Msg.Results[1].Error)
8989 assert.Equal(t, pb.MirrorState_MIRROR_STATE_PAUSED, res.Msg.Results[1].Status.State)
9090 }
9191
@@ -85,7 +85,7 @@ func TestMirrorSyncAllReportsPerRepositoryErrors(t *testing.T) {
85 require.NoError(t, err)85 require.NoError(t, err)
86 require.Len(t, res.Msg.Results, 2)86 require.Len(t, res.Msg.Results, 2)
87 assert.Empty(t, res.Msg.Results[0].Error)87 assert.Empty(t, res.Msg.Results[0].Error)
88- assert.Equal(t, "mirror diverged: rejected", res.Msg.Results[1].Error)88+ assert.Equal(t, "mirror diverged: see ermite mirror status", res.Msg.Results[1].Error)
89 assert.Equal(t, pb.MirrorState_MIRROR_STATE_PAUSED, res.Msg.Results[1].Status.State)89 assert.Equal(t, pb.MirrorState_MIRROR_STATE_PAUSED, res.Msg.Results[1].Status.State)
90 }90 }
91 91
M internal/domain/mirror_refs.go +3 -1
@@ -6,6 +6,7 @@ import (
66 "sort"
77 "strings"
88 "time"
9+ "unicode/utf8"
910 )
1011
1112 type MirrorRef struct {
@@ -23,11 +24,12 @@ type MirrorSnapshot struct {
2324 // MirrorRefspecs selects refs/heads/<default> (or every refs/heads/* with allBranches) and
2425 // refs/tags/*, from the refs of RepoReader.ListRefs (HEAD skipped, unborn default skipped).
2526 // Names are plain refs; the caller builds "<ref>:<ref>" (never "+", never a delete).
27+// Refs whose name is not valid UTF-8 are skipped: they cannot be stored in the snapshot.
2628 // Returned sorted by ref name.
2729 func MirrorRefspecs(refs []Ref, defaultBranch string, allBranches bool) []MirrorRef {
2830 var out []MirrorRef
2931 for _, r := range refs {
30- if r.ID.IsZero() || r.ID.String() == ZeroOID {
32+ if r.ID.IsZero() || r.ID.String() == ZeroOID || !utf8.ValidString(r.Name) {
3133 continue
3234 }
3335 if strings.HasPrefix(r.Name, "refs/tags/") || strings.HasPrefix(r.Name, "refs/heads/") && (allBranches || r.Name == "refs/heads/"+defaultBranch) {
@@ -6,6 +6,7 @@ import (
6 "sort"6 "sort"
7 "strings"7 "strings"
8 "time"8 "time"
9+ "unicode/utf8"
9 )10 )
10 11
11 type MirrorRef struct {12 type MirrorRef struct {
@@ -23,11 +24,12 @@ type MirrorSnapshot struct {
23 // MirrorRefspecs selects refs/heads/<default> (or every refs/heads/* with allBranches) and24 // MirrorRefspecs selects refs/heads/<default> (or every refs/heads/* with allBranches) and
24 // refs/tags/*, from the refs of RepoReader.ListRefs (HEAD skipped, unborn default skipped).25 // refs/tags/*, from the refs of RepoReader.ListRefs (HEAD skipped, unborn default skipped).
25 // Names are plain refs; the caller builds "<ref>:<ref>" (never "+", never a delete).26 // Names are plain refs; the caller builds "<ref>:<ref>" (never "+", never a delete).
27+// Refs whose name is not valid UTF-8 are skipped: they cannot be stored in the snapshot.
26 // Returned sorted by ref name.28 // Returned sorted by ref name.
27 func MirrorRefspecs(refs []Ref, defaultBranch string, allBranches bool) []MirrorRef {29 func MirrorRefspecs(refs []Ref, defaultBranch string, allBranches bool) []MirrorRef {
28 var out []MirrorRef30 var out []MirrorRef
29 for _, r := range refs {31 for _, r := range refs {
30- if r.ID.IsZero() || r.ID.String() == ZeroOID {32+ if r.ID.IsZero() || r.ID.String() == ZeroOID || !utf8.ValidString(r.Name) {
31 continue33 continue
32 }34 }
33 if strings.HasPrefix(r.Name, "refs/tags/") || strings.HasPrefix(r.Name, "refs/heads/") && (allBranches || r.Name == "refs/heads/"+defaultBranch) {35 if strings.HasPrefix(r.Name, "refs/tags/") || strings.HasPrefix(r.Name, "refs/heads/") && (allBranches || r.Name == "refs/heads/"+defaultBranch) {
M internal/domain/mirror_test.go +1 -1
@@ -240,7 +240,7 @@ func TestMirrorRecoveryFromAnyState(t *testing.T) {
240240 }
241241 func TestMirrorRefs(t *testing.T) {
242242 id, _ := ParseObjectID(strings.Repeat("a", 40))
243- refs := []Ref{{Name: "HEAD", ID: id}, {Name: "refs/heads/main", ID: id}, {Name: "refs/heads/other", ID: id}, {Name: "refs/tags/v1", ID: id}, {Name: "refs/pull/1", ID: id}, {Name: "refs/heads/unborn"}}
243+ refs := []Ref{{Name: "HEAD", ID: id}, {Name: "refs/heads/main", ID: id}, {Name: "refs/heads/other", ID: id}, {Name: "refs/tags/v1", ID: id}, {Name: "refs/pull/1", ID: id}, {Name: "refs/heads/unborn"}, {Name: "refs/tags/\xff", ID: id}, {Name: "refs/heads/\xff", ID: id}}
244244 for _, all := range []bool{false, true} {
245245 got := MirrorRefspecs(refs, "main", all)
246246 n := 2
@@ -240,7 +240,7 @@ func TestMirrorRecoveryFromAnyState(t *testing.T) {
240 }240 }
241 func TestMirrorRefs(t *testing.T) {241 func TestMirrorRefs(t *testing.T) {
242 id, _ := ParseObjectID(strings.Repeat("a", 40))242 id, _ := ParseObjectID(strings.Repeat("a", 40))
243- refs := []Ref{{Name: "HEAD", ID: id}, {Name: "refs/heads/main", ID: id}, {Name: "refs/heads/other", ID: id}, {Name: "refs/tags/v1", ID: id}, {Name: "refs/pull/1", ID: id}, {Name: "refs/heads/unborn"}}243+ refs := []Ref{{Name: "HEAD", ID: id}, {Name: "refs/heads/main", ID: id}, {Name: "refs/heads/other", ID: id}, {Name: "refs/tags/v1", ID: id}, {Name: "refs/pull/1", ID: id}, {Name: "refs/heads/unborn"}, {Name: "refs/tags/\xff", ID: id}, {Name: "refs/heads/\xff", ID: id}}
244 for _, all := range []bool{false, true} {244 for _, all := range []bool{false, true} {
245 got := MirrorRefspecs(refs, "main", all)245 got := MirrorRefspecs(refs, "main", all)
246 n := 2246 n := 2
M internal/usecase/mirrors_sync.go +4 -3
@@ -97,8 +97,8 @@ func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) (
9797 return out, nil
9898 }
9999
100-// Wait blocks until no sync is queued or running, or ctx is done. It is for tests and shutdown
101-// paths that need a deterministic barrier.
100+// Wait blocks until no sync is queued or running, or ctx is done. It is a test barrier: call it
101+// only while nothing enqueues new syncs and before Close (queued jobs are not drained by Close).
102102 func (m *Mirrors) Wait(ctx context.Context) {
103103 done := make(chan struct{})
104104 go func() { m.flightWG.Wait(); close(done) }()
@@ -254,7 +254,8 @@ func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, req mirrorR
254254 }
255255
256256 // record stores the outcome, unless the effective target changed during the push: the result
257-// then describes a target that no longer applies and is discarded.
257+// then describes a target that no longer applies and is discarded. The comparison includes the
258+// cron on purpose, like resetIfChanged: a schedule change during a push costs one extra push.
258259 func (m *Mirrors) record(ctx context.Context, repo domain.RepoName, pl mirrorPlan, selected []domain.MirrorRef, noOp bool, failure *domain.MirrorError, runErr error) (ports.MirrorSyncResult, error) {
259260 m.configMu.RLock()
260261 defer m.configMu.RUnlock()
@@ -97,8 +97,8 @@ func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) (
97 return out, nil97 return out, nil
98 }98 }
99 99
100-// Wait blocks until no sync is queued or running, or ctx is done. It is for tests and shutdown100+// Wait blocks until no sync is queued or running, or ctx is done. It is a test barrier: call it
101-// paths that need a deterministic barrier.101+// only while nothing enqueues new syncs and before Close (queued jobs are not drained by Close).
102 func (m *Mirrors) Wait(ctx context.Context) {102 func (m *Mirrors) Wait(ctx context.Context) {
103 done := make(chan struct{})103 done := make(chan struct{})
104 go func() { m.flightWG.Wait(); close(done) }()104 go func() { m.flightWG.Wait(); close(done) }()
@@ -254,7 +254,8 @@ func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, req mirrorR
254 }254 }
255 255
256 // record stores the outcome, unless the effective target changed during the push: the result256 // record stores the outcome, unless the effective target changed during the push: the result
257-// then describes a target that no longer applies and is discarded.257+// then describes a target that no longer applies and is discarded. The comparison includes the
258+// cron on purpose, like resetIfChanged: a schedule change during a push costs one extra push.
258 func (m *Mirrors) record(ctx context.Context, repo domain.RepoName, pl mirrorPlan, selected []domain.MirrorRef, noOp bool, failure *domain.MirrorError, runErr error) (ports.MirrorSyncResult, error) {259 func (m *Mirrors) record(ctx context.Context, repo domain.RepoName, pl mirrorPlan, selected []domain.MirrorRef, noOp bool, failure *domain.MirrorError, runErr error) (ports.MirrorSyncResult, error) {
259 m.configMu.RLock()260 m.configMu.RLock()
260 defer m.configMu.RUnlock()261 defer m.configMu.RUnlock()

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 44

↑↓ moveenter openesc close