Skip to Content
SuperpowersPlansWorktree Phase C Implementation Plan

Worktree Phase C Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 为 worktree 添加会话持久化、hooksPath 初始化、Footer 状态展示和退出对话框,使 worktree 在 --resume 后可恢复,用户始终知道自己在哪个隔离环境中。

Architecture: 新增 WorktreeSession sidecar JSON 文件(与 JSONL session 文件并存),EnterWorktree 写入、ExitWorktree 清除;CLI 层通过 useWorktreeSession hook 监听文件变化并同步到 UIState.activeWorktree;Footer 读取该字段内置渲染 worktree 行;WorktreeExitDialog 在检测到活跃 worktree 时拦截第二次 Ctrl+C。

Tech Stack: TypeScript, React (Ink), Node.js fs.watch, simple-git, Vitest


文件结构

操作文件说明
新建packages/core/src/services/worktreeSessionService.tsWorktreeSession 接口 + 读写清除函数
新建packages/core/src/services/worktreeSessionService.test.ts单元测试
修改packages/core/src/services/sessionService.ts新增 getWorktreeSessionPath() 公开方法
修改packages/core/src/services/gitWorktreeService.tscreateUserWorktree() / createAgentWorktree() 后追加 core.hooksPath 配置
修改packages/core/src/services/gitWorktreeService.test.tshooksPath 测试
修改packages/core/src/tools/enter-worktree.ts创建 worktree 后写入 WorktreeSession
修改packages/core/src/tools/enter-worktree.test.tssession 写入测试
修改packages/core/src/tools/exit-worktree.ts退出 worktree 后清除 WorktreeSession
修改packages/core/src/tools/exit-worktree.test.tssession 清除测试
新建packages/cli/src/ui/hooks/useWorktreeSession.ts监听 sidecar 文件,返回当前 WorktreeSession
修改packages/cli/src/ui/contexts/UIStateContext.tsx新增 activeWorktree 字段
修改packages/cli/src/ui/AppContainer.tsx同步 activeWorktree、注入 resume 上下文、拦截退出
修改packages/cli/src/ui/hooks/useStatusLine.tsStatusLineCommandInput 新增 worktree 字段
修改packages/cli/src/ui/components/Footer.tsx内置 worktree 行展示
新建packages/cli/src/ui/components/WorktreeExitDialog.tsx退出提示对话框
新建packages/cli/src/ui/components/WorktreeExitDialog.test.tsx组件测试
修改packages/cli/src/ui/components/DialogManager.tsx注册 WorktreeExitDialog

Task 1: WorktreeSession sidecar 存储

Files:

  • Create: packages/core/src/services/worktreeSessionService.ts

  • Create: packages/core/src/services/worktreeSessionService.test.ts

  • Modify: packages/core/src/services/sessionService.ts

  • Step 1: 新建 worktreeSessionService.ts

// packages/core/src/services/worktreeSessionService.ts import * as fs from 'node:fs/promises'; import { isNodeError } from '../utils/errors.js'; export interface WorktreeSession { slug: string; worktreePath: string; worktreeBranch: string; originalCwd: string; originalBranch: string; /** HEAD commit SHA at the moment the worktree was created. Used by WorktreeExitDialog to count new commits. */ originalHeadCommit: string; } export async function readWorktreeSession( filePath: string, ): Promise<WorktreeSession | null> { try { const raw = await fs.readFile(filePath, 'utf-8'); return JSON.parse(raw) as WorktreeSession; } catch (error) { if (isNodeError(error) && error.code === 'ENOENT') return null; throw error; } } export async function writeWorktreeSession( filePath: string, session: WorktreeSession, ): Promise<void> { await fs.mkdir(require('node:path').dirname(filePath), { recursive: true }); await fs.writeFile(filePath, JSON.stringify(session, null, 2), 'utf-8'); } export async function clearWorktreeSession(filePath: string): Promise<void> { try { await fs.unlink(filePath); } catch (error) { if (isNodeError(error) && error.code === 'ENOENT') return; throw error; } }
  • Step 2: 写失败测试
