From 2657756cacd9f9685f7a7bd029779b6c7fe40aa0 Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Fri, 7 Aug 2026 22:11:03 +0800 Subject: [PATCH] enhance: fall back to DEFAULT_TEMPLATE.md when style-specific template is missing (#38803) Closes #38801 Introduce `DEFAULT_TEMPLATE.md` as the default message for all merge styles. https://gitea.com/gitea/docs/pulls/491 By the way, fix incorrect os.Expand usage for merge message & repo template --------- Co-authored-by: waterWang Co-authored-by: wxiaoguang --- modules/git/fastimport.go | 21 +++++++++++++ modules/markup/html_issue.go | 2 +- modules/templates/vars/vars.go | 32 +++++++++++++++++-- modules/templates/vars/vars_test.go | 30 +++++++++++++----- routers/web/repo/issue_view.go | 2 +- services/pull/merge.go | 35 +++++++++++++++------ services/pull/merge_test.go | 48 +++++++++++++++++++++++++++++ services/repository/create.go | 2 +- services/repository/generate.go | 8 ++--- 9 files changed, 152 insertions(+), 28 deletions(-) diff --git a/modules/git/fastimport.go b/modules/git/fastimport.go index b0fab69690a..2fbdafb1f03 100644 --- a/modules/git/fastimport.go +++ b/modules/git/fastimport.go @@ -7,11 +7,18 @@ import ( "bytes" "context" "fmt" + "os" "gitea.dev/modules/git/gitcmd" + "gitea.dev/modules/git/gitrepo" "gitea.dev/modules/util" ) +type FastImportInit struct { + Bare bool + ObjectFormat string +} + type FastImportFile struct { Mode EntryMode Path string @@ -24,6 +31,20 @@ type FastImportCommit struct { Files []FastImportFile } +// ForceFastImportWithInit is for mainly for testing purpose +func ForceFastImportWithInit(ctx context.Context, repoLocalPath string, commits []FastImportCommit, initOpts ...FastImportInit) (RepositoryFacade, error) { + repo := gitrepo.RepositoryUnmanaged(repoLocalPath) + initOpt := util.OptionalArg(initOpts, FastImportInit{Bare: true}) + if exist, _ := IsRepositoryExist(ctx, repo); !exist { + _ = os.MkdirAll(repoLocalPath, 0o755) + err := InitRepositoryLocal(ctx, repoLocalPath, initOpt.Bare, util.IfZero(initOpt.ObjectFormat, "sha1")) + if err != nil { + return nil, err + } + } + return repo, ForceFastImport(ctx, repo, commits) +} + // ForceFastImport is for mainly for testing purpose func ForceFastImport(ctx context.Context, repo RepositoryFacade, commits []FastImportCommit) error { var buf bytes.Buffer diff --git a/modules/markup/html_issue.go b/modules/markup/html_issue.go index 675098700f5..e8fc28f6e9f 100644 --- a/modules/markup/html_issue.go +++ b/modules/markup/html_issue.go @@ -149,7 +149,7 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) { if hasExtTrackFormat && !ref.IsPull { ctx.RenderOptions.Metas["index"] = ref.Issue - res, err := vars.Expand(ctx.RenderOptions.Metas["format"], ctx.RenderOptions.Metas) + res, err := vars.ExpandCurlyBrace(ctx.RenderOptions.Metas["format"], ctx.RenderOptions.Metas) if err != nil { // here we could just log the error and continue the rendering log.Error("unable to expand template vars for ref %s, err: %v", ref.Issue, err) diff --git a/modules/templates/vars/vars.go b/modules/templates/vars/vars.go index 60d11ea609e..43297c58d86 100644 --- a/modules/templates/vars/vars.go +++ b/modules/templates/vars/vars.go @@ -5,14 +5,17 @@ package vars import ( "fmt" + "regexp" "strings" + "sync" "unicode" "unicode/utf8" ) -// Expand replaces all variables like {var} by `vars` map, it always returns the expanded string regardless of errors -// if error occurs, the error part doesn't change and is returned as it is. -func Expand(template string, vars map[string]string) (string, error) { +// ExpandCurlyBrace replaces all variables like {var} by `vars` map, +// it always returns the expanded string regardless of errors. +// if error occurs (wrong syntax, missing variable), the error part doesn't change and is returned as it is. +func ExpandCurlyBrace(template string, vars map[string]string) (string, error) { // in the future, if necessary, we can introduce some escape-char, // for example: it will use `#' as a reversed char, templates will use `{#{}` to do escape and output char '{'. var buf strings.Builder @@ -71,3 +74,26 @@ func Expand(template string, vars map[string]string) (string, error) { return buf.String(), err } + +var globalVars = sync.OnceValue(func() (ret struct { + regexpShellLike *regexp.Regexp +}, +) { + ret.regexpShellLike = regexp.MustCompile(`(\$\{[a-zA-Z_]\w*\}|\$[a-zA-Z_]\w*)`) + return ret +}) + +// ExpandShellLike works like os.Expand, the difference is that this function keeps the non-existing keys +func ExpandShellLike(template string, vars map[string]string) string { + re := globalVars().regexpShellLike + return re.ReplaceAllStringFunc(template, func(s string) string { + key := s[1:] + if strings.HasPrefix(key, "{") && strings.HasSuffix(key, "}") { + key = key[1 : len(key)-1] + } + if val, ok := vars[key]; ok { + return val + } + return s + }) +} diff --git a/modules/templates/vars/vars_test.go b/modules/templates/vars/vars_test.go index 9b481672378..d3994923fbd 100644 --- a/modules/templates/vars/vars_test.go +++ b/modules/templates/vars/vars_test.go @@ -9,8 +9,8 @@ import ( "github.com/stretchr/testify/assert" ) -func TestExpandVars(t *testing.T) { - kases := []struct { +func TestExpandCurlyBrace(t *testing.T) { + cases := []struct { tmpl string data map[string]string out string @@ -57,11 +57,11 @@ func TestExpandVars(t *testing.T) { }, } - for _, kase := range kases { - t.Run(kase.tmpl, func(t *testing.T) { - res, err := Expand(kase.tmpl, kase.data) - assert.Equal(t, kase.out, res) - if kase.error { + for _, c := range cases { + t.Run(c.tmpl, func(t *testing.T) { + res, err := ExpandCurlyBrace(c.tmpl, c.data) + assert.Equal(t, c.out, res) + if c.error { assert.Error(t, err) } else { assert.NoError(t, err) @@ -69,3 +69,19 @@ func TestExpandVars(t *testing.T) { }) } } + +func TestExpandShellLike(t *testing.T) { + cases := []struct { + tmpl string + data map[string]string + out string + }{ + {tmpl: "$key ${key} $other ${other}", data: map[string]string{"key": "val"}, out: "val val $other ${other}"}, + {tmpl: "$ key ${key }", data: map[string]string{"key": "val"}, out: "$ key ${key }"}, + } + + for _, c := range cases { + out := ExpandShellLike(c.tmpl, c.data) + assert.Equal(t, c.out, out, "tmpl: %s", c.tmpl) + } +} diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index 84c501c017a..5e481713215 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -284,7 +284,7 @@ func handleViewIssueRedirectExternal(ctx *context.Context) { if extIssueUnit.ExternalTrackerConfig().ExternalTrackerStyle == markup.IssueNameStyleNumeric || extIssueUnit.ExternalTrackerConfig().ExternalTrackerStyle == "" { metas := ctx.Repo.Repository.ComposeCommentMetas(ctx) metas["index"] = ctx.PathParam("index") - res, err := vars.Expand(extIssueUnit.ExternalTrackerConfig().ExternalTrackerFormat, metas) + res, err := vars.ExpandCurlyBrace(extIssueUnit.ExternalTrackerConfig().ExternalTrackerFormat, metas) if err != nil { log.Error("unable to expand template vars for issue url. issue: %s, err: %v", metas["index"], err) ctx.ServerError("Expand", err) diff --git a/services/pull/merge.go b/services/pull/merge.go index 144d3905c5c..737d000b019 100644 --- a/services/pull/merge.go +++ b/services/pull/merge.go @@ -32,6 +32,7 @@ import ( "gitea.dev/modules/references" repo_module "gitea.dev/modules/repository" "gitea.dev/modules/setting" + "gitea.dev/modules/templates/vars" "gitea.dev/modules/timeutil" "gitea.dev/modules/util" issue_service "gitea.dev/services/issue" @@ -66,17 +67,15 @@ func getMergeMessage(ctx context.Context, baseGitRepo *git.Repository, pr *issue reviewedBy := pr.GetApprovers(ctx) if mergeStyle != "" { - templateFilepath := fmt.Sprintf(".gitea/default_merge_message/%s_TEMPLATE.md", strings.ToUpper(string(mergeStyle))) commit, err := baseGitRepo.GetBranchCommit(ctx, pr.BaseRepo.DefaultBranch) if err != nil { return "", "", err } - templateContent, err := commit.GetFileContent(ctx, baseGitRepo, templateFilepath, setting.Repository.PullRequest.DefaultMergeMessageSize) + templateContent, err := resolveMergeMessageTemplate(ctx, baseGitRepo, commit, mergeStyle) if err != nil { - if !git.IsErrNotExist(err) { - return "", "", err - } - } else { + return "", "", err + } + if templateContent != "" { vars := map[string]string{ "BaseRepoOwnerName": pr.BaseRepo.OwnerName, "BaseRepoName": pr.BaseRepo.Name, @@ -146,14 +145,32 @@ func getMergeMessage(ctx context.Context, baseGitRepo *git.Repository, pr *issue return fmt.Sprintf("Merge pull request '%s' (%s%d) from %s:%s into %s", pr.Issue.Title, issueReference, pr.Issue.Index, pr.HeadRepo.FullName(), pr.HeadBranch, pr.BaseBranch), body, nil } -func expandDefaultMergeMessage(template string, vars map[string]string) (message, body string) { +// resolveMergeMessageTemplate returns the content of the merge message template for the given +// merge style. It first looks for a style-specific template ({STYLE}_TEMPLATE.md), and falls back +// to the generic DEFAULT_TEMPLATE.md if the style-specific one is not found. +func resolveMergeMessageTemplate(ctx context.Context, baseGitRepo *git.Repository, commit *git.Commit, mergeStyle repo_model.MergeStyle) (string, error) { + templateFilepath := fmt.Sprintf(".gitea/default_merge_message/%s_TEMPLATE.md", strings.ToUpper(string(mergeStyle))) + templateContent, err := commit.GetFileContent(ctx, baseGitRepo, templateFilepath, setting.Repository.PullRequest.DefaultMergeMessageSize) + if err == nil { + return templateContent, nil + } + if !git.IsErrNotExist(err) { + return "", err + } + templateContent, err = commit.GetFileContent(ctx, baseGitRepo, ".gitea/default_merge_message/DEFAULT_TEMPLATE.md", setting.Repository.PullRequest.DefaultMergeMessageSize) + if err == nil || git.IsErrNotExist(err) { + return templateContent, nil + } + return "", err +} + +func expandDefaultMergeMessage(template string, varsMap map[string]string) (message, body string) { message = strings.TrimSpace(template) if splits := strings.SplitN(message, "\n", 2); len(splits) == 2 { message = splits[0] body = strings.TrimSpace(splits[1]) } - mapping := func(s string) string { return vars[s] } - return os.Expand(message, mapping), os.Expand(body, mapping) + return vars.ExpandShellLike(message, varsMap), vars.ExpandShellLike(body, varsMap) } // GetDefaultMergeMessage returns default message used when merging pull request diff --git a/services/pull/merge_test.go b/services/pull/merge_test.go index 91abeb9d9c1..78104b91ac3 100644 --- a/services/pull/merge_test.go +++ b/services/pull/merge_test.go @@ -4,9 +4,13 @@ package pull import ( + "path/filepath" "testing" + "gitea.dev/modules/git" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_expandDefaultMergeMessage(t *testing.T) { @@ -90,3 +94,47 @@ func TestAddCommitMessageTailer(t *testing.T) { assert.Equal(t, "title\n\nTest-tailer: v1\nTest-tailer: v2", AddCommitMessageTailer("title\n\nTest-tailer: v1", "Test-tailer", "v2")) assert.Equal(t, "title\n\nTest-tailer: v1\nTest-tailer: v2", AddCommitMessageTailer("title\n\nTest-tailer: v1\n", "Test-tailer", "v2")) } + +func TestResolveMergeMessageTemplate(t *testing.T) { + t.Run("NoDefault", func(t *testing.T) { + repo, err := git.ForceFastImportWithInit(t.Context(), filepath.Join(t.TempDir(), "test-repo"), []git.FastImportCommit{ + {Ref: "refs/heads/master", Files: []git.FastImportFile{ + {Path: ".gitea/default_merge_message/REBASE_TEMPLATE.md", Content: "rebase template"}, + }}, + }) + require.NoError(t, err) + gitRepo, err := git.OpenRepository(t.Context(), repo) + require.NoError(t, err) + defer gitRepo.Close() + + commit, err := gitRepo.GetBranchCommit(t.Context(), "master") + require.NoError(t, err) + tmpl, err := resolveMergeMessageTemplate(t.Context(), gitRepo, commit, "merge") + assert.NoError(t, err) + assert.Equal(t, "", tmpl) + tmpl, err = resolveMergeMessageTemplate(t.Context(), gitRepo, commit, "rebase") + assert.NoError(t, err) + assert.Equal(t, "rebase template", tmpl) + }) + t.Run("WithDefault", func(t *testing.T) { + repo, err := git.ForceFastImportWithInit(t.Context(), filepath.Join(t.TempDir(), "test-repo"), []git.FastImportCommit{ + {Ref: "refs/heads/master", Files: []git.FastImportFile{ + {Path: ".gitea/default_merge_message/DEFAULT_TEMPLATE.md", Content: "default template"}, + {Path: ".gitea/default_merge_message/REBASE_TEMPLATE.md", Content: "rebase template"}, + }}, + }) + require.NoError(t, err) + gitRepo, err := git.OpenRepository(t.Context(), repo) + require.NoError(t, err) + defer gitRepo.Close() + + commit, err := gitRepo.GetBranchCommit(t.Context(), "master") + require.NoError(t, err) + tmpl, err := resolveMergeMessageTemplate(t.Context(), gitRepo, commit, "merge") + assert.NoError(t, err) + assert.Equal(t, "default template", tmpl) + tmpl, err = resolveMergeMessageTemplate(t.Context(), gitRepo, commit, "rebase") + assert.NoError(t, err) + assert.Equal(t, "rebase template", tmpl) + }) +} diff --git a/services/repository/create.go b/services/repository/create.go index b33c6047d04..b8efb9dbb9c 100644 --- a/services/repository/create.go +++ b/services/repository/create.go @@ -91,7 +91,7 @@ func prepareRepoCommit(ctx context.Context, repo *repo_model.Repository, tmpDir "CloneURL.HTTPS": cloneLink.HTTPS, "OwnerName": repo.OwnerName, } - res, err := vars.Expand(string(data), match) + res, err := vars.ExpandCurlyBrace(string(data), match) if err != nil { // here we could just log the error and continue the rendering log.Error("unable to expand template vars for repo README: %s, err: %v", opts.Readme, err) diff --git a/services/repository/generate.go b/services/repository/generate.go index 8b986626123..2a8eba0de87 100644 --- a/services/repository/generate.go +++ b/services/repository/generate.go @@ -24,6 +24,7 @@ import ( "gitea.dev/modules/log" repo_module "gitea.dev/modules/repository" "gitea.dev/modules/setting" + "gitea.dev/modules/templates/vars" "gitea.dev/modules/util" "github.com/huandu/xstrings" @@ -86,12 +87,7 @@ func generateExpansion(ctx context.Context, src string, templateRepo, generateRe } } - return os.Expand(src, func(key string) string { - if val, ok := expansionMap[key]; ok { - return val - } - return key - }) + return vars.ExpandShellLike(src, expansionMap) } type giteaTemplateFileMatcher struct {