Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 需要)
Expand Down
24 changes: 14 additions & 10 deletions docs/plans/plan-8-session-fork.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
summary: "Session Fork:从已有话题分叉出新会话,继承对话历史、工作区状态与隐性共识"
status: draft
status: in_progress
owner: lishuceo
last_updated: "2026-05-27"
read_when:
Expand Down Expand Up @@ -102,18 +102,22 @@ fork 后: /root/dev/.workspaces/abc-foo-fork-<id> (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-<shortId>"
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 才符合直觉。

Expand Down
9 changes: 2 additions & 7 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},

// 服务配置
Expand Down
22 changes: 10 additions & 12 deletions src/feishu/event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)';
Expand All @@ -1302,31 +1302,29 @@ 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)';
await feishuClient.replyText(messageId, reply);
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,
userId,
triggerMessageId: messageId,
description,
agentId,
clean,
});

if (!result.ok) {
Expand Down
202 changes: 185 additions & 17 deletions src/session/__tests__/fork.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -262,3 +247,186 @@ 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 不支持 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/<encoded-parent-workdir>
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([]);
});
});
Loading
Loading