// packages/core/src/services/worktreeSessionService.test.ts import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { readWorktreeSession, writeWorktreeSession, clearWorktreeSession, type WorktreeSession, } from './worktreeSessionService.js'; const sample: WorktreeSession = { slug: 'my-feature', worktreePath: '/repo/.qwen/worktrees/my-feature', worktreeBranch: 'worktree-my-feature', originalCwd: '/repo', originalBranch: 'main', }; let tmpDir: string; let filePath: string; beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-session-test-')); filePath = path.join(tmpDir, 'test.worktree.json'); }); afterEach(async () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); describe('readWorktreeSession', () => { it('returns null when file does not exist', async () => { expect(await readWorktreeSession(filePath)).toBeNull(); }); it('reads back what was written', async () => { await fs.writeFile(filePath, JSON.stringify(sample), 'utf-8'); expect(await readWorktreeSession(filePath)).toEqual(sample); }); }); describe('writeWorktreeSession', () => { it('writes a readable JSON file', async () => { await writeWorktreeSession(filePath, sample); const raw = await fs.readFile(filePath, 'utf-8'); expect(JSON.parse(raw)).toEqual(sample); }); it('overwrites existing file', async () => { await writeWorktreeSession(filePath, sample); const updated = { ...sample, slug: 'updated' }; await writeWorktreeSession(filePath, updated); expect(await readWorktreeSession(filePath)).toEqual(updated); }); }); describe('clearWorktreeSession', () => { it('deletes the file', async () => { await writeWorktreeSession(filePath, sample); await clearWorktreeSession(filePath); expect(await readWorktreeSession(filePath)).toBeNull(); }); it('is a no-op when file does not exist', async () => { await expect(clearWorktreeSession(filePath)).resolves.not.toThrow(); }); });
  • Step 3: 运行测试确认失败
cd packages/core npx vitest run src/services/worktreeSessionService.test.ts

期望:FAIL — 模块不存在。

  • Step 4: 修复 writeWorktreeSession 中的 require 调用

worktreeSessionService.ts 中用 path.dirname,需要在文件顶部引入 node:path

// 把 "require('node:path').dirname(filePath)" 替换为正确引入 import * as path from 'node:path'; export async function writeWorktreeSession( filePath: string, session: WorktreeSession, ): Promise<void> { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, JSON.stringify(session, null, 2), 'utf-8'); }
  • Step 5: 运行测试确认通过
cd packages/core npx vitest run src/services/worktreeSessionService.test.ts

期望:PASS — 6 tests passed。

  • Step 6: 在 SessionService 中新增 getWorktreeSessionPath()

packages/core/src/services/sessionService.ts 找到 private getChatsDir() 方法(约行 180),在其后添加:

getWorktreeSessionPath(sessionId: string): string { return path.join(this.getChatsDir(), `${sessionId}.worktree.json`); }
  • Step 7: 类型检查
cd packages/core npm run typecheck

期望:无错误。

  • Step 8: 提交
git add packages/core/src/services/worktreeSessionService.ts \ packages/core/src/services/worktreeSessionService.test.ts \ packages/core/src/services/sessionService.ts git commit -m "feat(worktree): add WorktreeSession sidecar storage"

Task 2: hooksPath post-creation setup

Files:

  • Modify: packages/core/src/services/gitWorktreeService.ts:1133-1158createUserWorktree

  • Modify: packages/core/src/services/gitWorktreeService.test.ts

  • Step 1: 写失败测试

gitWorktreeService.test.ts 中找到 createUserWorktree 测试组,新增:

