clawt 3.9.11 → 3.9.12

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/README.md CHANGED
@@ -159,6 +159,8 @@ clawt validate -b <branch> -r "pnpm test & pnpm build" # Run multiple commands
159
159
 
160
160
  Supports incremental mode: when re-validating the same branch, you can view incremental diffs between validations via `git diff`.
161
161
 
162
+ Automatically removes external symlinks (e.g., `node_modules` links to the main worktree) before detecting changes — these symlinks, typically created by AI Agents, can cause patch apply failures.
163
+
162
164
  When patch apply fails (target branch diverges too much from main), it automatically prompts whether to run `sync` to synchronize the main branch to the target worktree — no manual action needed.
163
165
 
164
166
  The `-r, --run` option auto-executes a specified command in the main worktree after successful validation (e.g., tests, builds). Command failure does not affect the validation result. Without `-r`, it automatically reads from the project's `validateRunCommand` config (configurable via `clawt init show`). Use `&` to separate multiple commands for parallel execution:
package/README.zh-CN.md CHANGED
@@ -159,6 +159,8 @@ clawt validate -b <branch> -r "pnpm test & pnpm build" # 并行执行多个命
159
159
 
160
160
  支持增量模式:再次 validate 同一分支时,可通过 `git diff` 查看两次之间的增量差异。
161
161
 
162
+ 执行前会自动清理目标 worktree 中指向外部的软链接(如 AI Agent 创建的 `node_modules` 链接),这些软链接会导致 patch apply 失败。
163
+
162
164
  当 patch apply 失败(目标分支与主分支差异过大)时,会自动询问是否执行 `sync` 同步主分支到目标 worktree,无需手动操作。
163
165
 
164
166
  `-r, --run` 选项可在 validate 成功后自动在主 worktree 中执行指定命令(如测试、构建等),命令执行失败不影响 validate 结果。不传 `-r` 时会自动从项目配置的 `validateRunCommand` 读取(可通过 `clawt init show` 设置)。支持用 `&` 分隔多个命令并行执行:
package/dist/index.js CHANGED
@@ -269,6 +269,8 @@ ${branches.map((b) => ` - ${b}`).join("\n")}`,
269
269
  VALIDATE_RUN_ERROR_COPIED: "\u2702 \u9519\u8BEF\u4FE1\u606F\u5DF2\u590D\u5236\u5230\u526A\u8D34\u677F",
270
270
  /** 剪贴板复制失败提示 */
271
271
  VALIDATE_RUN_ERROR_COPY_FAILED: "\u26A0 \u9519\u8BEF\u4FE1\u606F\u590D\u5236\u5230\u526A\u8D34\u677F\u5931\u8D25",
272
+ /** 检测到外部软链接警告 */
273
+ VALIDATE_EXTERNAL_SYMLINKS_FOUND: (count) => `\u26A0 \u68C0\u6D4B\u5230 ${count} \u4E2A\u6307\u5411 worktree \u5916\u90E8\u7684\u8F6F\u94FE\u63A5\uFF08\u53EF\u80FD\u7531 AI Agent \u521B\u5EFA\uFF09\uFF0C\u5DF2\u81EA\u52A8\u79FB\u9664`,
272
274
  /** 单命令(含 && 链)剪贴板错误格式 */
273
275
  VALIDATE_CLIPBOARD_SINGLE_ERROR: (command, stderr) => `${command} \u6307\u4EE4\u6267\u884C\u51FA\u9519\uFF0C\u9519\u8BEF\u4FE1\u606F\uFF1A
