import { execFileSync } from 'node:child_process' import { existsSync, promises as fsp, readFileSync, realpathSync, 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 { addDetachedWorktree, fastForwardWorktree, fetchPullRequestHead } from '../../src/git/worktree' const gitEnv: NodeJS.ProcessEnv = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_AUTHOR_NAME: 'review-test', GIT_AUTHOR_EMAIL: 'review@test.local', GIT_COMMITTER_NAME: 'review-test', GIT_COMMITTER_EMAIL: 'review@test.local', } function git(cwd: string, args: string[]): string { return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env: gitEnv }).trim() } function commit(cwd: string, file: string, content: string): string { writeFileSync(path.join(cwd, file), content) git(cwd, ['add', file]) git(cwd, ['commit', '-m', `edit ${file}`]) return git(cwd, ['rev-parse', 'HEAD']) } interface Fixture { root: string, peer: string, repo: string, worktree: string } /** origin(bare)上有 main,同事把 PR 的提交推到 Gitea 给 PR 留的 refs/pull/7/head;repo 是审查方的 clone,本机没有 PR 的分支。 */ async function setup(): Promise { const root = realpathSync(await fsp.mkdtemp(path.join(os.tmpdir(), 'sw-review-'))) const origin = path.join(root, 'origin.git') git(root, ['init', '--bare', '-b', 'main', origin]) const peer = path.join(root, 'peer') git(root, ['clone', origin, peer]) commit(peer, 'README.md', 'hello\n') git(peer, ['push', '-u', 'origin', 'main']) git(peer, ['checkout', '-b', 'feature/x']) commit(peer, 'x.txt', 'x1\n') git(peer, ['push', 'origin', 'HEAD:refs/pull/7/head']) const repo = path.join(root, 'repo') git(root, ['clone', origin, repo]) return { root, peer, repo, worktree: path.join(root, 'wt', 'repo', 'review-6') } } /** 同事在 PR 上又推了一个提交 */ function pushToPullRequest(fx: Fixture, content: string): string { const sha = commit(fx.peer, 'x.txt', content) git(fx.peer, ['push', 'origin', 'HEAD:refs/pull/7/head']) return sha } let fx: Fixture | undefined afterEach(() => { if (fx) rmSync(fx.root, { recursive: true, force: true }) fx = undefined }) async function reviewWorktree(fixture: Fixture): Promise { await fetchPullRequestHead(fixture.repo, 7) const head = git(fixture.peer, ['rev-parse', 'HEAD']) await addDetachedWorktree({ workspaceRoot: fixture.repo, worktreePath: fixture.worktree, commit: head }) return head } describe('审查用的 worktree', () => { it('从 refs/pull//head 拉 PR 的提交,建一个停在它上面、不挂分支的 worktree(父目录不在也建)', async () => { fx = await setup() const head = await reviewWorktree(fx) expect(readFileSync(path.join(fx.worktree, 'x.txt'), 'utf8')).toBe('x1\n') expect(git(fx.worktree, ['rev-parse', 'HEAD'])).toBe(head) expect(git(fx.worktree, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('HEAD') expect(git(fx.repo, ['branch', '--list'])).not.toContain('feature/x') }) it('目录被手动删掉后同一路径能再建', async () => { fx = await setup() await reviewWorktree(fx) rmSync(fx.worktree, { recursive: true, force: true }) await reviewWorktree(fx) expect(existsSync(path.join(fx.worktree, 'x.txt'))).toBe(true) }) }) describe('把 worktree 快进到 PR 最新的提交', () => { it('同事在 PR 上又推了提交、工作副本干净:快进过去', async () => { fx = await setup() await reviewWorktree(fx) const latest = pushToPullRequest(fx, 'x2\n') await fetchPullRequestHead(fx.repo, 7) expect(await fastForwardWorktree(fx.worktree, latest)).toBe('updated') expect(git(fx.worktree, ['rev-parse', 'HEAD'])).toBe(latest) expect(readFileSync(path.join(fx.worktree, 'x.txt'), 'utf8')).toBe('x2\n') }) it('已经在最新的提交上:不动', async () => { fx = await setup() const head = await reviewWorktree(fx) expect(await fastForwardWorktree(fx.worktree, head)).toBe('current') }) it('只有未跟踪的文件:照样快进,文件留着', async () => { fx = await setup() await reviewWorktree(fx) writeFileSync(path.join(fx.worktree, 'notes.txt'), 'mine\n') const latest = pushToPullRequest(fx, 'x2\n') await fetchPullRequestHead(fx.repo, 7) expect(await fastForwardWorktree(fx.worktree, latest)).toBe('updated') expect(readFileSync(path.join(fx.worktree, 'notes.txt'), 'utf8')).toBe('mine\n') }) it('有没提交的改动:不动,改动留着', async () => { fx = await setup() const head = await reviewWorktree(fx) writeFileSync(path.join(fx.worktree, 'x.txt'), 'local edit\n') const latest = pushToPullRequest(fx, 'x2\n') await fetchPullRequestHead(fx.repo, 7) expect(await fastForwardWorktree(fx.worktree, latest)).toBe('dirty') expect(git(fx.worktree, ['rev-parse', 'HEAD'])).toBe(head) expect(readFileSync(path.join(fx.worktree, 'x.txt'), 'utf8')).toBe('local edit\n') }) it('本地另有提交、PR 也往前走了:快进不了,不动', async () => { fx = await setup() await reviewWorktree(fx) const local = commit(fx.worktree, 'local.txt', 'local\n') const latest = pushToPullRequest(fx, 'x2\n') await fetchPullRequestHead(fx.repo, 7) expect(await fastForwardWorktree(fx.worktree, latest)).toBe('diverged') expect(git(fx.worktree, ['rev-parse', 'HEAD'])).toBe(local) }) it('目录不是工作副本的根(只是仓库里的普通目录):报错,外面的仓库不动', async () => { fx = await setup() await fetchPullRequestHead(fx.repo, 7) const latest = git(fx.peer, ['rev-parse', 'HEAD']) const before = git(fx.repo, ['rev-parse', 'HEAD']) const inside = path.join(fx.repo, 'review-6') await fsp.mkdir(inside) await expect(fastForwardWorktree(inside, latest)).rejects.toThrow('不是 git 工作副本的根目录') expect(git(fx.repo, ['rev-parse', 'HEAD'])).toBe(before) }) it('本地在 PR 最新的提交之上还有提交:已经含着它,不动', async () => { fx = await setup() const head = await reviewWorktree(fx) const local = commit(fx.worktree, 'local.txt', 'local\n') expect(await fastForwardWorktree(fx.worktree, head)).toBe('current') expect(git(fx.worktree, ['rev-parse', 'HEAD'])).toBe(local) }) })