feat(repo): prioritize well-known READMEs and optimize discovery (#38532)

This PR adjusts the repository README discovery logic to mirror GitHub's
priority order, while retaining support for Gitea's specific `.gitea/`
directory.

Previously, Gitea would prioritize a `README.md` at the root of the
repository over any READMEs in `.gitea/` or `.github/`. With this
change, well-known subdirectories are evaluated first when viewing the
repository root.

**New Priority Order:**
1. `.gitea/README.md`
2. `.github/README.md`
3. `/README.md` (root directory)
4. `docs/README.md`

This allows repository maintainers to use `.github/README.md` (or
`.gitea/README.md`) as the repository homepage without having to remove
or rename generic root `/README.md` files required by package managers
(like NPM, Crates.io, Docker, etc.).

---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Shudhanshu Singh
2026-07-21 09:14:25 +05:30
committed by GitHub
parent a4526d5a82
commit c0f4e5583e
48 changed files with 248 additions and 231 deletions

42
modules/git/fastimport.go Normal file
View File

@@ -0,0 +1,42 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"bytes"
"context"
"fmt"
"gitea.dev/modules/git/gitcmd"
)
type FastImportFile struct {
Mode EntryMode
Path string
Content string
}
type FastImportCommit struct {
Ref string
Message string
Files []FastImportFile
}
// ForceFastImport is for mainly for testing purpose
func ForceFastImport(ctx context.Context, repo RepositoryFacade, commits []FastImportCommit) error {
var buf bytes.Buffer
for i, c := range commits {
_, _ = fmt.Fprintf(&buf, "reset %s\n", c.Ref)
_, _ = fmt.Fprintf(&buf, "commit %s\nmark :%d\ncommitter Gitea <gitea@example.com> 1500000000 +0000\n", c.Ref, i+1)
_, _ = fmt.Fprintf(&buf, "data %d\n%s\n", len(c.Message), c.Message)
for _, f := range c.Files {
_, _ = fmt.Fprintf(&buf, "M %s inline %s\ndata %d\n%s\n", f.Mode.String(), f.Path, len(f.Content), f.Content)
}
}
buf.WriteString("done\n")
_, _, err := gitcmd.NewCommand("fast-import").AddArguments("--force", "--done").
WithRepo(repo).WithStdinBytes(buf.Bytes()).
RunStdString(ctx)
return err
}

View File

@@ -8,6 +8,7 @@ import (
"archive/zip"
"bytes"
"compress/gzip"
"context"
"io"
"net/http"
"net/http/httptest"
@@ -156,6 +157,7 @@ var AllowSkipExternalService = sync.OnceValue(func() bool {
})
type TestingT interface {
Context() context.Context
Helper()
Skipf(format string, args ...any)
Errorf(format string, args ...any)

View File

@@ -268,11 +268,11 @@ func prepareDirectoryFileIcons(ctx *context.Context, files []git.CommitInfo) {
ctx.Data["FileIconPoolHTML"] = renderedIconPool.RenderToHTML()
}
func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entries {
func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) (*git.TreeEntry, git.Entries) {
tree, err := ctx.Repo.Commit.SubTree(ctx, ctx.Repo.GitRepo, ctx.Repo.TreePath)
if err != nil {
HandleGitError(ctx, "Repo.Commit.SubTree", err)
return nil
return nil, nil
}
// TODO: LAST-COMMIT-ASYNC-LOADING: search this keyword to see more details
@@ -283,20 +283,20 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx, ctx.Repo.GitRepo, ctx.Repo.TreePath)
if err != nil {
HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err)
return nil
return nil, nil
}
if !entry.IsDir() {
HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err)
return nil
return nil, nil
}
allEntries, err := tree.ListEntries(ctx, ctx.Repo.GitRepo)
subEntries, err := tree.ListEntries(ctx, ctx.Repo.GitRepo)
if err != nil {
ctx.ServerError("ListEntries", err)
return nil
return nil, nil
}
allEntries.CustomSort(base.NaturalSortCompare)
subEntries.CustomSort(base.NaturalSortCompare)
commitInfoCtx := gocontext.Context(ctx)
if timeout > 0 {
@@ -305,10 +305,10 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri
defer cancel()
}
files, latestCommit, err := allEntries.GetCommitsInfo(commitInfoCtx, ctx.Repo.RepoLink, ctx.Repo.GitRepo, ctx.Repo.Commit, ctx.Repo.TreePath)
files, latestCommit, err := subEntries.GetCommitsInfo(commitInfoCtx, ctx.Repo.RepoLink, ctx.Repo.GitRepo, ctx.Repo.Commit, ctx.Repo.TreePath)
if err != nil {
ctx.ServerError("GetCommitsInfo", err)
return nil
return nil, nil
}
{ // this block is for testing purpose only
@@ -340,9 +340,9 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri
}
if !loadLatestCommitData(ctx, latestCommit) {
return nil
return nil, nil
}
return allEntries
return entry, subEntries
}
// RenderUserCards render a page show users according the input template