274
276
  ${stderr}`,
@@ -1166,7 +1168,7 @@ function parseParallelCommands(commandString) {
1166
1168
  return parts.map((part) => part.replace(new RegExp(placeholder, "g"), "&&").trim()).filter((part) => part.length > 0);
1167
1169
  }
1168
1170
  function spawnWithStderrCapture(command, options) {
1169
- return new Promise((resolve4) => {
1171
+ return new Promise((resolve5) => {
1170
1172
  const child = spawn(command, {
1171
1173
  cwd: options?.cwd,
1172
1174
  stdio: ["inherit", "inherit", "pipe"],
@@ -1178,14 +1180,14 @@ function spawnWithStderrCapture(command, options) {
1178
1180
  stderrChunks.push(chunk);
1179
1181
  });
1180
1182
  child.on("error", (err) => {
1181
- resolve4({
1183
+ resolve5({
1182
1184
  exitCode: 1,
1183
1185
  error: err.message,
1184
1186
  stderr: Buffer.concat(stderrChunks).toString("utf-8")
1185
1187
  });
1186
1188
  });
1187
1189
  child.on("close", (code) => {
1188
- resolve4({
1190
+ resolve5({
1189
1191
  exitCode: code ?? 1,
1190
1192
  stderr: Buffer.concat(stderrChunks).toString("utf-8")
1191
1193
  });
@@ -1526,14 +1528,14 @@ function confirmAction(question, nonInteractiveDefault = true) {
1526
1528
  if (isNonInteractive()) {
1527
1529
  return Promise.resolve(nonInteractiveDefault);
1528
1530
  }
1529
- return new Promise((resolve4) => {
1531
+ return new Promise((resolve5) => {
1530
1532
  const rl = createInterface({
1531
1533
  input: process.stdin,
1532
1534
  output: process.stdout
1533
1535
  });
1534
1536
  rl.question(`${question} (y/N) `, (answer) => {
1535
1537
  rl.close();
1536
- resolve4(answer.toLowerCase() === "y");
1538
+ resolve5(answer.toLowerCase() === "y");
1537
1539
  });
1538
1540
  });
1539
1541
  }
@@ -2124,6 +2126,42 @@ function getWorktreeStatus(worktree) {
2124
2126
  }
2125
2127
  }
2126
2128
 
2129
+ // src/utils/symlink-guard.ts
2130
+ import { readdirSync as readdirSync3, lstatSync, unlinkSync, readlinkSync } from "fs";
2131
+ import { join as join6, relative, isAbsolute as isAbsolute2, resolve } from "path";
2132
+ function isExternalSymlink(linkPath, worktreeRoot) {
2133
+ try {
2134
+ const target = readlinkSync(linkPath);
2135
+ const resolvedTarget = isAbsolute2(target) ? target : resolve(worktreeRoot, target);
2136
+ const relativePath = relative(worktreeRoot, resolvedTarget);
2137
+ return relativePath.startsWith("..") || isAbsolute2(relativePath);
2138
+ } catch {
2139
+ return false;
2140
+ }
2141
+ }
2142
+ function removeExternalSymlinks(dir) {
2143
+ const removed = [];
2144
+ try {
2145
+ const entries = readdirSync3(dir, { withFileTypes: true });
2146
+ for (const entry of entries) {
2147
+ if (!entry.isSymbolicLink()) continue;
2148
+ const fullPath = join6(dir, entry.name);
2149
+ if (!isExternalSymlink(fullPath, dir)) continue;
2150
+ try {
2151
+ const stat = lstatSync(fullPath);
2152
+ if (!stat.isSymbolicLink()) continue;
2153
+ unlinkSync(fullPath);
2154
+ removed.push(fullPath);
2155
+ logger.info(`\u5DF2\u79FB\u9664\u5916\u90E8\u8F6F\u94FE\u63A5: ${fullPath}`);
2156
+ } catch (error) {
2157
+ logger.warn(`\u79FB\u9664\u5916\u90E8\u8F6F\u94FE\u63A5\u5931\u8D25: ${fullPath} - ${error}`);
2158
+ }
2159
+ }
2160
+ } catch {
2161
+ }
2162
+ return removed;
2163
+ }
2164
+
2127
2165
  // src/utils/prompt.ts
2128
2166
  import Enquirer2 from "enquirer";
2129
2167
  async function promptCommitMessage(promptMessage, nonInteractiveErrorMessage) {
@@ -2142,8 +2180,8 @@ async function promptCommitMessage(promptMessage, nonInteractiveErrorMessage) {
2142
2180
 
2143
2181
  // src/utils/claude.ts
2144
2182
  import { spawnSync as spawnSync3 } from "child_process";
2145
- import { existsSync as existsSync7, readdirSync as readdirSync3 } from "fs";
2146
- import { join as join6 } from "path";
2183
+ import { existsSync as existsSync7, readdirSync as readdirSync4 } from "fs";
2184
+ import { join as join7 } from "path";
2147
2185
 
2148
2186
  // src/utils/terminal.ts
2149
2187
  import { execFileSync as execFileSync3 } from "child_process";
@@ -2287,11 +2325,11 @@ function encodeClaudeProjectPath(absolutePath) {
2287
2325
  }
2288
2326
  function hasClaudeSessionHistory(worktreePath) {
2289
2327
  const encodedName = encodeClaudeProjectPath(worktreePath);
2290
- const projectDir = join6(CLAUDE_PROJECTS_DIR, encodedName);
2328
+ const projectDir = join7(CLAUDE_PROJECTS_DIR, encodedName);
2291
2329
  if (!existsSync7(projectDir)) {
2292
2330
  return false;
2293
2331
  }
2294
- const entries = readdirSync3(projectDir);
2332
+ const entries = readdirSync4(projectDir);
2295
2333
  return entries.some((entry) => entry.endsWith(".jsonl"));
2296
2334
  }
2297
2335
  function launchInteractiveClaude(worktree, options = {}) {
@@ -2341,16 +2379,16 @@ function launchInteractiveClaudeInNewTerminal(worktree, hasPreviousSession) {
2341
2379
  }
2342
2380
 
2343
2381
  // src/utils/validate-snapshot.ts
2344
- import { join as join7 } from "path";
2345
- import { existsSync as existsSync8, readFileSync as readFileSync3, writeFileSync as writeFileSync3, unlinkSync, readdirSync as readdirSync4, rmdirSync as rmdirSync2, statSync as statSync2 } from "fs";
2382
+ import { join as join8 } from "path";
2383
+ import { existsSync as existsSync8, readFileSync as readFileSync3, writeFileSync as writeFileSync3, unlinkSync as unlinkSync2, readdirSync as readdirSync5, rmdirSync as rmdirSync2, statSync as statSync2 } from "fs";
2346
2384
  function getSnapshotPath(projectName, branchName) {
2347
- return join7(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.tree`);
2385
+ return join8(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.tree`);
2348
2386
  }
2349
2387
  function getSnapshotHeadPath(projectName, branchName) {
2350
- return join7(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.head`);
2388
+ return join8(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.head`);
2351
2389
  }
2352
2390
  function getSnapshotStagedPath(projectName, branchName) {
2353
- return join7(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.staged`);
2391
+ return join8(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.staged`);
2354
2392
  }
2355
2393
  function hasSnapshot(projectName, branchName) {
2356
2394
  return existsSync8(getSnapshotPath(projectName, branchName));
@@ -2372,7 +2410,7 @@ function readSnapshot(projectName, branchName) {
2372
2410
  return { treeHash, headCommitHash, stagedTreeHash };
2373
2411
  }
2374
2412
  function writeSnapshot(projectName, branchName, treeHash, headCommitHash, stagedTreeHash) {
2375
- const snapshotDir = join7(VALIDATE_SNAPSHOTS_DIR, projectName);
2413
+ const snapshotDir = join8(VALIDATE_SNAPSHOTS_DIR, projectName);
2376
2414
  ensureDir(snapshotDir);
2377
2415
  if (treeHash !== void 0) {
2378
2416
  writeFileSync3(getSnapshotPath(projectName, branchName), treeHash, "utf-8");
@@ -2390,34 +2428,34 @@ function removeSnapshot(projectName, branchName) {
2390
2428
  const headPath = getSnapshotHeadPath(projectName, branchName);
2391
2429
  const stagedPath = getSnapshotStagedPath(projectName, branchName);
2392
2430
  if (existsSync8(snapshotPath)) {
2393
- unlinkSync(snapshotPath);
2431
+ unlinkSync2(snapshotPath);
2394
2432
  logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${snapshotPath}`);
2395
2433
  }
2396
2434
  if (existsSync8(headPath)) {
2397
- unlinkSync(headPath);
2435
+ unlinkSync2(headPath);
2398
2436
  logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${headPath}`);
2399
2437
  }
2400
2438
  if (existsSync8(stagedPath)) {
2401
- unlinkSync(stagedPath);
2439
+ unlinkSync2(stagedPath);
2402
2440
  logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${stagedPath}`);
2403
2441
  }
2404
2442
  }
2405
2443
  function getProjectSnapshotBranches(projectName) {
2406
- const projectDir = join7(VALIDATE_SNAPSHOTS_DIR, projectName);
2444
+ const projectDir = join8(VALIDATE_SNAPSHOTS_DIR, projectName);
2407
2445
  if (!existsSync8(projectDir)) {
2408
2446
  return [];
2409
2447
  }
2410
- const files = readdirSync4(projectDir);
2448
+ const files = readdirSync5(projectDir);
2411
2449
  return files.filter((f) => f.endsWith(".tree")).map((f) => f.replace(/\.tree$/, ""));
2412
2450
  }
