import { execFileSync } from 'node:child_process' import { existsSync, promises as fsp, rmSync, writeFileSync } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { ensureWorktree } from '../../src/git/worktree' interface Fixture { root: string repo: string templatePath: string } const gitEnv: NodeJS.ProcessEnv = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_AUTHOR_NAME: 'ensure-test', GIT_AUTHOR_EMAIL: 'ensure@test.local', GIT_COMMITTER_NAME: 'ensure-test', GIT_COMMITTER_EMAIL: 'ensure@test.local', } function git(cwd: string, args: string[]): string { return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env: gitEnv }).trim() } async function setupRepo(): Promise { const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sw-ensure-')) const repo = path.join(root, 'sw-ensure', 'repo') await fsp.mkdir(repo, { recursive: true }) git(repo, ['init', '-b', 'main']) git(repo, ['config', 'user.email', 'ensure@test.local']) git(repo, ['config', 'user.name', 'ensure-test']) writeFileSync(path.join(repo, 'README.md'), 'hello\n') git(repo, ['add', 'README.md']) git(repo, ['commit', '-m', 'init']) const templatePath = path.join(root, 'sw-ensure', 'wt', 'feature-x') await fsp.mkdir(path.dirname(templatePath), { recursive: true }) return { root, repo, templatePath } } async function cleanup(fx: Fixture | undefined): Promise { if (!fx) return try { const out = git(fx.repo, ['worktree', 'list', '--porcelain']) const extras: string[] = [] for (const line of out.split('\n')) { if (!line.startsWith('worktree ')) continue const p = line.slice('worktree '.length) if (path.resolve(p) !== path.resolve(fx.repo)) extras.push(p) } for (const p of extras) { try { git(fx.repo, ['worktree', 'remove', '--force', p]) } catch { rmSync(p, { recursive: true, force: true }) } } } catch { // repo 可能已残,下面整棵 tmp 仍要删 } rmSync(fx.root, { recursive: true, force: true }) } describe('ensureWorktree', () => { let fx: Fixture | undefined afterEach(async () => { await cleanup(fx) fx = undefined }) it('本地已有 feature 分支且无 worktree 时丢掉旧 commit 再新建', async () => { fx = await setupRepo() git(fx.repo, ['checkout', '-b', 'feature/x']) writeFileSync(path.join(fx.repo, 'README.md'), 'hello\nextra-1\n') git(fx.repo, ['add', 'README.md']) git(fx.repo, ['commit', '-m', 'extra 1']) writeFileSync(path.join(fx.repo, 'README.md'), 'hello\nextra-1\nextra-2\n') git(fx.repo, ['add', 'README.md']) git(fx.repo, ['commit', '-m', 'extra 2']) writeFileSync(path.join(fx.repo, 'README.md'), 'hello\nextra-1\nextra-2\nextra-3\n') git(fx.repo, ['add', 'README.md']) git(fx.repo, ['commit', '-m', 'extra 3']) git(fx.repo, ['checkout', 'main']) const ahead = git(fx.repo, ['log', '--oneline', 'main..feature/x']) expect(ahead.split('\n').filter(Boolean).length).toBe(3) const actual = await ensureWorktree({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/x', }) expect(path.resolve(actual)).toBe(path.resolve(fx.templatePath)) expect(git(actual, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x') expect(git(fx.repo, ['log', '--oneline', 'main..feature/x'])).toBe('') }) it('分支和 worktree 都不存在时创建新分支 worktree', async () => { fx = await setupRepo() const actual = await ensureWorktree({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/fresh', }) expect(path.resolve(actual)).toBe(path.resolve(fx.templatePath)) expect(git(actual, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/fresh') expect(git(fx.repo, ['show-ref', '--verify', '--quiet', 'refs/heads/feature/fresh'])).toBe('') }) it('该 branch 已有 live worktree 时删掉旧 path 在模板 path 新建', async () => { fx = await setupRepo() git(fx.repo, ['branch', 'feature/x']) const existing = path.join(fx.root, 'sw-ensure', 'wt', 'already-here') await fsp.mkdir(path.dirname(existing), { recursive: true }) git(fx.repo, ['worktree', 'add', existing, 'feature/x']) const actual = await ensureWorktree({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/x', }) expect(path.resolve(actual)).toBe(path.resolve(fx.templatePath)) expect(existsSync(existing)).toBe(false) expect(git(actual, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x') const listed = git(fx.repo, ['worktree', 'list', '--porcelain']) const wtLines = listed.split('\n').filter(l => l.startsWith('worktree ')) expect(wtLines).toHaveLength(2) }) it('模板 path 是垃圾目录且分支不存在时删掉后 add -b 成功', async () => { fx = await setupRepo() await fsp.mkdir(fx.templatePath, { recursive: true }) writeFileSync(path.join(fx.templatePath, 'junk.txt'), 'garbage') const actual = await ensureWorktree({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/fresh', }) expect(path.resolve(actual)).toBe(path.resolve(fx.templatePath)) expect(git(actual, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/fresh') expect(existsSync(path.join(fx.templatePath, 'junk.txt'))).toBe(false) expect(existsSync(path.join(fx.templatePath, 'README.md'))).toBe(true) }) it('第二次调用仍成功且在同一模板 path', async () => { fx = await setupRepo() const first = await ensureWorktree({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/x', }) writeFileSync(path.join(first, 'leftover.txt'), 'should-be-gone') const second = await ensureWorktree({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/x', }) expect(path.resolve(second)).toBe(path.resolve(fx.templatePath)) expect(git(second, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x') expect(existsSync(path.join(second, 'leftover.txt'))).toBe(false) const wtLines = git(fx.repo, ['worktree', 'list', '--porcelain']) .split('\n') .filter(l => l.startsWith('worktree ')) expect(wtLines).toHaveLength(2) }) })