View File

@@ -141,7 +141,7 @@ func prepareHomeSidebarLicenses(ctx *context.Context) {
}
func prepareToRenderDirectory(ctx *context.Context) {
entries := renderDirectoryFiles(ctx, 1*time.Second)
treeEntry, subEntries := renderDirectoryFiles(ctx, 1*time.Second)
if ctx.Written() {
return
}
@@ -151,12 +151,11 @@ func prepareToRenderDirectory(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+ctx.Repo.TreePath, ctx.Repo.RefFullName.ShortName())
}
subfolder, readmeFile, err := findReadmeFileInEntries(ctx, ctx.Repo.TreePath, entries, true)
subfolder, readmeFile, err := findReadmeFileInRepoTree(ctx, ctx.Repo.TreePath, treeEntry, subEntries)
if err != nil {
ctx.ServerError("findReadmeFileInEntries", err)
ctx.ServerError("findReadmeFileInRepo", err)
return
}
prepareToRenderReadmeFile(ctx, subfolder, readmeFile)
}
@@ -356,7 +355,8 @@ func redirectFollowSymlink(ctx *context.Context, treePathEntry *git.TreeEntry) b
return false
}
if treePathEntry.IsLink() {
if res, err := git.EntryFollowLinks(ctx, ctx.Repo.GitRepo, ctx.Repo.Commit, ctx.Repo.TreePath, treePathEntry); err == nil {
res, err := git.EntryFollowLinks(ctx, ctx.Repo.GitRepo, ctx.Repo.Commit, ctx.Repo.TreePath, treePathEntry)
if err == nil {
redirect := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(res.TargetFullPath) + "?" + ctx.Req.URL.RawQuery
ctx.Redirect(redirect)
return true

View File

@@ -24,95 +24,101 @@ import (
)
// locate a README for a tree in one of the supported paths.
//
// entries is passed to reduce calls to ListEntries(), so
// this has precondition:
// entries are passed to reduce calls to ListEntries(), so this has precondition:
//
// entries == ctx.Repo.Commit.SubTree(ctx.Repo.TreePath).ListEntries()
//
// FIXME: There has to be a more efficient way of doing this
func findReadmeFileInEntries(ctx *context.Context, parentDir string, entries []*git.TreeEntry, tryWellKnownDirs bool) (string, *git.TreeEntry, error) {
docsEntries := make([]*git.TreeEntry, 3) // (one of docs/, .gitea/ or .github/)
// this function is tested by integration test ViewRepoDirectoryReadme
func findReadmeFileInRepoTree(ctx *context.Context, treePath string, tree *git.TreeEntry, rootSubEntries []*git.TreeEntry) (subFolder string, _ *git.TreeEntry, err error) {
gitRepo := ctx.Repo.GitRepo
var dirEntries []*git.TreeEntry
if treePath == "" {
// only try the special sub-folders when visiting the repo root
wellKnownSubDirs := findReadmeWellKnownSubDirs(rootSubEntries)
dirEntries = []*git.TreeEntry{wellKnownSubDirs.entryGitea, wellKnownSubDirs.entryGitHub, tree, wellKnownSubDirs.entryDocs}
} else {
dirEntries = []*git.TreeEntry{tree}
}
for _, dirEntry := range dirEntries {
if dirEntry == nil {
continue
}
var dirSubEntries []*git.TreeEntry
if dirEntry == tree {
subFolder, dirSubEntries = "", rootSubEntries
} else {
subFolder = dirEntry.Name()
dirSubEntries, err = dirEntry.Tree(ctx, gitRepo).ListEntries(ctx, gitRepo)
if err != nil {
return "", nil, err
}
}
found := findReadmeFileInEntries(ctx, path.Join(treePath, subFolder), dirSubEntries)
if found != nil {
return subFolder, found, nil
}
}
return "", nil, nil
}
func findReadmeWellKnownSubDirs(entries []*git.TreeEntry) (ret struct{ entryGitea, entryGitHub, entryDocs *git.TreeEntry }) {
for _, entry := range entries {
if tryWellKnownDirs && entry.IsDir() {
// as a special case for the top-level repo introduction README,
// fall back to subfolders, looking for e.g. docs/README.md, .gitea/README.zh-CN.txt, .github/README.txt, ...
// (note that docsEntries is ignored unless we are at the root)
lowerName := strings.ToLower(entry.Name())
switch lowerName {
case "docs":
if entry.Name() == "docs" || docsEntries[0] == nil {
docsEntries[0] = entry
}
case ".gitea":
if entry.Name() == ".gitea" || docsEntries[1] == nil {
docsEntries[1] = entry
}
case ".github":
if entry.Name() == ".github" || docsEntries[2] == nil {
docsEntries[2] = entry
}
if !entry.IsDir() {
continue
}
lowerName := strings.ToLower(entry.Name())
switch lowerName {
case ".gitea":
if entry.Name() == ".gitea" || ret.entryGitea == nil {
ret.entryGitea = entry
}
case ".github":
if entry.Name() == ".github" || ret.entryGitHub == nil {
ret.entryGitHub = entry
}
case "docs":
if entry.Name() == "docs" || ret.entryDocs == nil {
ret.entryDocs = entry
}
}
}
return ret
}
func findReadmeFileInEntries(ctx *context.Context, parentPath string, entries []*git.TreeEntry) *git.TreeEntry {
// Create a list of extensions in priority order
// 1. Markdown files - with and without localisation - e.g. README.en-us.md or README.md
// 1. Markdown files - with and without localization - e.g. README.en-us.md or README.md
// 2. Txt files - e.g. README.txt
// 3. No extension - e.g. README
exts := append(localizedExtensions(".md", ctx.Locale.Language()), ".txt", "") // sorted by priority
extCount := len(exts)
readmeFiles := make([]*git.TreeEntry, extCount+1)
readmeFiles := make([]*git.TreeEntry, extCount+1) // ext weight can be len(exts), so here "+1"
for _, entry := range entries {
if i, ok := util.IsReadmeFileExtension(entry.Name(), exts...); ok {
fullPath := path.Join(parentDir, entry.Name())
if readmeFiles[i] == nil || base.NaturalSortCompare(readmeFiles[i].Name(), entry.Blob(ctx.Repo.GitRepo).Name()) < 0 {
if entry.IsLink() {
res, err := git.EntryFollowLinks(ctx, ctx.Repo.GitRepo, ctx.Repo.Commit, fullPath, entry)
if err == nil && (res.TargetEntry.IsExecutable() || res.TargetEntry.IsRegular()) {
readmeFiles[i] = entry
}
} else {
readmeFiles[i] = entry
extWeight, ok := util.IsReadmeFileExtension(entry.Name(), exts...)
if !ok {
continue
}
fullPath := path.Join(parentPath, entry.Name())
if readmeFiles[extWeight] == nil || base.NaturalSortCompare(readmeFiles[extWeight].Name(), entry.Blob(ctx.Repo.GitRepo).Name()) < 0 {
if entry.IsLink() {
res, err := git.EntryFollowLinks(ctx, ctx.Repo.GitRepo, ctx.Repo.Commit, fullPath, entry)
if err == nil && (res.TargetEntry.IsExecutable() || res.TargetEntry.IsRegular()) {
readmeFiles[extWeight] = entry
}
} else {
readmeFiles[extWeight] = entry
}
}
}
var readmeFile *git.TreeEntry
for _, f := range readmeFiles {
if f != nil {
readmeFile = f
break
return f
}
}
if ctx.Repo.TreePath == "" && readmeFile == nil {
for _, subTreeEntry := range docsEntries {
if subTreeEntry == nil {
continue
}
subTree := subTreeEntry.Tree(ctx, ctx.Repo.GitRepo)
if subTree == nil {
// this should be impossible; if subTreeEntry exists so should this.
continue
}
childEntries, err := subTree.ListEntries(ctx, ctx.Repo.GitRepo)
if err != nil {
return "", nil, err
}
subfolder, readmeFile, err := findReadmeFileInEntries(ctx, path.Join(parentDir, subTreeEntry.Name()), childEntries, false)
if err != nil && !git.IsErrNotExist(err) {
return "", nil, err
}
if readmeFile != nil {
return path.Join(subTreeEntry.Name(), subfolder), readmeFile, nil
}
}
}
return "", readmeFile, nil
return nil
}
// localizedExtensions prepends the provided language code with and without a

View File

@@ -1,71 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"fmt"
"path"
"testing"
"gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFindReadmeFileInEntriesWithSymlinkInSubfolder(t *testing.T) {
for _, subdir := range []string{".github", ".gitea", "docs"} {
t.Run(subdir, func(t *testing.T) {
repoPath := t.TempDir()
stdin := fmt.Sprintf(`commit refs/heads/master
author Test <test@example.com> 1700000000 +0000
committer Test <test@example.com> 1700000000 +0000
data <<EOT
initial
EOT
M 100644 inline target.md
data <<EOT
target-content
EOT
M 120000 inline %s/README.md
data 12
../target.md
`, subdir)
var err error
err = gitcmd.NewCommand("init", "--bare", ".").WithDir(repoPath).RunWithStderr(t.Context())
require.NoError(t, err)
err = gitcmd.NewCommand("fast-import").WithDir(repoPath).WithStdinBytes([]byte(stdin)).RunWithStderr(t.Context())
require.NoError(t, err)
gitRepo, err := git.OpenRepositoryLocal(repoPath)
require.NoError(t, err)
defer gitRepo.Close()
commit, err := gitRepo.GetBranchCommit(t.Context(), "master")
require.NoError(t, err)
entries, err := commit.Tree().ListEntries(t.Context(), gitRepo)
require.NoError(t, err)
ctx, _ := contexttest.MockContext(t, "/")
ctx.Repo.Commit = commit
ctx.Repo.GitRepo = gitRepo
foundDir, foundReadme, err := findReadmeFileInEntries(ctx, "", entries, true)
require.NoError(t, err)
require.NotNil(t, foundReadme)
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(t.Context(), gitRepo, commit, path.Join(foundDir, foundReadme.Name()), foundReadme)
require.NoError(t, err)
assert.Equal(t, "target.md", res.TargetFullPath)
})
}
}

View File

@@ -1,21 +0,0 @@
ea9ef877d1d88af76682d8798418081264f10cfc refs/heads/fallbacks
0d4c14db927c9ffba01fa7e126cc748b5c02c01e refs/heads/fallbacks2
c66d5b07c2063d3268707f22226c708b589574ef refs/heads/fallbacks3
89f8426e9eb5eff35c09b3565836c8f8e15d0ce9 refs/heads/fallbacks4
b0e902496eae435ad03c92a5d479f916ef2d4893 refs/heads/fallbacks5
84a5500b5cc040b11daf53fc42c542a99589dc76 refs/heads/fallbacks6
cf406a96e416d7de5c4c1bbfffdd672300c822bf refs/heads/fallbacks7
0d6ac644b969e9199915a492da9dba08c179fd23 refs/heads/fallbacks8
5038febc0c57215beb3748d7ae4091a25a4acc93 refs/heads/fallbacks9
9134e1f178ca4cccf1a197142646f2d7627e8cd5 refs/heads/i18n
744d2441e55bc0010d6b340d303f0106a627ad29 refs/heads/master
3c492566170b057e962c025515ab38bbd7444077 refs/heads/plain
3882d6373a0882a6739b3cd9b24d21c630621234 refs/heads/sp-ace
bf5ed898252eaa50dcc01108ed4417c3ea98a294 refs/heads/special-subdir-.gitea
c03543573ab088ce1cf7090a387d2be621426234 refs/heads/special-subdir-.github
e75957ad9b7e6ed16dda183529ec283db0bbc5fe refs/heads/special-subdir-docs
46f5d5ab33d701642e08c713fab42af89fdd4fea refs/heads/special-subdir-nested
9c0f872256b839c2b97ec22fd348d87b14045513 refs/heads/subdir
d7a854fff61e45b98234d7aa79ecbcb1619cd3dd refs/heads/symlink
30b9c0ed4b1039dbd99f3fb537b84ca507e0549d refs/heads/symlink-loop
41489b7be5c2244d2b7b524dcb31caf3bd1f9ccc refs/heads/txt

View File

@@ -1,2 +0,0 @@
x<01><>;<0E>0 @<40>s
_<EFBFBD>*N<><4E>H<08><>1q<01>u(<28>?<3F>t<EFBFBD><74>T<EFBFBD>0<><30>==<3D><><EFBFBD>Q<EFBFBD><05>+<2B>*4<><34>d<EFBFBD><64>h<EFBFBD>S<EFBFBD>η.z<><7A><EFBFBD><0E>͙Z3<5A><33>ct<><74>0'<27>As<41>5h<08>zL<7A>=D<><44>B<EFBFBD>\cx-ݴ<><DDB4>!O<><4F><EFBFBD><EFBFBD><EFBFBD><02>q<EFBFBD><71><EFBFBD><EFBFBD><EFBFBD><<3C><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> 0<>T<EFBFBD><EFBFBD><E8A385><EFBFBD>5<EFBFBD>=-<2D><><EFBFBD><7F><EFBFBD>U s<>7,O<>#<23>M<EFBFBD>

View File

@@ -1 +0,0 @@
x<01><>M<0E> <10><61> <0B> 0&Ƹs<C6B8><73> ئ<>'<27>.<2E><><EFBFBD>x<02><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>4 ]<5D><1A><>.)<29>D<EFBFBD><44>Q<EFBFBD>|@b6Xbd<62><64>}<7D>2+b<>%<25>T<> <1F>I>g<> 2<>7Q<37><51><EFBFBD>. (c<><63><EFBFBD><EFBFBD><EFBFBD>"o<1D><><EFBFBD>n<EFBFBD>M<EFBFBD><<3C>[6<>_^橼<>Z<1A><>T<07><>U<EFBFBD><19> <20><><EFBFBD>n<EFBFBD><6E>

View File

@@ -1,3 +0,0 @@
x<01><>;<0E>0 @<40>s
_<EFBFBD>*N뤕bcc<63><02><>Њ<EFBFBD><54>S!N<><4E><EFBFBD><EFBFBD><EFBFBD>d<1E>><3E><><EFBFBD>!<21><><10>քLu<>Ul<55>#q<><71><EFBFBD><EFBFBD>l<EFBFBD>Q<EFBFBD>,<2C><EFBFBD><EA94A1>lCBn$6<><36>X<EFBFBD><44>bbҖR0<><30><EFBFBD><EFBFBD><EFBFBD>y<EFBFBD>[/O<>n<EFBFBD><6E><EFBFBD>
<EFBFBD>i<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2<0F>1<0F><>Ї@<40>e<EFBFBD>p<EFBFBD>d<EFBFBD><64><EFBFBD><69> <0C><>!<21><<3C><07><>N<EFBFBD>

View File

@@ -1,3 +0,0 @@
x<01><>M
1 <0C>a<EFBFBD>=E.<2E><>m<EFBFBD><6D> <20>Νwh<77><68><EFBFBD><EFBFBD>#<23>"<22><>A<<3C><>g<EFBFBD>~|eǾ<>#<23>jU:<3A><><EFBFBD><05>$%<25>9o<39><6F>{<7B>9F <09>գQdsOU<4F><06>H<1C><>rA<72>(<28>=<3D>x<EFBFBD><78>E<EFBFBD><45><EFBFBD>$nkҳ]<5D>
<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>\ҫV<D2AB>M 7<><0F>yx<79><6D><07>1<06>-<2D>1 <20><><><DA96>

View File

@@ -1,2 +0,0 @@
P pack-8933bd634b76f8154310cccb52537a0195e43166.pack

View File

@@ -1,22 +0,0 @@
# pack-refs with: peeled fully-peeled sorted
ea9ef877d1d88af76682d8798418081264f10cfc refs/heads/fallbacks
0d4c14db927c9ffba01fa7e126cc748b5c02c01e refs/heads/fallbacks2
c66d5b07c2063d3268707f22226c708b589574ef refs/heads/fallbacks3
89f8426e9eb5eff35c09b3565836c8f8e15d0ce9 refs/heads/fallbacks4
b0e902496eae435ad03c92a5d479f916ef2d4893 refs/heads/fallbacks5
84a5500b5cc040b11daf53fc42c542a99589dc76 refs/heads/fallbacks6
cf406a96e416d7de5c4c1bbfffdd672300c822bf refs/heads/fallbacks7
0d6ac644b969e9199915a492da9dba08c179fd23 refs/heads/fallbacks8
5038febc0c57215beb3748d7ae4091a25a4acc93 refs/heads/fallbacks9
9134e1f178ca4cccf1a197142646f2d7627e8cd5 refs/heads/i18n
744d2441e55bc0010d6b340d303f0106a627ad29 refs/heads/master
3c492566170b057e962c025515ab38bbd7444077 refs/heads/plain
3882d6373a0882a6739b3cd9b24d21c630621234 refs/heads/sp-ace
bf5ed898252eaa50dcc01108ed4417c3ea98a294 refs/heads/special-subdir-.gitea
c03543573ab088ce1cf7090a387d2be621426234 refs/heads/special-subdir-.github
e75957ad9b7e6ed16dda183529ec283db0bbc5fe refs/heads/special-subdir-docs
46f5d5ab33d701642e08c713fab42af89fdd4fea refs/heads/special-subdir-nested
9c0f872256b839c2b97ec22fd348d87b14045513 refs/heads/subdir
d7a854fff61e45b98234d7aa79ecbcb1619cd3dd refs/heads/symlink
30b9c0ed4b1039dbd99f3fb537b84ca507e0549d refs/heads/symlink-loop
41489b7be5c2244d2b7b524dcb31caf3bd1f9ccc refs/heads/txt

View File

@@ -1 +0,0 @@
fe495ea336f079ef2bed68648d0ba9a37cdbd4aa

View File

@@ -1,6 +1,7 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//nolint:govet // disable "composites: gitea.dev/modules/git.FastImportFile struct literal uses unkeyed fields"
package integration
import (
@@ -19,6 +20,7 @@ import (
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/util"
@@ -308,10 +310,92 @@ func testViewRepoDirectory(t *testing.T) {
func testViewRepoDirectoryReadme(t *testing.T) {
defer tests.PrintCurrentTest(t)()
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
repo56 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: user2.ID, Name: "readme-test"})
const regular, symlink = git.EntryModeBlob, git.EntryModeSymlink
allGitea := []git.FastImportFile{
{regular, ".gitea/README.en.md", "This is .gitea/README.en.md"},
{regular, ".gitea/README.md", "This is .gitea/README.md"},
{regular, ".gitea/README", "This is .gitea/README"},
}
allGithub := []git.FastImportFile{
{regular, ".github/README.en.md", "This is .github/README.en.md"},
{regular, ".github/README.md", "This is .github/README.md"},
{regular, ".github/README", "This is .github/README"},
}
allRoot := []git.FastImportFile{
{regular, "README.en.md", "This is README.en.md"},
{regular, "README.md", "This is README.md"},
{regular, "README", "This is README"},
}
allDocs := []git.FastImportFile{
{regular, "docs/README.en.md", "This is docs/README.en.md"},
{regular, "docs/README.md", "This is docs/README.md"},
{regular, "docs/README", "This is docs/README"},
}
combineFiles := func(slices ...[]git.FastImportFile) (res []git.FastImportFile) {
for _, s := range slices {
res = append(res, s...)
}
return res
}
err := git.ForceFastImport(t.Context(), repo56.CodeStorageRepo(), []git.FastImportCommit{
{Ref: "refs/heads/master", Message: "init master", Files: []git.FastImportFile{{regular, "README.md", "The cake is a lie."}}},
{Ref: "refs/heads/txt", Message: "init txt", Files: []git.FastImportFile{{regular, "README.txt", "My spoon is too big."}}},
{Ref: "refs/heads/plain", Message: "init plain", Files: []git.FastImportFile{{regular, "README", "Birken my stocks gee howdy"}}},
{Ref: "refs/heads/i18n", Message: "init i18n", Files: []git.FastImportFile{{regular, "README.zh.md", "你好世界"}}},
{Ref: "refs/heads/subdir", Message: "init subdir", Files: []git.FastImportFile{{regular, "libcake/README.md", "Four pints of sugar."}}},
{Ref: "refs/heads/special-subdir-docs", Message: "init special-subdir-docs", Files: []git.FastImportFile{{regular, "docs/README.md", "This is in docs/"}}},
{Ref: "refs/heads/special-subdir-.gitea", Message: "init special-subdir-.gitea", Files: []git.FastImportFile{{regular, ".gitea/README.md", "This is in .gitea/"}}},
{Ref: "refs/heads/special-subdir-.github", Message: "init special-subdir-.github", Files: []git.FastImportFile{{regular, ".github/README.md", "This is in .github/"}}},
{Ref: "refs/heads/special-subdir-nested", Message: "init special-subdir-nested", Files: []git.FastImportFile{
{regular, ".gitea/docs/README.md", "This is in docs/"},
{regular, "subproject/.github/README.md", "This is in .github/"},
}},
{Ref: "refs/heads/symlink", Message: "init symlink", Files: []git.FastImportFile{
{symlink, ".github/README.md", "../some/other/path/awefulcake.txt"},
{symlink, "some/README.txt", "other/path/awefulcake.txt"},
{regular, "some/other/path/awefulcake.txt", "This is in some/other/path"},
{symlink, "trampoline", "up/back/down/down"},
{symlink, "up/back/down/down/README.md", "../../../../up/down/left/reelmein"},
{regular, "up/down/left/reelmein", "It's a me, mario"},
}},
{Ref: "refs/heads/symlink-loop", Message: "init symlink-loop", Files: []git.FastImportFile{
{symlink, "README.md", "trampoline"},
{symlink, "some/README.txt", "other/path/awefulcake.txt"},
{symlink, "some/other/path/awefulcake.txt", "../../../README.md"},
{symlink, "trampoline", "README.md"},
}},
{Ref: "refs/heads/sp-ace", Message: "init sp-ace", Files: []git.FastImportFile{{regular, "read me", "The cake is a lie."}}},
{Ref: "refs/heads/fallbacks", Message: "init fallbacks", Files: combineFiles(allGitea, allGithub, allRoot, allDocs)},
{Ref: "refs/heads/fallbacks2", Message: "init fallbacks2", Files: combineFiles(allGitea[1:], allGithub, allRoot, allDocs)},
{Ref: "refs/heads/fallbacks3", Message: "init fallbacks3", Files: combineFiles(allGitea[2:], allGithub, allRoot, allDocs)},
{Ref: "refs/heads/fallbacks4", Message: "init fallbacks4", Files: combineFiles(allGithub, allRoot, allDocs)},
{Ref: "refs/heads/fallbacks5", Message: "init fallbacks5", Files: combineFiles(allGithub[1:], allRoot, allDocs)},
{Ref: "refs/heads/fallbacks6", Message: "init fallbacks6", Files: combineFiles(allGithub[2:], allRoot, allDocs)},
{Ref: "refs/heads/fallbacks7", Message: "init fallbacks7", Files: combineFiles(allRoot, allDocs)},
{Ref: "refs/heads/fallbacks8", Message: "init fallbacks8", Files: combineFiles(allRoot[1:], allDocs)},
{Ref: "refs/heads/fallbacks9", Message: "init fallbacks9", Files: combineFiles(allRoot[2:], allDocs)},
{Ref: "refs/heads/fallbacks10", Message: "init fallbacks10", Files: combineFiles(allDocs)},
{Ref: "refs/heads/fallbacks11", Message: "init fallbacks11", Files: combineFiles(allDocs[1:])},
{Ref: "refs/heads/fallbacks12", Message: "init fallbacks12", Files: combineFiles(allDocs[2:])},
{Ref: "refs/heads/fallbacks-broken-symlinks", Message: "init fallbacks-broken-symlinks", Files: []git.FastImportFile{
{symlink, ".gitea/README.md", "non-existent-file"},
{symlink, ".github/README.md", "non-existent-file"},
{symlink, "README.md", "non-existent-file"},
{regular, "docs/README", "This is docs/README"},
}},
})
require.NoError(t, err)
// there are many combinations:
// - READMEs can be .md, .txt, or have no extension
// - READMEs can be tagged with a language and even a country code
// - READMEs can be stored in docs/, .gitea/, or .github/
// - READMEs can be stored in "docs/", ".gitea/", or ".github/"
// - READMEs can be symlinks to other files
// - READMEs can be broken symlinks which should not render
//
@@ -344,7 +428,7 @@ func testViewRepoDirectoryReadme(t *testing.T) {
check("md", "/user2/readme-test/src/branch/master/", "README.md", "markdown", "The cake is a lie.")
check("txt", "/user2/readme-test/src/branch/txt/", "README.txt", "plain-text", "My spoon is too big.")
check("plain", "/user2/readme-test/src/branch/plain/", "README", "plain-text", "Birken my stocks gee howdy")
check("i18n", "/user2/readme-test/src/branch/i18n/", "README.zh.md", "markdown", "蛋糕是一个谎言")
check("i18n", "/user2/readme-test/src/branch/i18n/", "README.zh.md", "markdown", "你好世界")
// using HEAD ref
check("branch-HEAD", "/user2/readme-test/src/branch/HEAD/", "README.md", "markdown", "The cake is a lie.")
@@ -357,33 +441,32 @@ func testViewRepoDirectoryReadme(t *testing.T) {
check(".gitea", "/user2/readme-test/src/branch/special-subdir-.gitea/", ".gitea/README.md", "markdown", "This is in .gitea/")
check(".github", "/user2/readme-test/src/branch/special-subdir-.github/", ".github/README.md", "markdown", "This is in .github/")
// symlinks
// symlinks are subtle:
// - they should be able to handle going a reasonable number of times up and down in the tree
// - they shouldn't get stuck on link cycles
// - they should determine the filetype based on the name of the link, not the target
check("symlink", "/user2/readme-test/src/branch/symlink/", "README.md", "markdown", "This is in some/other/path")
check("symlink", "/user2/readme-test/src/branch/symlink/", ".github/README.md", "markdown", "This is in some/other/path")
check("symlink-multiple", "/user2/readme-test/src/branch/symlink/some/", "README.txt", "plain-text", "This is in some/other/path")
check("symlink-up-and-down", "/user2/readme-test/src/branch/symlink/up/back/down/down", "README.md", "markdown", "It's a me, mario")
// testing fallback rules
// READMEs are searched in this order:
// - [README.zh-cn.md, README.zh_cn.md, README.zh.md, README_zh.md, README.md, README.txt, README,
// docs/README.zh-cn.md, docs/README.zh_cn.md, docs/README.zh.md, docs/README_zh.md, docs/README.md, docs/README.txt, docs/README,
// .gitea/README.zh-cn.md, .gitea/README.zh_cn.md, .gitea/README.zh.md, .gitea/README_zh.md, .gitea/README.md, .gitea/README.txt, .gitea/README,
// .github/README.zh-cn.md, .github/README.zh_cn.md, .github/README.zh.md, .github/README_zh.md, .github/README.md, .github/README.txt, .github/README]
// - directories: .gitea -> .github -> (root) -> docs
// - extensions: longer to shorter, non-English to English, e.g.: ".zh-cn.md" -> ".zh.md" -> "_zh.md" -> "en.md" -> ".md" -> (no-ext)
// and a broken/looped symlink counts as not existing at all and should be skipped.
// again, this doesn't cover all cases, but it covers a few
check("fallback/top", "/user2/readme-test/src/branch/fallbacks/", "README.en.md", "markdown", "This is README.en.md")
check("fallback/2", "/user2/readme-test/src/branch/fallbacks2/", "README.md", "markdown", "This is README.md")
check("fallback/3", "/user2/readme-test/src/branch/fallbacks3/", "README", "plain-text", "This is README")
check("fallback/4", "/user2/readme-test/src/branch/fallbacks4/", "docs/README.en.md", "markdown", "This is docs/README.en.md")
check("fallback/5", "/user2/readme-test/src/branch/fallbacks5/", "docs/README.md", "markdown", "This is docs/README.md")
check("fallback/6", "/user2/readme-test/src/branch/fallbacks6/", "docs/README", "plain-text", "This is docs/README")
check("fallback/7", "/user2/readme-test/src/branch/fallbacks7/", ".gitea/README.en.md", "markdown", "This is .gitea/README.en.md")
check("fallback/8", "/user2/readme-test/src/branch/fallbacks8/", ".gitea/README.md", "markdown", "This is .gitea/README.md")
check("fallback/9", "/user2/readme-test/src/branch/fallbacks9/", ".gitea/README", "plain-text", "This is .gitea/README")
check("fallback/top", "/user2/readme-test/src/branch/fallbacks/", ".gitea/README.en.md", "markdown", "This is .gitea/README.en.md")
check("fallback/2", "/user2/readme-test/src/branch/fallbacks2/", ".gitea/README.md", "markdown", "This is .gitea/README.md")
check("fallback/3", "/user2/readme-test/src/branch/fallbacks3/", ".gitea/README", "plain-text", "This is .gitea/README")
check("fallback/4", "/user2/readme-test/src/branch/fallbacks4/", ".github/README.en.md", "markdown", "This is .github/README.en.md")
check("fallback/5", "/user2/readme-test/src/branch/fallbacks5/", ".github/README.md", "markdown", "This is .github/README.md")
check("fallback/6", "/user2/readme-test/src/branch/fallbacks6/", ".github/README", "plain-text", "This is .github/README")
check("fallback/7", "/user2/readme-test/src/branch/fallbacks7/", "README.en.md", "markdown", "This is README.en.md")
check("fallback/8", "/user2/readme-test/src/branch/fallbacks8/", "README.md", "markdown", "This is README.md")
check("fallback/9", "/user2/readme-test/src/branch/fallbacks9/", "README", "plain-text", "This is README")
check("fallback/10", "/user2/readme-test/src/branch/fallbacks10/", "docs/README.en.md", "markdown", "This is docs/README.en.md")
check("fallback/11", "/user2/readme-test/src/branch/fallbacks11/", "docs/README.md", "markdown", "This is docs/README.md")
check("fallback/12", "/user2/readme-test/src/branch/fallbacks12/", "docs/README", "plain-text", "This is docs/README")
// this case tests that broken symlinks count as missing files, instead of rendering their contents
check("fallbacks-broken-symlinks", "/user2/readme-test/src/branch/fallbacks-broken-symlinks/", "docs/README", "plain-text", "This is docs/README")
@@ -407,24 +490,34 @@ func testViewRepoDirectoryReadme(t *testing.T) {
})
}
missing("sp-ace", "/user2/readme-test/src/branch/sp-ace/")
missing("nested-special", "/user2/readme-test/src/branch/special-subdir-nested/subproject") // the special subdirs should only trigger on the repo root
missing("nested-special", "/user2/readme-test/src/branch/special-subdir-nested/subproject") // the special sub-dirs should only trigger on the repo root
missing("special-subdir-nested", "/user2/readme-test/src/branch/special-subdir-nested/")
missing("symlink-loop", "/user2/readme-test/src/branch/symlink-loop/")
}
func testViewRepoSymlink(t *testing.T) {
session := loginUser(t, "user2")
req := NewRequest(t, "GET", "/user2/readme-test/src/branch/symlink")
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: 2, ID: 1})
err := git.ForceFastImport(t.Context(), repo.CodeStorageRepo(), []git.FastImportCommit{
{
Ref: "refs/heads/symlink", Message: "test", Files: []git.FastImportFile{
{git.EntryModeSymlink, "README.md", "some/other/path/awefulcake.txt"},
{git.EntryModeBlob, "some/other/path/awefulcake.txt", "text content"},
},
},
})
require.NoError(t, err)
req := NewRequest(t, "GET", "/user2/repo1/src/branch/symlink")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
AssertHTMLElement(t, htmlDoc, ".entry-symbol-link", true)
followSymbolLinkHref := htmlDoc.Find(".entry-symbol-link").AttrOr("href", "")
require.Equal(t, "/user2/readme-test/src/branch/symlink/README.md?follow_symlink=1", followSymbolLinkHref)
require.Equal(t, "/user2/repo1/src/branch/symlink/README.md?follow_symlink=1", followSymbolLinkHref)
req = NewRequest(t, "GET", followSymbolLinkHref)
resp = session.MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, "/user2/readme-test/src/branch/symlink/some/other/path/awefulcake.txt?follow_symlink=1", resp.Header().Get("Location"))
assert.Equal(t, "/user2/repo1/src/branch/symlink/some/other/path/awefulcake.txt?follow_symlink=1", resp.Header().Get("Location"))
}
func testMarkDownReadmeImage(t *testing.T) {