2413
2451
  function removeProjectSnapshots(projectName) {
2414
- const projectDir = join7(VALIDATE_SNAPSHOTS_DIR, projectName);
2452
+ const projectDir = join8(VALIDATE_SNAPSHOTS_DIR, projectName);
2415
2453
  if (!existsSync8(projectDir)) {
2416
2454
  return;
2417
2455
  }
2418
- const files = readdirSync4(projectDir);
2456
+ const files = readdirSync5(projectDir);
2419
2457
  for (const file of files) {
2420
- unlinkSync(join7(projectDir, file));
2458
+ unlinkSync2(join8(projectDir, file));
2421
2459
  }
2422
2460
  try {
2423
2461
  rmdirSync2(projectDir);
@@ -2944,7 +2982,7 @@ var ProgressRenderer = class {
2944
2982
  };
2945
2983
 
2946
2984
  // src/utils/task-file.ts
2947
- import { resolve } from "path";
2985
+ import { resolve as resolve2 } from "path";
2948
2986
  import { existsSync as existsSync9, readFileSync as readFileSync4 } from "fs";
2949
2987
  var TASK_BLOCK_REGEX = /<!-- CLAWT-TASKS:START -->([\s\S]*?)<!-- CLAWT-TASKS:END -->/g;
2950
2988
  var BRANCH_LINE_REGEX = /^#\s*branch:\s*(.+)$/;
@@ -2990,7 +3028,7 @@ function parseTaskFile(content, options) {
2990
3028
  return entries;
2991
3029
  }
2992
3030
  function loadTaskFile(filePath, options) {
2993
- const absolutePath = resolve(filePath);
3031
+ const absolutePath = resolve2(filePath);
2994
3032
  if (!existsSync9(absolutePath)) {
2995
3033
  throw new ClawtError(MESSAGES.TASK_FILE_NOT_FOUND(absolutePath));
2996
3034
  }
@@ -3123,7 +3161,7 @@ function executeClaudeTask(worktree, task, onActivity, continueSession) {
3123
3161
  stdio: ["ignore", "pipe", "pipe"]
3124
3162
  }
3125
3163
  );
3126
- const promise = new Promise((resolve4) => {
3164
+ const promise = new Promise((resolve5) => {
3127
3165
  let stderr = "";
3128
3166
  let finalResult = null;
3129
3167
  const lineBuffer = createLineBuffer();
@@ -3159,7 +3197,7 @@ function executeClaudeTask(worktree, task, onActivity, continueSession) {
3159
3197
  if (finalResult) {
3160
3198
  success = !finalResult.is_error;
3161
3199
  }
3162
- resolve4({
3200
+ resolve5({
3163
3201
  task,
3164
3202
  branch: worktree.branch,
3165
3203
  worktreePath: worktree.path,
@@ -3169,7 +3207,7 @@ function executeClaudeTask(worktree, task, onActivity, continueSession) {
3169
3207
  });
3170
3208
  });
3171
3209
  child.on("error", (err) => {
3172
- resolve4({
3210
+ resolve5({
3173
3211
  task,
3174
3212
  branch: worktree.branch,
3175
3213
  worktreePath: worktree.path,
@@ -3228,7 +3266,7 @@ async function executeWithConcurrency(worktrees, tasks, concurrency, renderer, s
3228
3266
  const results = new Array(total);
3229
3267
  let nextIndex = 0;
3230
3268
  let completedCount = 0;
3231
- return new Promise((resolve4) => {
3269
+ return new Promise((resolve5) => {
3232
3270
  function launchNext() {
3233
3271
  if (nextIndex >= total || isInterrupted()) return;
3234
3272
  const index = nextIndex;
@@ -3249,7 +3287,7 @@ async function executeWithConcurrency(worktrees, tasks, concurrency, renderer, s
3249
3287
  }
3250
3288
  launchNext();
3251
3289
  if (completedCount === total) {
3252
- resolve4(results);
3290
+ resolve5(results);
3253
3291
  }
3254
3292
  });
3255
3293
  }
@@ -3309,11 +3347,11 @@ async function executeBatchTasks(worktrees, tasks, concurrency, continueFlags) {
3309
3347
  printWarning(MESSAGES.INTERRUPTED);
3310
3348
  killAllChildProcesses(childProcesses);
3311
3349
  await Promise.allSettled(childProcesses.map(
3312
- (cp) => new Promise((resolve4) => {
3350
+ (cp) => new Promise((resolve5) => {
3313
3351
  if (cp.exitCode !== null) {
3314
- resolve4();
3352
+ resolve5();
3315
3353
  } else {
3316
- cp.on("close", () => resolve4());
3354
+ cp.on("close", () => resolve5());
3317
3355
  }
3318
3356
  })
3319
3357
  ));
@@ -3339,7 +3377,7 @@ async function executeBatchTasks(worktrees, tasks, concurrency, continueFlags) {
3339
3377
 
3340
3378
  // src/utils/dry-run.ts
3341
3379
  import chalk6 from "chalk";
3342
- import { join as join8 } from "path";
3380
+ import { join as join9 } from "path";
3343
3381
  var DRY_RUN_TASK_DESC_MAX_LENGTH = 80;
3344
3382
  function truncateTaskDesc(task) {
3345
3383
  const oneLine = task.replace(/\n/g, " ").trim();
@@ -3367,7 +3405,7 @@ function printDryRunPreview(branchNames, tasks, concurrency) {
3367
3405
  let hasConflict = false;
3368
3406
  for (let i = 0; i < branchNames.length; i++) {
3369
3407
  const branch = branchNames[i];
3370
- const worktreePath = join8(projectDir, branch);
3408
+ const worktreePath = join9(projectDir, branch);
3371
3409
  const exists = checkBranchExists(branch);
3372
3410
  if (exists) hasConflict = true;
3373
3411
  const indexLabel = `[${i + 1}/${branchNames.length}]`;
@@ -3559,7 +3597,7 @@ function isNewerVersion(latest, current) {
3559
3597
  return false;
3560
3598
  }
3561
3599
  function fetchLatestVersion() {
3562
- return new Promise((resolve4) => {
3600
+ return new Promise((resolve5) => {
3563
3601
  const req = request(NPM_REGISTRY_URL, { timeout: NPM_REGISTRY_TIMEOUT_MS }, (res) => {
3564
3602
  let data = "";
3565
3603
  res.on("data", (chunk) => {
@@ -3568,16 +3606,16 @@ function fetchLatestVersion() {
3568
3606
  res.on("end", () => {
3569
3607
  try {
3570
3608
  const parsed = JSON.parse(data);
3571
- resolve4(parsed.version ?? null);
3609
+ resolve5(parsed.version ?? null);
3572
3610
  } catch {
3573
- resolve4(null);
3611
+ resolve5(null);
3574
3612
  }
3575
3613
  });
3576
3614
  });
3577
- req.on("error", () => resolve4(null));
3615
+ req.on("error", () => resolve5(null));
3578
3616
  req.on("timeout", () => {
3579
3617
  req.destroy();
3580
- resolve4(null);
3618
+ resolve5(null);
3581
3619
  });
3582
3620
  req.end();
3583
3621
  });
@@ -4230,8 +4268,8 @@ var InteractivePanel = class {
4230
4268
  return;
4231
4269
  }
4232
4270
  this.stateManager.updateData(await this.collectStatusFn());
4233
- return new Promise((resolve4) => {
4234
- this.resolveStart = resolve4;
4271
+ return new Promise((resolve5) => {
4272
+ this.resolveStart = resolve5;
4235
4273
  this.initTerminal();
4236
4274
  this.keyboardController.start();
4237
4275
  this.startAutoRefresh();
@@ -4508,14 +4546,14 @@ var InteractivePanel = class {
4508
4546
  * @returns {Promise<void>} 用户按回车时 resolve
4509
4547
  */
4510
4548
  waitForEnter() {
4511
- return new Promise((resolve4) => {
4549
+ return new Promise((resolve5) => {
4512
4550
  const rl = createInterface2({
4513
4551
  input: process.stdin,
4514
4552
  output: process.stdout
4515
4553
  });
4516
4554
  rl.once("line", () => {
4517
4555
  rl.close();
4518
- resolve4();
4556
+ resolve5();
4519
4557
  });
4520
4558
  });
4521
4559
  }
@@ -4608,7 +4646,7 @@ async function handleMergeConflict(currentBranch, incomingBranch, cwd, autoFlag)
4608
4646
  // src/hooks/post-create.ts
4609
4647
  import { existsSync as existsSync10, accessSync, chmodSync, constants as fsConstants } from "fs";
4610
4648
  import { spawn as spawn2 } from "child_process";
4611
- import { join as join9 } from "path";
4649
+ import { join as join10 } from "path";
4612
4650
  var POST_CREATE_SCRIPT_RELATIVE_PATH = ".clawt/postCreate.sh";
4613
4651
  function isExecutable(filePath) {
4614
4652
  try {
@@ -4640,7 +4678,7 @@ function resolvePostCreateHook() {
4640
4678
  }
4641
4679
  }
4642
4680
  const mainWorktreePath = getMainWorktreePath();
4643
- const scriptPath = join9(mainWorktreePath, POST_CREATE_SCRIPT_RELATIVE_PATH);
4681
+ const scriptPath = join10(mainWorktreePath, POST_CREATE_SCRIPT_RELATIVE_PATH);
4644
4682
  if (existsSync10(scriptPath)) {
4645
4683
  if (!isExecutable(scriptPath)) {
4646
4684
  autoFixExecutablePermission(scriptPath);
@@ -4653,7 +4691,7 @@ function getSourceLabel(hook) {
4653
4691
  return hook.source === "projectConfig" ? "\u9879\u76EE\u914D\u7F6E (postCreate)" : ".clawt/postCreate.sh";
4654
4692
  }
4655
4693
  function executeOneHook(worktree, hook) {
4656
- return new Promise((resolve4) => {
4694
+ return new Promise((resolve5) => {
4657
4695
  const result = {
4658
4696
  worktreePath: worktree.path,
4659
4697
  branch: worktree.branch,
@@ -4670,7 +4708,7 @@ function executeOneHook(worktree, hook) {
4670
4708
  result.success = false;
4671
4709
  result.error = err.message;
4672
4710
  logger.error(`postCreate hook \u5F02\u5E38: ${hook.command} @ ${worktree.path}: ${result.error}`);
4673
- resolve4(result);
4711
+ resolve5(result);
4674
4712
  });
4675
4713
  child.on("close", (code) => {
4676
4714
  if (code !== null && code !== 0) {
@@ -4680,13 +4718,13 @@ function executeOneHook(worktree, hook) {
4680
4718
  } else {
4681
4719
  logger.info(`postCreate hook \u6210\u529F: ${hook.command} @ ${worktree.path}`);
4682
4720
  }
4683
- resolve4(result);
4721
+ resolve5(result);
4684
4722
  });
4685
4723
  } catch (err) {
4686
4724
  result.success = false;
4687
4725
  result.error = err instanceof Error ? err.message : String(err);
4688
4726
  logger.error(`postCreate hook \u542F\u52A8\u5931\u8D25: ${hook.command} @ ${worktree.path}: ${result.error}`);
4689
- resolve4(result);
4727
+ resolve5(result);
4690
4728
  }
4691
4729
  });
4692
4730
  }
@@ -5249,6 +5287,10 @@ async function handleValidate(options) {
5249
5287
  const branchName = worktree.branch;
5250
5288
  const targetWorktreePath = worktree.path;
5251
5289
  logger.info(`validate \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F: ${branchName}`);
5290
+ const removedSymlinks = removeExternalSymlinks(targetWorktreePath);
5291
+ if (removedSymlinks.length > 0) {
5292
+ printWarning(MESSAGES.VALIDATE_EXTERNAL_SYMLINKS_FOUND(removedSymlinks.length));
5293
+ }
5252
5294
  const hasUncommitted = !isWorkingDirClean(targetWorktreePath);
5253
5295
  const hasCommitted = hasLocalCommits(branchName, mainWorktreePath);
5254
5296
  if (!hasUncommitted && !hasCommitted) {
@@ -5882,8 +5924,8 @@ function registerAliasCommand(program2) {
5882
5924
  }
5883
5925
 
5884
5926
  // src/commands/projects.ts
5885
- import { existsSync as existsSync11, readdirSync as readdirSync5, statSync as statSync4 } from "fs";
5886
- import { join as join10 } from "path";
5927
+ import { existsSync as existsSync11, readdirSync as readdirSync6, statSync as statSync4 } from "fs";
5928
+ import { join as join11 } from "path";
5887
5929
  import chalk13 from "chalk";
5888
5930
  function registerProjectsCommand(program2) {
5889
5931
  program2.command("projects [name]").description("\u5C55\u793A\u6240\u6709\u9879\u76EE\u7684 worktree \u6982\u89C8\uFF0C\u6216\u67E5\u770B\u6307\u5B9A\u9879\u76EE\u7684 worktree \u8BE6\u60C5").option("--json", "\u4EE5 JSON \u683C\u5F0F\u8F93\u51FA").action((name, options) => {
@@ -5907,7 +5949,7 @@ function handleProjectsOverview(json) {
5907
5949
  printProjectsOverviewAsText(result);
5908
5950
  }
5909
5951
  function handleProjectDetail(name, json) {
5910
- const projectDir = join10(WORKTREES_DIR, name);
5952
+ const projectDir = join11(WORKTREES_DIR, name);
5911
5953
  if (!existsSync11(projectDir)) {
5912
5954
  printError(MESSAGES.PROJECTS_NOT_FOUND(name));
5913
5955
  process.exit(1);
@@ -5924,13 +5966,13 @@ function collectProjectsOverview() {
5924
5966
  if (!existsSync11(WORKTREES_DIR)) {
5925
5967
  return { projects: [], totalProjects: 0, totalDiskUsage: 0 };
5926
5968
  }
5927
- const entries = readdirSync5(WORKTREES_DIR, { withFileTypes: true });
5969
+ const entries = readdirSync6(WORKTREES_DIR, { withFileTypes: true });
5928
5970
  const projects = [];
5929
5971
  for (const entry of entries) {
5930
5972
  if (!entry.isDirectory()) {
5931
5973
  continue;
5932
5974
  }
5933
- const projectDir = join10(WORKTREES_DIR, entry.name);
5975
+ const projectDir = join11(WORKTREES_DIR, entry.name);
5934
5976
  const overview = collectSingleProjectOverview(entry.name, projectDir);
5935
5977
  projects.push(overview);
5936
5978
  }
@@ -5943,11 +5985,11 @@ function collectProjectsOverview() {
5943
5985
  };
5944
5986
  }
5945
5987
  function collectSingleProjectOverview(name, projectDir) {
5946
- const subEntries = readdirSync5(projectDir, { withFileTypes: true });
5988
+ const subEntries = readdirSync6(projectDir, { withFileTypes: true });
5947
5989
  const worktreeDirs = subEntries.filter((e) => e.isDirectory());
5948
5990
  const worktreeCount = worktreeDirs.length;
5949
5991
  const diskUsage = calculateDirSize(projectDir);
5950
- const lastActiveTime = resolveProjectLastActiveTime(projectDir, worktreeDirs.map((e) => join10(projectDir, e.name)));
5992
+ const lastActiveTime = resolveProjectLastActiveTime(projectDir, worktreeDirs.map((e) => join11(projectDir, e.name)));
5951
5993
  return {
5952
5994
  name,
5953
5995
  worktreeCount,
@@ -5956,13 +5998,13 @@ function collectSingleProjectOverview(name, projectDir) {
5956
5998
  };
5957
5999
  }
5958
6000
  function collectProjectDetail(name, projectDir) {
5959
- const subEntries = readdirSync5(projectDir, { withFileTypes: true });
6001
+ const subEntries = readdirSync6(projectDir, { withFileTypes: true });
5960
6002
  const worktrees = [];
5961
6003
  for (const entry of subEntries) {
5962
6004
  if (!entry.isDirectory()) {
5963
6005
  continue;
5964
6006
  }
5965
- const wtPath = join10(projectDir, entry.name);
6007
+ const wtPath = join11(projectDir, entry.name);
5966
6008
  const detail = collectSingleWorktreeDetail(entry.name, wtPath);
5967
6009
  worktrees.push(detail);
5968
6010
  }
@@ -6062,7 +6104,7 @@ function printWorktreeDetailItem(wt) {
6062
6104
 
6063
6105
  // src/commands/completion.ts
6064
6106
  import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, existsSync as existsSync13 } from "fs";
6065
- import { resolve as resolve2 } from "path";
6107
+ import { resolve as resolve3 } from "path";
6066
6108
  import { homedir as homedir2 } from "os";
6067
6109
 
6068
6110
  // src/utils/completion-scripts.ts
@@ -6112,23 +6154,23 @@ compdef _clawt_completion clawt
6112
6154
  }
6113
6155
 
6114
6156
  // src/utils/completion-engine.ts
6115
- import { existsSync as existsSync12, readdirSync as readdirSync6, statSync as statSync5 } from "fs";
6116
- import { join as join11, dirname, basename as basename2 } from "path";
6157
+ import { existsSync as existsSync12, readdirSync as readdirSync7, statSync as statSync5 } from "fs";
6158
+ import { join as join12, dirname, basename as basename2 } from "path";
6117
6159
  function completeFilePath(partial) {
6118
6160
  const cwd = process.cwd();
6119
6161
  const hasDir = partial.includes("/");
6120
- const searchDir = hasDir ? join11(cwd, dirname(partial)) : cwd;
6162
+ const searchDir = hasDir ? join12(cwd, dirname(partial)) : cwd;
6121
6163
  const prefix = hasDir ? basename2(partial) : partial;
6122
6164
  if (!existsSync12(searchDir)) {
6123
6165
  return [];
6124
6166
  }
6125
- const entries = readdirSync6(searchDir);
6167
+ const entries = readdirSync7(searchDir);
6126
6168
  const results = [];
6127
6169
  const dirPrefix = hasDir ? dirname(partial) + "/" : "";
6128
6170
  for (const entry of entries) {
6129
6171
  if (!entry.startsWith(prefix)) continue;
6130
6172
  if (entry.startsWith(".")) continue;
6131
- const fullPath = join11(searchDir, entry);
6173
+ const fullPath = join12(searchDir, entry);
6132
6174
  try {
6133
6175
  const stat = statSync5(fullPath);
6134
6176
  if (stat.isDirectory()) {
@@ -6234,14 +6276,14 @@ function installCompletions() {
6234
6276
  const home = homedir2();
6235
6277
  try {
6236
6278
  if (shell.includes("zsh")) {
6237
- const rcPath = resolve2(home, ".zshrc");
6279
+ const rcPath = resolve3(home, ".zshrc");
6238
6280
  const script = `
6239
6281
  # clawt completion
6240
6282
  source <(clawt completion zsh)
6241
6283
  `;
6242
6284
  appendToFile(rcPath, script);
6243
6285
  } else if (shell.includes("bash")) {
6244
- const rcPath = resolve2(home, ".bashrc");
6286
+ const rcPath = resolve3(home, ".bashrc");
6245
6287
  const script = `
6246
6288
  # clawt completion
6247
6289
  eval "$(clawt completion bash)"
@@ -6251,7 +6293,7 @@ eval "$(clawt completion bash)"
6251
6293
  printWarning(MESSAGES.COMPLETION_INSTALL_UNKNOWN_SHELL);
6252
6294
  }
6253
6295
  } catch (error) {
6254
- const filePath = shell.includes("zsh") ? resolve2(home, ".zshrc") : resolve2(home, ".bashrc");
6296
+ const filePath = shell.includes("zsh") ? resolve3(home, ".zshrc") : resolve3(home, ".bashrc");
6255
6297
  printError(MESSAGES.COMPLETION_INSTALL_WRITE_ERROR(filePath));
6256
6298
  }
6257
6299
  }
@@ -6353,17 +6395,17 @@ async function handleHome() {
6353
6395
  }
6354
6396
 
6355
6397
  // src/commands/tasks.ts
6356
- import { resolve as resolve3, dirname as dirname2, join as join12 } from "path";
6398
+ import { resolve as resolve4, dirname as dirname2, join as join13 } from "path";
6357
6399
  import { existsSync as existsSync14, writeFileSync as writeFileSync6 } from "fs";
6358
6400
  function registerTasksCommand(program2) {
6359
6401
  const taskCmd = program2.command("tasks").description("\u4EFB\u52A1\u6587\u4EF6\u7BA1\u7406");
6360
6402
  taskCmd.command("init").description("\u751F\u6210\u4EFB\u52A1\u6A21\u677F\u6587\u4EF6").argument("[path]", "\u8F93\u51FA\u6587\u4EF6\u8DEF\u5F84").action(async (path2) => {
6361
- const filePath = path2 ?? join12(TASK_TEMPLATE_OUTPUT_DIR, generateTaskFilename(TASK_TEMPLATE_FILENAME_PREFIX));
6403
+ const filePath = path2 ?? join13(TASK_TEMPLATE_OUTPUT_DIR, generateTaskFilename(TASK_TEMPLATE_FILENAME_PREFIX));
6362
6404
  await handleTasksInit(filePath);
6363
6405
  });
6364
6406
  }
6365
6407
  async function handleTasksInit(filePath) {
6366
- const absolutePath = resolve3(filePath);
6408
+ const absolutePath = resolve4(filePath);
6367
6409
  logger.info(`tasks init \u547D\u4EE4\u6267\u884C\uFF0C\u76EE\u6807\u6587\u4EF6: ${absolutePath}`);
6368
6410
  if (existsSync14(absolutePath)) {
6369
6411
  throw new ClawtError(MESSAGES.TASK_INIT_FILE_EXISTS(filePath));
@@ -260,6 +260,8 @@ ${branches.map((b) => ` - ${b}`).join("\n")}`,
260
260
  VALIDATE_RUN_ERROR_COPIED: "\u2702 \u9519\u8BEF\u4FE1\u606F\u5DF2\u590D\u5236\u5230\u526A\u8D34\u677F",
261
261
  /** 剪贴板复制失败提示 */
262
262
  VALIDATE_RUN_ERROR_COPY_FAILED: "\u26A0 \u9519\u8BEF\u4FE1\u606F\u590D\u5236\u5230\u526A\u8D34\u677F\u5931\u8D25",
263
+ /** 检测到外部软链接警告 */
264
+ VALIDATE_EXTERNAL_SYMLINKS_FOUND: (count) => `\u26A0 \u68C0\u6D4B\u5230 ${count} \u4E2A\u6307\u5411 worktree \u5916\u90E8\u7684\u8F6F\u94FE\u63A5\uFF08\u53EF\u80FD\u7531 AI Agent \u521B\u5EFA\uFF09\uFF0C\u5DF2\u81EA\u52A8\u79FB\u9664`,
263
265
  /** 单命令(含 && 链)剪贴板错误格式 */
264
266
  VALIDATE_CLIPBOARD_SINGLE_ERROR: (command, stderr) => `${command} \u6307\u4EE4\u6267\u884C\u51FA\u9519\uFF0C\u9519\u8BEF\u4FE1\u606F\uFF1A
265
267
  ${stderr}`,
package/docs/validate.md CHANGED
@@ -66,6 +66,21 @@ validate 命令引入了**快照(snapshot)机制**来支持增量对比。
66
66
  - 多个匹配 → 通过交互式列表让用户从匹配结果中选择
67
67
  3. **无匹配** → 报错退出,并列出所有可用分支名
68
68
 
69
+ ##### 步骤 0.5:清理外部软链接
70
+
71
+ 在变更检测之前,自动扫描目标 worktree 根目录,移除指向 worktree 外部路径的软链接。
72
+
73
+ **背景:** AI Agent(如 Claude Code)在 worktree 中执行任务时,可能会通过软链接(如 `node_modules → /path/to/main-worktree/node_modules`)引用主 worktree 或其他外部路径的依赖目录。这些指向外部的软链接会导致 `git diff` 和 `git apply` 的行为异常,进而使 patch apply 失败。validate 命令在变更检测前自动清理这些软链接,确保后续 patch 流程正常执行。
74
+
75
+ **实现要点:**
76
+
77
+ - `removeExternalSymlinks(dir)`(`src/utils/symlink-guard.ts`):扫描目录中所有软链接,判断其目标路径是否在 worktree 根目录之外(通过 `path.relative` 判断是否以 `..` 开头),移除外部软链接并返回被移除的路径列表
78
+ - 内部辅助函数 `isExternalSymlink(linkPath, worktreeRoot)` 判断软链接目标是否指向外部:先解析软链接目标(绝对路径直接使用,相对路径基于 worktreeRoot 解析),再通过 `relative` 判断目标是否在 worktree 之外
79
+ - 安全措施:删除前通过 `lstatSync` 再次确认目标仍是软链接(而非已被替换的普通文件),缩小 TOCTOU 口
80
+ - 不可读目录或删除失败时静默处理(仅输出 warn 日志),不中断 validate 流程
81
+ - 如果移除了外部软链接,输出警告提示:`⚠ 检测到 N 个指向 worktree 外部的软链接(可能由 AI Agent 创建),已自动移除`
82
+ - 消息常量:`MESSAGES.VALIDATE_EXTERNAL_SYMLINKS_FOUND`(`src/constants/messages/validate.ts`)
83
+
69
84
  ##### 步骤 1:检测目标分支变更
70
85
 
71
86
  统一检测目标 worktree 的未提交修改和已提交 commit:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "3.9.11",
3
+ "version": "3.9.12",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -34,6 +34,7 @@ import {
34
34
  saveCurrentSnapshotTree,
35
35
  loadOldSnapshotToStage,
36
36
  switchToValidateBranch,
37
+ removeExternalSymlinks,
37
38
  } from '../utils/index.js';
38
39
  import type { WorktreeResolveMessages } from '../utils/index.js';
39
40
 
@@ -274,6 +275,12 @@ async function handleValidate(options: ValidateOptions): Promise<void> {
274
275
 
275
276
  logger.info(`validate 命令执行,分支: ${branchName}`);
276
277
 
278
+ // 通常由 AI Agent 创建的指向外部的软链接会导致 patch apply 失败
279
+ const removedSymlinks = removeExternalSymlinks(targetWorktreePath);
280
+ if (removedSymlinks.length > 0) {
281
+ printWarning(MESSAGES.VALIDATE_EXTERNAL_SYMLINKS_FOUND(removedSymlinks.length));
282
+ }
283
+
277
284
  // 统一检测未提交修改 + 已提交 commit
278
285
  const hasUncommitted = !isWorkingDirClean(targetWorktreePath);
279
286
  const hasCommitted = hasLocalCommits(branchName, mainWorktreePath);
@@ -75,6 +75,9 @@ export const VALIDATE_MESSAGES = {
75
75
  VALIDATE_RUN_ERROR_COPIED: '✂ 错误信息已复制到剪贴板',
76
76
  /** 剪贴板复制失败提示 */
77
77
  VALIDATE_RUN_ERROR_COPY_FAILED: '⚠ 错误信息复制到剪贴板失败',
78
+ /** 检测到外部软链接警告 */
79
+ VALIDATE_EXTERNAL_SYMLINKS_FOUND: (count: number) =>
80
+ `⚠ 检测到 ${count} 个指向 worktree 外部的软链接(可能由 AI Agent 创建),已自动移除`,
78
81
  /** 单命令(含 && 链)剪贴板错误格式 */
79
82
  VALIDATE_CLIPBOARD_SINGLE_ERROR: (command: string, stderr: string) =>
80
83
  `${command} 指令执行出错,错误信息:\n${stderr}`,
@@ -71,6 +71,7 @@ export { createWorktrees, getProjectWorktrees, getProjectWorktreeDir, cleanupWor
71
71
  export { loadConfig, writeDefaultConfig, writeConfig, saveConfig, getConfigValue, ensureClawtDirs, parseConcurrency } from './config.js';
72
72
  export { printSuccess, printError, printWarning, printInfo, printHint, printSeparator, printDoubleSeparator, confirmAction, confirmDestructiveAction, formatWorktreeStatus, isWorktreeIdle, formatDuration, formatRelativeTime, formatDiskSize, formatLocalISOString, generateTaskFilename } from './formatter.js';
73
73
  export { ensureDir, removeEmptyDir, calculateDirSize } from './fs.js';
74
+ export { removeExternalSymlinks } from './symlink-guard.js';
74
75
  export { multilineInput, promptCommitMessage } from './prompt.js';
75
76
  export { launchInteractiveClaude, hasClaudeSessionHistory, launchInteractiveClaudeInNewTerminal } from './claude.js';
76
77
  export { getSnapshotPath, hasSnapshot, getSnapshotModifiedTime, readSnapshotTreeHash, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots, getProjectSnapshotBranches } from './validate-snapshot.js';
@@ -0,0 +1,61 @@
1
+ import { readdirSync, lstatSync, unlinkSync, readlinkSync } from 'node:fs';
2
+ import { join, relative, isAbsolute, resolve } from 'node:path';
3
+ import { logger } from '../logger/index.js';
4
+
5
+ /**
6
+ * 检测路径是否为指向 worktree 外部的外部软链接
7
+ * @param {string} linkPath - 软链接的绝对路径
8
+ * @param {string} worktreeRoot - worktree 根目录的绝对路径
9
+ * @returns {boolean} 是否为外部软链接
10
+ */
11
+ function isExternalSymlink(linkPath: string, worktreeRoot: string): boolean {
12
+ try {
13
+ const target = readlinkSync(linkPath);
14
+ // 顶层软链接的相对路径基于 worktreeRoot 解析
15
+ const resolvedTarget = isAbsolute(target)
16
+ ? target
17
+ : resolve(worktreeRoot, target);
18
+
19
+ const relativePath = relative(worktreeRoot, resolvedTarget);
20
+ return relativePath.startsWith('..') || isAbsolute(relativePath);
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * 移除目录中指向外部路径的软链接
28
+ * 同一循环内边扫描边删除,缩小 TOCTOU 窗口;
29
+ * 删除前用 lstatSync 确认目标仍是软链接,避免误删已被替换的普通文件
30
+ * @param {string} dir - 要清理的目录绝对路径
31
+ * @returns {string[]} 被移除的软链接路径列表
32
+ */
33
+ export function removeExternalSymlinks(dir: string): string[] {
34
+ const removed: string[] = [];
35
+
36
+ try {
37
+ const entries = readdirSync(dir, { withFileTypes: true });
38
+ for (const entry of entries) {
39
+ if (!entry.isSymbolicLink()) continue;
40
+
41
+ const fullPath = join(dir, entry.name);
42
+ if (!isExternalSymlink(fullPath, dir)) continue;
43
+
44
+ try {
45
+ // lstatSync 不跟随软链接,确认删除前仍是软链接而非被替换的普通文件
46
+ const stat = lstatSync(fullPath);
47
+ if (!stat.isSymbolicLink()) continue;
48
+
49
+ unlinkSync(fullPath);
50
+ removed.push(fullPath);
51
+ logger.info(`已移除外部软链接: ${fullPath}`);
52
+ } catch (error) {
53
+ logger.warn(`移除外部软链接失败: ${fullPath} - ${error}`);
54
+ }
55
+ }
56
+ } catch {
57
+ // 目录不可读时静默返回
58
+ }
59
+
60
+ return removed;
61
+ }
@@ -46,6 +46,7 @@ vi.mock('../../../src/constants/index.js', () => ({
46
46
  VALIDATE_CONFIRM_AUTO_SYNC: (branch: string) => `是否自动 sync ${branch}`,
47
47
  VALIDATE_AUTO_SYNC_DECLINED: (branch: string) => `已跳过 ${branch} 的自动 sync`,
48
48
  VALIDATE_AUTO_SYNC_START: (branch: string) => `正在自动 sync ${branch}`,
49
+ VALIDATE_EXTERNAL_SYMLINKS_FOUND: (count: number) => `检测到 ${count} 个外部软链接`,
49
50
  },
50
51
  }));
51
52
 
@@ -94,6 +95,7 @@ vi.mock('../../../src/utils/index.js', () => ({
94
95
  executeRunCommand: vi.fn(),
95
96
  guardMainWorkBranch: vi.fn().mockResolvedValue(undefined),
96
97
  guardMainWorkBranchExists: vi.fn(),
98
+ removeExternalSymlinks: vi.fn().mockReturnValue([]),
97
99
  }));
98
100
 
99
101
  import { registerValidateCommand } from '../../../src/commands/validate.js';
@@ -7,6 +7,10 @@ vi.mock('../../../src/logger/index.js', () => ({
7
7
 
8
8
  // mock node:child_process
9
9
  vi.mock('node:child_process', () => ({
10
+ exec: vi.fn(),
11
+ execSync: vi.fn(),
12
+ execFileSync: vi.fn(),
13
+ spawn: vi.fn(),
10
14
  spawnSync: vi.fn(),
11
15
  }));
12
16
 
@@ -21,6 +25,11 @@ vi.mock('../../../src/utils/config.js', () => ({
21
25
  getConfigValue: vi.fn(),
22
26
  }));
23
27
 
28
+ // mock project-config(避免 resolveClaudeCodeCommand 调用 getGitTopLevel/execSync)
29
+ vi.mock('../../../src/utils/project-config.js', () => ({
30
+ resolveClaudeCodeCommand: vi.fn(),
31
+ }));
32
+
24
33
  // mock formatter
25
34
  vi.mock('../../../src/utils/formatter.js', () => ({
26
35
  printInfo: vi.fn(),
@@ -31,12 +40,14 @@ import { spawnSync } from 'node:child_process';
31
40
  import { existsSync, readdirSync } from 'node:fs';
32
41
  import { launchInteractiveClaude, hasClaudeSessionHistory, buildClaudeCommand } from '../../../src/utils/claude.js';
33
42
  import { getConfigValue } from '../../../src/utils/config.js';
43
+ import { resolveClaudeCodeCommand } from '../../../src/utils/project-config.js';
34
44
  import { printInfo, printWarning } from '../../../src/utils/formatter.js';
35
45
  import { ClawtError } from '../../../src/errors/index.js';
36
46
  import { createWorktreeInfo } from '../../helpers/fixtures.js';
37
47
 
38
48
  const mockedSpawnSync = vi.mocked(spawnSync);
39
49
  const mockedGetConfigValue = vi.mocked(getConfigValue);
50
+ const mockedResolveClaudeCodeCommand = vi.mocked(resolveClaudeCodeCommand);
40
51
  const mockedPrintInfo = vi.mocked(printInfo);
41
52
  const mockedPrintWarning = vi.mocked(printWarning);
42
53
  const mockedExistsSync = vi.mocked(existsSync);
@@ -86,7 +97,7 @@ describe('launchInteractiveClaude', () => {
86
97
  });
87
98
 
88
99
  it('正常启动 Claude Code(退出码为 0)', () => {
89
- mockedGetConfigValue.mockReturnValue('claude');
100
+ mockedResolveClaudeCodeCommand.mockReturnValue('claude');
90
101
  mockedExistsSync.mockReturnValue(false);
91
102
  mockedSpawnSync.mockReturnValue({
92
103
  status: 0,
@@ -100,7 +111,7 @@ describe('launchInteractiveClaude', () => {
100
111
 
101
112
  launchInteractiveClaude(worktree);
102
113
 
103
- expect(mockedGetConfigValue).toHaveBeenCalledWith('claudeCodeCommand');
114
+ expect(mockedResolveClaudeCodeCommand).toHaveBeenCalled();
104
115
  expect(mockedSpawnSync).toHaveBeenCalledWith(
105
116
  'claude',
106
117
  expect.any(Array),
@@ -112,7 +123,7 @@ describe('launchInteractiveClaude', () => {
112
123
  });
113
124
 
114
125
  it('输出分支和路径信息', () => {
115
- mockedGetConfigValue.mockReturnValue('claude');
126
+ mockedResolveClaudeCodeCommand.mockReturnValue('claude');
116
127
  mockedExistsSync.mockReturnValue(false);
117
128
  mockedSpawnSync.mockReturnValue({
118
129
  status: 0,
@@ -131,7 +142,7 @@ describe('launchInteractiveClaude', () => {
131
142
  });
132
143
 
133
144
  it('支持带参数的命令(如 npx claude)', () => {
134
- mockedGetConfigValue.mockReturnValue('npx claude');
145
+ mockedResolveClaudeCodeCommand.mockReturnValue('npx claude');
135
146
  mockedExistsSync.mockReturnValue(false);
136
147
  mockedSpawnSync.mockReturnValue({
137
148
  status: 0,
@@ -153,7 +164,7 @@ describe('launchInteractiveClaude', () => {
153
164
  });
154
165
 
155
166
  it('spawnSync 返回 error 时抛出 ClawtError', () => {
156
- mockedGetConfigValue.mockReturnValue('claude');
167
+ mockedResolveClaudeCodeCommand.mockReturnValue('claude');
157
168
  mockedExistsSync.mockReturnValue(false);
158
169
  mockedSpawnSync.mockReturnValue({
159
170
  status: null,
@@ -170,7 +181,7 @@ describe('launchInteractiveClaude', () => {
170
181
  });
171
182
 
172
183
  it('非零退出码时调用 printWarning', () => {
173
- mockedGetConfigValue.mockReturnValue('claude');
184
+ mockedResolveClaudeCodeCommand.mockReturnValue('claude');
174
185
  mockedExistsSync.mockReturnValue(false);
175
186
  mockedSpawnSync.mockReturnValue({
176
187
  status: 1,
@@ -188,7 +199,7 @@ describe('launchInteractiveClaude', () => {
188
199
  });
189
200
 
190
201
  it('退出码为 null 时不调用 printWarning', () => {
191
- mockedGetConfigValue.mockReturnValue('claude');
202
+ mockedResolveClaudeCodeCommand.mockReturnValue('claude');
192
203
  mockedExistsSync.mockReturnValue(false);
193
204
  mockedSpawnSync.mockReturnValue({
194
205
  status: null,
@@ -206,7 +217,7 @@ describe('launchInteractiveClaude', () => {
206
217
  });
207
218
 
208
219
  it('退出码为 0 时不调用 printWarning', () => {
209
- mockedGetConfigValue.mockReturnValue('claude');
220
+ mockedResolveClaudeCodeCommand.mockReturnValue('claude');
210
221
  mockedExistsSync.mockReturnValue(false);
211
222
  mockedSpawnSync.mockReturnValue({
212
223
  status: 0,
@@ -224,7 +235,7 @@ describe('launchInteractiveClaude', () => {
224
235
  });
225
236
 
226
237
  it('autoContinue 启用且有会话历史时追加 --continue 参数', () => {
227
- mockedGetConfigValue.mockReturnValue('claude');
238
+ mockedResolveClaudeCodeCommand.mockReturnValue('claude');
228
239
  mockedExistsSync.mockReturnValue(true);
229
240
  mockedReaddirSync.mockReturnValue(['session-abc.jsonl'] as unknown as ReturnType<typeof readdirSync>);
230
241
  mockedSpawnSync.mockReturnValue({
@@ -245,7 +256,7 @@ describe('launchInteractiveClaude', () => {
245
256
  });
246
257
 
247
258
  it('autoContinue 启用但无会话历史时不追加 --continue 参数', () => {
248
- mockedGetConfigValue.mockReturnValue('claude');
259
+ mockedResolveClaudeCodeCommand.mockReturnValue('claude');
249
260
  mockedExistsSync.mockReturnValue(false);
250
261
  mockedSpawnSync.mockReturnValue({
251
262
  status: 0,
@@ -265,7 +276,7 @@ describe('launchInteractiveClaude', () => {
265
276
  });
266
277
 
267
278
  it('不传 autoContinue 时即使有会话历史也不追加 --continue', () => {
268
- mockedGetConfigValue.mockReturnValue('claude');
279
+ mockedResolveClaudeCodeCommand.mockReturnValue('claude');
269
280
  mockedExistsSync.mockReturnValue(true);
270
281
  mockedReaddirSync.mockReturnValue(['session-abc.jsonl'] as unknown as ReturnType<typeof readdirSync>);
271
282
  mockedSpawnSync.mockReturnValue({
@@ -285,7 +296,7 @@ describe('launchInteractiveClaude', () => {
285
296
  });
286
297
 
287
298
  it('不包含 --append-system-prompt 参数', () => {
288
- mockedGetConfigValue.mockReturnValue('claude');
299
+ mockedResolveClaudeCodeCommand.mockReturnValue('claude');
289
300
  mockedExistsSync.mockReturnValue(false);
290
301
  mockedSpawnSync.mockReturnValue({
291
302
  status: 0,
@@ -311,7 +322,7 @@ describe('buildClaudeCommand', () => {
311
322
  });
312
323
 
313
324
  it('生成包含 cd 和 claude 命令的字符串', () => {
314
- mockedGetConfigValue.mockReturnValue('claude');
325
+ mockedResolveClaudeCodeCommand.mockReturnValue('claude');
315
326
 
316
327
  const cmd = buildClaudeCommand(worktree, false);
317
328
 
@@ -320,7 +331,7 @@ describe('buildClaudeCommand', () => {
320
331
  });
321
332
 
322
333
  it('hasPreviousSession 为 true 时包含 --continue', () => {
323
- mockedGetConfigValue.mockReturnValue('claude');
334
+ mockedResolveClaudeCodeCommand.mockReturnValue('claude');
324
335
 
325
336
  const cmd = buildClaudeCommand(worktree, true);
326
337
 
@@ -328,7 +339,7 @@ describe('buildClaudeCommand', () => {
328
339
  });
329
340
 
330
341
  it('hasPreviousSession 为 false 时不包含 --continue', () => {
331
- mockedGetConfigValue.mockReturnValue('claude');
342
+ mockedResolveClaudeCodeCommand.mockReturnValue('claude');
332
343
 
333
344
  const cmd = buildClaudeCommand(worktree, false);
334
345
 
@@ -336,7 +347,7 @@ describe('buildClaudeCommand', () => {
336
347
  });
337
348
 
338
349
  it('不包含 --append-system-prompt 参数', () => {
339
- mockedGetConfigValue.mockReturnValue('claude');
350
+ mockedResolveClaudeCodeCommand.mockReturnValue('claude');
340
351
 
341
352
  const cmd = buildClaudeCommand(worktree, false);
342
353
 
@@ -344,7 +355,7 @@ describe('buildClaudeCommand', () => {
344
355
  });
345
356
 
346
357
  it('路径中的单引号被正确转义', () => {
347
- mockedGetConfigValue.mockReturnValue('claude');
358
+ mockedResolveClaudeCodeCommand.mockReturnValue('claude');
348
359
  const wtWithQuote = createWorktreeInfo({
349
360
  path: "/tmp/test's-worktree",
350
361
  branch: 'feat',
@@ -18,7 +18,11 @@ vi.mock('../../../src/errors/index.js', () => ({
18
18
 
19
19
  // mock node:child_process
20
20
  vi.mock('node:child_process', () => ({
21
+ exec: vi.fn(),
22
+ execSync: vi.fn(),
21
23
  execFileSync: vi.fn(),
24
+ spawn: vi.fn(),
25
+ spawnSync: vi.fn(),
22
26
  }));
23
27
 
24
28
  // mock constants(使用与 src/constants/messages/merge.ts 一致的消息文本)
@@ -236,9 +236,9 @@ describe('waitForGitIndexLockRetrySync', () => {
236
236
  waitForGitIndexLockRetrySync();
237
237
  const elapsed = Date.now() - startTime;
238
238
 
239
- // 延迟时间应该接近 150ms(允许 50ms 误差)
240
- expect(elapsed).toBeGreaterThanOrEqual(100);
241
- expect(elapsed).toBeLessThanOrEqual(300);
239
+ // 延迟时间应该接近 1000ms(允许 500ms 误差,兼容 CI 环境的调度延迟)
240
+ expect(elapsed).toBeGreaterThanOrEqual(500);
241
+ expect(elapsed).toBeLessThanOrEqual(2000);
242
242
 
243
243
  stderrWriteSpy.mockRestore();
244
244
  });
@@ -2,9 +2,11 @@ import { describe, it, expect, vi } from 'vitest';
2
2
 
3
3
  // mock node:child_process
4
4
  vi.mock('node:child_process', () => ({
5
+ exec: vi.fn(),
5
6
  execSync: vi.fn(),
6
7
  execFileSync: vi.fn(),
7
8
  spawn: vi.fn(),
9
+ spawnSync: vi.fn(),
8
10
  }));
9
11
 
10
12
  // mock logger
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdirSync, rmSync, symlinkSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { removeExternalSymlinks } from '../../../src/utils/symlink-guard.js';
6
+
7
+ /** 创建临时测试目录的唯一路径 */
8
+ function createTestDir(prefix: string): string {
9
+ return join(tmpdir(), `clawt-test-symlink-${prefix}-${Date.now()}`);
10
+ }
11
+
12
+ describe('symlink-guard', () => {
13
+ let worktreeDir: string;
14
+ let externalDir: string;
15
+
16
+ beforeEach(() => {
17
+ worktreeDir = createTestDir('worktree');
18
+ externalDir = createTestDir('external');
19
+ mkdirSync(worktreeDir, { recursive: true });
20
+ mkdirSync(externalDir, { recursive: true });
21
+ });
22
+
23
+ afterEach(() => {
24
+ rmSync(worktreeDir, { recursive: true, force: true });
25
+ rmSync(externalDir, { recursive: true, force: true });
26
+ });
27
+
28
+ describe('removeExternalSymlinks', () => {
29
+ it('应移除外部软链接并返回被移除的路径列表', () => {
30
+ mkdirSync(join(externalDir, 'node_modules_real'), { recursive: true });
31
+ symlinkSync(join(externalDir, 'node_modules_real'), join(worktreeDir, 'node_modules'));
32
+
33
+ const removed = removeExternalSymlinks(worktreeDir);
34
+ expect(removed).toHaveLength(1);
35
+ expect(existsSync(join(worktreeDir, 'node_modules'))).toBe(false);
36
+ });
37
+
38
+ it('不应移除内部软链接', () => {
39
+ mkdirSync(join(worktreeDir, 'target'), { recursive: true });
40
+ symlinkSync(join(worktreeDir, 'target'), join(worktreeDir, 'link'));
41
+
42
+ const removed = removeExternalSymlinks(worktreeDir);
43
+ expect(removed).toHaveLength(0);
44
+ expect(existsSync(join(worktreeDir, 'link'))).toBe(true);
45
+ });
46
+
47
+ it('应处理无软链接的目录', () => {
48
+ const removed = removeExternalSymlinks(worktreeDir);
49
+ expect(removed).toHaveLength(0);
50
+ });
51
+
52
+ it('应处理不存在的目录', () => {
53
+ const removed = removeExternalSymlinks('/nonexistent/path/12345');
54
+ expect(removed).toHaveLength(0);
55
+ });
56
+
57
+ it('应移除多个外部软链接', () => {
58
+ mkdirSync(join(externalDir, 'target1'), { recursive: true });
59
+ mkdirSync(join(externalDir, 'target2'), { recursive: true });
60
+
61
+ symlinkSync(join(externalDir, 'target1'), join(worktreeDir, 'node_modules'));
62
+ symlinkSync(join(externalDir, 'target2'), join(worktreeDir, '.venv'));
63
+
64
+ const removed = removeExternalSymlinks(worktreeDir);
65
+ expect(removed).toHaveLength(2);
66
+ expect(existsSync(join(worktreeDir, 'node_modules'))).toBe(false);
67
+ expect(existsSync(join(worktreeDir, '.venv'))).toBe(false);
68
+ });
69
+ });
70
+ });