From 34af6208444874ab1c83c9c9ecc4fb9c3cc62587 Mon Sep 17 00:00:00 2001 From: lishuceo Date: Wed, 27 May 2026 18:36:20 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(fork):=20/fork=20--clean=20=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=20+=20=E5=88=A0=20FORK=5FALLOWED=5FUSERS,=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=20enabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 解析 /fork [--clean] [描述],--clean 透传给 forkSession - 删除 FORK_ALLOWED_USERS 白名单(devbot 已有 ALLOWED_USER_IDS,重复) - FORK_ENABLED 默认开启,改为 != 'false' 才关 Co-Authored-By: Claude Opus 4.7 --- .env.example | 6 ++---- src/config.ts | 9 ++------- src/feishu/event-handler.ts | 22 ++++++++++------------ 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index 516d3b3..412b0a6 100644 --- a/.env.example +++ b/.env.example @@ -21,10 +21,8 @@ ALLOWED_USER_IDS= OWNER_USER_ID= # === Session Fork 配置 (Plan 8) === -# /fork 命令开关,P0a 默认关闭 (opt-in)。生产手动验证 OK 后再设为 true -# FORK_ENABLED=true -# Fork 白名单用户 open_id (逗号分隔)。为空则所有 allowed 用户可用 -# FORK_ALLOWED_USERS= +# /fork 命令开关,默认开启。设为 false 可关闭 +# FORK_ENABLED=false # === Claude Code 配置 === # Anthropic API Key (Claude Code 需要) diff --git a/src/config.ts b/src/config.ts index 877ae20..87fc79f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -203,13 +203,8 @@ export const config = { // Session Fork 配置 (Plan 8) fork: { - /** 启用 /fork 命令(P0a 默认 opt-in,需显式 FORK_ENABLED=true 才启用) */ - enabled: process.env.FORK_ENABLED === 'true', - /** 白名单用户 open_id (空则所有 allowed 用户可用) */ - allowedUsers: (process.env.FORK_ALLOWED_USERS || '') - .split(',') - .map((s) => s.trim()) - .filter(Boolean), + /** 启用 /fork 命令(默认开启,设 FORK_ENABLED=false 可关闭) */ + enabled: process.env.FORK_ENABLED !== 'false', }, // 服务配置 diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index 384da14..1faa2e8 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -1291,7 +1291,7 @@ async function handleSlashCommand( return true; } - // /fork [描述] - Session Fork (Plan 8) + // /fork [--clean] [描述] - Session Fork (Plan 8) if (trimmed === '/fork' || trimmed.startsWith('/fork ')) { if (!config.fork.enabled) { const reply = '⚠️ /fork 命令未启用 (FORK_ENABLED=false)'; @@ -1302,16 +1302,6 @@ async function handleSlashCommand( } return true; } - const allowed = config.fork.allowedUsers; - if (!isOwner(userId) && allowed.length > 0 && !allowed.includes(userId)) { - const reply = '⚠️ /fork 命令需要管理员或白名单用户权限'; - if (threadReplyMsgId) { - await feishuClient.replyTextInThread(threadReplyMsgId, reply); - } else { - await feishuClient.replyText(messageId, reply); - } - return true; - } if (!effectiveThreadId) { const reply = '⚠️ /fork 必须在话题内执行(先进入一个话题再 /fork)'; @@ -1319,7 +1309,14 @@ async function handleSlashCommand( return true; } - const description = trimmed === '/fork' ? '' : trimmed.slice('/fork '.length).trim(); + // 解析 /fork [--clean] [描述]: --clean 必须在 description 之前 + const rawArgs = trimmed === '/fork' ? '' : trimmed.slice('/fork '.length).trim(); + let clean = false; + let description = rawArgs; + if (rawArgs === '--clean' || rawArgs.startsWith('--clean ')) { + clean = true; + description = rawArgs === '--clean' ? '' : rawArgs.slice('--clean '.length).trim(); + } const result = await forkSession({ parentThreadId: effectiveThreadId, chatId, @@ -1327,6 +1324,7 @@ async function handleSlashCommand( triggerMessageId: messageId, description, agentId, + clean, }); if (!result.ok) { From a4c69615a57443cafa8e903c13f73aea35317eb9 Mon Sep 17 00:00:00 2001 From: lishuceo Date: Wed, 27 May 2026 18:36:31 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(fork):=20P0b=20=E5=9C=BA=E6=99=AF=20A?= =?UTF-8?q?=20=E2=80=94=20worktree=20+=20=E9=BB=98=E8=AE=A4=E7=BB=A7?= =?UTF-8?q?=E6=89=BF=E7=88=B6=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0a 之前对已 setup_workspace 的话题直接拒绝。P0b 实装: - 在父 git 仓库上 'git worktree add -b -fork-' - 默认继承父 WIP: stash push -u → 子 stash apply → 父 stash pop (git stash create 不支持 -u,只能走 push+pop 路径) - /fork --clean 跳过 WIP 继承,子从纯 HEAD 起步 - 失败时按 JSONL → worktree → branch 顺序回滚 - 新增 reason 码 worktree_create_failed / stash_apply_failed 最危险分支:子 apply 成功但父 pop 失败 → 父 WIP 卡在 stash@{0},日志 CRITICAL 提示用户手动 'git stash pop' 恢复。 Co-Authored-By: Claude Opus 4.7 --- src/session/fork.ts | 167 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 137 insertions(+), 30 deletions(-) diff --git a/src/session/fork.ts b/src/session/fork.ts index 3f506d9..a475869 100644 --- a/src/session/fork.ts +++ b/src/session/fork.ts @@ -1,4 +1,5 @@ import { randomBytes, randomUUID } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; import { unlinkSync } from 'node:fs'; import { resolve } from 'node:path'; import { config } from '../config.js'; @@ -12,9 +13,10 @@ export type ForkFailureReason = | 'no_parent_thread' | 'parent_not_found' | 'no_conversation' - | 'scenario_a_not_supported_yet' | 'parent_jsonl_missing' | 'feishu_thread_create_failed' + | 'worktree_create_failed' + | 'stash_apply_failed' | 'unknown'; export interface ForkResult { @@ -40,15 +42,33 @@ export interface ForkOptions { triggerMessageId: string; description?: string; agentId?: string; + /** 场景 A 下若为 true,跳过父 WIP 继承,子 worktree 从干净 HEAD 起步 */ + clean?: boolean; } +const GIT_TIMEOUT_MS = 30_000; + function generateShortId(): string { return randomBytes(2).toString('hex'); } +function gitExec(cwd: string, args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + timeout: GIT_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'pipe'], + }).toString(); +} + +function gitCurrentBranch(cwd: string): string { + return gitExec(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']).trim(); +} + /** - * 执行 Session Fork。P0a 仅支持「共享 DEFAULT_WORK_DIR」场景。 - * 已 setup_workspace 的会话目前直接拒绝,等 P0b 用 worktree+stash 落地。 + * 执行 Session Fork。 + * - 场景 B (父在 DEFAULT_WORK_DIR): 共享工作目录,不创建 worktree + * - 场景 A (父已 setup_workspace): 创建独立 git worktree + 默认继承父 WIP */ export async function forkSession(opts: ForkOptions): Promise { const agentId = opts.agentId ?? 'dev'; @@ -66,14 +86,7 @@ export async function forkSession(opts: ForkOptions): Promise Date: Wed, 27 May 2026 18:36:38 +0800 Subject: [PATCH 3/5] =?UTF-8?q?test(fork):=20=E5=9C=BA=E6=99=AF=20A=20?= =?UTF-8?q?=E5=85=A8=E8=A6=86=E7=9B=96(=E9=BB=98=E8=AE=A4=20WIP=20/=20--cl?= =?UTF-8?q?ean=20/=20=E6=97=A0=20WIP=20/=20=E5=A4=B1=E8=B4=A5=E5=9B=9E?= =?UTF-8?q?=E6=BB=9A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 个新 case 在 tmpdir 中 git init 真实仓库: - 默认: staged + unstaged + untracked 全继承,父工作树 status 不变 - --clean: 子是纯 HEAD,父 WIP 不传递 - 父无 WIP: stash push 不创建条目,子干净,不报错 - worktree_create_failed: 父非 git 仓库 → 干净回滚,无 JSONL/分支泄漏 - feishu 失败: worktree + branch + JSONL 都被清理 删除 scenario_a_not_supported_yet 旧 case(reason 已移除)。 Co-Authored-By: Claude Opus 4.7 --- src/session/__tests__/fork.test.ts | 193 ++++++++++++++++++++++++++--- 1 file changed, 176 insertions(+), 17 deletions(-) diff --git a/src/session/__tests__/fork.test.ts b/src/session/__tests__/fork.test.ts index 2c6f1c7..908ae62 100644 --- a/src/session/__tests__/fork.test.ts +++ b/src/session/__tests__/fork.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'; import { tmpdir, homedir } from 'node:os'; import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; // 必须在 import 被测模块之前 mock 依赖。 vi.mock('../../utils/logger.js', () => ({ @@ -155,22 +156,6 @@ describe('forkSession', () => { expect(result.reason).toBe('no_conversation'); }); - it('scenario_a_not_supported_yet:父话题已 setup_workspace', async () => { - const customDir = mkdtempSync(join(tmpdir(), 'fork-custom-')); - try { - getThreadSessionMock.mockReturnValue(makeParentSession({ workingDir: customDir })); - const result = await forkSession({ - parentThreadId: PARENT_THREAD_ID, chatId: CHAT_ID, userId: USER_ID, triggerMessageId: TRIGGER_MSG_ID, - }); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.reason).toBe('scenario_a_not_supported_yet'); - expect(sendTextMock).not.toHaveBeenCalled(); - } finally { - rmSync(customDir, { recursive: true, force: true }); - } - }); - it('parent_jsonl_missing:父 JSONL 文件不存在', async () => { rmSync(parentJsonl, { force: true }); getThreadSessionMock.mockReturnValue(makeParentSession()); @@ -262,3 +247,177 @@ describe('forkSession', () => { expect(leftoverForks).toEqual([]); }); }); + +// ============================================================ +// 场景 A (P0b): 父话题已 setup_workspace,fork 需要新建 worktree + 继承 WIP +// ============================================================ + +function initGitRepo(dir: string): void { + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir, timeout: 10_000 }); + execFileSync('git', ['config', 'user.email', 'test@test'], { cwd: dir }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir }); + writeFileSync(join(dir, 'README.md'), '# initial\n'); + execFileSync('git', ['add', '.'], { cwd: dir }); + execFileSync('git', ['commit', '-q', '-m', 'init', '--no-gpg-sign'], { cwd: dir }); +} + +function makeForkChildSession(workdir: string, conversationId: string = PARENT_CONV_ID) { + return { + threadId: PARENT_THREAD_ID, + chatId: CHAT_ID, + userId: USER_ID, + workingDir: workdir, + conversationId, + conversationCwd: workdir, + systemPromptHash: 'hash-abc', + approved: true, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +function seedJsonlAt(workdir: string, conversationId: string): string { + const path = resolveSessionJsonlPath(workdir, conversationId); + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, '{"role":"user","content":"hi"}\n'); + return path; +} + +describe('forkSession - 场景 A (worktree + WIP)', () => { + let parentWorkdir: string; + let parentJsonlPath: string; + + beforeEach(() => { + sendTextMock.mockReset(); + replyInThreadMock.mockReset(); + getThreadSessionMock.mockReset(); + createForkedThreadSessionMock.mockReset(); + sendTextMock.mockResolvedValue('om_new_root'); + replyInThreadMock.mockResolvedValue({ messageId: 'om_reply', threadId: 'omt_new_thread' }); + + parentWorkdir = mkdtempSync(join(tmpdir(), 'fork-parent-')); + initGitRepo(parentWorkdir); + parentJsonlPath = seedJsonlAt(parentWorkdir, PARENT_CONV_ID); + getThreadSessionMock.mockReturnValue(makeForkChildSession(parentWorkdir)); + }); + + afterEach(() => { + // 先清 worktree(若有),否则父删了 worktree 引用是悬空的 + try { + execFileSync('git', ['worktree', 'prune'], { cwd: parentWorkdir }); + } catch { /* ignore */ } + rmSync(parentWorkdir, { recursive: true, force: true }); + rmSync(`${parentWorkdir}-fork-*`, { recursive: true, force: true }); + // JSONL 项目目录在 ~/.claude/projects/ + const projectDir = join(parentJsonlPath, '..'); + if (existsSync(projectDir)) rmSync(projectDir, { recursive: true, force: true }); + }); + + it('默认继承父 WIP:staged + unstaged + untracked 全部带过来,父工作树不动', async () => { + // 准备父 WIP + writeFileSync(join(parentWorkdir, 'staged.txt'), 'staged-content\n'); + execFileSync('git', ['add', 'staged.txt'], { cwd: parentWorkdir }); + writeFileSync(join(parentWorkdir, 'README.md'), '# initial\nunstaged change\n'); + writeFileSync(join(parentWorkdir, 'untracked.txt'), 'untracked-content\n'); + + // 记录父 fork 前 status + const parentStatusBefore = execFileSync('git', ['status', '--porcelain'], + { cwd: parentWorkdir, encoding: 'utf8' }); + + const result = await forkSession({ + parentThreadId: PARENT_THREAD_ID, chatId: CHAT_ID, userId: USER_ID, triggerMessageId: TRIGGER_MSG_ID, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + // 子 worktree 应存在且包含 3 类 WIP 文件 + expect(result.workingDir).toBe(`${parentWorkdir}-fork-${result.shortId}`); + expect(existsSync(result.workingDir)).toBe(true); + expect(existsSync(join(result.workingDir, 'staged.txt'))).toBe(true); + expect(existsSync(join(result.workingDir, 'untracked.txt'))).toBe(true); + expect(readFileSync(join(result.workingDir, 'README.md'), 'utf8')).toContain('unstaged change'); + + // 父工作树 status 与 fork 前完全一致 + const parentStatusAfter = execFileSync('git', ['status', '--porcelain'], + { cwd: parentWorkdir, encoding: 'utf8' }); + expect(parentStatusAfter).toBe(parentStatusBefore); + + // DB 写入的 workingDir 是新 worktree 路径 + expect(createForkedThreadSessionMock.mock.calls[0][0].workingDir).toBe(result.workingDir); + }); + + it('--clean 跳过 WIP 继承:子 worktree 是纯 HEAD,无未提交改动', async () => { + writeFileSync(join(parentWorkdir, 'wip.txt'), 'should-not-appear\n'); + execFileSync('git', ['add', 'wip.txt'], { cwd: parentWorkdir }); + + const result = await forkSession({ + parentThreadId: PARENT_THREAD_ID, chatId: CHAT_ID, userId: USER_ID, triggerMessageId: TRIGGER_MSG_ID, + clean: true, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(existsSync(join(result.workingDir, 'wip.txt'))).toBe(false); + const childStatus = execFileSync('git', ['status', '--porcelain'], + { cwd: result.workingDir, encoding: 'utf8' }); + expect(childStatus.trim()).toBe(''); + }); + + it('父无 WIP:stash create 返回空,子 worktree 也是纯净的,不报错', async () => { + // 父就是 init 后干净状态 + const result = await forkSession({ + parentThreadId: PARENT_THREAD_ID, chatId: CHAT_ID, userId: USER_ID, triggerMessageId: TRIGGER_MSG_ID, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const childStatus = execFileSync('git', ['status', '--porcelain'], + { cwd: result.workingDir, encoding: 'utf8' }); + expect(childStatus.trim()).toBe(''); + }); + + it('worktree_create_failed:父不是 git 仓库 → 干净回滚,无 JSONL/分支泄漏', async () => { + // 删掉父的 .git 模拟"无法 worktree add" 的失败场景 + rmSync(join(parentWorkdir, '.git'), { recursive: true, force: true }); + + const result = await forkSession({ + parentThreadId: PARENT_THREAD_ID, chatId: CHAT_ID, userId: USER_ID, triggerMessageId: TRIGGER_MSG_ID, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe('worktree_create_failed'); + expect(sendTextMock).not.toHaveBeenCalled(); + expect(createForkedThreadSessionMock).not.toHaveBeenCalled(); + + const projectDir = join(parentJsonlPath, '..'); + const leftoverForks = readdirSync(projectDir) + .filter((f) => f.endsWith('.jsonl') && !f.startsWith(PARENT_CONV_ID)); + expect(leftoverForks).toEqual([]); + }); + + it('feishu 失败时回滚 worktree + branch + JSONL', async () => { + sendTextMock.mockResolvedValue(undefined); + + const result = await forkSession({ + parentThreadId: PARENT_THREAD_ID, chatId: CHAT_ID, userId: USER_ID, triggerMessageId: TRIGGER_MSG_ID, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe('feishu_thread_create_failed'); + + // worktree 目录应被清理 + const forkDirs = readdirSync(join(parentWorkdir, '..')) + .filter((f) => f.startsWith(`${parentWorkdir.split('/').pop()}-fork-`)); + expect(forkDirs).toEqual([]); + + // 分支应被删除(git branch --list 不返回 fork 分支) + const branches = execFileSync('git', ['branch', '--list', 'main-fork-*'], + { cwd: parentWorkdir, encoding: 'utf8' }); + expect(branches.trim()).toBe(''); + + // JSONL 应被清理 + const projectDir = join(parentJsonlPath, '..'); + const leftoverForks = readdirSync(projectDir) + .filter((f) => f.endsWith('.jsonl') && !f.startsWith(PARENT_CONV_ID)); + expect(leftoverForks).toEqual([]); + }); +}); From 2a057de070f13496a45e3561a5b6522022cdd3b7 Mon Sep 17 00:00:00 2001 From: lishuceo Date: Wed, 27 May 2026 18:36:46 +0800 Subject: [PATCH 4/5] =?UTF-8?q?docs(fork):=20plan-8=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E5=AE=9E=E7=8E=B0(stash=20push+pop,=20?= =?UTF-8?q?=E9=9D=9E=20create)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git stash create 不支持 -u(选项被当 message),无法捕获 untracked。 实装必须走 push --include-untracked → 子 apply → 父 pop 路径, 父工作树有亚秒级清空窗口(fork 同步,父 Claude idle,可接受)。 更新最危险分支说明。 Co-Authored-By: Claude Opus 4.7 --- docs/plans/plan-8-session-fork.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/plans/plan-8-session-fork.md b/docs/plans/plan-8-session-fork.md index 46ccae2..0f780ab 100644 --- a/docs/plans/plan-8-session-fork.md +++ b/docs/plans/plan-8-session-fork.md @@ -1,6 +1,6 @@ --- summary: "Session Fork:从已有话题分叉出新会话,继承对话历史、工作区状态与隐性共识" -status: draft +status: in_progress owner: lishuceo last_updated: "2026-05-27" read_when: @@ -102,18 +102,22 @@ fork 后: /root/dev/.workspaces/abc-foo-fork- (branch: feat/foo-fork- 2. **继承未提交修改**(**默认行为**,staged / unstaged / untracked 全要): ```bash cd <原工作区> - STASH_REF=$(git stash create -u) # 含 untracked,不入 stash 栈、不动工作树 - if [ -n "$STASH_REF" ]; then - cd <新工作区> - git stash apply $STASH_REF # 失败则 abort 整个 fork - fi + # 1) 把 WIP 推到 stash 栈(含 untracked),父工作树短暂变干净 + git stash push --include-untracked --quiet -m "fork-temp-" + STASH_SHA=$(git rev-parse stash@{0}) # 父无任何 WIP 时 push 不创建条目,直接跳过 + + cd <新工作区> + git stash apply $STASH_SHA # 失败则父先 pop 恢复,再 abort 整个 fork + + cd <原工作区> + git stash pop --quiet # 父恢复 ``` **关键性质**: - - `git stash create` 仅生成 stash commit object,不 push 到栈、**完全不修改父工作树** — 父话题继续工作零干扰 - - 父无任何 WIP 时 `STASH_REF` 为空字符串,跳过 apply,新 worktree 就是干净 HEAD + - **不能用 `git stash create -u`** — `create` 不接受 `-u` 选项(`-u` 被当成 stash message),无法包含 untracked。必须走 push+pop 路径 + - 父工作树有亚秒级的"清空"窗口(push 之后、pop 之前);/fork 是用户同步触发的,期间父话题 Claude idle 不写文件,可接受 + - 父无任何 WIP 时 `stash push` 不创建条目,整段跳过,新 worktree 就是干净 HEAD - 子 worktree apply 后所有改动都是 unstaged(staged/unstaged 区分会丢失);如果业务上一定要保留 index 状态,需要额外 `git diff --cached` → `git apply --cached`,P0b 暂不做 - - `-u` 含 untracked 文件(node_modules 等大产物的取舍见下文「未跟踪产物处理」) - - 备选:`rsync` 差异文件(git stash 在 submodule/特殊文件场景的兜底) + - 最危险分支:子 apply 成功但父 pop 失败 → 父 WIP 卡在 stash@{0},日志 CRITICAL 提示用户手动 `git stash pop` 恢复 **可选 `--clean` 标志**:`/fork --clean` 时跳过本步骤,新 worktree 从纯 HEAD 起步。适用于「我想用同样的对话历史但从干净代码起点重新试」。默认不带 `--clean`,因为绝大多数 fork 场景都是「在当前进行中的工作上分两条路试」,带 WIP 才符合直觉。 From b81a0f289448ff539e1058238aa15673b748eb6f Mon Sep 17 00:00:00 2001 From: lishuceo Date: Wed, 27 May 2026 18:52:23 +0800 Subject: [PATCH 5/5] fix: address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fork.ts: 更新 catch 注释,反映 push+pop 路径(原注释还说 stash create) - fork.test.ts: afterEach 用 readdir+filter 真正清理 fork 子 worktree (rmSync 不支持 glob,原 '${path}-fork-*' 是字面量被静默吞掉) Co-Authored-By: Claude Opus 4.7 --- src/session/__tests__/fork.test.ts | 11 ++++++++++- src/session/fork.ts | 5 ++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/session/__tests__/fork.test.ts b/src/session/__tests__/fork.test.ts index 908ae62..49230c6 100644 --- a/src/session/__tests__/fork.test.ts +++ b/src/session/__tests__/fork.test.ts @@ -307,7 +307,16 @@ describe('forkSession - 场景 A (worktree + WIP)', () => { execFileSync('git', ['worktree', 'prune'], { cwd: parentWorkdir }); } catch { /* ignore */ } rmSync(parentWorkdir, { recursive: true, force: true }); - rmSync(`${parentWorkdir}-fork-*`, { recursive: true, force: true }); + // rmSync 不支持 glob,必须手动 readdir + filter 清理 fork 出来的子 worktree + const tmpRoot = join(parentWorkdir, '..'); + const baseName = parentWorkdir.split('/').pop()!; + if (existsSync(tmpRoot)) { + for (const entry of readdirSync(tmpRoot)) { + if (entry.startsWith(`${baseName}-fork-`)) { + rmSync(join(tmpRoot, entry), { recursive: true, force: true }); + } + } + } // JSONL 项目目录在 ~/.claude/projects/ const projectDir = join(parentJsonlPath, '..'); if (existsSync(projectDir)) rmSync(projectDir, { recursive: true, force: true }); diff --git a/src/session/fork.ts b/src/session/fork.ts index a475869..0f17dbc 100644 --- a/src/session/fork.ts +++ b/src/session/fork.ts @@ -239,7 +239,10 @@ export async function forkSession(opts: ForkOptions): Promise