detached 509893d

fix(cli): require --head for mr merge

Without --head the CLI merged the live head, which defeats the ADR 0017
safeguard. The flag is now required, with an error that tells the user to run
`ermite mr show` to get the head.
commit
509893df1d585883a9b75552a29ad500b1eeffb1
author
brice · · 2026-10-10 17:46 UTC
parent
b87e471
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +38 -44
M cmd/ermite/cli_mr.go +9 -26
@@ -750,35 +750,31 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
750750 "as only parent, signed by the server's key (see `ermite signing-key show`). The target must be an\n" +
751751 "ancestor of the head, else rebase the branch and push it again; the repository settings may allow\n" +
752752 "only one of the modes.\n\n" +
753- "The server merges only if the source branch still points to the head you reviewed. --head gives\n" +
754- "that commit (the head of `mr show`, a full id). Without it the CLI reads the current head and merges\n" +
755- "that, which skips the safeguard: pass --head when others may push to the branch. Needs the\n" +
753+ "The server merges only if the source branch still points to the head you reviewed, so --head is\n" +
754+ "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" +
755+ "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" +
756756 "mr:merge scope." + jsonMRHelp,
757757 Args: cobra.ExactArgs(2),
758758 ValidArgsFunction: completeMRRepoThenNumber(env),
759759 }
760760 cmd.Flags().BoolVar(&squash, "squash", false, "squash the branch into one signed commit instead of a fast-forward")
761- cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed (default: the current head)")
761+ cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)")
762762 asJSON := addJSONFlag(cmd)
763763 cmd.RunE = func(cmd *cobra.Command, args []string) error {
764764 n, err := parseNumber(args[1])
765765 if err != nil {
766766 return err
767767 }
768+ head = strings.TrimSpace(head)
769+ if head == "" {
770+ return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+
771+ "so that only the commit you reviewed is merged", args[0], n)
772+ }
768773 c, err := env.client(cmd, true)
769774 if err != nil {
770775 return err
771776 }
772777 ctx := env.ctx(cmd)
773- head = strings.TrimSpace(head)
774- if head == "" {
775- cur, err := currentHead(ctx, c, args[0], n)
776- if err != nil {
777- return err
778- }
779- head = cur
780- noticef(cmd, "merging %s!%d at %s (no --head given)", clean(args[0]), n, head)
781- }
782778 mode := ermite.MergeMode_MERGE_MODE_FAST_FORWARD
783779 if squash {
784780 mode = ermite.MergeMode_MERGE_MODE_SQUASH
@@ -800,19 +796,6 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
800796 return cmd
801797 }
802798
803-// currentHead reads the head of a merge request from the server.
804-func currentHead(ctx context.Context, c *client.Client, repo string, n int64) (string, error) {
805- res, err := c.MergeRequests().GetMergeRequest(ctx, connect.NewRequest(&ermite.GetMergeRequestRequest{Repo: repo, Number: n}))
806- if err != nil {
807- return "", c.Describe(err)
808- }
809- head := res.Msg.GetMergeRequest().GetHeadId()
810- if head == "" {
811- return "", errors.New("the source branch no longer exists: nothing to merge")
812- }
813- return head, nil
814-}
815-
816799 func newMRCloseCmd(env *cliEnv) *cobra.Command {
817800 cmd := &cobra.Command{
818801 Use: "close <repo> <number>",
@@ -750,35 +750,31 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
750 "as only parent, signed by the server's key (see `ermite signing-key show`). The target must be an\n" +750 "as only parent, signed by the server's key (see `ermite signing-key show`). The target must be an\n" +
751 "ancestor of the head, else rebase the branch and push it again; the repository settings may allow\n" +751 "ancestor of the head, else rebase the branch and push it again; the repository settings may allow\n" +
752 "only one of the modes.\n\n" +752 "only one of the modes.\n\n" +
753- "The server merges only if the source branch still points to the head you reviewed. --head gives\n" +753+ "The server merges only if the source branch still points to the head you reviewed, so --head is\n" +
754- "that commit (the head of `mr show`, a full id). Without it the CLI reads the current head and merges\n" +754+ "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" +
755- "that, which skips the safeguard: pass --head when others may push to the branch. Needs the\n" +755+ "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" +
756 "mr:merge scope." + jsonMRHelp,756 "mr:merge scope." + jsonMRHelp,
757 Args: cobra.ExactArgs(2),757 Args: cobra.ExactArgs(2),
758 ValidArgsFunction: completeMRRepoThenNumber(env),758 ValidArgsFunction: completeMRRepoThenNumber(env),
759 }759 }
760 cmd.Flags().BoolVar(&squash, "squash", false, "squash the branch into one signed commit instead of a fast-forward")760 cmd.Flags().BoolVar(&squash, "squash", false, "squash the branch into one signed commit instead of a fast-forward")
761- cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed (default: the current head)")761+ cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)")
762 asJSON := addJSONFlag(cmd)762 asJSON := addJSONFlag(cmd)
763 cmd.RunE = func(cmd *cobra.Command, args []string) error {763 cmd.RunE = func(cmd *cobra.Command, args []string) error {
764 n, err := parseNumber(args[1])764 n, err := parseNumber(args[1])
765 if err != nil {765 if err != nil {
766 return err766 return err
767 }767 }
768+ head = strings.TrimSpace(head)
769+ if head == "" {
770+ return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+
771+ "so that only the commit you reviewed is merged", args[0], n)
772+ }
768 c, err := env.client(cmd, true)773 c, err := env.client(cmd, true)
769 if err != nil {774 if err != nil {
770 return err775 return err
771 }776 }
772 ctx := env.ctx(cmd)777 ctx := env.ctx(cmd)
773- head = strings.TrimSpace(head)
774- if head == "" {
775- cur, err := currentHead(ctx, c, args[0], n)
776- if err != nil {
777- return err
778- }
779- head = cur
780- noticef(cmd, "merging %s!%d at %s (no --head given)", clean(args[0]), n, head)
781- }
782 mode := ermite.MergeMode_MERGE_MODE_FAST_FORWARD778 mode := ermite.MergeMode_MERGE_MODE_FAST_FORWARD
783 if squash {779 if squash {
784 mode = ermite.MergeMode_MERGE_MODE_SQUASH780 mode = ermite.MergeMode_MERGE_MODE_SQUASH
@@ -800,19 +796,6 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
800 return cmd796 return cmd
801 }797 }
802 798
803-// currentHead reads the head of a merge request from the server.
804-func currentHead(ctx context.Context, c *client.Client, repo string, n int64) (string, error) {
805- res, err := c.MergeRequests().GetMergeRequest(ctx, connect.NewRequest(&ermite.GetMergeRequestRequest{Repo: repo, Number: n}))
806- if err != nil {
807- return "", c.Describe(err)
808- }
809- head := res.Msg.GetMergeRequest().GetHeadId()
810- if head == "" {
811- return "", errors.New("the source branch no longer exists: nothing to merge")
812- }
813- return head, nil
814-}
815-
816 func newMRCloseCmd(env *cliEnv) *cobra.Command {799 func newMRCloseCmd(env *cliEnv) *cobra.Command {
817 cmd := &cobra.Command{800 cmd := &cobra.Command{
818 Use: "close <repo> <number>",801 Use: "close <repo> <number>",
M cmd/ermite/cli_mr_e2e_test.go +6 -4
@@ -259,16 +259,18 @@ func TestMergeRequestsCLIEndToEnd(t *testing.T) {
259259 require.Error(t, res.err)
260260 assert.Contains(t, res.err.Error(), "precondition failed")
261261
262- // Squash, with --squash and without --head (the CLI reads the head and says so).
262+ // Squash, with --squash. Without --head the merge is refused before it reaches the server.
263263 c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3)
264264 c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy")
265265 c.writeFile(wt, "two.txt", []byte("two\n"))
266266 c.mustGit(wt, "push", "-q", url, "feat/two")
267267 c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t)
268268 w.nextOfType(t, "merge_request_opened")
269- res = c.cli(t, secret, "mr", "merge", "proj", "2", "--squash")
269+ _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--squash")
270+ requireOneLineError(t, stderr, code, "--head is required", "ermite mr show proj 2")
271+ head4 := c.bareGit("proj", "rev-parse", "feat/two")
272+ res = c.cli(t, secret, "mr", "merge", "proj", "2", "--squash", "--head", head4)
270273 require.NoError(t, res.err, res.stderr)
271- assert.Contains(t, res.stderr, "no --head given")
272274 assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\n$`, res.stdout)
273275 sq := c.bareGit("proj", "rev-parse", "main")
274276 assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^"))
@@ -335,7 +337,7 @@ func TestSigningKeyShowVerifiesASquashCommit(t *testing.T) {
335337 c.writeFile(wt, "x.txt", []byte("x\n"))
336338 c.mustGit(wt, "push", "-q", url, "main", "feat")
337339 c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat", "--title", "Add x").ok(t)
338- res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--squash")
340+ res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--squash", "--head", c.bareGit("proj", "rev-parse", "feat"))
339341 require.NoError(t, res.err, res.stderr)
340342 sq := c.bareGit("proj", "rev-parse", "main")
341343
@@ -259,16 +259,18 @@ func TestMergeRequestsCLIEndToEnd(t *testing.T) {
259 require.Error(t, res.err)259 require.Error(t, res.err)
260 assert.Contains(t, res.err.Error(), "precondition failed")260 assert.Contains(t, res.err.Error(), "precondition failed")
261 261
262- // Squash, with --squash and without --head (the CLI reads the head and says so).262+ // Squash, with --squash. Without --head the merge is refused before it reaches the server.
263 c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3)263 c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3)
264 c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy")264 c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy")
265 c.writeFile(wt, "two.txt", []byte("two\n"))265 c.writeFile(wt, "two.txt", []byte("two\n"))
266 c.mustGit(wt, "push", "-q", url, "feat/two")266 c.mustGit(wt, "push", "-q", url, "feat/two")
267 c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t)267 c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t)
268 w.nextOfType(t, "merge_request_opened")268 w.nextOfType(t, "merge_request_opened")
269- res = c.cli(t, secret, "mr", "merge", "proj", "2", "--squash")269+ _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--squash")
270+ requireOneLineError(t, stderr, code, "--head is required", "ermite mr show proj 2")
271+ head4 := c.bareGit("proj", "rev-parse", "feat/two")
272+ res = c.cli(t, secret, "mr", "merge", "proj", "2", "--squash", "--head", head4)
270 require.NoError(t, res.err, res.stderr)273 require.NoError(t, res.err, res.stderr)
271- assert.Contains(t, res.stderr, "no --head given")
272 assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\n$`, res.stdout)274 assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\n$`, res.stdout)
273 sq := c.bareGit("proj", "rev-parse", "main")275 sq := c.bareGit("proj", "rev-parse", "main")
274 assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^"))276 assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^"))
@@ -335,7 +337,7 @@ func TestSigningKeyShowVerifiesASquashCommit(t *testing.T) {
335 c.writeFile(wt, "x.txt", []byte("x\n"))337 c.writeFile(wt, "x.txt", []byte("x\n"))
336 c.mustGit(wt, "push", "-q", url, "main", "feat")338 c.mustGit(wt, "push", "-q", url, "main", "feat")
337 c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat", "--title", "Add x").ok(t)339 c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat", "--title", "Add x").ok(t)
338- res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--squash")340+ res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--squash", "--head", c.bareGit("proj", "rev-parse", "feat"))
339 require.NoError(t, res.err, res.stderr)341 require.NoError(t, res.err, res.stderr)
340 sq := c.bareGit("proj", "rev-parse", "main")342 sq := c.bareGit("proj", "rev-parse", "main")
341 343
M cmd/ermite/cli_mr_test.go +23 -14
@@ -271,12 +271,11 @@ func TestMRResolveMergeAndClose(t *testing.T) {
271271 assert.Equal(t, headA, m.GetHeadId())
272272 assert.EqualValues(t, 1, m.GetNumber())
273273
274- // Without --head the CLI reads it, and says so.
275- res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--json").ok(t)
276- assert.Contains(t, res.stderr, "no --head given")
274+ // The default mode is a fast-forward.
275+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t)
277276 assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State)
278277 calls, reqs = f.snapshot()
279- assert.Equal(t, []string{"GetMergeRequest", "MergeMergeRequest"}, calls[3:])
278+ assert.Equal(t, "MergeMergeRequest", calls[len(calls)-1])
280279 m = reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest)
281280 assert.Equal(t, ermite.MergeMode_MERGE_MODE_FAST_FORWARD, m.GetMode(), "fast-forward by default")
282281 assert.Equal(t, headA, m.GetHeadId())
@@ -287,17 +286,27 @@ func TestMRResolveMergeAndClose(t *testing.T) {
287286 assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State)
288287 }
289288
290-func TestMRMergeWithoutHeadWhenTheBranchIsGone(t *testing.T) {
291- gone := proto.Clone(mrOne).(*ermite.MergeRequest)
292- gone.HeadId = ""
293- f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {
294- return &ermite.GetMergeRequestResponse{MergeRequest: gone}, nil
295- }}
296- res := runCLI(t, envFor(f.serve(t)), "", "mr", "merge", "alpha", "1")
297- require.Error(t, res.err)
298- assert.Contains(t, res.err.Error(), "no longer exists")
289+// ADR 0017: the merge is of the commit that was reviewed, so --head is
290+// required, and the CLI does not fill it in from the live head.
291+func TestMRMergeNeedsTheHead(t *testing.T) {
292+ f := &fakeAPI{}
293+ url := f.serve(t)
294+ for _, args := range [][]string{
295+ {"mr", "merge", "alpha", "1"},
296+ {"mr", "merge", "alpha", "1", "--squash"},
297+ {"mr", "merge", "alpha", "1", "--head", " "},
298+ } {
299+ res := runCLI(t, envFor(url), "", args...)
300+ require.Error(t, res.err, args)
301+ assert.Contains(t, res.err.Error(), "--head is required")
302+ assert.Contains(t, res.err.Error(), "ermite mr show alpha 1", "tells where to get the head")
303+ }
299304 calls, _ := f.snapshot()
300- assert.Equal(t, []string{"GetMergeRequest"}, calls)
305+ assert.Empty(t, calls, "nothing reaches the server")
306+
307+ res := runCLI(t, envFor(url), "", "mr", "merge", "--help").ok(t)
308+ assert.Contains(t, res.stdout, "(required)")
309+ assert.NotContains(t, res.stdout, "default: the current head")
301310 }
302311
303312 func TestMRErrorsAreFriendly(t *testing.T) {
@@ -271,12 +271,11 @@ func TestMRResolveMergeAndClose(t *testing.T) {
271 assert.Equal(t, headA, m.GetHeadId())271 assert.Equal(t, headA, m.GetHeadId())
272 assert.EqualValues(t, 1, m.GetNumber())272 assert.EqualValues(t, 1, m.GetNumber())
273 273
274- // Without --head the CLI reads it, and says so.274+ // The default mode is a fast-forward.
275- res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--json").ok(t)275+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t)
276- assert.Contains(t, res.stderr, "no --head given")
277 assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State)276 assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State)
278 calls, reqs = f.snapshot()277 calls, reqs = f.snapshot()
279- assert.Equal(t, []string{"GetMergeRequest", "MergeMergeRequest"}, calls[3:])278+ assert.Equal(t, "MergeMergeRequest", calls[len(calls)-1])
280 m = reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest)279 m = reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest)
281 assert.Equal(t, ermite.MergeMode_MERGE_MODE_FAST_FORWARD, m.GetMode(), "fast-forward by default")280 assert.Equal(t, ermite.MergeMode_MERGE_MODE_FAST_FORWARD, m.GetMode(), "fast-forward by default")
282 assert.Equal(t, headA, m.GetHeadId())281 assert.Equal(t, headA, m.GetHeadId())
@@ -287,17 +286,27 @@ func TestMRResolveMergeAndClose(t *testing.T) {
287 assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State)286 assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State)
288 }287 }
289 288
290-func TestMRMergeWithoutHeadWhenTheBranchIsGone(t *testing.T) {289+// ADR 0017: the merge is of the commit that was reviewed, so --head is
291- gone := proto.Clone(mrOne).(*ermite.MergeRequest)290+// required, and the CLI does not fill it in from the live head.
292- gone.HeadId = ""291+func TestMRMergeNeedsTheHead(t *testing.T) {
293- f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {292+ f := &fakeAPI{}
294- return &ermite.GetMergeRequestResponse{MergeRequest: gone}, nil293+ url := f.serve(t)
295- }}294+ for _, args := range [][]string{
296- res := runCLI(t, envFor(f.serve(t)), "", "mr", "merge", "alpha", "1")295+ {"mr", "merge", "alpha", "1"},
297- require.Error(t, res.err)296+ {"mr", "merge", "alpha", "1", "--squash"},
298- assert.Contains(t, res.err.Error(), "no longer exists")297+ {"mr", "merge", "alpha", "1", "--head", " "},
298+ } {
299+ res := runCLI(t, envFor(url), "", args...)
300+ require.Error(t, res.err, args)
301+ assert.Contains(t, res.err.Error(), "--head is required")
302+ assert.Contains(t, res.err.Error(), "ermite mr show alpha 1", "tells where to get the head")
303+ }
299 calls, _ := f.snapshot()304 calls, _ := f.snapshot()
300- assert.Equal(t, []string{"GetMergeRequest"}, calls)305+ assert.Empty(t, calls, "nothing reaches the server")
306+
307+ res := runCLI(t, envFor(url), "", "mr", "merge", "--help").ok(t)
308+ assert.Contains(t, res.stdout, "(required)")
309+ assert.NotContains(t, res.stdout, "default: the current head")
301 }310 }
302 311
303 func TestMRErrorsAreFriendly(t *testing.T) {312 func TestMRErrorsAreFriendly(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close