From e867e27106ef8c492c123b19633945d25cb8dcaf Mon Sep 17 00:00:00 2001 From: SKX <88086959+SKX-void@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:09:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(git):=20=E5=88=87=E6=8D=A2=E6=9C=AA?= =?UTF-8?q?=E8=B7=9F=E8=B8=AA=E6=96=87=E4=BB=B6=E6=98=BE=E7=A4=BA=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E4=BB=A5=E6=94=AF=E6=8C=81=E6=96=B0=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=A4=B9=E5=86=85=E6=96=87=E4=BB=B6=E5=B7=AE=E5=BC=82=20(#242)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- src/git.ts | 11 +++++++++-- tests/git.spec.ts | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/git.ts b/src/git.ts index 0a735d1..5077205 100644 --- a/src/git.ts +++ b/src/git.ts @@ -143,13 +143,20 @@ export async function currentBranch(cwd: string): Promise { 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 { 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) } } diff --git a/tests/git.spec.ts b/tests/git.spec.ts index cee7996..ed14b5a 100644 --- a/tests/git.spec.ts +++ b/tests/git.spec.ts @@ -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'