clawt 3.10.4 → 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 +228 -86
- package/dist/postinstall.js +27 -0
- 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-01-sync-validate-diverged-findings.md +203 -0
- package/docs/superpowers/findings/2026-06-09-worktree-base-branch-findings.md +58 -0
- package/docs/superpowers/plans/2026-06-01-validate-ignored-files-conflict.md +412 -0
- package/docs/superpowers/plans/2026-06-09-worktree-base-branch.md +386 -0
- package/docs/superpowers/specs/2026-06-01-validate-ignored-files-conflict-design.md +76 -0
- package/docs/superpowers/specs/2026-06-09-worktree-base-branch-design.md +169 -0
- package/docs/validate.md +42 -5
- 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/constants/messages/validate.ts +17 -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/git-core.ts +23 -0
- package/src/utils/index.ts +4 -2
- package/src/utils/interactive-panel-render.ts +6 -3
- package/src/utils/validate-core.ts +52 -0
- 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/git-core.test.ts +43 -0
- package/tests/unit/utils/interactive-panel-render.test.ts +124 -0
- package/tests/unit/utils/validate-core.test.ts +60 -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
package/docs/validate.md
CHANGED
|
@@ -158,9 +158,44 @@ git restore --staged .
|
|
|
158
158
|
> 此步骤结束后,目标 worktree 的代码保持原样,主 worktree 工作目录包含目标分支的全量变更。
|
|
159
159
|
> 如果 patch apply 失败(兜底场景),`migrateChangesViaPatch` 返回 `{ success: false }`,进入自动 sync 交互流程(见下文 [patch apply 失败后的自动 sync 流程](#patch-apply-失败后的自动-sync-流程))。
|
|
160
160
|
|
|
161
|
+
###### 幽灵文件检测(patch apply 前置拦截)
|
|
162
|
+
|
|
163
|
+
在执行昂贵的 `git diff --binary` 之前,`migrateChangesViaPatch` 会先进行轻量级的**幽灵文件检测**,提前拦截一类常见的 patch apply 失败场景。
|
|
164
|
+
|
|
165
|
+
**背景:** AI Agent(如 Claude Code)在 worktree 中工作时,可能会创建被 `.gitignore` 忽略的文件(如 `node_modules/` 下的依赖、构建产物等)。这些文件不受 git 跟踪,但当目标分支的 patch 中包含同名文件时,`git apply` 会因为"文件已存在于工作区中"而失败。由于这些文件被 `.gitignore` 忽略,`git clean -fd` 无法清理它们(需要 `git clean -fdx`),用户往往难以自行发现和定位。
|
|
166
|
+
|
|
167
|
+
**检测流程:**
|
|
168
|
+
|
|
169
|
+
1. **获取 patch 涉及的文件列表**:通过 `git diff --name-only HEAD...<branchName>` 轻量获取目标分支变更涉及的所有文件路径(不含二进制内容,远比 `--binary` 便宜)
|
|
170
|
+
2. **筛选被 `.gitignore` 忽略的文件**:调用 `gitCheckIgnored()`(`src/utils/git-core.ts`),通过 `git check-ignore` 批量检测哪些文件被忽略规则匹配
|
|
171
|
+
3. **确认文件物理存在**:对被忽略的文件进一步检查其是否真实存在于主 worktree 文件系统中(`existsSync`),只有同时满足"被忽略"和"物理存在"两个条件的才是幽灵文件
|
|
172
|
+
4. **拦截并提示**:如果检测到幽灵文件,生成针对性的 `git clean -fdx` 清理命令(按直接父目录去重,通过 `buildCleanCommands()` 生成),输出清晰的错误提示后返回 `{ success: false }`
|
|
173
|
+
|
|
174
|
+
**错误提示示例:**
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
检测到被 .gitignore 忽略的文件残留在主 worktree 中,导致变更无法应用:
|
|
178
|
+
- dist/bundle.js
|
|
179
|
+
- node_modules/.cache/temp.json
|
|
180
|
+
|
|
181
|
+
请手动清理后重试:
|
|
182
|
+
git clean -fdx dist/
|
|
183
|
+
git clean -fdx node_modules/.cache/
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
> 幽灵文件检测失败后,同样进入 [patch apply 失败后的自动 sync 流程](#patch-apply-失败后的自动-sync-流程)(询问用户是否执行 sync),但用户通常应根据提示先手动清理幽灵文件再重试 validate。
|
|
187
|
+
> 如果 `git diff --name-only` 执行失败(如分支不存在),检测会静默跳过(降级为原有行为,让后续 apply 自行报错)。
|
|
188
|
+
|
|
189
|
+
**实现要点:**
|
|
190
|
+
|
|
191
|
+
- `detectIgnoredFilesInPatch(branchName, mainWorktreePath)`(`src/utils/validate-core.ts`):检测 patch 中的幽灵文件,返回幽灵文件的相对路径列表
|
|
192
|
+
- `gitCheckIgnored(paths, cwd)`(`src/utils/git-core.ts`):封装 `git check-ignore` 命令,批量检测文件是否被忽略,退出码 1(无匹配)视为正常情况返回空数组
|
|
193
|
+
- `buildCleanCommands(files)`(`src/utils/validate-core.ts`):根据冲突文件列表,按直接父目录去重生成 `git clean -fdx <dir>/` 命令
|
|
194
|
+
- 消息常量:`MESSAGES.VALIDATE_IGNORED_FILES_CONFLICT`(`src/constants/messages/validate.ts`):双语提示,最多展示 10 个文件路径,超出部分显示总数
|
|
195
|
+
|
|
161
196
|
##### patch apply 失败后的自动 sync 流程
|
|
162
197
|
|
|
163
|
-
当 patch apply
|
|
198
|
+
当 patch 迁移失败时(包括 patch apply 冲突和幽灵文件检测拦截两种情况),validate 不再直接退出,而是先通过 `ensureOnMainWorkBranch()` 确保主 worktree 切回主工作分支,然后通过 `handlePatchApplyFailure()` 函数进入交互流程:
|
|
164
199
|
|
|
165
200
|
1. **询问用户**:提示 `是否立即执行 sync 同步主分支到 <branchName>?`
|
|
166
201
|
2. **用户拒绝** → 输出提示 `请手动执行 clawt sync -b <branchName> 同步主分支后重试`,退出
|
|
@@ -170,10 +205,12 @@ git restore --staged .
|
|
|
170
205
|
|
|
171
206
|
**实现要点:**
|
|
172
207
|
|
|
173
|
-
- `migrateChangesViaPatch()`(`src/utils/validate-core.ts`)返回 `{ success: boolean }`,patch apply
|
|
208
|
+
- `migrateChangesViaPatch()`(`src/utils/validate-core.ts`)返回 `{ success: boolean }`,patch apply 失败或幽灵文件检测拦截时返回 `{ success: false }` 而非抛出异常
|
|
209
|
+
- `detectIgnoredFilesInPatch(branchName, mainWorktreePath)`(`src/utils/validate-core.ts`):幽灵文件检测函数,在 patch apply 之前调用,返回被 `.gitignore` 忽略且物理存在的文件列表
|
|
210
|
+
- `gitCheckIgnored(paths, cwd)`(`src/utils/git-core.ts`):封装 `git check-ignore`,批量检测文件是否被忽略规则匹配
|
|
174
211
|
- `handleFirstValidate()` 和 `handleIncrementalValidate()` 为 `async` 函数,支持交互式确认
|
|
175
|
-
- `handlePatchApplyFailure()`(`src/commands/validate.ts`)为异步函数,负责 patch
|
|
176
|
-
- 消息常量:`MESSAGES.VALIDATE_CONFIRM_AUTO_SYNC`、`MESSAGES.VALIDATE_AUTO_SYNC_START`、`MESSAGES.VALIDATE_AUTO_SYNC_DECLINED`(`src/constants/messages/validate.ts`)
|
|
212
|
+
- `handlePatchApplyFailure()`(`src/commands/validate.ts`)为异步函数,负责 patch 迁移失败后的交互逻辑(含幽灵文件冲突和 patch apply 冲突两种场景)
|
|
213
|
+
- 消息常量:`MESSAGES.VALIDATE_CONFIRM_AUTO_SYNC`、`MESSAGES.VALIDATE_AUTO_SYNC_START`、`MESSAGES.VALIDATE_AUTO_SYNC_DECLINED`、`MESSAGES.VALIDATE_IGNORED_FILES_CONFLICT`(`src/constants/messages/validate.ts`)
|
|
177
214
|
|
|
178
215
|
##### 步骤 5:保存快照为 git tree 对象
|
|
179
216
|
|
|
@@ -323,7 +360,7 @@ git checkout clawt-validate-<branchName>
|
|
|
323
360
|
|
|
324
361
|
##### 步骤 4:从目标分支获取最新全量变更
|
|
325
362
|
|
|
326
|
-
通过 patch 方式从目标分支获取最新全量变更(流程同首次 validate 的步骤 4
|
|
363
|
+
通过 patch 方式从目标分支获取最新全量变更(流程同首次 validate 的步骤 4,包含幽灵文件前置检测)。如果幽灵文件检测拦截或 patch apply 失败,同样进入自动 sync 交互流程(见首次 validate 的 [patch apply 失败后的自动 sync 流程](#patch-apply-失败后的自动-sync-流程)),validate 流程提前结束。
|
|
327
364
|
|
|
328
365
|
##### 步骤 5:检测是否有新变更
|
|
329
366
|
|
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);
|
|
@@ -40,6 +40,23 @@ const VALIDATE_MESSAGES_I18N = {
|
|
|
40
40
|
'zh-CN': (branch: string) =>
|
|
41
41
|
`变更迁移失败:目标分支与主分支差异过大\n 请先执行 clawt sync -b ${branch} 同步主分支后重试`,
|
|
42
42
|
},
|
|
43
|
+
/** validate 检测到被 .gitignore 忽略的残留文件冲突 */
|
|
44
|
+
VALIDATE_IGNORED_FILES_CONFLICT: {
|
|
45
|
+
en: (files: string[], cleanCommands: string[]) => {
|
|
46
|
+
const maxDisplay = 10;
|
|
47
|
+
const displayed = files.slice(0, maxDisplay).map(f => ` - ${f}`).join('\n');
|
|
48
|
+
const more = files.length > maxDisplay ? `\n ...(${files.length} files total)` : '';
|
|
49
|
+
const cmds = cleanCommands.map(c => ` ${c}`).join('\n');
|
|
50
|
+
return `Ignored files left in main worktree are blocking patch apply:\n${displayed}${more}\n\nPlease clean up manually and retry:\n${cmds}`;
|
|
51
|
+
},
|
|
52
|
+
'zh-CN': (files: string[], cleanCommands: string[]) => {
|
|
53
|
+
const maxDisplay = 10;
|
|
54
|
+
const displayed = files.slice(0, maxDisplay).map(f => ` - ${f}`).join('\n');
|
|
55
|
+
const more = files.length > maxDisplay ? `\n ...(共 ${files.length} 个文件)` : '';
|
|
56
|
+
const cmds = cleanCommands.map(c => ` ${c}`).join('\n');
|
|
57
|
+
return `检测到被 .gitignore 忽略的文件残留在主 worktree 中,导致变更无法应用:\n${displayed}${more}\n\n请手动清理后重试:\n${cmds}`;
|
|
58
|
+
},
|
|
59
|
+
},
|
|
43
60
|
/** validate 无可用 worktree */
|
|
44
61
|
VALIDATE_NO_WORKTREES: {
|
|
45
62
|
en: 'No worktrees available, please create one with clawt run or clawt create first',
|
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/git-core.ts
CHANGED
|
@@ -498,3 +498,26 @@ export function gitMergeAbort(cwd?: string): void {
|
|
|
498
498
|
export function buildAutoSaveCommitMessage(mainBranch: string, branch: string): string {
|
|
499
499
|
return `${AUTO_SAVE_COMMIT_MESSAGE_PREFIX} ${mainBranch} into ${branch}`;
|
|
500
500
|
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* 批量检测文件是否被 .gitignore 忽略
|
|
504
|
+
* 使用 git check-ignore 命令,退出码 1 表示无匹配(非错误)
|
|
505
|
+
* @param {string[]} paths - 要检测的文件路径列表
|
|
506
|
+
* @param {string} [cwd] - 工作目录
|
|
507
|
+
* @returns {string[]} 被忽略的文件路径列表
|
|
508
|
+
*/
|
|
509
|
+
export function gitCheckIgnored(paths: string[], cwd?: string): string[] {
|
|
510
|
+
if (paths.length === 0) return [];
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
const output = execFileSync('git', ['check-ignore', '--', ...paths], {
|
|
514
|
+
cwd,
|
|
515
|
+
encoding: 'utf-8',
|
|
516
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
517
|
+
});
|
|
518
|
+
return output.trim().split('\n').filter(Boolean);
|
|
519
|
+
} catch {
|
|
520
|
+
// git check-ignore 退出码 1 表示无匹配文件,属于正常情况
|
|
521
|
+
return [];
|
|
522
|
+
}
|
|
523
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -63,18 +63,20 @@ export {
|
|
|
63
63
|
gitMergeAbort,
|
|
64
64
|
buildAutoSaveCommitMessage,
|
|
65
65
|
throwIfGitIndexLockError,
|
|
66
|
+
gitCheckIgnored,
|
|
66
67
|
} from './git.js';
|
|
67
68
|
export { sanitizeBranchName, generateBranchNames, validateBranchesNotExist } from './branch.js';
|
|
68
69
|
export { validateMainWorktree, validateGitInstalled, validateClaudeCodeInstalled, validateHeadExists, validateWorkingDirClean, runPreChecks } from './validation.js';
|
|
69
70
|
export type { PreCheckOptions } from './validation.js';
|
|
70
71
|
export { createWorktrees, getProjectWorktrees, getProjectWorktreeDir, cleanupWorktrees, getWorktreeStatus, createWorktreesByBranches } from './worktree.js';
|
|
71
72
|
export { loadConfig, writeDefaultConfig, writeConfig, saveConfig, getConfigValue, ensureClawtDirs, parseConcurrency } from './config.js';
|
|
72
|
-
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';
|
|
73
74
|
export { ensureDir, removeEmptyDir, calculateDirSize } from './fs.js';
|
|
74
75
|
export { removeExternalSymlinks } from './symlink-guard.js';
|
|
75
76
|
export { multilineInput, promptCommitMessage } from './prompt.js';
|
|
76
77
|
export { launchInteractiveClaude, hasClaudeSessionHistory, launchInteractiveClaudeInNewTerminal } from './claude.js';
|
|
77
78
|
export { getSnapshotPath, hasSnapshot, getSnapshotModifiedTime, readSnapshotTreeHash, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots, getProjectSnapshotBranches } from './validate-snapshot.js';
|
|
79
|
+
export { removeWorktreeMetadata } from './worktree-metadata.js';
|
|
78
80
|
export { findExactMatch, findFuzzyMatches, promptGroupedMultiSelectBranches, resolveTargetWorktree, resolveTargetWorktrees, groupWorktreesByDate, buildGroupedChoices, buildGroupMembershipMap, formatRelativeDate, getWorktreeCreatedDate, getWorktreeCreatedTime } from './worktree-matcher.js';
|
|
79
81
|
export type { WorktreeResolveMessages, WorktreeMultiResolveMessages } from './worktree-matcher.js';
|
|
80
82
|
export { ProgressRenderer } from './progress.js';
|
|
@@ -92,7 +94,7 @@ export { getValidateBranchName, createValidateBranch, deleteValidateBranch, rebu
|
|
|
92
94
|
export { safeStringify } from './json.js';
|
|
93
95
|
export { isNonInteractive, setNonInteractive } from './interactive.js';
|
|
94
96
|
export { executeRunCommand } from './validate-runner.js';
|
|
95
|
-
export { migrateChangesViaPatch, computeCurrentTreeHash, saveCurrentSnapshotTree, loadOldSnapshotToStage, switchToValidateBranch } from './validate-core.js';
|
|
97
|
+
export { migrateChangesViaPatch, computeCurrentTreeHash, saveCurrentSnapshotTree, loadOldSnapshotToStage, switchToValidateBranch, detectIgnoredFilesInPatch } from './validate-core.js';
|
|
96
98
|
export { InteractivePanel } from './interactive-panel.js';
|
|
97
99
|
export { buildPanelFrame, buildGroupedWorktreeLines, buildDisplayOrder, renderDateSeparator, renderWorktreeBlock, renderSnapshotSummary, renderFooter, calculateVisibleRows } from './interactive-panel-render.js';
|
|
98
100
|
export type { PanelLine } from './interactive-panel-render.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);
|
|
@@ -2,6 +2,8 @@ import { logger } from '../logger/index.js';
|
|
|
2
2
|
import { ClawtError } from '../errors/index.js';
|
|
3
3
|
import { MESSAGES } from '../constants/index.js';
|
|
4
4
|
import { getCurrentLanguage } from './i18n.js';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
5
7
|
import {
|
|
6
8
|
gitAddAll,
|
|
7
9
|
gitCommit,
|
|
@@ -22,8 +24,26 @@ import {
|
|
|
22
24
|
getHeadCommitHash,
|
|
23
25
|
writeSnapshot,
|
|
24
26
|
printWarning,
|
|
27
|
+
gitCheckIgnored,
|
|
28
|
+
execCommand,
|
|
25
29
|
} from './index.js';
|
|
26
30
|
|
|
31
|
+
/**
|
|
32
|
+
* 根据冲突文件列表生成 git clean 清理命令
|
|
33
|
+
* 按直接父目录去重,生成针对性的清理命令
|
|
34
|
+
* @param {string[]} files - 冲突文件的相对路径列表
|
|
35
|
+
* @returns {string[]} 清理命令列表
|
|
36
|
+
*/
|
|
37
|
+
function buildCleanCommands(files: string[]): string[] {
|
|
38
|
+
const dirs = new Set<string>();
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
const lastSlash = file.lastIndexOf('/');
|
|
41
|
+
const dir = lastSlash > 0 ? file.substring(0, lastSlash) : '.';
|
|
42
|
+
dirs.add(dir);
|
|
43
|
+
}
|
|
44
|
+
return Array.from(dirs).map(dir => `git clean -fdx ${dir}/`);
|
|
45
|
+
}
|
|
46
|
+
|
|
27
47
|
/**
|
|
28
48
|
* 通过 patch 将目标分支的全量变更(已提交 + 未提交)迁移到主 worktree
|
|
29
49
|
* 使用 git diff HEAD...branch --binary 获取变更,避免 stash 方式无法检测已提交 commit 的问题
|
|
@@ -44,6 +64,16 @@ export function migrateChangesViaPatch(targetWorktreePath: string, mainWorktreeP
|
|
|
44
64
|
didTempCommit = true;
|
|
45
65
|
}
|
|
46
66
|
|
|
67
|
+
// 先执行轻量检测:检测被 .gitignore 忽略的残留文件(幽灵文件),在 apply 之前拦截
|
|
68
|
+
// 使用 --name-only 远比 --binary 便宜,检测到冲突时可跳过昂贵的 binary diff
|
|
69
|
+
const ignoredFiles = detectIgnoredFilesInPatch(branchName, mainWorktreePath);
|
|
70
|
+
if (ignoredFiles.length > 0) {
|
|
71
|
+
const cleanCommands = buildCleanCommands(ignoredFiles);
|
|
72
|
+
logger.warn(`检测到 ${ignoredFiles.length} 个被忽略的残留文件冲突`);
|
|
73
|
+
printWarning(MESSAGES.VALIDATE_IGNORED_FILES_CONFLICT(ignoredFiles, cleanCommands));
|
|
74
|
+
return { success: false };
|
|
75
|
+
}
|
|
76
|
+
|
|
47
77
|
// 在主 worktree 执行三点 diff,获取目标分支自分叉点以来的全量变更
|
|
48
78
|
const patch = gitDiffBinaryAgainstBranch(branchName, mainWorktreePath);
|
|
49
79
|
|
|
@@ -173,3 +203,25 @@ export function switchToValidateBranch(branchName: string, mainWorktreePath: str
|
|
|
173
203
|
}
|
|
174
204
|
return validateBranchName;
|
|
175
205
|
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 检测 patch 中被 .gitignore 忽略且物理存在于主 worktree 的文件(幽灵文件)
|
|
209
|
+
* 这些文件会导致 git apply 失败("已经存在于工作区中")
|
|
210
|
+
* @param {string} branchName - 目标分支名
|
|
211
|
+
* @param {string} mainWorktreePath - 主 worktree 路径
|
|
212
|
+
* @returns {string[]} 幽灵文件的相对路径列表
|
|
213
|
+
*/
|
|
214
|
+
export function detectIgnoredFilesInPatch(branchName: string, mainWorktreePath: string): string[] {
|
|
215
|
+
try {
|
|
216
|
+
const output = execCommand(`git diff --name-only HEAD...${branchName}`, { cwd: mainWorktreePath });
|
|
217
|
+
const patchFiles = output.split('\n').filter(Boolean);
|
|
218
|
+
if (patchFiles.length === 0) return [];
|
|
219
|
+
|
|
220
|
+
// 筛选被 .gitignore 忽略且物理存在的文件(幽灵文件)
|
|
221
|
+
return gitCheckIgnored(patchFiles, mainWorktreePath)
|
|
222
|
+
.filter(file => existsSync(join(mainWorktreePath, file)));
|
|
223
|
+
} catch {
|
|
224
|
+
// diff 失败时跳过检测,降级为当前行为(让 apply 自行报错)
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -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
|
|