it('configures core.hooksPath to main repo after creation', async () => { const result = await service.createUserWorktree('hooks-test'); expect(result.success).toBe(true); const worktreePath = result.worktree!.path; const worktreeGit = simpleGit(worktreePath); const hooksPath = await worktreeGit.raw([ 'config', '--local', 'core.hooksPath', ]); // Should point to the main repo's .git/hooks expect(hooksPath.trim()).toContain('.git/hooks'); });
  • Step 2: 运行测试确认失败
cd packages/core npx vitest run src/services/gitWorktreeService.test.ts -t "configures core.hooksPath"

期望:FAIL — hooksPath 为空。

  • Step 3: 在 createUserWorktree 中追加 hooksPath 配置

gitWorktreeService.ts 中找到 createUserWorktreegit worktree add 调用之后(约行 1140),在 return { success: true, worktree } 之前添加:

// Configure hooksPath so commits inside this worktree run the main // repo's hooks. Priority: .husky/ (common) → .git/hooks (fallback). // Mirrors claude-code's performPostCreationSetup() logic. try { const huskyPath = path.join(this.sourceRepoPath, '.husky'); const gitHooksPath = path.join(this.sourceRepoPath, '.git', 'hooks'); let hooksPath: string | null = null; for (const candidate of [huskyPath, gitHooksPath]) { try { await fs.stat(candidate); hooksPath = candidate; break; } catch { // Not found — try next. } } if (hooksPath) { const worktreeGit = simpleGit(worktreePath); // Skip the subprocess if core.hooksPath is already set to the same value // (~14ms spawn overhead per claude-code's comment on parseGitConfigValue). let existing = ''; try { existing = ( await worktreeGit.raw(['config', '--local', 'core.hooksPath']) ).trim(); } catch { // Key not set — empty string means "proceed". } if (existing !== hooksPath) { await worktreeGit.raw(['config', 'core.hooksPath', hooksPath]); } } } catch (hookError) { debugLogger.warn( `createUserWorktree: failed to set core.hooksPath: ${hookError}`, ); // Non-fatal: worktree is usable, just without inherited hooks. }

this.sourceRepoPathGitWorktreeService 构造函数赋值的私有字段(this.sourceRepoPath = path.resolve(sourceRepoPath),约行 224)。需要在文件顶部确认已 import * as fs from 'node:fs/promises'

  • Step 4: 对 createAgentWorktree 做相同修改

找到 createAgentWorktree 方法,在其 git worktree add 之后添加相同的 hooksPath 代码块(完整代码与 Step 3 相同,slug 来自 agent worktree 的参数)。

  • Step 5: 运行测试确认通过
cd packages/core npx vitest run src/services/gitWorktreeService.test.ts -t "configures core.hooksPath"

期望:PASS

  • Step 6: 提交
git add packages/core/src/services/gitWorktreeService.ts \ packages/core/src/services/gitWorktreeService.test.ts git commit -m "feat(worktree): configure core.hooksPath after worktree creation"

Task 3: EnterWorktreeTool 写入 WorktreeSession

Files:

  • Modify: packages/core/src/tools/enter-worktree.ts

  • Modify: packages/core/src/tools/enter-worktree.test.ts

  • Step 1: 写失败测试

enter-worktree.test.ts 的成功创建用例之后新增:

import { readWorktreeSession } from '../services/worktreeSessionService.js'; it('writes WorktreeSession sidecar after creating worktree', async () => { // Arrange: use the existing test setup that creates a real git repo // and invokes the tool (copy from existing "custom name" test) const result = await invokeTool(tool, { name: 'session-test' }); expect(result.error).toBeUndefined(); const sessionPath = config .getSessionService() .getWorktreeSessionPath(config.getSessionId()); const session = await readWorktreeSession(sessionPath); expect(session).not.toBeNull(); expect(session!.slug).toBe('session-test'); expect(session!.worktreePath).toContain('session-test'); expect(session!.worktreeBranch).toBe('worktree-session-test'); expect(session!.originalCwd).toBeTruthy(); expect(session!.originalBranch).toBeTruthy(); expect(session!.originalHeadCommit).toMatch(/^[0-9a-f]{7,40}$/); });
  • Step 2: 运行测试确认失败
cd packages/core npx vitest run src/tools/enter-worktree.test.ts -t "writes WorktreeSession"

期望:FAIL — session file is null。

  • Step 3: 修改 enter-worktree.ts,在成功创建后写入 session

enter-worktree.ts 顶部新增 import:

import { writeWorktreeSession } from '../services/worktreeSessionService.js';

execute() 方法中,获取 baseBranch 之后、createUserWorktree() 调用之前,先抓取当前 HEAD commit SHA:

// Capture HEAD before branching — WorktreeExitDialog uses this to count // new commits created inside the worktree (mirrors claude-code approach). let originalHeadCommit = ''; try { originalHeadCommit = await service.getHeadCommit(); } catch { // Non-fatal. }

同时在 GitWorktreeService 中新增公开方法(gitWorktreeService.ts,放在 getCurrentBranch() 附近):

async getHeadCommit(): Promise<string> { try { return (await this.git.raw(['rev-parse', '--short', 'HEAD'])).trim(); } catch { return ''; } }

writeWorktreeSessionMarker(...) 调用之后,新增:

// Persist worktree session so --resume can restore context. try { await writeWorktreeSession( this.config .getSessionService() .getWorktreeSessionPath(this.config.getSessionId()), { slug, worktreePath: result.worktree.path, worktreeBranch: result.worktree.branch, originalCwd: projectRoot, originalBranch: baseBranch ?? 'HEAD', originalHeadCommit, }, ); } catch (error) { debugLogger.warn(`enter_worktree: failed to write session state: ${error}`); }
  • Step 4: 运行测试确认通过
cd packages/core npx vitest run src/tools/enter-worktree.test.ts

期望:全部通过,无回归。

  • Step 5: 类型检查
cd packages/core && npm run typecheck
  • Step 6: 提交
git add packages/core/src/tools/enter-worktree.ts \ packages/core/src/tools/enter-worktree.test.ts git commit -m "feat(worktree): persist WorktreeSession in EnterWorktreeTool"

Task 4: ExitWorktreeTool 清除 WorktreeSession

Files:

  • Modify: packages/core/src/tools/exit-worktree.ts

  • Modify: packages/core/src/tools/exit-worktree.test.ts

  • Step 1: 写失败测试

exit-worktree.test.ts 新增两个用例(keep 和 remove 都应该清除 session):

import { writeWorktreeSession, readWorktreeSession, } from '../services/worktreeSessionService.js'; async function seedSession(cfg: Config, slug: string) { await writeWorktreeSession( cfg.getSessionService().getWorktreeSessionPath(cfg.getSessionId()), { slug, worktreePath: `/repo/.qwen/worktrees/${slug}`, worktreeBranch: `worktree-${slug}`, originalCwd: '/repo', originalBranch: 'main', }, ); } it('clears WorktreeSession after keep', async () => { await seedSession(config, 'exit-keep-test'); // Create the worktree first so exit_worktree can find it await config.getWorktreeService().createUserWorktree('exit-keep-test'); await invokeTool(tool, { name: 'exit-keep-test', action: 'keep' }); const sessionPath = config .getSessionService() .getWorktreeSessionPath(config.getSessionId()); expect(await readWorktreeSession(sessionPath)).toBeNull(); }); it('clears WorktreeSession after remove', async () => { await seedSession(config, 'exit-remove-test'); await config.getWorktreeService().createUserWorktree('exit-remove-test'); await invokeTool(tool, { name: 'exit-remove-test', action: 'remove' }); const sessionPath = config .getSessionService() .getWorktreeSessionPath(config.getSessionId()); expect(await readWorktreeSession(sessionPath)).toBeNull(); });
  • Step 2: 运行测试确认失败
cd packages/core npx vitest run src/tools/exit-worktree.test.ts -t "clears WorktreeSession"

期望:FAIL

  • Step 3: 修改 exit-worktree.ts

在顶部新增 import:

import { clearWorktreeSession } from '../services/worktreeSessionService.js';

找到 action === 'keep' 的返回路径(约行 184-196),在 return { llmContent: ..., returnDisplay: ... } 之前新增:

try { await clearWorktreeSession( this.config .getSessionService() .getWorktreeSessionPath(this.config.getSessionId()), ); } catch (error) { debugLogger.warn(`exit_worktree: failed to clear session state: ${error}`); }

找到 action === 'remove' 的成功返回路径(removeUserWorktree 调用之后),同样新增相同的 clearWorktreeSession 调用块。

  • Step 4: 运行测试确认通过
cd packages/core npx vitest run src/tools/exit-worktree.test.ts

期望:全部通过。

  • Step 5: 提交
git add packages/core/src/tools/exit-worktree.ts \ packages/core/src/tools/exit-worktree.test.ts git commit -m "feat(worktree): clear WorktreeSession in ExitWorktreeTool"

Task 5: useWorktreeSession hook + UIState.activeWorktree

Files:

  • Create: packages/cli/src/ui/hooks/useWorktreeSession.ts

  • Modify: packages/cli/src/ui/contexts/UIStateContext.tsx

  • Modify: packages/cli/src/ui/AppContainer.tsx

  • Step 1: 在 UIStateContext.tsx 新增 activeWorktree 字段

找到 UIState interface(约行 85),在 branchName: string | undefined; 附近新增:

activeWorktree: { slug: string; branch: string; path: string; originalCwd: string; originalBranch: string; originalHeadCommit: string; } | null;

找到 UIState 的初始值(通常在 AppContainer.tsx 的 UIState provider 处)或 createContext 的 defaultValue,添加 activeWorktree: null

  • Step 2: 新建 useWorktreeSession.ts
// packages/cli/src/ui/hooks/useWorktreeSession.ts import { useState, useEffect } from 'react'; import * as fs from 'node:fs'; import { readWorktreeSession, type WorktreeSession, } from '@qwen-code/qwen-code-core'; import { useConfig } from '../contexts/ConfigContext.js'; export function useWorktreeSession(): WorktreeSession | null { const config = useConfig(); const [session, setSession] = useState<WorktreeSession | null>(null); useEffect(() => { const sessionService = config.getSessionService(); const sessionId = config.getSessionId(); const filePath = sessionService.getWorktreeSessionPath(sessionId); let watcher: fs.FSWatcher | undefined; const load = async () => { try { const ws = await readWorktreeSession(filePath); setSession(ws); } catch { setSession(null); } }; void load(); try { watcher = fs.watch(filePath, () => void load()); } catch { // File does not exist yet — watcher set up on next write event via load() } return () => { watcher?.close(); }; }, [config]); return session; }

注意:readWorktreeSessionWorktreeSession 需要从 @qwen-code/qwen-code-core 导出,需要同时在 packages/core/src/index.ts 中新增导出:

export { readWorktreeSession, writeWorktreeSession, clearWorktreeSession, type WorktreeSession, } from './services/worktreeSessionService.js';
  • Step 3: 在 AppContainer.tsx 使用 hook 同步 activeWorktree

AppContainer.tsx 顶部新增 import:

import { useWorktreeSession } from './hooks/useWorktreeSession.js';

AppContainer 函数体内(靠近 branchName 的使用处),新增:

const worktreeSession = useWorktreeSession();

在传递给 UIStateContext.Provider 的 value 中新增:

activeWorktree: worktreeSession ? { slug: worktreeSession.slug, branch: worktreeSession.worktreeBranch, path: worktreeSession.worktreePath, originalCwd: worktreeSession.originalCwd, originalBranch: worktreeSession.originalBranch, originalHeadCommit: worktreeSession.originalHeadCommit, } : null,
  • Step 4: 类型检查
npm run typecheck

从仓库根运行(跨 workspace 检查)。期望:无错误。

  • Step 5: 提交
git add packages/core/src/services/worktreeSessionService.ts \ packages/core/src/index.ts \ packages/cli/src/ui/hooks/useWorktreeSession.ts \ packages/cli/src/ui/contexts/UIStateContext.tsx \ packages/cli/src/ui/AppContainer.tsx git commit -m "feat(worktree): add useWorktreeSession hook and UIState.activeWorktree"

Files:

  • Modify: packages/cli/src/ui/hooks/useStatusLine.ts

  • Modify: packages/cli/src/ui/components/Footer.tsx

  • Step 1: 在 useStatusLine.ts 新增 worktree 字段

找到 StatusLineCommandInput interface(约行 21),在 git?: { branch: string } 字段之后新增:

worktree?: { /** worktree slug(短名称,如 "my-feature") */ name: string; /** worktree 物理路径 */ path: string; /** git 分支名(如 "worktree-my-feature") */ branch: string; /** 进入 worktree 前的工作目录 */ original_cwd: string; /** 进入 worktree 前的分支 */ original_branch: string; };

字段名和 claude-code 保持一致,方便用户在 qwen-code 和 claude-code 之间复用 statusline 脚本。

找到 doUpdate 回调中构造 input: StatusLineCommandInput 对象的地方(约行 225),在 ...(ui.branchName && { git: { branch: ui.branchName } }) 之后新增:

...(uiStateRef.current.activeWorktree && { worktree: { name: uiStateRef.current.activeWorktree.slug, path: uiStateRef.current.activeWorktree.path, branch: uiStateRef.current.activeWorktree.branch, original_cwd: uiStateRef.current.activeWorktree.originalCwd, original_branch: uiStateRef.current.activeWorktree.originalBranch, }, }),

注意:UIState.activeWorktree 需要也包含 originalCwdoriginalBranch 字段(在 Task 5 的 AppContainer 映射中补充)。

  • Step 2: 在 Footer.tsx 新增 worktree 内置展示行

Footer.tsx 顶部引入 useUIState(已有)。

找到 statusLineLines 渲染区域(约行 140-148):

{ statusLineLines.length > 0 && !uiState.ctrlCPressedOnce && !uiState.ctrlDPressedOnce && statusLineLines.map((line, i) => ( <Text key={`status-line-${i}`} dimColor wrap="truncate"> {line} </Text> )); }

在其之前插入 worktree 行(当 activeWorktree 非空且无用户 statusline 时显示):

{ uiState.activeWorktree && !uiState.ctrlCPressedOnce && !uiState.ctrlDPressedOnce && statusLineLines.length === 0 && ( <Text dimColor wrap="truncate"> {`⎇ ${uiState.activeWorktree.branch} (${uiState.activeWorktree.slug})`} </Text> ); }
  • Step 3: 类型检查 + 构建
npm run typecheck && npm run build

期望:无错误。

  • Step 4: 提交
git add packages/cli/src/ui/hooks/useStatusLine.ts \ packages/cli/src/ui/components/Footer.tsx git commit -m "feat(worktree): show active worktree in Footer and StatusLine payload"

Task 7: —resume worktree 上下文注入

Files:

  • Modify: packages/cli/src/ui/AppContainer.tsx:459-489

  • Step 1: 在 resume 路径中注入 worktree 上下文消息

AppContainer.tsx 中找到 resume 路径(约行 459-489):

const resumedSessionData = config.getResumedSessionData(); if (resumedSessionData) { const historyItems = buildResumedHistoryItems(resumedSessionData, config); historyManager.loadHistory(historyItems); // ... }

修改为:

const resumedSessionData = config.getResumedSessionData(); if (resumedSessionData) { const historyItems = buildResumedHistoryItems(resumedSessionData, config); historyManager.loadHistory(historyItems); // If there is an active worktree session, inject a context reminder so // the model immediately knows to continue using the worktree path. const ws = await readWorktreeSession( config.getSessionService().getWorktreeSessionPath(config.getSessionId()), ); if (ws) { // Verify the worktree directory still exists before treating it as active. const worktreeAlive = await fs .stat(ws.worktreePath) .then((s) => s.isDirectory()) .catch(() => false); if (worktreeAlive) { historyManager.addItem( { type: MessageType.INFO, text: `[Resumed] Active worktree: "${ws.slug}" at ${ws.worktreePath} ` + `(branch: ${ws.worktreeBranch}). Continue using this path for all file operations.`, }, Date.now(), ); } else { // Stale sidecar — worktree was deleted externally, clean up. await clearWorktreeSession( config .getSessionService() .getWorktreeSessionPath(config.getSessionId()), ); } } // ... rest of existing resume code (background agents, session name) }

在文件顶部新增 import:

import { readWorktreeSession, clearWorktreeSession, } from '@qwen-code/qwen-code-core'; import * as fs from 'node:fs/promises';

fs 可能已经引入,检查后合并。)

  • Step 2: 类型检查
npm run typecheck
  • Step 3: 提交
git add packages/cli/src/ui/AppContainer.tsx git commit -m "feat(worktree): inject context message on --resume when worktree is active"

Task 8: WorktreeExitDialog

Files:

  • Create: packages/cli/src/ui/components/WorktreeExitDialog.tsx

  • Create: packages/cli/src/ui/components/WorktreeExitDialog.test.tsx

  • Modify: packages/cli/src/ui/components/DialogManager.tsx

  • Modify: packages/cli/src/ui/contexts/UIStateContext.tsx

  • Modify: packages/cli/src/ui/AppContainer.tsx

  • Step 1: 在 AppContainer.tsx 新增 dialog 状态

showWelcomeBackDialog 等 dialog 状态由各自的 hook 返回给 AppContainer,然后通过 UIState value 对象传入 Provider。对 WorktreeExitDialog 采用同样模式:

AppContainer.tsx 函数体内新增:

const [showWorktreeExitDialog, setShowWorktreeExitDialog] = useState(false);

在 UIState Provider 的 value 对象中新增:

showWorktreeExitDialog,

UIState interface 中新增(靠近其他 dialog 字段):

showWorktreeExitDialog: boolean;
  • Step 2: 写失败组件测试
// packages/cli/src/ui/components/WorktreeExitDialog.test.tsx import { describe, it, expect, vi } from 'vitest'; import { render } from 'ink-testing-library'; import React from 'react'; import { WorktreeExitDialog } from './WorktreeExitDialog.js'; describe('WorktreeExitDialog', () => { it('shows loading state initially', () => { const { lastFrame } = render( <WorktreeExitDialog slug="my-feature" branch="worktree-my-feature" worktreePath="/tmp/repo/.qwen/worktrees/my-feature" originalHeadCommit="abc1234" onKeep={vi.fn()} onRemove={vi.fn()} onCancel={vi.fn()} />, ); // Should show loading spinner immediately before git status resolves expect(lastFrame()).toContain('Checking'); }); it('renders slug, branch, and options after loading (no changes)', async () => { // Use vi.mock to stub execFileNoThrow / execFile so git status returns empty // and rev-list returns "0". See existing dialog tests for the mock pattern. // After async effect resolves: // - shows "my-feature" and "worktree-my-feature" // - shows Keep and Remove options // - shows "no uncommitted changes" or similar }); });
  • Step 3: 运行测试确认失败
cd packages/cli npx vitest run src/ui/components/WorktreeExitDialog.test.tsx

期望:FAIL — 模块不存在。

  • Step 4: 新建 WorktreeExitDialog.tsx

参考 WelcomeBackDialog.tsx 的 RadioSelect 模式,加入 mount 时的脏状态检查(对齐 claude-code WorktreeExitDialog.tsxloadChanges 逻辑):

// packages/cli/src/ui/components/WorktreeExitDialog.tsx import React, { useEffect, useState } from 'react'; import { Box, Text } from 'ink'; import { execa } from 'execa'; import { RadioSelect } from '../shared/RadioSelect.js'; import type { RadioSelectItem } from '../shared/RadioSelect.js'; import { theme } from '../semantic-colors.js'; interface WorktreeExitDialogProps { slug: string; branch: string; worktreePath: string; originalHeadCommit: string; onKeep: () => void; onRemove: () => void; onCancel: () => void; } type Choice = 'keep' | 'remove' | 'cancel'; export const WorktreeExitDialog: React.FC<WorktreeExitDialogProps> = ({ slug, branch, worktreePath, originalHeadCommit, onKeep, onRemove, onCancel, }) => { const [loading, setLoading] = useState(true); const [changedFiles, setChangedFiles] = useState<string[]>([]); const [commitCount, setCommitCount] = useState(0); const [selected, setSelected] = useState<Choice>('keep'); useEffect(() => { async function loadDirtyState() { try { // Uncommitted changes (tracked + untracked) const { stdout: statusOut } = await execa( 'git', ['status', '--porcelain'], { cwd: worktreePath }, ); const files = statusOut.split('\n').filter((l) => l.trim().length > 0); setChangedFiles(files); // New commits since worktree was created if (originalHeadCommit) { const { stdout: countOut } = await execa( 'git', ['rev-list', '--count', `${originalHeadCommit}..HEAD`], { cwd: worktreePath }, ); setCommitCount(parseInt(countOut.trim(), 10) || 0); } } catch { // If git fails, show dialog without counts. } finally { setLoading(false); } } void loadDirtyState(); }, [worktreePath, originalHeadCommit]); const options: Array<RadioSelectItem<Choice>> = [ { key: 'keep', label: 'Keep worktree (exit without deleting)', value: 'keep', }, { key: 'remove', label: changedFiles.length > 0 || commitCount > 0 ? `Remove worktree and branch (discards ${commitCount} commit(s), ${changedFiles.length} file(s))` : 'Remove worktree and branch', value: 'remove', }, { key: 'cancel', label: 'Cancel (stay in session)', value: 'cancel' }, ]; if (loading) { return ( <Box marginY={1} paddingX={2}> <Text color={theme.text.secondary}>Checking worktree status…</Text> </Box> ); } return ( <Box flexDirection="column" marginY={1} paddingX={2}> <Text color={theme.status.warning}> {`Active worktree: "${slug}" (${branch})`} </Text> {(changedFiles.length > 0 || commitCount > 0) && ( <Box flexDirection="column" marginBottom={1}> {commitCount > 0 && ( <Text color={theme.text.secondary}> {` ${commitCount} new commit(s) on ${branch}`} </Text> )} {changedFiles.length > 0 && ( <Text color={theme.text.secondary}> {` ${changedFiles.length} uncommitted file(s)`} </Text> )} </Box> )} <Text color={theme.text.secondary}>What would you like to do?</Text> <RadioSelect items={options} selectedValue={selected} onSelect={(value) => { if (value === 'keep') onKeep(); else if (value === 'remove') onRemove(); else onCancel(); }} onChange={setSelected} /> </Box> ); };

注意:execa 是项目已有依赖(或用 execFileNoThrow,参考 claude-code 的方式)。检查 packages/cli/package.json 确认可用的 exec 工具;如果无 execa,改用 Node.js 内置 execFile 包装。

  • Step 5: 运行测试确认通过
cd packages/cli npx vitest run src/ui/components/WorktreeExitDialog.test.tsx

期望:loading 状态测试通过。

  • Step 6: 在 DialogManager.tsx 注册

找到 DialogManager 中最后一个 dialog 渲染块,新增:

import { WorktreeExitDialog } from './WorktreeExitDialog.js'; // 在 DialogManager 返回的 JSX 中,在最后一个 dialog 之后添加: { uiState.showWorktreeExitDialog && uiState.activeWorktree && ( <WorktreeExitDialog slug={uiState.activeWorktree.slug} branch={uiState.activeWorktree.branch} worktreePath={uiState.activeWorktree.path} originalHeadCommit={uiState.activeWorktree.originalHeadCommit} onKeep={() => { setShowWorktreeExitDialog(false); handleSlashCommand('/quit'); }} onRemove={async () => { setShowWorktreeExitDialog(false); // Remove the worktree directly via service (no tool call needed). try { const svc = new GitWorktreeService(config.getTargetDir()); await svc.removeUserWorktree(uiState.activeWorktree!.slug, { deleteBranch: true, }); await clearWorktreeSession( config .getSessionService() .getWorktreeSessionPath(config.getSessionId()), ); } catch { // Non-fatal — exit anyway. } handleSlashCommand('/quit'); }} onCancel={() => { setShowWorktreeExitDialog(false); }} /> ); }

setShowWorktreeExitDialog 来自 Step 1 在 AppContainer 中定义的 useState,需要通过 props 或直接在 DialogManager 的调用处传入(参考其他 dialog 的传参模式)。

  • Step 7: 在 AppContainer.tsx 拦截第二次 Ctrl+C

handleExit 回调(约行 2387)中,找到 pressedOncetrue 时调用 handleSlashCommand('/quit') 的分支:

// Fast double-press: Direct quit (preserve user habit) if (pressedOnce) { if (timerRef.current) { clearTimeout(timerRef.current); } // Exit directly handleSlashCommand('/quit'); return; }

修改为:

if (pressedOnce) { if (timerRef.current) { clearTimeout(timerRef.current); } // If inside a worktree, show the exit dialog instead of quitting directly. if (worktreeSession) { setShowWorktreeExitDialog(true); return; } handleSlashCommand('/quit'); return; }

worktreeSession 是 Step 1 中 useWorktreeSession() 的返回值(已在 AppContainer 函数体内)。将其加入 handleExituseCallback 依赖数组。setShowWorktreeExitDialog 来自 Step 1 的 useState。

  • Step 9: 类型检查 + 全量测试
npm run typecheck cd packages/core && npx vitest run cd packages/cli && npx vitest run

期望:全部通过,无回归。

  • Step 10: 构建
npm run build && npm run bundle

期望:dist/cli.js 生成无报错。

  • Step 11: 提交
git add packages/cli/src/ui/components/WorktreeExitDialog.tsx \ packages/cli/src/ui/components/WorktreeExitDialog.test.tsx \ packages/cli/src/ui/components/DialogManager.tsx \ packages/cli/src/ui/contexts/UIStateContext.tsx \ packages/cli/src/ui/AppContainer.tsx git commit -m "feat(worktree): add WorktreeExitDialog — intercept Ctrl+C when worktree is active"

验收标准

场景预期行为
enter_worktree 调用后<sessionId>.worktree.json 存在,内含 slug / path / branch
exit_worktree 调用后<sessionId>.worktree.json 被删除
--resume 时 worktree 仍存在Footer 显示 worktree 行;INFO 消息提示路径
--resume 时 worktree 已删除sidecar 文件被清理,无 worktree 行展示
worktree 内第一次 Ctrl+C显示 “Press Ctrl+C again to exit.”
worktree 内第二次 Ctrl+C显示 WorktreeExitDialog(keep / remove / cancel)
非 worktree 环境第二次 Ctrl+C直接退出(行为不变)
新建 worktree 内提交core.hooksPath 指向主仓库 hooks,pre-commit 正常触发
statusline 脚本 stdinJSON payload 含 worktree.slugworktree.branch
Last updated on