本篇讲解
src/worktree.ts------如何用 git worktree 创建隔离的执行副本,让 AI 助手的修改不影响原项目,除非你主动 promote。
1. 为什么需要 Worktree?
默认情况下,AI 助手直接在项目根目录执行命令(executionSurface: 'direct')。改了就改了,删了就删了。
但如果你想让 AI 助手"先在副本里改,改好了再合并回原项目"呢?
bash
your-project/ ← 主仓库
${元数据目录}/worktrees/
└── 1704830400000/ ← worktree 副本(独立分支)
└── src/
└── package.json
AI 助手在 worktree 副本里工作,改什么都只影响副本。等你确认没问题了,再 promoteWorktree() 把改动合并回主仓库。
2. Git Worktree 是什么?
Git worktree 是 Git 自带的功能,允许你从同一个仓库检出多个工作目录,每个目录对应不同的分支。
bash
# 在 ${元数据目录}/worktrees/1704830400000/ 创建一个新分支的 worktree
git worktree add -b ${分支名前缀, 自定义的固定值}/1704830400000 ${元数据目录}/worktrees/1704830400000
这个新目录和主仓库共享 .git,但文件系统是独立的。
3. 核心函数
3.1 isGitRepository
typescript
export function isGitRepository(dir: string): boolean {
return fs.existsSync(path.join(dir, '.git'));
}
3.2 createGitWorktree
typescript
/**
* 创建一个 git worktree 隔离副本。
*
* 在 worktreesDir 下创建一个新目录,并用 git worktree add 检出到一个新分支。
* 新分支名为 "前缀/时间戳"(如 nio-fe-codebuddy-agent/1704830400000),
* 方便一眼区分沙箱自动创建的分支和人工创建的分支。
*
* @param options.repoRoot - 主仓库根目录
* @param options.worktreesDir - worktree 存放目录(如 .nio-fe-codebuddy/worktrees/)
* @param options.branchPrefix - 分支名前缀(如 nio-fe-codebuddy-agent)
* @returns GitWorktreeHandle,包含 worktree 路径和分支名
*/
export async function createGitWorktree(options: {
repoRoot: string;
worktreesDir: string;
branchPrefix: string;
}): Promise<GitWorktreeHandle> {
const repoRoot = path.resolve(options.repoRoot);
// 不是 git 仓库就无法创建 worktree
if (!isGitRepository(repoRoot)) {
throw new Error(`Not a git repository: ${repoRoot}`);
}
// 确保 worktrees 目录存在
fs.mkdirSync(options.worktreesDir, { recursive: true });
// 用时间戳作为后缀,保证每次创建的分支名和目录名唯一
const suffix = `${Date.now()}`;
// 分支名 = 前缀/时间戳,如 nio-fe-codebuddy-agent/1704830400000
const branchName = `${options.branchPrefix}/${suffix}`;
// worktree 目录 = worktreesDir/时间戳,如 .nio-fe-codebuddy/worktrees/1704830400000
const worktreePath = path.join(options.worktreesDir, suffix);
// 执行 git worktree add -b <分支名> <目录>,创建新分支并检出
await runGit(repoRoot, ['worktree', 'add', '-b', branchName, worktreePath]);
return { worktreePath, branchName };
}
步骤:
- 确保是 git 仓库
- 创建 worktrees 目录
- 用时间戳生成唯一的分支名和路径
- 执行
git worktree add
返回的 GitWorktreeHandle 包含两个信息:
worktreePath:worktree 目录的绝对路径(作为executionRoot)branchName:创建的分支名
3.3 removeGitWorktree
typescript
/**
* 删除一个 git worktree 及其关联的分支。
*
* 先用 git worktree remove --force 强制移除目录,
* 再用 git branch -D 删除关联的分支(失败不报错,分支可能已不存在)。
*
* @param repoRoot - 主仓库根目录
* @param handle - createGitWorktree 返回的句柄
*/
export async function removeGitWorktree(
repoRoot: string,
handle: GitWorktreeHandle,
): Promise<void> {
const root = path.resolve(repoRoot);
// 如果 worktree 目录还存在,强制移除
if (fs.existsSync(handle.worktreePath)) {
await runGit(root, ['worktree', 'remove', '--force', handle.worktreePath]);
}
// 删除关联分支(catch 忽略错误,分支可能已随 worktree 一起清理)
await runGit(root, ['branch', '-D', handle.branchName]).catch(() => undefined);
}
两步清理:
- 删除 worktree 目录(
git worktree remove --force) - 删除对应分支(
git branch -D)
3.4 promoteGitWorktree
typescript
/**
* 将 worktree 的修改合并回主仓库(promote 操作)。
*
* 前提条件:主仓库和 worktree 都必须是干净状态(没有未提交的修改)。
* 合并使用 --no-ff(不快进),确保产生一个合并提交,方便追溯。
*
* @param repoRoot - 主仓库根目录
* @param handle - createGitWorktree 返回的句柄
* @returns merged 表示是否合并成功,message 是结果描述
*/
export async function promoteGitWorktree(
repoRoot: string,
handle: GitWorktreeHandle,
): Promise<{ merged: boolean; message: string }> {
const root = path.resolve(repoRoot);
const worktreeBranch = handle.branchName;
// 前置检查 1:主仓库必须是干净状态
if (!(await isGitTreeClean(root))) {
return {
merged: false,
message: 'Cannot promote: main repository has uncommitted changes.',
};
}
// 前置检查 2:worktree 也必须是干净状态
if (!(await isGitTreeClean(handle.worktreePath))) {
return {
merged: false,
message: 'Cannot promote: worktree has uncommitted changes.',
};
}
// 前置检查 3:主仓库当前必须在某个分支上(不能是 detached HEAD)
let currentBranch: string;
try {
currentBranch = (await runGit(root, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim();
} catch {
return { merged: false, message: 'Cannot promote: failed to read current branch.' };
}
if (!currentBranch || currentBranch === 'HEAD') {
return { merged: false, message: 'Cannot promote: detached HEAD on main repo.' };
}
// 执行合并:--no-ff 保证产生合并提交,方便追溯 worktree 的改动
try {
await runGit(root, [
'merge',
'--no-ff',
worktreeBranch,
'-m',
`Promote agent worktree ${worktreeBranch}`,
]);
return { merged: true, message: `Merged ${worktreeBranch} into ${currentBranch}.` };
} catch (error) {
return {
merged: false,
message: `Merge failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
promote 条件:
- 主仓库没有未提交的更改
- worktree 也没有未提交的更改
- 主仓库不在 detached HEAD 状态
- 合并成功
4. isGitTreeClean
typescript
/**
* 检查一个 git 目录是否是干净状态(没有未提交的修改)。
* 用 git status --porcelain 判断:输出为空 = 干净。
*/
export async function isGitTreeClean(dir: string): Promise<boolean> {
const result = await runGit(dir, ['status', '--porcelain']);
return result.stdout.trim() === '';
}
git status --porcelain 输出为空表示工作目录干净。
5. runGit------底层 Git 执行
typescript
/**
* 执行 git 命令的通用工具函数。
*
* @param cwd - 执行目录
* @param args - git 子命令和参数
* @returns 退出码、stdout、stderr
* @throws 非 0 退出码时抛出错误
*/
function runGit(
cwd: string,
args: string[],
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
child.on('error', (error) => { reject(error); });
child.on('close', (code) => {
const exitCode = typeof code === 'number' ? code : 1;
if (exitCode !== 0) {
reject(new Error(stderr.trim() || stdout.trim() || `git ${args.join(' ')} failed`));
return;
}
resolve({ code: exitCode, stdout, stderr });
});
});
}
注意 :这里的 runGit 和 executor.ts 里的 spawnAndWait 不同。它:
- 不需要超时(git 操作通常很快)
- 不需要 IO 回调
- 需要收集 stdout/stderr 文本
- 非零退出码会 reject
6. 生命周期
ini
AgentSandbox.initialize()
│
├── executionSurface === 'worktree'
│ │
│ ▼ createGitWorktree()
│ │
│ └── executionRoot = worktreePath
│
└── executionSurface === 'direct'
│
└── executionRoot = projectRoot
AgentSandbox.executeCommand(...)
│ 所有命令在 executionRoot 下执行
AgentSandbox.promoteWorktree()
│ 把 worktree 分支合并回主仓库
AgentSandbox.dispose()
│ 清理 worktree
│
└── removeGitWorktree()
7. 小结
| 函数 | 作用 |
|---|---|
isGitRepository |
检查目录是否是 git 仓库 |
createGitWorktree |
创建隔离的 worktree 副本 |
removeGitWorktree |
清理 worktree 和分支 |
promoteGitWorktree |
将 worktree 合并回主仓库 |
isGitTreeClean |
检查工作目录是否干净 |
核心思想:在副本里改,改好了再合并。改不好?直接删副本,原项目完好无损。