feat(repo): add --squash-merge-commit-message flag to gh repo edit

Add a single --squash-merge-commit-message flag that maps to both
squash_merge_commit_title and squash_merge_commit_message API fields.

Supported values:
- default: COMMIT_OR_PR_TITLE + COMMIT_MESSAGES
- pr-title: PR_TITLE + BLANK
- pr-title-commits: PR_TITLE + COMMIT_MESSAGES
- pr-title-description: PR_TITLE + PR_BODY

The flag requires --enable-squash-merge to be set alongside it. In
interactive mode, the squash merge commit message prompt appears when
squash merging is selected.

Closes #10092
This commit is contained in:
yuvrajangadsingh 2026-03-05 16:36:59 +05:30
parent 19864b9e1e
commit 4ae0c5851b
2 changed files with 263 additions and 18 deletions

View file

@ -35,6 +35,11 @@ const (
allowSquashMerge = "Allow Squash Merging"
allowRebaseMerge = "Allow Rebase Merging"
squashMsgDefault = "default"
squashMsgPRTitle = "pr-title"
squashMsgPRTitleCommits = "pr-title-commits"
squashMsgPRTitleDescription = "pr-title-description"
optionAllowForking = "Allow Forking"
optionDefaultBranchName = "Default Branch Name"
optionDescription = "Description"
@ -69,24 +74,27 @@ type EditRepositoryInput struct {
enableAdvancedSecurity *bool
enableSecretScanning *bool
enableSecretScanningPushProtection *bool
squashMergeCommitMsg *string
AllowForking *bool `json:"allow_forking,omitempty"`
AllowUpdateBranch *bool `json:"allow_update_branch,omitempty"`
DefaultBranch *string `json:"default_branch,omitempty"`
DeleteBranchOnMerge *bool `json:"delete_branch_on_merge,omitempty"`
Description *string `json:"description,omitempty"`
EnableAutoMerge *bool `json:"allow_auto_merge,omitempty"`
EnableIssues *bool `json:"has_issues,omitempty"`
EnableMergeCommit *bool `json:"allow_merge_commit,omitempty"`
EnableProjects *bool `json:"has_projects,omitempty"`
EnableDiscussions *bool `json:"has_discussions,omitempty"`
EnableRebaseMerge *bool `json:"allow_rebase_merge,omitempty"`
EnableSquashMerge *bool `json:"allow_squash_merge,omitempty"`
EnableWiki *bool `json:"has_wiki,omitempty"`
Homepage *string `json:"homepage,omitempty"`
IsTemplate *bool `json:"is_template,omitempty"`
SecurityAndAnalysis *SecurityAndAnalysisInput `json:"security_and_analysis,omitempty"`
Visibility *string `json:"visibility,omitempty"`
AllowForking *bool `json:"allow_forking,omitempty"`
AllowUpdateBranch *bool `json:"allow_update_branch,omitempty"`
DefaultBranch *string `json:"default_branch,omitempty"`
DeleteBranchOnMerge *bool `json:"delete_branch_on_merge,omitempty"`
Description *string `json:"description,omitempty"`
EnableAutoMerge *bool `json:"allow_auto_merge,omitempty"`
EnableIssues *bool `json:"has_issues,omitempty"`
EnableMergeCommit *bool `json:"allow_merge_commit,omitempty"`
EnableProjects *bool `json:"has_projects,omitempty"`
EnableDiscussions *bool `json:"has_discussions,omitempty"`
EnableRebaseMerge *bool `json:"allow_rebase_merge,omitempty"`
EnableSquashMerge *bool `json:"allow_squash_merge,omitempty"`
EnableWiki *bool `json:"has_wiki,omitempty"`
Homepage *string `json:"homepage,omitempty"`
IsTemplate *bool `json:"is_template,omitempty"`
SecurityAndAnalysis *SecurityAndAnalysisInput `json:"security_and_analysis,omitempty"`
SquashMergeCommitTitle *string `json:"squash_merge_commit_title,omitempty"`
SquashMergeCommitMessage *string `json:"squash_merge_commit_message,omitempty"`
Visibility *string `json:"visibility,omitempty"`
}
func NewCmdEdit(f *cmdutil.Factory, runF func(options *EditOptions) error) *cobra.Command {
@ -120,6 +128,14 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(options *EditOptions) error) *cobr
When the %[1]s--visibility%[1]s flag is used, %[1]s--accept-visibility-change-consequences%[1]s flag is required.
For information on all the potential consequences, see <https://gh.io/setting-repository-visibility>.
When the %[1]s--enable-squash-merge%[1]s flag is used, %[1]s--squash-merge-commit-message%[1]s
can be used to change the default squash merge commit message behavior:
- %[1]sdefault%[1]s: uses commit title and message for 1 commit, or pull request title and list of commits for 2 or more
- %[1]spr-title%[1]s: uses pull request title
- %[1]spr-title-commits%[1]s: uses pull request title and list of commits
- %[1]spr-title-description%[1]s: uses pull request title and description
`, "`"),
Args: cobra.MaximumNArgs(1),
Example: heredoc.Doc(`
@ -162,6 +178,16 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(options *EditOptions) error) *cobr
return cmdutil.FlagErrorf("use of --visibility flag requires --accept-visibility-change-consequences flag")
}
if opts.Edits.squashMergeCommitMsg != nil {
if err := validateSquashMergeCommitMsg(*opts.Edits.squashMergeCommitMsg); err != nil {
return err
}
if opts.Edits.EnableSquashMerge == nil {
return cmdutil.FlagErrorf("--squash-merge-commit-message requires --enable-squash-merge")
}
transformSquashMergeOpts(&opts.Edits)
}
if hasSecurityEdits(opts.Edits) {
opts.Edits.SecurityAndAnalysis = transformSecurityAndAnalysisOpts(opts)
}
@ -192,6 +218,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(options *EditOptions) error) *cobr
cmdutil.NilBoolFlag(cmd, &opts.Edits.DeleteBranchOnMerge, "delete-branch-on-merge", "", "Delete head branch when pull requests are merged")
cmdutil.NilBoolFlag(cmd, &opts.Edits.AllowForking, "allow-forking", "", "Allow forking of an organization repository")
cmdutil.NilBoolFlag(cmd, &opts.Edits.AllowUpdateBranch, "allow-update-branch", "", "Allow a pull request head branch that is behind its base branch to be updated")
cmdutil.NilStringFlag(cmd, &opts.Edits.squashMergeCommitMsg, "squash-merge-commit-message", "", "The default value for a squash merge commit message: {default|pr-title|pr-title-commits|pr-title-description}")
cmd.Flags().StringSliceVar(&opts.AddTopics, "add-topic", nil, "Add repository topic")
cmd.Flags().StringSliceVar(&opts.RemoveTopics, "remove-topic", nil, "Remove repository topic")
cmd.Flags().BoolVar(&opts.AcceptVisibilityChangeConsequences, "accept-visibility-change-consequences", false, "Accept the consequences of changing the repository visibility")
@ -474,6 +501,25 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error {
return fmt.Errorf("you need to allow at least one merge strategy")
}
if enableSquashMerge {
squashMsgOptions := []string{
squashMsgDefault,
squashMsgPRTitle,
squashMsgPRTitleCommits,
squashMsgPRTitleDescription,
}
idx, err := p.Select(
"Default squash merge commit message",
squashMsgDefault,
squashMsgOptions)
if err != nil {
return err
}
selected := squashMsgOptions[idx]
opts.Edits.squashMergeCommitMsg = &selected
transformSquashMergeOpts(&opts.Edits)
}
opts.Edits.EnableAutoMerge = &r.AutoMergeAllowed
c, err := p.Confirm("Enable Auto Merge?", r.AutoMergeAllowed)
if err != nil {
@ -634,3 +680,39 @@ func transformSecurityAndAnalysisOpts(opts *EditOptions) *SecurityAndAnalysisInp
}
return securityOptions
}
var validSquashMsgValues = []string{squashMsgDefault, squashMsgPRTitle, squashMsgPRTitleCommits, squashMsgPRTitleDescription}
func validateSquashMergeCommitMsg(value string) error {
for _, v := range validSquashMsgValues {
if value == v {
return nil
}
}
return cmdutil.FlagErrorf("invalid value for --squash-merge-commit-message: %q. Valid values are: %s", value, strings.Join(validSquashMsgValues, ", "))
}
// transformSquashMergeOpts maps the user-facing squash merge commit message option
// to the two API fields: squash_merge_commit_title and squash_merge_commit_message.
func transformSquashMergeOpts(edits *EditRepositoryInput) {
if edits.squashMergeCommitMsg == nil {
return
}
var title, message string
switch *edits.squashMergeCommitMsg {
case squashMsgDefault:
title = "COMMIT_OR_PR_TITLE"
message = "COMMIT_MESSAGES"
case squashMsgPRTitle:
title = "PR_TITLE"
message = "BLANK"
case squashMsgPRTitleCommits:
title = "PR_TITLE"
message = "COMMIT_MESSAGES"
case squashMsgPRTitleDescription:
title = "PR_TITLE"
message = "PR_BODY"
}
edits.SquashMergeCommitTitle = &title
edits.SquashMergeCommitMessage = &message
}

View file

@ -91,6 +91,29 @@ func TestNewCmdEdit(t *testing.T) {
},
},
},
{
name: "squash merge commit message with enable-squash-merge",
args: "--enable-squash-merge --squash-merge-commit-message pr-title",
wantOpts: EditOptions{
Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"),
Edits: EditRepositoryInput{
squashMergeCommitMsg: sp("pr-title"),
EnableSquashMerge: bp(true),
SquashMergeCommitTitle: sp("PR_TITLE"),
SquashMergeCommitMessage: sp("BLANK"),
},
},
},
{
name: "squash merge commit message without enable-squash-merge",
args: "--squash-merge-commit-message default",
wantErr: "--squash-merge-commit-message requires --enable-squash-merge",
},
{
name: "squash merge commit message with invalid value",
args: "--enable-squash-merge --squash-merge-commit-message blah",
wantErr: `invalid value for --squash-merge-commit-message: "blah". Valid values are: default, pr-title, pr-title-commits, pr-title-description`,
},
}
for _, tt := range tests {
@ -235,6 +258,26 @@ func Test_editRun(t *testing.T) {
}))
},
},
{
name: "set squash merge commit message to pr-title-description",
opts: EditOptions{
Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"),
Edits: EditRepositoryInput{
EnableSquashMerge: bp(true),
SquashMergeCommitTitle: sp("PR_TITLE"),
SquashMergeCommitMessage: sp("PR_BODY"),
},
},
httpStubs: func(t *testing.T, r *httpmock.Registry) {
r.Register(
httpmock.REST("PATCH", "repos/OWNER/REPO"),
httpmock.RESTPayload(200, `{}`, func(payload map[string]interface{}) {
assert.Equal(t, true, payload["allow_squash_merge"])
assert.Equal(t, "PR_TITLE", payload["squash_merge_commit_title"])
assert.Equal(t, "PR_BODY", payload["squash_merge_commit_message"])
}))
},
},
{
name: "does not have sufficient permissions for security edits",
opts: EditOptions{
@ -633,7 +676,7 @@ func Test_editRun_interactive(t *testing.T) {
},
},
{
name: "updates repo merge options",
name: "updates repo merge options without squash",
opts: EditOptions{
Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"),
InteractiveMode: true,
@ -691,6 +734,72 @@ func Test_editRun_interactive(t *testing.T) {
}))
},
},
{
name: "updates repo merge options with squash and commit message",
opts: EditOptions{
Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"),
InteractiveMode: true,
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterMultiSelect("What do you want to edit?", nil, editList,
func(_ string, _, opts []string) ([]int, error) {
return []int{4}, nil
})
pm.RegisterMultiSelect("Allowed merge strategies", nil,
[]string{allowMergeCommits, allowSquashMerge, allowRebaseMerge},
func(_ string, _, opts []string) ([]int, error) {
return []int{1}, nil
})
pm.RegisterSelect("Default squash merge commit message",
[]string{"default", "pr-title", "pr-title-commits", "pr-title-description"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "pr-title-description")
})
pm.RegisterConfirm("Enable Auto Merge?", func(_ string, _ bool) (bool, error) {
return false, nil
})
pm.RegisterConfirm("Automatically delete head branches after merging?", func(_ string, _ bool) (bool, error) {
return false, nil
})
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`
{
"data": {
"repository": {
"description": "old description",
"homePageUrl": "https://url.com",
"defaultBranchRef": {
"name": "main"
},
"isInOrganization": false,
"squashMergeAllowed": false,
"rebaseMergeAllowed": false,
"mergeCommitAllowed": true,
"deleteBranchOnMerge": false,
"repositoryTopics": {
"nodes": [{
"topic": {
"name": "x"
}
}]
}
}
}
}`))
reg.Register(
httpmock.REST("PATCH", "repos/OWNER/REPO"),
httpmock.RESTPayload(200, `{}`, func(payload map[string]interface{}) {
assert.Equal(t, false, payload["allow_merge_commit"])
assert.Equal(t, true, payload["allow_squash_merge"])
assert.Equal(t, false, payload["allow_rebase_merge"])
assert.Equal(t, "PR_TITLE", payload["squash_merge_commit_title"])
assert.Equal(t, "PR_BODY", payload["squash_merge_commit_message"])
}))
},
},
}
for _, tt := range tests {
@ -818,6 +927,60 @@ func Test_transformSecurityAndAnalysisOpts(t *testing.T) {
}
}
func Test_transformSquashMergeOpts(t *testing.T) {
tests := []struct {
name string
input string
wantTitle string
wantMessage string
}{
{
name: "default",
input: "default",
wantTitle: "COMMIT_OR_PR_TITLE",
wantMessage: "COMMIT_MESSAGES",
},
{
name: "pr-title",
input: "pr-title",
wantTitle: "PR_TITLE",
wantMessage: "BLANK",
},
{
name: "pr-title-commits",
input: "pr-title-commits",
wantTitle: "PR_TITLE",
wantMessage: "COMMIT_MESSAGES",
},
{
name: "pr-title-description",
input: "pr-title-description",
wantTitle: "PR_TITLE",
wantMessage: "PR_BODY",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
edits := &EditRepositoryInput{
squashMergeCommitMsg: sp(tt.input),
}
transformSquashMergeOpts(edits)
assert.Equal(t, tt.wantTitle, *edits.SquashMergeCommitTitle)
assert.Equal(t, tt.wantMessage, *edits.SquashMergeCommitMessage)
})
}
}
func Test_validateSquashMergeCommitMsg(t *testing.T) {
assert.NoError(t, validateSquashMergeCommitMsg("default"))
assert.NoError(t, validateSquashMergeCommitMsg("pr-title"))
assert.NoError(t, validateSquashMergeCommitMsg("pr-title-commits"))
assert.NoError(t, validateSquashMergeCommitMsg("pr-title-description"))
assert.Error(t, validateSquashMergeCommitMsg("blah"))
assert.Error(t, validateSquashMergeCommitMsg(""))
}
func sp(v string) *string {
return &v
}