clawt 3.10.5 → 3.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/AGENTS.md +16 -0
  2. package/dist/index.js +163 -89
  3. package/docs/create.md +1 -0
  4. package/docs/list.md +21 -10
  5. package/docs/merge.md +1 -0
  6. package/docs/remove.md +2 -0
  7. package/docs/spec.md +4 -1
  8. package/docs/status.md +9 -1
  9. package/docs/superpowers/findings/2026-06-09-worktree-base-branch-findings.md +58 -0
  10. package/docs/superpowers/plans/2026-06-09-worktree-base-branch.md +386 -0
  11. package/docs/superpowers/specs/2026-06-09-worktree-base-branch-design.md +169 -0
  12. package/package.json +1 -1
  13. package/src/commands/list.ts +5 -3
  14. package/src/commands/merge.ts +1 -1
  15. package/src/commands/remove.ts +3 -0
  16. package/src/commands/status.ts +5 -0
  17. package/src/types/status.ts +2 -0
  18. package/src/types/worktree.ts +12 -0
  19. package/src/utils/formatter.ts +22 -0
  20. package/src/utils/index.ts +2 -1
  21. package/src/utils/interactive-panel-render.ts +6 -3
  22. package/src/utils/worktree-metadata.ts +82 -0
  23. package/src/utils/worktree.ts +29 -10
  24. package/tests/helpers/fixtures.ts +1 -0
  25. package/tests/unit/commands/cover-validate.test.ts +4 -4
  26. package/tests/unit/commands/create.test.ts +3 -3
  27. package/tests/unit/commands/list.test.ts +66 -3
  28. package/tests/unit/commands/merge.test.ts +1 -1
  29. package/tests/unit/commands/remove.test.ts +24 -18
  30. package/tests/unit/commands/resume.test.ts +21 -21
  31. package/tests/unit/commands/run.test.ts +17 -17
  32. package/tests/unit/commands/status.test.ts +85 -10
  33. package/tests/unit/commands/sync.test.ts +4 -4
  34. package/tests/unit/commands/validate.test.ts +1 -1
  35. package/tests/unit/utils/interactive-panel-render.test.ts +124 -0
  36. package/tests/unit/utils/worktree-matcher.test.ts +2 -2
  37. package/tests/unit/utils/worktree-metadata.test.ts +91 -0
  38. package/tests/unit/utils/worktree.test.ts +65 -0
