detached 5cb495b

fix(mr): read squash-merged requests whose commits were pruned and check branches under the merge lock

commit
5cb495bb00891ca123b2e2888314681782f47886
author
brice · · 2026-10-11 05:53 UTC
parent
4ea27ba
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +60 -9
M internal/app/mergerequests.go +20 -9
@@ -188,21 +188,21 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
188188 if err != nil {
189189 v.AddErr("description", err)
190190 }
191- source, target, err := m.checkBranches(ctx, name, params, &v)
191+ // Under the lock, so that two requests for the same pair cannot both pass
192+ // the check for an existing one, and a merge that deletes its source
193+ // cannot remove a branch between its check here and the creation.
194+ unlock, err := m.lockRepo(ctx, name)
192195 if err != nil {
193196 return domain.MergeRequest{}, err
194197 }
195- if err := v.Err(); err != nil {
198+ defer unlock()
199+ source, target, err := m.checkBranches(ctx, name, params, &v)
200+ if err != nil {
196201 return domain.MergeRequest{}, err
197202 }
198-
199- // Under the lock, so that two requests for the same pair cannot both pass
200- // the check for an existing one.
201- unlock, err := m.lockRepo(ctx, name)
202- if err != nil {
203+ if err := v.Err(); err != nil {
203204 return domain.MergeRequest{}, err
204205 }
205- defer unlock()
206206 existing, err := m.store.List(ctx, name)
207207 if err != nil {
208208 return domain.MergeRequest{}, fmt.Errorf("list merge requests of %q: %w", name, err)
@@ -414,6 +414,16 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
414414 if head.IsZero() {
415415 return d, nil // the source branch is gone: no commits, no diff
416416 }
417+ if !mr.IsOpen() {
418+ // After a squash the reviewed commits are only reachable from the
419+ // source branch; once it is deleted, gc may prune them.
420+ switch ok, err := m.git.HasCommit(ctx, name, head); {
421+ case err != nil:
422+ return MergeRequestDetail{}, err
423+ case !ok:
424+ return d, nil // the reviewed commits are gone: no commits, no diff
425+ }
426+ }
417427 if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil {
418428 return MergeRequestDetail{}, err
419429 }
@@ -860,7 +870,8 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
860870
861871 // checkDeletable refuses to delete the source branch of mr when it is the
862872 // default branch or the source or target of another open request. It runs
863-// under the merge lock, which also guards the creation of requests.
873+// under the merge lock, which Create also holds while it checks its branches
874+// and stores the request.
864875 func (m *MergeRequests) checkDeletable(ctx context.Context, name domain.RepoName, mr domain.MergeRequest) error {
865876 def, _, err := m.repos.DefaultBranch(ctx, name)
866877 if err != nil {
@@ -188,21 +188,21 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
188 if err != nil {188 if err != nil {
189 v.AddErr("description", err)189 v.AddErr("description", err)
190 }190 }
191- source, target, err := m.checkBranches(ctx, name, params, &v)191+ // Under the lock, so that two requests for the same pair cannot both pass
192+ // the check for an existing one, and a merge that deletes its source
193+ // cannot remove a branch between its check here and the creation.
194+ unlock, err := m.lockRepo(ctx, name)
192 if err != nil {195 if err != nil {
193 return domain.MergeRequest{}, err196 return domain.MergeRequest{}, err
194 }197 }
195- if err := v.Err(); err != nil {198+ defer unlock()
199+ source, target, err := m.checkBranches(ctx, name, params, &v)
200+ if err != nil {
196 return domain.MergeRequest{}, err201 return domain.MergeRequest{}, err
197 }202 }
198-203+ if err := v.Err(); err != nil {
199- // Under the lock, so that two requests for the same pair cannot both pass
200- // the check for an existing one.
201- unlock, err := m.lockRepo(ctx, name)
202- if err != nil {
203 return domain.MergeRequest{}, err204 return domain.MergeRequest{}, err
204 }205 }
205- defer unlock()
206 existing, err := m.store.List(ctx, name)206 existing, err := m.store.List(ctx, name)
207 if err != nil {207 if err != nil {
208 return domain.MergeRequest{}, fmt.Errorf("list merge requests of %q: %w", name, err)208 return domain.MergeRequest{}, fmt.Errorf("list merge requests of %q: %w", name, err)
@@ -414,6 +414,16 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
414 if head.IsZero() {414 if head.IsZero() {
415 return d, nil // the source branch is gone: no commits, no diff415 return d, nil // the source branch is gone: no commits, no diff
416 }416 }
417+ if !mr.IsOpen() {
418+ // After a squash the reviewed commits are only reachable from the
419+ // source branch; once it is deleted, gc may prune them.
420+ switch ok, err := m.git.HasCommit(ctx, name, head); {
421+ case err != nil:
422+ return MergeRequestDetail{}, err
423+ case !ok:
424+ return d, nil // the reviewed commits are gone: no commits, no diff
425+ }
426+ }
417 if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil {427 if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil {
418 return MergeRequestDetail{}, err428 return MergeRequestDetail{}, err
419 }429 }
@@ -860,7 +870,8 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
860 870
861 // checkDeletable refuses to delete the source branch of mr when it is the871 // checkDeletable refuses to delete the source branch of mr when it is the
862 // default branch or the source or target of another open request. It runs872 // default branch or the source or target of another open request. It runs
863-// under the merge lock, which also guards the creation of requests.873+// under the merge lock, which Create also holds while it checks its branches
874+// and stores the request.
864 func (m *MergeRequests) checkDeletable(ctx context.Context, name domain.RepoName, mr domain.MergeRequest) error {875 func (m *MergeRequests) checkDeletable(ctx context.Context, name domain.RepoName, mr domain.MergeRequest) error {
865 def, _, err := m.repos.DefaultBranch(ctx, name)876 def, _, err := m.repos.DefaultBranch(ctx, name)
866 if err != nil {877 if err != nil {
M internal/app/mergerequests_delete_test.go +40 -0
@@ -112,6 +112,46 @@ func TestMergeRefusesToDeleteANeededBranch(t *testing.T) {
112112 })
113113 }
114114
115+// After a squash with deletion, gc may prune the reviewed commits: the merged
116+// request still reads, without commits or diff.
117+func TestSquashMergedRequestReadsAfterItsCommitsArePruned(t *testing.T) {
118+ r := newMRRig(t)
119+ r.seed()
120+ head := r.feature("feat")
121+ mr := r.open("feat")
122+ res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{Mode: domain.MergeModeSquash, HeadID: head, DeleteSource: true})
123+ require.NoError(t, err)
124+ require.True(t, res.SourceDeleted)
125+
126+ r.run(r.bare(), r.env(author), "reflog", "expire", "--expire=now", "--all")
127+ r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now")
128+ ok, err := r.git.HasCommit(r.ctx, r.repo, head)
129+ require.NoError(t, err)
130+ require.False(t, ok, "the reviewed commits are pruned")
131+
132+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
133+ require.NoError(t, err)
134+ assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)
135+ assert.Equal(t, res.MergedCommitID, d.MergeRequest.MergedCommitID)
136+ assert.Empty(t, d.Commits)
137+ assert.Empty(t, d.Diff.Files)
138+ assert.True(t, d.DiffBaseID.IsZero())
139+}
140+
141+// A request opened on a branch a merge is deleting waits for the merge lock
142+// and then sees the branch is gone.
143+func TestCreateAfterAMergeDeletedTheBranch(t *testing.T) {
144+ r := newMRRig(t)
145+ r.seed()
146+ head := r.feature("feat")
147+ mr := r.open("feat")
148+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, DeleteSource: true})
149+ require.NoError(t, err)
150+ _, err = r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "late", Source: "feat"})
151+ require.ErrorIs(t, err, domain.ErrInvalid)
152+ assert.Contains(t, fieldsOf(t, err), "source_branch")
153+}
154+
115155 // movingGit pushes to the source branch between the merge and the deletion.
116156 type movingGit struct {
117157 *gitcli.Git
@@ -112,6 +112,46 @@ func TestMergeRefusesToDeleteANeededBranch(t *testing.T) {
112 })112 })
113 }113 }
114 114
115+// After a squash with deletion, gc may prune the reviewed commits: the merged
116+// request still reads, without commits or diff.
117+func TestSquashMergedRequestReadsAfterItsCommitsArePruned(t *testing.T) {
118+ r := newMRRig(t)
119+ r.seed()
120+ head := r.feature("feat")
121+ mr := r.open("feat")
122+ res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{Mode: domain.MergeModeSquash, HeadID: head, DeleteSource: true})
123+ require.NoError(t, err)
124+ require.True(t, res.SourceDeleted)
125+
126+ r.run(r.bare(), r.env(author), "reflog", "expire", "--expire=now", "--all")
127+ r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now")
128+ ok, err := r.git.HasCommit(r.ctx, r.repo, head)
129+ require.NoError(t, err)
130+ require.False(t, ok, "the reviewed commits are pruned")
131+
132+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
133+ require.NoError(t, err)
134+ assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)
135+ assert.Equal(t, res.MergedCommitID, d.MergeRequest.MergedCommitID)
136+ assert.Empty(t, d.Commits)
137+ assert.Empty(t, d.Diff.Files)
138+ assert.True(t, d.DiffBaseID.IsZero())
139+}
140+
141+// A request opened on a branch a merge is deleting waits for the merge lock
142+// and then sees the branch is gone.
143+func TestCreateAfterAMergeDeletedTheBranch(t *testing.T) {
144+ r := newMRRig(t)
145+ r.seed()
146+ head := r.feature("feat")
147+ mr := r.open("feat")
148+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, DeleteSource: true})
149+ require.NoError(t, err)
150+ _, err = r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "late", Source: "feat"})
151+ require.ErrorIs(t, err, domain.ErrInvalid)
152+ assert.Contains(t, fieldsOf(t, err), "source_branch")
153+}
154+
115 // movingGit pushes to the source branch between the merge and the deletion.155 // movingGit pushes to the source branch between the merge and the deletion.
116 type movingGit struct {156 type movingGit struct {
117 *gitcli.Git157 *gitcli.Git

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