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",