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.
Files changed (48) hide show
  1. package/AGENTS.md +16 -0
  2. package/dist/index.js +228 -86
  3. package/dist/postinstall.js +27 -0
  4. package/docs/create.md +1 -0
  5. package/docs/list.md +21 -10
  6. package/docs/merge.md +1 -0
  7. package/docs/remove.md +2 -0
  8. package/docs/spec.md +4 -1
  9. package/docs/status.md +9 -1
  10. package/docs/superpowers/findings/2026-06-01-sync-validate-diverged-findings.md +203 -0
  11. package/docs/superpowers/findings/2026-06-09-worktree-base-branch-findings.md +58 -0
  12. package/docs/superpowers/plans/2026-06-01-validate-ignored-files-conflict.md +412 -0
  13. package/docs/superpowers/plans/2026-06-09-worktree-base-branch.md +386 -0
  14. package/docs/superpowers/specs/2026-06-01-validate-ignored-files-conflict-design.md +76 -0
  15. package/docs/superpowers/specs/2026-06-09-worktree-base-branch-design.md +169 -0
  16. package/docs/validate.md +42 -5
  17. package/package.json +1 -1
  18. package/src/commands/list.ts +5 -3
  19. package/src/commands/merge.ts +1 -1
  20. package/src/commands/remove.ts +3 -0
  21. package/src/commands/status.ts +5 -0
  22. package/src/constants/messages/validate.ts +17 -0
  23. package/src/types/status.ts +2 -0
  24. package/src/types/worktree.ts +12 -0
  25. package/src/utils/formatter.ts +22 -0
  26. package/src/utils/git-core.ts +23 -0
  27. package/src/utils/index.ts +4 -2
  28. package/src/utils/interactive-panel-render.ts +6 -3
  29. package/src/utils/validate-core.ts +52 -0
  30. package/src/utils/worktree-metadata.ts +82 -0
  31. package/src/utils/worktree.ts +29 -10
  32. package/tests/helpers/fixtures.ts +1 -0
  33. package/tests/unit/commands/cover-validate.test.ts +4 -4
  34. package/tests/unit/commands/create.test.ts +3 -3
  35. package/tests/unit/commands/list.test.ts +66 -3
  36. package/tests/unit/commands/merge.test.ts +1 -1
  37. package/tests/unit/commands/remove.test.ts +24 -18
  38. package/tests/unit/commands/resume.test.ts +21 -21
  39. package/tests/unit/commands/run.test.ts +17 -17
  40. package/tests/unit/commands/status.test.ts +85 -10
  41. package/tests/unit/commands/sync.test.ts +4 -4
  42. package/tests/unit/commands/validate.test.ts +1 -1
  43. package/tests/unit/utils/git-core.test.ts +43 -0
  44. package/tests/unit/utils/interactive-panel-render.test.ts +124 -0
  45. package/tests/unit/utils/validate-core.test.ts +60 -0
  46. package/tests/unit/utils/worktree-matcher.test.ts +2 -2
  47. package/tests/unit/utils/worktree-metadata.test.ts +91 -0
  48. 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 失败时,validate 不再直接退出,而是先通过 `ensureOnMainWorkBranch()` 确保主 worktree 切回主工作分支,然后通过 `handlePatchApplyFailure()` 函数进入交互流程:
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 失败时返回 `{ success: false }` 而非抛出异常
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)。如果 patch apply 失败,同样进入自动 sync 交互流程(见首次 validate 的 [patch apply 失败后的自动 sync 流程](#patch-apply-失败后的自动-sync-流程)),validate 流程提前结束。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "3.10.4",
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);
@@ -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',
@@ -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 - 文件名前缀
@@ -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
+ }
@@ -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
+ }
@@ -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
  }