From 8062c1febd4eec212de3e70cc9f78d1d6f73c258 Mon Sep 17 00:00:00 2001 From: Jim Paris Date: Fri, 27 Feb 2026 12:47:37 -0500 Subject: [PATCH 1/4] Fix README symlink resolution in subdirectories like .github The `findReadmeFileInEntries` function recursively searches for a README file in well-known subdirectories such as `.github/`, `.gitea/`, and `docs/`. However, the recursive call incorrectly passed the original `parentDir` (usually the repo root) instead of the path to the subdirectory. This caused symlinks inside these subdirectories (e.g., `.github/README.md` pointing to `../subdir/README.md`) to be resolved relative to the root of the repository instead of the subdirectory, resulting in "not found" errors and the README failing to render. This change correctly appends the subdirectory name to the `parentDir` when making the recursive call, allowing `git.EntryFollowLinks` to operate from the correct base path and successfully resolve the symlinks. --- routers/web/repo/view_readme.go | 2 +- routers/web/repo/view_readme_test.go | 80 ++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 routers/web/repo/view_readme_test.go diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go index 830709422ee90..eba3ffc36fd49 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -102,7 +102,7 @@ func findReadmeFileInEntries(ctx *context.Context, parentDir string, entries []* return "", nil, err } - subfolder, readmeFile, err := findReadmeFileInEntries(ctx, parentDir, childEntries, false) + subfolder, readmeFile, err := findReadmeFileInEntries(ctx, path.Join(parentDir, subTreeEntry.Name()), childEntries, false) if err != nil && !git.IsErrNotExist(err) { return "", nil, err } diff --git a/routers/web/repo/view_readme_test.go b/routers/web/repo/view_readme_test.go new file mode 100644 index 0000000000000..e422e10b761e0 --- /dev/null +++ b/routers/web/repo/view_readme_test.go @@ -0,0 +1,80 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "context" + "os" + "path" + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindReadmeFileInEntriesWithSymlinkInSubfolder(t *testing.T) { + unittest.PrepareTestEnv(t) + + // Create a target README file content + targetContent := "This is the target README." + + subdirs := []string{".github", ".gitea", "docs"} + + for _, subdir := range subdirs { + t.Run(subdir, func(t *testing.T) { + // Create a temporary git repository for each case to avoid interference + repoPath := t.TempDir() + require.NoError(t, git.InitRepository(context.Background(), repoPath, false, git.Sha1ObjectFormat.Name())) + repo, err := git.OpenRepository(context.Background(), repoPath) + require.NoError(t, err) + defer repo.Close() + + // Create a target README file + targetFile := "target.md" + require.NoError(t, os.WriteFile(path.Join(repoPath, targetFile), []byte(targetContent), 0o644)) + + // Create a symlinked README.md in the subfolder + dirPath := path.Join(repoPath, subdir) + require.NoError(t, os.Mkdir(dirPath, 0o755)) + require.NoError(t, os.Symlink("../target.md", path.Join(dirPath, "README.md"))) + + // Commit the files + require.NoError(t, git.AddChanges(context.Background(), repoPath, true)) + require.NoError(t, git.CommitChanges(context.Background(), repoPath, git.CommitChangesOptions{ + Message: "Add symlinked README in " + subdir, + Author: &git.Signature{ + Name: "Test", + Email: "test@example.com", + }, + })) + + commit, err := repo.GetBranchCommit("master") + require.NoError(t, err) + + entries, err := commit.ListEntries() + require.NoError(t, err) + + ctx, _ := contexttest.MockContext(t, "/") + ctx.Repo.Commit = commit + ctx.Repo.TreePath = "" + + subfolder, readmeFile, err := findReadmeFileInEntries(ctx, "", entries, true) + require.NoError(t, err) + + assert.Equal(t, subdir, subfolder) + require.NotNil(t, readmeFile) + assert.Equal(t, "README.md", readmeFile.Name()) + assert.True(t, readmeFile.IsLink()) + + // Verify that it can follow the link + res, err := git.EntryFollowLinks(commit, path.Join(subfolder, readmeFile.Name()), readmeFile) + require.NoError(t, err) + assert.Equal(t, "target.md", res.TargetFullPath) + }) + } +} From cb410b33a98040ecbd61afe457bdc7dce07b3124 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 28 Feb 2026 12:29:45 +0800 Subject: [PATCH 2/4] fix test --- routers/web/repo/view_readme_test.go | 63 ++++++++++++---------------- 1 file changed, 27 insertions(+), 36 deletions(-) diff --git a/routers/web/repo/view_readme_test.go b/routers/web/repo/view_readme_test.go index e422e10b761e0..3083ce950b5de 100644 --- a/routers/web/repo/view_readme_test.go +++ b/routers/web/repo/view_readme_test.go @@ -4,13 +4,12 @@ package repo import ( - "context" - "os" + "fmt" "path" "testing" - "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" @@ -18,42 +17,35 @@ import ( ) func TestFindReadmeFileInEntriesWithSymlinkInSubfolder(t *testing.T) { - unittest.PrepareTestEnv(t) - - // Create a target README file content - targetContent := "This is the target README." - - subdirs := []string{".github", ".gitea", "docs"} - - for _, subdir := range subdirs { + for _, subdir := range []string{".github", ".gitea", "docs"} { t.Run(subdir, func(t *testing.T) { - // Create a temporary git repository for each case to avoid interference repoPath := t.TempDir() - require.NoError(t, git.InitRepository(context.Background(), repoPath, false, git.Sha1ObjectFormat.Name())) - repo, err := git.OpenRepository(context.Background(), repoPath) + stdin := fmt.Sprintf(`commit refs/heads/master +author Test 1700000000 +0000 +committer Test 1700000000 +0000 +data < Date: Sat, 28 Feb 2026 12:39:38 +0800 Subject: [PATCH 3/4] fine tune --- routers/web/repo/view_readme_test.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/routers/web/repo/view_readme_test.go b/routers/web/repo/view_readme_test.go index 3083ce950b5de..5f61b03fb804a 100644 --- a/routers/web/repo/view_readme_test.go +++ b/routers/web/repo/view_readme_test.go @@ -53,17 +53,16 @@ data 12 ctx, _ := contexttest.MockContext(t, "/") ctx.Repo.Commit = commit - ctx.Repo.TreePath = "" - subfolder, readmeFile, err := findReadmeFileInEntries(ctx, "", entries, true) + foundDir, foundReadme, err := findReadmeFileInEntries(ctx, "", entries, true) require.NoError(t, err) - require.NotNil(t, readmeFile) + require.NotNil(t, foundReadme) - assert.Equal(t, subdir, subfolder) - assert.Equal(t, "README.md", readmeFile.Name()) - assert.True(t, readmeFile.IsLink()) + assert.Equal(t, subdir, foundDir) + assert.Equal(t, "README.md", foundReadme.Name()) + assert.True(t, foundReadme.IsLink()) // Verify that it can follow the link - res, err := git.EntryFollowLinks(commit, path.Join(subfolder, readmeFile.Name()), readmeFile) + res, err := git.EntryFollowLinks(commit, path.Join(foundDir, foundReadme.Name()), foundReadme) require.NoError(t, err) assert.Equal(t, "target.md", res.TargetFullPath) }) From d019ab17ed49201791205c6598067786597d045a Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 28 Feb 2026 12:51:17 +0800 Subject: [PATCH 4/4] refactor --- modules/git/commit.go | 44 --------------- tests/integration/git_general_test.go | 16 +++--- .../git_helper_for_declarative_test.go | 54 +++++++++++++++++-- tests/integration/pull_merge_test.go | 4 +- tests/integration/ssh_key_test.go | 4 +- 5 files changed, 62 insertions(+), 60 deletions(-) diff --git a/modules/git/commit.go b/modules/git/commit.go index b98d36d946f0f..dfecfe605755b 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -86,50 +86,6 @@ func (c *Commit) GetCommitByPath(relpath string) (*Commit, error) { return c.repo.getCommitByPathWithID(c.ID, relpath) } -// AddChanges marks local changes to be ready for commit. -func AddChanges(ctx context.Context, repoPath string, all bool, files ...string) error { - cmd := gitcmd.NewCommand().AddArguments("add") - if all { - cmd.AddArguments("--all") - } - cmd.AddDashesAndList(files...) - _, _, err := cmd.WithDir(repoPath).RunStdString(ctx) - return err -} - -// CommitChangesOptions the options when a commit created -type CommitChangesOptions struct { - Committer *Signature - Author *Signature - Message string -} - -// CommitChanges commits local changes with given committer, author and message. -// If author is nil, it will be the same as committer. -func CommitChanges(ctx context.Context, repoPath string, opts CommitChangesOptions) error { - cmd := gitcmd.NewCommand() - if opts.Committer != nil { - cmd.AddOptionValues("-c", "user.name="+opts.Committer.Name) - cmd.AddOptionValues("-c", "user.email="+opts.Committer.Email) - } - cmd.AddArguments("commit") - - if opts.Author == nil { - opts.Author = opts.Committer - } - if opts.Author != nil { - cmd.AddOptionFormat("--author='%s <%s>'", opts.Author.Name, opts.Author.Email) - } - cmd.AddOptionFormat("--message=%s", opts.Message) - - _, _, err := cmd.WithDir(repoPath).RunStdString(ctx) - // No stderr but exit status 1 means nothing to commit. - if gitcmd.IsErrorExitCode(err, 1) { - return nil - } - return err -} - // CommitsByRange returns the specific page commits before current revision, every page's number default by CommitsRangeSize func (c *Commit) CommitsByRange(page, pageSize int, not, since, until string) ([]*Commit, error) { return c.repo.commitsByRangeWithTime(c.ID, page, pageSize, not, since, until) diff --git a/tests/integration/git_general_test.go b/tests/integration/git_general_test.go index f789ae3747116..b82dd60021736 100644 --- a/tests/integration/git_general_test.go +++ b/tests/integration/git_general_test.go @@ -199,10 +199,10 @@ func lfsCommitAndPushTest(t *testing.T, dstPath string, sizes ...int) (pushedFil _, _, err = gitcmd.NewCommand("lfs").AddArguments("track").AddDynamicArguments(prefix + "*"). WithDir(dstPath).RunStdString(t.Context()) assert.NoError(t, err) - err = git.AddChanges(t.Context(), dstPath, false, ".gitattributes") + err = gitAddChangesDeprecated(t.Context(), dstPath, false, ".gitattributes") assert.NoError(t, err) - err = git.CommitChanges(t.Context(), dstPath, git.CommitChangesOptions{ + err = gitCommitChangesDeprecated(t.Context(), dstPath, gitCommitChangesOptions{ Committer: &git.Signature{ Email: "user2@example.com", Name: "User Two", @@ -347,11 +347,11 @@ func generateCommitWithNewData(ctx context.Context, size int, repoPath, email, f _ = tmpFile.Close() // Commit - err = git.AddChanges(ctx, repoPath, false, filepath.Base(tmpFile.Name())) + err = gitAddChangesDeprecated(ctx, repoPath, false, filepath.Base(tmpFile.Name())) if err != nil { return "", err } - err = git.CommitChanges(ctx, repoPath, git.CommitChangesOptions{ + err = gitCommitChangesDeprecated(ctx, repoPath, gitCommitChangesOptions{ Committer: &git.Signature{ Email: email, Name: fullName, @@ -837,10 +837,10 @@ func doCreateAgitFlowPull(dstPath string, ctx *APITestContext, headBranch string err := os.WriteFile(path.Join(dstPath, "test_file"), []byte("## test content"), 0o666) require.NoError(t, err) - err = git.AddChanges(t.Context(), dstPath, true) + err = gitAddChangesDeprecated(t.Context(), dstPath, true) assert.NoError(t, err) - err = git.CommitChanges(t.Context(), dstPath, git.CommitChangesOptions{ + err = gitCommitChangesDeprecated(t.Context(), dstPath, gitCommitChangesOptions{ Committer: &git.Signature{ Email: "user2@example.com", Name: "user2", @@ -909,10 +909,10 @@ func doCreateAgitFlowPull(dstPath string, ctx *APITestContext, headBranch string err := os.WriteFile(path.Join(dstPath, "test_file"), []byte("## test content \n ## test content 2"), 0o666) require.NoError(t, err) - err = git.AddChanges(t.Context(), dstPath, true) + err = gitAddChangesDeprecated(t.Context(), dstPath, true) assert.NoError(t, err) - err = git.CommitChanges(t.Context(), dstPath, git.CommitChangesOptions{ + err = gitCommitChangesDeprecated(t.Context(), dstPath, gitCommitChangesOptions{ Committer: &git.Signature{ Email: "user2@example.com", Name: "user2", diff --git a/tests/integration/git_helper_for_declarative_test.go b/tests/integration/git_helper_for_declarative_test.go index 0e4163041a168..8c58e59115ba7 100644 --- a/tests/integration/git_helper_for_declarative_test.go +++ b/tests/integration/git_helper_for_declarative_test.go @@ -58,6 +58,52 @@ func createSSHUrl(gitPath string, u *url.URL) *url.URL { return &u2 } +// gitAddChangesDeprecated marks local changes to be ready for commit. +// Deprecated: use "git fast-import" instead for better performance and more control over the commit creation. +func gitAddChangesDeprecated(ctx context.Context, repoPath string, all bool, files ...string) error { + cmd := gitcmd.NewCommand().AddArguments("add") + if all { + cmd.AddArguments("--all") + } + cmd.AddDashesAndList(files...) + _, _, err := cmd.WithDir(repoPath).RunStdString(ctx) + return err +} + +// CommitChangesOptions the options when a commit created +type gitCommitChangesOptions struct { + Committer *git.Signature + Author *git.Signature + Message string +} + +// gitCommitChangesDeprecated commits local changes with given committer, author and message. +// If author is nil, it will be the same as committer. +// Deprecated: use "git fast-import" instead for better performance and more control over the commit creation. +func gitCommitChangesDeprecated(ctx context.Context, repoPath string, opts gitCommitChangesOptions) error { + cmd := gitcmd.NewCommand() + if opts.Committer != nil { + cmd.AddOptionValues("-c", "user.name="+opts.Committer.Name) + cmd.AddOptionValues("-c", "user.email="+opts.Committer.Email) + } + cmd.AddArguments("commit") + + if opts.Author == nil { + opts.Author = opts.Committer + } + if opts.Author != nil { + cmd.AddOptionFormat("--author='%s <%s>'", opts.Author.Name, opts.Author.Email) + } + cmd.AddOptionFormat("--message=%s", opts.Message) + + _, _, err := cmd.WithDir(repoPath).RunStdString(ctx) + // No stderr but exit status 1 means nothing to commit. + if gitcmd.IsErrorExitCode(err, 1) { + return nil + } + return err +} + func onGiteaRun[T testing.TB](t T, callback func(T, *url.URL)) { defer tests.PrepareTestEnv(t, 1)() s := http.Server{ @@ -128,13 +174,13 @@ func doGitInitTestRepository(dstPath string) func(*testing.T) { RunStdString(t.Context()) assert.NoError(t, err) assert.NoError(t, os.WriteFile(filepath.Join(dstPath, "README.md"), []byte("# Testing Repository\n\nOriginally created in: "+dstPath), 0o644)) - assert.NoError(t, git.AddChanges(t.Context(), dstPath, true)) + assert.NoError(t, gitAddChangesDeprecated(t.Context(), dstPath, true)) signature := git.Signature{ Email: "test@example.com", Name: "test", When: time.Now(), } - assert.NoError(t, git.CommitChanges(t.Context(), dstPath, git.CommitChangesOptions{ + assert.NoError(t, gitCommitChangesDeprecated(t.Context(), dstPath, gitCommitChangesOptions{ Committer: &signature, Author: &signature, Message: "Initial Commit", @@ -181,12 +227,12 @@ func doGitCheckoutWriteFileCommit(opts localGitAddCommitOptions) func(*testing.T doGitCheckoutBranch(opts.LocalRepoPath, opts.CheckoutBranch)(t) localFilePath := filepath.Join(opts.LocalRepoPath, opts.TreeFilePath) require.NoError(t, os.WriteFile(localFilePath, []byte(opts.TreeFileContent), 0o644)) - require.NoError(t, git.AddChanges(t.Context(), opts.LocalRepoPath, true)) + require.NoError(t, gitAddChangesDeprecated(t.Context(), opts.LocalRepoPath, true)) signature := git.Signature{ Email: "test@test.test", Name: "test", } - require.NoError(t, git.CommitChanges(t.Context(), opts.LocalRepoPath, git.CommitChangesOptions{ + require.NoError(t, gitCommitChangesDeprecated(t.Context(), opts.LocalRepoPath, gitCommitChangesOptions{ Committer: &signature, Author: &signature, Message: fmt.Sprintf("update %s @ %s", opts.TreeFilePath, opts.CheckoutBranch), diff --git a/tests/integration/pull_merge_test.go b/tests/integration/pull_merge_test.go index c80f790425ebf..e39ea658df121 100644 --- a/tests/integration/pull_merge_test.go +++ b/tests/integration/pull_merge_test.go @@ -997,10 +997,10 @@ func TestPullAutoMergeAfterCommitStatusSucceedAndApprovalForAgitFlow(t *testing. err := os.WriteFile(path.Join(dstPath, "test_file"), []byte("## test content"), 0o666) assert.NoError(t, err) - err = git.AddChanges(t.Context(), dstPath, true) + err = gitAddChangesDeprecated(t.Context(), dstPath, true) assert.NoError(t, err) - err = git.CommitChanges(t.Context(), dstPath, git.CommitChangesOptions{ + err = gitCommitChangesDeprecated(t.Context(), dstPath, gitCommitChangesOptions{ Committer: &git.Signature{ Email: "user2@example.com", Name: "user2", diff --git a/tests/integration/ssh_key_test.go b/tests/integration/ssh_key_test.go index 0f16d41adbccc..1625dc3756d49 100644 --- a/tests/integration/ssh_key_test.go +++ b/tests/integration/ssh_key_test.go @@ -28,13 +28,13 @@ func doCheckRepositoryEmptyStatus(ctx APITestContext, isEmpty bool) func(*testin func doAddChangesToCheckout(dstPath, filename string) func(*testing.T) { return func(t *testing.T) { assert.NoError(t, os.WriteFile(filepath.Join(dstPath, filename), fmt.Appendf(nil, "# Testing Repository\n\nOriginally created in: %s at time: %v", dstPath, time.Now()), 0o644)) - assert.NoError(t, git.AddChanges(t.Context(), dstPath, true)) + assert.NoError(t, gitAddChangesDeprecated(t.Context(), dstPath, true)) signature := git.Signature{ Email: "test@example.com", Name: "test", When: time.Now(), } - assert.NoError(t, git.CommitChanges(t.Context(), dstPath, git.CommitChangesOptions{ + assert.NoError(t, gitCommitChangesDeprecated(t.Context(), dstPath, gitCommitChangesOptions{ Committer: &signature, Author: &signature, Message: "Initial Commit",