@@ -0,0 +1,169 @@
1
+ # Worktree Base Branch Design
2
+
3
+ ## 背景
4
+
5
+ 当前 `clawt status`、`clawt status -i` 和 `clawt list` 会把同一项目下的 worktree 展示在一起,但不会标明这些 worktree 分支最初基于哪个分支创建。当用户同时从 `master`、`test` 等多个分支创建 worktree 时,容易把基于 `test` 的 worktree 误合并到 `master`,风险较高。
6
+
7
+ ## 目标
8
+
9
+ - 在 `clawt create` 创建 worktree 时记录创建瞬间的真实当前分支,作为该 worktree 的来源分支。
10
+ - 在 `clawt status`、`clawt status -i`、`clawt list` 中展示来源分支。
11
+ - JSON 输出包含稳定字段,便于脚本消费。
12
+ - 历史 worktree 没有元数据时明确展示未记录,不通过不可靠 git 推断伪造结果。
13
+
14
+ ## 非目标
15
+
16
+ - 不新增历史回填命令。
17
+ - 不修改 `clawt merge` 的合并目标逻辑。
18
+ - 不改变 `clawt init` 的项目主工作分支配置语义。
19
+ - 不把 worktree 元数据写入 `~/.clawt/projects/<projectName>/config.json`。
20
+
21
+ ## 来源分支语义
22
+
23
+ 来源分支定义为执行 `clawt create` 时,创建 worktree 前主 worktree 当前所在的真实分支。示例:用户当时位于 `test` 分支并创建 `feature-login`,则 `feature-login` 的 `baseBranch` 为 `test`。
24
+
25
+ 该语义优先于项目配置中的 `clawtMainWorkBranch`。如果现有前置检查在创建前切换了分支,则记录切换后的真实当前分支,因为它才是实际执行 `git worktree add -b` 的基准。
26
+
27
+ ## 存储设计
28
+
29
+ 每个 worktree 分支使用一个独立 JSON 文件:
30
+
31
+ ```text
32
+ ~/.clawt/projects/<projectName>/worktrees/<branchName>.json
33
+ ```
34
+
35
+ 文件内容:
36
+
37
+ ```json
38
+ {
39
+ "branch": "feature-login",
40
+ "baseBranch": "test",
41
+ "createdAt": "2026-06-09T10:30:00.000Z"
42
+ }
43
+ ```
44
+
45
+ 字段说明:
46
+
47
+ | 字段 | 类型 | 说明 |
48
+ |------|------|------|
49
+ | `branch` | `string` | worktree 对应分支名 |
50
+ | `baseBranch` | `string` | 创建 worktree 时所在的真实当前分支 |
51
+ | `createdAt` | `string` | 元数据写入时间,ISO 8601 字符串 |
52
+
53
+ 元数据属于项目级动态数据,应放在 `~/.clawt/projects/<projectName>/worktrees/` 下。项目配置 `config.json` 继续只保存配置项,避免运行元数据污染配置文件。
54
+
55
+ ## 代码结构
56
+
57
+ 新增 `src/utils/worktree-metadata.ts`,职责限定为 worktree 元数据路径、读写、删除:
58
+
59
+ - `getWorktreeMetadataPath(projectName, branchName)`:生成单个分支元数据路径。
60
+ - `saveWorktreeMetadata(projectName, metadata)`:保存来源分支元数据。
61
+ - `loadWorktreeMetadata(projectName, branchName)`:读取元数据,缺失或解析失败时返回 `null`。
62
+ - `removeWorktreeMetadata(projectName, branchName)`:删除元数据,文件不存在时不报错。
63
+
64
+ 新增类型 `WorktreeMetadata`,包含 `branch`、`baseBranch`、`createdAt`。现有 `WorktreeInfo` 和 `WorktreeDetailedStatus` 增加 `baseBranch: string | null`。
65
+
66
+ ## 数据流
67
+
68
+ 创建流程:
69
+
70
+ 1. `clawt create` 完成前置检查。
71
+ 2. `createWorktrees()` 在创建前读取 `getCurrentBranch()`。
72
+ 3. 每成功创建一个 worktree 和 validate 分支后,写入对应 metadata 文件。
73
+ 4. 命令输出继续展示目录、分支、验证分支;是否在创建输出中展示来源分支由实现保持简洁,可不新增。
74
+
75
+ 读取流程:
76
+
77
+ 1. `getProjectWorktrees()` 扫描 `~/.clawt/worktrees/<projectName>/` 并与 `git worktree list` 交叉验证。
78
+ 2. 对每个有效 worktree 读取 `~/.clawt/projects/<projectName>/worktrees/<branch>.json`。
79
+ 3. 返回 `WorktreeInfo` 时带上 `baseBranch`,没有元数据时为 `null`。
80
+ 4. `status` 的收集层把 `baseBranch` 传入 `WorktreeDetailedStatus`。
81
+ 5. `list`、`status` 文本渲染和交互面板只负责展示已有字段。
82
+
83
+ ## 展示规则
84
+
85
+ `clawt status` 普通文本输出中,每个 worktree 条目增加一行:
86
+
87
+ ```text
88
+ 来源分支: test
89
+ ```
90
+
91
+ 英文语言环境显示:
92
+
93
+ ```text
94
+ Base branch: test
95
+ ```
96
+
97
+ 没有元数据时显示:
98
+
99
+ ```text
100
+ 来源分支: 未记录
101
+ ```
102
+
103
+ 英文语言环境显示:
104
+
105
+ ```text
106
+ Base branch: Not recorded
107
+ ```
108
+
109
+ `clawt status -i` 交互面板在每个 worktree 块中同样显示来源分支行。
110
+
111
+ `clawt list` 文本输出在路径与分支行中追加来源分支:
112
+
113
+ ```text
114
+ ~/.clawt/worktrees/project/feature-login [feature-login] <- test
115
+ ```
116
+
117
+ 没有元数据时:
118
+
119
+ ```text
120
+ ~/.clawt/worktrees/project/feature-login [feature-login] <- 未记录
121
+ ```
122
+
123
+ JSON 输出新增 `baseBranch` 字段:
124
+
125
+ ```json
126
+ {
127
+ "path": "~/.clawt/worktrees/project/feature-login",
128
+ "branch": "feature-login",
129
+ "baseBranch": "test"
130
+ }
131
+ ```
132
+
133
+ 历史 worktree 无元数据时:
134
+
135
+ ```json
136
+ {
137
+ "path": "~/.clawt/worktrees/project/legacy",
138
+ "branch": "legacy",
139
+ "baseBranch": null
140
+ }
141
+ ```
142
+
143
+ ## 清理规则
144
+
145
+ `cleanupWorktrees()` 删除 worktree、普通分支和 validate 分支时,同步删除对应 metadata 文件。删除 metadata 失败不阻断主清理流程,只记录日志。
146
+
147
+ ## 错误处理
148
+
149
+ - 元数据文件不存在:返回 `null`,展示未记录。
150
+ - 元数据 JSON 解析失败:记录 warning,返回 `null`。
151
+ - 保存 metadata 失败:记录错误并抛出,让创建流程暴露问题,因为创建后无法记录来源会削弱防误操作能力。
152
+ - 删除 metadata 失败:记录错误,不影响删除 worktree 的主流程。
153
+
154
+ ## 测试要求
155
+
156
+ - `createWorktrees()` 和 `createWorktreesByBranches()` 创建成功后写入 `baseBranch`。
157
+ - `getProjectWorktrees()` 能读取已有 `baseBranch`,缺失时返回 `null`。
158
+ - `cleanupWorktrees()` 删除对应 metadata。
159
+ - `clawt list --json` 输出 `baseBranch`。
160
+ - `clawt status --json` 输出 `baseBranch`。
161
+ - `status` 文本输出和 `status -i` 渲染显示来源分支。
162
+ - 元数据解析失败不导致 status/list 崩溃。
163
+
164
+ ## 验收标准
165
+
166
+ - 新创建的 worktree 在 `status`、`status -i`、`list` 中能看到来源分支。
167
+ - 旧 worktree 没有 metadata 时显示未记录,JSON 为 `null`。
168
+ - 删除 worktree 时对应 metadata 文件被清理。
169
+ - 现有 `status`、`list`、`create` 单元测试通过,新增测试覆盖来源分支功能。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "3.10.5",
3
+ "version": "3.10.6",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -11,9 +11,10 @@ import {
11
11
  formatWorktreeStatus,
12
12
  isWorktreeIdle,
13
13
  printInfo,
14
+ formatBaseBranchInline,
14
15
  } from '../utils/index.js';
