Files
apes-sidebar/tests/git.spec.ts
T
Menghuan1918 24a3813c63 feat!: 合并 Git 面板与文件追踪为统一「文件变动」tab(双视角 + 统一 diff 栈)
在 PR #471(file-trace)基础上的演进:

- 合并 tab:git tab(id 保留,兼容持久化布局)升级为「文件变动」双视角
  tab——Git 视角(原 GitView 全部能力:staged/unstaged/提交/历史/worktree·
  子仓库选择/右键菜单/确认弹窗)+ 本轮文件视角(原 file-trace:会话事件
  日志实时折叠的模型读写编辑记录,按文件分组、新增类型筛选 chips、相对
  时间);file-trace tab 移除(未随任何版本发布)。
- 统一 diff 组件:新 src/client/diff/ 模块(rows.ts 引擎 + DiffRows/
  DiffFiles 渲染器 + highlight.ts 语法着色)——git diff 与会话操作 diff
  共用一套渲染:mod 改写配对(del-run/add-run 对半标蓝)、行内字符级
  高亮(修复了旧实现旧侧失配不高亮的问题,改为行对象身份键)、上下文
  hunk 折叠、git hunk 间未输出区间按行号范围生成不可展开折叠、语法着色
  色相落位 diff 三色空档、+n −m 统计 chips、行底色 tint;DiffTab 改用
  该栈(git diff tab 从此也有高亮)。
- 交互:两视角共用底部可拖拽预览面板(点击文件/提交/操作即预览,不再
  直接开 diff tab;面板可刷新、可键盘/拖拽调高、git 目标可一键展开为
  独立 diff tab);视角选择与面板高度持久化在 tab.meta;git 行 XY 徽章
  按状态字母着色;选中行高亮。
- i18n:fileTrace* 十键重命名为 changes* 前缀,git 键值更新为「文件
  变动」,新增 7 键(视角/筛选/面板操作),全部 21 个词典同步。
- 结构:src/client/changes/(ChangesTab/GitLens/SessionLens/DiffPane/
  ops)+ src/client/diff/;GitView/DiffView/filetrace/ 删除;git 样式
  从 sidebar.module.css 迁至 changes.module.css。
- 测试:builtins 8→7(git tab 承接 badge pin)、diff-rows(unified
  转换/mod 配对/间隙折叠/统计)、changes-tab(壳交互 jsdom)、
  diff-files-collapse(原 DiffView 折叠行为钉住)、changes-ops(原
  filetrace 数据层)、git-view-worktree 迁移至 GitLens;全量 1122 通过。
- 文档:README ×2 功能矩阵合并、external-plugin-guide §4.4 与内置清单
  7 tab、mount e2e 菜单标题同步。
2026-08-31 01:31:22 +08:00

255 lines
10 KiB
TypeScript

