clawt 3.10.4 → 3.10.5

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/dist/index.js CHANGED
@@ -819,6 +819,33 @@ var VALIDATE_MESSAGES_I18N = {
819
819
  "zh-CN": (branch) => `\u53D8\u66F4\u8FC1\u79FB\u5931\u8D25\uFF1A\u76EE\u6807\u5206\u652F\u4E0E\u4E3B\u5206\u652F\u5DEE\u5F02\u8FC7\u5927
820
820
  \u8BF7\u5148\u6267\u884C clawt sync -b ${branch} \u540C\u6B65\u4E3B\u5206\u652F\u540E\u91CD\u8BD5`
821
821
  },
822
+ /** validate 检测到被 .gitignore 忽略的残留文件冲突 */
823
+ VALIDATE_IGNORED_FILES_CONFLICT: {
824
+ en: (files, cleanCommands) => {
825
+ const maxDisplay = 10;
826
+ const displayed = files.slice(0, maxDisplay).map((f) => ` - ${f}`).join("\n");
827
+ const more = files.length > maxDisplay ? `
828
+ ...(${files.length} files total)` : "";
829
+ const cmds = cleanCommands.map((c) => ` ${c}`).join("\n");
830
+ return `Ignored files left in main worktree are blocking patch apply:
831
+ ${displayed}${more}
832
+
833
+ Please clean up manually and retry:
834
+ ${cmds}`;
835
+ },
836
+ "zh-CN": (files, cleanCommands) => {
837
+ const maxDisplay = 10;
838
+ const displayed = files.slice(0, maxDisplay).map((f) => ` - ${f}`).join("\n");
839
+ const more = files.length > maxDisplay ? `
840
+ ...\uFF08\u5171 ${files.length} \u4E2A\u6587\u4EF6\uFF09` : "";
841
+ const cmds = cleanCommands.map((c) => ` ${c}`).join("\n");
842
+ return `\u68C0\u6D4B\u5230\u88AB .gitignore \u5FFD\u7565\u7684\u6587\u4EF6\u6B8B\u7559\u5728\u4E3B worktree \u4E2D\uFF0C\u5BFC\u81F4\u53D8\u66F4\u65E0\u6CD5\u5E94\u7528\uFF1A
843
+ ${displayed}${more}
844
+
845
+ \u8BF7\u624B\u52A8\u6E05\u7406\u540E\u91CD\u8BD5\uFF1A
846
+ ${cmds}`;
847
+ }
848
+ },
822
849
  /** validate 无可用 worktree */
