Opening the source-control panel could freeze the whole sidebar, and the frozen layout restored itself after every reload, leaving no way out (issue #369). Three unbounded layers compounded: - `git status --porcelain -z --untracked-files=all` shipped every row of a pathological untracked set into one response; the browser main thread froze on response.json() plus the unvirtualized change list, and the 2s poll replayed the freeze. status() now caps entries at 2000 and reports `truncated` (the panel shows a notice, mirroring fs.read's truncation semantics); worktree change counts inherit the bound for free. - repoRoots() probed every visible child directory of a non-repo cwd (e.g. ~) serially, once per git.* call, each with the full 30s command budget. Discovery now uses a 3s probe timeout, caps probes at 200 directories, and shares one in-flight scan plus a 60s TTL cache per cwd. - A hung layout persisted `panelOpen: true` with the git tab active, so a reload remounted straight back into the freeze with no escape hatch. Loading with `?dsh-sidebar-reset` now drops the persisted layout (and the shared width) and starts from the default. statusTruncated copy synced across all 19 shipped dictionaries.
255 lines
10 KiB
TypeScript
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/DiffView.tsx'
|
|
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([])
|
|
})
|
|
})
|