feat(mr): per-mode merge checks, signed merge commits and side effects after the response
- commit
- f44d044bbcc4f285d342bfad46ff560c5ea3a985
- author
- brice · · 2026-10-19 13:30 UTC
- committer
- brice · · 2026-10-19 14:36 UTC
- parent
- b292b48
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +477 -87
M internal/app/cli_mr.go +6 -9
| @@ -976,10 +976,11 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 976 | 976 | "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" + | |
| 977 | 977 | "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" + | |
| 978 | 978 | "mr:merge scope.\n\n" + | |
| 979 | - | "--delete-branch deletes the source branch after the merge. The merge is refused when the branch\n" + | |
| 980 | - | "is the default branch or used by another open merge request. If the deletion fails after the\n" + | |
| 981 | - | "merge, the merge stands, the branch is kept and a warning goes to standard error." + jsonMRHelp + | |
| 982 | - | "\n--json adds source_branch_deleted (true when the branch was deleted).", | |
| 979 | + | "--delete-branch asks the server to delete the source branch right after the merge, in the\n" + | |
| 980 | + | "background. The merge is refused when the branch is the default branch or used by another open\n" + | |
| 981 | + | "merge request. If the deletion later fails (the branch moved, say), the merge stands and the\n" + | |
| 982 | + | "branch is kept." + jsonMRHelp + | |
| 983 | + | "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).", | |
| 983 | 984 | Args: cobra.ExactArgs(2), | |
| 984 | 985 | ValidArgsFunction: completeMRRepoThenNumber(env), | |
| 985 | 986 | } | |
| @@ -1014,17 +1015,13 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1014 | 1015 | } | |
| 1015 | 1016 | m := res.Msg.GetMergeRequest() | |
| 1016 | 1017 | deleted := res.Msg.GetSourceBranchDeleted() | |
| 1017 | - | if deleteBranch && !deleted { | |
| 1018 | - | _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ermite: warning: the branch %s was kept (it moved after the merge, or the server cannot delete it)\n", | |
| 1019 | - | clean(m.GetSourceBranch())) | |
| 1020 | - | } | |
| 1021 | 1018 | if *asJSON { | |
| 1022 | 1019 | return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted}) | |
| 1023 | 1020 | } | |
| 1024 | 1021 | _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(), | |
| 1025 | 1022 | clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId())) | |
| 1026 | 1023 | if err == nil && deleted { | |
| 1027 | - | _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted branch %s\n", clean(m.GetSourceBranch())) | |
| 1024 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleting branch %s (done by the server after the merge)\n", clean(m.GetSourceBranch())) | |
| 1028 | 1025 | } | |
| 1029 | 1026 | return err | |
| 1030 | 1027 | } | |
| @@ -976,10 +976,11 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||||
| 976 | "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" + | 976 | "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" + | ||
| 977 | "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" + | 977 | "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" + | ||
| 978 | "mr:merge scope.\n\n" + | 978 | "mr:merge scope.\n\n" + | ||
| 979 | - | "--delete-branch deletes the source branch after the merge. The merge is refused when the branch\n" + | 979 | + | "--delete-branch asks the server to delete the source branch right after the merge, in the\n" + |
| 980 | - | "is the default branch or used by another open merge request. If the deletion fails after the\n" + | 980 | + | "background. The merge is refused when the branch is the default branch or used by another open\n" + |
| 981 | - | "merge, the merge stands, the branch is kept and a warning goes to standard error." + jsonMRHelp + | 981 | + | "merge request. If the deletion later fails (the branch moved, say), the merge stands and the\n" + |
| 982 | - | "\n--json adds source_branch_deleted (true when the branch was deleted).", | 982 | + | "branch is kept." + jsonMRHelp + |
| 983 | + | "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).", | |||
| 983 | Args: cobra.ExactArgs(2), | 984 | Args: cobra.ExactArgs(2), | ||
| 984 | ValidArgsFunction: completeMRRepoThenNumber(env), | 985 | ValidArgsFunction: completeMRRepoThenNumber(env), | ||
| 985 | } | 986 | } | ||
| @@ -1014,17 +1015,13 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||||
| 1014 | } | 1015 | } | ||
| 1015 | m := res.Msg.GetMergeRequest() | 1016 | m := res.Msg.GetMergeRequest() | ||
| 1016 | deleted := res.Msg.GetSourceBranchDeleted() | 1017 | deleted := res.Msg.GetSourceBranchDeleted() | ||
| 1017 | - | if deleteBranch && !deleted { | |||
| 1018 | - | _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ermite: warning: the branch %s was kept (it moved after the merge, or the server cannot delete it)\n", | |||
| 1019 | - | clean(m.GetSourceBranch())) | |||
| 1020 | - | } | |||
| 1021 | if *asJSON { | 1018 | if *asJSON { | ||
| 1022 | return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted}) | 1019 | return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted}) | ||
| 1023 | } | 1020 | } | ||
| 1024 | _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(), | 1021 | _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(), | ||
| 1025 | clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId())) | 1022 | clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId())) | ||
| 1026 | if err == nil && deleted { | 1023 | if err == nil && deleted { | ||
| 1027 | - | _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted branch %s\n", clean(m.GetSourceBranch())) | 1024 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleting branch %s (done by the server after the merge)\n", clean(m.GetSourceBranch())) |
| 1028 | } | 1025 | } | ||
| 1029 | return err | 1026 | return err | ||
| 1030 | } | 1027 | } | ||
M internal/app/cli_mr_test.go +3 -3
| @@ -326,7 +326,7 @@ func TestMRMergeDeleteBranch(t *testing.T) { | |||
| 326 | 326 | url := f.serve(t) | |
| 327 | 327 | ||
| 328 | 328 | res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t) | |
| 329 | - | assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleted branch feat/x\n", res.stdout) | |
| 329 | + | assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleting branch feat/x (done by the server after the merge)\n", res.stdout) | |
| 330 | 330 | assert.Empty(t, res.stderr) | |
| 331 | 331 | _, reqs := f.snapshot() | |
| 332 | 332 | assert.True(t, reqs[0].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch()) | |
| @@ -334,11 +334,11 @@ func TestMRMergeDeleteBranch(t *testing.T) { | |||
| 334 | 334 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch", "--json").ok(t) | |
| 335 | 335 | assert.Contains(t, res.stdout, `"source_branch_deleted": true`) | |
| 336 | 336 | ||
| 337 | - | // The merge stands when the branch is kept: a warning, no failure. | |
| 337 | + | // Deletion is asynchronous: the CLI makes no claim when the server did not | |
| 338 | + | // request it. | |
| 338 | 339 | deleted = false | |
| 339 | 340 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t) | |
| 340 | 341 | assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\n", res.stdout) | |
| 341 | - | assert.Contains(t, res.stderr, "ermite: warning: the branch feat/x was kept") | |
| 342 | 342 | ||
| 343 | 343 | // Without the flag nothing is asked and nothing is said. | |
| 344 | 344 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA).ok(t) | |
| @@ -326,7 +326,7 @@ func TestMRMergeDeleteBranch(t *testing.T) { | |||||
| 326 | url := f.serve(t) | 326 | url := f.serve(t) | ||
| 327 | 327 | ||||
| 328 | res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t) | 328 | res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t) | ||
| 329 | - | assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleted branch feat/x\n", res.stdout) | 329 | + | assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleting branch feat/x (done by the server after the merge)\n", res.stdout) |
| 330 | assert.Empty(t, res.stderr) | 330 | assert.Empty(t, res.stderr) | ||
| 331 | _, reqs := f.snapshot() | 331 | _, reqs := f.snapshot() | ||
| 332 | assert.True(t, reqs[0].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch()) | 332 | assert.True(t, reqs[0].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch()) | ||
| @@ -334,11 +334,11 @@ func TestMRMergeDeleteBranch(t *testing.T) { | |||||
| 334 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch", "--json").ok(t) | 334 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch", "--json").ok(t) | ||
| 335 | assert.Contains(t, res.stdout, `"source_branch_deleted": true`) | 335 | assert.Contains(t, res.stdout, `"source_branch_deleted": true`) | ||
| 336 | 336 | ||||
| 337 | - | // The merge stands when the branch is kept: a warning, no failure. | 337 | + | // Deletion is asynchronous: the CLI makes no claim when the server did not |
| 338 | + | // request it. | |||
| 338 | deleted = false | 339 | deleted = false | ||
| 339 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t) | 340 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t) | ||
| 340 | assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\n", res.stdout) | 341 | assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\n", res.stdout) | ||
| 341 | - | assert.Contains(t, res.stderr, "ermite: warning: the branch feat/x was kept") | |||
| 342 | 342 | ||||
| 343 | // Without the flag nothing is asked and nothing is said. | 343 | // Without the flag nothing is asked and nothing is said. | ||
| 344 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA).ok(t) | 344 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA).ok(t) | ||
M internal/app/forge.go +3 -0
| @@ -519,6 +519,9 @@ func (f *forge) Close(ctx context.Context) { | |||
| 519 | 519 | if f.hooks != nil { | |
| 520 | 520 | f.hooks.Close(ctx) | |
| 521 | 521 | } | |
| 522 | + | if f.MergeRequests != nil { | |
| 523 | + | f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability | |
| 524 | + | } | |
| 522 | 525 | if f.CI != nil { | |
| 523 | 526 | f.CI.Close(ctx) | |
| 524 | 527 | } | |
| @@ -519,6 +519,9 @@ func (f *forge) Close(ctx context.Context) { | |||||
| 519 | if f.hooks != nil { | 519 | if f.hooks != nil { | ||
| 520 | f.hooks.Close(ctx) | 520 | f.hooks.Close(ctx) | ||
| 521 | } | 521 | } | ||
| 522 | + | if f.MergeRequests != nil { | |||
| 523 | + | f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability | |||
| 524 | + | } | |||
| 522 | if f.CI != nil { | 525 | if f.CI != nil { | ||
| 523 | f.CI.Close(ctx) | 526 | f.CI.Close(ctx) | ||
| 524 | } | 527 | } | ||
M internal/app/mr_api_test.go +2 -1
| @@ -375,7 +375,8 @@ func TestMergeDeletesTheSourceOverTheAPI(t *testing.T) { | |||
| 375 | 375 | Repo: "proj", Number: 1, HeadId: r.head, DeleteSourceBranch: true, | |
| 376 | 376 | })) | |
| 377 | 377 | require.NoError(t, err) | |
| 378 | - | assert.True(t, res.Msg.GetSourceBranchDeleted()) | |
| 378 | + | assert.True(t, res.Msg.GetSourceBranchDeleted(), "requested") | |
| 379 | + | r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the response | |
| 379 | 380 | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat")) | |
| 380 | 381 | ||
| 381 | 382 | // The default branch as a source: refused before anything changes. | |
| @@ -375,7 +375,8 @@ func TestMergeDeletesTheSourceOverTheAPI(t *testing.T) { | |||||
| 375 | Repo: "proj", Number: 1, HeadId: r.head, DeleteSourceBranch: true, | 375 | Repo: "proj", Number: 1, HeadId: r.head, DeleteSourceBranch: true, | ||
| 376 | })) | 376 | })) | ||
| 377 | require.NoError(t, err) | 377 | require.NoError(t, err) | ||
| 378 | - | assert.True(t, res.Msg.GetSourceBranchDeleted()) | 378 | + | assert.True(t, res.Msg.GetSourceBranchDeleted(), "requested") |
| 379 | + | r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the response | |||
| 379 | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat")) | 380 | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat")) | ||
| 380 | 381 | ||||
| 381 | // The default branch as a source: refused before anything changes. | 382 | // The default branch as a source: refused before anything changes. | ||
M internal/app/web_mr_test.go +2 -0
| @@ -392,6 +392,7 @@ func TestWebMergeRequestSquashCloseAndList(t *testing.T) { | |||
| 392 | 392 | assert.Contains(t, b.get("/proj/merge-requests/1", as(token)).body, `name="delete_branch" value="1"`, "the box offers to delete the branch") | |
| 393 | 393 | res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}, "delete_branch": {"1"}}) | |
| 394 | 394 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | |
| 395 | + | r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect | |
| 395 | 396 | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"), "the source branch is deleted") | |
| 396 | 397 | merged := r.headOf("main") | |
| 397 | 398 | assert.NotEqual(t, head, merged, "squash makes a new commit") | |
| @@ -511,6 +512,7 @@ func TestWebMergeDeleteBranchBox(t *testing.T) { | |||
| 511 | 512 | one := b.get("/proj/merge-requests/1", as(token)) | |
| 512 | 513 | res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {formHead(t, one.body)}, "delete_branch": {"1"}}) | |
| 513 | 514 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | |
| 515 | + | r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect | |
| 514 | 516 | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search")) | |
| 515 | 517 | done := b.get("/proj/merge-requests/1", nil) | |
| 516 | 518 | assert.Equal(t, 200, done.status, "the merged page reads without its branch") | |
| @@ -392,6 +392,7 @@ func TestWebMergeRequestSquashCloseAndList(t *testing.T) { | |||||
| 392 | assert.Contains(t, b.get("/proj/merge-requests/1", as(token)).body, `name="delete_branch" value="1"`, "the box offers to delete the branch") | 392 | assert.Contains(t, b.get("/proj/merge-requests/1", as(token)).body, `name="delete_branch" value="1"`, "the box offers to delete the branch") | ||
| 393 | res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}, "delete_branch": {"1"}}) | 393 | res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}, "delete_branch": {"1"}}) | ||
| 394 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | 394 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | ||
| 395 | + | r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect | |||
| 395 | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"), "the source branch is deleted") | 396 | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"), "the source branch is deleted") | ||
| 396 | merged := r.headOf("main") | 397 | merged := r.headOf("main") | ||
| 397 | assert.NotEqual(t, head, merged, "squash makes a new commit") | 398 | assert.NotEqual(t, head, merged, "squash makes a new commit") | ||
| @@ -511,6 +512,7 @@ func TestWebMergeDeleteBranchBox(t *testing.T) { | |||||
| 511 | one := b.get("/proj/merge-requests/1", as(token)) | 512 | one := b.get("/proj/merge-requests/1", as(token)) | ||
| 512 | res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {formHead(t, one.body)}, "delete_branch": {"1"}}) | 513 | res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {formHead(t, one.body)}, "delete_branch": {"1"}}) | ||
| 513 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | 514 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | ||
| 515 | + | r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect | |||
| 514 | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search")) | 516 | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search")) | ||
| 515 | done := b.get("/proj/merge-requests/1", nil) | 517 | done := b.get("/proj/merge-requests/1", nil) | ||
| 516 | assert.Equal(t, 200, done.status, "the merged page reads without its branch") | 518 | assert.Equal(t, 200, done.status, "the merged page reads without its branch") | ||
M internal/ports/dto.go +3 -2
| @@ -324,8 +324,9 @@ type MergeParams struct { | |||
| 324 | 324 | // MergeResult is a merged request and what happened to its source branch. | |
| 325 | 325 | type MergeResult struct { | |
| 326 | 326 | domain.MergeRequest | |
| 327 | - | // SourceDeleted is true when DeleteSource was asked and the branch is | |
| 328 | - | // gone. | |
| 327 | + | // SourceDeleted is true when DeleteSource was asked: the deletion is | |
| 328 | + | // requested, and done asynchronously after the merge returned (it is | |
| 329 | + | // skipped if the branch moved meanwhile). | |
| 329 | 330 | SourceDeleted bool | |
| 330 | 331 | } | |
| 331 | 332 | ||
| @@ -324,8 +324,9 @@ type MergeParams struct { | |||||
| 324 | // MergeResult is a merged request and what happened to its source branch. | 324 | // MergeResult is a merged request and what happened to its source branch. | ||
| 325 | type MergeResult struct { | 325 | type MergeResult struct { | ||
| 326 | domain.MergeRequest | 326 | domain.MergeRequest | ||
| 327 | - | // SourceDeleted is true when DeleteSource was asked and the branch is | 327 | + | // SourceDeleted is true when DeleteSource was asked: the deletion is |
| 328 | - | // gone. | 328 | + | // requested, and done asynchronously after the merge returned (it is |
| 329 | + | // skipped if the branch moved meanwhile). | |||
| 329 | SourceDeleted bool | 330 | SourceDeleted bool | ||
| 330 | } | 331 | } | ||
| 331 | 332 | ||||
M internal/usecase/mergerequests.go +151 -65
| @@ -60,6 +60,9 @@ type MergeRequests struct { | |||
| 60 | 60 | ||
| 61 | 61 | mu sync.Mutex | |
| 62 | 62 | locks map[domain.RepoName]chan struct{} | |
| 63 | + | ||
| 64 | + | // after tracks the post-merge side effects running off the request path. | |
| 65 | + | after sync.WaitGroup | |
| 63 | 66 | } | |
| 64 | 67 | ||
| 65 | 68 | var ( | |
| @@ -117,6 +120,21 @@ func (m *MergeRequests) WithVerifier(v ports.CommitVerifier) *MergeRequests { | |||
| 117 | 120 | return m | |
| 118 | 121 | } | |
| 119 | 122 | ||
| 123 | + | // Wait waits for the post-merge side effects still running (source branch | |
| 124 | + | // deletion, events, CI and mergeability hooks), or for ctx to end. Call it | |
| 125 | + | // before closing what they use. | |
| 126 | + | func (m *MergeRequests) Wait(ctx context.Context) { | |
| 127 | + | done := make(chan struct{}) | |
| 128 | + | go func() { | |
| 129 | + | m.after.Wait() | |
| 130 | + | close(done) | |
| 131 | + | }() | |
| 132 | + | select { | |
| 133 | + | case <-done: | |
| 134 | + | case <-ctx.Done(): | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 120 | 138 | // lockRepo takes the merge lock of a repository, waiting until ctx ends. The | |
| 121 | 139 | // merges of one repository, and the creation of its merge requests, run one at | |
| 122 | 140 | // a time. The returned function releases the lock. | |
| @@ -881,30 +899,76 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d | |||
| 881 | 899 | // domain.ErrPreconditionFailed when the request is not open, the mode is not | |
| 882 | 900 | // allowed by the settings of the repository, the source branch no longer | |
| 883 | 901 | // points to the head ("branch moved, read again") or is gone, the target is | |
| 884 | - | // gone or is not an ancestor of the head (the author rebases and pushes | |
| 885 | - | // again), or the branch brings nothing. Checks come with CI (CI-1): there are | |
| 886 | - | // none yet, so the setting that lets failing checks block the merge has | |
| 887 | - | // nothing to act on. | |
| 902 | + | // gone, the mode's own condition does not hold (see below), or the branch | |
| 903 | + | // brings nothing. Checks come with CI (CI-1): there are none yet, so the | |
| 904 | + | // setting that lets failing checks block the merge has nothing to act on. | |
| 888 | 905 | // | |
| 889 | - | // A fast-forward moves the target to the head, with the old value as a | |
| 890 | - | // precondition. A squash writes one commit with the tree of the head, the | |
| 891 | - | // target as parent and the author and message of domain.NewSquashCommit, | |
| 892 | - | // signed by the instance key, and moves the target to it the same way. | |
| 906 | + | // The conditions are always checked with Git, under the lock, never from the | |
| 907 | + | // stored mergeability. A fast-forward needs the target to be an ancestor of | |
| 908 | + | // the head (the author rebases and pushes again) and moves the target to the | |
| 909 | + | // head, with the old value as a precondition. A squash and a merge commit need | |
| 910 | + | // the head to merge cleanly into the target (a behind branch is fine). A squash | |
| 911 | + | // writes one commit with the merged tree, the target as parent and the author | |
| 912 | + | // and message of domain.NewSquashCommit; a merge commit has two parents (the | |
| 913 | + | // target, then the head) and the message of domain.NewMergeCommit. Both are | |
| 914 | + | // signed by the instance key and move the target the same way. | |
| 893 | 915 | // | |
| 894 | 916 | // With DeleteSource the merge is also refused, before anything changes, when | |
| 895 | 917 | // the source is the default branch or the source or target of another open | |
| 896 | - | // request. After the merge the source is deleted if it still points to the | |
| 897 | - | // head; the PushEvent then carries the deletion too. A failed deletion is | |
| 898 | - | // logged and leaves the branch: the merge stands and SourceDeleted is false. | |
| 918 | + | // request. | |
| 919 | + | // | |
| 920 | + | // Only the merge itself runs before Merge returns, under the lock. Afterwards, | |
| 921 | + | // off the request path and without the lock: the deletion of the source branch | |
| 922 | + | // (if it still points to the head; a failure is logged and leaves the branch), | |
| 923 | + | // MergeRequestMerged then a PushEvent (which carries the deletion too), and | |
| 924 | + | // the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore | |
| 925 | + | // only says that the deletion was requested. | |
| 899 | 926 | func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) { | |
| 900 | - | mr, deleted, err := m.merge(ctx, p, name, number, params) | |
| 901 | - | return ports.MergeResult{MergeRequest: mr, SourceDeleted: deleted}, err | |
| 927 | + | mr, err := m.merge(ctx, p, name, number, params) | |
| 928 | + | return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err | |
| 902 | 929 | } | |
| 903 | 930 | ||
| 904 | - | func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, bool, error) { | |
| 931 | + | // maxConflictPathsShown is how many conflicting paths a refusal lists. | |
| 932 | + | const maxConflictPathsShown = 5 | |
| 933 | + | ||
| 934 | + | // checkMode is the authoritative per-mode check of a merge, done with Git. It | |
| 935 | + | // returns the tree a squash or merge commit gets (zero for a fast-forward). | |
| 936 | + | func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID) (domain.ObjectID, error) { | |
| 937 | + | if mode == domain.MergeModeFastForward { | |
| 938 | + | ok, err := m.git.IsAncestor(ctx, mr.Repo, tip, head) | |
| 939 | + | if err != nil { | |
| 940 | + | return domain.ObjectID{}, err | |
| 941 | + | } | |
| 942 | + | if !ok { | |
| 943 | + | return domain.ObjectID{}, fmt.Errorf("%w: the target has moved: rebase and push again, or merge with squash or a merge commit", domain.ErrPreconditionFailed) | |
| 944 | + | } | |
| 945 | + | return domain.ObjectID{}, nil | |
| 946 | + | } | |
| 947 | + | res, err := m.git.MergeTree(ctx, mr.Repo, tip, head) | |
| 948 | + | if err != nil { | |
| 949 | + | return domain.ObjectID{}, err | |
| 950 | + | } | |
| 951 | + | if !res.Clean { | |
| 952 | + | what := "no common history" | |
| 953 | + | if len(res.ConflictPaths) > 0 { | |
| 954 | + | shown := res.ConflictPaths | |
| 955 | + | if len(shown) > maxConflictPathsShown { | |
| 956 | + | shown = shown[:maxConflictPathsShown] | |
| 957 | + | } | |
| 958 | + | what = strings.Join(shown, ", ") | |
| 959 | + | if len(shown) < len(res.ConflictPaths) || res.Truncated { | |
| 960 | + | what += ", ..." | |
| 961 | + | } | |
| 962 | + | } | |
| 963 | + | return domain.ObjectID{}, fmt.Errorf("%w: conflicts with %s: %s: rebase and push again", domain.ErrPreconditionFailed, mr.Target, what) | |
| 964 | + | } | |
| 965 | + | return res.Tree, nil | |
| 966 | + | } | |
| 967 | + | ||
| 968 | + | func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) { | |
| 905 | 969 | repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge) | |
| 906 | 970 | if err != nil { | |
| 907 | - | return domain.MergeRequest{}, false, err | |
| 971 | + | return domain.MergeRequest{}, err | |
| 908 | 972 | } | |
| 909 | 973 | mode := params.Mode | |
| 910 | 974 | if mode == "" { | |
| @@ -918,114 +982,131 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 918 | 982 | v.Add("head_id", "is required") | |
| 919 | 983 | } | |
| 920 | 984 | if err := v.Err(); err != nil { | |
| 921 | - | return domain.MergeRequest{}, false, err | |
| 985 | + | return domain.MergeRequest{}, err | |
| 922 | 986 | } | |
| 923 | 987 | ||
| 924 | 988 | unlock, err := m.lockRepo(ctx, name) | |
| 925 | 989 | if err != nil { | |
| 926 | - | return domain.MergeRequest{}, false, err | |
| 990 | + | return domain.MergeRequest{}, err | |
| 927 | 991 | } | |
| 928 | - | defer unlock() | |
| 992 | + | var once sync.Once | |
| 993 | + | release := func() { once.Do(unlock) } | |
| 994 | + | defer release() | |
| 929 | 995 | ||
| 930 | 996 | mr, err := m.store.Get(ctx, name, number) | |
| 931 | 997 | if err != nil { | |
| 932 | - | return domain.MergeRequest{}, false, err | |
| 998 | + | return domain.MergeRequest{}, err | |
| 933 | 999 | } | |
| 934 | 1000 | // A merge moves the target and may delete the source: a token limited to | |
| 935 | 1001 | // branch prefixes may do so only within them. | |
| 936 | 1002 | if !p.AllowsRef("refs/heads/" + mr.Target) { | |
| 937 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target) | |
| 1003 | + | return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target) | |
| 938 | 1004 | } | |
| 939 | 1005 | if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) { | |
| 940 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source) | |
| 1006 | + | return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source) | |
| 941 | 1007 | } | |
| 942 | 1008 | if !mr.IsOpen() { | |
| 943 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | |
| 1009 | + | return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | |
| 944 | 1010 | } | |
| 945 | 1011 | if mr.IsDraft() { | |
| 946 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number) | |
| 1012 | + | return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number) | |
| 947 | 1013 | } | |
| 948 | 1014 | // The settings are read again under the lock: a change applies at once. | |
| 949 | 1015 | repo, err = m.meta.Get(ctx, name) | |
| 950 | 1016 | if err != nil { | |
| 951 | 1017 | if errors.Is(err, domain.ErrNotFound) { | |
| 952 | - | return domain.MergeRequest{}, false, err // deleted meanwhile | |
| 1018 | + | return domain.MergeRequest{}, err // deleted meanwhile | |
| 953 | 1019 | } | |
| 954 | - | return domain.MergeRequest{}, false, fmt.Errorf("get repository %q: %w", name, err) | |
| 1020 | + | return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err) | |
| 955 | 1021 | } | |
| 956 | 1022 | settings := repo.Settings | |
| 957 | 1023 | if settings.IsZero() { | |
| 958 | 1024 | settings = domain.DefaultRepoSettings() | |
| 959 | 1025 | } | |
| 960 | 1026 | if !settings.Allows(mode) { | |
| 961 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name) | |
| 1027 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name) | |
| 962 | 1028 | } | |
| 963 | 1029 | ||
| 964 | 1030 | head, err := m.branchOrZero(ctx, name, mr.Source) | |
| 965 | 1031 | switch { | |
| 966 | 1032 | case err != nil: | |
| 967 | - | return domain.MergeRequest{}, false, err | |
| 1033 | + | return domain.MergeRequest{}, err | |
| 968 | 1034 | case head.IsZero(): | |
| 969 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source) | |
| 1035 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source) | |
| 970 | 1036 | case head != params.HeadID: | |
| 971 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID) | |
| 1037 | + | return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID) | |
| 972 | 1038 | } | |
| 973 | 1039 | tip, err := m.branchOrZero(ctx, name, mr.Target) | |
| 974 | 1040 | switch { | |
| 975 | 1041 | case err != nil: | |
| 976 | - | return domain.MergeRequest{}, false, err | |
| 1042 | + | return domain.MergeRequest{}, err | |
| 977 | 1043 | case tip.IsZero(): | |
| 978 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target) | |
| 1044 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target) | |
| 979 | 1045 | case tip == head: | |
| 980 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head) | |
| 1046 | + | return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head) | |
| 981 | 1047 | } | |
| 982 | - | switch ok, err := m.git.IsAncestor(ctx, name, tip, head); { | |
| 983 | - | case err != nil: | |
| 984 | - | return domain.MergeRequest{}, false, err | |
| 985 | - | case !ok: | |
| 986 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: %q is not an ancestor of %q: rebase it on %q and push again", domain.ErrPreconditionFailed, mr.Target, mr.Source, mr.Target) | |
| 1048 | + | tree, err := m.checkMode(ctx, mr, mode, tip, head) | |
| 1049 | + | if err != nil { | |
| 1050 | + | return domain.MergeRequest{}, err | |
| 987 | 1051 | } | |
| 988 | 1052 | if settings.FailingChecksBlock { | |
| 989 | 1053 | if err := m.checksAllow(ctx, name, head); err != nil { | |
| 990 | - | return domain.MergeRequest{}, false, err | |
| 1054 | + | return domain.MergeRequest{}, err | |
| 991 | 1055 | } | |
| 992 | 1056 | } | |
| 993 | 1057 | if params.DeleteSource { | |
| 994 | 1058 | if err := m.checkDeletable(ctx, name, mr); err != nil { | |
| 995 | - | return domain.MergeRequest{}, false, err | |
| 1059 | + | return domain.MergeRequest{}, err | |
| 996 | 1060 | } | |
| 997 | 1061 | } | |
| 998 | 1062 | ||
| 999 | - | merged, err := m.moveTarget(ctx, mr, mode, tip, head, p) | |
| 1063 | + | merged, err := m.moveTarget(ctx, mr, mode, tip, head, tree, p) | |
| 1000 | 1064 | if err != nil { | |
| 1001 | - | return domain.MergeRequest{}, false, err | |
| 1065 | + | return domain.MergeRequest{}, err | |
| 1002 | 1066 | } | |
| 1003 | 1067 | m.recordPush(ctx, name) // the target moved, whatever happens next | |
| 1004 | 1068 | mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged) | |
| 1005 | 1069 | if err != nil { | |
| 1006 | - | return domain.MergeRequest{}, false, err | |
| 1070 | + | return domain.MergeRequest{}, err | |
| 1007 | 1071 | } | |
| 1008 | 1072 | log := zerowrap.FromCtx(ctx) | |
| 1009 | 1073 | log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()). | |
| 1010 | 1074 | Str("target", mr.Target).Str("commit", merged.String()). | |
| 1011 | 1075 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged") | |
| 1076 | + | ||
| 1077 | + | // The merge is done and recorded. The rest runs without the lock and after | |
| 1078 | + | // the caller got its answer. | |
| 1079 | + | release() | |
| 1080 | + | bg := context.WithoutCancel(ctx) | |
| 1081 | + | m.after.Add(1) | |
| 1082 | + | go func() { | |
| 1083 | + | defer m.after.Done() | |
| 1084 | + | m.afterMerge(bg, p, repo, mr, head, tip, merged, params.DeleteSource) | |
| 1085 | + | }() | |
| 1086 | + | return mr, nil | |
| 1087 | + | } | |
| 1088 | + | ||
| 1089 | + | // afterMerge is what follows a merge off the request path: errors are logged, | |
| 1090 | + | // never returned. mr is the merged request, tip the old target and merged the | |
| 1091 | + | // new one. | |
| 1092 | + | func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) { | |
| 1093 | + | name := repo.Name | |
| 1094 | + | log := zerowrap.FromCtx(ctx) | |
| 1012 | 1095 | updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}} | |
| 1013 | - | deleted := false | |
| 1014 | - | if params.DeleteSource { | |
| 1015 | - | // The merge is done and recorded: a failure here keeps the branch. Who | |
| 1016 | - | // pushed the source goes first (the branch is gone either way for a | |
| 1096 | + | if deleteSource { | |
| 1097 | + | // Who pushed the source goes first (the branch is gone either way for a | |
| 1017 | 1098 | // schedule; a kept branch merely has no record: not trusted). | |
| 1018 | - | if err := recordRefTips(ctx, m.tips, name, []domain.RefUpdate{{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source}}, "", domain.CIPusher{}, false); err != nil { | |
| 1099 | + | del := domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source} | |
| 1100 | + | if err := recordRefTips(ctx, m.tips, name, []domain.RefUpdate{del}, "", domain.CIPusher{}, false); err != nil { | |
| 1019 | 1101 | log.Warn().Err(err).Str("repo", name.String()).Str("branch", mr.Source).Msg("the record of the source branch was kept") | |
| 1020 | 1102 | } | |
| 1021 | - | switch err := m.git.DeleteBranch(context.WithoutCancel(ctx), name, mr.Source, head); { | |
| 1103 | + | switch err := m.git.DeleteBranch(ctx, name, mr.Source, head); { | |
| 1022 | 1104 | case err != nil: | |
| 1023 | - | log.Warn().Err(err).Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source). | |
| 1105 | + | log.Warn().Err(err).Str("repo", name.String()).Int64("number", mr.Number).Str("branch", mr.Source). | |
| 1024 | 1106 | Msg("source branch kept after the merge") | |
| 1025 | 1107 | default: | |
| 1026 | - | deleted = true | |
| 1027 | - | updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source}) | |
| 1028 | - | log.Info().Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source). | |
| 1108 | + | updates = append(updates, del) | |
| 1109 | + | log.Info().Str("repo", name.String()).Int64("number", mr.Number).Str("branch", mr.Source). | |
| 1029 | 1110 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge") | |
| 1030 | 1111 | } | |
| 1031 | 1112 | } | |
| @@ -1034,19 +1115,18 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 1034 | 1115 | m.bus.Publish(ctx, domain.PushEvent{ | |
| 1035 | 1116 | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates, | |
| 1036 | 1117 | }) | |
| 1037 | - | if m.mergeab != nil { | |
| 1038 | - | m.mergeab.OnClosed(ctx, mr) | |
| 1039 | - | m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target | |
| 1040 | - | } | |
| 1041 | 1118 | if m.pulls != nil { | |
| 1042 | - | m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p) | |
| 1119 | + | m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: mr.Number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p) | |
| 1043 | 1120 | } | |
| 1044 | 1121 | if m.ci != nil { | |
| 1045 | 1122 | // GitHub runs the "push" workflows of a merge, with the owner as actor; | |
| 1046 | 1123 | // the run is trusted with what the merging principal may read. | |
| 1047 | 1124 | m.ci.OnMerge(ctx, name, updates, p) | |
| 1048 | 1125 | } | |
| 1049 | - | return mr, deleted, nil | |
| 1126 | + | if m.mergeab != nil { | |
| 1127 | + | m.mergeab.OnClosed(ctx, mr) | |
| 1128 | + | m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target | |
| 1129 | + | } | |
| 1050 | 1130 | } | |
| 1051 | 1131 | ||
| 1052 | 1132 | // WhileOpen implements ports.PullGate: fn runs under the merge lock, so a | |
| @@ -1139,10 +1219,20 @@ func (m *MergeRequests) recordPush(ctx context.Context, name domain.RepoName) { | |||
| 1139 | 1219 | } | |
| 1140 | 1220 | ||
| 1141 | 1221 | // moveTarget moves the target branch from tip to the head (fast-forward) or to | |
| 1142 | - | // a new signed squash commit, and returns the new tip. | |
| 1143 | - | func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID, p domain.Principal) (domain.ObjectID, error) { | |
| 1222 | + | // a new signed squash or merge commit with the given tree, and returns the new tip. | |
| 1223 | + | func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head, tree domain.ObjectID, p domain.Principal) (domain.ObjectID, error) { | |
| 1144 | 1224 | newTip := head | |
| 1145 | - | if mode == domain.MergeModeSquash { | |
| 1225 | + | switch mode { | |
| 1226 | + | case domain.MergeModeMerge: | |
| 1227 | + | commit := domain.NewMergeCommit(mr, tree, tip, head, m.committer, m.clock.Now()) | |
| 1228 | + | armored, err := m.signer.Sign(commit.Payload()) | |
| 1229 | + | if err != nil { | |
| 1230 | + | return domain.ObjectID{}, fmt.Errorf("sign the merge commit of !%d in %q: %w", mr.Number, mr.Repo, err) | |
| 1231 | + | } | |
| 1232 | + | if newTip, err = m.git.WriteCommit(ctx, mr.Repo, commit.Signed(armored)); err != nil { | |
| 1233 | + | return domain.ObjectID{}, err | |
| 1234 | + | } | |
| 1235 | + | case domain.MergeModeSquash: | |
| 1146 | 1236 | commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits) | |
| 1147 | 1237 | if err != nil { | |
| 1148 | 1238 | return domain.ObjectID{}, err | |
| @@ -1150,10 +1240,6 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, | |||
| 1150 | 1240 | if more { | |
| 1151 | 1241 | return domain.ObjectID{}, fmt.Errorf("%w: the branch has more than %d commits to squash", domain.ErrPreconditionFailed, domain.MaxSquashCommits) | |
| 1152 | 1242 | } | |
| 1153 | - | tree, err := m.git.CommitTree(ctx, mr.Repo, head) | |
| 1154 | - | if err != nil { | |
| 1155 | - | return domain.ObjectID{}, err | |
| 1156 | - | } | |
| 1157 | 1243 | commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now()) | |
| 1158 | 1244 | armored, err := m.signer.Sign(commit.Payload()) | |
| 1159 | 1245 | if err != nil { | |
| @@ -60,6 +60,9 @@ type MergeRequests struct { | |||||
| 60 | 60 | ||||
| 61 | mu sync.Mutex | 61 | mu sync.Mutex | ||
| 62 | locks map[domain.RepoName]chan struct{} | 62 | locks map[domain.RepoName]chan struct{} | ||
| 63 | + | ||||
| 64 | + | // after tracks the post-merge side effects running off the request path. | |||
| 65 | + | after sync.WaitGroup | |||
| 63 | } | 66 | } | ||
| 64 | 67 | ||||
| 65 | var ( | 68 | var ( | ||
| @@ -117,6 +120,21 @@ func (m *MergeRequests) WithVerifier(v ports.CommitVerifier) *MergeRequests { | |||||
| 117 | return m | 120 | return m | ||
| 118 | } | 121 | } | ||
| 119 | 122 | ||||
| 123 | + | // Wait waits for the post-merge side effects still running (source branch | |||
| 124 | + | // deletion, events, CI and mergeability hooks), or for ctx to end. Call it | |||
| 125 | + | // before closing what they use. | |||
| 126 | + | func (m *MergeRequests) Wait(ctx context.Context) { | |||
| 127 | + | done := make(chan struct{}) | |||
| 128 | + | go func() { | |||
| 129 | + | m.after.Wait() | |||
| 130 | + | close(done) | |||
| 131 | + | }() | |||
| 132 | + | select { | |||
| 133 | + | case <-done: | |||
| 134 | + | case <-ctx.Done(): | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 120 | // lockRepo takes the merge lock of a repository, waiting until ctx ends. The | 138 | // lockRepo takes the merge lock of a repository, waiting until ctx ends. The | ||
| 121 | // merges of one repository, and the creation of its merge requests, run one at | 139 | // merges of one repository, and the creation of its merge requests, run one at | ||
| 122 | // a time. The returned function releases the lock. | 140 | // a time. The returned function releases the lock. | ||
| @@ -881,30 +899,76 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d | |||||
| 881 | // domain.ErrPreconditionFailed when the request is not open, the mode is not | 899 | // domain.ErrPreconditionFailed when the request is not open, the mode is not | ||
| 882 | // allowed by the settings of the repository, the source branch no longer | 900 | // allowed by the settings of the repository, the source branch no longer | ||
| 883 | // points to the head ("branch moved, read again") or is gone, the target is | 901 | // points to the head ("branch moved, read again") or is gone, the target is | ||
| 884 | - | // gone or is not an ancestor of the head (the author rebases and pushes | 902 | + | // gone, the mode's own condition does not hold (see below), or the branch |
| 885 | - | // again), or the branch brings nothing. Checks come with CI (CI-1): there are | 903 | + | // brings nothing. Checks come with CI (CI-1): there are none yet, so the |
| 886 | - | // none yet, so the setting that lets failing checks block the merge has | 904 | + | // setting that lets failing checks block the merge has nothing to act on. |
| 887 | - | // nothing to act on. | |||
| 888 | // | 905 | // | ||
| 889 | - | // A fast-forward moves the target to the head, with the old value as a | 906 | + | // The conditions are always checked with Git, under the lock, never from the |
| 890 | - | // precondition. A squash writes one commit with the tree of the head, the | 907 | + | // stored mergeability. A fast-forward needs the target to be an ancestor of |
| 891 | - | // target as parent and the author and message of domain.NewSquashCommit, | 908 | + | // the head (the author rebases and pushes again) and moves the target to the |
| 892 | - | // signed by the instance key, and moves the target to it the same way. | 909 | + | // head, with the old value as a precondition. A squash and a merge commit need |
| 910 | + | // the head to merge cleanly into the target (a behind branch is fine). A squash | |||
| 911 | + | // writes one commit with the merged tree, the target as parent and the author | |||
| 912 | + | // and message of domain.NewSquashCommit; a merge commit has two parents (the | |||
| 913 | + | // target, then the head) and the message of domain.NewMergeCommit. Both are | |||
| 914 | + | // signed by the instance key and move the target the same way. | |||
| 893 | // | 915 | // | ||
| 894 | // With DeleteSource the merge is also refused, before anything changes, when | 916 | // With DeleteSource the merge is also refused, before anything changes, when | ||
| 895 | // the source is the default branch or the source or target of another open | 917 | // the source is the default branch or the source or target of another open | ||
| 896 | - | // request. After the merge the source is deleted if it still points to the | 918 | + | // request. |
| 897 | - | // head; the PushEvent then carries the deletion too. A failed deletion is | 919 | + | // |
| 898 | - | // logged and leaves the branch: the merge stands and SourceDeleted is false. | 920 | + | // Only the merge itself runs before Merge returns, under the lock. Afterwards, |
| 921 | + | // off the request path and without the lock: the deletion of the source branch | |||
| 922 | + | // (if it still points to the head; a failure is logged and leaves the branch), | |||
| 923 | + | // MergeRequestMerged then a PushEvent (which carries the deletion too), and | |||
| 924 | + | // the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore | |||
| 925 | + | // only says that the deletion was requested. | |||
| 899 | func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) { | 926 | func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) { | ||
| 900 | - | mr, deleted, err := m.merge(ctx, p, name, number, params) | 927 | + | mr, err := m.merge(ctx, p, name, number, params) |
| 901 | - | return ports.MergeResult{MergeRequest: mr, SourceDeleted: deleted}, err | 928 | + | return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err |
| 902 | } | 929 | } | ||
| 903 | 930 | ||||
| 904 | - | func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, bool, error) { | 931 | + | // maxConflictPathsShown is how many conflicting paths a refusal lists. |
| 932 | + | const maxConflictPathsShown = 5 | |||
| 933 | + | ||||
| 934 | + | // checkMode is the authoritative per-mode check of a merge, done with Git. It | |||
| 935 | + | // returns the tree a squash or merge commit gets (zero for a fast-forward). | |||
| 936 | + | func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID) (domain.ObjectID, error) { | |||
| 937 | + | if mode == domain.MergeModeFastForward { | |||
| 938 | + | ok, err := m.git.IsAncestor(ctx, mr.Repo, tip, head) | |||
| 939 | + | if err != nil { | |||
| 940 | + | return domain.ObjectID{}, err | |||
| 941 | + | } | |||
| 942 | + | if !ok { | |||
| 943 | + | return domain.ObjectID{}, fmt.Errorf("%w: the target has moved: rebase and push again, or merge with squash or a merge commit", domain.ErrPreconditionFailed) | |||
| 944 | + | } | |||
| 945 | + | return domain.ObjectID{}, nil | |||
| 946 | + | } | |||
| 947 | + | res, err := m.git.MergeTree(ctx, mr.Repo, tip, head) | |||
| 948 | + | if err != nil { | |||
| 949 | + | return domain.ObjectID{}, err | |||
| 950 | + | } | |||
| 951 | + | if !res.Clean { | |||
| 952 | + | what := "no common history" | |||
| 953 | + | if len(res.ConflictPaths) > 0 { | |||
| 954 | + | shown := res.ConflictPaths | |||
| 955 | + | if len(shown) > maxConflictPathsShown { | |||
| 956 | + | shown = shown[:maxConflictPathsShown] | |||
| 957 | + | } | |||
| 958 | + | what = strings.Join(shown, ", ") | |||
| 959 | + | if len(shown) < len(res.ConflictPaths) || res.Truncated { | |||
| 960 | + | what += ", ..." | |||
| 961 | + | } | |||
| 962 | + | } | |||
| 963 | + | return domain.ObjectID{}, fmt.Errorf("%w: conflicts with %s: %s: rebase and push again", domain.ErrPreconditionFailed, mr.Target, what) | |||
| 964 | + | } | |||
| 965 | + | return res.Tree, nil | |||
| 966 | + | } | |||
| 967 | + | ||||
| 968 | + | func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) { | |||
| 905 | repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge) | 969 | repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge) | ||
| 906 | if err != nil { | 970 | if err != nil { | ||
| 907 | - | return domain.MergeRequest{}, false, err | 971 | + | return domain.MergeRequest{}, err |
| 908 | } | 972 | } | ||
| 909 | mode := params.Mode | 973 | mode := params.Mode | ||
| 910 | if mode == "" { | 974 | if mode == "" { | ||
| @@ -918,114 +982,131 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||||
| 918 | v.Add("head_id", "is required") | 982 | v.Add("head_id", "is required") | ||
| 919 | } | 983 | } | ||
| 920 | if err := v.Err(); err != nil { | 984 | if err := v.Err(); err != nil { | ||
| 921 | - | return domain.MergeRequest{}, false, err | 985 | + | return domain.MergeRequest{}, err |
| 922 | } | 986 | } | ||
| 923 | 987 | ||||
| 924 | unlock, err := m.lockRepo(ctx, name) | 988 | unlock, err := m.lockRepo(ctx, name) | ||
| 925 | if err != nil { | 989 | if err != nil { | ||
| 926 | - | return domain.MergeRequest{}, false, err | 990 | + | return domain.MergeRequest{}, err |
| 927 | } | 991 | } | ||
| 928 | - | defer unlock() | 992 | + | var once sync.Once |
| 993 | + | release := func() { once.Do(unlock) } | |||
| 994 | + | defer release() | |||
| 929 | 995 | ||||
| 930 | mr, err := m.store.Get(ctx, name, number) | 996 | mr, err := m.store.Get(ctx, name, number) | ||
| 931 | if err != nil { | 997 | if err != nil { | ||
| 932 | - | return domain.MergeRequest{}, false, err | 998 | + | return domain.MergeRequest{}, err |
| 933 | } | 999 | } | ||
| 934 | // A merge moves the target and may delete the source: a token limited to | 1000 | // A merge moves the target and may delete the source: a token limited to | ||
| 935 | // branch prefixes may do so only within them. | 1001 | // branch prefixes may do so only within them. | ||
| 936 | if !p.AllowsRef("refs/heads/" + mr.Target) { | 1002 | if !p.AllowsRef("refs/heads/" + mr.Target) { | ||
| 937 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target) | 1003 | + | return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target) |
| 938 | } | 1004 | } | ||
| 939 | if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) { | 1005 | if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) { | ||
| 940 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source) | 1006 | + | return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source) |
| 941 | } | 1007 | } | ||
| 942 | if !mr.IsOpen() { | 1008 | if !mr.IsOpen() { | ||
| 943 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | 1009 | + | return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) |
| 944 | } | 1010 | } | ||
| 945 | if mr.IsDraft() { | 1011 | if mr.IsDraft() { | ||
| 946 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number) | 1012 | + | return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number) |
| 947 | } | 1013 | } | ||
| 948 | // The settings are read again under the lock: a change applies at once. | 1014 | // The settings are read again under the lock: a change applies at once. | ||
| 949 | repo, err = m.meta.Get(ctx, name) | 1015 | repo, err = m.meta.Get(ctx, name) | ||
| 950 | if err != nil { | 1016 | if err != nil { | ||
| 951 | if errors.Is(err, domain.ErrNotFound) { | 1017 | if errors.Is(err, domain.ErrNotFound) { | ||
| 952 | - | return domain.MergeRequest{}, false, err // deleted meanwhile | 1018 | + | return domain.MergeRequest{}, err // deleted meanwhile |
| 953 | } | 1019 | } | ||
| 954 | - | return domain.MergeRequest{}, false, fmt.Errorf("get repository %q: %w", name, err) | 1020 | + | return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err) |
| 955 | } | 1021 | } | ||
| 956 | settings := repo.Settings | 1022 | settings := repo.Settings | ||
| 957 | if settings.IsZero() { | 1023 | if settings.IsZero() { | ||
| 958 | settings = domain.DefaultRepoSettings() | 1024 | settings = domain.DefaultRepoSettings() | ||
| 959 | } | 1025 | } | ||
| 960 | if !settings.Allows(mode) { | 1026 | if !settings.Allows(mode) { | ||
| 961 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name) | 1027 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name) |
| 962 | } | 1028 | } | ||
| 963 | 1029 | ||||
| 964 | head, err := m.branchOrZero(ctx, name, mr.Source) | 1030 | head, err := m.branchOrZero(ctx, name, mr.Source) | ||
| 965 | switch { | 1031 | switch { | ||
| 966 | case err != nil: | 1032 | case err != nil: | ||
| 967 | - | return domain.MergeRequest{}, false, err | 1033 | + | return domain.MergeRequest{}, err |
| 968 | case head.IsZero(): | 1034 | case head.IsZero(): | ||
| 969 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source) | 1035 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source) |
| 970 | case head != params.HeadID: | 1036 | case head != params.HeadID: | ||
| 971 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID) | 1037 | + | return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID) |
| 972 | } | 1038 | } | ||
| 973 | tip, err := m.branchOrZero(ctx, name, mr.Target) | 1039 | tip, err := m.branchOrZero(ctx, name, mr.Target) | ||
| 974 | switch { | 1040 | switch { | ||
| 975 | case err != nil: | 1041 | case err != nil: | ||
| 976 | - | return domain.MergeRequest{}, false, err | 1042 | + | return domain.MergeRequest{}, err |
| 977 | case tip.IsZero(): | 1043 | case tip.IsZero(): | ||
| 978 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target) | 1044 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target) |
| 979 | case tip == head: | 1045 | case tip == head: | ||
| 980 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head) | 1046 | + | return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head) |
| 981 | } | 1047 | } | ||
| 982 | - | switch ok, err := m.git.IsAncestor(ctx, name, tip, head); { | 1048 | + | tree, err := m.checkMode(ctx, mr, mode, tip, head) |
| 983 | - | case err != nil: | 1049 | + | if err != nil { |
| 984 | - | return domain.MergeRequest{}, false, err | 1050 | + | return domain.MergeRequest{}, err |
| 985 | - | case !ok: | |||
| 986 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: %q is not an ancestor of %q: rebase it on %q and push again", domain.ErrPreconditionFailed, mr.Target, mr.Source, mr.Target) | |||
| 987 | } | 1051 | } | ||
| 988 | if settings.FailingChecksBlock { | 1052 | if settings.FailingChecksBlock { | ||
| 989 | if err := m.checksAllow(ctx, name, head); err != nil { | 1053 | if err := m.checksAllow(ctx, name, head); err != nil { | ||
| 990 | - | return domain.MergeRequest{}, false, err | 1054 | + | return domain.MergeRequest{}, err |
| 991 | } | 1055 | } | ||
| 992 | } | 1056 | } | ||
| 993 | if params.DeleteSource { | 1057 | if params.DeleteSource { | ||
| 994 | if err := m.checkDeletable(ctx, name, mr); err != nil { | 1058 | if err := m.checkDeletable(ctx, name, mr); err != nil { | ||
| 995 | - | return domain.MergeRequest{}, false, err | 1059 | + | return domain.MergeRequest{}, err |
| 996 | } | 1060 | } | ||
| 997 | } | 1061 | } | ||
| 998 | 1062 | ||||
| 999 | - | merged, err := m.moveTarget(ctx, mr, mode, tip, head, p) | 1063 | + | merged, err := m.moveTarget(ctx, mr, mode, tip, head, tree, p) |
| 1000 | if err != nil { | 1064 | if err != nil { | ||
| 1001 | - | return domain.MergeRequest{}, false, err | 1065 | + | return domain.MergeRequest{}, err |
| 1002 | } | 1066 | } | ||
| 1003 | m.recordPush(ctx, name) // the target moved, whatever happens next | 1067 | m.recordPush(ctx, name) // the target moved, whatever happens next | ||
| 1004 | mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged) | 1068 | mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged) | ||
| 1005 | if err != nil { | 1069 | if err != nil { | ||
| 1006 | - | return domain.MergeRequest{}, false, err | 1070 | + | return domain.MergeRequest{}, err |
| 1007 | } | 1071 | } | ||
| 1008 | log := zerowrap.FromCtx(ctx) | 1072 | log := zerowrap.FromCtx(ctx) | ||
| 1009 | log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()). | 1073 | log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()). | ||
| 1010 | Str("target", mr.Target).Str("commit", merged.String()). | 1074 | Str("target", mr.Target).Str("commit", merged.String()). | ||
| 1011 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged") | 1075 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged") | ||
| 1076 | + | ||||
| 1077 | + | // The merge is done and recorded. The rest runs without the lock and after | |||
| 1078 | + | // the caller got its answer. | |||
| 1079 | + | release() | |||
| 1080 | + | bg := context.WithoutCancel(ctx) | |||
| 1081 | + | m.after.Add(1) | |||
| 1082 | + | go func() { | |||
| 1083 | + | defer m.after.Done() | |||
| 1084 | + | m.afterMerge(bg, p, repo, mr, head, tip, merged, params.DeleteSource) | |||
| 1085 | + | }() | |||
| 1086 | + | return mr, nil | |||
| 1087 | + | } | |||
| 1088 | + | ||||
| 1089 | + | // afterMerge is what follows a merge off the request path: errors are logged, | |||
| 1090 | + | // never returned. mr is the merged request, tip the old target and merged the | |||
| 1091 | + | // new one. | |||
| 1092 | + | func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) { | |||
| 1093 | + | name := repo.Name | |||
| 1094 | + | log := zerowrap.FromCtx(ctx) | |||
| 1012 | updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}} | 1095 | updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}} | ||
| 1013 | - | deleted := false | 1096 | + | if deleteSource { |
| 1014 | - | if params.DeleteSource { | 1097 | + | // Who pushed the source goes first (the branch is gone either way for a |
| 1015 | - | // The merge is done and recorded: a failure here keeps the branch. Who | |||
| 1016 | - | // pushed the source goes first (the branch is gone either way for a | |||
| 1017 | // schedule; a kept branch merely has no record: not trusted). | 1098 | // schedule; a kept branch merely has no record: not trusted). | ||
| 1018 | - | if err := recordRefTips(ctx, m.tips, name, []domain.RefUpdate{{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source}}, "", domain.CIPusher{}, false); err != nil { | 1099 | + | del := domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source} |
| 1100 | + | if err := recordRefTips(ctx, m.tips, name, []domain.RefUpdate{del}, "", domain.CIPusher{}, false); err != nil { | |||
| 1019 | log.Warn().Err(err).Str("repo", name.String()).Str("branch", mr.Source).Msg("the record of the source branch was kept") | 1101 | log.Warn().Err(err).Str("repo", name.String()).Str("branch", mr.Source).Msg("the record of the source branch was kept") | ||
| 1020 | } | 1102 | } | ||
| 1021 | - | switch err := m.git.DeleteBranch(context.WithoutCancel(ctx), name, mr.Source, head); { | 1103 | + | switch err := m.git.DeleteBranch(ctx, name, mr.Source, head); { |
| 1022 | case err != nil: | 1104 | case err != nil: | ||
| 1023 | - | log.Warn().Err(err).Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source). | 1105 | + | log.Warn().Err(err).Str("repo", name.String()).Int64("number", mr.Number).Str("branch", mr.Source). |
| 1024 | Msg("source branch kept after the merge") | 1106 | Msg("source branch kept after the merge") | ||
| 1025 | default: | 1107 | default: | ||
| 1026 | - | deleted = true | 1108 | + | updates = append(updates, del) |
| 1027 | - | updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source}) | 1109 | + | log.Info().Str("repo", name.String()).Int64("number", mr.Number).Str("branch", mr.Source). |
| 1028 | - | log.Info().Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source). | |||
| 1029 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge") | 1110 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge") | ||
| 1030 | } | 1111 | } | ||
| 1031 | } | 1112 | } | ||
| @@ -1034,19 +1115,18 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||||
| 1034 | m.bus.Publish(ctx, domain.PushEvent{ | 1115 | m.bus.Publish(ctx, domain.PushEvent{ | ||
| 1035 | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates, | 1116 | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates, | ||
| 1036 | }) | 1117 | }) | ||
| 1037 | - | if m.mergeab != nil { | |||
| 1038 | - | m.mergeab.OnClosed(ctx, mr) | |||
| 1039 | - | m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target | |||
| 1040 | - | } | |||
| 1041 | if m.pulls != nil { | 1118 | if m.pulls != nil { | ||
| 1042 | - | m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p) | 1119 | + | m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: mr.Number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p) |
| 1043 | } | 1120 | } | ||
| 1044 | if m.ci != nil { | 1121 | if m.ci != nil { | ||
| 1045 | // GitHub runs the "push" workflows of a merge, with the owner as actor; | 1122 | // GitHub runs the "push" workflows of a merge, with the owner as actor; | ||
| 1046 | // the run is trusted with what the merging principal may read. | 1123 | // the run is trusted with what the merging principal may read. | ||
| 1047 | m.ci.OnMerge(ctx, name, updates, p) | 1124 | m.ci.OnMerge(ctx, name, updates, p) | ||
| 1048 | } | 1125 | } | ||
| 1049 | - | return mr, deleted, nil | 1126 | + | if m.mergeab != nil { |
| 1127 | + | m.mergeab.OnClosed(ctx, mr) | |||
| 1128 | + | m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target | |||
| 1129 | + | } | |||
| 1050 | } | 1130 | } | ||
| 1051 | 1131 | ||||
| 1052 | // WhileOpen implements ports.PullGate: fn runs under the merge lock, so a | 1132 | // WhileOpen implements ports.PullGate: fn runs under the merge lock, so a | ||
| @@ -1139,10 +1219,20 @@ func (m *MergeRequests) recordPush(ctx context.Context, name domain.RepoName) { | |||||
| 1139 | } | 1219 | } | ||
| 1140 | 1220 | ||||
| 1141 | // moveTarget moves the target branch from tip to the head (fast-forward) or to | 1221 | // moveTarget moves the target branch from tip to the head (fast-forward) or to | ||
| 1142 | - | // a new signed squash commit, and returns the new tip. | 1222 | + | // a new signed squash or merge commit with the given tree, and returns the new tip. |
| 1143 | - | func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID, p domain.Principal) (domain.ObjectID, error) { | 1223 | + | func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head, tree domain.ObjectID, p domain.Principal) (domain.ObjectID, error) { |
| 1144 | newTip := head | 1224 | newTip := head | ||
| 1145 | - | if mode == domain.MergeModeSquash { | 1225 | + | switch mode { |
| 1226 | + | case domain.MergeModeMerge: | |||
| 1227 | + | commit := domain.NewMergeCommit(mr, tree, tip, head, m.committer, m.clock.Now()) | |||
| 1228 | + | armored, err := m.signer.Sign(commit.Payload()) | |||
| 1229 | + | if err != nil { | |||
| 1230 | + | return domain.ObjectID{}, fmt.Errorf("sign the merge commit of !%d in %q: %w", mr.Number, mr.Repo, err) | |||
| 1231 | + | } | |||
| 1232 | + | if newTip, err = m.git.WriteCommit(ctx, mr.Repo, commit.Signed(armored)); err != nil { | |||
| 1233 | + | return domain.ObjectID{}, err | |||
| 1234 | + | } | |||
| 1235 | + | case domain.MergeModeSquash: | |||
| 1146 | commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits) | 1236 | commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits) | ||
| 1147 | if err != nil { | 1237 | if err != nil { | ||
| 1148 | return domain.ObjectID{}, err | 1238 | return domain.ObjectID{}, err | ||
| @@ -1150,10 +1240,6 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, | |||||
| 1150 | if more { | 1240 | if more { | ||
| 1151 | return domain.ObjectID{}, fmt.Errorf("%w: the branch has more than %d commits to squash", domain.ErrPreconditionFailed, domain.MaxSquashCommits) | 1241 | return domain.ObjectID{}, fmt.Errorf("%w: the branch has more than %d commits to squash", domain.ErrPreconditionFailed, domain.MaxSquashCommits) | ||
| 1152 | } | 1242 | } | ||
| 1153 | - | tree, err := m.git.CommitTree(ctx, mr.Repo, head) | |||
| 1154 | - | if err != nil { | |||
| 1155 | - | return domain.ObjectID{}, err | |||
| 1156 | - | } | |||
| 1157 | commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now()) | 1243 | commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now()) | ||
| 1158 | armored, err := m.signer.Sign(commit.Payload()) | 1244 | armored, err := m.signer.Sign(commit.Payload()) | ||
| 1159 | if err != nil { | 1245 | if err != nil { | ||
M internal/usecase/mergerequests_delete_test.go +3 -1
| @@ -31,6 +31,7 @@ func TestMergeDeletesTheSourceBranch(t *testing.T) { | |||
| 31 | 31 | require.NoError(t, err) | |
| 32 | 32 | assert.True(t, res.SourceDeleted) | |
| 33 | 33 | assert.Equal(t, domain.MRStateMerged, res.State) | |
| 34 | + | r.mrs.Wait(r.ctx) // the deletion happens after Merge returned | |
| 34 | 35 | _, err = r.git.BranchHead(r.ctx, r.repo, "feat") | |
| 35 | 36 | assert.ErrorIs(t, err, domain.ErrNotFound, "the branch is gone") | |
| 36 | 37 | ||
| @@ -192,8 +193,9 @@ func TestMergeKeepsASourceThatMovedAfterTheMerge(t *testing.T) { | |||
| 192 | 193 | ||
| 193 | 194 | res, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |
| 194 | 195 | require.NoError(t, err, "the merge stands") | |
| 195 | - | assert.False(t, res.SourceDeleted) | |
| 196 | + | assert.True(t, res.SourceDeleted, "the deletion was requested") | |
| 196 | 197 | assert.Equal(t, domain.MRStateMerged, res.State) | |
| 198 | + | mrs.Wait(r.ctx) | |
| 197 | 199 | assert.Equal(t, head, r.branchHead("main")) | |
| 198 | 200 | assert.Equal(t, moved, r.branchHead("feat"), "the late commit is kept") | |
| 199 | 201 | evs := r.drain() | |
| @@ -31,6 +31,7 @@ func TestMergeDeletesTheSourceBranch(t *testing.T) { | |||||
| 31 | require.NoError(t, err) | 31 | require.NoError(t, err) | ||
| 32 | assert.True(t, res.SourceDeleted) | 32 | assert.True(t, res.SourceDeleted) | ||
| 33 | assert.Equal(t, domain.MRStateMerged, res.State) | 33 | assert.Equal(t, domain.MRStateMerged, res.State) | ||
| 34 | + | r.mrs.Wait(r.ctx) // the deletion happens after Merge returned | |||
| 34 | _, err = r.git.BranchHead(r.ctx, r.repo, "feat") | 35 | _, err = r.git.BranchHead(r.ctx, r.repo, "feat") | ||
| 35 | assert.ErrorIs(t, err, domain.ErrNotFound, "the branch is gone") | 36 | assert.ErrorIs(t, err, domain.ErrNotFound, "the branch is gone") | ||
| 36 | 37 | ||||
| @@ -192,8 +193,9 @@ func TestMergeKeepsASourceThatMovedAfterTheMerge(t *testing.T) { | |||||
| 192 | 193 | ||||
| 193 | res, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | 194 | res, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | ||
| 194 | require.NoError(t, err, "the merge stands") | 195 | require.NoError(t, err, "the merge stands") | ||
| 195 | - | assert.False(t, res.SourceDeleted) | 196 | + | assert.True(t, res.SourceDeleted, "the deletion was requested") |
| 196 | assert.Equal(t, domain.MRStateMerged, res.State) | 197 | assert.Equal(t, domain.MRStateMerged, res.State) | ||
| 198 | + | mrs.Wait(r.ctx) | |||
| 197 | assert.Equal(t, head, r.branchHead("main")) | 199 | assert.Equal(t, head, r.branchHead("main")) | ||
| 198 | assert.Equal(t, moved, r.branchHead("feat"), "the late commit is kept") | 200 | assert.Equal(t, moved, r.branchHead("feat"), "the late commit is kept") | ||
| 199 | evs := r.drain() | 201 | evs := r.drain() | ||
M internal/usecase/mergerequests_integration_test.go +11 -6
| @@ -207,6 +207,7 @@ func (r *mrRig) open(branch string) domain.MergeRequest { | |||
| 207 | 207 | // subscriber before Publish returns: whatever a returned call published is | |
| 208 | 208 | // already in the channel. | |
| 209 | 209 | func (r *mrRig) drain() []domain.Event { | |
| 210 | + | r.mrs.Wait(r.ctx) // the events of a merge are published after it returned | |
| 210 | 211 | var out []domain.Event | |
| 211 | 212 | for { | |
| 212 | 213 | select { | |
| @@ -959,11 +960,10 @@ func TestMergeRefusals(t *testing.T) { | |||
| 959 | 960 | r.push("main") | |
| 960 | 961 | tip := r.branchHead("main") | |
| 961 | 962 | ||
| 962 | - | for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} { | |
| 963 | - | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode}) | |
| 964 | - | require.ErrorIs(t, err, domain.ErrPreconditionFailed, mode) | |
| 965 | - | assert.Contains(t, err.Error(), "rebase", mode) | |
| 966 | - | } | |
| 963 | + | // Only a fast-forward needs the target to be an ancestor. | |
| 964 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward}) | |
| 965 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 966 | + | assert.Contains(t, err.Error(), "the target has moved: rebase and push again") | |
| 967 | 967 | assert.Equal(t, tip, r.branchHead("main")) | |
| 968 | 968 | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 969 | 969 | require.NoError(t, err) | |
| @@ -1047,8 +1047,13 @@ func TestConcurrentMergesSerialize(t *testing.T) { | |||
| 1047 | 1047 | const n = 6 | |
| 1048 | 1048 | heads := map[int64]domain.ObjectID{} | |
| 1049 | 1049 | for i := range n { | |
| 1050 | + | // Every branch writes the same file differently: once one is merged the | |
| 1051 | + | // others neither fast-forward nor merge cleanly. | |
| 1050 | 1052 | b := fmt.Sprintf("f%d", i) | |
| 1051 | - | heads[r.openFeature(b).Number] = r.branchHead(b) | |
| 1053 | + | r.sh("checkout", "-q", "-B", b, "main") | |
| 1054 | + | r.commit(author, "same.txt", b+"\n", "add "+b) | |
| 1055 | + | r.push(b) | |
| 1056 | + | heads[r.open(b).Number] = r.branchHead(b) | |
| 1052 | 1057 | } | |
| 1053 | 1058 | ||
| 1054 | 1059 | type outcome struct { | |
| @@ -207,6 +207,7 @@ func (r *mrRig) open(branch string) domain.MergeRequest { | |||||
| 207 | // subscriber before Publish returns: whatever a returned call published is | 207 | // subscriber before Publish returns: whatever a returned call published is | ||
| 208 | // already in the channel. | 208 | // already in the channel. | ||
| 209 | func (r *mrRig) drain() []domain.Event { | 209 | func (r *mrRig) drain() []domain.Event { | ||
| 210 | + | r.mrs.Wait(r.ctx) // the events of a merge are published after it returned | |||
| 210 | var out []domain.Event | 211 | var out []domain.Event | ||
| 211 | for { | 212 | for { | ||
| 212 | select { | 213 | select { | ||
| @@ -959,11 +960,10 @@ func TestMergeRefusals(t *testing.T) { | |||||
| 959 | r.push("main") | 960 | r.push("main") | ||
| 960 | tip := r.branchHead("main") | 961 | tip := r.branchHead("main") | ||
| 961 | 962 | ||||
| 962 | - | for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} { | 963 | + | // Only a fast-forward needs the target to be an ancestor. |
| 963 | - | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode}) | 964 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward}) |
| 964 | - | require.ErrorIs(t, err, domain.ErrPreconditionFailed, mode) | 965 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) |
| 965 | - | assert.Contains(t, err.Error(), "rebase", mode) | 966 | + | assert.Contains(t, err.Error(), "the target has moved: rebase and push again") |
| 966 | - | } | |||
| 967 | assert.Equal(t, tip, r.branchHead("main")) | 967 | assert.Equal(t, tip, r.branchHead("main")) | ||
| 968 | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | 968 | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | ||
| 969 | require.NoError(t, err) | 969 | require.NoError(t, err) | ||
| @@ -1047,8 +1047,13 @@ func TestConcurrentMergesSerialize(t *testing.T) { | |||||
| 1047 | const n = 6 | 1047 | const n = 6 | ||
| 1048 | heads := map[int64]domain.ObjectID{} | 1048 | heads := map[int64]domain.ObjectID{} | ||
| 1049 | for i := range n { | 1049 | for i := range n { | ||
| 1050 | + | // Every branch writes the same file differently: once one is merged the | |||
| 1051 | + | // others neither fast-forward nor merge cleanly. | |||
| 1050 | b := fmt.Sprintf("f%d", i) | 1052 | b := fmt.Sprintf("f%d", i) | ||
| 1051 | - | heads[r.openFeature(b).Number] = r.branchHead(b) | 1053 | + | r.sh("checkout", "-q", "-B", b, "main") |
| 1054 | + | r.commit(author, "same.txt", b+"\n", "add "+b) | |||
| 1055 | + | r.push(b) | |||
| 1056 | + | heads[r.open(b).Number] = r.branchHead(b) | |||
| 1052 | } | 1057 | } | ||
| 1053 | 1058 | ||||
| 1054 | type outcome struct { | 1059 | type outcome struct { | ||
A internal/usecase/mergerequests_merge_test.go +293 -0
| @@ -0,0 +1,293 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "crypto/ed25519" | |
| 9 | + | "strings" | |
| 10 | + | "sync" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/adapters/sshsig" | |
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | "bnema.dev/ermite/internal/usecase" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // gateHooks is a CI and a pull_request trigger that block on a channel, to | |
| 24 | + | // tell what runs before Merge returns. | |
| 25 | + | type gateHooks struct { | |
| 26 | + | release chan struct{} | |
| 27 | + | mu sync.Mutex | |
| 28 | + | calls []string | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func newGateHooks() *gateHooks { return &gateHooks{release: make(chan struct{})} } | |
| 32 | + | ||
| 33 | + | func (g *gateHooks) note(s string) { | |
| 34 | + | <-g.release | |
| 35 | + | g.mu.Lock() | |
| 36 | + | defer g.mu.Unlock() | |
| 37 | + | g.calls = append(g.calls, s) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (g *gateHooks) ran() []string { | |
| 41 | + | g.mu.Lock() | |
| 42 | + | defer g.mu.Unlock() | |
| 43 | + | return append([]string(nil), g.calls...) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (g *gateHooks) OnPush(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {} | |
| 47 | + | ||
| 48 | + | func (g *gateHooks) OnMerge(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) { | |
| 49 | + | g.note("ci") | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // OnPullRequest blocks for the closing of a request only: Create reports the | |
| 53 | + | // opening on the request path. | |
| 54 | + | func (g *gateHooks) OnPullRequest(_ context.Context, _ domain.RepoName, ev ports.PullRequestEvent, _ domain.Principal) { | |
| 55 | + | if ev.Action == domain.PullClosed { | |
| 56 | + | g.note("pulls") | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // gated builds merge request use cases over the rig whose CI and pull hooks | |
| 61 | + | // block until g.release is closed. | |
| 62 | + | func (r *mrRig) gated(g *gateHooks) *usecase.MergeRequests { | |
| 63 | + | signer, err := sshsig.New(bytes.Repeat([]byte{9}, ed25519.SeedSize)) | |
| 64 | + | require.NoError(r.t, err) | |
| 65 | + | return usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | |
| 66 | + | Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: signer, | |
| 67 | + | Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, | |
| 68 | + | Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, CI: g, PullRequests: g, | |
| 69 | + | }) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // behind moves main on with a commit of its own (a file the feature does not | |
| 73 | + | // touch), and returns the new tip. | |
| 74 | + | func (r *mrRig) behind() domain.ObjectID { | |
| 75 | + | r.sh("checkout", "-q", "main") | |
| 76 | + | r.commit(author, "news.txt", "n\n", "main moved") | |
| 77 | + | r.push("main") | |
| 78 | + | return r.branchHead("main") | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestMergeModesWhenBehind(t *testing.T) { | |
| 82 | + | t.Parallel() | |
| 83 | + | t.Run("fast-forward is refused", func(t *testing.T) { | |
| 84 | + | t.Parallel() | |
| 85 | + | r := newMRRig(t) | |
| 86 | + | r.seed() | |
| 87 | + | head := r.feature("feat") | |
| 88 | + | mr := r.open("feat") | |
| 89 | + | tip := r.behind() | |
| 90 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 91 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 92 | + | assert.Contains(t, err.Error(), "the target has moved: rebase and push again, or merge with squash or a merge commit") | |
| 93 | + | assert.Equal(t, tip, r.branchHead("main")) | |
| 94 | + | }) | |
| 95 | + | ||
| 96 | + | t.Run("squash merges the merged tree", func(t *testing.T) { | |
| 97 | + | t.Parallel() | |
| 98 | + | r := newMRRig(t) | |
| 99 | + | r.seed() | |
| 100 | + | head := r.feature("feat") | |
| 101 | + | mr := r.open("feat") | |
| 102 | + | tip := r.behind() | |
| 103 | + | res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash}) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | sq := res.MergedCommitID.String() | |
| 106 | + | g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) } | |
| 107 | + | assert.Equal(t, tip.String(), g("rev-parse", sq+"^"), "the current tip is the parent") | |
| 108 | + | files := g("ls-tree", "-r", "--name-only", sq) | |
| 109 | + | assert.Contains(t, files, "news.txt", "the target's own change is kept") | |
| 110 | + | assert.Contains(t, files, "c.txt", "the branch's change is there") | |
| 111 | + | r.verifyCommit(sq) | |
| 112 | + | }) | |
| 113 | + | ||
| 114 | + | t.Run("merge commit has two parents, the merge tree and a good signature", func(t *testing.T) { | |
| 115 | + | t.Parallel() | |
| 116 | + | r := newMRRig(t) | |
| 117 | + | r.seed() | |
| 118 | + | head := r.feature("feat") | |
| 119 | + | mr := r.open("feat") | |
| 120 | + | tip := r.behind() | |
| 121 | + | want := r.run(r.bare(), r.env(author), "merge-tree", "--write-tree", tip.String(), head.String()) | |
| 122 | + | res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeMerge}) | |
| 123 | + | require.NoError(t, err) | |
| 124 | + | assert.Equal(t, domain.MergeModeMerge, res.MergeMode) | |
| 125 | + | assert.Equal(t, tip, res.BaseID) | |
| 126 | + | id := res.MergedCommitID.String() | |
| 127 | + | assert.Equal(t, id, r.branchHead("main").String()) | |
| 128 | + | g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) } | |
| 129 | + | assert.Equal(t, tip.String()+" "+head.String(), g("log", "-1", "--format=%P", id), "parents: target, head") | |
| 130 | + | assert.Equal(t, strings.TrimSpace(want), g("rev-parse", id+"^{tree}")) | |
| 131 | + | assert.Equal(t, "Merge branch 'feat' into main (!1)\n\nAdd feat", g("log", "-1", "--format=%B", id)) | |
| 132 | + | assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%an <%ae>", id)) | |
| 133 | + | assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%cn <%ce>", id)) | |
| 134 | + | r.verifyCommit(id) | |
| 135 | + | }) | |
| 136 | + | ||
| 137 | + | t.Run("merge commit when up to date", func(t *testing.T) { | |
| 138 | + | t.Parallel() | |
| 139 | + | r := newMRRig(t) | |
| 140 | + | main := r.seed() | |
| 141 | + | head := r.feature("feat") | |
| 142 | + | mr := r.open("feat") | |
| 143 | + | res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeMerge}) | |
| 144 | + | require.NoError(t, err) | |
| 145 | + | id := res.MergedCommitID.String() | |
| 146 | + | g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) } | |
| 147 | + | assert.Equal(t, main.String()+" "+head.String(), g("log", "-1", "--format=%P", id)) | |
| 148 | + | assert.Equal(t, g("rev-parse", head.String()+"^{tree}"), g("rev-parse", id+"^{tree}")) | |
| 149 | + | }) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestMergeRefusedOnConflictAndNoCommonHistory(t *testing.T) { | |
| 153 | + | t.Parallel() | |
| 154 | + | conflict := func(t *testing.T) (*mrRig, domain.MergeRequest, domain.ObjectID, domain.ObjectID) { | |
| 155 | + | r := newMRRig(t) | |
| 156 | + | r.seed() | |
| 157 | + | head := r.feature("feat") // changes line 2 of a.txt | |
| 158 | + | mr := r.open("feat") | |
| 159 | + | r.sh("checkout", "-q", "main") | |
| 160 | + | r.commit(author, "a.txt", "one\nTWO\nthree\n", "main changes a too") | |
| 161 | + | r.push("main") | |
| 162 | + | return r, mr, head, r.branchHead("main") | |
| 163 | + | } | |
| 164 | + | for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge} { | |
| 165 | + | t.Run("conflict "+string(mode), func(t *testing.T) { | |
| 166 | + | t.Parallel() | |
| 167 | + | r, mr, head, tip := conflict(t) | |
| 168 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode}) | |
| 169 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 170 | + | if mode != domain.MergeModeFastForward { | |
| 171 | + | assert.Contains(t, err.Error(), "conflicts with main: a.txt: rebase and push again") | |
| 172 | + | } | |
| 173 | + | assert.Equal(t, tip, r.branchHead("main")) | |
| 174 | + | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 175 | + | require.NoError(t, err) | |
| 176 | + | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State) | |
| 177 | + | }) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | for _, mode := range []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeMerge} { | |
| 181 | + | t.Run("no common history "+string(mode), func(t *testing.T) { | |
| 182 | + | t.Parallel() | |
| 183 | + | r := newMRRig(t) | |
| 184 | + | r.seed() | |
| 185 | + | r.sh("checkout", "-q", "--orphan", "island") | |
| 186 | + | r.commit(author, "island.txt", "i\n", "an unrelated root") | |
| 187 | + | r.push("island") | |
| 188 | + | head := r.branchHead("island") | |
| 189 | + | mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "island", Source: "island"}) | |
| 190 | + | if err != nil { | |
| 191 | + | t.Skipf("a request without common history cannot be opened: %v", err) | |
| 192 | + | } | |
| 193 | + | tip := r.branchHead("main") | |
| 194 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode}) | |
| 195 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 196 | + | assert.Contains(t, err.Error(), "no common history") | |
| 197 | + | assert.Equal(t, tip, r.branchHead("main")) | |
| 198 | + | }) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestMergeReturnsBeforeTheSideEffects(t *testing.T) { | |
| 203 | + | t.Parallel() | |
| 204 | + | r := newMRRig(t) | |
| 205 | + | r.seed() | |
| 206 | + | head := r.feature("feat") | |
| 207 | + | mr := r.open("feat") | |
| 208 | + | r.drain() | |
| 209 | + | g := newGateHooks() | |
| 210 | + | mrs := r.gated(g) | |
| 211 | + | ||
| 212 | + | done := make(chan error, 1) | |
| 213 | + | go func() { | |
| 214 | + | _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |
| 215 | + | done <- err | |
| 216 | + | }() | |
| 217 | + | select { | |
| 218 | + | case err := <-done: | |
| 219 | + | require.NoError(t, err, "Merge does not wait for the blocked hooks") | |
| 220 | + | case <-time.After(10 * time.Second): | |
| 221 | + | t.Fatal("Merge waited for the side effects") | |
| 222 | + | } | |
| 223 | + | // The merge is complete and recorded though the hooks have not run. | |
| 224 | + | d, err := mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 225 | + | require.NoError(t, err) | |
| 226 | + | assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State) | |
| 227 | + | assert.Equal(t, d.MergeRequest.MergedCommitID, r.branchHead("main")) | |
| 228 | + | assert.Empty(t, g.ran()) | |
| 229 | + | ||
| 230 | + | // Wait waits for them: it times out while they are blocked, then returns. | |
| 231 | + | ctx, cancel := context.WithTimeout(r.ctx, 50*time.Millisecond) | |
| 232 | + | start := time.Now() | |
| 233 | + | mrs.Wait(ctx) | |
| 234 | + | cancel() | |
| 235 | + | assert.GreaterOrEqual(t, time.Since(start), 40*time.Millisecond, "Wait waits for the pending side effects") | |
| 236 | + | ||
| 237 | + | waited := make(chan struct{}) | |
| 238 | + | go func() { mrs.Wait(context.Background()); close(waited) }() | |
| 239 | + | select { | |
| 240 | + | case <-waited: | |
| 241 | + | t.Fatal("Wait returned with side effects pending") | |
| 242 | + | case <-time.After(50 * time.Millisecond): | |
| 243 | + | } | |
| 244 | + | close(g.release) | |
| 245 | + | select { | |
| 246 | + | case <-waited: | |
| 247 | + | case <-time.After(10 * time.Second): | |
| 248 | + | t.Fatal("Wait did not return after the side effects ended") | |
| 249 | + | } | |
| 250 | + | assert.ElementsMatch(t, []string{"ci", "pulls"}, g.ran()) | |
| 251 | + | _, err = r.git.BranchHead(r.ctx, r.repo, "feat") | |
| 252 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "the source was deleted") | |
| 253 | + | ||
| 254 | + | // Merged, then pushed, with the deletion in the push. | |
| 255 | + | evs := r.drain() | |
| 256 | + | require.Len(t, evs, 2) | |
| 257 | + | assert.IsType(t, domain.MergeRequestMerged{}, evs[0]) | |
| 258 | + | push, ok := evs[1].(domain.PushEvent) | |
| 259 | + | require.True(t, ok) | |
| 260 | + | assert.Len(t, push.Updates, 2) | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func TestMergeReleasesTheLockBeforeTheSideEffects(t *testing.T) { | |
| 264 | + | t.Parallel() | |
| 265 | + | r := newMRRig(t) | |
| 266 | + | r.seed() | |
| 267 | + | head := r.feature("feat") | |
| 268 | + | mr := r.open("feat") | |
| 269 | + | r.sh("checkout", "-q", "main") | |
| 270 | + | other := r.openFeature("other") | |
| 271 | + | g := newGateHooks() | |
| 272 | + | defer func() { | |
| 273 | + | close(g.release) | |
| 274 | + | }() | |
| 275 | + | mrs := r.gated(g) | |
| 276 | + | ||
| 277 | + | _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 278 | + | require.NoError(t, err) | |
| 279 | + | t.Cleanup(func() { mrs.Wait(context.Background()) }) | |
| 280 | + | require.Empty(t, g.ran(), "the side effects are still blocked") | |
| 281 | + | ||
| 282 | + | // Create and another Merge need the repository lock: they must finish. | |
| 283 | + | ctx, cancel := context.WithTimeout(r.ctx, 10*time.Second) | |
| 284 | + | defer cancel() | |
| 285 | + | r.sh("checkout", "-q", "-B", "third", "main") | |
| 286 | + | r.commit(author, "third.txt", "t\n", "third") | |
| 287 | + | r.push("third") | |
| 288 | + | _, err = mrs.Create(ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "third", Source: "third"}) | |
| 289 | + | require.NoError(t, err, "Create is not blocked by the side effects of a merge") | |
| 290 | + | _, err = mrs.Merge(ctx, mrM, r.repo, other.Number, ports.MergeParams{HeadID: r.branchHead("other"), Mode: domain.MergeModeMerge}) | |
| 291 | + | require.NoError(t, err, "a second merge is not blocked either") | |
| 292 | + | assert.Empty(t, g.ran()) | |
| 293 | + | } | |
| @@ -0,0 +1,293 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "crypto/ed25519" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/stretchr/testify/assert" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ||||
| 17 | + | "bnema.dev/ermite/internal/adapters/sshsig" | |||
| 18 | + | "bnema.dev/ermite/internal/domain" | |||
| 19 | + | "bnema.dev/ermite/internal/ports" | |||
| 20 | + | "bnema.dev/ermite/internal/usecase" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // gateHooks is a CI and a pull_request trigger that block on a channel, to | |||
| 24 | + | // tell what runs before Merge returns. | |||
| 25 | + | type gateHooks struct { | |||
| 26 | + | release chan struct{} | |||
| 27 | + | mu sync.Mutex | |||
| 28 | + | calls []string | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func newGateHooks() *gateHooks { return &gateHooks{release: make(chan struct{})} } | |||
| 32 | + | ||||
| 33 | + | func (g *gateHooks) note(s string) { | |||
| 34 | + | <-g.release | |||
| 35 | + | g.mu.Lock() | |||
| 36 | + | defer g.mu.Unlock() | |||
| 37 | + | g.calls = append(g.calls, s) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func (g *gateHooks) ran() []string { | |||
| 41 | + | g.mu.Lock() | |||
| 42 | + | defer g.mu.Unlock() | |||
| 43 | + | return append([]string(nil), g.calls...) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (g *gateHooks) OnPush(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {} | |||
| 47 | + | ||||
| 48 | + | func (g *gateHooks) OnMerge(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) { | |||
| 49 | + | g.note("ci") | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // OnPullRequest blocks for the closing of a request only: Create reports the | |||
| 53 | + | // opening on the request path. | |||
| 54 | + | func (g *gateHooks) OnPullRequest(_ context.Context, _ domain.RepoName, ev ports.PullRequestEvent, _ domain.Principal) { | |||
| 55 | + | if ev.Action == domain.PullClosed { | |||
| 56 | + | g.note("pulls") | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // gated builds merge request use cases over the rig whose CI and pull hooks | |||
| 61 | + | // block until g.release is closed. | |||
| 62 | + | func (r *mrRig) gated(g *gateHooks) *usecase.MergeRequests { | |||
| 63 | + | signer, err := sshsig.New(bytes.Repeat([]byte{9}, ed25519.SeedSize)) | |||
| 64 | + | require.NoError(r.t, err) | |||
| 65 | + | return usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | |||
| 66 | + | Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: signer, | |||
| 67 | + | Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, | |||
| 68 | + | Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, CI: g, PullRequests: g, | |||
| 69 | + | }) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // behind moves main on with a commit of its own (a file the feature does not | |||
| 73 | + | // touch), and returns the new tip. | |||
| 74 | + | func (r *mrRig) behind() domain.ObjectID { | |||
| 75 | + | r.sh("checkout", "-q", "main") | |||
| 76 | + | r.commit(author, "news.txt", "n\n", "main moved") | |||
| 77 | + | r.push("main") | |||
| 78 | + | return r.branchHead("main") | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestMergeModesWhenBehind(t *testing.T) { | |||
| 82 | + | t.Parallel() | |||
| 83 | + | t.Run("fast-forward is refused", func(t *testing.T) { | |||
| 84 | + | t.Parallel() | |||
| 85 | + | r := newMRRig(t) | |||
| 86 | + | r.seed() | |||
| 87 | + | head := r.feature("feat") | |||
| 88 | + | mr := r.open("feat") | |||
| 89 | + | tip := r.behind() | |||
| 90 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |||
| 91 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |||
| 92 | + | assert.Contains(t, err.Error(), "the target has moved: rebase and push again, or merge with squash or a merge commit") | |||
| 93 | + | assert.Equal(t, tip, r.branchHead("main")) | |||
| 94 | + | }) | |||
| 95 | + | ||||
| 96 | + | t.Run("squash merges the merged tree", func(t *testing.T) { | |||
| 97 | + | t.Parallel() | |||
| 98 | + | r := newMRRig(t) | |||
| 99 | + | r.seed() | |||
| 100 | + | head := r.feature("feat") | |||
| 101 | + | mr := r.open("feat") | |||
| 102 | + | tip := r.behind() | |||
| 103 | + | res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash}) | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | sq := res.MergedCommitID.String() | |||
| 106 | + | g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) } | |||
| 107 | + | assert.Equal(t, tip.String(), g("rev-parse", sq+"^"), "the current tip is the parent") | |||
| 108 | + | files := g("ls-tree", "-r", "--name-only", sq) | |||
| 109 | + | assert.Contains(t, files, "news.txt", "the target's own change is kept") | |||
| 110 | + | assert.Contains(t, files, "c.txt", "the branch's change is there") | |||
| 111 | + | r.verifyCommit(sq) | |||
| 112 | + | }) | |||
| 113 | + | ||||
| 114 | + | t.Run("merge commit has two parents, the merge tree and a good signature", func(t *testing.T) { | |||
| 115 | + | t.Parallel() | |||
| 116 | + | r := newMRRig(t) | |||
| 117 | + | r.seed() | |||
| 118 | + | head := r.feature("feat") | |||
| 119 | + | mr := r.open("feat") | |||
| 120 | + | tip := r.behind() | |||
| 121 | + | want := r.run(r.bare(), r.env(author), "merge-tree", "--write-tree", tip.String(), head.String()) | |||
| 122 | + | res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeMerge}) | |||
| 123 | + | require.NoError(t, err) | |||
| 124 | + | assert.Equal(t, domain.MergeModeMerge, res.MergeMode) | |||
| 125 | + | assert.Equal(t, tip, res.BaseID) | |||
| 126 | + | id := res.MergedCommitID.String() | |||
| 127 | + | assert.Equal(t, id, r.branchHead("main").String()) | |||
| 128 | + | g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) } | |||
| 129 | + | assert.Equal(t, tip.String()+" "+head.String(), g("log", "-1", "--format=%P", id), "parents: target, head") | |||
| 130 | + | assert.Equal(t, strings.TrimSpace(want), g("rev-parse", id+"^{tree}")) | |||
| 131 | + | assert.Equal(t, "Merge branch 'feat' into main (!1)\n\nAdd feat", g("log", "-1", "--format=%B", id)) | |||
| 132 | + | assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%an <%ae>", id)) | |||
| 133 | + | assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%cn <%ce>", id)) | |||
| 134 | + | r.verifyCommit(id) | |||
| 135 | + | }) | |||
| 136 | + | ||||
| 137 | + | t.Run("merge commit when up to date", func(t *testing.T) { | |||
| 138 | + | t.Parallel() | |||
| 139 | + | r := newMRRig(t) | |||
| 140 | + | main := r.seed() | |||
| 141 | + | head := r.feature("feat") | |||
| 142 | + | mr := r.open("feat") | |||
| 143 | + | res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeMerge}) | |||
| 144 | + | require.NoError(t, err) | |||
| 145 | + | id := res.MergedCommitID.String() | |||
| 146 | + | g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) } | |||
| 147 | + | assert.Equal(t, main.String()+" "+head.String(), g("log", "-1", "--format=%P", id)) | |||
| 148 | + | assert.Equal(t, g("rev-parse", head.String()+"^{tree}"), g("rev-parse", id+"^{tree}")) | |||
| 149 | + | }) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func TestMergeRefusedOnConflictAndNoCommonHistory(t *testing.T) { | |||
| 153 | + | t.Parallel() | |||
| 154 | + | conflict := func(t *testing.T) (*mrRig, domain.MergeRequest, domain.ObjectID, domain.ObjectID) { | |||
| 155 | + | r := newMRRig(t) | |||
| 156 | + | r.seed() | |||
| 157 | + | head := r.feature("feat") // changes line 2 of a.txt | |||
| 158 | + | mr := r.open("feat") | |||
| 159 | + | r.sh("checkout", "-q", "main") | |||
| 160 | + | r.commit(author, "a.txt", "one\nTWO\nthree\n", "main changes a too") | |||
| 161 | + | r.push("main") | |||
| 162 | + | return r, mr, head, r.branchHead("main") | |||
| 163 | + | } | |||
| 164 | + | for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge} { | |||
| 165 | + | t.Run("conflict "+string(mode), func(t *testing.T) { | |||
| 166 | + | t.Parallel() | |||
| 167 | + | r, mr, head, tip := conflict(t) | |||
| 168 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode}) | |||
| 169 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |||
| 170 | + | if mode != domain.MergeModeFastForward { | |||
| 171 | + | assert.Contains(t, err.Error(), "conflicts with main: a.txt: rebase and push again") | |||
| 172 | + | } | |||
| 173 | + | assert.Equal(t, tip, r.branchHead("main")) | |||
| 174 | + | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |||
| 175 | + | require.NoError(t, err) | |||
| 176 | + | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State) | |||
| 177 | + | }) | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | for _, mode := range []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeMerge} { | |||
| 181 | + | t.Run("no common history "+string(mode), func(t *testing.T) { | |||
| 182 | + | t.Parallel() | |||
| 183 | + | r := newMRRig(t) | |||
| 184 | + | r.seed() | |||
| 185 | + | r.sh("checkout", "-q", "--orphan", "island") | |||
| 186 | + | r.commit(author, "island.txt", "i\n", "an unrelated root") | |||
| 187 | + | r.push("island") | |||
| 188 | + | head := r.branchHead("island") | |||
| 189 | + | mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "island", Source: "island"}) | |||
| 190 | + | if err != nil { | |||
| 191 | + | t.Skipf("a request without common history cannot be opened: %v", err) | |||
| 192 | + | } | |||
| 193 | + | tip := r.branchHead("main") | |||
| 194 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode}) | |||
| 195 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |||
| 196 | + | assert.Contains(t, err.Error(), "no common history") | |||
| 197 | + | assert.Equal(t, tip, r.branchHead("main")) | |||
| 198 | + | }) | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func TestMergeReturnsBeforeTheSideEffects(t *testing.T) { | |||
| 203 | + | t.Parallel() | |||
| 204 | + | r := newMRRig(t) | |||
| 205 | + | r.seed() | |||
| 206 | + | head := r.feature("feat") | |||
| 207 | + | mr := r.open("feat") | |||
| 208 | + | r.drain() | |||
| 209 | + | g := newGateHooks() | |||
| 210 | + | mrs := r.gated(g) | |||
| 211 | + | ||||
| 212 | + | done := make(chan error, 1) | |||
| 213 | + | go func() { | |||
| 214 | + | _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |||
| 215 | + | done <- err | |||
| 216 | + | }() | |||
| 217 | + | select { | |||
| 218 | + | case err := <-done: | |||
| 219 | + | require.NoError(t, err, "Merge does not wait for the blocked hooks") | |||
| 220 | + | case <-time.After(10 * time.Second): | |||
| 221 | + | t.Fatal("Merge waited for the side effects") | |||
| 222 | + | } | |||
| 223 | + | // The merge is complete and recorded though the hooks have not run. | |||
| 224 | + | d, err := mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |||
| 225 | + | require.NoError(t, err) | |||
| 226 | + | assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State) | |||
| 227 | + | assert.Equal(t, d.MergeRequest.MergedCommitID, r.branchHead("main")) | |||
| 228 | + | assert.Empty(t, g.ran()) | |||
| 229 | + | ||||
| 230 | + | // Wait waits for them: it times out while they are blocked, then returns. | |||
| 231 | + | ctx, cancel := context.WithTimeout(r.ctx, 50*time.Millisecond) | |||
| 232 | + | start := time.Now() | |||
| 233 | + | mrs.Wait(ctx) | |||
| 234 | + | cancel() | |||
| 235 | + | assert.GreaterOrEqual(t, time.Since(start), 40*time.Millisecond, "Wait waits for the pending side effects") | |||
| 236 | + | ||||
| 237 | + | waited := make(chan struct{}) | |||
| 238 | + | go func() { mrs.Wait(context.Background()); close(waited) }() | |||
| 239 | + | select { | |||
| 240 | + | case <-waited: | |||
| 241 | + | t.Fatal("Wait returned with side effects pending") | |||
| 242 | + | case <-time.After(50 * time.Millisecond): | |||
| 243 | + | } | |||
| 244 | + | close(g.release) | |||
| 245 | + | select { | |||
| 246 | + | case <-waited: | |||
| 247 | + | case <-time.After(10 * time.Second): | |||
| 248 | + | t.Fatal("Wait did not return after the side effects ended") | |||
| 249 | + | } | |||
| 250 | + | assert.ElementsMatch(t, []string{"ci", "pulls"}, g.ran()) | |||
| 251 | + | _, err = r.git.BranchHead(r.ctx, r.repo, "feat") | |||
| 252 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "the source was deleted") | |||
| 253 | + | ||||
| 254 | + | // Merged, then pushed, with the deletion in the push. | |||
| 255 | + | evs := r.drain() | |||
| 256 | + | require.Len(t, evs, 2) | |||
| 257 | + | assert.IsType(t, domain.MergeRequestMerged{}, evs[0]) | |||
| 258 | + | push, ok := evs[1].(domain.PushEvent) | |||
| 259 | + | require.True(t, ok) | |||
| 260 | + | assert.Len(t, push.Updates, 2) | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | func TestMergeReleasesTheLockBeforeTheSideEffects(t *testing.T) { | |||
| 264 | + | t.Parallel() | |||
| 265 | + | r := newMRRig(t) | |||
| 266 | + | r.seed() | |||
| 267 | + | head := r.feature("feat") | |||
| 268 | + | mr := r.open("feat") | |||
| 269 | + | r.sh("checkout", "-q", "main") | |||
| 270 | + | other := r.openFeature("other") | |||
| 271 | + | g := newGateHooks() | |||
| 272 | + | defer func() { | |||
| 273 | + | close(g.release) | |||
| 274 | + | }() | |||
| 275 | + | mrs := r.gated(g) | |||
| 276 | + | ||||
| 277 | + | _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |||
| 278 | + | require.NoError(t, err) | |||
| 279 | + | t.Cleanup(func() { mrs.Wait(context.Background()) }) | |||
| 280 | + | require.Empty(t, g.ran(), "the side effects are still blocked") | |||
| 281 | + | ||||
| 282 | + | // Create and another Merge need the repository lock: they must finish. | |||
| 283 | + | ctx, cancel := context.WithTimeout(r.ctx, 10*time.Second) | |||
| 284 | + | defer cancel() | |||
| 285 | + | r.sh("checkout", "-q", "-B", "third", "main") | |||
| 286 | + | r.commit(author, "third.txt", "t\n", "third") | |||
| 287 | + | r.push("third") | |||
| 288 | + | _, err = mrs.Create(ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "third", Source: "third"}) | |||
| 289 | + | require.NoError(t, err, "Create is not blocked by the side effects of a merge") | |||
| 290 | + | _, err = mrs.Merge(ctx, mrM, r.repo, other.Number, ports.MergeParams{HeadID: r.branchHead("other"), Mode: domain.MergeModeMerge}) | |||
| 291 | + | require.NoError(t, err, "a second merge is not blocked either") | |||
| 292 | + | assert.Empty(t, g.ran()) | |||
| 293 | + | } | |||