823
850
  VALIDATE_NO_WORKTREES: {
824
851
  en: "No worktrees available, please create one with clawt run or clawt create first",
@@ -2817,6 +2844,19 @@ function gitMergeContinue(cwd) {
2817
2844
  function buildAutoSaveCommitMessage(mainBranch, branch) {
2818
2845
  return `${AUTO_SAVE_COMMIT_MESSAGE_PREFIX} ${mainBranch} into ${branch}`;
2819
2846
  }
2847
+ function gitCheckIgnored(paths, cwd) {
2848
+ if (paths.length === 0) return [];
2849
+ try {
2850
+ const output = execFileSync2("git", ["check-ignore", "--", ...paths], {
2851
+ cwd,
2852
+ encoding: "utf-8",
2853
+ stdio: ["pipe", "pipe", "pipe"]
2854
+ });
2855
+ return output.trim().split("\n").filter(Boolean);
2856
+ } catch {
2857
+ return [];
2858
+ }
2859
+ }
2820
2860
 
2821
2861
  // src/utils/git-branch.ts
2822
2862
  function checkBranchExists(branchName, cwd) {
@@ -5093,6 +5133,17 @@ async function executeRunCommand(command, mainWorktreePath) {
5093
5133
  }
5094
5134
 
5095
5135
  // src/utils/validate-core.ts
5136
+ import { existsSync as existsSync10 } from "fs";
5137
+ import { join as join10 } from "path";
5138
+ function buildCleanCommands(files) {
5139
+ const dirs = /* @__PURE__ */ new Set();
5140
+ for (const file of files) {
5141
+ const lastSlash = file.lastIndexOf("/");
5142
+ const dir = lastSlash > 0 ? file.substring(0, lastSlash) : ".";
5143
+ dirs.add(dir);
5144
+ }
5145
+ return Array.from(dirs).map((dir) => `git clean -fdx ${dir}/`);
5146
+ }
5096
5147
  function migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName, hasUncommitted) {
5097
5148
  let didTempCommit = false;
5098
5149
  try {
@@ -5101,6 +5152,13 @@ function migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName
5101
5152
  gitCommit("clawt:temp-commit-for-validate", targetWorktreePath);
5102
5153
  didTempCommit = true;
5103
5154
  }
5155
+ const ignoredFiles = detectIgnoredFilesInPatch(branchName, mainWorktreePath);
5156
+ if (ignoredFiles.length > 0) {
5157
+ const cleanCommands = buildCleanCommands(ignoredFiles);
5158
+ logger.warn(`\u68C0\u6D4B\u5230 ${ignoredFiles.length} \u4E2A\u88AB\u5FFD\u7565\u7684\u6B8B\u7559\u6587\u4EF6\u51B2\u7A81`);
5159
+ printWarning(MESSAGES.VALIDATE_IGNORED_FILES_CONFLICT(ignoredFiles, cleanCommands));
5160
+ return { success: false };
5161
+ }
5104
5162
  const patch = gitDiffBinaryAgainstBranch(branchName, mainWorktreePath);
5105
5163
  if (patch.length > 0) {
5106
5164
  try {
@@ -5175,6 +5233,16 @@ function switchToValidateBranch(branchName, mainWorktreePath) {
5175
5233
  }
5176
5234
  return validateBranchName;
5177
5235
  }
5236
+ function detectIgnoredFilesInPatch(branchName, mainWorktreePath) {
5237
+ try {
5238
+ const output = execCommand(`git diff --name-only HEAD...${branchName}`, { cwd: mainWorktreePath });
5239
+ const patchFiles = output.split("\n").filter(Boolean);
5240
+ if (patchFiles.length === 0) return [];
5241
+ return gitCheckIgnored(patchFiles, mainWorktreePath).filter((file) => existsSync10(join10(mainWorktreePath, file)));
5242
+ } catch {
5243
+ return [];
5244
+ }
5245
+ }
5178
5246
 
5179
5247
  // src/utils/interactive-panel.ts
5180
5248
  import { createInterface as createInterface2 } from "readline";
@@ -5967,9 +6035,9 @@ async function handleMergeConflict(currentBranch, incomingBranch, cwd, autoFlag)
5967
6035
  }
5968
6036
 
5969
6037
  // src/hooks/post-create.ts
5970
- import { existsSync as existsSync10, accessSync, chmodSync, constants as fsConstants } from "fs";
6038
+ import { existsSync as existsSync11, accessSync, chmodSync, constants as fsConstants } from "fs";
5971
6039
  import { spawn as spawn2 } from "child_process";
5972
- import { join as join10 } from "path";
6040
+ import { join as join11 } from "path";
5973
6041
  var POST_CREATE_SCRIPT_RELATIVE_PATH = ".clawt/postCreate.sh";
5974
6042
  function isExecutable(filePath) {
5975
6043
  try {
@@ -6001,8 +6069,8 @@ function resolvePostCreateHook() {
6001
6069
  }
6002
6070
  }
6003
6071
  const mainWorktreePath = getMainWorktreePath();
6004
- const scriptPath = join10(mainWorktreePath, POST_CREATE_SCRIPT_RELATIVE_PATH);
6005
- if (existsSync10(scriptPath)) {
6072
+ const scriptPath = join11(mainWorktreePath, POST_CREATE_SCRIPT_RELATIVE_PATH);
6073
+ if (existsSync11(scriptPath)) {
6006
6074
  if (!isExecutable(scriptPath)) {
6007
6075
  autoFixExecutablePermission(scriptPath);
6008
6076
  }
@@ -7263,8 +7331,8 @@ function registerAliasCommand(program2) {
7263
7331
  }
7264
7332
 
7265
7333
  // src/commands/projects.ts
7266
- import { existsSync as existsSync11, readdirSync as readdirSync6, statSync as statSync4 } from "fs";
7267
- import { join as join11 } from "path";
7334
+ import { existsSync as existsSync12, readdirSync as readdirSync6, statSync as statSync4 } from "fs";
7335
+ import { join as join12 } from "path";
7268
7336
  import chalk13 from "chalk";
7269
7337
  function registerProjectsCommand(program2) {
7270
7338
  program2.command("projects [name]").description(getCurrentLanguage() === "en" ? "Show worktree overview across projects, or view details for a specific project" : "\u5C55\u793A\u6240\u6709\u9879\u76EE\u7684 worktree \u6982\u89C8\uFF0C\u6216\u67E5\u770B\u6307\u5B9A\u9879\u76EE\u7684 worktree \u8BE6\u60C5").option("--json", getCurrentLanguage() === "en" ? "Output in JSON format" : "\u4EE5 JSON \u683C\u5F0F\u8F93\u51FA").action((name, options) => {
@@ -7288,8 +7356,8 @@ function handleProjectsOverview(json) {
7288
7356
  printProjectsOverviewAsText(result);
7289
7357
  }
7290
7358
  function handleProjectDetail(name, json) {
7291
- const projectDir = join11(WORKTREES_DIR, name);
7292
- if (!existsSync11(projectDir)) {
7359
+ const projectDir = join12(WORKTREES_DIR, name);
7360
+ if (!existsSync12(projectDir)) {
7293
7361
  printError(MESSAGES.PROJECTS_NOT_FOUND(name));
7294
7362
  process.exit(1);
7295
7363
  }
@@ -7302,7 +7370,7 @@ function handleProjectDetail(name, json) {
7302
7370
  printProjectDetailAsText(result);
7303
7371
  }
7304
7372
  function collectProjectsOverview() {
7305
- if (!existsSync11(WORKTREES_DIR)) {
7373
+ if (!existsSync12(WORKTREES_DIR)) {
7306
7374
  return { projects: [], totalProjects: 0, totalDiskUsage: 0 };
7307
7375
  }
7308
7376
  const entries = readdirSync6(WORKTREES_DIR, { withFileTypes: true });
@@ -7311,7 +7379,7 @@ function collectProjectsOverview() {
7311
7379
  if (!entry.isDirectory()) {
7312
7380
  continue;
7313
7381
  }
7314
- const projectDir = join11(WORKTREES_DIR, entry.name);
7382
+ const projectDir = join12(WORKTREES_DIR, entry.name);
7315
7383
  const overview = collectSingleProjectOverview(entry.name, projectDir);
7316
7384
  projects.push(overview);
7317
7385
  }
@@ -7328,7 +7396,7 @@ function collectSingleProjectOverview(name, projectDir) {
7328
7396
  const worktreeDirs = subEntries.filter((e) => e.isDirectory());
7329
7397
  const worktreeCount = worktreeDirs.length;
7330
7398
  const diskUsage = calculateDirSize(projectDir);
7331
- const lastActiveTime = resolveProjectLastActiveTime(projectDir, worktreeDirs.map((e) => join11(projectDir, e.name)));
7399
+ const lastActiveTime = resolveProjectLastActiveTime(projectDir, worktreeDirs.map((e) => join12(projectDir, e.name)));
7332
7400
  return {
7333
7401
  name,
7334
7402
  worktreeCount,
@@ -7343,7 +7411,7 @@ function collectProjectDetail(name, projectDir) {
7343
7411
  if (!entry.isDirectory()) {
7344
7412
  continue;
7345
7413
  }
7346
- const wtPath = join11(projectDir, entry.name);
7414
+ const wtPath = join12(projectDir, entry.name);
7347
7415
  const detail = collectSingleWorktreeDetail(entry.name, wtPath);
7348
7416
  worktrees.push(detail);
7349
7417
  }
@@ -7443,7 +7511,7 @@ function printWorktreeDetailItem(wt) {
7443
7511
  }
7444
7512
 
7445
7513
  // src/commands/completion.ts
7446
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, existsSync as existsSync13 } from "fs";
7514
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, existsSync as existsSync14 } from "fs";
7447
7515
  import { resolve as resolve3 } from "path";
7448
7516
  import { homedir as homedir2 } from "os";
7449
7517
 
@@ -7494,14 +7562,14 @@ compdef _clawt_completion clawt
7494
7562
  }
7495
7563
 
7496
7564
  // src/utils/completion-engine.ts
7497
- import { existsSync as existsSync12, readdirSync as readdirSync7, statSync as statSync5 } from "fs";
7498
- import { join as join12, dirname, basename as basename2 } from "path";
7565
+ import { existsSync as existsSync13, readdirSync as readdirSync7, statSync as statSync5 } from "fs";
7566
+ import { join as join13, dirname, basename as basename2 } from "path";
7499
7567
  function completeFilePath(partial) {
7500
7568
  const cwd = process.cwd();
7501
7569
  const hasDir = partial.includes("/");
7502
- const searchDir = hasDir ? join12(cwd, dirname(partial)) : cwd;
7570
+ const searchDir = hasDir ? join13(cwd, dirname(partial)) : cwd;
7503
7571
  const prefix = hasDir ? basename2(partial) : partial;
7504
- if (!existsSync12(searchDir)) {
7572
+ if (!existsSync13(searchDir)) {
7505
7573
  return [];
7506
7574
  }
7507
7575
  const entries = readdirSync7(searchDir);
@@ -7510,7 +7578,7 @@ function completeFilePath(partial) {
7510
7578
  for (const entry of entries) {
7511
7579
  if (!entry.startsWith(prefix)) continue;
7512
7580
  if (entry.startsWith(".")) continue;
7513
- const fullPath = join12(searchDir, entry);
7581
+ const fullPath = join13(searchDir, entry);
7514
7582
  try {
7515
7583
  const stat = statSync5(fullPath);
7516
7584
  if (stat.isDirectory()) {
@@ -7599,7 +7667,7 @@ function generateCompletions(program2, args) {
7599
7667
 
7600
7668
  // src/commands/completion.ts
7601
7669
  function appendToFile(filePath, content) {
7602
- if (existsSync13(filePath)) {
7670
+ if (existsSync14(filePath)) {
7603
7671
  const current = readFileSync6(filePath, "utf-8");
7604
7672
  if (current.includes("clawt completion")) {
7605
7673
  printInfo(MESSAGES.COMPLETION_INSTALL_EXISTS + ": " + filePath);
@@ -7739,19 +7807,19 @@ async function handleHome() {
7739
7807
  }
7740
7808
 
7741
7809
  // src/commands/tasks.ts
7742
- import { resolve as resolve4, dirname as dirname2, join as join13 } from "path";
7743
- import { existsSync as existsSync14, writeFileSync as writeFileSync6 } from "fs";
7810
+ import { resolve as resolve4, dirname as dirname2, join as join14 } from "path";
7811
+ import { existsSync as existsSync15, writeFileSync as writeFileSync6 } from "fs";
7744
7812
  function registerTasksCommand(program2) {
7745
7813
  const taskCmd = program2.command("tasks").description(getCurrentLanguage() === "en" ? "Task file management" : "\u4EFB\u52A1\u6587\u4EF6\u7BA1\u7406");
7746
7814
  taskCmd.command("init").description(getCurrentLanguage() === "en" ? "Generate task template file" : "\u751F\u6210\u4EFB\u52A1\u6A21\u677F\u6587\u4EF6").argument("[path]", getCurrentLanguage() === "en" ? "Output file path" : "\u8F93\u51FA\u6587\u4EF6\u8DEF\u5F84").action(async (path2) => {
7747
- const filePath = path2 ?? join13(TASK_TEMPLATE_OUTPUT_DIR, generateTaskFilename(TASK_TEMPLATE_FILENAME_PREFIX));
7815
+ const filePath = path2 ?? join14(TASK_TEMPLATE_OUTPUT_DIR, generateTaskFilename(TASK_TEMPLATE_FILENAME_PREFIX));
7748
7816
  await handleTasksInit(filePath);
7749
7817
  });
7750
7818
  }
7751
7819
  async function handleTasksInit(filePath) {
7752
7820
  const absolutePath = resolve4(filePath);
7753
7821
  logger.info(`tasks init \u547D\u4EE4\u6267\u884C\uFF0C\u76EE\u6807\u6587\u4EF6: ${absolutePath}`);
7754
- if (existsSync14(absolutePath)) {
7822
+ if (existsSync15(absolutePath)) {
7755
7823
  throw new ClawtError(MESSAGES.TASK_INIT_FILE_EXISTS(filePath));
7756
7824
  }
7757
7825
  ensureDir(dirname2(absolutePath));
@@ -726,6 +726,33 @@ var VALIDATE_MESSAGES_I18N = {
726
726
  "zh-CN": (branch) => `\u53D8\u66F4\u8FC1\u79FB\u5931\u8D25\uFF1A\u76EE\u6807\u5206\u652F\u4E0E\u4E3B\u5206\u652F\u5DEE\u5F02\u8FC7\u5927
727
727
  \u8BF7\u5148\u6267\u884C clawt sync -b ${branch} \u540C\u6B65\u4E3B\u5206\u652F\u540E\u91CD\u8BD5`
728
728
  },
729
+ /** validate 检测到被 .gitignore 忽略的残留文件冲突 */
730
+ VALIDATE_IGNORED_FILES_CONFLICT: {
731
+ en: (files, cleanCommands) => {
732
+ const maxDisplay = 10;
733
+ const displayed = files.slice(0, maxDisplay).map((f) => ` - ${f}`).join("\n");
734
+ const more = files.length > maxDisplay ? `
735
+ ...(${files.length} files total)` : "";
736
+ const cmds = cleanCommands.map((c) => ` ${c}`).join("\n");
737
+ return `Ignored files left in main worktree are blocking patch apply:
738
+ ${displayed}${more}
739
+
740
+ Please clean up manually and retry:
741
+ ${cmds}`;
742
+ },
743
+ "zh-CN": (files, cleanCommands) => {
744
+ const maxDisplay = 10;
745
+ const displayed = files.slice(0, maxDisplay).map((f) => ` - ${f}`).join("\n");
746
+ const more = files.length > maxDisplay ? `
747
+ ...\uFF08\u5171 ${files.length} \u4E2A\u6587\u4EF6\uFF09` : "";
748
+ const cmds = cleanCommands.map((c) => ` ${c}`).join("\n");
749
+ return `\u68C0\u6D4B\u5230\u88AB .gitignore \u5FFD\u7565\u7684\u6587\u4EF6\u6B8B\u7559\u5728\u4E3B worktree \u4E2D\uFF0C\u5BFC\u81F4\u53D8\u66F4\u65E0\u6CD5\u5E94\u7528\uFF1A
750
+ ${displayed}${more}
751
+
752
+ \u8BF7\u624B\u52A8\u6E05\u7406\u540E\u91CD\u8BD5\uFF1A
753
+ ${cmds}`;
754
+ }
755
+ },
729
756
  /** validate 无可用 worktree */
730
757
  VALIDATE_NO_WORKTREES: {
731
758
  en: "No worktrees available, please create one with clawt run or clawt create first",
@@ -0,0 +1,203 @@
1
+ # sync 后 validate 仍然失败 — 调查记录
2
+
3
+ **日期:** 2026-06-01
4
+
5
+ ---
6
+
7
+ ## 现象描述
8
+
9
+ 用户在交互式面板(`clawt status`)中对目标分支执行 validate,patch apply 失败,提示:
10
+
11
+ ```
12
+ ⚠ Change migration failed: target branch has diverged too far from main
13
+ Please run clawt sync -b <target-branch> first, then retry
14
+ ```
15
+
16
+ 用户选择自动 sync(或手动执行 `clawt sync -b <target-branch>`),sync 成功:
17
+
18
+ ```
19
+ ✓ Synced latest code from <main-branch> to <target-branch>
20
+ Validation branch clawt-validate-<target-branch> has been rebuilt
21
+ ```
22
+
23
+ 但再次执行 validate 时,仍然报同样的错误。
24
+
25
+ ---
26
+
27
+ ## 调查过程
28
+
29
+ ### 第一步:确认分支状态
30
+
31
+ - 主工作分支:HEAD 指向最新 commit
32
+ - 验证分支:与主分支 HEAD 一致(sync 后已重建)
33
+ - 目标分支:包含多次 merge commit,已合并主分支最新代码
34
+
35
+ **结论:** sync 已成功,验证分支已正确重建,分支状态本身没有问题。
36
+
37
+ ### 第二步:模拟 patch apply
38
+
39
+ 执行三点 diff 并尝试 apply:
40
+
41
+ ```bash
42
+ git diff clawt-validate-<target-branch>...<target-branch> --binary | git apply --check -
43
+ ```
44
+
45
+ 输出:
46
+
47
+ ```
48
+ 错误:<ignored-dir>/findings/some-findings.md:已经存在于工作区中
49
+ 错误:<ignored-dir>/plans/some-plan.md:已经存在于工作区中
50
+ ...(共 N 个文件)
51
+ ```
52
+
53
+ **关键发现:** patch 失败的原因不是分支差异过大,而是目标分支中跟踪的某些文件在主 worktree 工作目录中已经物理存在。
54
+
55
+ ### 第三步:追溯文件来源
56
+
57
+ 检查主 worktree 工作目录:
58
+
59
+ ```bash
60
+ ls <ignored-dir>/findings/
61
+ # some-findings.md ← 物理存在
62
+ # ...(共多个文件)
63
+ ```
64
+
65
+ 检查 `git status`:
66
+
67
+ ```
68
+ 无文件要提交,干净的工作区
69
+ ```
70
+
71
+ **为什么这些文件物理存在但 git status 不显示?**
72
+
73
+ 检查 `.gitignore`:
74
+
75
+ ```bash
76
+ cat .gitignore | grep <ignored-dir>
77
+ # <ignored-dir>/ ← 被忽略!
78
+ ```
79
+
80
+ **结论:** 该目录在主分支的 `.gitignore` 中,这些文件是**被忽略的未跟踪文件**,`git status` 不会显示它们。
81
+
82
+ ### 第四步:追溯文件如何进入主 worktree
83
+
84
+ 这些文件是由**之前的 validate 操作**创建的:
85
+
86
+ 1. validate 通过 `git diff HEAD...branch --binary | git apply -` 将目标分支的变更(包括被忽略目录下的文件)应用到主 worktree 工作目录
87
+ 2. 目标分支跟踪了这些文件(尽管 `.gitignore` 中有该目录,但 AI Agent 在 worktree 中用 `git add -f` 强制添加了它们)
88
+ 3. validate 完成后,这些文件作为**未跟踪文件**留在主 worktree 工作目录中
89
+
90
+ ### 第五步:追溯为什么清理无效
91
+
92
+ validate 的清理逻辑(`handleIncrementalValidate` 步骤 2 和 `handleDirtyWorkingDir` 的 reset 选项)使用:
93
+
94
+ ```bash
95
+ git reset --hard # 只影响已跟踪文件
96
+ git clean -fd # 删除未跟踪文件,但**不删除被忽略的文件**
97
+ ```
98
+
99
+ `git clean -fd` 的行为:
100
+ - 删除未跟踪且未被忽略的文件 ✓
101
+ - **跳过**在 `.gitignore` 中的文件 ✗
102
+
103
+ 要删除被忽略的文件,需要 `git clean -fdx`(`-x` 表示包括被忽略的文件)。
104
+
105
+ ### 第六步:确认 sync 为什么不解决问题
106
+
107
+ `executeSyncForBranch` 的操作:
108
+ 1. 检查目标 worktree 未提交变更 → 自动 commit
109
+ 2. 在目标 worktree 中 `git merge <main-branch>`
110
+ 3. 重建验证分支
111
+
112
+ **sync 完全不清理主 worktree 的工作目录**,因此那些被忽略的残留文件仍然存在。
113
+
114
+ ---
115
+
116
+ ## 根本原因
117
+
118
+ **完整因果链:**
119
+
120
+ ```
121
+ 上游 commit 将某目录加入 .gitignore 并从 git 跟踪中移除
122
+
123
+ 目标分支仍然跟踪着自己新增的文件(AI Agent 用 git add -f 强制提交)
124
+
125
+ validate 通过 patch apply 将这些文件创建到主 worktree 工作目录
126
+
127
+ validate 结束后的清理(git clean -fd)无法删除这些文件(因为在 .gitignore 中)
128
+
129
+ 文件作为"被忽略的未跟踪文件"永久残留在主 worktree
130
+
131
+ 下次 validate 的 patch apply 试图创建同名文件 → "已经存在于工作区中" → 失败
132
+
133
+ auto-sync 不清理主 worktree 工作目录 → 问题持续存在(死循环)
134
+ ```
135
+
136
+ **核心矛盾:**
137
+
138
+ - 被忽略目录在主分支 `.gitignore` 中 → `git clean -fd` 不删除
139
+ - 被忽略目录在目标分支被跟踪 → patch 包含这些文件 → apply 时与物理存在的文件冲突
140
+
141
+ ---
142
+
143
+ ## 影响范围
144
+
145
+ 此 bug 在以下条件同时满足时触发:
146
+
147
+ 1. 目标分支跟踪了某些在 `.gitignore` 中的文件(通常是 AI Agent 用 `git add -f` 强制添加)
148
+ 2. 主分支的 `.gitignore` 包含对应路径
149
+ 3. 至少执行过一次 validate(使文件通过 patch 进入主 worktree)
150
+ 4. 后续再次执行 validate(patch apply 因文件已存在而失败)
151
+
152
+ ---
153
+
154
+ ## 技术决策记录
155
+
156
+ | 假设 | 验证结果 |
157
+ |------|----------|
158
+ | 分支确实 diverged(差异过大) | ❌ 排除:三点 diff 正常,merge-base 正确 |
159
+ | 验证分支未正确重建 | ❌ 排除:验证分支与主分支 HEAD 一致 |
160
+ | sync 未成功执行 | ❌ 排除:sync 成功,目标分支已合并主分支 |
161
+ | patch 内容与主分支冲突 | ❌ 排除:不是内容冲突,是文件已存在 |
162
+ | **被忽略文件残留导致 patch apply 失败** | ✅ 确认:`git apply --check` 明确报 "已经存在于工作区中" |
163
+
164
+ ---
165
+
166
+ ## Brainstorming 决策(2026-06-01)
167
+
168
+ ### 方案评估
169
+
170
+ | 方案 | 安全性 | 便利性 | 误删风险 | 代码复杂度 |
171
+ |------|--------|--------|----------|------------|
172
+ | A:检测 + 提示用户手动清理 | 高 | 低 | 无 | 低 |
173
+ | B:检测 + 自动清理后继续 | 中 | 高 | 有 | 略高 |
174
+
175
+ ### 最终决策
176
+
177
+ **选择方案 A(检测 + 提示用户手动清理)**
178
+
179
+ 理由:
180
+ 1. 这类冲突本质上是项目 `.gitignore` 配置与分支跟踪状态不一致导致的,属于需要用户知情的项目级决策
181
+ 2. 不应该由工具静默处理,用户需要知道哪些文件被清理了
182
+ 3. 实现简单,风险为零
183
+
184
+ ### 实现方案
185
+
186
+ 1. 新增 `gitCheckIgnored(paths, cwd)` — 批量检测文件是否被 `.gitignore` 忽略
187
+ 2. 新增 `detectIgnoredFilesInPatch(branchName, mainWorktreePath)` — 检测 patch 中的幽灵文件
188
+ 3. 修改 `migrateChangesViaPatch` — apply 前调用检测函数,有冲突则输出提示并返回失败
189
+ 4. 新增消息常量 `VALIDATE_IGNORED_FILES_CONFLICT`(双语)
190
+
191
+ ### 提示格式
192
+
193
+ ```
194
+ ⚠ 检测到被 .gitignore 忽略的文件残留在主 worktree 中,导致变更无法应用:
195
+ - <ignored-dir>/findings/some-findings.md
196
+ - <ignored-dir>/plans/some-plan.md
197
+ ...(共 N 个文件)
198
+
199
+ 请手动清理后重试:
200
+ git clean -fdx <ignored-dir>/
201
+ ```
202
+
203
+ 清理命令按冲突文件的直接父目录去重生成。
@@ -0,0 +1,412 @@
1
+ # validate 被忽略文件冲突检测 Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** 在 validate 的 patch apply 之前检测被 `.gitignore` 忽略的残留文件(幽灵文件),输出清晰的错误提示和清理命令,替代当前误导性的 "diverged too far" 信息。
6
+
7
+ **Architecture:** 在 `migrateChangesViaPatch` 中,`git apply` 之前新增检测步骤:通过 `git diff --name-only` 获取 patch 文件列表 → `git check-ignore` 筛选被忽略的文件 → `fs.existsSync` 确认物理存在 → 有冲突则输出提示并返回失败。
8
+
9
+ **Tech Stack:** TypeScript, Node.js, Git CLI (`git check-ignore`, `git diff --name-only`), Vitest
10
+
11
+ ---
12
+
13
+ ### Task 1: 新增 `gitCheckIgnored` 函数
14
+
15
+ **Files:**
16
+ - Modify: `src/utils/git-core.ts`
17
+ - Test: `tests/unit/utils/git-core.test.ts`(新建)
18
+
19
+ - [ ] **Step 1: 编写 `gitCheckIgnored` 的失败测试**
20
+
21
+ ```typescript
22
+ // tests/unit/utils/git-core.test.ts
23
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
24
+ import { gitCheckIgnored } from '../../../src/utils/git-core.js';
25
+
26
+ // mock execSync
27
+ vi.mock('node:child_process', () => ({
28
+ execSync: vi.fn(),
29
+ execFileSync: vi.fn(),
30
+ }));
31
+
32
+ import { execSync } from 'node:child_process';
33
+ const mockExecSync = vi.mocked(execSync);
34
+
35
+ describe('gitCheckIgnored', () => {
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ });
39
+
40
+ it('空数组输入时返回空数组', () => {
41
+ const result = gitCheckIgnored([]);
42
+ expect(result).toEqual([]);
43
+ expect(mockExecSync).not.toHaveBeenCalled();
44
+ });
45
+
46
+ it('全部被忽略时返回全部路径', () => {
47
+ mockExecSync.mockReturnValue('docs/superpowers/a.md\ndocs/superpowers/b.md\n');
48
+ const result = gitCheckIgnored(['docs/superpowers/a.md', 'docs/superpowers/b.md']);
49
+ expect(result).toEqual(['docs/superpowers/a.md', 'docs/superpowers/b.md']);
50
+ });
51
+
52
+ it('全部不被忽略时返回空数组', () => {
53
+ // git check-ignore 无匹配时退出码为 1,execSync 抛出异常
54
+ mockExecSync.mockImplementation(() => { throw new Error('exit code 1'); });
55
+ const result = gitCheckIgnored(['src/index.ts']);
56
+ expect(result).toEqual([]);
57
+ });
58
+
59
+ it('混合场景时仅返回被忽略的路径', () => {
60
+ mockExecSync.mockReturnValue('docs/superpowers/a.md\n');
61
+ const result = gitCheckIgnored(['docs/superpowers/a.md', 'src/index.ts']);
62
+ expect(result).toEqual(['docs/superpowers/a.md']);
63
+ });
64
+ });
65
+ ```
66
+
67
+ - [ ] **Step 2: 运行测试确认失败**
68
+
69
+ Run: `npx vitest run tests/unit/utils/git-core.test.ts`
70
+ Expected: FAIL — `gitCheckIgnored` 未定义
71
+
72
+ - [ ] **Step 3: 实现 `gitCheckIgnored`**
73
+
74
+ 在 `src/utils/git-core.ts` 中新增:
75
+
76
+ ```typescript
77
+ /**
78
+ * 批量检测文件是否被 .gitignore 忽略
79
+ * 使用 git check-ignore 命令,退出码 1 表示无匹配(非错误)
80
+ * @param {string[]} paths - 要检测的文件路径列表
81
+ * @param {string} [cwd] - 工作目录
82
+ * @returns {string[]} 被忽略的文件路径列表
83
+ */
84
+ export function gitCheckIgnored(paths: string[], cwd?: string): string[] {
85
+ if (paths.length === 0) return [];
86
+
87
+ try {
88
+ const output = execSync(`git check-ignore ${paths.map(p => `"${p}"`).join(' ')}`, {
89
+ cwd,
90
+ encoding: 'utf-8',
91
+ stdio: ['pipe', 'pipe', 'pipe'],
92
+ });
93
+ return output.trim().split('\n').filter(Boolean);
94
+ } catch {
95
+ // git check-ignore 退出码 1 表示无匹配文件,属于正常情况
96
+ return [];
97
+ }
98
+ }
99
+ ```
100
+
101
+ - [ ] **Step 4: 运行测试确认通过**
102
+
103
+ Run: `npx vitest run tests/unit/utils/git-core.test.ts`
104
+ Expected: PASS
105
+
106
+ - [ ] **Step 5: 在 `src/utils/index.ts` 中导出**
107
+
108
+ 在 `src/utils/index.ts` 的 git-core 导出块中添加 `gitCheckIgnored`:
109
+
110
+ ```typescript
111
+ // 在现有的 git-core 导出列表中添加 gitCheckIgnored
112
+ ```
113
+
114
+ 具体位置:在 `export { ... } from './git.js'` 块中添加 `gitCheckIgnored`(因为 `git.ts` 通过 `export * from './git-core.js'` 重导出)。
115
+
116
+ - [ ] **Step 6: 提交**
117
+
118
+ ```bash
119
+ git add src/utils/git-core.ts src/utils/index.ts tests/unit/utils/git-core.test.ts
120
+ git commit -m "feat: add gitCheckIgnored for batch gitignore detection"
121
+ ```
122
+
123
+ ---
124
+
125
+ ### Task 2: 新增 `detectIgnoredFilesInPatch` 函数
126
+
127
+ **Files:**
128
+ - Modify: `src/utils/validate-core.ts`
129
+ - Test: `tests/unit/utils/validate-core.test.ts`(新建)
130
+
131
+ - [ ] **Step 1: 编写 `detectIgnoredFilesInPatch` 的失败测试**
132
+
133
+ ```typescript
134
+ // tests/unit/utils/validate-core.test.ts
135
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
136
+
137
+ vi.mock('../../../src/utils/git-core.js', async () => {
138
+ const actual = await vi.importActual('../../../src/utils/git-core.js');
139
+ return { ...actual, gitCheckIgnored: vi.fn(), execSync: vi.fn() };
140
+ });
141
+
142
+ vi.mock('node:fs', () => ({
143
+ existsSync: vi.fn(),
144
+ }));
145
+
146
+ import { detectIgnoredFilesInPatch } from '../../../src/utils/validate-core.js';
147
+ import { gitCheckIgnored } from '../../../src/utils/git-core.js';
148
+ import { existsSync } from 'node:fs';
149
+ import { execSync } from 'node:child_process';
150
+
151
+ const mockGitCheckIgnored = vi.mocked(gitCheckIgnored);
152
+ const mockExistsSync = vi.mocked(existsSync);
153
+ const mockExecSync = vi.mocked(execSync);
154
+
155
+ describe('detectIgnoredFilesInPatch', () => {
156
+ beforeEach(() => {
157
+ vi.clearAllMocks();
158
+ });
159
+
160
+ it('无幽灵文件时返回空数组', () => {
161
+ mockExecSync.mockReturnValue('src/a.ts\nsrc/b.ts\n');
162
+ mockGitCheckIgnored.mockReturnValue([]);
163
+ const result = detectIgnoredFilesInPatch('feature', '/main');
164
+ expect(result).toEqual([]);
165
+ });
166
+
167
+ it('检测到幽灵文件时返回文件列表', () => {
168
+ mockExecSync.mockReturnValue('docs/superpowers/a.md\nsrc/b.ts\n');
169
+ mockGitCheckIgnored.mockReturnValue(['docs/superpowers/a.md']);
170
+ mockExistsSync.mockImplementation((p: string) => p === '/main/docs/superpowers/a.md');
171
+ const result = detectIgnoredFilesInPatch('feature', '/main');
172
+ expect(result).toEqual(['docs/superpowers/a.md']);
173
+ });
174
+
175
+ it('被忽略但物理不存在的文件不包含在结果中', () => {
176
+ mockExecSync.mockReturnValue('docs/superpowers/a.md\n');
177
+ mockGitCheckIgnored.mockReturnValue(['docs/superpowers/a.md']);
178
+ mockExistsSync.mockReturnValue(false);
179
+ const result = detectIgnoredFilesInPatch('feature', '/main');
180
+ expect(result).toEqual([]);
181
+ });
182
+
183
+ it('git diff --name-only 失败时返回空数组(降级)', () => {
184
+ mockExecSync.mockImplementation(() => { throw new Error('fatal'); });
185
+ const result = detectIgnoredFilesInPatch('feature', '/main');
186
+ expect(result).toEqual([]);
187
+ });
188
+ });
189
+ ```
190
+
191
+ - [ ] **Step 2: 运行测试确认失败**
192
+
193
+ Run: `npx vitest run tests/unit/utils/validate-core.test.ts`
194
+ Expected: FAIL — `detectIgnoredFilesInPatch` 未定义
195
+
196
+ - [ ] **Step 3: 实现 `detectIgnoredFilesInPatch`**
197
+
198
+ 在 `src/utils/validate-core.ts` 中新增:
199
+
200
+ ```typescript
201
+ import { existsSync } from 'node:fs';
202
+ import { join } from 'node:path';
203
+ import { execSync } from 'node:child_process';
204
+ import { EXEC_MAX_BUFFER } from '../constants/index.js';
205
+
206
+ /**
207
+ * 检测 patch 中被 .gitignore 忽略且物理存在于主 worktree 的文件(幽灵文件)
208
+ * 这些文件会导致 git apply 失败("已经存在于工作区中")
209
+ * @param {string} branchName - 目标分支名
210
+ * @param {string} mainWorktreePath - 主 worktree 路径
211
+ * @returns {string[]} 幽灵文件的相对路径列表
212
+ */
213
+ export function detectIgnoredFilesInPatch(branchName: string, mainWorktreePath: string): string[] {
214
+ let patchFiles: string[];
215
+ try {
216
+ const output = execSync(`git diff --name-only HEAD...${branchName}`, {
217
+ cwd: mainWorktreePath,
218
+ encoding: 'utf-8',
219
+ stdio: ['pipe', 'pipe', 'pipe'],
220
+ maxBuffer: EXEC_MAX_BUFFER,
221
+ });
222
+ patchFiles = output.trim().split('\n').filter(Boolean);
223
+ } catch {
224
+ // diff 失败时跳过检测,降级为当前行为(让 apply 自行报错)
225
+ return [];
226
+ }
227
+
228
+ if (patchFiles.length === 0) return [];
229
+
230
+ const ignoredFiles = gitCheckIgnored(patchFiles, mainWorktreePath);
231
+ if (ignoredFiles.length === 0) return [];
232
+
233
+ // 仅保留物理存在的文件(幽灵文件)
234
+ return ignoredFiles.filter(file => existsSync(join(mainWorktreePath, file)));
235
+ }
236
+ ```
237
+
238
+ 注意:需要在文件顶部的 import 中添加 `gitCheckIgnored`(从 `./index.js` 导入)和 `EXEC_MAX_BUFFER`(从 `../constants/index.js` 导入)。
239
+
240
+ - [ ] **Step 4: 运行测试确认通过**
241
+
242
+ Run: `npx vitest run tests/unit/utils/validate-core.test.ts`
243
+ Expected: PASS
244
+
245
+ - [ ] **Step 5: 在 `src/utils/index.ts` 中导出**
246
+
247
+ 在 `src/utils/index.ts` 的 validate-core 导出行中添加 `detectIgnoredFilesInPatch`:
248
+
249
+ ```typescript
250
+ export { migrateChangesViaPatch, computeCurrentTreeHash, saveCurrentSnapshotTree, loadOldSnapshotToStage, switchToValidateBranch, detectIgnoredFilesInPatch } from './validate-core.js';
251
+ ```
252
+
253
+ - [ ] **Step 6: 提交**
254
+
255
+ ```bash
256
+ git add src/utils/validate-core.ts src/utils/index.ts tests/unit/utils/validate-core.test.ts
257
+ git commit -m "feat: add detectIgnoredFilesInPatch for ghost file detection"
258
+ ```
259
+
260
+ ---
261
+
262
+ ### Task 3: 新增消息常量 `VALIDATE_IGNORED_FILES_CONFLICT`
263
+
264
+ **Files:**
265
+ - Modify: `src/constants/messages/validate.ts`
266
+
267
+ - [ ] **Step 1: 添加双语消息常量**
268
+
269
+ 在 `src/constants/messages/validate.ts` 的 `VALIDATE_MESSAGES_I18N` 对象中,在 `VALIDATE_PATCH_APPLY_FAILED` 之后添加:
270
+
271
+ ```typescript
272
+ /** validate 检测到被 .gitignore 忽略的残留文件冲突 */
273
+ VALIDATE_IGNORED_FILES_CONFLICT: {
274
+ en: (files: string[], cleanCommands: string[]) => {
275
+ const maxDisplay = 10;
276
+ const displayed = files.slice(0, maxDisplay).map(f => ` - ${f}`).join('\n');
277
+ const more = files.length > maxDisplay ? `\n ...(${files.length} files total)` : '';
278
+ const cmds = cleanCommands.map(c => ` ${c}`).join('\n');
279
+ return `Ignored files left in main worktree are blocking patch apply:\n${displayed}${more}\n\nPlease clean up manually and retry:\n${cmds}`;
280
+ },
281
+ 'zh-CN': (files: string[], cleanCommands: string[]) => {
282
+ const maxDisplay = 10;
283
+ const displayed = files.slice(0, maxDisplay).map(f => ` - ${f}`).join('\n');
284
+ const more = files.length > maxDisplay ? `\n ...(共 ${files.length} 个文件)` : '';
285
+ const cmds = cleanCommands.map(c => ` ${c}`).join('\n');
286
+ return `检测到被 .gitignore 忽略的文件残留在主 worktree 中,导致变更无法应用:\n${displayed}${more}\n\n请手动清理后重试:\n${cmds}`;
287
+ },
288
+ },
289
+ ```
290
+
291
+ - [ ] **Step 2: 提交**
292
+
293
+ ```bash
294
+ git add src/constants/messages/validate.ts
295
+ git commit -m "feat: add VALIDATE_IGNORED_FILES_CONFLICT message constant"
296
+ ```
297
+
298
+ ---
299
+
300
+ ### Task 4: 修改 `migrateChangesViaPatch` 集成检测逻辑
301
+
302
+ **Files:**
303
+ - Modify: `src/utils/validate-core.ts`
304
+
305
+ - [ ] **Step 1: 修改 `migrateChangesViaPatch`**
306
+
307
+ 在 `migrateChangesViaPatch` 函数中,`gitApplyFromStdin` 调用之前,添加幽灵文件检测逻辑:
308
+
309
+ ```typescript
310
+ export function migrateChangesViaPatch(targetWorktreePath: string, mainWorktreePath: string, branchName: string, hasUncommitted: boolean): { success: boolean } {
311
+ let didTempCommit = false;
312
+
313
+ try {
314
+ // 如果有未提交修改,先做临时 commit 以便 diff 能捕获全部变更
315
+ if (hasUncommitted) {
316
+ gitAddAll(targetWorktreePath);
317
+ gitCommit('clawt:temp-commit-for-validate', targetWorktreePath);
318
+ didTempCommit = true;
319
+ }
320
+
321
+ // 在主 worktree 执行三点 diff,获取目标分支自分叉点以来的全量变更
322
+ const patch = gitDiffBinaryAgainstBranch(branchName, mainWorktreePath);
323
+
324
+ // 检测被 .gitignore 忽略的残留文件(幽灵文件),在 apply 之前拦截
325
+ const ignoredFiles = detectIgnoredFilesInPatch(branchName, mainWorktreePath);
326
+ if (ignoredFiles.length > 0) {
327
+ const cleanCommands = buildCleanCommands(ignoredFiles);
328
+ logger.warn(`检测到 ${ignoredFiles.length} 个被忽略的残留文件冲突`);
329
+ printWarning(MESSAGES.VALIDATE_IGNORED_FILES_CONFLICT(ignoredFiles, cleanCommands));
330
+ return { success: false };
331
+ }
332
+
333
+ // 应用 patch 到主 worktree 工作目录
334
+ if (patch.length > 0) {
335
+ try {
336
+ gitApplyFromStdin(patch, mainWorktreePath);
337
+ } catch (error) {
338
+ logger.warn(`patch apply 失败: ${error}`);
339
+ printWarning(MESSAGES.VALIDATE_PATCH_APPLY_FAILED(branchName));
340
+ return { success: false };
341
+ }
342
+ }
343
+
344
+ return { success: true };
345
+ } finally {
346
+ // ...(finally 块保持不变)
347
+ }
348
+ }
349
+ ```
350
+
351
+ - [ ] **Step 2: 实现 `buildCleanCommands` 辅助函数**
352
+
353
+ 在 `src/utils/validate-core.ts` 中新增(不导出,仅内部使用):
354
+
355
+ ```typescript
356
+ /**
357
+ * 根据冲突文件列表生成 git clean 清理命令
358
+ * 按直接父目录去重,生成针对性的清理命令
359
+ * @param {string[]} files - 冲突文件的相对路径列表
360
+ * @returns {string[]} 清理命令列表
361
+ */
362
+ function buildCleanCommands(files: string[]): string[] {
363
+ const dirs = new Set<string>();
364
+ for (const file of files) {
365
+ const lastSlash = file.lastIndexOf('/');
366
+ const dir = lastSlash > 0 ? file.substring(0, lastSlash) : '.';
367
+ dirs.add(dir);
368
+ }
369
+ return Array.from(dirs).map(dir => `git clean -fdx ${dir}/`);
370
+ }
371
+ ```
372
+
373
+ - [ ] **Step 3: 确保 import 完整**
374
+
375
+ 在 `src/utils/validate-core.ts` 顶部确认以下 import 存在:
376
+ - `MESSAGES` 从 `'../constants/index.js'`(已有)
377
+ - `printWarning` 从 `'./index.js'`(已有)
378
+ - `detectIgnoredFilesInPatch` 在同文件中定义,无需额外 import
379
+ - `buildCleanCommands` 在同文件中定义,无需额外 import
380
+
381
+ - [ ] **Step 4: 运行全部测试确认无回归**
382
+
383
+ Run: `npx vitest run`
384
+ Expected: 全部 PASS
385
+
386
+ - [ ] **Step 5: 提交**
387
+
388
+ ```bash
389
+ git add src/utils/validate-core.ts
390
+ git commit -m "feat: integrate ghost file detection into migrateChangesViaPatch"
391
+ ```
392
+
393
+ ---
394
+
395
+ ### Task 5: 集成验证
396
+
397
+ - [ ] **Step 1: 构建项目确认无编译错误**
398
+
399
+ Run: `npm run build`
400
+ Expected: 构建成功
401
+
402
+ - [ ] **Step 2: 运行全部测试**
403
+
404
+ Run: `npm test`
405
+ Expected: 全部 PASS
406
+
407
+ - [ ] **Step 3: 提交全部变更(如有遗漏)**
408
+
409
+ ```bash
410
+ git add -A
411
+ git commit -m "feat: detect ignored ghost files before patch apply in validate"
412
+ ```
@@ -0,0 +1,76 @@
1
+ # validate 被忽略文件冲突检测 — 设计文档
2
+
3
+ ## 问题
4
+
5
+ 当目标分支跟踪了某些在 `.gitignore` 中的文件(如 AI Agent 用 `git add -f` 强制提交),validate 通过 `git apply` 将这些文件创建到主 worktree 工作目录后,`git clean -fd` 无法清理它们(因为被 `.gitignore` 忽略)。后续 validate 的 patch apply 因文件已存在而失败,且 sync 无法打破这个死循环。
6
+
7
+ ## 修复目标
8
+
9
+ 在 `migrateChangesViaPatch` 中,`git apply` 之前检测被 `.gitignore` 忽略且物理存在于主 worktree 工作目录的文件("幽灵文件"),若检测到冲突则输出清晰的错误提示和可执行的清理命令,而非当前的误导性信息 "diverged too far from main"。
10
+
11
+ ## 验收标准
12
+
13
+ 1. 检测到幽灵文件时,输出文件列表和 `git clean -fdx <dir>` 清理命令
14
+ 2. 未检测到幽灵文件时,行为与当前完全一致(无回归)
15
+ 3. 提示信息为双语(中英)
16
+ 4. 有单元测试覆盖检测逻辑
17
+
18
+ ## 架构
19
+
20
+ ### 检测流程
21
+
22
+ ```
23
+ migrateChangesViaPatch()
24
+ ├── gitDiffBinaryAgainstBranch() → 获取 patch
25
+ ├── detectIgnoredFilesInPatch() → 新增:检测幽灵文件
26
+ │ ├── git diff --name-only HEAD...branch → 获取 patch 涉及的文件列表
27
+ │ ├── git check-ignore <files> → 筛选被忽略的文件
28
+ │ └── fs.existsSync → 筛选物理存在的文件
29
+ ├── 有冲突 → printWarning + return { success: false }
30
+ └── 无冲突 → gitApplyFromStdin() → 正常 apply
31
+ ```
32
+
33
+ ### 新增函数
34
+
35
+ | 函数 | 文件 | 职责 |
36
+ |------|------|------|
37
+ | `gitCheckIgnored(paths, cwd)` | `src/utils/git-core.ts` | 批量检测文件是否被 `.gitignore` 忽略 |
38
+ | `detectIgnoredFilesInPatch(branchName, mainWorktreePath)` | `src/utils/validate-core.ts` | 检测 patch 中的幽灵文件列表 |
39
+
40
+ ### 修改函数
41
+
42
+ | 函数 | 文件 | 变更 |
43
+ |------|------|------|
44
+ | `migrateChangesViaPatch` | `src/utils/validate-core.ts` | apply 前调用检测函数 |
45
+
46
+ ### 新增消息常量
47
+
48
+ | 常量 | 文件 | 用途 |
49
+ |------|------|------|
50
+ | `VALIDATE_IGNORED_FILES_CONFLICT` | `src/constants/messages/validate.ts` | 幽灵文件冲突提示(含文件列表和清理命令) |
51
+
52
+ ### 提示格式
53
+
54
+ ```
55
+ ⚠ 检测到被 .gitignore 忽略的文件残留在主 worktree 中,导致变更无法应用:
56
+ - docs/superpowers/findings/2026-05-30-chat-message-block-findings.md
57
+ - docs/superpowers/plans/2026-05-30-chat-message-block.md
58
+ ...(共 18 个文件)
59
+
60
+ 请手动清理后重试:
61
+ git clean -fdx docs/superpowers/
62
+ ```
63
+
64
+ 清理命令按冲突文件的直接父目录去重生成。
65
+
66
+ ## 错误处理
67
+
68
+ - `git check-ignore` 无匹配时退出码为 1(非错误),需 catch 后返回空数组
69
+ - `git diff --name-only` 失败时不阻断流程,跳过检测继续 apply(降级为当前行为)
70
+ - 检测函数本身的异常不应阻断 validate 流程
71
+
72
+ ## 回归测试要求
73
+
74
+ - 无幽灵文件时:validate 正常通过(现有测试覆盖)
75
+ - 有幽灵文件时:validate 返回 `{ success: false }` 并输出提示
76
+ - `gitCheckIgnored` 单元测试:空输入、全部忽略、全部不忽略、混合场景
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.5",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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',
@@ -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,6 +63,7 @@ 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';
@@ -92,7 +93,7 @@ export { getValidateBranchName, createValidateBranch, deleteValidateBranch, rebu
92
93
  export { safeStringify } from './json.js';
93
94
  export { isNonInteractive, setNonInteractive } from './interactive.js';
94
95
  export { executeRunCommand } from './validate-runner.js';
95
- export { migrateChangesViaPatch, computeCurrentTreeHash, saveCurrentSnapshotTree, loadOldSnapshotToStage, switchToValidateBranch } from './validate-core.js';
96
+ export { migrateChangesViaPatch, computeCurrentTreeHash, saveCurrentSnapshotTree, loadOldSnapshotToStage, switchToValidateBranch, detectIgnoredFilesInPatch } from './validate-core.js';
96
97
  export { InteractivePanel } from './interactive-panel.js';
97
98
  export { buildPanelFrame, buildGroupedWorktreeLines, buildDisplayOrder, renderDateSeparator, renderWorktreeBlock, renderSnapshotSummary, renderFooter, calculateVisibleRows } from './interactive-panel-render.js';
98
99
  export type { PanelLine } from './interactive-panel-render.js';
@@ -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,43 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { gitCheckIgnored } from '../../../src/utils/git-core.js';
3
+
4
+ // mock child_process
5
+ vi.mock('node:child_process', () => ({
6
+ execSync: vi.fn(),
7
+ execFileSync: vi.fn(),
8
+ exec: vi.fn(),
9
+ }));
10
+
11
+ import { execFileSync } from 'node:child_process';
12
+ const mockExecFileSync = vi.mocked(execFileSync);
13
+
14
+ describe('gitCheckIgnored', () => {
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ });
18
+
19
+ it('空数组输入时返回空数组', () => {
20
+ const result = gitCheckIgnored([]);
21
+ expect(result).toEqual([]);
22
+ expect(mockExecFileSync).not.toHaveBeenCalled();
23
+ });
24
+
25
+ it('全部被忽略时返回全部路径', () => {
26
+ mockExecFileSync.mockReturnValue('docs/superpowers/a.md\ndocs/superpowers/b.md\n');
27
+ const result = gitCheckIgnored(['docs/superpowers/a.md', 'docs/superpowers/b.md']);
28
+ expect(result).toEqual(['docs/superpowers/a.md', 'docs/superpowers/b.md']);
29
+ });
30
+
31
+ it('全部不被忽略时返回空数组', () => {
32
+ // git check-ignore 无匹配时退出码为 1,execFileSync 抛出异常
33
+ mockExecFileSync.mockImplementation(() => { throw new Error('exit code 1'); });
34
+ const result = gitCheckIgnored(['src/index.ts']);
35
+ expect(result).toEqual([]);
36
+ });
37
+
38
+ it('混合场景时仅返回被忽略的路径', () => {
39
+ mockExecFileSync.mockReturnValue('docs/superpowers/a.md\n');
40
+ const result = gitCheckIgnored(['docs/superpowers/a.md', 'src/index.ts']);
41
+ expect(result).toEqual(['docs/superpowers/a.md']);
42
+ });
43
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ vi.mock('../../../src/utils/git-core.js', async () => {
4
+ const actual = await vi.importActual('../../../src/utils/git-core.js');
5
+ return { ...actual, gitCheckIgnored: vi.fn() };
6
+ });
7
+
8
+ vi.mock('node:fs', () => ({
9
+ existsSync: vi.fn(),
10
+ mkdirSync: vi.fn(),
11
+ }));
12
+
13
+ vi.mock('../../../src/utils/shell.js', async () => {
14
+ const actual = await vi.importActual('../../../src/utils/shell.js');
15
+ return { ...actual, execCommand: vi.fn() };
16
+ });
17
+
18
+ import { detectIgnoredFilesInPatch } from '../../../src/utils/validate-core.js';
19
+ import { gitCheckIgnored } from '../../../src/utils/git-core.js';
20
+ import { existsSync } from 'node:fs';
21
+ import { execCommand } from '../../../src/utils/shell.js';
22
+
23
+ const mockGitCheckIgnored = vi.mocked(gitCheckIgnored);
24
+ const mockExistsSync = vi.mocked(existsSync);
25
+ const mockExecCommand = vi.mocked(execCommand);
26
+
27
+ describe('detectIgnoredFilesInPatch', () => {
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+ });
31
+
32
+ it('无幽灵文件时返回空数组', () => {
33
+ mockExecCommand.mockReturnValue('src/a.ts\nsrc/b.ts');
34
+ mockGitCheckIgnored.mockReturnValue([]);
35
+ const result = detectIgnoredFilesInPatch('feature', '/main');
36
+ expect(result).toEqual([]);
37
+ });
38
+
39
+ it('检测到幽灵文件时返回文件列表', () => {
40
+ mockExecCommand.mockReturnValue('docs/superpowers/a.md\nsrc/b.ts');
41
+ mockGitCheckIgnored.mockReturnValue(['docs/superpowers/a.md']);
42
+ mockExistsSync.mockImplementation((p: string) => p === '/main/docs/superpowers/a.md');
43
+ const result = detectIgnoredFilesInPatch('feature', '/main');
44
+ expect(result).toEqual(['docs/superpowers/a.md']);
45
+ });
46
+
47
+ it('被忽略但物理不存在的文件不包含在结果中', () => {
48
+ mockExecCommand.mockReturnValue('docs/superpowers/a.md');
49
+ mockGitCheckIgnored.mockReturnValue(['docs/superpowers/a.md']);
50
+ mockExistsSync.mockReturnValue(false);
51
+ const result = detectIgnoredFilesInPatch('feature', '/main');
52
+ expect(result).toEqual([]);
53
+ });
54
+
55
+ it('git diff --name-only 失败时返回空数组(降级)', () => {
56
+ mockExecCommand.mockImplementation(() => { throw new Error('fatal'); });
57
+ const result = detectIgnoredFilesInPatch('feature', '/main');
58
+ expect(result).toEqual([]);
59
+ });
60
+ });