feat(vscode): ensureWorktreeFromRemote 从远端分支重建 worktree

Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph
This commit is contained in:
2026-08-26 15:37:49 +08:00
parent 24fc09cb0e
commit ad82808dd0
2 changed files with 120 additions and 2 deletions
+32 -2
View File
@@ -34,7 +34,7 @@ export interface WorktreeOpts {
branch: string branch: string
} }
function runGit(workspaceRoot: string, args: string[], failLabel?: string): Promise<string> { export function runGit(workspaceRoot: string, args: string[], failLabel?: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
execFile('git', ['-C', workspaceRoot, ...args], { timeout: 30_000 }, (err, stdout, stderr) => { execFile('git', ['-C', workspaceRoot, ...args], { timeout: 30_000 }, (err, stdout, stderr) => {
if (err) { if (err) {
@@ -94,7 +94,7 @@ function parseWorktreePorcelain(stdout: string): Array<{ path: string, branch: s
return entries return entries
} }
async function findLiveWorktreeForBranch(workspaceRoot: string, branch: string): Promise<string | undefined> { export async function findLiveWorktreeForBranch(workspaceRoot: string, branch: string): Promise<string | undefined> {
const stdout = await runGit(workspaceRoot, ['worktree', 'list', '--porcelain']) const stdout = await runGit(workspaceRoot, ['worktree', 'list', '--porcelain'])
for (const entry of parseWorktreePorcelain(stdout)) { for (const entry of parseWorktreePorcelain(stdout)) {
if (entry.prunable || entry.branch !== branch) if (entry.prunable || entry.branch !== branch)
@@ -135,6 +135,36 @@ export async function ensureWorktree(opts: WorktreeOpts): Promise<string> {
return worktreePath return worktreePath
} }
/**
* 接管方重建 worktree:分支已在 origin 上,本机从远端 checkout。
* `-B` 让本地同名残留分支直接对齐远端(发送方移交前已校验全部 push)。
*/
export async function ensureWorktreeFromRemote(opts: WorktreeOpts): Promise<string> {
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 侧注册。 * 删除一个 worktree 目录并清掉 git 侧注册。
* *
@@ -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 }
/** originbare)← 同事的 clone push feature 分支;repo 是接管方的 clone,只有 main。 */
async function setup(): Promise<Fixture> {
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/)
})
})