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.
- package/AGENTS.md +16 -0
- package/dist/index.js +163 -89
- package/docs/create.md +1 -0
- package/docs/list.md +21 -10
- package/docs/merge.md +1 -0
- package/docs/remove.md +2 -0
- package/docs/spec.md +4 -1
- package/docs/status.md +9 -1
- package/docs/superpowers/findings/2026-06-09-worktree-base-branch-findings.md +58 -0
- package/docs/superpowers/plans/2026-06-09-worktree-base-branch.md +386 -0
- package/docs/superpowers/specs/2026-06-09-worktree-base-branch-design.md +169 -0
- package/package.json +1 -1
- package/src/commands/list.ts +5 -3
- package/src/commands/merge.ts +1 -1
- package/src/commands/remove.ts +3 -0
- package/src/commands/status.ts +5 -0
- package/src/types/status.ts +2 -0
- package/src/types/worktree.ts +12 -0
- package/src/utils/formatter.ts +22 -0
- package/src/utils/index.ts +2 -1
- package/src/utils/interactive-panel-render.ts +6 -3
- package/src/utils/worktree-metadata.ts +82 -0
- package/src/utils/worktree.ts +29 -10
- package/tests/helpers/fixtures.ts +1 -0
- package/tests/unit/commands/cover-validate.test.ts +4 -4
- package/tests/unit/commands/create.test.ts +3 -3
- package/tests/unit/commands/list.test.ts +66 -3
- package/tests/unit/commands/merge.test.ts +1 -1
- package/tests/unit/commands/remove.test.ts +24 -18
- package/tests/unit/commands/resume.test.ts +21 -21
- package/tests/unit/commands/run.test.ts +17 -17
- package/tests/unit/commands/status.test.ts +85 -10
- package/tests/unit/commands/sync.test.ts +4 -4
- package/tests/unit/commands/validate.test.ts +1 -1
- package/tests/unit/utils/interactive-panel-render.test.ts +124 -0
- package/tests/unit/utils/worktree-matcher.test.ts +2 -2
- package/tests/unit/utils/worktree-metadata.test.ts +91 -0
- 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
package/src/commands/list.ts
CHANGED
|
@@ -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 和
|
|
17
|
+
// getWorktreeStatus、formatWorktreeStatus 和 formatBaseBranchInline 仅在文本模式下使用
|
|
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
|
|
54
|
+
* 以 JSON 格式输出 worktree 列表(包含 path、branch 和 baseBranch)
|
|
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)}`);
|
package/src/commands/merge.ts
CHANGED
|
@@ -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
|
|
package/src/commands/remove.ts
CHANGED
|
@@ -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);
|
package/src/commands/status.ts
CHANGED
|
@@ -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);
|
package/src/types/status.ts
CHANGED
package/src/types/worktree.ts
CHANGED
|
@@ -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 变更统计信息 */
|
package/src/utils/formatter.ts
CHANGED
|
@@ -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 - 文件名前缀
|
package/src/utils/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/worktree.ts
CHANGED
|
@@ -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.
|
|
38
|
-
const
|
|
39
|
+
// 4. 获取项目名并确保 worktree 目录存在
|
|
40
|
+
const projectName = getProjectName();
|
|
41
|
+
const projectDir = join(WORKTREES_DIR, projectName);
|
|
39
42
|
ensureDir(projectDir);
|
|
40
43
|
|
|
41
|
-
// 5.
|
|
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
|
-
|
|
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.
|
|
66
|
-
const
|
|
72
|
+
// 2. 获取项目名并确保 worktree 目录存在
|
|
73
|
+
const projectName = getProjectName();
|
|
74
|
+
const projectDir = join(WORKTREES_DIR, projectName);
|
|
67
75
|
ensureDir(projectDir);
|
|
68
76
|
|
|
69
|
-
// 3.
|
|
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
|
-
|
|
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
|
|
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 =
|
|
155
|
+
const projectDir = join(WORKTREES_DIR, projectName);
|
|
137
156
|
removeEmptyDir(projectDir);
|
|
138
157
|
}
|
|
139
158
|
|
|
@@ -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();
|