15
16
  import { getCurrentLanguage } from '../utils/i18n.js';
16
- // getWorktreeStatus 和 formatWorktreeStatus 仅在文本模式下使用
17
+ // getWorktreeStatus、formatWorktreeStatusformatBaseBranchInline 仅在文本模式下使用
17
18
 
18
19
  /**
19
20
  * 注册 list 命令:列出当前项目所有 worktree
@@ -50,7 +51,7 @@ async function handleList(options: ListOptions): Promise<void> {
50
51
  }
51
52
 
52
53
  /**
53
- * 以 JSON 格式输出 worktree 列表(仅包含 path 和 branch
54
+ * 以 JSON 格式输出 worktree 列表(包含 path、branchbaseBranch
54
55
  * @param {string} projectName - 项目名称
55
56
  * @param {import('../types/index.js').WorktreeInfo[]} worktrees - worktree 列表
56
57
  */
@@ -61,6 +62,7 @@ function printListAsJson(projectName: string, worktrees: import('../types/index.
61
62
  worktrees: worktrees.map((wt) => ({
62
63
  path: wt.path,
63
64
  branch: wt.branch,
65
+ baseBranch: wt.baseBranch,
64
66
  })),
65
67
  };
66
68
 
@@ -87,7 +89,7 @@ function printListAsText(projectName: string, worktrees: import('../types/index.
87
89
  const isIdle = status ? isWorktreeIdle(status) : false;
88
90
  const pathDisplay = isIdle ? chalk.hex('#FF8C00')(wt.path) : wt.path;
89
91
 
90
- printInfo(` ${pathDisplay} [${wt.branch}]`);
92
+ printInfo(` ${pathDisplay} [${wt.branch}] ${formatBaseBranchInline(wt.baseBranch)}`);
91
93
 
92
94
  if (status) {
93
95
  printInfo(` ${formatWorktreeStatus(status)}`);
@@ -155,7 +155,7 @@ async function shouldCleanupAfterMerge(branchName: string): Promise<boolean> {
155
155
  * @param {string} branchName - 分支名
156
156
  */
157
157
  function cleanupWorktreeAndBranch(worktreePath: string, branchName: string): void {
158
- cleanupWorktrees([{ path: worktreePath, branch: branchName }]);
158
+ cleanupWorktrees([{ path: worktreePath, branch: branchName, baseBranch: null }]);
159
159
  printSuccess(MESSAGES.WORKTREE_CLEANED(branchName));
160
160
  }
161
161
 
@@ -24,6 +24,7 @@ import {
24
24
  getValidateBranchName,
25
25
  deleteValidateBranch,
26
26
  getCurrentBranch,
27
+ removeWorktreeMetadata,
27
28
  } from '../utils/index.js';
28
29
  import type { WorktreeMultiResolveMessages } from '../utils/index.js';
29
30
  import { getCurrentLanguage } from '../utils/i18n.js';
@@ -120,6 +121,8 @@ async function handleRemove(options: RemoveOptions): Promise<void> {
120
121
  deleteValidateBranch(wt.branch);
121
122
  // 清理该分支对应的 validate 快照
122
123
  removeSnapshot(projectName, wt.branch);
124
+ // 清理该分支的来源分支元数据
125
+ removeWorktreeMetadata(projectName, wt.branch);
123
126
  printSuccess(MESSAGES.WORKTREE_REMOVED(wt.path));
124
127
  } catch (error) {
125
128
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -23,6 +23,7 @@ import {
23
23
  InteractivePanel,
24
24
  loadProjectConfig,
25
25
  checkBranchExists,
26
+ formatBaseBranchLine,
26
27
  } from '../utils/index.js';
27
28
  import { getCurrentLanguage } from '../utils/i18n.js';
28
29
 
@@ -141,6 +142,7 @@ async function collectWorktreeDetailedStatusAsync(worktree: WorktreeInfo, projec
141
142
  insertions: diffStat.insertions,
142
143
  deletions: diffStat.deletions,
143
144
  createdAt,
145
+ baseBranch: worktree.baseBranch,
144
146
  };
145
147
  }
146
148
 
@@ -362,6 +364,9 @@ function printWorktreeItem(wt: WorktreeDetailedStatus): void {
362
364
  printInfo(` ${chalk.green(lang === 'en' ? 'In sync with main' : '与主分支同步')}`);
363
365
  }
364
366
 
367
+ // 来源分支
368
+ printInfo(` ${chalk.gray(formatBaseBranchLine(wt.baseBranch))}`);
369
+
365
370
  // 分支创建时间
366
371
  if (wt.createdAt) {
367
372
  const relativeTime = formatRelativeTime(wt.createdAt);
@@ -18,6 +18,8 @@ export interface WorktreeDetailedStatus {
18
18
  deletions: number;
19
19
  /** 分支创建时间(首次分叉提交的 ISO 8601 时间字符串),无分叉提交时为 null */
20
20
  createdAt: string | null;
21
+ /** 创建 worktree 时所在的来源分支,无元数据时为 null */
22
+ baseBranch: string | null;
21
23
  }
22
24
 
23
25
  /** 主 worktree 状态信息 */
@@ -1,9 +1,21 @@
1
+ /** worktree 来源分支元数据 */
2
+ export interface WorktreeMetadata {
3
+ /** worktree 分支名 */
4
+ branch: string;
5
+ /** 创建 worktree 时所在的真实当前分支 */
6
+ baseBranch: string;
7
+ /** 元数据创建时间 */
8
+ createdAt: string;
9
+ }
10
+
1
11
  /** worktree 信息 */
2
12
  export interface WorktreeInfo {
3
13
  /** worktree 路径 */
4
14
  path: string;
5
15
  /** 分支名 */
6
16
  branch: string;
17
+ /** 创建 worktree 时所在的来源分支,无元数据时为 undefined 或 null */
18
+ baseBranch?: string | null;
7
19
  }
8
20
 
9
21
  /** worktree 变更统计信息 */
@@ -245,6 +245,28 @@ export function formatLocalISOString(date: Date): string {
245
245
  return `${iso}${sign}${hours}:${minutes}`;
246
246
  }
247
247
 
248
+ /**
249
+ * 格式化来源分支展示行(用于 status 文本输出和交互面板)
250
+ * @param {string | null | undefined} baseBranch - 来源分支
251
+ * @returns {string} 格式化的来源分支文本,如 "来源分支: test" 或 "来源分支: 未记录"
252
+ */
253
+ export function formatBaseBranchLine(baseBranch: string | null | undefined): string {
254
+ const lang = getCurrentLanguage();
255
+ const label = lang === 'en' ? 'Base branch' : '来源分支';
256
+ const fallback = lang === 'en' ? 'Not recorded' : '未记录';
257
+ return `${label}: ${baseBranch ?? fallback}`;
258
+ }
259
+
260
+ /**
261
+ * 格式化来源分支内联展示(用于 list 文本输出)
262
+ * @param {string | null | undefined} baseBranch - 来源分支
263
+ * @returns {string} 格式化的来源分支内联文本,如 "<- test" 或 "<- 未记录"
264
+ */
265
+ export function formatBaseBranchInline(baseBranch: string | null | undefined): string {
266
+ const fallback = getCurrentLanguage() === 'en' ? 'Not recorded' : '未记录';
267
+ return `<- ${baseBranch ?? fallback}`;
268
+ }
269
+
248
270
  /**
249
271
  * 生成任务模板文件名,格式:clawt-tasks-YYYY-MM-DD-HH-mm-ss.md
250
272
  * @param {string} prefix - 文件名前缀
@@ -70,12 +70,13 @@ export { validateMainWorktree, validateGitInstalled, validateClaudeCodeInstalled
70
70
  export type { PreCheckOptions } from './validation.js';
71
71
  export { createWorktrees, getProjectWorktrees, getProjectWorktreeDir, cleanupWorktrees, getWorktreeStatus, createWorktreesByBranches } from './worktree.js';
72
72
  export { loadConfig, writeDefaultConfig, writeConfig, saveConfig, getConfigValue, ensureClawtDirs, parseConcurrency } from './config.js';
73
- export { printSuccess, printError, printWarning, printInfo, printHint, printSeparator, printDoubleSeparator, confirmAction, confirmDestructiveAction, formatWorktreeStatus, isWorktreeIdle, formatDuration, formatRelativeTime, formatDiskSize, formatLocalISOString, generateTaskFilename } from './formatter.js';
73
+ export { printSuccess, printError, printWarning, printInfo, printHint, printSeparator, printDoubleSeparator, confirmAction, confirmDestructiveAction, formatWorktreeStatus, isWorktreeIdle, formatDuration, formatRelativeTime, formatDiskSize, formatLocalISOString, generateTaskFilename, formatBaseBranchLine, formatBaseBranchInline } from './formatter.js';
74
74
  export { ensureDir, removeEmptyDir, calculateDirSize } from './fs.js';
75
75
  export { removeExternalSymlinks } from './symlink-guard.js';
76
76
  export { multilineInput, promptCommitMessage } from './prompt.js';
77
77
  export { launchInteractiveClaude, hasClaudeSessionHistory, launchInteractiveClaudeInNewTerminal } from './claude.js';
78
78
  export { getSnapshotPath, hasSnapshot, getSnapshotModifiedTime, readSnapshotTreeHash, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots, getProjectSnapshotBranches } from './validate-snapshot.js';
79
+ export { removeWorktreeMetadata } from './worktree-metadata.js';
79
80
  export { findExactMatch, findFuzzyMatches, promptGroupedMultiSelectBranches, resolveTargetWorktree, resolveTargetWorktrees, groupWorktreesByDate, buildGroupedChoices, buildGroupMembershipMap, formatRelativeDate, getWorktreeCreatedDate, getWorktreeCreatedTime } from './worktree-matcher.js';
80
81
  export type { WorktreeResolveMessages, WorktreeMultiResolveMessages } from './worktree-matcher.js';
81
82
  export { ProgressRenderer } from './progress.js';
@@ -30,7 +30,7 @@ import {
30
30
  PANEL_COMMITS_BEHIND,
31
31
  } from '../constants/messages/index.js';
32
32
  import type { StatusResult, WorktreeDetailedStatus, MainWorktreeStatus } from '../types/index.js';
33
- import { formatRelativeTime, groupWorktreesByDate, formatRelativeDate } from './index.js';
33
+ import { formatRelativeTime, groupWorktreesByDate, formatRelativeDate, formatBaseBranchLine } from './index.js';
34
34
 
35
35
  /** 面板行类型 */
36
36
  export interface PanelLine {
@@ -136,7 +136,7 @@ export function buildPanelFrame(
136
136
  * @returns {number[]} 按显示顺序排列的原始索引数组
137
137
  */
138
138
  export function buildDisplayOrder(worktrees: WorktreeDetailedStatus[]): number[] {
139
- const worktreeInfos = worktrees.map((wt) => ({ path: wt.path, branch: wt.branch }));
139
+ const worktreeInfos = worktrees.map((wt) => ({ path: wt.path, branch: wt.branch, baseBranch: wt.baseBranch }));
140
140
  const groups = groupWorktreesByDate(worktreeInfos);
141
141
 
142
142
  // 构建分支名到原始索引的映射
@@ -164,7 +164,7 @@ export function buildGroupedWorktreeLines(worktrees: WorktreeDetailedStatus[], s
164
164
  const panelLines: PanelLine[] = [];
165
165
 
166
166
  // 构建临时 WorktreeInfo 兼容结构用于分组(groupWorktreesByDate 需要 path 字段)
167
- const worktreeInfos = worktrees.map((wt) => ({ path: wt.path, branch: wt.branch }));
167
+ const worktreeInfos = worktrees.map((wt) => ({ path: wt.path, branch: wt.branch, baseBranch: wt.baseBranch }));
168
168
  const groups = groupWorktreesByDate(worktreeInfos);
169
169
 
170
170
  // 构建分支名到原始索引的映射
@@ -259,6 +259,9 @@ export function renderWorktreeBlock(wt: WorktreeDetailedStatus, isSelected: bool
259
259
  lines.push(`${indent}${chalk.green(PANEL_SYNCED_WITH_MAIN)}`);
260
260
  }
261
261
 
262
+ // 来源分支
263
+ lines.push(`${indent}${chalk.gray(formatBaseBranchLine(wt.baseBranch))}`);
264
+
262
265
  // 分支创建时间
263
266
  if (wt.createdAt) {
264
267
  const relativeTime = formatRelativeTime(wt.createdAt);
@@ -0,0 +1,82 @@
1
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { PROJECTS_CONFIG_DIR } from '../constants/index.js';
4
+ import { safeStringify } from './json.js';
5
+ import { ensureDir } from './fs.js';
6
+ import { logger } from '../logger/index.js';
7
+ import type { WorktreeMetadata } from '../types/worktree.js';
8
+
9
+ /**
10
+ * 获取 worktree 元数据文件路径
11
+ * @param {string} projectName - 项目名
12
+ * @param {string} branchName - 分支名
13
+ * @returns {string} 元数据文件路径
14
+ */
15
+ export function getWorktreeMetadataPath(projectName: string, branchName: string): string {
16
+ return join(PROJECTS_CONFIG_DIR, projectName, 'worktrees', `${branchName}.json`);
17
+ }
18
+
19
+ /**
20
+ * 保存 worktree 来源分支元数据
21
+ * @param {string} projectName - 项目名
22
+ * @param {WorktreeMetadata} metadata - 元数据
23
+ */
24
+ export function saveWorktreeMetadata(projectName: string, metadata: WorktreeMetadata): void {
25
+ const metadataPath = getWorktreeMetadataPath(projectName, metadata.branch);
26
+ const metadataDir = dirname(metadataPath);
27
+
28
+ try {
29
+ ensureDir(metadataDir);
30
+ writeFileSync(metadataPath, safeStringify(metadata), 'utf-8');
31
+ } catch (error) {
32
+ logger.error(`保存 worktree 元数据失败: ${metadataPath}`, error);
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * 读取 worktree 来源分支元数据
39
+ * @param {string} projectName - 项目名
40
+ * @param {string} branchName - 分支名
41
+ * @returns {WorktreeMetadata | null} 元数据,不存在或解析失败时返回 null
42
+ */
43
+ export function loadWorktreeMetadata(projectName: string, branchName: string): WorktreeMetadata | null {
44
+ const metadataPath = getWorktreeMetadataPath(projectName, branchName);
45
+
46
+ if (!existsSync(metadataPath)) {
47
+ return null;
48
+ }
49
+
50
+ try {
51
+ const content = readFileSync(metadataPath, 'utf-8');
52
+ const parsed = JSON.parse(content);
53
+ // 校验必要字段,防止不安全的类型断言
54
+ if (!parsed || typeof parsed !== 'object' || !parsed.branch || !parsed.baseBranch) {
55
+ logger.warn(`worktree 元数据格式无效: ${metadataPath}`);
56
+ return null;
57
+ }
58
+ return parsed as WorktreeMetadata;
59
+ } catch (error) {
60
+ logger.warn(`解析 worktree 元数据失败: ${metadataPath}`, error);
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * 删除 worktree 来源分支元数据
67
+ *
68
+ * 删除失败时仅记录日志,不抛出异常(best-effort 语义)。
69
+ * @param {string} projectName - 项目名
70
+ * @param {string} branchName - 分支名
71
+ */
72
+ export function removeWorktreeMetadata(projectName: string, branchName: string): void {
73
+ const metadataPath = getWorktreeMetadataPath(projectName, branchName);
74
+
75
+ try {
76
+ if (existsSync(metadataPath)) {
77
+ rmSync(metadataPath);
78
+ }
79
+ } catch (error) {
80
+ logger.error(`删除 worktree 元数据失败: ${metadataPath}`, error);
81
+ }
82
+ }
@@ -6,6 +6,8 @@ import { createWorktree as gitCreateWorktree, getProjectName, gitWorktreeList, r
6
6
  import { sanitizeBranchName, generateBranchNames, validateBranchesNotExist } from './branch.js';
7
7
  import { ensureDir, removeEmptyDir } from './fs.js';
8
8
  import { createValidateBranch, deleteValidateBranch } from './validate-branch.js';
9
+ import { getCurrentBranch } from './git-branch.js';
10
+ import { saveWorktreeMetadata, loadWorktreeMetadata, removeWorktreeMetadata } from './worktree-metadata.js';
9
11
  import type { WorktreeInfo, WorktreeStatus } from '../types/index.js';
10
12
 
11
13
  /**
@@ -34,17 +36,22 @@ export function createWorktrees(branchName: string, count: number): WorktreeInfo
34
36
  // 3. 校验所有分支是否都不存在(在创建任何 worktree 之前)
35
37
  validateBranchesNotExist(branchNames);
36
38
 
37
- // 4. 确保项目 worktree 目录存在
38
- const projectDir = getProjectWorktreeDir();
39
+ // 4. 获取项目名并确保 worktree 目录存在
40
+ const projectName = getProjectName();
41
+ const projectDir = join(WORKTREES_DIR, projectName);
39
42
  ensureDir(projectDir);
40
43
 
41
- // 5. 串行创建 worktree 及对应验证分支
44
+ // 5. 记录当前分支作为来源分支
45
+ const baseBranch = getCurrentBranch();
46
+
47
+ // 6. 串行创建 worktree 及对应验证分支,并保存元数据
42
48
  const results: WorktreeInfo[] = [];
43
49
  for (const name of branchNames) {
44
50
  const worktreePath = join(projectDir, name);
45
51
  gitCreateWorktree(name, worktreePath);
46
52
  createValidateBranch(name);
47
- results.push({ path: worktreePath, branch: name });
53
+ saveWorktreeMetadata(projectName, { branch: name, baseBranch, createdAt: new Date().toISOString() });
54
+ results.push({ path: worktreePath, branch: name, baseBranch });
48
55
  logger.info(`worktree 创建完成: ${worktreePath} (分支: ${name})`);
49
56
  }
50
57
 
@@ -62,17 +69,22 @@ export function createWorktreesByBranches(branchNames: string[]): WorktreeInfo[]
62
69
  // 1. 校验所有分支是否都不存在
63
70
  validateBranchesNotExist(branchNames);
64
71
 
65
- // 2. 确保项目 worktree 目录存在
66
- const projectDir = getProjectWorktreeDir();
72
+ // 2. 获取项目名并确保 worktree 目录存在
73
+ const projectName = getProjectName();
74
+ const projectDir = join(WORKTREES_DIR, projectName);
67
75
  ensureDir(projectDir);
68
76
 
69
- // 3. 串行创建 worktree 及对应验证分支
77
+ // 3. 记录当前分支作为来源分支
78
+ const baseBranch = getCurrentBranch();
79
+
80
+ // 4. 串行创建 worktree 及对应验证分支,并保存元数据
70
81
  const results: WorktreeInfo[] = [];
71
82
  for (const name of branchNames) {
72
83
  const worktreePath = join(projectDir, name);
73
84
  gitCreateWorktree(name, worktreePath);
74
85
  createValidateBranch(name);
75
- results.push({ path: worktreePath, branch: name });
86
+ saveWorktreeMetadata(projectName, { branch: name, baseBranch, createdAt: new Date().toISOString() });
87
+ results.push({ path: worktreePath, branch: name, baseBranch });
76
88
  logger.info(`worktree 创建完成: ${worktreePath} (分支: ${name})`);
77
89
  }
78
90
 
@@ -85,7 +97,8 @@ export function createWorktreesByBranches(branchNames: string[]): WorktreeInfo[]
85
97
  * @returns {WorktreeInfo[]} 有效的 worktree 列表
86
98
  */
87
99
  export function getProjectWorktrees(): WorktreeInfo[] {
88
- const projectDir = getProjectWorktreeDir();
100
+ const projectName = getProjectName();
101
+ const projectDir = join(WORKTREES_DIR, projectName);
89
102
 
90
103
  if (!existsSync(projectDir)) {
91
104
  return [];
@@ -107,9 +120,12 @@ export function getProjectWorktrees(): WorktreeInfo[] {
107
120
  const fullPath = join(projectDir, entry.name);
108
121
  // 交叉验证:路径必须在 git worktree list 中
109
122
  if (registeredPaths.has(fullPath)) {
123
+ // 读取来源分支元数据,无元数据时 baseBranch 为 null
124
+ const metadata = loadWorktreeMetadata(projectName, entry.name);
110
125
  worktrees.push({
111
126
  path: fullPath,
112
127
  branch: entry.name,
128
+ baseBranch: metadata?.baseBranch ?? null,
113
129
  });
114
130
  }
115
131
  }
@@ -122,18 +138,21 @@ export function getProjectWorktrees(): WorktreeInfo[] {
122
138
  * @param {WorktreeInfo[]} worktrees - 待清理的 worktree 列表
123
139
  */
124
140
  export function cleanupWorktrees(worktrees: WorktreeInfo[]): void {
141
+ const projectName = getProjectName();
125
142
  for (const wt of worktrees) {
126
143
  try {
127
144
  removeWorktreeByPath(wt.path);
128
145
  deleteBranch(wt.branch);
129
146
  deleteValidateBranch(wt.branch);
147
+ // 删除来源分支元数据
148
+ removeWorktreeMetadata(projectName, wt.branch);
130
149
  logger.info(`已清理 worktree 和分支: ${wt.branch}`);
131
150
  } catch (error) {
132
151
  logger.error(`清理 worktree 失败: ${wt.path} - ${error}`);
133
152
  }
134
153
  }
135
154
  gitWorktreePrune();
136
- const projectDir = getProjectWorktreeDir();
155
+ const projectDir = join(WORKTREES_DIR, projectName);
137
156
  removeEmptyDir(projectDir);
138
157
  }
139
158
 
@@ -9,6 +9,7 @@ export function createWorktreeInfo(overrides: Partial<WorktreeInfo> = {}): Workt
9
9
  return {
10
10
  path: '/Users/test/.clawt/worktrees/my-project/feature-branch',
11
11
  branch: 'feature-branch',
12
+ baseBranch: null,
12
13
  ...overrides,
13
14
  };
14
15
  }
@@ -45,8 +45,8 @@ vi.mock('../../../src/utils/index.js', () => ({
45
45
  getProjectName: vi.fn().mockReturnValue('test-project'),
46
46
  getGitTopLevel: vi.fn().mockReturnValue('/repo'),
47
47
  getCurrentBranch: vi.fn().mockReturnValue('clawt-validate-feature'),
48
- getProjectWorktrees: vi.fn().mockReturnValue([{ path: '/path/feature', branch: 'feature' }]),
49
- findExactMatch: vi.fn().mockReturnValue({ path: '/path/feature', branch: 'feature' }),
48
+ getProjectWorktrees: vi.fn().mockReturnValue([{ path: '/path/feature', branch: 'feature', baseBranch: null }]),
49
+ findExactMatch: vi.fn().mockReturnValue({ path: '/path/feature', branch: 'feature', baseBranch: null }),
50
50
  hasSnapshot: vi.fn().mockReturnValue(true),
51
51
  readSnapshot: vi.fn().mockReturnValue({ treeHash: 'snapshot-tree-hash', headCommitHash: '', stagedTreeHash: '' }),
52
52
  writeSnapshot: vi.fn(),
@@ -103,8 +103,8 @@ beforeEach(() => {
103
103
  vi.clearAllMocks();
104
104
  // 恢复默认 mock 值
105
105
  mockedGetCurrentBranch.mockReturnValue('clawt-validate-feature');
106
- mockedGetProjectWorktrees.mockReturnValue([{ path: '/path/feature', branch: 'feature' }]);
107
- mockedFindExactMatch.mockReturnValue({ path: '/path/feature', branch: 'feature' });
106
+ mockedGetProjectWorktrees.mockReturnValue([{ path: '/path/feature', branch: 'feature', baseBranch: null }]);
107
+ mockedFindExactMatch.mockReturnValue({ path: '/path/feature', branch: 'feature', baseBranch: null });
108
108
  mockedHasSnapshot.mockReturnValue(true);
109
109
  mockedReadSnapshot.mockReturnValue({ treeHash: 'snapshot-tree-hash', headCommitHash: '', stagedTreeHash: '' });
110
110
  mockedIsWorkingDirClean.mockReturnValue(false);
@@ -67,7 +67,7 @@ describe('registerCreateCommand', () => {
67
67
  describe('handleCreate', () => {
68
68
  it('成功创建 worktree', async () => {
69
69
  mockedCreateWorktrees.mockReturnValue([
70
- { path: '/path/feature', branch: 'feature' },
70
+ { path: '/path/feature', branch: 'feature', baseBranch: null },
71
71
  ]);
72
72
 
73
73
  const program = new Command();
@@ -82,8 +82,8 @@ describe('handleCreate', () => {
82
82
 
83
83
  it('支持 -n 指定创建数量', async () => {
84
84
  mockedCreateWorktrees.mockReturnValue([
85
- { path: '/path/feature-1', branch: 'feature-1' },
86
- { path: '/path/feature-2', branch: 'feature-2' },
85
+ { path: '/path/feature-1', branch: 'feature-1', baseBranch: null },
86
+ { path: '/path/feature-2', branch: 'feature-2', baseBranch: null },
87
87
  ]);
88
88
 
89
89
  const program = new Command();