detached 13c62e2

fix(app): publish repo updates when only the HEAD move fails

commit
13c62e2a795eb2c5adf050232b53640c8691fe3b
author
brice · · 2026-10-10 11:41 UTC
parent
19ceb08
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +34 -9
M internal/app/repos_write.go +17 -6
@@ -64,7 +64,8 @@ type UpdateParams struct {
6464 // else domain.ErrInvalid. A default branch that does not exist gives
6565 // domain.ErrPreconditionFailed; nothing is written before every check passed.
6666 // The metadata is written first, then HEAD, and RepoUpdated is published after
67-// both (see the comment on the writes for the partial state).
67+// both. When only the HEAD move fails, RepoUpdated is still published for the
68+// stored metadata and the HEAD error is returned.
6869 func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.RepoName, params UpdateParams) (domain.Repo, error) {
6970 repo, err := lookupRepo(ctx, r.meta, name)
7071 if err != nil {
@@ -101,18 +102,25 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
101102 }
102103
103104 // Metadata first, then HEAD. The two writes are not atomic: when moving
104- // HEAD fails after the metadata was stored, the call fails with the
105- // description and visibility already changed, and no RepoUpdated is
106- // published for that partial state. The branch was checked above, so the
107- // remaining causes are a vanished repository or an I/O failure.
105+ // HEAD fails after the metadata was stored, the stored change is still
106+ // published as RepoUpdated (a repository made private must be withdrawn
107+ // from the watchers) and the call then fails with the HEAD error. The
108+ // branch was checked above, so the remaining causes are a vanished
109+ // repository or an I/O failure.
110+ metaDone := false
108111 if params.Description != nil || params.Visibility != nil {
109112 if repo, err = r.updateMeta(ctx, name, params); err != nil {
110113 return domain.Repo{}, err
111114 }
115+ metaDone = true
112116 }
117+ var headErr error
113118 if params.DefaultBranch != nil {
114119 if err := r.git.SetDefaultBranch(ctx, name, *params.DefaultBranch); err != nil {
115- return domain.Repo{}, fmt.Errorf("set default branch of %q: %w", name, err)
120+ headErr = fmt.Errorf("set default branch of %q: %w", name, err)
121+ if !metaDone {
122+ return domain.Repo{}, headErr
123+ }
116124 }
117125 }
118126
@@ -128,6 +136,9 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
128136 Repo: name, At: r.clock.Now().UTC(),
129137 Visibility: repo.Visibility, Previous: before.Visibility, CreatedAt: repo.CreatedAt,
130138 })
139+ if headErr != nil {
140+ return domain.Repo{}, headErr
141+ }
131142 return repo, nil
132143 }
133144
@@ -64,7 +64,8 @@ type UpdateParams struct {
64 // else domain.ErrInvalid. A default branch that does not exist gives64 // else domain.ErrInvalid. A default branch that does not exist gives
65 // domain.ErrPreconditionFailed; nothing is written before every check passed.65 // domain.ErrPreconditionFailed; nothing is written before every check passed.
66 // The metadata is written first, then HEAD, and RepoUpdated is published after66 // The metadata is written first, then HEAD, and RepoUpdated is published after
67-// both (see the comment on the writes for the partial state).67+// both. When only the HEAD move fails, RepoUpdated is still published for the
68+// stored metadata and the HEAD error is returned.
68 func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.RepoName, params UpdateParams) (domain.Repo, error) {69 func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.RepoName, params UpdateParams) (domain.Repo, error) {
69 repo, err := lookupRepo(ctx, r.meta, name)70 repo, err := lookupRepo(ctx, r.meta, name)
70 if err != nil {71 if err != nil {
@@ -101,18 +102,25 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
101 }102 }
102 103
103 // Metadata first, then HEAD. The two writes are not atomic: when moving104 // Metadata first, then HEAD. The two writes are not atomic: when moving
104- // HEAD fails after the metadata was stored, the call fails with the105+ // HEAD fails after the metadata was stored, the stored change is still
105- // description and visibility already changed, and no RepoUpdated is106+ // published as RepoUpdated (a repository made private must be withdrawn
106- // published for that partial state. The branch was checked above, so the107+ // from the watchers) and the call then fails with the HEAD error. The
107- // remaining causes are a vanished repository or an I/O failure.108+ // branch was checked above, so the remaining causes are a vanished
109+ // repository or an I/O failure.
110+ metaDone := false
108 if params.Description != nil || params.Visibility != nil {111 if params.Description != nil || params.Visibility != nil {
109 if repo, err = r.updateMeta(ctx, name, params); err != nil {112 if repo, err = r.updateMeta(ctx, name, params); err != nil {
110 return domain.Repo{}, err113 return domain.Repo{}, err
111 }114 }
115+ metaDone = true
112 }116 }
117+ var headErr error
113 if params.DefaultBranch != nil {118 if params.DefaultBranch != nil {
114 if err := r.git.SetDefaultBranch(ctx, name, *params.DefaultBranch); err != nil {119 if err := r.git.SetDefaultBranch(ctx, name, *params.DefaultBranch); err != nil {
115- return domain.Repo{}, fmt.Errorf("set default branch of %q: %w", name, err)120+ headErr = fmt.Errorf("set default branch of %q: %w", name, err)
121+ if !metaDone {
122+ return domain.Repo{}, headErr
123+ }
116 }124 }
117 }125 }
118 126
@@ -128,6 +136,9 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
128 Repo: name, At: r.clock.Now().UTC(),136 Repo: name, At: r.clock.Now().UTC(),
129 Visibility: repo.Visibility, Previous: before.Visibility, CreatedAt: repo.CreatedAt,137 Visibility: repo.Visibility, Previous: before.Visibility, CreatedAt: repo.CreatedAt,
130 })138 })
139+ if headErr != nil {
140+ return domain.Repo{}, headErr
141+ }
131 return repo, nil142 return repo, nil
132 }143 }
133 144
M internal/app/repos_write_test.go +17 -3
@@ -80,14 +80,28 @@ func TestUpdate(t *testing.T) {
8080 assert.Equal(t, []string{"meta", "head", "event"}, order)
8181 })
8282
83- t.Run("a HEAD that cannot move fails the call and publishes nothing", func(t *testing.T) {
83+ t.Run("a HEAD that cannot move after the metadata was stored still publishes, then fails", func(t *testing.T) {
8484 f := newFixture(t)
8585 boom := errors.New("boom")
86- f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
86+ pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "old"}
87+ f.meta.EXPECT().Get(ctx, n).Return(pub, nil)
8788 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
8889 f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
8990 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
90- _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Description: ptr("x"), DefaultBranch: ptr("dev")})
91+ f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
92+ Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic,
93+ }).Return().Once()
94+ _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Visibility: ptr(domain.VisibilityPrivate), DefaultBranch: ptr("dev")})
95+ assert.ErrorIs(t, err, boom)
96+ })
97+
98+ t.Run("a HEAD that cannot move with no metadata change publishes nothing", func(t *testing.T) {
99+ f := newFixture(t)
100+ boom := errors.New("boom")
101+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
102+ f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
103+ f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
104+ _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{DefaultBranch: ptr("dev")})
91105 assert.ErrorIs(t, err, boom)
92106 })
93107
@@ -80,14 +80,28 @@ func TestUpdate(t *testing.T) {
80 assert.Equal(t, []string{"meta", "head", "event"}, order)80 assert.Equal(t, []string{"meta", "head", "event"}, order)
81 })81 })
82 82
83- t.Run("a HEAD that cannot move fails the call and publishes nothing", func(t *testing.T) {83+ t.Run("a HEAD that cannot move after the metadata was stored still publishes, then fails", func(t *testing.T) {
84 f := newFixture(t)84 f := newFixture(t)
85 boom := errors.New("boom")85 boom := errors.New("boom")
86- f.meta.EXPECT().Get(ctx, n).Return(priv, nil)86+ pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "old"}
87+ f.meta.EXPECT().Get(ctx, n).Return(pub, nil)
87 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)88 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
88 f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)89 f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
89 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)90 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
90- _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Description: ptr("x"), DefaultBranch: ptr("dev")})91+ f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
92+ Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic,
93+ }).Return().Once()
94+ _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Visibility: ptr(domain.VisibilityPrivate), DefaultBranch: ptr("dev")})
95+ assert.ErrorIs(t, err, boom)
96+ })
97+
98+ t.Run("a HEAD that cannot move with no metadata change publishes nothing", func(t *testing.T) {
99+ f := newFixture(t)
100+ boom := errors.New("boom")
101+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
102+ f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
103+ f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
104+ _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{DefaultBranch: ptr("dev")})
91 assert.ErrorIs(t, err, boom)105 assert.ErrorIs(t, err, boom)
92 })106 })
93 107

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 3

Tags 44

↑↓ moveenter openesc close