diff --git a/vscode/src/git/worktree.ts b/vscode/src/git/worktree.ts index ea0f8b1..bdf53f5 100644 --- a/vscode/src/git/worktree.ts +++ b/vscode/src/git/worktree.ts @@ -34,7 +34,7 @@ export interface WorktreeOpts { branch: string } -function runGit(workspaceRoot: string, args: string[], failLabel?: string): Promise { +export function runGit(workspaceRoot: string, args: string[], failLabel?: string): Promise { return new Promise((resolve, reject) => { execFile('git', ['-C', workspaceRoot, ...args], { timeout: 30_000 }, (err, stdout, stderr) => { if (err) { @@ -94,7 +94,7 @@ function parseWorktreePorcelain(stdout: string): Array<{ path: string, branch: s return entries } -async function findLiveWorktreeForBranch(workspaceRoot: string, branch: string): Promise { +export async function findLiveWorktreeForBranch(workspaceRoot: string, branch: string): Promise { const stdout = await runGit(workspaceRoot, ['worktree', 'list', '--porcelain']) for (const entry of parseWorktreePorcelain(stdout)) { if (entry.prunable || entry.branch !== branch) @@ -135,6 +135,36 @@ export async function ensureWorktree(opts: WorktreeOpts): Promise { return worktreePath } +/** + * 接管方重建 worktree:分支已在 origin 上,本机从远端 checkout。 + * `-B` 让本地同名残留分支直接对齐远端(发送方移交前已校验全部 push)。 + */ +export async function ensureWorktreeFromRemote(opts: WorktreeOpts): Promise { + const { workspaceRoot, branch } = opts + const worktreePath = path.resolve(opts.worktreePath) + + await runGit(workspaceRoot, ['fetch', 'origin', branch], `拉取远端分支 ${branch}`) + + // ① 已经有 live worktree → 复用,不动模板路径 + const live = await findLiveWorktreeForBranch(workspaceRoot, branch) + if (live) + return live + + // ② 模板路径被垃圾目录占着 → kill + 删,否则 add 撞路径 + if (await pathExists(worktreePath)) { + killProcessesUsingWorktree(worktreePath) + await removeWorktreeDir(workspaceRoot, worktreePath) + } + + await fsp.mkdir(path.dirname(worktreePath), { recursive: true }) + await runGit( + workspaceRoot, + ['worktree', 'add', '-B', branch, worktreePath, `origin/${branch}`], + 'git worktree add', + ) + return worktreePath +} + /** * 删除一个 worktree 目录并清掉 git 侧注册。 * diff --git a/vscode/test/git/worktreeFromRemote.test.ts b/vscode/test/git/worktreeFromRemote.test.ts new file mode 100644 index 0000000..ae33b3e --- /dev/null +++ b/vscode/test/git/worktreeFromRemote.test.ts @@ -0,0 +1,88 @@ +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 { ensureWorktreeFromRemote } 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: 'remote-test', + GIT_AUTHOR_EMAIL: 'remote@test.local', + GIT_COMMITTER_NAME: 'remote-test', + GIT_COMMITTER_EMAIL: 'remote@test.local', +} + +function git(cwd: string, args: string[]): string { + return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env: gitEnv }).trim() +} + +interface Fixture { root: string, origin: string, repo: string, templatePath: string } + +/** origin(bare)← 同事的 clone push feature 分支;repo 是接管方的 clone,只有 main。 */ +async function setup(): Promise { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sw-remote-')) + 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]) + writeFileSync(path.join(peer, 'README.md'), 'hello\n') + git(peer, ['add', 'README.md']) + git(peer, ['commit', '-m', 'init']) + git(peer, ['push', '-u', 'origin', 'main']) + git(peer, ['checkout', '-b', 'feature/x']) + writeFileSync(path.join(peer, 'x.txt'), 'x\n') + git(peer, ['add', 'x.txt']) + git(peer, ['commit', '-m', 'feat x']) + git(peer, ['push', '-u', 'origin', 'feature/x']) + const repo = path.join(root, 'repo') + git(root, ['clone', origin, repo]) + const templatePath = path.join(root, 'wt', 'x') + return { root, origin, repo, templatePath } +} + +let fx: Fixture | undefined +afterEach(() => { + if (fx) + rmSync(fx.root, { recursive: true, force: true }) + fx = undefined +}) + +describe('ensureWorktreeFromRemote', () => { + it('从 origin 分支建 worktree,本地分支跟踪远端', async () => { + fx = await setup() + const out = await ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/x' }) + expect(out).toBe(fx.templatePath) + expect(existsSync(path.join(fx.templatePath, 'x.txt'))).toBe(true) + expect(git(fx.templatePath, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x') + expect(git(fx.templatePath, ['rev-parse', '--abbrev-ref', '@{u}'])).toBe('origin/feature/x') + }) + + it('该分支已有 live worktree 时直接复用', async () => { + fx = await setup() + const first = await ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/x' }) + const other = path.join(fx.root, 'wt', 'other') + const second = await ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: other, branch: 'feature/x' }) + expect(second).toBe(first) + expect(existsSync(other)).toBe(false) + }) + + it('模板路径被非 worktree 目录占着 → 删掉重建', async () => { + fx = await setup() + await fsp.mkdir(fx.templatePath, { recursive: true }) + writeFileSync(path.join(fx.templatePath, 'junk'), '') + await ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/x' }) + expect(existsSync(path.join(fx.templatePath, 'junk'))).toBe(false) + expect(existsSync(path.join(fx.templatePath, 'x.txt'))).toBe(true) + }) + + it('远端没有该分支 → 抛错', async () => { + fx = await setup() + await expect( + ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/none' }), + ).rejects.toThrow(/feature\/none/) + }) +})