Files
gitea/services/gitdiff/git_diff_tree_test.go
T
26f537dea6 fix(gitdiff): unquote C-quoted paths from git diff-tree --raw (#39781) (#39783)
Backport #39781 by @SudhanshuMatrix

## What

`parseGitDiffTreeLine` (`services/gitdiff/git_diff_tree.go`) read
`BasePath`/`HeadPath` straight from `git diff-tree --raw` output without
undoing git's C-quoting, unlike its sibling `parseLsTreeLine`
(`modules/git/parse.go`), which already does this with
`strconv.Unquote`.

This fix applies the same unquoting to both paths in the diff-tree
parser.

## Why

With the default `core.quotePath=true`, git C-quotes **any path
containing a non-ASCII byte** — not just tabs/newlines/backslashes. That
means any file with a CJK, Cyrillic, accented-Latin, or emoji name gets
octal-escaped, e.g.:

```
:100644 100644 b6fc4c6... 04fea06... M	"\346\227\245\346\234\254\350\252\236.txt"
```
(`日本語.txt`, confirmed against real `git diff-tree --raw` output)

`GetDiffTree` backs the file-tree sidebar on the commit, compare, and
pull request diff pages (`routers/web/repo/commit.go`, `compare.go`,
`pull.go`). Without unquoting, any such file shows its mangled, escaped
name instead of the real one, and the wrong path can throw off
jump-to-file and viewed-file-state behavior that key off the path.

Co-authored-by: Shudhanshu Singh <sudhanshuwriterblc@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-14 20:35:32 +00:00

483 lines
16 KiB
Go

// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitdiff
import (
"os"
"path/filepath"
"strings"
"testing"
"gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGitDiffTree(t *testing.T) {
test := []struct {
Name string
RepoPath string
BaseSha string
HeadSha string
useMergeBase bool
Expected *DiffTree
}{
{
Name: "happy path",
RepoPath: "../../modules/git/tests/repos/repo5_pulls",
BaseSha: "72866af952e98d02a73003501836074b286a78f6",
HeadSha: "d8e0bbb45f200e67d9a784ce55bd90821af45ebd",
Expected: &DiffTree{
Files: []*DiffTreeRecord{
{
Status: "modified",
HeadPath: "LICENSE",
BasePath: "LICENSE",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeBlob,
HeadBlobID: "ee469963e76ae1bb7ee83d7510df2864e6c8c640",
BaseBlobID: "c996f4725be8fc8c1d1c776e58c97ddc5d03b336",
},
{
Status: "modified",
HeadPath: "README.md",
BasePath: "README.md",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeBlob,
HeadBlobID: "9dfc0a6257d8eff526f0cfaf6a8ea950f55a9dba",
BaseBlobID: "074e590b8e64898b02beef03ece83f962c94f54c",
},
},
},
},
{
Name: "first commit (no parent)",
RepoPath: "../../modules/git/tests/repos/repo5_pulls",
HeadSha: "72866af952e98d02a73003501836074b286a78f6",
Expected: &DiffTree{
Files: []*DiffTreeRecord{
{
Status: "added",
HeadPath: ".gitignore",
BasePath: ".gitignore",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeNoEntry,
HeadBlobID: "f1c181ec9c5c921245027c6b452ecfc1d3626364",
BaseBlobID: "0000000000000000000000000000000000000000",
},
{
Status: "added",
HeadPath: "LICENSE",
BasePath: "LICENSE",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeNoEntry,
HeadBlobID: "c996f4725be8fc8c1d1c776e58c97ddc5d03b336",
BaseBlobID: "0000000000000000000000000000000000000000",
},
{
Status: "added",
HeadPath: "README.md",
BasePath: "README.md",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeNoEntry,
HeadBlobID: "074e590b8e64898b02beef03ece83f962c94f54c",
BaseBlobID: "0000000000000000000000000000000000000000",
},
},
},
},
{
Name: "first commit (no parent), merge base = true",
RepoPath: "../../modules/git/tests/repos/repo5_pulls",
HeadSha: "72866af952e98d02a73003501836074b286a78f6",
useMergeBase: true,
Expected: &DiffTree{
Files: []*DiffTreeRecord{
{
Status: "added",
HeadPath: ".gitignore",
BasePath: ".gitignore",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeNoEntry,
HeadBlobID: "f1c181ec9c5c921245027c6b452ecfc1d3626364",
BaseBlobID: "0000000000000000000000000000000000000000",
},
{
Status: "added",
HeadPath: "LICENSE",
BasePath: "LICENSE",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeNoEntry,
HeadBlobID: "c996f4725be8fc8c1d1c776e58c97ddc5d03b336",
BaseBlobID: "0000000000000000000000000000000000000000",
},
{
Status: "added",
HeadPath: "README.md",
BasePath: "README.md",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeNoEntry,
HeadBlobID: "074e590b8e64898b02beef03ece83f962c94f54c",
BaseBlobID: "0000000000000000000000000000000000000000",
},
},
},
},
{
Name: "base and head same",
RepoPath: "../../modules/git/tests/repos/repo5_pulls",
BaseSha: "ed8f4d2fa5b2420706580d191f5dd50c4e491f3f",
HeadSha: "ed8f4d2fa5b2420706580d191f5dd50c4e491f3f",
Expected: &DiffTree{
Files: []*DiffTreeRecord{},
},
},
{
Name: "useMergeBase false",
RepoPath: "../../modules/git/tests/repos/repo5_pulls",
BaseSha: "ed8f4d2fa5b2420706580d191f5dd50c4e491f3f",
HeadSha: "111cac04bd7d20301964e27a93698aabb5781b80", // this commit can be found on the update-readme branch
useMergeBase: false,
Expected: &DiffTree{
Files: []*DiffTreeRecord{
{
Status: "modified",
HeadPath: "LICENSE",
BasePath: "LICENSE",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeBlob,
HeadBlobID: "c996f4725be8fc8c1d1c776e58c97ddc5d03b336",
BaseBlobID: "ed5119b3c1f45547b6785bc03eac7f87570fa17f",
},
{
Status: "modified",
HeadPath: "README.md",
BasePath: "README.md",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeBlob,
HeadBlobID: "fb39771a8865c9a67f2ab9b616c854805664553c",
BaseBlobID: "9dfc0a6257d8eff526f0cfaf6a8ea950f55a9dba",
},
},
},
},
{
Name: "useMergeBase true",
RepoPath: "../../modules/git/tests/repos/repo5_pulls",
BaseSha: "ed8f4d2fa5b2420706580d191f5dd50c4e491f3f",
HeadSha: "111cac04bd7d20301964e27a93698aabb5781b80", // this commit can be found on the update-readme branch
useMergeBase: true,
Expected: &DiffTree{
Files: []*DiffTreeRecord{
{
Status: "modified",
HeadPath: "README.md",
BasePath: "README.md",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeBlob,
HeadBlobID: "fb39771a8865c9a67f2ab9b616c854805664553c",
BaseBlobID: "9dfc0a6257d8eff526f0cfaf6a8ea950f55a9dba",
},
},
},
},
{
Name: "no base set",
RepoPath: "../../modules/git/tests/repos/repo5_pulls",
HeadSha: "d8e0bbb45f200e67d9a784ce55bd90821af45ebd", // this commit can be found on the update-readme branch
useMergeBase: false,
Expected: &DiffTree{
Files: []*DiffTreeRecord{
{
Status: "modified",
HeadPath: "LICENSE",
BasePath: "LICENSE",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeBlob,
HeadBlobID: "ee469963e76ae1bb7ee83d7510df2864e6c8c640",
BaseBlobID: "ed5119b3c1f45547b6785bc03eac7f87570fa17f",
},
},
},
},
}
for _, tt := range test {
t.Run(tt.Name, func(t *testing.T) {
gitRepo, err := git.OpenRepositoryLocal(t.Context(), tt.RepoPath)
assert.NoError(t, err)
defer gitRepo.Close()
diffPaths, err := GetDiffTree(t.Context(), gitRepo, tt.useMergeBase, tt.BaseSha, tt.HeadSha)
require.NoError(t, err)
assert.Equal(t, tt.Expected, diffPaths)
})
}
}
func TestGitDiffTreeRespectsDiffOrderFile(t *testing.T) {
gitRepo, err := git.OpenRepositoryLocal(t.Context(), "../../modules/git/tests/repos/repo5_pulls")
require.NoError(t, err)
defer gitRepo.Close()
testDiffTree := func(t *testing.T) (filePaths []string) {
t.Helper()
diffTree, err := GetDiffTree(t.Context(), gitRepo, false, "72866af952e98d02a73003501836074b286a78f6", "d8e0bbb45f200e67d9a784ce55bd90821af45ebd")
require.NoError(t, err)
for _, f := range diffTree.Files {
filePaths = append(filePaths, f.HeadPath)
}
return filePaths
}
t.Run("NoDiffOrderFile", func(t *testing.T) {
assert.Equal(t, []string{"LICENSE", "README.md"}, testDiffTree(t))
})
t.Run("GlobalDiffOrderFile", func(t *testing.T) {
diffOrderFilePath := filepath.Join(t.TempDir(), "test-diff-order.txt")
err = os.WriteFile(diffOrderFilePath, []byte("README.md\nLICENSE\n"), 0o644)
require.NoError(t, err)
_, _, err = gitcmd.NewCommand("config", "set", "--global").AddDynamicArguments("diff.orderFile", diffOrderFilePath).RunStdString(t.Context())
require.NoError(t, err)
require.NoError(t, git.InitFull())
defer func() {
_, _, err = gitcmd.NewCommand("config", "unset", "--global").AddDynamicArguments("diff.orderFile").RunStdString(t.Context())
require.NoError(t, err)
require.NoError(t, git.InitFull())
}()
assert.Equal(t, []string{"README.md", "LICENSE"}, testDiffTree(t))
})
}
func TestParseGitDiffTree(t *testing.T) {
test := []struct {
Name string
GitOutput string
Expected []*DiffTreeRecord
}{
{
Name: "file change",
GitOutput: ":100644 100644 64e43d23bcd08db12563a0a4d84309cadb437e1a 5dbc7792b5bb228647cfcc8dfe65fc649119dedc M\tResources/views/curriculum/edit.blade.php",
Expected: []*DiffTreeRecord{
{
Status: "modified",
HeadPath: "Resources/views/curriculum/edit.blade.php",
BasePath: "Resources/views/curriculum/edit.blade.php",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeBlob,
HeadBlobID: "5dbc7792b5bb228647cfcc8dfe65fc649119dedc",
BaseBlobID: "64e43d23bcd08db12563a0a4d84309cadb437e1a",
},
},
},
{
Name: "file added",
GitOutput: ":000000 100644 0000000000000000000000000000000000000000 0063162fb403db15ceb0517b34ab782e4e58b619 A\tResources/views/class/index.blade.php",
Expected: []*DiffTreeRecord{
{
Status: "added",
HeadPath: "Resources/views/class/index.blade.php",
BasePath: "Resources/views/class/index.blade.php",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeNoEntry,
HeadBlobID: "0063162fb403db15ceb0517b34ab782e4e58b619",
BaseBlobID: "0000000000000000000000000000000000000000",
},
},
},
{
Name: "file deleted",
GitOutput: ":100644 000000 bac4286303c8c0017ea2f0a48c561ddcc0330a14 0000000000000000000000000000000000000000 D\tResources/views/classes/index.blade.php",
Expected: []*DiffTreeRecord{
{
Status: "deleted",
HeadPath: "Resources/views/classes/index.blade.php",
BasePath: "Resources/views/classes/index.blade.php",
HeadMode: git.EntryModeNoEntry,
BaseMode: git.EntryModeBlob,
HeadBlobID: "0000000000000000000000000000000000000000",
BaseBlobID: "bac4286303c8c0017ea2f0a48c561ddcc0330a14",
},
},
},
{
Name: "file renamed",
GitOutput: ":100644 100644 c8a055cfb45cd39747292983ad1797ceab40f5b1 97248f79a90aaf81fe7fd74b33c1cb182dd41783 R087\tDatabase/Seeders/AdminDatabaseSeeder.php\tDatabase/Seeders/AcademicDatabaseSeeder.php",
Expected: []*DiffTreeRecord{
{
Status: "renamed",
Score: 87,
HeadPath: "Database/Seeders/AcademicDatabaseSeeder.php",
BasePath: "Database/Seeders/AdminDatabaseSeeder.php",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeBlob,
HeadBlobID: "97248f79a90aaf81fe7fd74b33c1cb182dd41783",
BaseBlobID: "c8a055cfb45cd39747292983ad1797ceab40f5b1",
},
},
},
{
// make sure our code can unquote paths no matter core.quotePath is true or false: quoted chars are like "\t" or "\{oct}"
Name: "file modified with a C-quoted path",
GitOutput: ":100644 100644 b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0 04fea06420ca60892f73becee3614f6d023a4b7f M\t" + `"\346\227\245\346\234\254\350\252\236\ttest.txt"`,
Expected: []*DiffTreeRecord{
{
Status: "modified",
HeadPath: "日本語\ttest.txt",
BasePath: "日本語\ttest.txt",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeBlob,
HeadBlobID: "04fea06420ca60892f73becee3614f6d023a4b7f",
BaseBlobID: "b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0",
},
},
},
{
Name: "no changes",
GitOutput: ``,
Expected: []*DiffTreeRecord{},
},
{
Name: "multiple changes",
GitOutput: ":000000 100644 0000000000000000000000000000000000000000 db736b44533a840981f1f17b7029d0f612b69550 A\tHttp/Controllers/ClassController.php\n" +
":100644 000000 9a4d2344d4d0145db7c91b3f3e123c74367d4ef4 0000000000000000000000000000000000000000 D\tHttp/Controllers/ClassesController.php\n" +
":100644 100644 f060d6aede65d423f49e7dc248dfa0d8835ef920 b82c8e39a3602dedadb44669956d6eb5b6a7cc86 M\tHttp/Controllers/ProgramDirectorController.php\n",
Expected: []*DiffTreeRecord{
{
Status: "added",
HeadPath: "Http/Controllers/ClassController.php",
BasePath: "Http/Controllers/ClassController.php",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeNoEntry,
HeadBlobID: "db736b44533a840981f1f17b7029d0f612b69550",
BaseBlobID: "0000000000000000000000000000000000000000",
},
{
Status: "deleted",
HeadPath: "Http/Controllers/ClassesController.php",
BasePath: "Http/Controllers/ClassesController.php",
HeadMode: git.EntryModeNoEntry,
BaseMode: git.EntryModeBlob,
HeadBlobID: "0000000000000000000000000000000000000000",
BaseBlobID: "9a4d2344d4d0145db7c91b3f3e123c74367d4ef4",
},
{
Status: "modified",
HeadPath: "Http/Controllers/ProgramDirectorController.php",
BasePath: "Http/Controllers/ProgramDirectorController.php",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeBlob,
HeadBlobID: "b82c8e39a3602dedadb44669956d6eb5b6a7cc86",
BaseBlobID: "f060d6aede65d423f49e7dc248dfa0d8835ef920",
},
},
},
{
Name: "spaces in file path",
GitOutput: ":000000 100644 0000000000000000000000000000000000000000 db736b44533a840981f1f17b7029d0f612b69550 A\tHttp /Controllers/Class Controller.php\n" +
":100644 000000 9a4d2344d4d0145db7c91b3f3e123c74367d4ef4 0000000000000000000000000000000000000000 D\tHttp/Cont rollers/Classes Controller.php\n" +
":100644 100644 f060d6aede65d423f49e7dc248dfa0d8835ef920 b82c8e39a3602dedadb44669956d6eb5b6a7cc86 R010\tHttp/Controllers/Program Director Controller.php\tHttp/Cont rollers/ProgramDirectorController.php\n",
Expected: []*DiffTreeRecord{
{
Status: "added",
HeadPath: "Http /Controllers/Class Controller.php",
BasePath: "Http /Controllers/Class Controller.php",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeNoEntry,
HeadBlobID: "db736b44533a840981f1f17b7029d0f612b69550",
BaseBlobID: "0000000000000000000000000000000000000000",
},
{
Status: "deleted",
HeadPath: "Http/Cont rollers/Classes Controller.php",
BasePath: "Http/Cont rollers/Classes Controller.php",
HeadMode: git.EntryModeNoEntry,
BaseMode: git.EntryModeBlob,
HeadBlobID: "0000000000000000000000000000000000000000",
BaseBlobID: "9a4d2344d4d0145db7c91b3f3e123c74367d4ef4",
},
{
Status: "renamed",
Score: 10,
HeadPath: "Http/Cont rollers/ProgramDirectorController.php",
BasePath: "Http/Controllers/Program Director Controller.php",
HeadMode: git.EntryModeBlob,
BaseMode: git.EntryModeBlob,
HeadBlobID: "b82c8e39a3602dedadb44669956d6eb5b6a7cc86",
BaseBlobID: "f060d6aede65d423f49e7dc248dfa0d8835ef920",
},
},
},
{
Name: "file type changed",
GitOutput: ":100644 120000 344e0ca8aa791cc4164fb0ea645f334fd40d00f0 a7c2973de00bfdc6ca51d315f401b5199fe01dc3 T\twebpack.mix.js",
Expected: []*DiffTreeRecord{
{
Status: "typechanged",
HeadPath: "webpack.mix.js",
BasePath: "webpack.mix.js",
HeadMode: git.EntryModeSymlink,
BaseMode: git.EntryModeBlob,
HeadBlobID: "a7c2973de00bfdc6ca51d315f401b5199fe01dc3",
BaseBlobID: "344e0ca8aa791cc4164fb0ea645f334fd40d00f0",
},
},
},
}
for _, tt := range test {
t.Run(tt.Name, func(t *testing.T) {
entries, err := parseGitDiffTree(strings.NewReader(tt.GitOutput))
assert.NoError(t, err)
assert.Equal(t, tt.Expected, entries)
})
}
}
func TestGitDiffTreeErrors(t *testing.T) {
test := []struct {
Name string
RepoPath string
BaseSha string
HeadSha string
}{
{
Name: "head doesn't exist",
RepoPath: "../../modules/git/tests/repos/repo5_pulls",
BaseSha: "f32b0a9dfd09a60f616f29158f772cedd89942d2",
HeadSha: "asdfasdfasdf",
},
{
Name: "base doesn't exist",
RepoPath: "../../modules/git/tests/repos/repo5_pulls",
BaseSha: "asdfasdfasdf",
HeadSha: "f32b0a9dfd09a60f616f29158f772cedd89942d2",
},
{
Name: "head not set",
RepoPath: "../../modules/git/tests/repos/repo5_pulls",
BaseSha: "f32b0a9dfd09a60f616f29158f772cedd89942d2",
},
}
for _, tt := range test {
t.Run(tt.Name, func(t *testing.T) {
gitRepo, err := git.OpenRepositoryLocal(t.Context(), tt.RepoPath)
assert.NoError(t, err)
defer gitRepo.Close()
diffPaths, err := GetDiffTree(t.Context(), gitRepo, true, tt.BaseSha, tt.HeadSha)
assert.Error(t, err)
assert.Nil(t, diffPaths)
})
}
}