detached 4ea27ba

feat(mr): delete the source branch on merge from the web and the CLI

commit
4ea27ba3c2ec2048a29b656d03affce6e70dcb45
author
brice · · 2026-10-11 05:39 UTC
parent
d205ffd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
25 files · +688 -254
M README.md +1 -1
@@ -41,7 +41,7 @@ ermite token create <label> [--preset cli|agent|ci] [--scope repo:read …] [--r
4141 ermite token list | revoke <id>
4242 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]
4343 ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>
44-ermite mr merge <repo> <n> --head <id> [--squash] | close <repo> <n>
44+ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
4545 ermite web login # prints a one-time sign-in link (60 s)
4646 ermite web logout --all # signs every browser out
4747 ermite signing-key show # public key and allowed_signers line of squash commits
@@ -41,7 +41,7 @@ ermite token create <label> [--preset cli|agent|ci] [--scope repo:read …] [--r
41 ermite token list | revoke <id>41 ermite token list | revoke <id>
42 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]42 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]
43 ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>43 ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>
44-ermite mr merge <repo> <n> --head <id> [--squash] | close <repo> <n>44+ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
45 ermite web login # prints a one-time sign-in link (60 s)45 ermite web login # prints a one-time sign-in link (60 s)
46 ermite web logout --all # signs every browser out46 ermite web logout --all # signs every browser out
47 ermite signing-key show # public key and allowed_signers line of squash commits47 ermite signing-key show # public key and allowed_signers line of squash commits
M cmd/ermite/api_test.go +1 -1
@@ -158,7 +158,7 @@ func TestAC2ReadAccess(t *testing.T) {
158158 for _, who := range allPrincipals {
159159 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
160160 require.NoError(t, err, who)
161- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}, resp.Msg.GetFeatures(), who)
161+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}, resp.Msg.GetFeatures(), who)
162162 assert.NotEmpty(t, resp.Msg.GetVersion())
163163 }
164164 })
@@ -158,7 +158,7 @@ func TestAC2ReadAccess(t *testing.T) {
158 for _, who := range allPrincipals {158 for _, who := range allPrincipals {
159 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))159 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
160 require.NoError(t, err, who)160 require.NoError(t, err, who)
161- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}, resp.Msg.GetFeatures(), who)161+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}, resp.Msg.GetFeatures(), who)
162 assert.NotEmpty(t, resp.Msg.GetVersion())162 assert.NotEmpty(t, resp.Msg.GetVersion())
163 }163 }
164 })164 })
M cmd/ermite/cli_e2e_test.go +1 -1
@@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
324324 ver := decode[versionOut](t, res.stdout)
325325 assert.Equal(t, c.url, ver.Server.URL)
326326 assert.NotEmpty(t, ver.Server.Version)
327- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}, ver.Server.Features)
327+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}, ver.Server.Features)
328328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)
329329 stdout, stderr, code = c.bin(t, secret, "version")
330330 require.Equal(t, 0, code, stderr)
@@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
324 ver := decode[versionOut](t, res.stdout)324 ver := decode[versionOut](t, res.stdout)
325 assert.Equal(t, c.url, ver.Server.URL)325 assert.Equal(t, c.url, ver.Server.URL)
326 assert.NotEmpty(t, ver.Server.Version)326 assert.NotEmpty(t, ver.Server.Version)
327- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}, ver.Server.Features)327+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}, ver.Server.Features)
328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)
329 stdout, stderr, code = c.bin(t, secret, "version")329 stdout, stderr, code = c.bin(t, secret, "version")
330 require.Equal(t, 0, code, stderr)330 require.Equal(t, 0, code, stderr)
M cmd/ermite/cli_mr.go +23 -4
@@ -115,6 +115,12 @@ type jsonMergeRequest struct {
115115 MergeMode string `json:"merge_mode"`
116116 }
117117
118+// jsonMerged is the output of `mr merge --json`.
119+type jsonMerged struct {
120+ jsonMergeRequest
121+ SourceBranchDeleted bool `json:"source_branch_deleted"`
122+}
123+
118124 func toJSONMergeRequest(m *ermite.MergeRequest) jsonMergeRequest {
119125 return jsonMergeRequest{
120126 Repo: m.GetRepo(), Number: m.GetNumber(), Title: m.GetTitle(), Description: m.GetDescription(),
@@ -740,7 +746,7 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command {
740746 }
741747
742748 func newMRMergeCmd(env *cliEnv) *cobra.Command {
743- var squash bool
749+ var squash, deleteBranch bool
744750 var head string
745751 cmd := &cobra.Command{
746752 Use: "merge <repo> <number>",
@@ -753,12 +759,17 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
753759 "The server merges only if the source branch still points to the head you reviewed, so --head is\n" +
754760 "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" +
755761 "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" +
756- "mr:merge scope." + jsonMRHelp,
762+ "mr:merge scope.\n\n" +
763+ "--delete-branch deletes the source branch after the merge. The merge is refused when the branch\n" +
764+ "is the default branch or used by another open merge request. If the deletion fails after the\n" +
765+ "merge, the merge stands, the branch is kept and a warning goes to standard error." + jsonMRHelp +
766+ "\n--json adds source_branch_deleted (true when the branch was deleted).",
757767 Args: cobra.ExactArgs(2),
758768 ValidArgsFunction: completeMRRepoThenNumber(env),
759769 }
760770 cmd.Flags().BoolVar(&squash, "squash", false, "squash the branch into one signed commit instead of a fast-forward")
761771 cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)")
772+ cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge")
762773 asJSON := addJSONFlag(cmd)
763774 cmd.RunE = func(cmd *cobra.Command, args []string) error {
764775 n, err := parseNumber(args[1])
@@ -780,17 +791,25 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
780791 mode = ermite.MergeMode_MERGE_MODE_SQUASH
781792 }
782793 res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{
783- Repo: args[0], Number: n, Mode: mode, HeadId: head,
794+ Repo: args[0], Number: n, Mode: mode, HeadId: head, DeleteSourceBranch: deleteBranch,
784795 }))
785796 if err != nil {
786797 return c.Describe(err)
787798 }
788799 m := res.Msg.GetMergeRequest()
800+ deleted := res.Msg.GetSourceBranchDeleted()
801+ if deleteBranch && !deleted {
802+ _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ermite: warning: the branch %s was kept (it moved after the merge, or the server cannot delete it)\n",
803+ clean(m.GetSourceBranch()))
804+ }
789805 if *asJSON {
790- return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m))
806+ return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m), SourceBranchDeleted: deleted})
791807 }
792808 _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),
793809 clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId()))
810+ if err == nil && deleted {
811+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted branch %s\n", clean(m.GetSourceBranch()))
812+ }
794813 return err
795814 }
796815 return cmd
@@ -115,6 +115,12 @@ type jsonMergeRequest struct {
115 MergeMode string `json:"merge_mode"`115 MergeMode string `json:"merge_mode"`
116 }116 }
117 117
118+// jsonMerged is the output of `mr merge --json`.
119+type jsonMerged struct {
120+ jsonMergeRequest
121+ SourceBranchDeleted bool `json:"source_branch_deleted"`
122+}
123+
118 func toJSONMergeRequest(m *ermite.MergeRequest) jsonMergeRequest {124 func toJSONMergeRequest(m *ermite.MergeRequest) jsonMergeRequest {
119 return jsonMergeRequest{125 return jsonMergeRequest{
120 Repo: m.GetRepo(), Number: m.GetNumber(), Title: m.GetTitle(), Description: m.GetDescription(),126 Repo: m.GetRepo(), Number: m.GetNumber(), Title: m.GetTitle(), Description: m.GetDescription(),
@@ -740,7 +746,7 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command {
740 }746 }
741 747
742 func newMRMergeCmd(env *cliEnv) *cobra.Command {748 func newMRMergeCmd(env *cliEnv) *cobra.Command {
743- var squash bool749+ var squash, deleteBranch bool
744 var head string750 var head string
745 cmd := &cobra.Command{751 cmd := &cobra.Command{
746 Use: "merge <repo> <number>",752 Use: "merge <repo> <number>",
@@ -753,12 +759,17 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
753 "The server merges only if the source branch still points to the head you reviewed, so --head is\n" +759 "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" +760 "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" +761 "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" +
756- "mr:merge scope." + jsonMRHelp,762+ "mr:merge scope.\n\n" +
763+ "--delete-branch deletes the source branch after the merge. The merge is refused when the branch\n" +
764+ "is the default branch or used by another open merge request. If the deletion fails after the\n" +
765+ "merge, the merge stands, the branch is kept and a warning goes to standard error." + jsonMRHelp +
766+ "\n--json adds source_branch_deleted (true when the branch was deleted).",
757 Args: cobra.ExactArgs(2),767 Args: cobra.ExactArgs(2),
758 ValidArgsFunction: completeMRRepoThenNumber(env),768 ValidArgsFunction: completeMRRepoThenNumber(env),
759 }769 }
760 cmd.Flags().BoolVar(&squash, "squash", false, "squash the branch into one signed commit instead of a fast-forward")770 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, from `ermite mr show` (required)")771 cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)")
772+ cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge")
762 asJSON := addJSONFlag(cmd)773 asJSON := addJSONFlag(cmd)
763 cmd.RunE = func(cmd *cobra.Command, args []string) error {774 cmd.RunE = func(cmd *cobra.Command, args []string) error {
764 n, err := parseNumber(args[1])775 n, err := parseNumber(args[1])
@@ -780,17 +791,25 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
780 mode = ermite.MergeMode_MERGE_MODE_SQUASH791 mode = ermite.MergeMode_MERGE_MODE_SQUASH
781 }792 }
782 res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{793 res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{
783- Repo: args[0], Number: n, Mode: mode, HeadId: head,794+ Repo: args[0], Number: n, Mode: mode, HeadId: head, DeleteSourceBranch: deleteBranch,
784 }))795 }))
785 if err != nil {796 if err != nil {
786 return c.Describe(err)797 return c.Describe(err)
787 }798 }
788 m := res.Msg.GetMergeRequest()799 m := res.Msg.GetMergeRequest()
800+ deleted := res.Msg.GetSourceBranchDeleted()
801+ if deleteBranch && !deleted {
802+ _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ermite: warning: the branch %s was kept (it moved after the merge, or the server cannot delete it)\n",
803+ clean(m.GetSourceBranch()))
804+ }
789 if *asJSON {805 if *asJSON {
790- return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m))806+ return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m), SourceBranchDeleted: deleted})
791 }807 }
792 _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),808 _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),
793 clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId()))809 clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId()))
810+ if err == nil && deleted {
811+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted branch %s\n", clean(m.GetSourceBranch()))
812+ }
794 return err813 return err
795 }814 }
796 return cmd815 return cmd
M cmd/ermite/cli_mr_test.go +32 -0
@@ -309,6 +309,38 @@ func TestMRMergeNeedsTheHead(t *testing.T) {
309309 assert.NotContains(t, res.stdout, "default: the current head")
310310 }
311311
312+func TestMRMergeDeleteBranch(t *testing.T) {
313+ merged := proto.Clone(mrOne).(*ermite.MergeRequest)
314+ merged.State = ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED
315+ merged.MergedCommitId, merged.MergeMode = headA, ermite.MergeMode_MERGE_MODE_FAST_FORWARD
316+ deleted := true
317+ f := &fakeAPI{mergeMR: func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) {
318+ return &ermite.MergeMergeRequestResponse{MergeRequest: merged, SourceBranchDeleted: deleted}, nil
319+ }}
320+ url := f.serve(t)
321+
322+ res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)
323+ assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleted branch feat/x\n", res.stdout)
324+ assert.Empty(t, res.stderr)
325+ _, reqs := f.snapshot()
326+ assert.True(t, reqs[0].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch())
327+
328+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch", "--json").ok(t)
329+ assert.Contains(t, res.stdout, `"source_branch_deleted": true`)
330+
331+ // The merge stands when the branch is kept: a warning, no failure.
332+ deleted = false
333+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)
334+ assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\n", res.stdout)
335+ assert.Contains(t, res.stderr, "ermite: warning: the branch feat/x was kept")
336+
337+ // Without the flag nothing is asked and nothing is said.
338+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA).ok(t)
339+ assert.Empty(t, res.stderr)
340+ _, reqs = f.snapshot()
341+ assert.False(t, reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch())
342+}
343+
312344 func TestMRErrorsAreFriendly(t *testing.T) {
313345 f := &fakeAPI{
314346 listMR: func(*ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) {
@@ -309,6 +309,38 @@ func TestMRMergeNeedsTheHead(t *testing.T) {
309 assert.NotContains(t, res.stdout, "default: the current head")309 assert.NotContains(t, res.stdout, "default: the current head")
310 }310 }
311 311
312+func TestMRMergeDeleteBranch(t *testing.T) {
313+ merged := proto.Clone(mrOne).(*ermite.MergeRequest)
314+ merged.State = ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED
315+ merged.MergedCommitId, merged.MergeMode = headA, ermite.MergeMode_MERGE_MODE_FAST_FORWARD
316+ deleted := true
317+ f := &fakeAPI{mergeMR: func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) {
318+ return &ermite.MergeMergeRequestResponse{MergeRequest: merged, SourceBranchDeleted: deleted}, nil
319+ }}
320+ url := f.serve(t)
321+
322+ res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)
323+ assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleted branch feat/x\n", res.stdout)
324+ assert.Empty(t, res.stderr)
325+ _, reqs := f.snapshot()
326+ assert.True(t, reqs[0].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch())
327+
328+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch", "--json").ok(t)
329+ assert.Contains(t, res.stdout, `"source_branch_deleted": true`)
330+
331+ // The merge stands when the branch is kept: a warning, no failure.
332+ deleted = false
333+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)
334+ assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\n", res.stdout)
335+ assert.Contains(t, res.stderr, "ermite: warning: the branch feat/x was kept")
336+
337+ // Without the flag nothing is asked and nothing is said.
338+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA).ok(t)
339+ assert.Empty(t, res.stderr)
340+ _, reqs = f.snapshot()
341+ assert.False(t, reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch())
342+}
343+
312 func TestMRErrorsAreFriendly(t *testing.T) {344 func TestMRErrorsAreFriendly(t *testing.T) {
313 f := &fakeAPI{345 f := &fakeAPI{
314 listMR: func(*ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) {346 listMR: func(*ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) {
M cmd/ermite/cli_version.go +6 -5
@@ -100,9 +100,10 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
100100
101101 // featureUse says what breaks without a feature, for the version warning.
102102 var featureUse = map[string]string{
103- client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",
104- client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
105- client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
106- client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
107- client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
103+ client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",
104+ client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
105+ client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
106+ client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",
107+ client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
108+ client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
108109 }
@@ -100,9 +100,10 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
100 100
101 // featureUse says what breaks without a feature, for the version warning.101 // featureUse says what breaks without a feature, for the version warning.
102 var featureUse = map[string]string{102 var featureUse = map[string]string{
103- client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",103+ client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",
104- client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",104+ client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
105- client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",105+ client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
106- client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",106+ client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",
107- client.FeatureSession: "`ermite web login` needs: the command will fail on this server",107+ client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
108+ client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
108 }109 }
M cmd/ermite/cli_version_test.go +5 -5
@@ -14,7 +14,7 @@ import (
1414 )
1515
1616 // allFeatures are the features this client uses.
17-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}
17+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}
1818
1919 func versionFake(features ...string) *fakeAPI {
2020 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
@@ -35,15 +35,15 @@ func TestVersionWithServer(t *testing.T) {
3535 f := versionFake(allFeatures...)
3636 url := f.serve(t)
3737 res := runCLI(t, envFor(url), "", "version").ok(t)
38- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, signing_key, session\n", res.stdout)
38+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session\n", res.stdout)
3939 assert.Empty(t, res.stderr, "no warning when every feature is there")
4040
4141 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
42- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","signing_key","session"],"missing_features":[]}}`, res.stdout)
42+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":[]}}`, res.stdout)
4343 }
4444
4545 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
46- f := versionFake("repo.update", "merge_requests", "signing_key", "session")
46+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session")
4747 url := f.serve(t)
4848 res := runCLI(t, envFor(url), "", "version").ok(t)
4949 assert.Contains(t, res.stdout, "v0.4.0")
@@ -51,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
5151 assert.NotContains(t, res.stderr, "repo.update")
5252
5353 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
54- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout)
54+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout)
5555 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
5656
5757 f = versionFake()
@@ -14,7 +14,7 @@ import (
14 )14 )
15 15
16 // allFeatures are the features this client uses.16 // allFeatures are the features this client uses.
17-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}17+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}
18 18
19 func versionFake(features ...string) *fakeAPI {19 func versionFake(features ...string) *fakeAPI {
20 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {20 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
@@ -35,15 +35,15 @@ func TestVersionWithServer(t *testing.T) {
35 f := versionFake(allFeatures...)35 f := versionFake(allFeatures...)
36 url := f.serve(t)36 url := f.serve(t)
37 res := runCLI(t, envFor(url), "", "version").ok(t)37 res := runCLI(t, envFor(url), "", "version").ok(t)
38- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, signing_key, session\n", res.stdout)38+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session\n", res.stdout)
39 assert.Empty(t, res.stderr, "no warning when every feature is there")39 assert.Empty(t, res.stderr, "no warning when every feature is there")
40 40
41 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)41 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
42- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","signing_key","session"],"missing_features":[]}}`, res.stdout)42+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":[]}}`, res.stdout)
43 }43 }
44 44
45 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {45 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
46- f := versionFake("repo.update", "merge_requests", "signing_key", "session")46+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session")
47 url := f.serve(t)47 url := f.serve(t)
48 res := runCLI(t, envFor(url), "", "version").ok(t)48 res := runCLI(t, envFor(url), "", "version").ok(t)
49 assert.Contains(t, res.stdout, "v0.4.0")49 assert.Contains(t, res.stdout, "v0.4.0")
@@ -51,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
51 assert.NotContains(t, res.stderr, "repo.update")51 assert.NotContains(t, res.stderr, "repo.update")
52 52
53 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)53 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
54- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout)54+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout)
55 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")55 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
56 56
57 f = versionFake()57 f = versionFake()
M cmd/ermite/web_mr_test.go +5 -1
@@ -160,6 +160,8 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
160160 assert.Contains(t, own.body, `<option value="fast-forward">`)
161161 assert.Contains(t, own.body, `<option value="squash">`)
162162 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head)
163+ assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge`)
164+ assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default")
163165 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --squash")
164166 assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`)
165167 assert.Contains(t, own.body, `action="/proj/merge-requests/1/close"`)
@@ -377,8 +379,10 @@ func TestWebMergeRequestSquashCloseAndList(t *testing.T) {
377379 "source": {"feat/search"}, "target": {"main"}, "title": {"Squash me"}, "description": {""},
378380 })
379381 require.Equal(t, http.StatusSeeOther, res.status, res.body)
380- res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}})
382+ assert.Contains(t, b.get("/proj/merge-requests/1", as(token)).body, `name="delete_branch" value="1"`, "the box offers to delete the branch")
383+ res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}, "delete_branch": {"1"}})
381384 require.Equal(t, http.StatusSeeOther, res.status, res.body)
385+ assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"), "the source branch is deleted")
382386 merged := r.headOf("main")
383387 assert.NotEqual(t, head, merged, "squash makes a new commit")
384388 assert.Equal(t, main0, r.bareGit("proj", "rev-parse", "main^"), "on top of the old target")
@@ -160,6 +160,8 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
160 assert.Contains(t, own.body, `<option value="fast-forward">`)160 assert.Contains(t, own.body, `<option value="fast-forward">`)
161 assert.Contains(t, own.body, `<option value="squash">`)161 assert.Contains(t, own.body, `<option value="squash">`)
162 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head)162 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head)
163+ assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge`)
164+ assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default")
163 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --squash")165 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --squash")
164 assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`)166 assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`)
165 assert.Contains(t, own.body, `action="/proj/merge-requests/1/close"`)167 assert.Contains(t, own.body, `action="/proj/merge-requests/1/close"`)
@@ -377,8 +379,10 @@ func TestWebMergeRequestSquashCloseAndList(t *testing.T) {
377 "source": {"feat/search"}, "target": {"main"}, "title": {"Squash me"}, "description": {""},379 "source": {"feat/search"}, "target": {"main"}, "title": {"Squash me"}, "description": {""},
378 })380 })
379 require.Equal(t, http.StatusSeeOther, res.status, res.body)381 require.Equal(t, http.StatusSeeOther, res.status, res.body)
380- res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}})382+ assert.Contains(t, b.get("/proj/merge-requests/1", as(token)).body, `name="delete_branch" value="1"`, "the box offers to delete the branch")
383+ res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}, "delete_branch": {"1"}})
381 require.Equal(t, http.StatusSeeOther, res.status, res.body)384 require.Equal(t, http.StatusSeeOther, res.status, res.body)
385+ assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"), "the source branch is deleted")
382 merged := r.headOf("main")386 merged := r.headOf("main")
383 assert.NotEqual(t, head, merged, "squash makes a new commit")387 assert.NotEqual(t, head, merged, "squash makes a new commit")
384 assert.Equal(t, main0, r.bareGit("proj", "rev-parse", "main^"), "on top of the old target")388 assert.Equal(t, main0, r.bareGit("proj", "rev-parse", "main^"), "on top of the old target")
M internal/adapters/client/client_test.go +2 -2
@@ -139,10 +139,10 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139139 func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureSigningKey},
142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureSession,
145+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
146146 }))
147147 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
148148 }
@@ -139,10 +139,10 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139 func TestMissingFeatures(t *testing.T) {139 func TestMissingFeatures(t *testing.T) {
140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141 assert.Equal(t,141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureSigningKey},142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey},
143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"}))143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"}))
144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureSession,145+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
146 }))146 }))
147 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))147 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
148 }148 }
M internal/adapters/client/features.go +4 -1
@@ -15,6 +15,9 @@ const (
1515 FeatureEventsResume = "events.resume"
1616 // FeatureMergeRequests is MergeRequestService (`ermite mr`).
1717 FeatureMergeRequests = "merge_requests"
18+ // FeatureMergeDeleteBranch is MergeMergeRequest with delete_source_branch
19+ // (`ermite mr merge --delete-branch`).
20+ FeatureMergeDeleteBranch = "merge_requests.delete_branch"
1821 // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`).
1922 FeatureSigningKey = "signing_key"
2023 // FeatureSession is SessionService (`ermite web login`, `web logout`).
@@ -23,7 +26,7 @@ const (
2326
2427 // UsedFeatures lists the features the CLI uses, in the order they are reported.
2528 func UsedFeatures() []string {
26- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureSigningKey, FeatureSession}
29+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession}
2730 }
2831
2932 // MissingFeatures returns the features the CLI uses that have is lacking.
@@ -15,6 +15,9 @@ const (
15 FeatureEventsResume = "events.resume"15 FeatureEventsResume = "events.resume"
16 // FeatureMergeRequests is MergeRequestService (`ermite mr`).16 // FeatureMergeRequests is MergeRequestService (`ermite mr`).
17 FeatureMergeRequests = "merge_requests"17 FeatureMergeRequests = "merge_requests"
18+ // FeatureMergeDeleteBranch is MergeMergeRequest with delete_source_branch
19+ // (`ermite mr merge --delete-branch`).
20+ FeatureMergeDeleteBranch = "merge_requests.delete_branch"
18 // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`).21 // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`).
19 FeatureSigningKey = "signing_key"22 FeatureSigningKey = "signing_key"
20 // FeatureSession is SessionService (`ermite web login`, `web logout`).23 // FeatureSession is SessionService (`ermite web login`, `web logout`).
@@ -23,7 +26,7 @@ const (
23 26
24 // UsedFeatures lists the features the CLI uses, in the order they are reported.27 // UsedFeatures lists the features the CLI uses, in the order they are reported.
25 func UsedFeatures() []string {28 func UsedFeatures() []string {
26- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureSigningKey, FeatureSession}29+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession}
27 }30 }
28 31
29 // MissingFeatures returns the features the CLI uses that have is lacking.32 // MissingFeatures returns the features the CLI uses that have is lacking.
M internal/adapters/gitcli/merge.go +26 -0
@@ -235,3 +235,29 @@ func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch str
235235 }
236236 return fmt.Errorf("update branch %q of %q: %w", branch, repo, err)
237237 }
238+
239+// DeleteBranch runs `git update-ref -d refs/heads/<branch> <old>`, which takes
240+// the ref lock and refuses when the ref is not at old. A failure is told from
241+// a moved or deleted branch by reading the branch again.
242+//
243+//hexcheck:ignore suspicious-business-logic-in-adapter maps a refused update-ref to the conflict error of the port
244+func (g *Git) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
245+ dir, err := g.repoDir(repo)
246+ if err != nil {
247+ return err
248+ }
249+ if _, err := domain.ParseBranchName(branch); err != nil {
250+ return fmt.Errorf("delete branch of %q: %w", repo, err)
251+ }
252+ _, err = g.run(ctx, "--git-dir="+dir, "update-ref", "-d", headsRefPrefix+branch, old.String())
253+ if err == nil {
254+ return nil
255+ }
256+ switch cur, herr := g.BranchHead(ctx, repo, branch); {
257+ case errors.Is(herr, domain.ErrNotFound):
258+ return fmt.Errorf("branch %q of %q is gone: %w", branch, repo, domain.ErrConflict)
259+ case herr == nil && cur != old:
260+ return fmt.Errorf("branch %q of %q is at %s, not %s: %w", branch, repo, cur, old, domain.ErrConflict)
261+ }
262+ return fmt.Errorf("delete branch %q of %q: %w", branch, repo, err)
263+}
@@ -235,3 +235,29 @@ func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch str
235 }235 }
236 return fmt.Errorf("update branch %q of %q: %w", branch, repo, err)236 return fmt.Errorf("update branch %q of %q: %w", branch, repo, err)
237 }237 }
238+
239+// DeleteBranch runs `git update-ref -d refs/heads/<branch> <old>`, which takes
240+// the ref lock and refuses when the ref is not at old. A failure is told from
241+// a moved or deleted branch by reading the branch again.
242+//
243+//hexcheck:ignore suspicious-business-logic-in-adapter maps a refused update-ref to the conflict error of the port
244+func (g *Git) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
245+ dir, err := g.repoDir(repo)
246+ if err != nil {
247+ return err
248+ }
249+ if _, err := domain.ParseBranchName(branch); err != nil {
250+ return fmt.Errorf("delete branch of %q: %w", repo, err)
251+ }
252+ _, err = g.run(ctx, "--git-dir="+dir, "update-ref", "-d", headsRefPrefix+branch, old.String())
253+ if err == nil {
254+ return nil
255+ }
256+ switch cur, herr := g.BranchHead(ctx, repo, branch); {
257+ case errors.Is(herr, domain.ErrNotFound):
258+ return fmt.Errorf("branch %q of %q is gone: %w", branch, repo, domain.ErrConflict)
259+ case herr == nil && cur != old:
260+ return fmt.Errorf("branch %q of %q is at %s, not %s: %w", branch, repo, cur, old, domain.ErrConflict)
261+ }
262+ return fmt.Errorf("delete branch %q of %q: %w", branch, repo, err)
263+}
M internal/adapters/gitcli/merge_test.go +25 -0
@@ -206,3 +206,28 @@ func TestWriteCommitAndUpdateBranch(t *testing.T) {
206206 err = g.UpdateBranch(ctx, name(t, "missing"), "main", id, m.mainTip)
207207 assert.ErrorIs(t, err, domain.ErrNotFound)
208208 }
209+
210+func TestDeleteBranch(t *testing.T) {
211+ g, data := newGit(t)
212+ ctx := context.Background()
213+ m := newMergeFixture(t, data)
214+ m.f.Git("pack-refs", "--all") // the ref is in packed-refs too
215+
216+ // The old value is a precondition.
217+ err := g.DeleteBranch(ctx, m.f.Name, "feat", m.mainTip)
218+ assert.ErrorIs(t, err, domain.ErrConflict, "feat is not at main")
219+ assert.Equal(t, m.featTip.String(), m.f.Git("rev-parse", "refs/heads/feat"))
220+
221+ require.NoError(t, g.DeleteBranch(ctx, m.f.Name, "feat", m.featTip))
222+ _, err = g.BranchHead(ctx, m.f.Name, "feat")
223+ assert.ErrorIs(t, err, domain.ErrNotFound)
224+ assert.Empty(t, m.f.Git("for-each-ref", "refs/heads/feat"), "gone from packed-refs too")
225+
226+ err = g.DeleteBranch(ctx, m.f.Name, "feat", m.featTip)
227+ assert.ErrorIs(t, err, domain.ErrConflict, "already gone")
228+ err = g.DeleteBranch(ctx, m.f.Name, "refs/heads/main", m.mainTip)
229+ assert.Error(t, err, "only short branch names")
230+ assert.Equal(t, m.mainTip.String(), m.f.Git("rev-parse", "refs/heads/main"))
231+ err = g.DeleteBranch(ctx, name(t, "missing"), "main", m.mainTip)
232+ assert.ErrorIs(t, err, domain.ErrNotFound)
233+}
@@ -206,3 +206,28 @@ func TestWriteCommitAndUpdateBranch(t *testing.T) {
206 err = g.UpdateBranch(ctx, name(t, "missing"), "main", id, m.mainTip)206 err = g.UpdateBranch(ctx, name(t, "missing"), "main", id, m.mainTip)
207 assert.ErrorIs(t, err, domain.ErrNotFound)207 assert.ErrorIs(t, err, domain.ErrNotFound)
208 }208 }
209+
210+func TestDeleteBranch(t *testing.T) {
211+ g, data := newGit(t)
212+ ctx := context.Background()
213+ m := newMergeFixture(t, data)
214+ m.f.Git("pack-refs", "--all") // the ref is in packed-refs too
215+
216+ // The old value is a precondition.
217+ err := g.DeleteBranch(ctx, m.f.Name, "feat", m.mainTip)
218+ assert.ErrorIs(t, err, domain.ErrConflict, "feat is not at main")
219+ assert.Equal(t, m.featTip.String(), m.f.Git("rev-parse", "refs/heads/feat"))
220+
221+ require.NoError(t, g.DeleteBranch(ctx, m.f.Name, "feat", m.featTip))
222+ _, err = g.BranchHead(ctx, m.f.Name, "feat")
223+ assert.ErrorIs(t, err, domain.ErrNotFound)
224+ assert.Empty(t, m.f.Git("for-each-ref", "refs/heads/feat"), "gone from packed-refs too")
225+
226+ err = g.DeleteBranch(ctx, m.f.Name, "feat", m.featTip)
227+ assert.ErrorIs(t, err, domain.ErrConflict, "already gone")
228+ err = g.DeleteBranch(ctx, m.f.Name, "refs/heads/main", m.mainTip)
229+ assert.Error(t, err, "only short branch names")
230+ assert.Equal(t, m.mainTip.String(), m.f.Git("rev-parse", "refs/heads/main"))
231+ err = g.DeleteBranch(ctx, name(t, "missing"), "main", m.mainTip)
232+ assert.ErrorIs(t, err, domain.ErrNotFound)
233+}
M internal/adapters/rpc/api.go +1 -1
@@ -27,7 +27,7 @@ const MaxRequestBytes = 1 << 20
2727
2828 // Features are the optional capabilities of the contract that this server
2929 // implements, as GetVersion lists them.
30-var Features = []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}
30+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}
3131
3232 // Authenticator turns a token secret into a principal, or
3333 // domain.ErrUnauthenticated. app.Auth fits.
@@ -27,7 +27,7 @@ const MaxRequestBytes = 1 << 20
27 27
28 // Features are the optional capabilities of the contract that this server28 // Features are the optional capabilities of the contract that this server
29 // implements, as GetVersion lists them.29 // implements, as GetVersion lists them.
30-var Features = []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}30+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}
31 31
32 // Authenticator turns a token secret into a principal, or32 // Authenticator turns a token secret into a principal, or
33 // domain.ErrUnauthenticated. app.Auth fits.33 // domain.ErrUnauthenticated. app.Auth fits.
M internal/adapters/rpc/mergerequest.go +6 -2
@@ -245,11 +245,15 @@ func (s *mergeRequestService) MergeMergeRequest(ctx context.Context, req *connec
245245 if err != nil {
246246 return nil, connectError(ctx, err)
247247 }
248- mr, err := s.mrs.Merge(ctx, caller(ctx), name, req.Msg.GetNumber(), app.MergeParams{Mode: mode, HeadID: head})
248+ res, err := s.mrs.Merge(ctx, caller(ctx), name, req.Msg.GetNumber(), app.MergeParams{
249+ Mode: mode, HeadID: head, DeleteSource: req.Msg.GetDeleteSourceBranch(),
250+ })
249251 if err != nil {
250252 return nil, connectError(ctx, err)
251253 }
252- return connect.NewResponse(&pb.MergeMergeRequestResponse{MergeRequest: mergeRequestToPB(mr)}), nil
254+ return connect.NewResponse(&pb.MergeMergeRequestResponse{
255+ MergeRequest: mergeRequestToPB(res.MergeRequest), SourceBranchDeleted: res.SourceDeleted,
256+ }), nil
253257 }
254258
255259 func (s *mergeRequestService) MarkRead(ctx context.Context, req *connect.Request[pb.MarkReadRequest]) (*connect.Response[pb.MarkReadResponse], error) {
@@ -245,11 +245,15 @@ func (s *mergeRequestService) MergeMergeRequest(ctx context.Context, req *connec
245 if err != nil {245 if err != nil {
246 return nil, connectError(ctx, err)246 return nil, connectError(ctx, err)
247 }247 }
248- mr, err := s.mrs.Merge(ctx, caller(ctx), name, req.Msg.GetNumber(), app.MergeParams{Mode: mode, HeadID: head})248+ res, err := s.mrs.Merge(ctx, caller(ctx), name, req.Msg.GetNumber(), app.MergeParams{
249+ Mode: mode, HeadID: head, DeleteSource: req.Msg.GetDeleteSourceBranch(),
250+ })
249 if err != nil {251 if err != nil {
250 return nil, connectError(ctx, err)252 return nil, connectError(ctx, err)
251 }253 }
252- return connect.NewResponse(&pb.MergeMergeRequestResponse{MergeRequest: mergeRequestToPB(mr)}), nil254+ return connect.NewResponse(&pb.MergeMergeRequestResponse{
255+ MergeRequest: mergeRequestToPB(res.MergeRequest), SourceBranchDeleted: res.SourceDeleted,
256+ }), nil
253 }257 }
254 258
255 func (s *mergeRequestService) MarkRead(ctx context.Context, req *connect.Request[pb.MarkReadRequest]) (*connect.Response[pb.MarkReadResponse], error) {259 func (s *mergeRequestService) MarkRead(ctx context.Context, req *connect.Request[pb.MarkReadRequest]) (*connect.Response[pb.MarkReadResponse], error) {
M internal/adapters/web/mr.go +7 -3
@@ -147,6 +147,9 @@ type MergeBox struct {
147147 CLI Clone
148148 // CanMarkRead is true when the head differs from the one last read.
149149 CanMarkRead bool
150+ // CanDeleteBranch offers to delete the source after the merge: it is not
151+ // the default branch. The server checks again, with the open requests.
152+ CanDeleteBranch bool
150153 }
151154
152155 // ModeOption is a merge mode the repository allows.
@@ -528,7 +531,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
528531 p.Invisible = invisible
529532 p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
530533 if signed && open {
531- p.Box = s.mergeBox(d, n, head, lastRead)
534+ p.Box = s.mergeBox(d, n, m.GetSourceBranch(), head, lastRead)
532535 }
533536 if canWrite {
534537 p.GeneralForm = &CommentForm{
@@ -573,11 +576,12 @@ func (d *repoData) hasBranch(name string) bool {
573576 }
574577
575578 // mergeBox is what the owner can do with an open request.
576-func (s *Server) mergeBox(d *repoData, n int64, head, lastRead string) *MergeBox {
579+func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *MergeBox {
577580 b := &MergeBox{
578581 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
579582 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
580- CanMarkRead: head != "" && head != lastRead,
583+ CanMarkRead: head != "" && head != lastRead,
584+ CanDeleteBranch: source != d.repo.GetDefaultBranch(),
581585 }
582586 ff, sq := false, false
583587 if st := d.repo.GetSettings(); st != nil {
@@ -147,6 +147,9 @@ type MergeBox struct {
147 CLI Clone147 CLI Clone
148 // CanMarkRead is true when the head differs from the one last read.148 // CanMarkRead is true when the head differs from the one last read.
149 CanMarkRead bool149 CanMarkRead bool
150+ // CanDeleteBranch offers to delete the source after the merge: it is not
151+ // the default branch. The server checks again, with the open requests.
152+ CanDeleteBranch bool
150 }153 }
151 154
152 // ModeOption is a merge mode the repository allows.155 // ModeOption is a merge mode the repository allows.
@@ -528,7 +531,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
528 p.Invisible = invisible531 p.Invisible = invisible
529 p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")532 p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
530 if signed && open {533 if signed && open {
531- p.Box = s.mergeBox(d, n, head, lastRead)534+ p.Box = s.mergeBox(d, n, m.GetSourceBranch(), head, lastRead)
532 }535 }
533 if canWrite {536 if canWrite {
534 p.GeneralForm = &CommentForm{537 p.GeneralForm = &CommentForm{
@@ -573,11 +576,12 @@ func (d *repoData) hasBranch(name string) bool {
573 }576 }
574 577
575 // mergeBox is what the owner can do with an open request.578 // mergeBox is what the owner can do with an open request.
576-func (s *Server) mergeBox(d *repoData, n int64, head, lastRead string) *MergeBox {579+func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *MergeBox {
577 b := &MergeBox{580 b := &MergeBox{
578 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),581 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
579 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",582 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
580- CanMarkRead: head != "" && head != lastRead,583+ CanMarkRead: head != "" && head != lastRead,
584+ CanDeleteBranch: source != d.repo.GetDefaultBranch(),
581 }585 }
582 ff, sq := false, false586 ff, sq := false, false
583 if st := d.repo.GetSettings(); st != nil {587 if st := d.repo.GetSettings(); st != nil {
M internal/adapters/web/mr.templ +3 -0
@@ -382,6 +382,9 @@ templ mergeBox(p MRPage) {
382382 <option value={ m.Value }>{ m.Label }</option>
383383 }
384384 </select>
385+ if p.Box.CanDeleteBranch {
386+ <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge</label>
387+ }
385388 <div class="d-btns">
386389 <button type="submit" class="d-main">[merge]</button>
387390 </div>
@@ -382,6 +382,9 @@ templ mergeBox(p MRPage) {
382 <option value={ m.Value }>{ m.Label }</option>382 <option value={ m.Value }>{ m.Label }</option>
383 }383 }
384 </select>384 </select>
385+ if p.Box.CanDeleteBranch {
386+ <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge</label>
387+ }
385 <div class="d-btns">388 <div class="d-btns">
386 <button type="submit" class="d-main">[merge]</button>389 <button type="submit" class="d-main">[merge]</button>
387 </div>390 </div>
M internal/adapters/web/mr_post.go +5 -3
@@ -205,9 +205,10 @@ func (s *Server) mrClose(ctx context.Context, r *http.Request) (string, string,
205205 return back, back, nil
206206 }
207207
208-// mrMerge is POST /<repo>/merge-requests/<n>/merge. The form carries the mode
209-// and the head the owner was shown (head_id): the server merges only if the
210-// branch still points to it.
208+// mrMerge is POST /<repo>/merge-requests/<n>/merge. The form carries the mode,
209+// the head the owner was shown (head_id): the server merges only if the
210+// branch still points to it, and delete_branch when the owner asked to delete
211+// the source branch after the merge.
211212 func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, error) {
212213 repo := r.PathValue("repo")
213214 n, err := number(r)
@@ -226,6 +227,7 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
226227 }
227228 if _, err := s.cfg.MergeRequests.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
228229 Repo: repo, Number: n, Mode: mode, HeadId: r.PostFormValue("head_id"),
230+ DeleteSourceBranch: r.PostFormValue("delete_branch") == "1",
229231 })); err != nil {
230232 return "", back, err
231233 }
@@ -205,9 +205,10 @@ func (s *Server) mrClose(ctx context.Context, r *http.Request) (string, string,
205 return back, back, nil205 return back, back, nil
206 }206 }
207 207
208-// mrMerge is POST /<repo>/merge-requests/<n>/merge. The form carries the mode208+// mrMerge is POST /<repo>/merge-requests/<n>/merge. The form carries the mode,
209-// and the head the owner was shown (head_id): the server merges only if the209+// the head the owner was shown (head_id): the server merges only if the
210-// branch still points to it.210+// branch still points to it, and delete_branch when the owner asked to delete
211+// the source branch after the merge.
211 func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, error) {212 func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, error) {
212 repo := r.PathValue("repo")213 repo := r.PathValue("repo")
213 n, err := number(r)214 n, err := number(r)
@@ -226,6 +227,7 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
226 }227 }
227 if _, err := s.cfg.MergeRequests.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{228 if _, err := s.cfg.MergeRequests.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
228 Repo: repo, Number: n, Mode: mode, HeadId: r.PostFormValue("head_id"),229 Repo: repo, Number: n, Mode: mode, HeadId: r.PostFormValue("head_id"),
230+ DeleteSourceBranch: r.PostFormValue("delete_branch") == "1",
229 })); err != nil {231 })); err != nil {
230 return "", back, err232 return "", back, err
231 }233 }
M internal/adapters/web/mr_templ.go +223 -200
@@ -2042,66 +2042,89 @@ func mergeBox(p MRPage) templ.Component {
20422042 return templ_7745c5c3_Err
20432043 }
20442044 }
2045- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "</select><div class=\"d-btns\"><button type=\"submit\" class=\"d-main\">[merge]</button></div></form>")
2045+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "</select> ")
2046+ if templ_7745c5c3_Err != nil {
2047+ return templ_7745c5c3_Err
2048+ }
2049+ if p.Box.CanDeleteBranch {
2050+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<label class=\"d-check\"><input type=\"checkbox\" name=\"delete_branch\" value=\"1\"> delete ")
2051+ if templ_7745c5c3_Err != nil {
2052+ return templ_7745c5c3_Err
2053+ }
2054+ var templ_7745c5c3_Var105 string
2055+ templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2056+ if templ_7745c5c3_Err != nil {
2057+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 386, Col: 101}
2058+ }
2059+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
2060+ if templ_7745c5c3_Err != nil {
2061+ return templ_7745c5c3_Err
2062+ }
2063+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, " after the merge</label>")
2064+ if templ_7745c5c3_Err != nil {
2065+ return templ_7745c5c3_Err
2066+ }
2067+ }
2068+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "<div class=\"d-btns\"><button type=\"submit\" class=\"d-main\">[merge]</button></div></form>")
20462069 if templ_7745c5c3_Err != nil {
20472070 return templ_7745c5c3_Err
20482071 }
20492072 }
2050- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<div class=\"d-btns\">")
2073+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "<div class=\"d-btns\">")
20512074 if templ_7745c5c3_Err != nil {
20522075 return templ_7745c5c3_Err
20532076 }
20542077 if p.Box.CanMarkRead {
2055- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "<form method=\"post\" action=\"")
2078+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<form method=\"post\" action=\"")
20562079 if templ_7745c5c3_Err != nil {
20572080 return templ_7745c5c3_Err
20582081 }
2059- var templ_7745c5c3_Var105 templ.SafeURL
2060- templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
2082+ var templ_7745c5c3_Var106 templ.SafeURL
2083+ templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
20612084 if templ_7745c5c3_Err != nil {
2062- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 392, Col: 64}
2085+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 395, Col: 64}
20632086 }
2064- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
2087+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106))
20652088 if templ_7745c5c3_Err != nil {
20662089 return templ_7745c5c3_Err
20672090 }
2068- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
2091+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
20692092 if templ_7745c5c3_Err != nil {
20702093 return templ_7745c5c3_Err
20712094 }
2072- var templ_7745c5c3_Var106 string
2073- templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
2095+ var templ_7745c5c3_Var107 string
2096+ templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
20742097 if templ_7745c5c3_Err != nil {
2075- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 393, Col: 59}
2098+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 396, Col: 59}
20762099 }
2077- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var106)
2100+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var107)
20782101 if templ_7745c5c3_Err != nil {
20792102 return templ_7745c5c3_Err
20802103 }
2081- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "\"> <button type=\"submit\" title=\"the next visit can show only what changed after this head\">[mark read]</button></form>")
2104+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "\"> <button type=\"submit\" title=\"the next visit can show only what changed after this head\">[mark read]</button></form>")
20822105 if templ_7745c5c3_Err != nil {
20832106 return templ_7745c5c3_Err
20842107 }
20852108 }
2086- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<form method=\"post\" action=\"")
2109+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<form method=\"post\" action=\"")
20872110 if templ_7745c5c3_Err != nil {
20882111 return templ_7745c5c3_Err
20892112 }
2090- var templ_7745c5c3_Var107 templ.SafeURL
2091- templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
2113+ var templ_7745c5c3_Var108 templ.SafeURL
2114+ templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
20922115 if templ_7745c5c3_Err != nil {
2093- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 397, Col: 64}
2116+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 400, Col: 64}
20942117 }
2095- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
2118+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
20962119 if templ_7745c5c3_Err != nil {
20972120 return templ_7745c5c3_Err
20982121 }
2099- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\">[close]</button></form></div>")
2122+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\">[close]</button></form></div>")
21002123 if templ_7745c5c3_Err != nil {
21012124 return templ_7745c5c3_Err
21022125 }
21032126 if !p.Box.HeadGone {
2104- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "<p class=\"mute d-or\">or from a terminal</p>")
2127+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "<p class=\"mute d-or\">or from a terminal</p>")
21052128 if templ_7745c5c3_Err != nil {
21062129 return templ_7745c5c3_Err
21072130 }
@@ -2110,7 +2133,7 @@ func mergeBox(p MRPage) templ.Component {
21102133 return templ_7745c5c3_Err
21112134 }
21122135 }
2113- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "</section>")
2136+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</section>")
21142137 if templ_7745c5c3_Err != nil {
21152138 return templ_7745c5c3_Err
21162139 }
@@ -2136,12 +2159,12 @@ func Compare(l Layout, p ComparePage) templ.Component {
21362159 }()
21372160 }
21382161 ctx = templ.InitializeContext(ctx)
2139- templ_7745c5c3_Var108 := templ.GetChildren(ctx)
2140- if templ_7745c5c3_Var108 == nil {
2141- templ_7745c5c3_Var108 = templ.NopComponent
2162+ templ_7745c5c3_Var109 := templ.GetChildren(ctx)
2163+ if templ_7745c5c3_Var109 == nil {
2164+ templ_7745c5c3_Var109 = templ.NopComponent
21422165 }
21432166 ctx = templ.ClearChildren(ctx)
2144- templ_7745c5c3_Var109 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
2167+ templ_7745c5c3_Var110 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
21452168 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
21462169 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
21472170 if !templ_7745c5c3_IsBuffer {
@@ -2153,263 +2176,263 @@ func Compare(l Layout, p ComparePage) templ.Component {
21532176 }()
21542177 }
21552178 ctx = templ.InitializeContext(ctx)
2156- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "<header class=\"show mr-h\"><h1>")
2157- if templ_7745c5c3_Err != nil {
2158- return templ_7745c5c3_Err
2159- }
2160- var templ_7745c5c3_Var110 string
2161- templ_7745c5c3_Var110, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2162- if templ_7745c5c3_Err != nil {
2163- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 413, Col: 17}
2164- }
2165- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var110))
2166- if templ_7745c5c3_Err != nil {
2167- return templ_7745c5c3_Err
2168- }
2169- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, " → ")
2179+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "<header class=\"show mr-h\"><h1>")
21702180 if templ_7745c5c3_Err != nil {
21712181 return templ_7745c5c3_Err
21722182 }
21732183 var templ_7745c5c3_Var111 string
2174- templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2184+ templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
21752185 if templ_7745c5c3_Err != nil {
2176- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 413, Col: 34}
2186+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 416, Col: 17}
21772187 }
21782188 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
21792189 if templ_7745c5c3_Err != nil {
21802190 return templ_7745c5c3_Err
21812191 }
2182- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</h1><dl class=\"kv show-kv\"><dt>branch</dt><dd><a href=\"")
2192+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, " → ")
21832193 if templ_7745c5c3_Err != nil {
21842194 return templ_7745c5c3_Err
21852195 }
2186- var templ_7745c5c3_Var112 templ.SafeURL
2187- templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
2196+ var templ_7745c5c3_Var112 string
2197+ templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
21882198 if templ_7745c5c3_Err != nil {
2189- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 416, Col: 45}
2199+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 416, Col: 34}
21902200 }
21912201 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))
21922202 if templ_7745c5c3_Err != nil {
21932203 return templ_7745c5c3_Err
21942204 }
2195- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "\">")
2205+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "</h1><dl class=\"kv show-kv\"><dt>branch</dt><dd><a href=\"")
21962206 if templ_7745c5c3_Err != nil {
21972207 return templ_7745c5c3_Err
21982208 }
2199- var templ_7745c5c3_Var113 string
2200- templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2209+ var templ_7745c5c3_Var113 templ.SafeURL
2210+ templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
22012211 if templ_7745c5c3_Err != nil {
2202- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 416, Col: 58}
2212+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 419, Col: 45}
22032213 }
22042214 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))
22052215 if templ_7745c5c3_Err != nil {
22062216 return templ_7745c5c3_Err
22072217 }
2208- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "</a></dd><dt>target</dt><dd><a href=\"")
2218+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "\">")
22092219 if templ_7745c5c3_Err != nil {
22102220 return templ_7745c5c3_Err
22112221 }
2212- var templ_7745c5c3_Var114 templ.SafeURL
2213- templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
2222+ var templ_7745c5c3_Var114 string
2223+ templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
22142224 if templ_7745c5c3_Err != nil {
2215- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 45}
2225+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 419, Col: 58}
22162226 }
22172227 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var114))
22182228 if templ_7745c5c3_Err != nil {
22192229 return templ_7745c5c3_Err
22202230 }
2221- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "\">")
2231+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "</a></dd><dt>target</dt><dd><a href=\"")
22222232 if templ_7745c5c3_Err != nil {
22232233 return templ_7745c5c3_Err
22242234 }
2225- var templ_7745c5c3_Var115 string
2226- templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2235+ var templ_7745c5c3_Var115 templ.SafeURL
2236+ templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
22272237 if templ_7745c5c3_Err != nil {
2228- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 58}
2238+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 421, Col: 45}
22292239 }
22302240 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))
22312241 if templ_7745c5c3_Err != nil {
22322242 return templ_7745c5c3_Err
22332243 }
2234- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "</a></dd><dt>ahead</dt><dd>")
2244+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "\">")
22352245 if templ_7745c5c3_Err != nil {
22362246 return templ_7745c5c3_Err
22372247 }
22382248 var templ_7745c5c3_Var116 string
2239- templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
2249+ templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
22402250 if templ_7745c5c3_Err != nil {
2241- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 420, Col: 21}
2251+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 421, Col: 58}
22422252 }
22432253 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))
22442254 if templ_7745c5c3_Err != nil {
22452255 return templ_7745c5c3_Err
22462256 }
2247- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "</dd></dl></header>")
2257+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "</a></dd><dt>ahead</dt><dd>")
2258+ if templ_7745c5c3_Err != nil {
2259+ return templ_7745c5c3_Err
2260+ }
2261+ var templ_7745c5c3_Var117 string
2262+ templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
2263+ if templ_7745c5c3_Err != nil {
2264+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 423, Col: 21}
2265+ }
2266+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117))
2267+ if templ_7745c5c3_Err != nil {
2268+ return templ_7745c5c3_Err
2269+ }
2270+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "</dd></dl></header>")
22482271 if templ_7745c5c3_Err != nil {
22492272 return templ_7745c5c3_Err
22502273 }
22512274 if p.Note != "" {
2252- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "<p class=\"notice\">")
2275+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "<p class=\"notice\">")
22532276 if templ_7745c5c3_Err != nil {
22542277 return templ_7745c5c3_Err
22552278 }
2256- var templ_7745c5c3_Var117 string
2257- templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
2279+ var templ_7745c5c3_Var118 string
2280+ templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
22582281 if templ_7745c5c3_Err != nil {
2259- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 424, Col: 29}
2282+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 427, Col: 29}
22602283 }
2261- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117))
2284+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))
22622285 if templ_7745c5c3_Err != nil {
22632286 return templ_7745c5c3_Err
22642287 }
2265- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "</p>")
2288+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "</p>")
22662289 if templ_7745c5c3_Err != nil {
22672290 return templ_7745c5c3_Err
22682291 }
22692292 }
2270- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, " ")
2293+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, " ")
22712294 if templ_7745c5c3_Err != nil {
22722295 return templ_7745c5c3_Err
22732296 }
22742297 if p.Existing != nil {
2275- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "<p class=\"notice\">an open merge request already covers these branches: <a href=\"")
2298+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "<p class=\"notice\">an open merge request already covers these branches: <a href=\"")
22762299 if templ_7745c5c3_Err != nil {
22772300 return templ_7745c5c3_Err
22782301 }
2279- var templ_7745c5c3_Var118 templ.SafeURL
2280- templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
2302+ var templ_7745c5c3_Var119 templ.SafeURL
2303+ templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
22812304 if templ_7745c5c3_Err != nil {
2282- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 427, Col: 114}
2305+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 430, Col: 114}
22832306 }
2284- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))
2307+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var119))
22852308 if templ_7745c5c3_Err != nil {
22862309 return templ_7745c5c3_Err
22872310 }
2288- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "\">")
2311+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "\">")
22892312 if templ_7745c5c3_Err != nil {
22902313 return templ_7745c5c3_Err
22912314 }
2292- var templ_7745c5c3_Var119 string
2293- templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
2315+ var templ_7745c5c3_Var120 string
2316+ templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
22942317 if templ_7745c5c3_Err != nil {
2295- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 427, Col: 135}
2318+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 430, Col: 135}
22962319 }
2297- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var119))
2320+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120))
22982321 if templ_7745c5c3_Err != nil {
22992322 return templ_7745c5c3_Err
23002323 }
2301- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "</a></p>")
2324+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "</a></p>")
23022325 if templ_7745c5c3_Err != nil {
23032326 return templ_7745c5c3_Err
23042327 }
23052328 }
2306- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, " ")
2329+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, " ")
23072330 if templ_7745c5c3_Err != nil {
23082331 return templ_7745c5c3_Err
23092332 }
23102333 if p.Create != nil {
2311- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "<form class=\"cform create\" method=\"post\" action=\"")
2334+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "<form class=\"cform create\" method=\"post\" action=\"")
23122335 if templ_7745c5c3_Err != nil {
23132336 return templ_7745c5c3_Err
23142337 }
2315- var templ_7745c5c3_Var120 templ.SafeURL
2316- templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
2338+ var templ_7745c5c3_Var121 templ.SafeURL
2339+ templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
23172340 if templ_7745c5c3_Err != nil {
2318- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 430, Col: 83}
2341+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 83}
23192342 }
2320- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120))
2343+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))
23212344 if templ_7745c5c3_Err != nil {
23222345 return templ_7745c5c3_Err
23232346 }
2324- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "\"><input type=\"hidden\" name=\"source\" value=\"")
2347+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "\"><input type=\"hidden\" name=\"source\" value=\"")
23252348 if templ_7745c5c3_Err != nil {
23262349 return templ_7745c5c3_Err
23272350 }
2328- var templ_7745c5c3_Var121 string
2329- templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
2351+ var templ_7745c5c3_Var122 string
2352+ templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
23302353 if templ_7745c5c3_Err != nil {
2331- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 431, Col: 62}
2354+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 434, Col: 62}
23322355 }
2333- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var121)
2356+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var122)
23342357 if templ_7745c5c3_Err != nil {
23352358 return templ_7745c5c3_Err
23362359 }
2337- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "\"> <input type=\"hidden\" name=\"target\" value=\"")
2360+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "\"> <input type=\"hidden\" name=\"target\" value=\"")
23382361 if templ_7745c5c3_Err != nil {
23392362 return templ_7745c5c3_Err
23402363 }
2341- var templ_7745c5c3_Var122 string
2342- templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
2364+ var templ_7745c5c3_Var123 string
2365+ templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
23432366 if templ_7745c5c3_Err != nil {
2344- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 432, Col: 62}
2367+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 435, Col: 62}
23452368 }
2346- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var122)
2369+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var123)
23472370 if templ_7745c5c3_Err != nil {
23482371 return templ_7745c5c3_Err
23492372 }
2350- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "\"> <label for=\"mr-title\">title</label> <input id=\"mr-title\" name=\"title\" type=\"text\" maxlength=\"120\" required value=\"")
2373+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "\"> <label for=\"mr-title\">title</label> <input id=\"mr-title\" name=\"title\" type=\"text\" maxlength=\"120\" required value=\"")
23512374 if templ_7745c5c3_Err != nil {
23522375 return templ_7745c5c3_Err
23532376 }
2354- var templ_7745c5c3_Var123 string
2355- templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
2377+ var templ_7745c5c3_Var124 string
2378+ templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
23562379 if templ_7745c5c3_Err != nil {
2357- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 434, Col: 97}
2380+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 437, Col: 97}
23582381 }
2359- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var123)
2382+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var124)
23602383 if templ_7745c5c3_Err != nil {
23612384 return templ_7745c5c3_Err
23622385 }
2363- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "\" autocomplete=\"off\"> <label for=\"mr-desc\">description</label> <textarea id=\"mr-desc\" name=\"description\" rows=\"4\" maxlength=\"8000\" placeholder=\"plain text, optional\"></textarea><div class=\"f-btns\"><button type=\"submit\" class=\"f-main\">[create merge request]</button></div></form>")
2386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "\" autocomplete=\"off\"> <label for=\"mr-desc\">description</label> <textarea id=\"mr-desc\" name=\"description\" rows=\"4\" maxlength=\"8000\" placeholder=\"plain text, optional\"></textarea><div class=\"f-btns\"><button type=\"submit\" class=\"f-main\">[create merge request]</button></div></form>")
23642387 if templ_7745c5c3_Err != nil {
23652388 return templ_7745c5c3_Err
23662389 }
23672390 }
2368- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, " ")
2391+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, " ")
23692392 if templ_7745c5c3_Err != nil {
23702393 return templ_7745c5c3_Err
23712394 }
23722395 if p.HasDiff {
2373- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "<div class=\"diff-tools\" role=\"toolbar\" aria-label=\"Diff view\"><span class=\"dim\">")
2396+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "<div class=\"diff-tools\" role=\"toolbar\" aria-label=\"Diff view\"><span class=\"dim\">")
23742397 if templ_7745c5c3_Err != nil {
23752398 return templ_7745c5c3_Err
23762399 }
2377- var templ_7745c5c3_Var124 string
2378- templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
2400+ var templ_7745c5c3_Var125 string
2401+ templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
23792402 if templ_7745c5c3_Err != nil {
2380- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 444, Col: 57}
2403+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 447, Col: 57}
23812404 }
2382- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124))
2405+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125))
23832406 if templ_7745c5c3_Err != nil {
23842407 return templ_7745c5c3_Err
23852408 }
2386- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, " · <span class=\"plus\">+")
2409+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, " · <span class=\"plus\">+")
23872410 if templ_7745c5c3_Err != nil {
23882411 return templ_7745c5c3_Err
23892412 }
2390- var templ_7745c5c3_Var125 string
2391- templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
2413+ var templ_7745c5c3_Var126 string
2414+ templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
23922415 if templ_7745c5c3_Err != nil {
2393- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 444, Col: 97}
2416+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 447, Col: 97}
23942417 }
2395- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125))
2418+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
23962419 if templ_7745c5c3_Err != nil {
23972420 return templ_7745c5c3_Err
23982421 }
2399- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "</span> <span class=\"minus\">-")
2422+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "</span> <span class=\"minus\">-")
24002423 if templ_7745c5c3_Err != nil {
24012424 return templ_7745c5c3_Err
24022425 }
2403- var templ_7745c5c3_Var126 string
2404- templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
2426+ var templ_7745c5c3_Var127 string
2427+ templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
24052428 if templ_7745c5c3_Err != nil {
2406- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 444, Col: 142}
2429+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 447, Col: 142}
24072430 }
2408- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
2431+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127))
24092432 if templ_7745c5c3_Err != nil {
24102433 return templ_7745c5c3_Err
24112434 }
2412- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "</span></span> <span class=\"grow\"></span>")
2435+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, "</span></span> <span class=\"grow\"></span>")
24132436 if templ_7745c5c3_Err != nil {
24142437 return templ_7745c5c3_Err
24152438 }
@@ -2421,64 +2444,64 @@ func Compare(l Layout, p ComparePage) templ.Component {
24212444 if templ_7745c5c3_Err != nil {
24222445 return templ_7745c5c3_Err
24232446 }
2424- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "<button type=\"button\" aria-keyshortcuts=\"x\" hx-on:click=\"document.body.dispatchEvent(new CustomEvent('k-collapse'))\">[x] collapse all</button></div>")
2447+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "<button type=\"button\" aria-keyshortcuts=\"x\" hx-on:click=\"document.body.dispatchEvent(new CustomEvent('k-collapse'))\">[x] collapse all</button></div>")
24252448 if templ_7745c5c3_Err != nil {
24262449 return templ_7745c5c3_Err
24272450 }
24282451 if p.Invisible {
2429- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
2452+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
24302453 if templ_7745c5c3_Err != nil {
24312454 return templ_7745c5c3_Err
24322455 }
24332456 }
2434- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, " <div id=\"diffs\"")
2457+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, " <div id=\"diffs\"")
24352458 if templ_7745c5c3_Err != nil {
24362459 return templ_7745c5c3_Err
24372460 }
24382461 if p.Both {
2439- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, " data-diff=\"")
2462+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, " data-diff=\"")
24402463 if templ_7745c5c3_Err != nil {
24412464 return templ_7745c5c3_Err
24422465 }
2443- var templ_7745c5c3_Var127 string
2444- templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
2466+ var templ_7745c5c3_Var128 string
2467+ templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
24452468 if templ_7745c5c3_Err != nil {
2446- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 459, Col: 23}
2469+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 462, Col: 23}
24472470 }
2448- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var127)
2471+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var128)
24492472 if templ_7745c5c3_Err != nil {
24502473 return templ_7745c5c3_Err
24512474 }
2452- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "\"")
2475+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "\"")
24532476 if templ_7745c5c3_Err != nil {
24542477 return templ_7745c5c3_Err
24552478 }
24562479 } else {
2457- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, " data-view=\"")
2480+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, " data-view=\"")
24582481 if templ_7745c5c3_Err != nil {
24592482 return templ_7745c5c3_Err
24602483 }
2461- var templ_7745c5c3_Var128 string
2462- templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
2484+ var templ_7745c5c3_Var129 string
2485+ templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
24632486 if templ_7745c5c3_Err != nil {
2464- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 461, Col: 23}
2487+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 464, Col: 23}
24652488 }
2466- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var128)
2489+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var129)
24672490 if templ_7745c5c3_Err != nil {
24682491 return templ_7745c5c3_Err
24692492 }
2470- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "\"")
2493+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "\"")
24712494 if templ_7745c5c3_Err != nil {
24722495 return templ_7745c5c3_Err
24732496 }
24742497 }
24752498 if p.Both && !p.Pinned {
2476- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
2499+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
24772500 if templ_7745c5c3_Err != nil {
24782501 return templ_7745c5c3_Err
24792502 }
24802503 }
2481- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, ">")
2504+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, ">")
24822505 if templ_7745c5c3_Err != nil {
24832506 return templ_7745c5c3_Err
24842507 }
@@ -2488,14 +2511,14 @@ func Compare(l Layout, p ComparePage) templ.Component {
24882511 return templ_7745c5c3_Err
24892512 }
24902513 }
2491- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "</div>")
2514+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "</div>")
24922515 if templ_7745c5c3_Err != nil {
24932516 return templ_7745c5c3_Err
24942517 }
24952518 }
24962519 return nil
24972520 })
2498- templ_7745c5c3_Err = Shell(l, compareRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var109), templ_7745c5c3_Buffer)
2521+ templ_7745c5c3_Err = Shell(l, compareRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var110), templ_7745c5c3_Buffer)
24992522 if templ_7745c5c3_Err != nil {
25002523 return templ_7745c5c3_Err
25012524 }
@@ -2519,173 +2542,173 @@ func compareRail(p ComparePage) templ.Component {
25192542 }()
25202543 }
25212544 ctx = templ.InitializeContext(ctx)
2522- templ_7745c5c3_Var129 := templ.GetChildren(ctx)
2523- if templ_7745c5c3_Var129 == nil {
2524- templ_7745c5c3_Var129 = templ.NopComponent
2545+ templ_7745c5c3_Var130 := templ.GetChildren(ctx)
2546+ if templ_7745c5c3_Var130 == nil {
2547+ templ_7745c5c3_Var130 = templ.NopComponent
25252548 }
25262549 ctx = templ.ClearChildren(ctx)
2527- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">commits <span>")
2550+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">commits <span>")
25282551 if templ_7745c5c3_Err != nil {
25292552 return templ_7745c5c3_Err
25302553 }
2531- var templ_7745c5c3_Var130 string
2532- templ_7745c5c3_Var130, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
2554+ var templ_7745c5c3_Var131 string
2555+ templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
25332556 if templ_7745c5c3_Err != nil {
2534- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 477, Col: 53}
2557+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 480, Col: 53}
25352558 }
2536- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var130))
2559+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))
25372560 if templ_7745c5c3_Err != nil {
25382561 return templ_7745c5c3_Err
25392562 }
2540- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "</span></h2><ul class=\"pcommits\">")
2563+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "</span></h2><ul class=\"pcommits\">")
25412564 if templ_7745c5c3_Err != nil {
25422565 return templ_7745c5c3_Err
25432566 }
25442567 for _, c := range p.Ahead {
2545- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "<li><a href=\"")
2568+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "<li><a href=\"")
25462569 if templ_7745c5c3_Err != nil {
25472570 return templ_7745c5c3_Err
25482571 }
2549- var templ_7745c5c3_Var131 templ.SafeURL
2550- templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
2572+ var templ_7745c5c3_Var132 templ.SafeURL
2573+ templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
25512574 if templ_7745c5c3_Err != nil {
2552- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 481, Col: 37}
2575+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 37}
25532576 }
2554- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))
2577+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
25552578 if templ_7745c5c3_Err != nil {
25562579 return templ_7745c5c3_Err
25572580 }
2558- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "\">")
2581+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "\">")
25592582 if templ_7745c5c3_Err != nil {
25602583 return templ_7745c5c3_Err
25612584 }
2562- var templ_7745c5c3_Var132 string
2563- templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
2585+ var templ_7745c5c3_Var133 string
2586+ templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
25642587 if templ_7745c5c3_Err != nil {
2565- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 481, Col: 49}
2588+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 49}
25662589 }
2567- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
2590+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))
25682591 if templ_7745c5c3_Err != nil {
25692592 return templ_7745c5c3_Err
25702593 }
2571- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "</a> <span class=\"s\">")
2594+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "</a> <span class=\"s\">")
25722595 if templ_7745c5c3_Err != nil {
25732596 return templ_7745c5c3_Err
25742597 }
2575- var templ_7745c5c3_Var133 string
2576- templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
2598+ var templ_7745c5c3_Var134 string
2599+ templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
25772600 if templ_7745c5c3_Err != nil {
2578- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 482, Col: 32}
2601+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 485, Col: 32}
25792602 }
2580- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))
2603+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
25812604 if templ_7745c5c3_Err != nil {
25822605 return templ_7745c5c3_Err
25832606 }
2584- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "</span> <span class=\"mute\">")
2607+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "</span> <span class=\"mute\">")
25852608 if templ_7745c5c3_Err != nil {
25862609 return templ_7745c5c3_Err
25872610 }
2588- var templ_7745c5c3_Var134 string
2589- templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
2611+ var templ_7745c5c3_Var135 string
2612+ templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
25902613 if templ_7745c5c3_Err != nil {
2591- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 16}
2614+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 487, Col: 16}
25922615 }
2593- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
2616+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))
25942617 if templ_7745c5c3_Err != nil {
25952618 return templ_7745c5c3_Err
25962619 }
2597- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, " ")
2620+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, " ")
25982621 if templ_7745c5c3_Err != nil {
25992622 return templ_7745c5c3_Err
26002623 }
26012624 if c.WhenAt != "" {
2602- var templ_7745c5c3_Var135 string
2603- templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
2625+ var templ_7745c5c3_Var136 string
2626+ templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
26042627 if templ_7745c5c3_Err != nil {
2605- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 486, Col: 15}
2628+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 489, Col: 15}
26062629 }
2607- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))
2630+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136))
26082631 if templ_7745c5c3_Err != nil {
26092632 return templ_7745c5c3_Err
26102633 }
2611- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "<time datetime=\"")
2634+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "<time datetime=\"")
26122635 if templ_7745c5c3_Err != nil {
26132636 return templ_7745c5c3_Err
26142637 }
2615- var templ_7745c5c3_Var136 string
2616- templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
2638+ var templ_7745c5c3_Var137 string
2639+ templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
26172640 if templ_7745c5c3_Err != nil {
2618- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 486, Col: 42}
2641+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 489, Col: 42}
26192642 }
2620- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var136)
2643+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var137)
26212644 if templ_7745c5c3_Err != nil {
26222645 return templ_7745c5c3_Err
26232646 }
2624- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "\">")
2647+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "\">")
26252648 if templ_7745c5c3_Err != nil {
26262649 return templ_7745c5c3_Err
26272650 }
2628- var templ_7745c5c3_Var137 string
2629- templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
2651+ var templ_7745c5c3_Var138 string
2652+ templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
26302653 if templ_7745c5c3_Err != nil {
2631- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 486, Col: 53}
2654+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 489, Col: 53}
26322655 }
2633- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var137))
2656+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))
26342657 if templ_7745c5c3_Err != nil {
26352658 return templ_7745c5c3_Err
26362659 }
2637- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "</time>")
2660+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "</time>")
26382661 if templ_7745c5c3_Err != nil {
26392662 return templ_7745c5c3_Err
26402663 }
26412664 }
2642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "</span></li>")
2665+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "</span></li>")
26432666 if templ_7745c5c3_Err != nil {
26442667 return templ_7745c5c3_Err
26452668 }
26462669 }
2647- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "</ul>")
2670+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "</ul>")
26482671 if templ_7745c5c3_Err != nil {
26492672 return templ_7745c5c3_Err
26502673 }
26512674 if len(p.Ahead) == 0 {
2652- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "<p class=\"dim\">no commit ahead of ")
2675+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "<p class=\"dim\">no commit ahead of ")
26532676 if templ_7745c5c3_Err != nil {
26542677 return templ_7745c5c3_Err
26552678 }
2656- var templ_7745c5c3_Var138 string
2657- templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2679+ var templ_7745c5c3_Var139 string
2680+ templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
26582681 if templ_7745c5c3_Err != nil {
2659- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 493, Col: 47}
2682+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 496, Col: 47}
26602683 }
2661- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))
2684+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
26622685 if templ_7745c5c3_Err != nil {
26632686 return templ_7745c5c3_Err
26642687 }
2665- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, ".</p>")
2688+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, ".</p>")
26662689 if templ_7745c5c3_Err != nil {
26672690 return templ_7745c5c3_Err
26682691 }
26692692 }
2670- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "</section>")
2693+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "</section>")
26712694 if templ_7745c5c3_Err != nil {
26722695 return templ_7745c5c3_Err
26732696 }
26742697 if p.HasDiff {
2675- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">files <span>")
2698+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">files <span>")
26762699 if templ_7745c5c3_Err != nil {
26772700 return templ_7745c5c3_Err
26782701 }
2679- var templ_7745c5c3_Var139 string
2680- templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
2702+ var templ_7745c5c3_Var140 string
2703+ templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
26812704 if templ_7745c5c3_Err != nil {
2682- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 498, Col: 52}
2705+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 501, Col: 52}
26832706 }
2684- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
2707+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
26852708 if templ_7745c5c3_Err != nil {
26862709 return templ_7745c5c3_Err
26872710 }
2688- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "</span></h2>")
2711+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "</span></h2>")
26892712 if templ_7745c5c3_Err != nil {
26902713 return templ_7745c5c3_Err
26912714 }
@@ -2693,12 +2716,12 @@ func compareRail(p ComparePage) templ.Component {
26932716 if templ_7745c5c3_Err != nil {
26942717 return templ_7745c5c3_Err
26952718 }
2696- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "</section>")
2719+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "</section>")
26972720 if templ_7745c5c3_Err != nil {
26982721 return templ_7745c5c3_Err
26992722 }
27002723 }
2701- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">from a terminal</h2>")
2724+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">from a terminal</h2>")
27022725 if templ_7745c5c3_Err != nil {
27032726 return templ_7745c5c3_Err
27042727 }
@@ -2706,7 +2729,7 @@ func compareRail(p ComparePage) templ.Component {
27062729 if templ_7745c5c3_Err != nil {
27072730 return templ_7745c5c3_Err
27082731 }
2709- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "</section>")
2732+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "</section>")
27102733 if templ_7745c5c3_Err != nil {
27112734 return templ_7745c5c3_Err
27122735 }
@@ -2042,66 +2042,89 @@ func mergeBox(p MRPage) templ.Component {
2042 return templ_7745c5c3_Err2042 return templ_7745c5c3_Err
2043 }2043 }
2044 }2044 }
2045- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "</select><div class=\"d-btns\"><button type=\"submit\" class=\"d-main\">[merge]</button></div></form>")2045+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "</select> ")
2046+ if templ_7745c5c3_Err != nil {
2047+ return templ_7745c5c3_Err
2048+ }
2049+ if p.Box.CanDeleteBranch {
2050+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<label class=\"d-check\"><input type=\"checkbox\" name=\"delete_branch\" value=\"1\"> delete ")
2051+ if templ_7745c5c3_Err != nil {
2052+ return templ_7745c5c3_Err
2053+ }
2054+ var templ_7745c5c3_Var105 string
2055+ templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2056+ if templ_7745c5c3_Err != nil {
2057+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 386, Col: 101}
2058+ }
2059+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
2060+ if templ_7745c5c3_Err != nil {
2061+ return templ_7745c5c3_Err
2062+ }
2063+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, " after the merge</label>")
2064+ if templ_7745c5c3_Err != nil {
2065+ return templ_7745c5c3_Err
2066+ }
2067+ }
2068+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "<div class=\"d-btns\"><button type=\"submit\" class=\"d-main\">[merge]</button></div></form>")
2046 if templ_7745c5c3_Err != nil {2069 if templ_7745c5c3_Err != nil {
2047 return templ_7745c5c3_Err2070 return templ_7745c5c3_Err
2048 }2071 }
2049 }2072 }
2050- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<div class=\"d-btns\">")2073+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "<div class=\"d-btns\">")
2051 if templ_7745c5c3_Err != nil {2074 if templ_7745c5c3_Err != nil {
2052 return templ_7745c5c3_Err2075 return templ_7745c5c3_Err
2053 }2076 }
2054 if p.Box.CanMarkRead {2077 if p.Box.CanMarkRead {
2055- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "<form method=\"post\" action=\"")2078+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<form method=\"post\" action=\"")
2056 if templ_7745c5c3_Err != nil {2079 if templ_7745c5c3_Err != nil {
2057 return templ_7745c5c3_Err2080 return templ_7745c5c3_Err
2058 }2081 }
2059- var templ_7745c5c3_Var105 templ.SafeURL2082+ var templ_7745c5c3_Var106 templ.SafeURL
2060- templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))2083+ templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
2061 if templ_7745c5c3_Err != nil {2084 if templ_7745c5c3_Err != nil {
2062- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 392, Col: 64}2085+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 395, Col: 64}
2063 }2086 }
2064- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))2087+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106))
2065 if templ_7745c5c3_Err != nil {2088 if templ_7745c5c3_Err != nil {
2066 return templ_7745c5c3_Err2089 return templ_7745c5c3_Err
2067 }2090 }
2068- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "\"><input type=\"hidden\" name=\"head_id\" value=\"")2091+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
2069 if templ_7745c5c3_Err != nil {2092 if templ_7745c5c3_Err != nil {
2070 return templ_7745c5c3_Err2093 return templ_7745c5c3_Err
2071 }2094 }
2072- var templ_7745c5c3_Var106 string2095+ var templ_7745c5c3_Var107 string
2073- templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)2096+ templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
2074 if templ_7745c5c3_Err != nil {2097 if templ_7745c5c3_Err != nil {
2075- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 393, Col: 59}2098+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 396, Col: 59}
2076 }2099 }
2077- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var106)2100+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var107)
2078 if templ_7745c5c3_Err != nil {2101 if templ_7745c5c3_Err != nil {
2079 return templ_7745c5c3_Err2102 return templ_7745c5c3_Err
2080 }2103 }
2081- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "\"> <button type=\"submit\" title=\"the next visit can show only what changed after this head\">[mark read]</button></form>")2104+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "\"> <button type=\"submit\" title=\"the next visit can show only what changed after this head\">[mark read]</button></form>")
2082 if templ_7745c5c3_Err != nil {2105 if templ_7745c5c3_Err != nil {
2083 return templ_7745c5c3_Err2106 return templ_7745c5c3_Err
2084 }2107 }
2085 }2108 }
2086- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<form method=\"post\" action=\"")2109+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<form method=\"post\" action=\"")
2087 if templ_7745c5c3_Err != nil {2110 if templ_7745c5c3_Err != nil {
2088 return templ_7745c5c3_Err2111 return templ_7745c5c3_Err
2089 }2112 }
2090- var templ_7745c5c3_Var107 templ.SafeURL2113+ var templ_7745c5c3_Var108 templ.SafeURL
2091- templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))2114+ templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
2092 if templ_7745c5c3_Err != nil {2115 if templ_7745c5c3_Err != nil {
2093- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 397, Col: 64}2116+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 400, Col: 64}
2094 }2117 }
2095- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))2118+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
2096 if templ_7745c5c3_Err != nil {2119 if templ_7745c5c3_Err != nil {
2097 return templ_7745c5c3_Err2120 return templ_7745c5c3_Err
2098 }2121 }
2099- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\">[close]</button></form></div>")2122+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\">[close]</button></form></div>")
2100 if templ_7745c5c3_Err != nil {2123 if templ_7745c5c3_Err != nil {
2101 return templ_7745c5c3_Err2124 return templ_7745c5c3_Err
2102 }2125 }
2103 if !p.Box.HeadGone {2126 if !p.Box.HeadGone {
2104- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "<p class=\"mute d-or\">or from a terminal</p>")2127+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "<p class=\"mute d-or\">or from a terminal</p>")
2105 if templ_7745c5c3_Err != nil {2128 if templ_7745c5c3_Err != nil {
2106 return templ_7745c5c3_Err2129 return templ_7745c5c3_Err
2107 }2130 }
@@ -2110,7 +2133,7 @@ func mergeBox(p MRPage) templ.Component {
2110 return templ_7745c5c3_Err2133 return templ_7745c5c3_Err
2111 }2134 }
2112 }2135 }
2113- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "</section>")2136+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</section>")
2114 if templ_7745c5c3_Err != nil {2137 if templ_7745c5c3_Err != nil {
2115 return templ_7745c5c3_Err2138 return templ_7745c5c3_Err
2116 }2139 }
@@ -2136,12 +2159,12 @@ func Compare(l Layout, p ComparePage) templ.Component {
2136 }()2159 }()
2137 }2160 }
2138 ctx = templ.InitializeContext(ctx)2161 ctx = templ.InitializeContext(ctx)
2139- templ_7745c5c3_Var108 := templ.GetChildren(ctx)2162+ templ_7745c5c3_Var109 := templ.GetChildren(ctx)
2140- if templ_7745c5c3_Var108 == nil {2163+ if templ_7745c5c3_Var109 == nil {
2141- templ_7745c5c3_Var108 = templ.NopComponent2164+ templ_7745c5c3_Var109 = templ.NopComponent
2142 }2165 }
2143 ctx = templ.ClearChildren(ctx)2166 ctx = templ.ClearChildren(ctx)
2144- templ_7745c5c3_Var109 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {2167+ templ_7745c5c3_Var110 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
2145 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context2168 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
2146 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)2169 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
2147 if !templ_7745c5c3_IsBuffer {2170 if !templ_7745c5c3_IsBuffer {
@@ -2153,263 +2176,263 @@ func Compare(l Layout, p ComparePage) templ.Component {
2153 }()2176 }()
2154 }2177 }
2155 ctx = templ.InitializeContext(ctx)2178 ctx = templ.InitializeContext(ctx)
2156- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "<header class=\"show mr-h\"><h1>")2179+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "<header class=\"show mr-h\"><h1>")
2157- if templ_7745c5c3_Err != nil {
2158- return templ_7745c5c3_Err
2159- }
2160- var templ_7745c5c3_Var110 string
2161- templ_7745c5c3_Var110, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2162- if templ_7745c5c3_Err != nil {
2163- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 413, Col: 17}
2164- }
2165- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var110))
2166- if templ_7745c5c3_Err != nil {
2167- return templ_7745c5c3_Err
2168- }
2169- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, " → ")
2170 if templ_7745c5c3_Err != nil {2180 if templ_7745c5c3_Err != nil {
2171 return templ_7745c5c3_Err2181 return templ_7745c5c3_Err
2172 }2182 }
2173 var templ_7745c5c3_Var111 string2183 var templ_7745c5c3_Var111 string
2174- templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)2184+ templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2175 if templ_7745c5c3_Err != nil {2185 if templ_7745c5c3_Err != nil {
2176- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 413, Col: 34}2186+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 416, Col: 17}
2177 }2187 }
2178 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))2188 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
2179 if templ_7745c5c3_Err != nil {2189 if templ_7745c5c3_Err != nil {
2180 return templ_7745c5c3_Err2190 return templ_7745c5c3_Err
2181 }2191 }
2182- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</h1><dl class=\"kv show-kv\"><dt>branch</dt><dd><a href=\"")2192+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, " → ")
2183 if templ_7745c5c3_Err != nil {2193 if templ_7745c5c3_Err != nil {
2184 return templ_7745c5c3_Err2194 return templ_7745c5c3_Err
2185 }2195 }
2186- var templ_7745c5c3_Var112 templ.SafeURL2196+ var templ_7745c5c3_Var112 string
2187- templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))2197+ templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2188 if templ_7745c5c3_Err != nil {2198 if templ_7745c5c3_Err != nil {
2189- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 416, Col: 45}2199+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 416, Col: 34}
2190 }2200 }
2191 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))2201 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))
2192 if templ_7745c5c3_Err != nil {2202 if templ_7745c5c3_Err != nil {
2193 return templ_7745c5c3_Err2203 return templ_7745c5c3_Err
2194 }2204 }
2195- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "\">")2205+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "</h1><dl class=\"kv show-kv\"><dt>branch</dt><dd><a href=\"")
2196 if templ_7745c5c3_Err != nil {2206 if templ_7745c5c3_Err != nil {
2197 return templ_7745c5c3_Err2207 return templ_7745c5c3_Err
2198 }2208 }
2199- var templ_7745c5c3_Var113 string2209+ var templ_7745c5c3_Var113 templ.SafeURL
2200- templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)2210+ templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
2201 if templ_7745c5c3_Err != nil {2211 if templ_7745c5c3_Err != nil {
2202- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 416, Col: 58}2212+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 419, Col: 45}
2203 }2213 }
2204 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))2214 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))
2205 if templ_7745c5c3_Err != nil {2215 if templ_7745c5c3_Err != nil {
2206 return templ_7745c5c3_Err2216 return templ_7745c5c3_Err
2207 }2217 }
2208- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "</a></dd><dt>target</dt><dd><a href=\"")2218+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "\">")
2209 if templ_7745c5c3_Err != nil {2219 if templ_7745c5c3_Err != nil {
2210 return templ_7745c5c3_Err2220 return templ_7745c5c3_Err
2211 }2221 }
2212- var templ_7745c5c3_Var114 templ.SafeURL2222+ var templ_7745c5c3_Var114 string
2213- templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))2223+ templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2214 if templ_7745c5c3_Err != nil {2224 if templ_7745c5c3_Err != nil {
2215- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 45}2225+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 419, Col: 58}
2216 }2226 }
2217 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var114))2227 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var114))
2218 if templ_7745c5c3_Err != nil {2228 if templ_7745c5c3_Err != nil {
2219 return templ_7745c5c3_Err2229 return templ_7745c5c3_Err
2220 }2230 }
2221- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "\">")2231+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "</a></dd><dt>target</dt><dd><a href=\"")
2222 if templ_7745c5c3_Err != nil {2232 if templ_7745c5c3_Err != nil {
2223 return templ_7745c5c3_Err2233 return templ_7745c5c3_Err
2224 }2234 }
2225- var templ_7745c5c3_Var115 string2235+ var templ_7745c5c3_Var115 templ.SafeURL
2226- templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)2236+ templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
2227 if templ_7745c5c3_Err != nil {2237 if templ_7745c5c3_Err != nil {
2228- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 58}2238+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 421, Col: 45}
2229 }2239 }
2230 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))2240 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))
2231 if templ_7745c5c3_Err != nil {2241 if templ_7745c5c3_Err != nil {
2232 return templ_7745c5c3_Err2242 return templ_7745c5c3_Err
2233 }2243 }
2234- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "</a></dd><dt>ahead</dt><dd>")2244+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "\">")
2235 if templ_7745c5c3_Err != nil {2245 if templ_7745c5c3_Err != nil {
2236 return templ_7745c5c3_Err2246 return templ_7745c5c3_Err
2237 }2247 }
2238 var templ_7745c5c3_Var116 string2248 var templ_7745c5c3_Var116 string
2239- templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)2249+ templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2240 if templ_7745c5c3_Err != nil {2250 if templ_7745c5c3_Err != nil {
2241- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 420, Col: 21}2251+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 421, Col: 58}
2242 }2252 }
2243 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))2253 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))
2244 if templ_7745c5c3_Err != nil {2254 if templ_7745c5c3_Err != nil {
2245 return templ_7745c5c3_Err2255 return templ_7745c5c3_Err
2246 }2256 }
2247- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "</dd></dl></header>")2257+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "</a></dd><dt>ahead</dt><dd>")
2258+ if templ_7745c5c3_Err != nil {
2259+ return templ_7745c5c3_Err
2260+ }
2261+ var templ_7745c5c3_Var117 string
2262+ templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
2263+ if templ_7745c5c3_Err != nil {
2264+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 423, Col: 21}
2265+ }
2266+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117))
2267+ if templ_7745c5c3_Err != nil {
2268+ return templ_7745c5c3_Err
2269+ }
2270+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "</dd></dl></header>")
2248 if templ_7745c5c3_Err != nil {2271 if templ_7745c5c3_Err != nil {
2249 return templ_7745c5c3_Err2272 return templ_7745c5c3_Err
2250 }2273 }
2251 if p.Note != "" {2274 if p.Note != "" {
2252- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "<p class=\"notice\">")2275+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "<p class=\"notice\">")
2253 if templ_7745c5c3_Err != nil {2276 if templ_7745c5c3_Err != nil {
2254 return templ_7745c5c3_Err2277 return templ_7745c5c3_Err
2255 }2278 }
2256- var templ_7745c5c3_Var117 string2279+ var templ_7745c5c3_Var118 string
2257- templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)2280+ templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
2258 if templ_7745c5c3_Err != nil {2281 if templ_7745c5c3_Err != nil {
2259- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 424, Col: 29}2282+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 427, Col: 29}
2260 }2283 }
2261- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117))2284+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))
2262 if templ_7745c5c3_Err != nil {2285 if templ_7745c5c3_Err != nil {
2263 return templ_7745c5c3_Err2286 return templ_7745c5c3_Err
2264 }2287 }
2265- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "</p>")2288+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "</p>")
2266 if templ_7745c5c3_Err != nil {2289 if templ_7745c5c3_Err != nil {
2267 return templ_7745c5c3_Err2290 return templ_7745c5c3_Err
2268 }2291 }
2269 }2292 }
2270- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, " ")2293+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, " ")
2271 if templ_7745c5c3_Err != nil {2294 if templ_7745c5c3_Err != nil {
2272 return templ_7745c5c3_Err2295 return templ_7745c5c3_Err
2273 }2296 }
2274 if p.Existing != nil {2297 if p.Existing != nil {
2275- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "<p class=\"notice\">an open merge request already covers these branches: <a href=\"")2298+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "<p class=\"notice\">an open merge request already covers these branches: <a href=\"")
2276 if templ_7745c5c3_Err != nil {2299 if templ_7745c5c3_Err != nil {
2277 return templ_7745c5c3_Err2300 return templ_7745c5c3_Err
2278 }2301 }
2279- var templ_7745c5c3_Var118 templ.SafeURL2302+ var templ_7745c5c3_Var119 templ.SafeURL
2280- templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))2303+ templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
2281 if templ_7745c5c3_Err != nil {2304 if templ_7745c5c3_Err != nil {
2282- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 427, Col: 114}2305+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 430, Col: 114}
2283 }2306 }
2284- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))2307+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var119))
2285 if templ_7745c5c3_Err != nil {2308 if templ_7745c5c3_Err != nil {
2286 return templ_7745c5c3_Err2309 return templ_7745c5c3_Err
2287 }2310 }
2288- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "\">")2311+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "\">")
2289 if templ_7745c5c3_Err != nil {2312 if templ_7745c5c3_Err != nil {
2290 return templ_7745c5c3_Err2313 return templ_7745c5c3_Err
2291 }2314 }
2292- var templ_7745c5c3_Var119 string2315+ var templ_7745c5c3_Var120 string
2293- templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)2316+ templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
2294 if templ_7745c5c3_Err != nil {2317 if templ_7745c5c3_Err != nil {
2295- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 427, Col: 135}2318+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 430, Col: 135}
2296 }2319 }
2297- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var119))2320+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120))
2298 if templ_7745c5c3_Err != nil {2321 if templ_7745c5c3_Err != nil {
2299 return templ_7745c5c3_Err2322 return templ_7745c5c3_Err
2300 }2323 }
2301- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "</a></p>")2324+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "</a></p>")
2302 if templ_7745c5c3_Err != nil {2325 if templ_7745c5c3_Err != nil {
2303 return templ_7745c5c3_Err2326 return templ_7745c5c3_Err
2304 }2327 }
2305 }2328 }
2306- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, " ")2329+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, " ")
2307 if templ_7745c5c3_Err != nil {2330 if templ_7745c5c3_Err != nil {
2308 return templ_7745c5c3_Err2331 return templ_7745c5c3_Err
2309 }2332 }
2310 if p.Create != nil {2333 if p.Create != nil {
2311- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "<form class=\"cform create\" method=\"post\" action=\"")2334+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "<form class=\"cform create\" method=\"post\" action=\"")
2312 if templ_7745c5c3_Err != nil {2335 if templ_7745c5c3_Err != nil {
2313 return templ_7745c5c3_Err2336 return templ_7745c5c3_Err
2314 }2337 }
2315- var templ_7745c5c3_Var120 templ.SafeURL2338+ var templ_7745c5c3_Var121 templ.SafeURL
2316- templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))2339+ templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
2317 if templ_7745c5c3_Err != nil {2340 if templ_7745c5c3_Err != nil {
2318- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 430, Col: 83}2341+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 83}
2319 }2342 }
2320- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120))2343+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))
2321 if templ_7745c5c3_Err != nil {2344 if templ_7745c5c3_Err != nil {
2322 return templ_7745c5c3_Err2345 return templ_7745c5c3_Err
2323 }2346 }
2324- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "\"><input type=\"hidden\" name=\"source\" value=\"")2347+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "\"><input type=\"hidden\" name=\"source\" value=\"")
2325 if templ_7745c5c3_Err != nil {2348 if templ_7745c5c3_Err != nil {
2326 return templ_7745c5c3_Err2349 return templ_7745c5c3_Err
2327 }2350 }
2328- var templ_7745c5c3_Var121 string2351+ var templ_7745c5c3_Var122 string
2329- templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)2352+ templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
2330 if templ_7745c5c3_Err != nil {2353 if templ_7745c5c3_Err != nil {
2331- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 431, Col: 62}2354+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 434, Col: 62}
2332 }2355 }
2333- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var121)2356+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var122)
2334 if templ_7745c5c3_Err != nil {2357 if templ_7745c5c3_Err != nil {
2335 return templ_7745c5c3_Err2358 return templ_7745c5c3_Err
2336 }2359 }
2337- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "\"> <input type=\"hidden\" name=\"target\" value=\"")2360+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "\"> <input type=\"hidden\" name=\"target\" value=\"")
2338 if templ_7745c5c3_Err != nil {2361 if templ_7745c5c3_Err != nil {
2339 return templ_7745c5c3_Err2362 return templ_7745c5c3_Err
2340 }2363 }
2341- var templ_7745c5c3_Var122 string2364+ var templ_7745c5c3_Var123 string
2342- templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)2365+ templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
2343 if templ_7745c5c3_Err != nil {2366 if templ_7745c5c3_Err != nil {
2344- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 432, Col: 62}2367+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 435, Col: 62}
2345 }2368 }
2346- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var122)2369+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var123)
2347 if templ_7745c5c3_Err != nil {2370 if templ_7745c5c3_Err != nil {
2348 return templ_7745c5c3_Err2371 return templ_7745c5c3_Err
2349 }2372 }
2350- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "\"> <label for=\"mr-title\">title</label> <input id=\"mr-title\" name=\"title\" type=\"text\" maxlength=\"120\" required value=\"")2373+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "\"> <label for=\"mr-title\">title</label> <input id=\"mr-title\" name=\"title\" type=\"text\" maxlength=\"120\" required value=\"")
2351 if templ_7745c5c3_Err != nil {2374 if templ_7745c5c3_Err != nil {
2352 return templ_7745c5c3_Err2375 return templ_7745c5c3_Err
2353 }2376 }
2354- var templ_7745c5c3_Var123 string2377+ var templ_7745c5c3_Var124 string
2355- templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)2378+ templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
2356 if templ_7745c5c3_Err != nil {2379 if templ_7745c5c3_Err != nil {
2357- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 434, Col: 97}2380+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 437, Col: 97}
2358 }2381 }
2359- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var123)2382+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var124)
2360 if templ_7745c5c3_Err != nil {2383 if templ_7745c5c3_Err != nil {
2361 return templ_7745c5c3_Err2384 return templ_7745c5c3_Err
2362 }2385 }
2363- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "\" autocomplete=\"off\"> <label for=\"mr-desc\">description</label> <textarea id=\"mr-desc\" name=\"description\" rows=\"4\" maxlength=\"8000\" placeholder=\"plain text, optional\"></textarea><div class=\"f-btns\"><button type=\"submit\" class=\"f-main\">[create merge request]</button></div></form>")2386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "\" autocomplete=\"off\"> <label for=\"mr-desc\">description</label> <textarea id=\"mr-desc\" name=\"description\" rows=\"4\" maxlength=\"8000\" placeholder=\"plain text, optional\"></textarea><div class=\"f-btns\"><button type=\"submit\" class=\"f-main\">[create merge request]</button></div></form>")
2364 if templ_7745c5c3_Err != nil {2387 if templ_7745c5c3_Err != nil {
2365 return templ_7745c5c3_Err2388 return templ_7745c5c3_Err
2366 }2389 }
2367 }2390 }
2368- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, " ")2391+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, " ")
2369 if templ_7745c5c3_Err != nil {2392 if templ_7745c5c3_Err != nil {
2370 return templ_7745c5c3_Err2393 return templ_7745c5c3_Err
2371 }2394 }
2372 if p.HasDiff {2395 if p.HasDiff {
2373- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "<div class=\"diff-tools\" role=\"toolbar\" aria-label=\"Diff view\"><span class=\"dim\">")2396+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "<div class=\"diff-tools\" role=\"toolbar\" aria-label=\"Diff view\"><span class=\"dim\">")
2374 if templ_7745c5c3_Err != nil {2397 if templ_7745c5c3_Err != nil {
2375 return templ_7745c5c3_Err2398 return templ_7745c5c3_Err
2376 }2399 }
2377- var templ_7745c5c3_Var124 string2400+ var templ_7745c5c3_Var125 string
2378- templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))2401+ templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
2379 if templ_7745c5c3_Err != nil {2402 if templ_7745c5c3_Err != nil {
2380- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 444, Col: 57}2403+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 447, Col: 57}
2381 }2404 }
2382- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124))2405+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125))
2383 if templ_7745c5c3_Err != nil {2406 if templ_7745c5c3_Err != nil {
2384 return templ_7745c5c3_Err2407 return templ_7745c5c3_Err
2385 }2408 }
2386- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, " · <span class=\"plus\">+")2409+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, " · <span class=\"plus\">+")
2387 if templ_7745c5c3_Err != nil {2410 if templ_7745c5c3_Err != nil {
2388 return templ_7745c5c3_Err2411 return templ_7745c5c3_Err
2389 }2412 }
2390- var templ_7745c5c3_Var125 string2413+ var templ_7745c5c3_Var126 string
2391- templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))2414+ templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
2392 if templ_7745c5c3_Err != nil {2415 if templ_7745c5c3_Err != nil {
2393- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 444, Col: 97}2416+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 447, Col: 97}
2394 }2417 }
2395- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125))2418+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
2396 if templ_7745c5c3_Err != nil {2419 if templ_7745c5c3_Err != nil {
2397 return templ_7745c5c3_Err2420 return templ_7745c5c3_Err
2398 }2421 }
2399- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "</span> <span class=\"minus\">-")2422+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "</span> <span class=\"minus\">-")
2400 if templ_7745c5c3_Err != nil {2423 if templ_7745c5c3_Err != nil {
2401 return templ_7745c5c3_Err2424 return templ_7745c5c3_Err
2402 }2425 }
2403- var templ_7745c5c3_Var126 string2426+ var templ_7745c5c3_Var127 string
2404- templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))2427+ templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
2405 if templ_7745c5c3_Err != nil {2428 if templ_7745c5c3_Err != nil {
2406- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 444, Col: 142}2429+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 447, Col: 142}
2407 }2430 }
2408- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))2431+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127))
2409 if templ_7745c5c3_Err != nil {2432 if templ_7745c5c3_Err != nil {
2410 return templ_7745c5c3_Err2433 return templ_7745c5c3_Err
2411 }2434 }
2412- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "</span></span> <span class=\"grow\"></span>")2435+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, "</span></span> <span class=\"grow\"></span>")
2413 if templ_7745c5c3_Err != nil {2436 if templ_7745c5c3_Err != nil {
2414 return templ_7745c5c3_Err2437 return templ_7745c5c3_Err
2415 }2438 }
@@ -2421,64 +2444,64 @@ func Compare(l Layout, p ComparePage) templ.Component {
2421 if templ_7745c5c3_Err != nil {2444 if templ_7745c5c3_Err != nil {
2422 return templ_7745c5c3_Err2445 return templ_7745c5c3_Err
2423 }2446 }
2424- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "<button type=\"button\" aria-keyshortcuts=\"x\" hx-on:click=\"document.body.dispatchEvent(new CustomEvent('k-collapse'))\">[x] collapse all</button></div>")2447+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "<button type=\"button\" aria-keyshortcuts=\"x\" hx-on:click=\"document.body.dispatchEvent(new CustomEvent('k-collapse'))\">[x] collapse all</button></div>")
2425 if templ_7745c5c3_Err != nil {2448 if templ_7745c5c3_Err != nil {
2426 return templ_7745c5c3_Err2449 return templ_7745c5c3_Err
2427 }2450 }
2428 if p.Invisible {2451 if p.Invisible {
2429- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")2452+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
2430 if templ_7745c5c3_Err != nil {2453 if templ_7745c5c3_Err != nil {
2431 return templ_7745c5c3_Err2454 return templ_7745c5c3_Err
2432 }2455 }
2433 }2456 }
2434- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, " <div id=\"diffs\"")2457+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, " <div id=\"diffs\"")
2435 if templ_7745c5c3_Err != nil {2458 if templ_7745c5c3_Err != nil {
2436 return templ_7745c5c3_Err2459 return templ_7745c5c3_Err
2437 }2460 }
2438 if p.Both {2461 if p.Both {
2439- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, " data-diff=\"")2462+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, " data-diff=\"")
2440 if templ_7745c5c3_Err != nil {2463 if templ_7745c5c3_Err != nil {
2441 return templ_7745c5c3_Err2464 return templ_7745c5c3_Err
2442 }2465 }
2443- var templ_7745c5c3_Var127 string2466+ var templ_7745c5c3_Var128 string
2444- templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)2467+ templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
2445 if templ_7745c5c3_Err != nil {2468 if templ_7745c5c3_Err != nil {
2446- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 459, Col: 23}2469+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 462, Col: 23}
2447 }2470 }
2448- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var127)2471+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var128)
2449 if templ_7745c5c3_Err != nil {2472 if templ_7745c5c3_Err != nil {
2450 return templ_7745c5c3_Err2473 return templ_7745c5c3_Err
2451 }2474 }
2452- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "\"")2475+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "\"")
2453 if templ_7745c5c3_Err != nil {2476 if templ_7745c5c3_Err != nil {
2454 return templ_7745c5c3_Err2477 return templ_7745c5c3_Err
2455 }2478 }
2456 } else {2479 } else {
2457- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, " data-view=\"")2480+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, " data-view=\"")
2458 if templ_7745c5c3_Err != nil {2481 if templ_7745c5c3_Err != nil {
2459 return templ_7745c5c3_Err2482 return templ_7745c5c3_Err
2460 }2483 }
2461- var templ_7745c5c3_Var128 string2484+ var templ_7745c5c3_Var129 string
2462- templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)2485+ templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
2463 if templ_7745c5c3_Err != nil {2486 if templ_7745c5c3_Err != nil {
2464- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 461, Col: 23}2487+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 464, Col: 23}
2465 }2488 }
2466- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var128)2489+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var129)
2467 if templ_7745c5c3_Err != nil {2490 if templ_7745c5c3_Err != nil {
2468 return templ_7745c5c3_Err2491 return templ_7745c5c3_Err
2469 }2492 }
2470- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "\"")2493+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "\"")
2471 if templ_7745c5c3_Err != nil {2494 if templ_7745c5c3_Err != nil {
2472 return templ_7745c5c3_Err2495 return templ_7745c5c3_Err
2473 }2496 }
2474 }2497 }
2475 if p.Both && !p.Pinned {2498 if p.Both && !p.Pinned {
2476- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")2499+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
2477 if templ_7745c5c3_Err != nil {2500 if templ_7745c5c3_Err != nil {
2478 return templ_7745c5c3_Err2501 return templ_7745c5c3_Err
2479 }2502 }
2480 }2503 }
2481- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, ">")2504+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, ">")
2482 if templ_7745c5c3_Err != nil {2505 if templ_7745c5c3_Err != nil {
2483 return templ_7745c5c3_Err2506 return templ_7745c5c3_Err
2484 }2507 }
@@ -2488,14 +2511,14 @@ func Compare(l Layout, p ComparePage) templ.Component {
2488 return templ_7745c5c3_Err2511 return templ_7745c5c3_Err
2489 }2512 }
2490 }2513 }
2491- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "</div>")2514+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "</div>")
2492 if templ_7745c5c3_Err != nil {2515 if templ_7745c5c3_Err != nil {
2493 return templ_7745c5c3_Err2516 return templ_7745c5c3_Err
2494 }2517 }
2495 }2518 }
2496 return nil2519 return nil
2497 })2520 })
2498- templ_7745c5c3_Err = Shell(l, compareRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var109), templ_7745c5c3_Buffer)2521+ templ_7745c5c3_Err = Shell(l, compareRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var110), templ_7745c5c3_Buffer)
2499 if templ_7745c5c3_Err != nil {2522 if templ_7745c5c3_Err != nil {
2500 return templ_7745c5c3_Err2523 return templ_7745c5c3_Err
2501 }2524 }
@@ -2519,173 +2542,173 @@ func compareRail(p ComparePage) templ.Component {
2519 }()2542 }()
2520 }2543 }
2521 ctx = templ.InitializeContext(ctx)2544 ctx = templ.InitializeContext(ctx)
2522- templ_7745c5c3_Var129 := templ.GetChildren(ctx)2545+ templ_7745c5c3_Var130 := templ.GetChildren(ctx)
2523- if templ_7745c5c3_Var129 == nil {2546+ if templ_7745c5c3_Var130 == nil {
2524- templ_7745c5c3_Var129 = templ.NopComponent2547+ templ_7745c5c3_Var130 = templ.NopComponent
2525 }2548 }
2526 ctx = templ.ClearChildren(ctx)2549 ctx = templ.ClearChildren(ctx)
2527- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">commits <span>")2550+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">commits <span>")
2528 if templ_7745c5c3_Err != nil {2551 if templ_7745c5c3_Err != nil {
2529 return templ_7745c5c3_Err2552 return templ_7745c5c3_Err
2530 }2553 }
2531- var templ_7745c5c3_Var130 string2554+ var templ_7745c5c3_Var131 string
2532- templ_7745c5c3_Var130, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))2555+ templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
2533 if templ_7745c5c3_Err != nil {2556 if templ_7745c5c3_Err != nil {
2534- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 477, Col: 53}2557+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 480, Col: 53}
2535 }2558 }
2536- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var130))2559+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))
2537 if templ_7745c5c3_Err != nil {2560 if templ_7745c5c3_Err != nil {
2538 return templ_7745c5c3_Err2561 return templ_7745c5c3_Err
2539 }2562 }
2540- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "</span></h2><ul class=\"pcommits\">")2563+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "</span></h2><ul class=\"pcommits\">")
2541 if templ_7745c5c3_Err != nil {2564 if templ_7745c5c3_Err != nil {
2542 return templ_7745c5c3_Err2565 return templ_7745c5c3_Err
2543 }2566 }
2544 for _, c := range p.Ahead {2567 for _, c := range p.Ahead {
2545- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "<li><a href=\"")2568+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "<li><a href=\"")
2546 if templ_7745c5c3_Err != nil {2569 if templ_7745c5c3_Err != nil {
2547 return templ_7745c5c3_Err2570 return templ_7745c5c3_Err
2548 }2571 }
2549- var templ_7745c5c3_Var131 templ.SafeURL2572+ var templ_7745c5c3_Var132 templ.SafeURL
2550- templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))2573+ templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
2551 if templ_7745c5c3_Err != nil {2574 if templ_7745c5c3_Err != nil {
2552- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 481, Col: 37}2575+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 37}
2553 }2576 }
2554- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))2577+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
2555 if templ_7745c5c3_Err != nil {2578 if templ_7745c5c3_Err != nil {
2556 return templ_7745c5c3_Err2579 return templ_7745c5c3_Err
2557 }2580 }
2558- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "\">")2581+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "\">")
2559 if templ_7745c5c3_Err != nil {2582 if templ_7745c5c3_Err != nil {
2560 return templ_7745c5c3_Err2583 return templ_7745c5c3_Err
2561 }2584 }
2562- var templ_7745c5c3_Var132 string2585+ var templ_7745c5c3_Var133 string
2563- templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)2586+ templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
2564 if templ_7745c5c3_Err != nil {2587 if templ_7745c5c3_Err != nil {
2565- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 481, Col: 49}2588+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 49}
2566 }2589 }
2567- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))2590+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))
2568 if templ_7745c5c3_Err != nil {2591 if templ_7745c5c3_Err != nil {
2569 return templ_7745c5c3_Err2592 return templ_7745c5c3_Err
2570 }2593 }
2571- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "</a> <span class=\"s\">")2594+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "</a> <span class=\"s\">")
2572 if templ_7745c5c3_Err != nil {2595 if templ_7745c5c3_Err != nil {
2573 return templ_7745c5c3_Err2596 return templ_7745c5c3_Err
2574 }2597 }
2575- var templ_7745c5c3_Var133 string2598+ var templ_7745c5c3_Var134 string
2576- templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)2599+ templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
2577 if templ_7745c5c3_Err != nil {2600 if templ_7745c5c3_Err != nil {
2578- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 482, Col: 32}2601+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 485, Col: 32}
2579 }2602 }
2580- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))2603+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
2581 if templ_7745c5c3_Err != nil {2604 if templ_7745c5c3_Err != nil {
2582 return templ_7745c5c3_Err2605 return templ_7745c5c3_Err
2583 }2606 }
2584- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "</span> <span class=\"mute\">")2607+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "</span> <span class=\"mute\">")
2585 if templ_7745c5c3_Err != nil {2608 if templ_7745c5c3_Err != nil {
2586 return templ_7745c5c3_Err2609 return templ_7745c5c3_Err
2587 }2610 }
2588- var templ_7745c5c3_Var134 string2611+ var templ_7745c5c3_Var135 string
2589- templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)2612+ templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
2590 if templ_7745c5c3_Err != nil {2613 if templ_7745c5c3_Err != nil {
2591- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 16}2614+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 487, Col: 16}
2592 }2615 }
2593- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))2616+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))
2594 if templ_7745c5c3_Err != nil {2617 if templ_7745c5c3_Err != nil {
2595 return templ_7745c5c3_Err2618 return templ_7745c5c3_Err
2596 }2619 }
2597- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, " ")2620+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, " ")
2598 if templ_7745c5c3_Err != nil {2621 if templ_7745c5c3_Err != nil {
2599 return templ_7745c5c3_Err2622 return templ_7745c5c3_Err
2600 }2623 }
2601 if c.WhenAt != "" {2624 if c.WhenAt != "" {
2602- var templ_7745c5c3_Var135 string2625+ var templ_7745c5c3_Var136 string
2603- templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")2626+ templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
2604 if templ_7745c5c3_Err != nil {2627 if templ_7745c5c3_Err != nil {
2605- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 486, Col: 15}2628+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 489, Col: 15}
2606 }2629 }
2607- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))2630+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136))
2608 if templ_7745c5c3_Err != nil {2631 if templ_7745c5c3_Err != nil {
2609 return templ_7745c5c3_Err2632 return templ_7745c5c3_Err
2610 }2633 }
2611- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "<time datetime=\"")2634+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "<time datetime=\"")
2612 if templ_7745c5c3_Err != nil {2635 if templ_7745c5c3_Err != nil {
2613 return templ_7745c5c3_Err2636 return templ_7745c5c3_Err
2614 }2637 }
2615- var templ_7745c5c3_Var136 string2638+ var templ_7745c5c3_Var137 string
2616- templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)2639+ templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
2617 if templ_7745c5c3_Err != nil {2640 if templ_7745c5c3_Err != nil {
2618- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 486, Col: 42}2641+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 489, Col: 42}
2619 }2642 }
2620- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var136)2643+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var137)
2621 if templ_7745c5c3_Err != nil {2644 if templ_7745c5c3_Err != nil {
2622 return templ_7745c5c3_Err2645 return templ_7745c5c3_Err
2623 }2646 }
2624- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "\">")2647+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "\">")
2625 if templ_7745c5c3_Err != nil {2648 if templ_7745c5c3_Err != nil {
2626 return templ_7745c5c3_Err2649 return templ_7745c5c3_Err
2627 }2650 }
2628- var templ_7745c5c3_Var137 string2651+ var templ_7745c5c3_Var138 string
2629- templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)2652+ templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
2630 if templ_7745c5c3_Err != nil {2653 if templ_7745c5c3_Err != nil {
2631- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 486, Col: 53}2654+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 489, Col: 53}
2632 }2655 }
2633- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var137))2656+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))
2634 if templ_7745c5c3_Err != nil {2657 if templ_7745c5c3_Err != nil {
2635 return templ_7745c5c3_Err2658 return templ_7745c5c3_Err
2636 }2659 }
2637- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "</time>")2660+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "</time>")
2638 if templ_7745c5c3_Err != nil {2661 if templ_7745c5c3_Err != nil {
2639 return templ_7745c5c3_Err2662 return templ_7745c5c3_Err
2640 }2663 }
2641 }2664 }
2642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "</span></li>")2665+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "</span></li>")
2643 if templ_7745c5c3_Err != nil {2666 if templ_7745c5c3_Err != nil {
2644 return templ_7745c5c3_Err2667 return templ_7745c5c3_Err
2645 }2668 }
2646 }2669 }
2647- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "</ul>")2670+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "</ul>")
2648 if templ_7745c5c3_Err != nil {2671 if templ_7745c5c3_Err != nil {
2649 return templ_7745c5c3_Err2672 return templ_7745c5c3_Err
2650 }2673 }
2651 if len(p.Ahead) == 0 {2674 if len(p.Ahead) == 0 {
2652- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "<p class=\"dim\">no commit ahead of ")2675+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "<p class=\"dim\">no commit ahead of ")
2653 if templ_7745c5c3_Err != nil {2676 if templ_7745c5c3_Err != nil {
2654 return templ_7745c5c3_Err2677 return templ_7745c5c3_Err
2655 }2678 }
2656- var templ_7745c5c3_Var138 string2679+ var templ_7745c5c3_Var139 string
2657- templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)2680+ templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2658 if templ_7745c5c3_Err != nil {2681 if templ_7745c5c3_Err != nil {
2659- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 493, Col: 47}2682+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 496, Col: 47}
2660 }2683 }
2661- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))2684+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
2662 if templ_7745c5c3_Err != nil {2685 if templ_7745c5c3_Err != nil {
2663 return templ_7745c5c3_Err2686 return templ_7745c5c3_Err
2664 }2687 }
2665- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, ".</p>")2688+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, ".</p>")
2666 if templ_7745c5c3_Err != nil {2689 if templ_7745c5c3_Err != nil {
2667 return templ_7745c5c3_Err2690 return templ_7745c5c3_Err
2668 }2691 }
2669 }2692 }
2670- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "</section>")2693+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "</section>")
2671 if templ_7745c5c3_Err != nil {2694 if templ_7745c5c3_Err != nil {
2672 return templ_7745c5c3_Err2695 return templ_7745c5c3_Err
2673 }2696 }
2674 if p.HasDiff {2697 if p.HasDiff {
2675- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">files <span>")2698+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">files <span>")
2676 if templ_7745c5c3_Err != nil {2699 if templ_7745c5c3_Err != nil {
2677 return templ_7745c5c3_Err2700 return templ_7745c5c3_Err
2678 }2701 }
2679- var templ_7745c5c3_Var139 string2702+ var templ_7745c5c3_Var140 string
2680- templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))2703+ templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
2681 if templ_7745c5c3_Err != nil {2704 if templ_7745c5c3_Err != nil {
2682- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 498, Col: 52}2705+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 501, Col: 52}
2683 }2706 }
2684- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))2707+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
2685 if templ_7745c5c3_Err != nil {2708 if templ_7745c5c3_Err != nil {
2686 return templ_7745c5c3_Err2709 return templ_7745c5c3_Err
2687 }2710 }
2688- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "</span></h2>")2711+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "</span></h2>")
2689 if templ_7745c5c3_Err != nil {2712 if templ_7745c5c3_Err != nil {
2690 return templ_7745c5c3_Err2713 return templ_7745c5c3_Err
2691 }2714 }
@@ -2693,12 +2716,12 @@ func compareRail(p ComparePage) templ.Component {
2693 if templ_7745c5c3_Err != nil {2716 if templ_7745c5c3_Err != nil {
2694 return templ_7745c5c3_Err2717 return templ_7745c5c3_Err
2695 }2718 }
2696- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "</section>")2719+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "</section>")
2697 if templ_7745c5c3_Err != nil {2720 if templ_7745c5c3_Err != nil {
2698 return templ_7745c5c3_Err2721 return templ_7745c5c3_Err
2699 }2722 }
2700 }2723 }
2701- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">from a terminal</h2>")2724+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">from a terminal</h2>")
2702 if templ_7745c5c3_Err != nil {2725 if templ_7745c5c3_Err != nil {
2703 return templ_7745c5c3_Err2726 return templ_7745c5c3_Err
2704 }2727 }
@@ -2706,7 +2729,7 @@ func compareRail(p ComparePage) templ.Component {
2706 if templ_7745c5c3_Err != nil {2729 if templ_7745c5c3_Err != nil {
2707 return templ_7745c5c3_Err2730 return templ_7745c5c3_Err
2708 }2731 }
2709- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "</section>")2732+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "</section>")
2710 if templ_7745c5c3_Err != nil {2733 if templ_7745c5c3_Err != nil {
2711 return templ_7745c5c3_Err2734 return templ_7745c5c3_Err
2712 }2735 }
M internal/adapters/web/static/css/60-mr.css +2 -0
@@ -24,6 +24,8 @@
2424 display: block; width: 100%; font: inherit; color: inherit; background: var(--paper-alt);
2525 border: 1px solid var(--rule); border-radius: 0; padding: 0 1ch;
2626 }
27+ & .d-check { display: block; margin-top: 4px; color: var(--ink-dim); cursor: pointer; }
28+ & .d-check input { accent-color: var(--ink); margin: 0 1ch 0 0; vertical-align: middle; }
2729 & .d-btns { display: flex; gap: 2ch; margin-top: 4px; flex-wrap: wrap; }
2830 & .d-btns button { color: var(--ink-dim); }
2931 & .d-btns button:hover { color: var(--ink); text-decoration: underline; }
@@ -24,6 +24,8 @@
24 display: block; width: 100%; font: inherit; color: inherit; background: var(--paper-alt);24 display: block; width: 100%; font: inherit; color: inherit; background: var(--paper-alt);
25 border: 1px solid var(--rule); border-radius: 0; padding: 0 1ch;25 border: 1px solid var(--rule); border-radius: 0; padding: 0 1ch;
26 }26 }
27+ & .d-check { display: block; margin-top: 4px; color: var(--ink-dim); cursor: pointer; }
28+ & .d-check input { accent-color: var(--ink); margin: 0 1ch 0 0; vertical-align: middle; }
27 & .d-btns { display: flex; gap: 2ch; margin-top: 4px; flex-wrap: wrap; }29 & .d-btns { display: flex; gap: 2ch; margin-top: 4px; flex-wrap: wrap; }
28 & .d-btns button { color: var(--ink-dim); }30 & .d-btns button { color: var(--ink-dim); }
29 & .d-btns button:hover { color: var(--ink); text-decoration: underline; }31 & .d-btns button:hover { color: var(--ink); text-decoration: underline; }
M internal/app/mergerequests.go +85 -22
@@ -703,6 +703,16 @@ type MergeParams struct {
703703 // HeadID is the commit the caller reviewed; the source branch must still
704704 // point to it.
705705 HeadID domain.ObjectID
706+ // DeleteSource deletes the source branch after the merge.
707+ DeleteSource bool
708+}
709+
710+// MergeResult is a merged request and what happened to its source branch.
711+type MergeResult struct {
712+ domain.MergeRequest
713+ // SourceDeleted is true when DeleteSource was asked and the branch is
714+ // gone.
715+ SourceDeleted bool
706716 }
707717
708718 // Merge merges an open merge request, publishes MergeRequestMerged then a
@@ -722,10 +732,21 @@ type MergeParams struct {
722732 // precondition. A squash writes one commit with the tree of the head, the
723733 // target as parent and the author and message of domain.NewSquashCommit,
724734 // signed by the instance key, and moves the target to it the same way.
725-func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (domain.MergeRequest, error) {
735+//
736+// With DeleteSource the merge is also refused, before anything changes, when
737+// the source is the default branch or the source or target of another open
738+// request. After the merge the source is deleted if it still points to the
739+// head; the PushEvent then carries the deletion too. A failed deletion is
740+// logged and leaves the branch: the merge stands and SourceDeleted is false.
741+func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (MergeResult, error) {
742+ mr, deleted, err := m.merge(ctx, p, name, number, params)
743+ return MergeResult{MergeRequest: mr, SourceDeleted: deleted}, err
744+}
745+
746+func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (domain.MergeRequest, bool, error) {
726747 repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)
727748 if err != nil {
728- return domain.MergeRequest{}, err
749+ return domain.MergeRequest{}, false, err
729750 }
730751 mode := params.Mode
731752 if mode == "" {
@@ -739,83 +760,125 @@ func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name doma
739760 v.Add("head_id", "is required")
740761 }
741762 if err := v.Err(); err != nil {
742- return domain.MergeRequest{}, err
763+ return domain.MergeRequest{}, false, err
743764 }
744765
745766 unlock, err := m.lockRepo(ctx, name)
746767 if err != nil {
747- return domain.MergeRequest{}, err
768+ return domain.MergeRequest{}, false, err
748769 }
749770 defer unlock()
750771
751772 mr, err := m.store.Get(ctx, name, number)
752773 if err != nil {
753- return domain.MergeRequest{}, err
774+ return domain.MergeRequest{}, false, err
754775 }
755776 if !mr.IsOpen() {
756- return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
777+ return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
757778 }
758779 // The settings are read again under the lock: a change applies at once.
759780 repo, err = m.meta.Get(ctx, name)
760781 if err != nil {
761782 if errors.Is(err, domain.ErrNotFound) {
762- return domain.MergeRequest{}, err // deleted meanwhile
783+ return domain.MergeRequest{}, false, err // deleted meanwhile
763784 }
764- return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err)
785+ return domain.MergeRequest{}, false, fmt.Errorf("get repository %q: %w", name, err)
765786 }
766787 settings := repo.Settings
767788 if settings.IsZero() {
768789 settings = domain.DefaultRepoSettings()
769790 }
770791 if !settings.Allows(mode) {
771- return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
792+ return domain.MergeRequest{}, false, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
772793 }
773794
774795 head, err := m.branchOrZero(ctx, name, mr.Source)
775796 switch {
776797 case err != nil:
777- return domain.MergeRequest{}, err
798+ return domain.MergeRequest{}, false, err
778799 case head.IsZero():
779- return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
800+ return domain.MergeRequest{}, false, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
780801 case head != params.HeadID:
781- return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
802+ return domain.MergeRequest{}, false, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
782803 }
783804 tip, err := m.branchOrZero(ctx, name, mr.Target)
784805 switch {
785806 case err != nil:
786- return domain.MergeRequest{}, err
807+ return domain.MergeRequest{}, false, err
787808 case tip.IsZero():
788- return domain.MergeRequest{}, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target)
809+ return domain.MergeRequest{}, false, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target)
789810 case tip == head:
790- return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head)
811+ return domain.MergeRequest{}, false, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head)
791812 }
792813 switch ok, err := m.git.IsAncestor(ctx, name, tip, head); {
793814 case err != nil:
794- return domain.MergeRequest{}, err
815+ return domain.MergeRequest{}, false, err
795816 case !ok:
796- return domain.MergeRequest{}, 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)
817+ 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)
818+ }
819+ if params.DeleteSource {
820+ if err := m.checkDeletable(ctx, name, mr); err != nil {
821+ return domain.MergeRequest{}, false, err
822+ }
797823 }
798824
799825 merged, err := m.moveTarget(ctx, mr, mode, tip, head)
800826 if err != nil {
801- return domain.MergeRequest{}, err
827+ return domain.MergeRequest{}, false, err
802828 }
803829 m.recordPush(ctx, name) // the target moved, whatever happens next
804830 mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)
805831 if err != nil {
806- return domain.MergeRequest{}, err
832+ return domain.MergeRequest{}, false, err
807833 }
808834 log := zerowrap.FromCtx(ctx)
809835 log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()).
810836 Str("target", mr.Target).Str("commit", merged.String()).
811837 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged")
838+ updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
839+ deleted := false
840+ if params.DeleteSource {
841+ // The merge is done and recorded: a failure here keeps the branch.
842+ switch err := m.git.DeleteBranch(context.WithoutCancel(ctx), name, mr.Source, head); {
843+ case err != nil:
844+ log.Warn().Err(err).Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source).
845+ Msg("source branch kept after the merge")
846+ default:
847+ deleted = true
848+ updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source})
849+ log.Info().Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source).
850+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge")
851+ }
852+ }
812853 at := mr.UpdatedAt
813854 m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
814855 m.bus.Publish(ctx, domain.PushEvent{
815- Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
816- Updates: []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}},
856+ Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
817857 })
818- return mr, nil
858+ return mr, deleted, nil
859+}
860+
861+// checkDeletable refuses to delete the source branch of mr when it is the
862+// default branch or the source or target of another open request. It runs
863+// under the merge lock, which also guards the creation of requests.
864+func (m *MergeRequests) checkDeletable(ctx context.Context, name domain.RepoName, mr domain.MergeRequest) error {
865+ def, _, err := m.repos.DefaultBranch(ctx, name)
866+ if err != nil {
867+ return fmt.Errorf("default branch of %q: %w", name, err)
868+ }
869+ if mr.Source == def {
870+ return fmt.Errorf("%w: %q is the default branch of %s: it cannot be deleted", domain.ErrPreconditionFailed, mr.Source, name)
871+ }
872+ all, err := m.store.List(ctx, name)
873+ if err != nil {
874+ return fmt.Errorf("list merge requests of %q: %w", name, err)
875+ }
876+ for _, o := range all {
877+ if o.Number != mr.Number && o.IsOpen() && (o.Source == mr.Source || o.Target == mr.Source) {
878+ return fmt.Errorf("%w: !%d still uses %q: it cannot be deleted", domain.ErrPreconditionFailed, o.Number, mr.Source)
879+ }
880+ }
881+ return nil
819882 }
820883
821884 // recordPush does for a merge what Pushes.Record does for a push, as the merge
@@ -703,6 +703,16 @@ type MergeParams struct {
703 // HeadID is the commit the caller reviewed; the source branch must still703 // HeadID is the commit the caller reviewed; the source branch must still
704 // point to it.704 // point to it.
705 HeadID domain.ObjectID705 HeadID domain.ObjectID
706+ // DeleteSource deletes the source branch after the merge.
707+ DeleteSource bool
708+}
709+
710+// MergeResult is a merged request and what happened to its source branch.
711+type MergeResult struct {
712+ domain.MergeRequest
713+ // SourceDeleted is true when DeleteSource was asked and the branch is
714+ // gone.
715+ SourceDeleted bool
706 }716 }
707 717
708 // Merge merges an open merge request, publishes MergeRequestMerged then a718 // Merge merges an open merge request, publishes MergeRequestMerged then a
@@ -722,10 +732,21 @@ type MergeParams struct {
722 // precondition. A squash writes one commit with the tree of the head, the732 // precondition. A squash writes one commit with the tree of the head, the
723 // target as parent and the author and message of domain.NewSquashCommit,733 // target as parent and the author and message of domain.NewSquashCommit,
724 // signed by the instance key, and moves the target to it the same way.734 // signed by the instance key, and moves the target to it the same way.
725-func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (domain.MergeRequest, error) {735+//
736+// With DeleteSource the merge is also refused, before anything changes, when
737+// the source is the default branch or the source or target of another open
738+// request. After the merge the source is deleted if it still points to the
739+// head; the PushEvent then carries the deletion too. A failed deletion is
740+// logged and leaves the branch: the merge stands and SourceDeleted is false.
741+func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (MergeResult, error) {
742+ mr, deleted, err := m.merge(ctx, p, name, number, params)
743+ return MergeResult{MergeRequest: mr, SourceDeleted: deleted}, err
744+}
745+
746+func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (domain.MergeRequest, bool, error) {
726 repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)747 repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)
727 if err != nil {748 if err != nil {
728- return domain.MergeRequest{}, err749+ return domain.MergeRequest{}, false, err
729 }750 }
730 mode := params.Mode751 mode := params.Mode
731 if mode == "" {752 if mode == "" {
@@ -739,83 +760,125 @@ func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name doma
739 v.Add("head_id", "is required")760 v.Add("head_id", "is required")
740 }761 }
741 if err := v.Err(); err != nil {762 if err := v.Err(); err != nil {
742- return domain.MergeRequest{}, err763+ return domain.MergeRequest{}, false, err
743 }764 }
744 765
745 unlock, err := m.lockRepo(ctx, name)766 unlock, err := m.lockRepo(ctx, name)
746 if err != nil {767 if err != nil {
747- return domain.MergeRequest{}, err768+ return domain.MergeRequest{}, false, err
748 }769 }
749 defer unlock()770 defer unlock()
750 771
751 mr, err := m.store.Get(ctx, name, number)772 mr, err := m.store.Get(ctx, name, number)
752 if err != nil {773 if err != nil {
753- return domain.MergeRequest{}, err774+ return domain.MergeRequest{}, false, err
754 }775 }
755 if !mr.IsOpen() {776 if !mr.IsOpen() {
756- return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)777+ return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
757 }778 }
758 // The settings are read again under the lock: a change applies at once.779 // The settings are read again under the lock: a change applies at once.
759 repo, err = m.meta.Get(ctx, name)780 repo, err = m.meta.Get(ctx, name)
760 if err != nil {781 if err != nil {
761 if errors.Is(err, domain.ErrNotFound) {782 if errors.Is(err, domain.ErrNotFound) {
762- return domain.MergeRequest{}, err // deleted meanwhile783+ return domain.MergeRequest{}, false, err // deleted meanwhile
763 }784 }
764- return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err)785+ return domain.MergeRequest{}, false, fmt.Errorf("get repository %q: %w", name, err)
765 }786 }
766 settings := repo.Settings787 settings := repo.Settings
767 if settings.IsZero() {788 if settings.IsZero() {
768 settings = domain.DefaultRepoSettings()789 settings = domain.DefaultRepoSettings()
769 }790 }
770 if !settings.Allows(mode) {791 if !settings.Allows(mode) {
771- return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)792+ return domain.MergeRequest{}, false, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
772 }793 }
773 794
774 head, err := m.branchOrZero(ctx, name, mr.Source)795 head, err := m.branchOrZero(ctx, name, mr.Source)
775 switch {796 switch {
776 case err != nil:797 case err != nil:
777- return domain.MergeRequest{}, err798+ return domain.MergeRequest{}, false, err
778 case head.IsZero():799 case head.IsZero():
779- return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)800+ return domain.MergeRequest{}, false, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
780 case head != params.HeadID:801 case head != params.HeadID:
781- return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)802+ return domain.MergeRequest{}, false, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
782 }803 }
783 tip, err := m.branchOrZero(ctx, name, mr.Target)804 tip, err := m.branchOrZero(ctx, name, mr.Target)
784 switch {805 switch {
785 case err != nil:806 case err != nil:
786- return domain.MergeRequest{}, err807+ return domain.MergeRequest{}, false, err
787 case tip.IsZero():808 case tip.IsZero():
788- return domain.MergeRequest{}, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target)809+ return domain.MergeRequest{}, false, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target)
789 case tip == head:810 case tip == head:
790- return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head)811+ return domain.MergeRequest{}, false, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head)
791 }812 }
792 switch ok, err := m.git.IsAncestor(ctx, name, tip, head); {813 switch ok, err := m.git.IsAncestor(ctx, name, tip, head); {
793 case err != nil:814 case err != nil:
794- return domain.MergeRequest{}, err815+ return domain.MergeRequest{}, false, err
795 case !ok:816 case !ok:
796- return domain.MergeRequest{}, 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)817+ 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)
818+ }
819+ if params.DeleteSource {
820+ if err := m.checkDeletable(ctx, name, mr); err != nil {
821+ return domain.MergeRequest{}, false, err
822+ }
797 }823 }
798 824
799 merged, err := m.moveTarget(ctx, mr, mode, tip, head)825 merged, err := m.moveTarget(ctx, mr, mode, tip, head)
800 if err != nil {826 if err != nil {
801- return domain.MergeRequest{}, err827+ return domain.MergeRequest{}, false, err
802 }828 }
803 m.recordPush(ctx, name) // the target moved, whatever happens next829 m.recordPush(ctx, name) // the target moved, whatever happens next
804 mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)830 mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)
805 if err != nil {831 if err != nil {
806- return domain.MergeRequest{}, err832+ return domain.MergeRequest{}, false, err
807 }833 }
808 log := zerowrap.FromCtx(ctx)834 log := zerowrap.FromCtx(ctx)
809 log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()).835 log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()).
810 Str("target", mr.Target).Str("commit", merged.String()).836 Str("target", mr.Target).Str("commit", merged.String()).
811 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged")837 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged")
838+ updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
839+ deleted := false
840+ if params.DeleteSource {
841+ // The merge is done and recorded: a failure here keeps the branch.
842+ switch err := m.git.DeleteBranch(context.WithoutCancel(ctx), name, mr.Source, head); {
843+ case err != nil:
844+ log.Warn().Err(err).Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source).
845+ Msg("source branch kept after the merge")
846+ default:
847+ deleted = true
848+ updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source})
849+ log.Info().Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source).
850+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge")
851+ }
852+ }
812 at := mr.UpdatedAt853 at := mr.UpdatedAt
813 m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})854 m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
814 m.bus.Publish(ctx, domain.PushEvent{855 m.bus.Publish(ctx, domain.PushEvent{
815- Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,856+ Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
816- Updates: []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}},
817 })857 })
818- return mr, nil858+ return mr, deleted, nil
859+}
860+
861+// checkDeletable refuses to delete the source branch of mr when it is the
862+// default branch or the source or target of another open request. It runs
863+// under the merge lock, which also guards the creation of requests.
864+func (m *MergeRequests) checkDeletable(ctx context.Context, name domain.RepoName, mr domain.MergeRequest) error {
865+ def, _, err := m.repos.DefaultBranch(ctx, name)
866+ if err != nil {
867+ return fmt.Errorf("default branch of %q: %w", name, err)
868+ }
869+ if mr.Source == def {
870+ return fmt.Errorf("%w: %q is the default branch of %s: it cannot be deleted", domain.ErrPreconditionFailed, mr.Source, name)
871+ }
872+ all, err := m.store.List(ctx, name)
873+ if err != nil {
874+ return fmt.Errorf("list merge requests of %q: %w", name, err)
875+ }
876+ for _, o := range all {
877+ if o.Number != mr.Number && o.IsOpen() && (o.Source == mr.Source || o.Target == mr.Source) {
878+ return fmt.Errorf("%w: !%d still uses %q: it cannot be deleted", domain.ErrPreconditionFailed, o.Number, mr.Source)
879+ }
880+ }
881+ return nil
819 }882 }
820 883
821 // recordPush does for a merge what Pushes.Record does for a push, as the merge884 // recordPush does for a merge what Pushes.Record does for a push, as the merge
A internal/app/mergerequests_delete_test.go +151 -0
@@ -0,0 +1,151 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/adapters/gitcli"
13+ "bnema.dev/ermite/internal/app"
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+// A merge can delete its source branch; the push event carries both refs.
18+func TestMergeDeletesTheSourceBranch(t *testing.T) {
19+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
20+ t.Run(string(mode), func(t *testing.T) {
21+ r := newMRRig(t)
22+ main := r.seed()
23+ head := r.feature("feat")
24+ mr := r.open("feat")
25+ r.drain()
26+
27+ res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{Mode: mode, HeadID: head, DeleteSource: true})
28+ require.NoError(t, err)
29+ assert.True(t, res.SourceDeleted)
30+ assert.Equal(t, domain.MRStateMerged, res.State)
31+ _, err = r.git.BranchHead(r.ctx, r.repo, "feat")
32+ assert.ErrorIs(t, err, domain.ErrNotFound, "the branch is gone")
33+
34+ evs := r.drain()
35+ require.Len(t, evs, 2)
36+ assert.Equal(t, []domain.RefUpdate{
37+ {Old: main.String(), New: res.MergedCommitID.String(), Ref: "refs/heads/main"},
38+ {Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/feat"},
39+ }, evs[1].(domain.PushEvent).Updates)
40+
41+ // The merged request still reads, with its commits and diff.
42+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
43+ require.NoError(t, err)
44+ assert.Len(t, d.Commits, 2)
45+ assert.Len(t, d.Diff.Files, 2)
46+ })
47+ }
48+
49+ t.Run("without the option the branch stays", func(t *testing.T) {
50+ r := newMRRig(t)
51+ r.seed()
52+ head := r.feature("feat")
53+ mr := r.open("feat")
54+ res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head})
55+ require.NoError(t, err)
56+ assert.False(t, res.SourceDeleted)
57+ assert.Equal(t, head, r.branchHead("feat"))
58+ })
59+}
60+
61+// Deleting the source is refused before the merge when the branch is still
62+// needed: nothing changes.
63+func TestMergeRefusesToDeleteANeededBranch(t *testing.T) {
64+ t.Run("another open request uses it as its source", func(t *testing.T) {
65+ r := newMRRig(t)
66+ main := r.seed()
67+ head := r.feature("feat")
68+ r.sh("checkout", "-q", "-B", "release", "main")
69+ r.push("release")
70+ mr := r.open("feat")
71+ _, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "to release", Source: "feat", Target: "release"})
72+ require.NoError(t, err)
73+
74+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, DeleteSource: true})
75+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
76+ assert.Contains(t, err.Error(), "!2 still uses")
77+ assert.Equal(t, main, r.branchHead("main"), "not merged")
78+ assert.Equal(t, head, r.branchHead("feat"))
79+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
80+ require.NoError(t, err)
81+ assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
82+ })
83+
84+ t.Run("another open request targets it", func(t *testing.T) {
85+ r := newMRRig(t)
86+ r.seed()
87+ head := r.feature("feat")
88+ mr := r.open("feat")
89+ r.sh("checkout", "-q", "-B", "sub", "feat")
90+ r.commit(author, "d.txt", "dee\n", "add d")
91+ r.push("sub")
92+ _, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "stacked", Source: "sub", Target: "feat"})
93+ require.NoError(t, err)
94+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, DeleteSource: true})
95+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
96+ assert.Equal(t, head, r.branchHead("feat"))
97+ })
98+
99+ t.Run("the default branch", func(t *testing.T) {
100+ r := newMRRig(t)
101+ main := r.seed()
102+ r.sh("checkout", "-q", "-B", "old", "main~1")
103+ r.push("old")
104+ old := r.branchHead("old")
105+ mr, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "back", Source: "main", Target: "old"})
106+ require.NoError(t, err)
107+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: main, DeleteSource: true})
108+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
109+ assert.Contains(t, err.Error(), "default branch")
110+ assert.Equal(t, old, r.branchHead("old"), "not merged")
111+ assert.Equal(t, main, r.branchHead("main"))
112+ })
113+}
114+
115+// movingGit pushes to the source branch between the merge and the deletion.
116+type movingGit struct {
117+ *gitcli.Git
118+ move func()
119+}
120+
121+func (g movingGit) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
122+ g.move()
123+ return g.Git.DeleteBranch(ctx, repo, branch, old)
124+}
125+
126+// A branch that moves after the merge is kept: the merge stands.
127+func TestMergeKeepsASourceThatMovedAfterTheMerge(t *testing.T) {
128+ r := newMRRig(t)
129+ r.seed()
130+ head := r.feature("feat")
131+ mr := r.open("feat")
132+ var moved domain.ObjectID
133+ mrs := app.NewMergeRequests(app.MergeRequestsConfig{
134+ Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched,
135+ Git: movingGit{Git: r.git, move: func() {
136+ moved = r.commit(author, "late.txt", "late\n", "pushed during the merge")
137+ r.push("feat")
138+ }},
139+ })
140+ r.drain()
141+
142+ res, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, DeleteSource: true})
143+ require.NoError(t, err, "the merge stands")
144+ assert.False(t, res.SourceDeleted)
145+ assert.Equal(t, domain.MRStateMerged, res.State)
146+ assert.Equal(t, head, r.branchHead("main"))
147+ assert.Equal(t, moved, r.branchHead("feat"), "the late commit is kept")
148+ evs := r.drain()
149+ require.Len(t, evs, 2)
150+ assert.Len(t, evs[1].(domain.PushEvent).Updates, 1, "only the target moved")
151+}
@@ -0,0 +1,151 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/adapters/gitcli"
13+ "bnema.dev/ermite/internal/app"
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+// A merge can delete its source branch; the push event carries both refs.
18+func TestMergeDeletesTheSourceBranch(t *testing.T) {
19+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
20+ t.Run(string(mode), func(t *testing.T) {
21+ r := newMRRig(t)
22+ main := r.seed()
23+ head := r.feature("feat")
24+ mr := r.open("feat")
25+ r.drain()
26+
27+ res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{Mode: mode, HeadID: head, DeleteSource: true})
28+ require.NoError(t, err)
29+ assert.True(t, res.SourceDeleted)
30+ assert.Equal(t, domain.MRStateMerged, res.State)
31+ _, err = r.git.BranchHead(r.ctx, r.repo, "feat")
32+ assert.ErrorIs(t, err, domain.ErrNotFound, "the branch is gone")
33+
34+ evs := r.drain()
35+ require.Len(t, evs, 2)
36+ assert.Equal(t, []domain.RefUpdate{
37+ {Old: main.String(), New: res.MergedCommitID.String(), Ref: "refs/heads/main"},
38+ {Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/feat"},
39+ }, evs[1].(domain.PushEvent).Updates)
40+
41+ // The merged request still reads, with its commits and diff.
42+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
43+ require.NoError(t, err)
44+ assert.Len(t, d.Commits, 2)
45+ assert.Len(t, d.Diff.Files, 2)
46+ })
47+ }
48+
49+ t.Run("without the option the branch stays", func(t *testing.T) {
50+ r := newMRRig(t)
51+ r.seed()
52+ head := r.feature("feat")
53+ mr := r.open("feat")
54+ res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head})
55+ require.NoError(t, err)
56+ assert.False(t, res.SourceDeleted)
57+ assert.Equal(t, head, r.branchHead("feat"))
58+ })
59+}
60+
61+// Deleting the source is refused before the merge when the branch is still
62+// needed: nothing changes.
63+func TestMergeRefusesToDeleteANeededBranch(t *testing.T) {
64+ t.Run("another open request uses it as its source", func(t *testing.T) {
65+ r := newMRRig(t)
66+ main := r.seed()
67+ head := r.feature("feat")
68+ r.sh("checkout", "-q", "-B", "release", "main")
69+ r.push("release")
70+ mr := r.open("feat")
71+ _, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "to release", Source: "feat", Target: "release"})
72+ require.NoError(t, err)
73+
74+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, DeleteSource: true})
75+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
76+ assert.Contains(t, err.Error(), "!2 still uses")
77+ assert.Equal(t, main, r.branchHead("main"), "not merged")
78+ assert.Equal(t, head, r.branchHead("feat"))
79+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
80+ require.NoError(t, err)
81+ assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
82+ })
83+
84+ t.Run("another open request targets it", func(t *testing.T) {
85+ r := newMRRig(t)
86+ r.seed()
87+ head := r.feature("feat")
88+ mr := r.open("feat")
89+ r.sh("checkout", "-q", "-B", "sub", "feat")
90+ r.commit(author, "d.txt", "dee\n", "add d")
91+ r.push("sub")
92+ _, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "stacked", Source: "sub", Target: "feat"})
93+ require.NoError(t, err)
94+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, DeleteSource: true})
95+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
96+ assert.Equal(t, head, r.branchHead("feat"))
97+ })
98+
99+ t.Run("the default branch", func(t *testing.T) {
100+ r := newMRRig(t)
101+ main := r.seed()
102+ r.sh("checkout", "-q", "-B", "old", "main~1")
103+ r.push("old")
104+ old := r.branchHead("old")
105+ mr, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "back", Source: "main", Target: "old"})
106+ require.NoError(t, err)
107+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: main, DeleteSource: true})
108+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
109+ assert.Contains(t, err.Error(), "default branch")
110+ assert.Equal(t, old, r.branchHead("old"), "not merged")
111+ assert.Equal(t, main, r.branchHead("main"))
112+ })
113+}
114+
115+// movingGit pushes to the source branch between the merge and the deletion.
116+type movingGit struct {
117+ *gitcli.Git
118+ move func()
119+}
120+
121+func (g movingGit) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
122+ g.move()
123+ return g.Git.DeleteBranch(ctx, repo, branch, old)
124+}
125+
126+// A branch that moves after the merge is kept: the merge stands.
127+func TestMergeKeepsASourceThatMovedAfterTheMerge(t *testing.T) {
128+ r := newMRRig(t)
129+ r.seed()
130+ head := r.feature("feat")
131+ mr := r.open("feat")
132+ var moved domain.ObjectID
133+ mrs := app.NewMergeRequests(app.MergeRequestsConfig{
134+ Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched,
135+ Git: movingGit{Git: r.git, move: func() {
136+ moved = r.commit(author, "late.txt", "late\n", "pushed during the merge")
137+ r.push("feat")
138+ }},
139+ })
140+ r.drain()
141+
142+ res, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, DeleteSource: true})
143+ require.NoError(t, err, "the merge stands")
144+ assert.False(t, res.SourceDeleted)
145+ assert.Equal(t, domain.MRStateMerged, res.State)
146+ assert.Equal(t, head, r.branchHead("main"))
147+ assert.Equal(t, moved, r.branchHead("feat"), "the late commit is kept")
148+ evs := r.drain()
149+ require.Len(t, evs, 2)
150+ assert.Len(t, evs[1].(domain.PushEvent).Updates, 1, "only the target moved")
151+}
M internal/ports/mocks/MergeGit.go +69 -0
@@ -191,6 +191,75 @@ func (_c *MergeGit_CommitTree_Call) RunAndReturn(run func(ctx context.Context, r
191191 return _c
192192 }
193193
194+// DeleteBranch provides a mock function for the type MergeGit
195+func (_mock *MergeGit) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
196+ ret := _mock.Called(ctx, repo, branch, old)
197+
198+ if len(ret) == 0 {
199+ panic("no return value specified for DeleteBranch")
200+ }
201+
202+ var r0 error
203+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, domain.ObjectID) error); ok {
204+ r0 = returnFunc(ctx, repo, branch, old)
205+ } else {
206+ r0 = ret.Error(0)
207+ }
208+ return r0
209+}
210+
211+// MergeGit_DeleteBranch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteBranch'
212+type MergeGit_DeleteBranch_Call struct {
213+ *mock.Call
214+}
215+
216+// DeleteBranch is a helper method to define mock.On call
217+// - ctx context.Context
218+// - repo domain.RepoName
219+// - branch string
220+// - old domain.ObjectID
221+func (_e *MergeGit_Expecter) DeleteBranch(ctx any, repo any, branch any, old any) *MergeGit_DeleteBranch_Call {
222+ return &MergeGit_DeleteBranch_Call{Call: _e.mock.On("DeleteBranch", ctx, repo, branch, old)}
223+}
224+
225+func (_c *MergeGit_DeleteBranch_Call) Run(run func(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID)) *MergeGit_DeleteBranch_Call {
226+ _c.Call.Run(func(args mock.Arguments) {
227+ var arg0 context.Context
228+ if args[0] != nil {
229+ arg0 = args[0].(context.Context)
230+ }
231+ var arg1 domain.RepoName
232+ if args[1] != nil {
233+ arg1 = args[1].(domain.RepoName)
234+ }
235+ var arg2 string
236+ if args[2] != nil {
237+ arg2 = args[2].(string)
238+ }
239+ var arg3 domain.ObjectID
240+ if args[3] != nil {
241+ arg3 = args[3].(domain.ObjectID)
242+ }
243+ run(
244+ arg0,
245+ arg1,
246+ arg2,
247+ arg3,
248+ )
249+ })
250+ return _c
251+}
252+
253+func (_c *MergeGit_DeleteBranch_Call) Return(err error) *MergeGit_DeleteBranch_Call {
254+ _c.Call.Return(err)
255+ return _c
256+}
257+
258+func (_c *MergeGit_DeleteBranch_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error) *MergeGit_DeleteBranch_Call {
259+ _c.Call.Return(run)
260+ return _c
261+}
262+
194263 // DiffCommits provides a mock function for the type MergeGit
195264 func (_mock *MergeGit) DiffCommits(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) {
196265 ret := _mock.Called(ctx, repo, base, head, limits)
@@ -191,6 +191,75 @@ func (_c *MergeGit_CommitTree_Call) RunAndReturn(run func(ctx context.Context, r
191 return _c191 return _c
192 }192 }
193 193
194+// DeleteBranch provides a mock function for the type MergeGit
195+func (_mock *MergeGit) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
196+ ret := _mock.Called(ctx, repo, branch, old)
197+
198+ if len(ret) == 0 {
199+ panic("no return value specified for DeleteBranch")
200+ }
201+
202+ var r0 error
203+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, domain.ObjectID) error); ok {
204+ r0 = returnFunc(ctx, repo, branch, old)
205+ } else {
206+ r0 = ret.Error(0)
207+ }
208+ return r0
209+}
210+
211+// MergeGit_DeleteBranch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteBranch'
212+type MergeGit_DeleteBranch_Call struct {
213+ *mock.Call
214+}
215+
216+// DeleteBranch is a helper method to define mock.On call
217+// - ctx context.Context
218+// - repo domain.RepoName
219+// - branch string
220+// - old domain.ObjectID
221+func (_e *MergeGit_Expecter) DeleteBranch(ctx any, repo any, branch any, old any) *MergeGit_DeleteBranch_Call {
222+ return &MergeGit_DeleteBranch_Call{Call: _e.mock.On("DeleteBranch", ctx, repo, branch, old)}
223+}
224+
225+func (_c *MergeGit_DeleteBranch_Call) Run(run func(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID)) *MergeGit_DeleteBranch_Call {
226+ _c.Call.Run(func(args mock.Arguments) {
227+ var arg0 context.Context
228+ if args[0] != nil {
229+ arg0 = args[0].(context.Context)
230+ }
231+ var arg1 domain.RepoName
232+ if args[1] != nil {
233+ arg1 = args[1].(domain.RepoName)
234+ }
235+ var arg2 string
236+ if args[2] != nil {
237+ arg2 = args[2].(string)
238+ }
239+ var arg3 domain.ObjectID
240+ if args[3] != nil {
241+ arg3 = args[3].(domain.ObjectID)
242+ }
243+ run(
244+ arg0,
245+ arg1,
246+ arg2,
247+ arg3,
248+ )
249+ })
250+ return _c
251+}
252+
253+func (_c *MergeGit_DeleteBranch_Call) Return(err error) *MergeGit_DeleteBranch_Call {
254+ _c.Call.Return(err)
255+ return _c
256+}
257+
258+func (_c *MergeGit_DeleteBranch_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error) *MergeGit_DeleteBranch_Call {
259+ _c.Call.Return(run)
260+ return _c
261+}
262+
194 // DiffCommits provides a mock function for the type MergeGit263 // DiffCommits provides a mock function for the type MergeGit
195 func (_mock *MergeGit) DiffCommits(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) {264 func (_mock *MergeGit) DiffCommits(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) {
196 ret := _mock.Called(ctx, repo, base, head, limits)265 ret := _mock.Called(ctx, repo, base, head, limits)
M internal/ports/ports.go +3 -0
@@ -277,6 +277,9 @@ type MergeGit interface {
277277 // UpdateBranch moves refs/heads/<branch> from old to new. It returns
278278 // domain.ErrConflict when the branch no longer points to old.
279279 UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, new domain.ObjectID) error
280+ // DeleteBranch deletes refs/heads/<branch> if it still points to old. It
281+ // returns domain.ErrConflict when the branch moved or is gone.
282+ DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error
280283 }
281284
282285 // CommitSigner signs the commits ermite creates (ADR 0022). The private key
@@ -277,6 +277,9 @@ type MergeGit interface {
277 // UpdateBranch moves refs/heads/<branch> from old to new. It returns277 // UpdateBranch moves refs/heads/<branch> from old to new. It returns
278 // domain.ErrConflict when the branch no longer points to old.278 // domain.ErrConflict when the branch no longer points to old.
279 UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, new domain.ObjectID) error279 UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, new domain.ObjectID) error
280+ // DeleteBranch deletes refs/heads/<branch> if it still points to old. It
281+ // returns domain.ErrConflict when the branch moved or is gone.
282+ DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error
280 }283 }
281 284
282 // CommitSigner signs the commits ermite creates (ADR 0022). The private key285 // CommitSigner signs the commits ermite creates (ADR 0022). The private key
M skills/ermite/CLI.md +1 -1
@@ -55,7 +55,7 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
5555 | `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers |
5656 | `mr comment <repo> <n> -m TEXT\|- [--path P --line N --side old\|new]` | `mr:write` | the line must be in the current diff; a deleted file uses its old path |
5757 | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | doing it twice is not an error |
58-| `mr merge <repo> <n> --head <full-id> [--squash]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head |
58+| `mr merge <repo> <n> --head <full-id> [--squash] [--delete-branch]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
5959 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
6060
6161 Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`.
@@ -55,7 +55,7 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
55 | `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers |55 | `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers |
56 | `mr comment <repo> <n> -m TEXT\|- [--path P --line N --side old\|new]` | `mr:write` | the line must be in the current diff; a deleted file uses its old path |56 | `mr comment <repo> <n> -m TEXT\|- [--path P --line N --side old\|new]` | `mr:write` | the line must be in the current diff; a deleted file uses its old path |
57 | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | doing it twice is not an error |57 | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | doing it twice is not an error |
58-| `mr merge <repo> <n> --head <full-id> [--squash]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head |58+| `mr merge <repo> <n> --head <full-id> [--squash] [--delete-branch]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
59 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |59 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
60 60
61 Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`.61 Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`.
M skills/ermite/SKILL.md +1 -1
@@ -34,7 +34,7 @@ git push https://git.example.com/<repo>.git main # password = token,
3434 1. List: `ermite mr list <repo> --json`.
3535 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.
3636 3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`. Without `--path` the comment is general. The line must appear in the diff.
37-4. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--squash` for one commit signed by the server.
37+4. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--squash` for one commit signed by the server and `--delete-branch` to delete the source branch afterwards.
3838
3939 The merge is refused when the branch moved since `head_id` (review again) or when the target is not an ancestor of the head (rebase and push again). The repository may allow only one merge mode.
4040
@@ -34,7 +34,7 @@ git push https://git.example.com/<repo>.git main # password = token,
34 1. List: `ermite mr list <repo> --json`.34 1. List: `ermite mr list <repo> --json`.
35 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.35 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.
36 3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`. Without `--path` the comment is general. The line must appear in the diff.36 3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`. Without `--path` the comment is general. The line must appear in the diff.
37-4. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--squash` for one commit signed by the server.37+4. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--squash` for one commit signed by the server and `--delete-branch` to delete the source branch afterwards.
38 38
39 The merge is refused when the branch moved since `head_id` (review again) or when the target is not an ancestor of the head (rebase and push again). The repository may allow only one merge mode.39 The merge is refused when the branch moved since `head_id` (review again) or when the target is not an ancestor of the head (rebase and push again). The repository may allow only one merge mode.
40 40

Keyboard shortcuts

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

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

Branches 2

Tags 44

↑↓ moveenter openesc close