fix(git): 切换未跟踪文件显示模式以支持新文件夹内文件差异 (#242)

* fix(git): 切换未跟踪文件显示模式以支持新文件夹内文件差异
当使用 `git status --untracked-files=normal` 时,新文件夹会被折叠为单条 `?? dir/` 条目,点击后因无法读取目录(`fs.read` 报错 "is a directory")而无法加载差异。切换至 `--untracked-files=all` 使新文件夹下的每个文件独立显示为一行,并可正常查看差异,与 VSCode 默认行为一致。同时添加回归测试以验证 porcelain 输出格式。

* test(git): 补真实 git 集成测试钉住 status() 的 --untracked-files=all(review feedback)

---------

Co-authored-by: Menghuan1918 <menghuan2003@outlook.com>
This commit is contained in:
SKX
2026-08-22 23:09:58 +08:00
committed by GitHub
co-authored by Menghuan1918
parent 4148130aac
commit e867e27106
2 changed files with 44 additions and 3 deletions
+9 -2
View File
@@ -143,13 +143,20 @@ export async function currentBranch(cwd: string): Promise<string> {
return out.trim()
}
/** Working-tree status (untracked included). */
/**
* Working-tree status (untracked included). `--untracked-files=all` lists
* the CONTENTS of new directories as individual entries (`?? newdir/a.ts`
* rather than a collapsed `?? newdir/`), so every row in the source-control
* panel is a real file whose diff tab can load. With `=normal`, git folds a
* new folder into one trailing-slash entry that has no diff output and
* cannot be read as a file.
*/
export async function status(cwd: string): Promise<GitStatusResult> {
const repo = await isGitRepo(cwd)
if (!repo) return { isRepo: false, entries: [] }
const [branch, raw] = await Promise.all([
currentBranch(cwd).catch(() => 'HEAD'),
runGit(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']),
runGit(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
])
return { isRepo: true, branch, entries: parsePorcelainZ(raw) }
}
+35 -1
View File
@@ -1,6 +1,10 @@
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
import { parseLogLines, parsePorcelainZ, status } from '../src/git.ts'
describe('git parsing', () => {
it('parses porcelain -z entries including renames', () => {
@@ -14,6 +18,36 @@ describe('git parsing', () => {
])
})
it('keeps untracked files inside new directories as individual rows (status --untracked-files=all)', () => {
// status() runs with --untracked-files=all, so a new folder must surface
// as one entry PER FILE (?? newdir/a.ts), never a collapsed ?? newdir/
// row that has no diff and cannot be read (regression: new folders showed
// as a single folder row whose diff tab failed with "is a directory").
const output = ['?? newdir/a.ts', '?? newdir/sub/b.ts', ''].join('\0')
expect(parsePorcelainZ(output)).toEqual([
{ path: 'newdir/a.ts', xy: '??' },
{ path: 'newdir/sub/b.ts', xy: '??' },
])
})
it('keeps untracked files inside new directories as individual rows (real git)', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-git-status-'))
try {
execFileSync('git', ['init', '-q'], { cwd: root })
execFileSync('git', ['config', 'user.email', 't@t'], { cwd: root })
execFileSync('git', ['config', 'user.name', 't'], { cwd: root })
execFileSync('git', ['commit', '-q', '--allow-empty', '-m', 'init'], { cwd: root })
mkdirSync(join(root, 'newdir'))
writeFileSync(join(root, 'newdir', 'a.ts'), 'x')
const result = await status(root)
const paths = result.entries.map(entry => entry.path)
expect(paths).toContain('newdir/a.ts')
expect(paths.some(path => path.endsWith('/'))).toBe(false)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('parses log rows with unit separators (full hash + refs)', () => {
const rows = parseLogLines(
'abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\x1fabc1234def5678abc1234def5678abc1234def5678\x1fHEAD -> main, origin/main\n'