diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..eda1abeeeb4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Instructions for agents + +- Use `make help` to find available development targets +- Before committing go code changes, run `make fmt` +- Before committing `go.mod` changes, run `make tidy` +- Before committing new `.go` files, add the current year into the copyright header +- Before committing files, removed any trailing whitespace diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 3eaffde9701..084c66aab03 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1329,9 +1329,12 @@ LEVEL = Info ;; Leave it empty to allow users to select any theme from "{CustomPath}/public/assets/css/theme-*.css" ;THEMES = ;; -;; The icons for file list (basic/material), this is a temporary option which will be replaced by a user setting in the future. +;; The icon theme for files (basic/material) ;FILE_ICON_THEME = material ;; +;; The icon theme for folders (basic/material) +;FOLDER_ICON_THEME = basic +;; ;; All available reactions users can choose on issues/prs and comments. ;; Values can be emoji alias (:smile:) or a unicode emoji. ;; For custom reactions, add a tightly cropped square image to public/assets/img/emoji/reaction_name.png diff --git a/go.mod b/go.mod index 16154ad6cf4..26a2b818efc 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( github.com/ProtonMail/go-crypto v1.3.0 github.com/PuerkitoBio/goquery v1.10.3 github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.8.0 - github.com/alecthomas/chroma/v2 v2.23.0 + github.com/alecthomas/chroma/v2 v2.23.1 github.com/aws/aws-sdk-go-v2/credentials v1.18.10 github.com/aws/aws-sdk-go-v2/service/codecommit v1.32.2 github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb @@ -50,10 +50,10 @@ require ( github.com/gliderlabs/ssh v0.3.8 github.com/go-ap/activitypub v0.0.0-20250810115208-cb73b20a1742 github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 - github.com/go-chi/chi/v5 v5.2.3 + github.com/go-chi/chi/v5 v5.2.4 github.com/go-chi/cors v1.2.2 github.com/go-co-op/gocron v1.37.0 - github.com/go-enry/go-enry/v2 v2.9.2 + github.com/go-enry/go-enry/v2 v2.9.4 github.com/go-git/go-billy/v5 v5.6.2 github.com/go-git/go-git/v5 v5.16.3 github.com/go-ldap/ldap/v3 v3.4.11 diff --git a/go.sum b/go.sum index acd5d8912eb..7490b3923c4 100644 --- a/go.sum +++ b/go.sum @@ -98,8 +98,8 @@ github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.8.0/go.mod h1:1HmmMEVsr+0R github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= -github.com/alecthomas/chroma/v2 v2.23.0 h1:u/Orux1J0eLuZDeQ44froV8smumheieI0EofhbyKhhk= -github.com/alecthomas/chroma/v2 v2.23.0/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= @@ -316,14 +316,14 @@ github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73/go.mod h1:jyveZeGw5La github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-chi/chi/v5 v5.0.1/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= -github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= +github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0= github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY= -github.com/go-enry/go-enry/v2 v2.9.2 h1:giOQAtCgBX08kosrX818DCQJTCNtKwoPBGu0qb6nKTY= -github.com/go-enry/go-enry/v2 v2.9.2/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8= +github.com/go-enry/go-enry/v2 v2.9.4 h1:DS4l06/NgMzYjsJ2J52wORo6UsfFDjDCwfAn7w3gG44= +github.com/go-enry/go-enry/v2 v2.9.4/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8= github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo= github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4= github.com/go-fed/httpsig v1.1.1-0.20201223112313-55836744818e h1:oRq/fiirun5HqlEWMLIcDmLpIELlG4iGbd0s8iqgPi8= diff --git a/modules/fileicon/render.go b/modules/fileicon/render.go index 6b2fcfa81e8..5bf2a3a02ec 100644 --- a/modules/fileicon/render.go +++ b/modules/fileicon/render.go @@ -34,7 +34,13 @@ func (p *RenderedIconPool) RenderToHTML() template.HTML { } func RenderEntryIconHTML(renderedIconPool *RenderedIconPool, entry *EntryInfo) template.HTML { - if setting.UI.FileIconTheme == "material" { + // Use folder theme for directories and symlinks to directories + theme := setting.UI.FileIconTheme + if entry.EntryMode.IsDir() || (entry.EntryMode.IsLink() && entry.SymlinkToMode.IsDir()) { + theme = setting.UI.FolderIconTheme + } + + if theme == "material" { return DefaultMaterialIconProvider().EntryIconHTML(renderedIconPool, entry) } return BasicEntryIconHTML(entry) diff --git a/modules/fileicon/render_test.go b/modules/fileicon/render_test.go new file mode 100644 index 00000000000..d9998f3f4c8 --- /dev/null +++ b/modules/fileicon/render_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package fileicon_test + +import ( + "testing" + + "code.gitea.io/gitea/modules/fileicon" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestRenderEntryIconHTML_WithDifferentThemes(t *testing.T) { + // Test that folder icons use the folder theme + t.Run("FolderUsesBasicTheme", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.FileIconTheme, "material")() + defer test.MockVariableValue(&setting.UI.FolderIconTheme, "basic")() + + folderEntry := &fileicon.EntryInfo{ + BaseName: "testfolder", + EntryMode: git.EntryModeTree, + } + + html := fileicon.RenderEntryIconHTML(nil, folderEntry) + // Basic theme renders octicon classes + assert.Contains(t, string(html), "octicon-file-directory-fill") + }) + + t.Run("FileUsesMaterialTheme", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.FileIconTheme, "material")() + defer test.MockVariableValue(&setting.UI.FolderIconTheme, "basic")() + + fileEntry := &fileicon.EntryInfo{ + BaseName: "test.js", + EntryMode: git.EntryModeBlob, + } + + html := fileicon.RenderEntryIconHTML(nil, fileEntry) + // Material theme for files renders material icons + assert.Contains(t, string(html), "svg-mfi-") + }) + + t.Run("SymlinkToFolderUsesBasicTheme", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.FileIconTheme, "material")() + defer test.MockVariableValue(&setting.UI.FolderIconTheme, "basic")() + + symlinkEntry := &fileicon.EntryInfo{ + BaseName: "link", + EntryMode: git.EntryModeSymlink, + SymlinkToMode: git.EntryModeTree, + } + + html := fileicon.RenderEntryIconHTML(nil, symlinkEntry) + // Symlinks to folders should use folder theme + assert.Contains(t, string(html), "octicon-file-directory-symlink") + }) + + t.Run("BothMaterialTheme", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.FileIconTheme, "material")() + defer test.MockVariableValue(&setting.UI.FolderIconTheme, "material")() + + folderEntry := &fileicon.EntryInfo{ + BaseName: "testfolder", + EntryMode: git.EntryModeTree, + } + + html := fileicon.RenderEntryIconHTML(nil, folderEntry) + // Material theme for folders renders material folder icons + assert.Contains(t, string(html), "svg-mfi-") + }) +} diff --git a/modules/setting/mailer_test.go b/modules/setting/mailer_test.go index ceef35b051d..f281715973e 100644 --- a/modules/setting/mailer_test.go +++ b/modules/setting/mailer_test.go @@ -11,12 +11,12 @@ import ( func Test_loadMailerFrom(t *testing.T) { kases := map[string]*Mailer{ - "smtp.mydomain.com": { - SMTPAddr: "smtp.mydomain.com", + "smtp.mydomain.test": { + SMTPAddr: "smtp.mydomain.test", SMTPPort: "465", }, - "smtp.mydomain.com:123": { - SMTPAddr: "smtp.mydomain.com", + "smtp.mydomain.test:123": { + SMTPAddr: "smtp.mydomain.test", SMTPPort: "123", }, ":123": { diff --git a/modules/setting/ui.go b/modules/setting/ui.go index 13cb0f5c662..77a5b45d0a0 100644 --- a/modules/setting/ui.go +++ b/modules/setting/ui.go @@ -29,6 +29,7 @@ var UI = struct { DefaultTheme string Themes []string FileIconTheme string + FolderIconTheme string Reactions []string ReactionsLookup container.Set[string] `ini:"-"` CustomEmojis []string @@ -88,6 +89,7 @@ var UI = struct { MaxDisplayFileSize: 8388608, DefaultTheme: `gitea-auto`, FileIconTheme: `material`, + FolderIconTheme: `basic`, Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`}, CustomEmojis: []string{`git`, `gitea`, `codeberg`, `gitlab`, `github`, `gogs`}, CustomEmojisMap: map[string]string{"git": ":git:", "gitea": ":gitea:", "codeberg": ":codeberg:", "gitlab": ":gitlab:", "github": ":github:", "gogs": ":gogs:"}, diff --git a/services/git/compare.go b/services/git/compare.go index 6c49fff26ac..251a0350585 100644 --- a/services/git/compare.go +++ b/services/git/compare.go @@ -84,7 +84,11 @@ func GetCompareInfo(ctx context.Context, baseRepo, headRepo *repo_model.Reposito // We have a common base - therefore we know that ... should work if !fileOnly { - compareInfo.Commits, err = headGitRepo.ShowPrettyFormatLogToList(ctx, compareInfo.BaseCommitID+compareInfo.CompareSeparator+compareInfo.HeadCommitID) + // In git log/rev-list, the "..." syntax represents the symmetric difference between two references, + // which is different from the meaning of "..." in git diff (where it implies diffing from the merge base). + // For listing PR commits, we must use merge-base..head to include only the commits introduced by the head branch. + // Otherwise, commits newly pushed to the base branch would also be included, which is incorrect. + compareInfo.Commits, err = headGitRepo.ShowPrettyFormatLogToList(ctx, compareInfo.MergeBase+".."+compareInfo.HeadCommitID) if err != nil { return nil, fmt.Errorf("ShowPrettyFormatLogToList: %w", err) } diff --git a/services/issue/commit.go b/services/issue/commit.go index 963d0359fd3..66ad93a97da 100644 --- a/services/issue/commit.go +++ b/services/issue/commit.go @@ -89,6 +89,24 @@ func issueAddTime(ctx context.Context, issue *issues_model.Issue, doer *user_mod return err } +// isSelfReference checks if a commit is the merge commit of the PR it references. +// This prevents creating self-referencing timeline entries when a PR merge commit +// contains a reference to its own PR number in the commit message. +func isSelfReference(ctx context.Context, issue *issues_model.Issue, commitSHA string) bool { + if !issue.IsPull { + return false + } + + if err := issue.LoadPullRequest(ctx); err != nil { + if !issues_model.IsErrPullRequestNotExist(err) { + log.Error("LoadPullRequest: %v", err) + } + return false + } + + return issue.PullRequest.MergedCommitID == commitSHA +} + // getIssueFromRef returns the issue referenced by a ref. Returns a nil *Issue // if the provided ref references a non-existent issue. func getIssueFromRef(ctx context.Context, repo *repo_model.Repository, index int64) (*issues_model.Issue, error) { @@ -158,6 +176,11 @@ func UpdateIssuesCommit(ctx context.Context, doer *user_model.User, repo *repo_m continue } + // Skip self-references: if this commit is the merge commit of the PR it references + if isSelfReference(ctx, refIssue, c.Sha1) { + continue + } + message := fmt.Sprintf(`%s`, html.EscapeString(repo.Link()), html.EscapeString(url.PathEscape(c.Sha1)), html.EscapeString(strings.SplitN(c.Message, "\n", 2)[0])) if err = CreateRefComment(ctx, doer, refRepo, refIssue, message, c.Sha1); err != nil { if errors.Is(err, user_model.ErrBlockedUser) { diff --git a/services/issue/commit_test.go b/services/issue/commit_test.go index d19702269a9..5950762c449 100644 --- a/services/issue/commit_test.go +++ b/services/issue/commit_test.go @@ -298,3 +298,59 @@ func TestUpdateIssuesCommit_AnotherRepoNoPermission(t *testing.T) { unittest.AssertNotExistsBean(t, issueBean, "is_closed=1") unittest.CheckConsistencyFor(t, &activities_model.Action{}) } + +func TestUpdateIssuesCommit_SelfReference(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // Test that a PR merge commit that references its own PR does not create a self-reference comment + // PR #2 (issue_id=2) has merged_commit_id: 1a8823cd1a9549fde083f992f6b9b87a7ab74fb3 + pushCommits := []*repository.PushCommit{ + { + Sha1: "1a8823cd1a9549fde083f992f6b9b87a7ab74fb3", + CommitterEmail: "user2@example.com", + CommitterName: "User Two", + AuthorEmail: "user2@example.com", + AuthorName: "User Two", + Message: "Merge pull request 'issue2' (#2) from branch1 into master", + }, + } + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + selfRefCommentBean := &issues_model.Comment{ + Type: issues_model.CommentTypeCommitRef, + CommitSHA: "1a8823cd1a9549fde083f992f6b9b87a7ab74fb3", + PosterID: user.ID, + IssueID: 2, + } + + unittest.AssertNotExistsBean(t, selfRefCommentBean) + assert.NoError(t, UpdateIssuesCommit(t.Context(), user, repo, pushCommits, repo.DefaultBranch)) + unittest.AssertNotExistsBean(t, selfRefCommentBean) + unittest.CheckConsistencyFor(t, &activities_model.Action{}) + + // Test that normal commit references are still created + pushCommits2 := []*repository.PushCommit{ + { + Sha1: "abcdef9876543210", + CommitterEmail: "user2@example.com", + CommitterName: "User Two", + AuthorEmail: "user2@example.com", + AuthorName: "User Two", + Message: "Fix bug, refs #1", + }, + } + + otherRefCommentBean := &issues_model.Comment{ + Type: issues_model.CommentTypeCommitRef, + CommitSHA: "abcdef9876543210", + PosterID: user.ID, + IssueID: 1, + } + + unittest.AssertNotExistsBean(t, otherRefCommentBean) + assert.NoError(t, UpdateIssuesCommit(t.Context(), user, repo, pushCommits2, repo.DefaultBranch)) + unittest.AssertExistsAndLoadBean(t, otherRefCommentBean) + unittest.CheckConsistencyFor(t, &activities_model.Action{}) +} diff --git a/services/repository/files/tree_test.go b/services/repository/files/tree_test.go index e7511b3eed4..b85f65f431e 100644 --- a/services/repository/files/tree_test.go +++ b/services/repository/files/tree_test.go @@ -56,6 +56,7 @@ func TestGetTreeBySHA(t *testing.T) { func TestGetTreeViewNodes(t *testing.T) { unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1") ctx.Repo.RefFullName = git.RefNameFromBranch("sub-home-md-img-check") contexttest.LoadRepo(t, ctx, 1) @@ -69,11 +70,13 @@ func TestGetTreeViewNodes(t *testing.T) { mockIconForFile := func(id string) template.HTML { return template.HTML(``) } - mockIconForFolder := func(id string) template.HTML { - return template.HTML(``) + mockIconForFolder := func() template.HTML { + // With basic theme (default for folders), we get octicon icons without IDs + return template.HTML(`octicon-file-directory-fill(16/)`) } - mockOpenIconForFolder := func(id string) template.HTML { - return template.HTML(``) + mockOpenIconForFolder := func() template.HTML { + // With basic theme (default for folders), we get octicon icons without IDs + return template.HTML(`octicon-file-directory-open-fill(16/)`) } treeNodes, err := GetTreeViewNodes(ctx, curRepoLink, renderedIconPool, ctx.Repo.Commit, "", "") assert.NoError(t, err) @@ -82,8 +85,8 @@ func TestGetTreeViewNodes(t *testing.T) { EntryName: "docs", EntryMode: "tree", FullPath: "docs", - EntryIcon: mockIconForFolder(`svg-mfi-folder-docs`), - EntryIconOpen: mockOpenIconForFolder(`svg-mfi-folder-docs`), + EntryIcon: mockIconForFolder(), + EntryIconOpen: mockOpenIconForFolder(), }, }, treeNodes) @@ -94,8 +97,8 @@ func TestGetTreeViewNodes(t *testing.T) { EntryName: "docs", EntryMode: "tree", FullPath: "docs", - EntryIcon: mockIconForFolder(`svg-mfi-folder-docs`), - EntryIconOpen: mockOpenIconForFolder(`svg-mfi-folder-docs`), + EntryIcon: mockIconForFolder(), + EntryIconOpen: mockOpenIconForFolder(), Children: []*TreeViewNode{ { EntryName: "README.md",