import { execFile, execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { describe, expect, it } from 'vitest'
import { parseUnifiedDiff } from '../src/client/diff/rows.ts'
import { parseLogLines, parsePorcelainZ, repoRoots, status } from '../src/git.ts'
const execFileAsync = promisify(execFile)
const normalizePath = (path: string): string => path.replaceAll('\\', '/')
// macOS tmpdir() is the /var symlink while git reports the resolved
// /private/var prefix — canonicalize both sides before comparing.
const canonical = (path: string): string => normalizePath(realpathSync(path))
describe('git parsing', () => {
it('discovers and selects direct child repositories under a workspace directory', async () => {
const workspace = await mkdtemp(join(tmpdir(), 'dsh-better-sidebar-git-'))
const first = join(workspace, 'first-repo')
const second = join(workspace, 'second-repo')
try {
await Promise.all([mkdir(first), mkdir(second)])
await Promise.all([
execFileAsync('git', ['-C', first, 'init']),
execFileAsync('git', ['-C', second, 'init']),
])
await expect(repoRoots(workspace)).resolves.toEqual([canonical(first), canonical(second)])
await expect(status(workspace, canonical(second))).resolves.toMatchObject({
isRepo: true,
root: canonical(second),
repositories: [canonical(first), canonical(second)],
})
} finally {
await rm(workspace, { recursive: true, force: true })
}
})
it('parses porcelain -z entries including renames', () => {
const output = ['M src/a.ts', ' M src/b.ts', '?? src/c.ts', 'R src/new.ts', 'src/old.ts', ''].join('\0')
const entries = parsePorcelainZ(output)
expect(entries).toEqual([
{ path: 'src/a.ts', xy: 'M ' },
{ path: 'src/b.ts', xy: ' M' },
{ path: 'src/c.ts', xy: '??' },
{ path: 'src/new.ts', xy: 'R ' },
])
})
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('caps status entries at the truncation limit (issue #369)', async () => {
// A pathological untracked set — e.g. a repository discovered under a
// home-directory cwd — must ship a bounded payload: the browser main
// thread froze when one status response carried tens of thousands of
// rows into response.json() and the unvirtualized change list.
const root = mkdtempSync(join(tmpdir(), 'dsh-git-truncate-'))
try {
execFileSync('git', ['init', '-q'], { cwd: root })
const many = join(root, 'many')
mkdirSync(many)
for (let index = 0; index <= 2_000; index += 1) writeFileSync(join(many, `f${index}.ts`), 'x')
const result = await status(root)
expect(result.entries).toHaveLength(2_000)
expect(result.truncated).toBe(true)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('leaves ordinary statuses untruncated', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-git-untruncated-'))
try {
execFileSync('git', ['init', '-q'], { cwd: root })
for (const name of ['a.ts', 'b.ts', 'c.ts']) writeFileSync(join(root, name), 'x')
const result = await status(root)
expect(result.entries).toHaveLength(3)
expect(result.truncated).toBe(false)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('shares one in-flight discovery scan between concurrent callers and caches the result (issue #369)', async () => {
// The panel fires gitStatus/gitBranch/gitLog in parallel and then polls
// every 2s; without sharing/caching, each call re-probed every visible
// child directory of the cwd (the home-directory spawn storm of #369).
const workspace = await mkdtemp(join(tmpdir(), 'dsh-git-cache-'))
const repo = join(workspace, 'a-repo')
try {
await mkdir(repo)
await execFileAsync('git', ['-C', repo, 'init'])
const first = repoRoots(workspace)
// A second call while the first scan is still running joins it…
expect(repoRoots(workspace)).toBe(first)
const roots = await first
expect(roots).toEqual([canonical(repo)])
// …and once settled, the cached array (same reference) is served
// without re-probing for the TTL window.
await expect(repoRoots(workspace)).resolves.toBe(roots)
} finally {
await rm(workspace, { 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'
+ 'def5678\x1fSecond subject\x1fBob\x1f2024-01-02 10:00:00 +0800\x1fdef5678abc1234def5678abc1234def5678abc1234\x1f\n',
)
expect(rows).toEqual([
{
hash: 'abc1234',
subject: 'First subject',
author: 'Alice',
date: '2024-01-01 10:00:00 +0800',
hashFull: 'abc1234def5678abc1234def5678abc1234def5678',
refs: 'HEAD -> main, origin/main',
},
{
hash: 'def5678',
subject: 'Second subject',
author: 'Bob',
date: '2024-01-02 10:00:00 +0800',
hashFull: 'def5678abc1234def5678abc1234def5678abc1234',
refs: '',
},
])
})
it('parses a multi-file unified diff with aligned line numbers', () => {
const diff = [
'diff --git a/src/a.ts b/src/a.ts',
'index 1234567..89abcde 100644',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -1,4 +1,5 @@ section with @@ inside',
' line1',
'-line2',
'+line2b',
' context',
'+trailing',
'diff --git a/README.md b/README.md',
'new file mode 100644',
'index 0000000..1234567',
'--- /dev/null',
'+++ b/README.md',
'@@ -0,0 +1,2 @@',
'+hello',
'+world',
'',
].join('\n')
const parsed = parseUnifiedDiff(diff)
expect(parsed.files).toHaveLength(2)
const first = parsed.files[0]!
expect(first.oldPath).toBe('a/src/a.ts')
expect(first.newPath).toBe('b/src/a.ts')
expect(first.binary).toBe(false)
expect(first.hunks).toHaveLength(1)
expect(first.hunks[0]!.oldStart).toBe(1)
expect(first.hunks[0]!.newStart).toBe(1)
expect(first.hunks[0]!.header).toBe(' section with @@ inside')
expect(first.hunks[0]!.lines).toEqual([
{ kind: 'ctx', text: 'line1', oldNum: 1, newNum: 1 },
{ kind: 'del', text: 'line2', oldNum: 2, newNum: null },
{ kind: 'add', text: 'line2b', oldNum: null, newNum: 2 },
{ kind: 'ctx', text: 'context', oldNum: 3, newNum: 3 },
{ kind: 'add', text: 'trailing', oldNum: null, newNum: 4 },
])
const second = parsed.files[1]!
expect(second.oldPath).toBe('/dev/null')
expect(second.hunks[0]!.lines[0]).toEqual({ kind: 'add', text: 'hello', oldNum: null, newNum: 1 })
expect(second.hunks[0]!.lines[1]).toEqual({ kind: 'add', text: 'world', oldNum: null, newNum: 2 })
})
it('parses binary, deletion and no-newline markers', () => {
const diff = [
'diff --git a/img.png b/img.png',
'index 111..222 100644',
'Binary files a/img.png and b/img.png differ',
'diff --git a/gone.ts b/gone.ts',
'deleted file mode 100644',
'--- a/gone.ts',
'+++ /dev/null',
'@@ -1,2 +0,0 @@',
'-one',
'-two',
'\\ No newline at end of file',
'',
].join('\n')
const parsed = parseUnifiedDiff(diff)
expect(parsed.files).toHaveLength(2)
expect(parsed.files[0]!.binary).toBe(true)
expect(parsed.files[0]!.hunks).toHaveLength(0)
const gone = parsed.files[1]!
expect(gone.newPath).toBe('/dev/null')
expect(gone.hunks[0]!.lines).toEqual([
{ kind: 'del', text: 'one', oldNum: 1, newNum: null },
{ kind: 'del', text: 'two', oldNum: 2, newNum: null },
{ kind: 'meta', text: ' No newline at end of file', oldNum: null, newNum: null },
])
})
it('keeps mode/rename-only sections hunkless', () => {
const parsed = parseUnifiedDiff([
'diff --git a/run.sh b/run.sh',
'old mode 100644',
'new mode 100755',
'diff --git a/old.ts b/new.ts',
'similarity index 90%',
'rename from old.ts',
'rename to new.ts',
'',
].join('\n'))
expect(parsed.files).toHaveLength(2)
expect(parsed.files[0]!.oldPath).toBe('')
expect(parsed.files[0]!.hunks).toHaveLength(0)
expect(parsed.files[1]!.hunks).toHaveLength(0)
})
it('parses an empty or junk diff into no files', () => {
expect(parseUnifiedDiff('').files).toEqual([])
expect(parseUnifiedDiff('no diff here\n').files).toEqual([])
})
})