clawt 3.8.6 → 3.8.8

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
@@ -67,7 +67,13 @@ var COMMON_MESSAGES = {
67
67
  /** 守卫检测:配置的主工作分支已不存在 */
68
68
  GUARD_BRANCH_NOT_EXISTS: (branchName) => `\u914D\u7F6E\u7684\u4E3B\u5DE5\u4F5C\u5206\u652F ${branchName} \u5DF2\u4E0D\u5B58\u5728\uFF0C\u8BF7\u6267\u884C clawt init \u91CD\u65B0\u8BBE\u7F6E\u4E3B\u5DE5\u4F5C\u5206\u652F`,
69
69
  /** 守卫检测:当前分支与配置的主工作分支不一致 */
70
- GUARD_BRANCH_MISMATCH: (configuredBranch, currentBranch) => `\u5F53\u524D\u5206\u652F ${currentBranch} \u4E0E\u914D\u7F6E\u7684\u4E3B\u5DE5\u4F5C\u5206\u652F ${configuredBranch} \u4E0D\u4E00\u81F4\uFF0C\u5982\u9700\u66F4\u65B0\u8BF7\u6267\u884C clawt init`
70
+ GUARD_BRANCH_MISMATCH: (configuredBranch, currentBranch) => `\u5F53\u524D\u5206\u652F ${currentBranch} \u4E0E\u914D\u7F6E\u7684\u4E3B\u5DE5\u4F5C\u5206\u652F ${configuredBranch} \u4E0D\u4E00\u81F4\uFF0C\u5982\u9700\u66F4\u65B0\u8BF7\u6267\u884C clawt init`,
71
+ /** Git index 被锁定(index.lock 存在) */
72
+ GIT_INDEX_LOCKED: (lockFilePath) => `Git index \u88AB\u9501\u5B9A\uFF0C\u65E0\u6CD5\u6267\u884C\u64CD\u4F5C
73
+ \u539F\u56E0\uFF1A\u9501\u6587\u4EF6\u5DF2\u5B58\u5728\uFF08\u53EF\u80FD\u662F\u4E0A\u6B21 git \u64CD\u4F5C\u5F02\u5E38\u4E2D\u65AD\u6B8B\u7559\uFF09
74
+ \u9501\u6587\u4EF6\u8DEF\u5F84\uFF1A${lockFilePath}
75
+ \u4FEE\u590D\u65B9\u6CD5\uFF1A\u786E\u8BA4\u6CA1\u6709\u5176\u4ED6 git \u64CD\u4F5C\u5728\u8FDB\u884C\u540E\uFF0C\u6267\u884C\u4EE5\u4E0B\u547D\u4EE4\u5220\u9664\u9501\u6587\u4EF6\uFF1A
76
+ rm ${lockFilePath}`
71
77
  };
72
78
 
73
79
  // src/constants/messages/run.ts
@@ -993,20 +999,71 @@ function enableConsoleTransport() {
993
999
  }
994
1000
 
995
1001
  // src/utils/shell.ts
996
- import { execSync, execFileSync, spawn, spawnSync } from "child_process";
1002
+ import { execSync as execSync2, execFileSync, spawn, spawnSync } from "child_process";
1003
+
1004
+ // src/utils/git-lock.ts
1005
+ import { join as join2, isAbsolute } from "path";
1006
+ import { execSync } from "child_process";
1007
+ var INDEX_LOCK_ERROR_PATTERNS = [
1008
+ /Unable to write.*index/i,
1009
+ /index\.lock/i,
1010
+ /Unable to create.*index/i
1011
+ ];
1012
+ var INDEX_LOCK_PATH_EXTRACT_PATTERN = /'([^']*index\.lock)'/;
1013
+ function isGitIndexLockError(errorMessage) {
1014
+ return INDEX_LOCK_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage));
1015
+ }
1016
+ function extractFullErrorMessage(error) {
1017
+ if (!(error instanceof Error)) return String(error);
1018
+ const stderr = error.stderr;
1019
+ const stderrStr = stderr ? String(stderr) : "";
1020
+ return stderrStr ? `${error.message}
1021
+ ${stderrStr}` : error.message;
1022
+ }
1023
+ function findGitIndexLockPath(cwd) {
1024
+ try {
1025
+ const gitDir = execSync("git rev-parse --git-dir", {
1026
+ cwd,
1027
+ encoding: "utf-8",
1028
+ stdio: ["pipe", "pipe", "pipe"]
1029
+ }).trim();
1030
+ return isAbsolute(gitDir) ? join2(gitDir, "index.lock") : join2(cwd || process.cwd(), gitDir, "index.lock");
1031
+ } catch (error) {
1032
+ logger.warn(`\u5B9A\u4F4D git \u76EE\u5F55\u5931\u8D25: ${error}`);
1033
+ return join2(cwd || process.cwd(), ".git", "index.lock");
1034
+ }
1035
+ }
1036
+ function parseIndexLockPathFromError(errorMessage) {
1037
+ const match = errorMessage.match(INDEX_LOCK_PATH_EXTRACT_PATTERN);
1038
+ return match?.[1];
1039
+ }
1040
+ function throwIfGitIndexLockError(error, cwd) {
1041
+ const errorMessage = extractFullErrorMessage(error);
1042
+ if (isGitIndexLockError(errorMessage)) {
1043
+ const lockFilePath = parseIndexLockPathFromError(errorMessage) ?? findGitIndexLockPath(cwd);
1044
+ throw new ClawtError(MESSAGES.GIT_INDEX_LOCKED(lockFilePath));
1045
+ }
1046
+ }
1047
+
1048
+ // src/utils/shell.ts
997
1049
  function getEnvWithoutNestedSessionFlag() {
998
1050
  const { CLAUDECODE: _, ...env } = process.env;
999
1051
  return env;
1000
1052
  }
1001
1053
  function execCommand(command, options) {
1002
1054
  logger.debug(`\u6267\u884C\u547D\u4EE4: ${command}${options?.cwd ? ` (cwd: ${options.cwd})` : ""}`);
1003
- const result = execSync(command, {
1004
- cwd: options?.cwd,
1005
- encoding: "utf-8",
1006
- stdio: ["pipe", "pipe", "pipe"],
1007
- maxBuffer: EXEC_MAX_BUFFER
1008
- });
1009
- return result.trim();
1055
+ try {
1056
+ const result = execSync2(command, {
1057
+ cwd: options?.cwd,
1058
+ encoding: "utf-8",
1059
+ stdio: ["pipe", "pipe", "pipe"],
1060
+ maxBuffer: EXEC_MAX_BUFFER
1061
+ });
1062
+ return result.trim();
1063
+ } catch (error) {
1064
+ throwIfGitIndexLockError(error, options?.cwd);
1065
+ throw error;
1066
+ }
1010
1067
  }
1011
1068
  function spawnProcess(command, args, options) {
1012
1069
  logger.debug(`\u542F\u52A8\u5B50\u8FDB\u7A0B: ${command} ${args.join(" ")}${options?.cwd ? ` (cwd: ${options.cwd})` : ""}`);
@@ -1025,14 +1082,19 @@ function killAllChildProcesses(children) {
1025
1082
  }
1026
1083
  function execCommandWithInput(command, args, options) {
1027
1084
  logger.debug(`\u6267\u884C\u547D\u4EE4(stdin): ${command} ${args.join(" ")}${options.cwd ? ` (cwd: ${options.cwd})` : ""}`);
1028
- const result = execFileSync(command, args, {
1029
- cwd: options.cwd,
1030
- input: options.input,
1031
- encoding: "utf-8",
1032
- stdio: ["pipe", "pipe", "pipe"],
1033
- maxBuffer: EXEC_MAX_BUFFER
1034
- });
1035
- return result.trim();
1085
+ try {
1086
+ const result = execFileSync(command, args, {
1087
+ cwd: options.cwd,
1088
+ input: options.input,
1089
+ encoding: "utf-8",
1090
+ stdio: ["pipe", "pipe", "pipe"],
1091
+ maxBuffer: EXEC_MAX_BUFFER
1092
+ });
1093
+ return result.trim();
1094
+ } catch (error) {
1095
+ throwIfGitIndexLockError(error, options.cwd);
1096
+ throw error;
1097
+ }
1036
1098
  }
1037
1099
  function runCommandInherited(command, options) {
1038
1100
  logger.debug(`\u6267\u884C\u547D\u4EE4(inherit): ${command}${options?.cwd ? ` (cwd: ${options.cwd})` : ""}`);
@@ -1127,7 +1189,7 @@ function copyToClipboard(text) {
1127
1189
 
1128
1190
  // src/utils/git-core.ts
1129
1191
  import { basename } from "path";
1130
- import { execSync as execSync2, execFileSync as execFileSync2 } from "child_process";
1192
+ import { execSync as execSync3, execFileSync as execFileSync2 } from "child_process";
1131
1193
  function getGitCommonDir(cwd) {
1132
1194
  return execCommand("git rev-parse --git-common-dir", { cwd });
1133
1195
  }
@@ -1220,7 +1282,7 @@ function getHeadCommitHash(cwd) {
1220
1282
  }
1221
1283
  function gitDiffBinaryAgainstBranch(branchName, cwd) {
1222
1284
  logger.debug(`\u6267\u884C\u547D\u4EE4: git diff HEAD...${branchName} --binary${cwd ? ` (cwd: ${cwd})` : ""}`);
1223
- return execSync2(`git diff HEAD...${branchName} --binary`, {
1285
+ return execSync3(`git diff HEAD...${branchName} --binary`, {
1224
1286
  cwd,
1225
1287
  stdio: ["pipe", "pipe", "pipe"],
1226
1288
  maxBuffer: EXEC_MAX_BUFFER
@@ -1258,7 +1320,7 @@ function getCommitTreeHash(commitHash, cwd) {
1258
1320
  }
1259
1321
  function gitDiffTree(baseTreeHash, targetTreeHash, cwd) {
1260
1322
  logger.debug(`\u6267\u884C\u547D\u4EE4: git diff-tree -p --binary ${baseTreeHash} ${targetTreeHash}${cwd ? ` (cwd: ${cwd})` : ""}`);
1261
- return execSync2(`git diff-tree -p --binary ${baseTreeHash} ${targetTreeHash}`, {
1323
+ return execSync3(`git diff-tree -p --binary ${baseTreeHash} ${targetTreeHash}`, {
1262
1324
  cwd,
1263
1325
  stdio: ["pipe", "pipe", "pipe"],
1264
1326
  maxBuffer: EXEC_MAX_BUFFER
@@ -1535,11 +1597,11 @@ function validateBranchesNotExist(branchNames) {
1535
1597
 
1536
1598
  // src/utils/project-config.ts
1537
1599
  import { existsSync as existsSync3, readFileSync, writeFileSync } from "fs";
1538
- import { join as join3 } from "path";
1600
+ import { join as join4 } from "path";
1539
1601
 
1540
1602
  // src/utils/fs.ts
1541
1603
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, rmdirSync, statSync } from "fs";
1542
- import { join as join2 } from "path";
1604
+ import { join as join3 } from "path";
1543
1605
  function ensureDir(dirPath) {
1544
1606
  if (!existsSync2(dirPath)) {
1545
1607
  mkdirSync2(dirPath, { recursive: true });
@@ -1561,7 +1623,7 @@ function calculateDirSize(dirPath) {
1561
1623
  try {
1562
1624
  const entries = readdirSync(dirPath, { withFileTypes: true });
1563
1625
  for (const entry of entries) {
1564
- const fullPath = join2(dirPath, entry.name);
1626
+ const fullPath = join3(dirPath, entry.name);
1565
1627
  try {
1566
1628
  if (entry.isDirectory()) {
1567
1629
  totalSize += calculateDirSize(fullPath);
@@ -1578,7 +1640,7 @@ function calculateDirSize(dirPath) {
1578
1640
 
1579
1641
  // src/utils/project-config.ts
1580
1642
  function getProjectConfigPath(projectName) {
1581
- return join3(PROJECTS_CONFIG_DIR, projectName, "config.json");
1643
+ return join4(PROJECTS_CONFIG_DIR, projectName, "config.json");
1582
1644
  }
1583
1645
  function loadProjectConfig() {
1584
1646
  const projectName = getProjectName();
@@ -1597,7 +1659,7 @@ function loadProjectConfig() {
1597
1659
  function saveProjectConfig(config2) {
1598
1660
  const projectName = getProjectName();
1599
1661
  const configPath = getProjectConfigPath(projectName);
1600
- const projectDir = join3(PROJECTS_CONFIG_DIR, projectName);
1662
+ const projectDir = join4(PROJECTS_CONFIG_DIR, projectName);
1601
1663
  ensureDir(projectDir);
1602
1664
  writeFileSync(configPath, JSON.stringify(config2, null, 2), "utf-8");
1603
1665
  logger.info(`\u9879\u76EE\u914D\u7F6E\u5DF2\u4FDD\u5B58: ${configPath}`);
@@ -1792,11 +1854,11 @@ async function runPreChecks(options) {
1792
1854
  }
1793
1855
 
1794
1856
  // src/utils/worktree.ts
1795
- import { join as join4 } from "path";
1857
+ import { join as join5 } from "path";
1796
1858
  import { existsSync as existsSync4, readdirSync as readdirSync2 } from "fs";
1797
1859
  function getProjectWorktreeDir() {
1798
1860
  const projectName = getProjectName();
1799
- return join4(WORKTREES_DIR, projectName);
1861
+ return join5(WORKTREES_DIR, projectName);
1800
1862
  }
1801
1863
  function createWorktrees(branchName, count) {
1802
1864
  const sanitized = sanitizeBranchName(branchName);
@@ -1806,7 +1868,7 @@ function createWorktrees(branchName, count) {
1806
1868
  ensureDir(projectDir);
1807
1869
  const results = [];
1808
1870
  for (const name of branchNames) {
1809
- const worktreePath = join4(projectDir, name);
1871
+ const worktreePath = join5(projectDir, name);
1810
1872
  createWorktree(name, worktreePath);
1811
1873
  createValidateBranch(name);
1812
1874
  results.push({ path: worktreePath, branch: name });
@@ -1820,7 +1882,7 @@ function createWorktreesByBranches(branchNames) {
1820
1882
  ensureDir(projectDir);
1821
1883
  const results = [];
1822
1884
  for (const name of branchNames) {
1823
- const worktreePath = join4(projectDir, name);
1885
+ const worktreePath = join5(projectDir, name);
1824
1886
  createWorktree(name, worktreePath);
1825
1887
  createValidateBranch(name);
1826
1888
  results.push({ path: worktreePath, branch: name });
@@ -1843,7 +1905,7 @@ function getProjectWorktrees() {
1843
1905
  if (!entry.isDirectory()) {
1844
1906
  continue;
1845
1907
  }
1846
- const fullPath = join4(projectDir, entry.name);
1908
+ const fullPath = join5(projectDir, entry.name);
1847
1909
  if (registeredPaths.has(fullPath)) {
1848
1910
  worktrees.push({
1849
1911
  path: fullPath,
@@ -1944,7 +2006,7 @@ async function promptCommitMessage(promptMessage, nonInteractiveErrorMessage) {
1944
2006
  // src/utils/claude.ts
1945
2007
  import { spawnSync as spawnSync3 } from "child_process";
1946
2008
  import { existsSync as existsSync7, readdirSync as readdirSync3 } from "fs";
1947
- import { join as join5 } from "path";
2009
+ import { join as join6 } from "path";
1948
2010
 
1949
2011
  // src/utils/terminal.ts
1950
2012
  import { execFileSync as execFileSync3 } from "child_process";
@@ -2023,7 +2085,7 @@ function encodeClaudeProjectPath(absolutePath) {
2023
2085
  }
2024
2086
  function hasClaudeSessionHistory(worktreePath) {
2025
2087
  const encodedName = encodeClaudeProjectPath(worktreePath);
2026
- const projectDir = join5(CLAUDE_PROJECTS_DIR, encodedName);
2088
+ const projectDir = join6(CLAUDE_PROJECTS_DIR, encodedName);
2027
2089
  if (!existsSync7(projectDir)) {
2028
2090
  return false;
2029
2091
  }
@@ -2081,16 +2143,16 @@ function launchInteractiveClaudeInNewTerminal(worktree, hasPreviousSession) {
2081
2143
  }
2082
2144
 
2083
2145
  // src/utils/validate-snapshot.ts
2084
- import { join as join6 } from "path";
2146
+ import { join as join7 } from "path";
2085
2147
  import { existsSync as existsSync8, readFileSync as readFileSync3, writeFileSync as writeFileSync3, unlinkSync, readdirSync as readdirSync4, rmdirSync as rmdirSync2, statSync as statSync2 } from "fs";
2086
2148
  function getSnapshotPath(projectName, branchName) {
2087
- return join6(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.tree`);
2149
+ return join7(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.tree`);
2088
2150
  }
2089
2151
  function getSnapshotHeadPath(projectName, branchName) {
2090
- return join6(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.head`);
2152
+ return join7(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.head`);
2091
2153
  }
2092
2154
  function getSnapshotStagedPath(projectName, branchName) {
2093
- return join6(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.staged`);
2155
+ return join7(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.staged`);
2094
2156
  }
2095
2157
  function hasSnapshot(projectName, branchName) {
2096
2158
  return existsSync8(getSnapshotPath(projectName, branchName));
@@ -2112,7 +2174,7 @@ function readSnapshot(projectName, branchName) {
2112
2174
  return { treeHash, headCommitHash, stagedTreeHash };
2113
2175
  }
2114
2176
  function writeSnapshot(projectName, branchName, treeHash, headCommitHash, stagedTreeHash) {
2115
- const snapshotDir = join6(VALIDATE_SNAPSHOTS_DIR, projectName);
2177
+ const snapshotDir = join7(VALIDATE_SNAPSHOTS_DIR, projectName);
2116
2178
  ensureDir(snapshotDir);
2117
2179
  if (treeHash !== void 0) {
2118
2180
  writeFileSync3(getSnapshotPath(projectName, branchName), treeHash, "utf-8");
@@ -2143,7 +2205,7 @@ function removeSnapshot(projectName, branchName) {
2143
2205
  }
2144
2206
  }
2145
2207
  function getProjectSnapshotBranches(projectName) {
2146
- const projectDir = join6(VALIDATE_SNAPSHOTS_DIR, projectName);
2208
+ const projectDir = join7(VALIDATE_SNAPSHOTS_DIR, projectName);
2147
2209
  if (!existsSync8(projectDir)) {
2148
2210
  return [];
2149
2211
  }
@@ -2151,13 +2213,13 @@ function getProjectSnapshotBranches(projectName) {
2151
2213
  return files.filter((f) => f.endsWith(".tree")).map((f) => f.replace(/\.tree$/, ""));
2152
2214
  }
2153
2215
  function removeProjectSnapshots(projectName) {
2154
- const projectDir = join6(VALIDATE_SNAPSHOTS_DIR, projectName);
2216
+ const projectDir = join7(VALIDATE_SNAPSHOTS_DIR, projectName);
2155
2217
  if (!existsSync8(projectDir)) {
2156
2218
  return;
2157
2219
  }
2158
2220
  const files = readdirSync4(projectDir);
2159
2221
  for (const file of files) {
2160
- unlinkSync(join6(projectDir, file));
2222
+ unlinkSync(join7(projectDir, file));
2161
2223
  }
2162
2224
  try {
2163
2225
  rmdirSync2(projectDir);
@@ -3080,7 +3142,7 @@ async function executeBatchTasks(worktrees, tasks, concurrency, continueFlags) {
3080
3142
 
3081
3143
  // src/utils/dry-run.ts
3082
3144
  import chalk6 from "chalk";
3083
- import { join as join7 } from "path";
3145
+ import { join as join8 } from "path";
3084
3146
  var DRY_RUN_TASK_DESC_MAX_LENGTH = 80;
3085
3147
  function truncateTaskDesc(task) {
3086
3148
  const oneLine = task.replace(/\n/g, " ").trim();
@@ -3108,7 +3170,7 @@ function printDryRunPreview(branchNames, tasks, concurrency) {
3108
3170
  let hasConflict = false;
3109
3171
  for (let i = 0; i < branchNames.length; i++) {
3110
3172
  const branch = branchNames[i];
3111
- const worktreePath = join7(projectDir, branch);
3173
+ const worktreePath = join8(projectDir, branch);
3112
3174
  const exists = checkBranchExists(branch);
3113
3175
  if (exists) hasConflict = true;
3114
3176
  const indexLabel = `[${i + 1}/${branchNames.length}]`;
@@ -3264,7 +3326,7 @@ async function promptStringValue(key, currentValue) {
3264
3326
 
3265
3327
  // src/utils/update-checker.ts
3266
3328
  import { readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
3267
- import { execSync as execSync3 } from "child_process";
3329
+ import { execSync as execSync4 } from "child_process";
3268
3330
  import { request } from "https";
3269
3331
  import chalk8 from "chalk";
3270
3332
  import stringWidth2 from "string-width";
@@ -3330,7 +3392,7 @@ function detectPackageManager() {
3330
3392
  ];
3331
3393
  for (const { name, command } of checks) {
3332
3394
  try {
3333
- const output = execSync3(command, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
3395
+ const output = execSync4(command, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
3334
3396
  if (output.includes(PACKAGE_NAME)) {
3335
3397
  return name;
3336
3398
  }
@@ -4002,14 +4064,7 @@ var InteractivePanel = class {
4002
4064
  this.clearTimers();
4003
4065
  this.keyboardController.stop();
4004
4066
  this.restoreTerminal();
4005
- if (this.resizeHandler) {
4006
- process.stdout.removeListener("resize", this.resizeHandler);
4007
- this.resizeHandler = null;
4008
- }
4009
- if (this.exitHandler) {
4010
- process.removeListener("exit", this.exitHandler);
4011
- this.exitHandler = null;
4012
- }
4067
+ this.removeTerminalListeners();
4013
4068
  if (this.resolveStart) {
4014
4069
  this.resolveStart();
4015
4070
  this.resolveStart = null;
@@ -4019,6 +4074,7 @@ var InteractivePanel = class {
4019
4074
  * 初始化终端:进入备选屏幕、隐藏光标、禁用行换行
4020
4075
  */
4021
4076
  initTerminal() {
4077
+ this.removeTerminalListeners();
4022
4078
  process.stdout.write(ALT_SCREEN_ENTER);
4023
4079
  process.stdout.write(CURSOR_HIDE);
4024
4080
  process.stdout.write(LINE_WRAP_DISABLE);
@@ -4029,13 +4085,22 @@ var InteractivePanel = class {
4029
4085
  }
4030
4086
  };
4031
4087
  process.stdout.on("resize", this.resizeHandler);
4032
- this.exitHandler = () => {
4033
- process.stdout.write(LINE_WRAP_ENABLE);
4034
- process.stdout.write(CURSOR_SHOW);
4035
- process.stdout.write(ALT_SCREEN_LEAVE);
4036
- };
4088
+ this.exitHandler = () => this.restoreTerminal();
4037
4089
  process.on("exit", this.exitHandler);
4038
4090
  }
4091
+ /**
4092
+ * 移除终端事件监听器(resize 和 exit)
4093
+ */
4094
+ removeTerminalListeners() {
4095
+ if (this.resizeHandler) {
4096
+ process.stdout.removeListener("resize", this.resizeHandler);
4097
+ this.resizeHandler = null;
4098
+ }
4099
+ if (this.exitHandler) {
4100
+ process.removeListener("exit", this.exitHandler);
4101
+ this.exitHandler = null;
4102
+ }
4103
+ }
4039
4104
  /**
4040
4105
  * 恢复终端:启用行换行、显示光标、退出备选屏幕
4041
4106
  */
@@ -4178,6 +4243,7 @@ var InteractivePanel = class {
4178
4243
  this.isOperating = true;
4179
4244
  this.clearTimers();
4180
4245
  this.restoreTerminal();
4246
+ this.removeTerminalListeners();
4181
4247
  this.keyboardController.stop();
4182
4248
  action();
4183
4249
  console.log(PANEL_PRESS_ENTER_TO_RETURN);
@@ -4336,7 +4402,7 @@ async function handleMergeConflict(currentBranch, incomingBranch, cwd, autoFlag)
4336
4402
  // src/hooks/post-create.ts
4337
4403
  import { existsSync as existsSync10, accessSync, chmodSync, constants as fsConstants } from "fs";
4338
4404
  import { spawn as spawn2 } from "child_process";
4339
- import { join as join8 } from "path";
4405
+ import { join as join9 } from "path";
4340
4406
  var POST_CREATE_SCRIPT_RELATIVE_PATH = ".clawt/postCreate.sh";
4341
4407
  function isExecutable(filePath) {
4342
4408
  try {
@@ -4368,7 +4434,7 @@ function resolvePostCreateHook() {
4368
4434
  }
4369
4435
  }
4370
4436
  const mainWorktreePath = getMainWorktreePath();
4371
- const scriptPath = join8(mainWorktreePath, POST_CREATE_SCRIPT_RELATIVE_PATH);
4437
+ const scriptPath = join9(mainWorktreePath, POST_CREATE_SCRIPT_RELATIVE_PATH);
4372
4438
  if (existsSync10(scriptPath)) {
4373
4439
  if (!isExecutable(scriptPath)) {
4374
4440
  autoFixExecutablePermission(scriptPath);
@@ -5596,7 +5662,7 @@ function registerAliasCommand(program2) {
5596
5662
 
5597
5663
  // src/commands/projects.ts
5598
5664
  import { existsSync as existsSync11, readdirSync as readdirSync5, statSync as statSync4 } from "fs";
5599
- import { join as join9 } from "path";
5665
+ import { join as join10 } from "path";
5600
5666
  import chalk13 from "chalk";
5601
5667
  function registerProjectsCommand(program2) {
5602
5668
  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) => {
@@ -5620,7 +5686,7 @@ function handleProjectsOverview(json) {
5620
5686
  printProjectsOverviewAsText(result);
5621
5687
  }
5622
5688
  function handleProjectDetail(name, json) {
5623
- const projectDir = join9(WORKTREES_DIR, name);
5689
+ const projectDir = join10(WORKTREES_DIR, name);
5624
5690
  if (!existsSync11(projectDir)) {
5625
5691
  printError(MESSAGES.PROJECTS_NOT_FOUND(name));
5626
5692
  process.exit(1);
@@ -5643,7 +5709,7 @@ function collectProjectsOverview() {
5643
5709
  if (!entry.isDirectory()) {
5644
5710
  continue;
5645
5711
  }
5646
- const projectDir = join9(WORKTREES_DIR, entry.name);
5712
+ const projectDir = join10(WORKTREES_DIR, entry.name);
5647
5713
  const overview = collectSingleProjectOverview(entry.name, projectDir);
5648
5714
  projects.push(overview);
5649
5715
  }
@@ -5660,7 +5726,7 @@ function collectSingleProjectOverview(name, projectDir) {
5660
5726
  const worktreeDirs = subEntries.filter((e) => e.isDirectory());
5661
5727
  const worktreeCount = worktreeDirs.length;
5662
5728
  const diskUsage = calculateDirSize(projectDir);
5663
- const lastActiveTime = resolveProjectLastActiveTime(projectDir, worktreeDirs.map((e) => join9(projectDir, e.name)));
5729
+ const lastActiveTime = resolveProjectLastActiveTime(projectDir, worktreeDirs.map((e) => join10(projectDir, e.name)));
5664
5730
  return {
5665
5731
  name,
5666
5732
  worktreeCount,
@@ -5675,7 +5741,7 @@ function collectProjectDetail(name, projectDir) {
5675
5741
  if (!entry.isDirectory()) {
5676
5742
  continue;
5677
5743
  }
5678
- const wtPath = join9(projectDir, entry.name);
5744
+ const wtPath = join10(projectDir, entry.name);
5679
5745
  const detail = collectSingleWorktreeDetail(entry.name, wtPath);
5680
5746
  worktrees.push(detail);
5681
5747
  }
@@ -5826,11 +5892,11 @@ compdef _clawt_completion clawt
5826
5892
 
5827
5893
  // src/utils/completion-engine.ts
5828
5894
  import { existsSync as existsSync12, readdirSync as readdirSync6, statSync as statSync5 } from "fs";
5829
- import { join as join10, dirname, basename as basename2 } from "path";
5895
+ import { join as join11, dirname, basename as basename2 } from "path";
5830
5896
  function completeFilePath(partial) {
5831
5897
  const cwd = process.cwd();
5832
5898
  const hasDir = partial.includes("/");
5833
- const searchDir = hasDir ? join10(cwd, dirname(partial)) : cwd;
5899
+ const searchDir = hasDir ? join11(cwd, dirname(partial)) : cwd;
5834
5900
  const prefix = hasDir ? basename2(partial) : partial;
5835
5901
  if (!existsSync12(searchDir)) {
5836
5902
  return [];
@@ -5841,7 +5907,7 @@ function completeFilePath(partial) {
5841
5907
  for (const entry of entries) {
5842
5908
  if (!entry.startsWith(prefix)) continue;
5843
5909
  if (entry.startsWith(".")) continue;
5844
- const fullPath = join10(searchDir, entry);
5910
+ const fullPath = join11(searchDir, entry);
5845
5911
  try {
5846
5912
  const stat = statSync5(fullPath);
5847
5913
  if (stat.isDirectory()) {
@@ -6065,12 +6131,12 @@ async function handleHome() {
6065
6131
  }
6066
6132
 
6067
6133
  // src/commands/tasks.ts
6068
- import { resolve as resolve3, dirname as dirname2, join as join11 } from "path";
6134
+ import { resolve as resolve3, dirname as dirname2, join as join12 } from "path";
6069
6135
  import { existsSync as existsSync14, writeFileSync as writeFileSync6 } from "fs";
6070
6136
  function registerTasksCommand(program2) {
6071
6137
  const taskCmd = program2.command("tasks").description("\u4EFB\u52A1\u6587\u4EF6\u7BA1\u7406");
6072
6138
  taskCmd.command("init").description("\u751F\u6210\u4EFB\u52A1\u6A21\u677F\u6587\u4EF6").argument("[path]", "\u8F93\u51FA\u6587\u4EF6\u8DEF\u5F84").action(async (path2) => {
6073
- const filePath = path2 ?? join11(TASK_TEMPLATE_OUTPUT_DIR, generateTaskFilename(TASK_TEMPLATE_FILENAME_PREFIX));
6139
+ const filePath = path2 ?? join12(TASK_TEMPLATE_OUTPUT_DIR, generateTaskFilename(TASK_TEMPLATE_FILENAME_PREFIX));
6074
6140
  await handleTasksInit(filePath);
6075
6141
  });
6076
6142
  }
@@ -58,7 +58,13 @@ var COMMON_MESSAGES = {
58
58
  /** 守卫检测:配置的主工作分支已不存在 */
59
59
  GUARD_BRANCH_NOT_EXISTS: (branchName) => `\u914D\u7F6E\u7684\u4E3B\u5DE5\u4F5C\u5206\u652F ${branchName} \u5DF2\u4E0D\u5B58\u5728\uFF0C\u8BF7\u6267\u884C clawt init \u91CD\u65B0\u8BBE\u7F6E\u4E3B\u5DE5\u4F5C\u5206\u652F`,
60
60
  /** 守卫检测:当前分支与配置的主工作分支不一致 */
61
- GUARD_BRANCH_MISMATCH: (configuredBranch, currentBranch) => `\u5F53\u524D\u5206\u652F ${currentBranch} \u4E0E\u914D\u7F6E\u7684\u4E3B\u5DE5\u4F5C\u5206\u652F ${configuredBranch} \u4E0D\u4E00\u81F4\uFF0C\u5982\u9700\u66F4\u65B0\u8BF7\u6267\u884C clawt init`
61
+ GUARD_BRANCH_MISMATCH: (configuredBranch, currentBranch) => `\u5F53\u524D\u5206\u652F ${currentBranch} \u4E0E\u914D\u7F6E\u7684\u4E3B\u5DE5\u4F5C\u5206\u652F ${configuredBranch} \u4E0D\u4E00\u81F4\uFF0C\u5982\u9700\u66F4\u65B0\u8BF7\u6267\u884C clawt init`,
62
+ /** Git index 被锁定(index.lock 存在) */
63
+ GIT_INDEX_LOCKED: (lockFilePath) => `Git index \u88AB\u9501\u5B9A\uFF0C\u65E0\u6CD5\u6267\u884C\u64CD\u4F5C
64
+ \u539F\u56E0\uFF1A\u9501\u6587\u4EF6\u5DF2\u5B58\u5728\uFF08\u53EF\u80FD\u662F\u4E0A\u6B21 git \u64CD\u4F5C\u5F02\u5E38\u4E2D\u65AD\u6B8B\u7559\uFF09
65
+ \u9501\u6587\u4EF6\u8DEF\u5F84\uFF1A${lockFilePath}
66
+ \u4FEE\u590D\u65B9\u6CD5\uFF1A\u786E\u8BA4\u6CA1\u6709\u5176\u4ED6 git \u64CD\u4F5C\u5728\u8FDB\u884C\u540E\uFF0C\u6267\u884C\u4EE5\u4E0B\u547D\u4EE4\u5220\u9664\u9501\u6587\u4EF6\uFF1A
67
+ rm ${lockFilePath}`
62
68
  };
63
69
 
64
70
  // src/constants/messages/run.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "3.8.6",
3
+ "version": "3.8.8",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -48,4 +48,11 @@ export const COMMON_MESSAGES = {
48
48
  /** 守卫检测:当前分支与配置的主工作分支不一致 */
49
49
  GUARD_BRANCH_MISMATCH: (configuredBranch: string, currentBranch: string) =>
50
50
  `当前分支 ${currentBranch} 与配置的主工作分支 ${configuredBranch} 不一致,如需更新请执行 clawt init`,
51
+ /** Git index 被锁定(index.lock 存在) */
52
+ GIT_INDEX_LOCKED: (lockFilePath: string) =>
53
+ `Git index 被锁定,无法执行操作\n` +
54
+ ` 原因:锁文件已存在(可能是上次 git 操作异常中断残留)\n` +
55
+ ` 锁文件路径:${lockFilePath}\n` +
56
+ ` 修复方法:确认没有其他 git 操作在进行后,执行以下命令删除锁文件:\n` +
57
+ ` rm ${lockFilePath}`,
51
58
  } as const;
@@ -0,0 +1,90 @@
1
+ import { join, isAbsolute } from 'node:path';
2
+ import { execSync } from 'node:child_process';
3
+ import { logger } from '../logger/index.js';
4
+ import { ClawtError } from '../errors/index.js';
5
+ import { MESSAGES } from '../constants/index.js';
6
+
7
+ /**
8
+ * index.lock 错误的关键词匹配模式
9
+ * 每个模式同时要求包含 index 关键词,避免 "Unable to write" 单独匹配导致误报
10
+ */
11
+ const INDEX_LOCK_ERROR_PATTERNS = [
12
+ /Unable to write.*index/i,
13
+ /index\.lock/i,
14
+ /Unable to create.*index/i,
15
+ ];
16
+
17
+ /** 从 Git 错误消息中提取 index.lock 文件路径的正则(路径被 ASCII 单引号包裹) */
18
+ const INDEX_LOCK_PATH_EXTRACT_PATTERN = /'([^']*index\.lock)'/;
19
+
20
+ /**
21
+ * 检测错误消息是否为 Git index.lock 相关错误
22
+ * @param {string} errorMessage - 错误消息字符串
23
+ * @returns {boolean} 是否为 index.lock 相关错误
24
+ */
25
+ export function isGitIndexLockError(errorMessage: string): boolean {
26
+ return INDEX_LOCK_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage));
27
+ }
28
+
29
+ /**
30
+ * 从错误对象中提取完整的错误消息(包括 stderr)
31
+ * execSync/execFileSync 抛出的错误对象的 stderr 属性包含 git 的实际错误输出
32
+ * @param {unknown} error - 捕获的错误对象
33
+ * @returns {string} 合并后的错误消息
34
+ */
35
+ function extractFullErrorMessage(error: unknown): string {
36
+ if (!(error instanceof Error)) return String(error);
37
+ const stderr = (error as { stderr?: string | Buffer }).stderr;
38
+ const stderrStr = stderr ? String(stderr) : '';
39
+ return stderrStr ? `${error.message}\n${stderrStr}` : error.message;
40
+ }
41
+
42
+ /**
43
+ * 定位 Git index.lock 文件的完整路径
44
+ * 兼容主 worktree 和子 worktree 场景(子 worktree 的 .git 是文件而非目录)
45
+ * @param {string} [cwd] - 工作目录
46
+ * @returns {string} index.lock 文件的完整路径
47
+ */
48
+ export function findGitIndexLockPath(cwd?: string): string {
49
+ try {
50
+ // 不使用 shell.ts 的 execCommand,避免循环依赖(shell.ts → git-lock.ts → shell.ts)
51
+ const gitDir = execSync('git rev-parse --git-dir', {
52
+ cwd,
53
+ encoding: 'utf-8',
54
+ stdio: ['pipe', 'pipe', 'pipe'],
55
+ }).trim();
56
+ // git rev-parse --git-dir 在某些场景(如设置了 GIT_DIR 环境变量)下返回绝对路径
57
+ return isAbsolute(gitDir) ? join(gitDir, 'index.lock') : join(cwd || process.cwd(), gitDir, 'index.lock');
58
+ } catch (error) {
59
+ logger.warn(`定位 git 目录失败: ${error}`);
60
+ // 降级:返回默认路径
61
+ return join(cwd || process.cwd(), '.git', 'index.lock');
62
+ }
63
+ }
64
+
65
+ /**
66
+ * 从 Git 错误消息中解析 index.lock 文件的完整路径
67
+ * Git 的 index.lock 错误消息格式统一为:Unable to create '<绝对路径>/index.lock': File exists
68
+ * 路径被 ASCII 单引号包裹(中英文 locale 均如此,实测验证)
69
+ * @param {string} errorMessage - 错误消息字符串
70
+ * @returns {string | undefined} 解析出的路径,无法解析时返回 undefined
71
+ */
72
+ function parseIndexLockPathFromError(errorMessage: string): string | undefined {
73
+ const match = errorMessage.match(INDEX_LOCK_PATH_EXTRACT_PATTERN);
74
+ return match?.[1];
75
+ }
76
+
77
+ /**
78
+ * 检测错误是否为 Git index.lock 错误,如果是则抛出中文友好提示的 ClawtError
79
+ * @param {unknown} error - 捕获的错误对象
80
+ * @param {string} [cwd] - 工作目录(用于定位锁文件路径)
81
+ * @throws {ClawtError} 当检测到 index.lock 错误时抛出
82
+ */
83
+ export function throwIfGitIndexLockError(error: unknown, cwd?: string): void {
84
+ const errorMessage = extractFullErrorMessage(error);
85
+ if (isGitIndexLockError(errorMessage)) {
86
+ // 优先从 git 错误消息中解析路径(零开销且最准确),解析失败时降级到 findGitIndexLockPath
87
+ const lockFilePath = parseIndexLockPathFromError(errorMessage) ?? findGitIndexLockPath(cwd);
88
+ throw new ClawtError(MESSAGES.GIT_INDEX_LOCKED(lockFilePath));
89
+ }
90
+ }
package/src/utils/git.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './git-core.js';
2
2
  export * from './git-branch.js';
3
3
  export * from './git-worktree.js';
4
+ export * from './git-lock.js';
@@ -57,6 +57,7 @@ export {
57
57
  gitMergeContinue,
58
58
  gitMergeAbort,
59
59
  buildAutoSaveCommitMessage,
60
+ throwIfGitIndexLockError,
60
61
  } from './git.js';
61
62
  export { sanitizeBranchName, generateBranchNames, validateBranchesNotExist } from './branch.js';
62
63
  export { validateMainWorktree, validateGitInstalled, validateClaudeCodeInstalled, validateHeadExists, validateWorkingDirClean, runPreChecks } from './validation.js';
@@ -125,17 +125,7 @@ export class InteractivePanel {
125
125
  // 恢复终端
126
126
  this.restoreTerminal();
127
127
 
128
- // 移除 resize 监听
129
- if (this.resizeHandler) {
130
- process.stdout.removeListener('resize', this.resizeHandler);
131
- this.resizeHandler = null;
132
- }
133
-
134
- // 移除 exit 兜底
135
- if (this.exitHandler) {
136
- process.removeListener('exit', this.exitHandler);
137
- this.exitHandler = null;
138
- }
128
+ this.removeTerminalListeners();
139
129
 
140
130
  // 完成 Promise
141
131
  if (this.resolveStart) {
@@ -148,6 +138,9 @@ export class InteractivePanel {
148
138
  * 初始化终端:进入备选屏幕、隐藏光标、禁用行换行
149
139
  */
150
140
  private initTerminal(): void {
141
+ // 确保不会重复注册监听器
142
+ this.removeTerminalListeners();
143
+
151
144
  process.stdout.write(ALT_SCREEN_ENTER);
152
145
  process.stdout.write(CURSOR_HIDE);
153
146
  process.stdout.write(LINE_WRAP_DISABLE);
@@ -163,14 +156,24 @@ export class InteractivePanel {
163
156
  process.stdout.on('resize', this.resizeHandler);
164
157
 
165
158
  // 注册 exit 兜底,确保异常退出时终端状态被恢复
166
- this.exitHandler = () => {
167
- process.stdout.write(LINE_WRAP_ENABLE);
168
- process.stdout.write(CURSOR_SHOW);
169
- process.stdout.write(ALT_SCREEN_LEAVE);
170
- };
159
+ this.exitHandler = () => this.restoreTerminal();
171
160
  process.on('exit', this.exitHandler);
172
161
  }
173
162
 
163
+ /**
164
+ * 移除终端事件监听器(resize 和 exit)
165
+ */
166
+ private removeTerminalListeners(): void {
167
+ if (this.resizeHandler) {
168
+ process.stdout.removeListener('resize', this.resizeHandler);
169
+ this.resizeHandler = null;
170
+ }
171
+ if (this.exitHandler) {
172
+ process.removeListener('exit', this.exitHandler);
173
+ this.exitHandler = null;
174
+ }
175
+ }
176
+
174
177
  /**
175
178
  * 恢复终端:启用行换行、显示光标、退出备选屏幕
176
179
  */
@@ -365,6 +368,8 @@ export class InteractivePanel {
365
368
 
366
369
  // 恢复终端以便子命令输出
367
370
  this.restoreTerminal();
371
+ // 移除旧的终端事件监听器,避免 initTerminal() 重复注册导致泄漏
372
+ this.removeTerminalListeners();
368
373
 
369
374
  // 恢复 stdin 以便子命令交互
370
375
  this.keyboardController.stop();
@@ -1,6 +1,7 @@
1
1
  import { execSync, execFileSync, spawn, spawnSync, type ChildProcess, type SpawnSyncReturns, type StdioOptions } from 'node:child_process';
2
2
  import { logger } from '../logger/index.js';
3
3
  import { EXEC_MAX_BUFFER } from '../constants/git.js';
4
+ import { throwIfGitIndexLockError } from './git-lock.js';
4
5
 
5
6
  /**
6
7
  * 获取移除了 CLAUDECODE 嵌套会话标记的环境变量副本
@@ -45,17 +46,23 @@ export interface ParallelCommandResultWithStderr extends ParallelCommandResult {
45
46
  * @param {object} options - 可选配置
46
47
  * @param {string} options.cwd - 工作目录
47
48
  * @returns {string} 命令的标准输出(已 trim)
48
- * @throws {Error} 命令执行失败时抛出
49
+ * @throws {ClawtError} 检测到 index.lock 错误时抛出中文友好提示
50
+ * @throws {Error} 其他命令执行失败时抛出
49
51
  */
50
52
  export function execCommand(command: string, options?: { cwd?: string }): string {
51
53
  logger.debug(`执行命令: ${command}${options?.cwd ? ` (cwd: ${options.cwd})` : ''}`);
52
- const result = execSync(command, {
53
- cwd: options?.cwd,
54
- encoding: 'utf-8',
55
- stdio: ['pipe', 'pipe', 'pipe'],
56
- maxBuffer: EXEC_MAX_BUFFER,
57
- });
58
- return result.trim();
54
+ try {
55
+ const result = execSync(command, {
56
+ cwd: options?.cwd,
57
+ encoding: 'utf-8',
58
+ stdio: ['pipe', 'pipe', 'pipe'],
59
+ maxBuffer: EXEC_MAX_BUFFER,
60
+ });
61
+ return result.trim();
62
+ } catch (error) {
63
+ throwIfGitIndexLockError(error, options?.cwd);
64
+ throw error;
65
+ }
59
66
  }
60
67
 
61
68
  /**
@@ -100,18 +107,24 @@ export function killAllChildProcesses(children: ChildProcess[]): void {
100
107
  * @param {Buffer} options.input - 通过 stdin 传入的数据(Buffer 格式,保留二进制完整性)
101
108
  * @param {string} [options.cwd] - 工作目录
102
109
  * @returns {string} 命令的标准输出(已 trim)
103
- * @throws {Error} 命令执行失败时抛出
110
+ * @throws {ClawtError} 检测到 index.lock 错误时抛出中文友好提示
111
+ * @throws {Error} 其他命令执行失败时抛出
104
112
  */
105
113
  export function execCommandWithInput(command: string, args: string[], options: { input: Buffer; cwd?: string }): string {
106
114
  logger.debug(`执行命令(stdin): ${command} ${args.join(' ')}${options.cwd ? ` (cwd: ${options.cwd})` : ''}`);
107
- const result = execFileSync(command, args, {
108
- cwd: options.cwd,
109
- input: options.input,
110
- encoding: 'utf-8',
111
- stdio: ['pipe', 'pipe', 'pipe'],
112
- maxBuffer: EXEC_MAX_BUFFER,
113
- });
114
- return result.trim();
115
+ try {
116
+ const result = execFileSync(command, args, {
117
+ cwd: options.cwd,
118
+ input: options.input,
119
+ encoding: 'utf-8',
120
+ stdio: ['pipe', 'pipe', 'pipe'],
121
+ maxBuffer: EXEC_MAX_BUFFER,
122
+ });
123
+ return result.trim();
124
+ } catch (error) {
125
+ throwIfGitIndexLockError(error, options.cwd);
126
+ throw error;
127
+ }
115
128
  }
116
129
 
117
130
  /**
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ // mock node:child_process
4
+ vi.mock('node:child_process', () => ({
5
+ execSync: vi.fn(),
6
+ }));
7
+
8
+ // mock logger
9
+ vi.mock('../../../src/logger/index.js', () => ({
10
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
11
+ }));
12
+
13
+ // mock errors
14
+ vi.mock('../../../src/errors/index.js', () => ({
15
+ ClawtError: class ClawtError extends Error {
16
+ constructor(message: string) {
17
+ super(message);
18
+ this.name = 'ClawtError';
19
+ }
20
+ },
21
+ }));
22
+
23
+ // mock constants
24
+ vi.mock('../../../src/constants/index.js', () => ({
25
+ MESSAGES: {
26
+ GIT_INDEX_LOCKED: (lockFilePath: string) => `Git index 被锁定,锁文件路径:${lockFilePath}`,
27
+ },
28
+ }));
29
+
30
+ import { execSync } from 'node:child_process';
31
+ import { isGitIndexLockError, findGitIndexLockPath, throwIfGitIndexLockError } from '../../../src/utils/git-lock.js';
32
+
33
+ const mockedExecSync = vi.mocked(execSync);
34
+
35
+ describe('isGitIndexLockError', () => {
36
+ it('检测 "Unable to write index" 错误', () => {
37
+ expect(isGitIndexLockError('fatal: Unable to write index.')).toBe(true);
38
+ });
39
+
40
+ it('检测 "Unable to write new index file" 错误', () => {
41
+ expect(isGitIndexLockError('fatal: Unable to write new index file')).toBe(true);
42
+ });
43
+
44
+ it('检测包含 "index.lock" 的错误', () => {
45
+ expect(isGitIndexLockError("fatal: Unable to create '/repo/.git/index.lock': File exists.")).toBe(true);
46
+ });
47
+
48
+ it('检测 "Unable to create" 含 index 的错误', () => {
49
+ expect(isGitIndexLockError("Unable to create '/path/.git/index.lock'")).toBe(true);
50
+ });
51
+
52
+ it('不误判普通 merge 冲突错误', () => {
53
+ expect(isGitIndexLockError('CONFLICT (content): Merge conflict in file.ts')).toBe(false);
54
+ });
55
+
56
+ it('不误判分支不存在错误', () => {
57
+ expect(isGitIndexLockError("error: pathspec 'nonexistent' did not match any file(s) known to git")).toBe(false);
58
+ });
59
+
60
+ it('不误判普通命令失败', () => {
61
+ expect(isGitIndexLockError('Command failed: git merge feature-branch')).toBe(false);
62
+ });
63
+
64
+ it('不误判 push 拒绝错误', () => {
65
+ expect(isGitIndexLockError('error: failed to push some refs')).toBe(false);
66
+ });
67
+
68
+ it('空字符串不匹配', () => {
69
+ expect(isGitIndexLockError('')).toBe(false);
70
+ });
71
+
72
+ it('不误判不含 index 关键词的 Unable to write 错误', () => {
73
+ expect(isGitIndexLockError('fatal: Unable to write sha1 filename')).toBe(false);
74
+ });
75
+
76
+ it('大小写不敏感匹配 "unable to write"', () => {
77
+ expect(isGitIndexLockError('FATAL: UNABLE TO WRITE INDEX.')).toBe(true);
78
+ });
79
+ });
80
+
81
+ describe('findGitIndexLockPath', () => {
82
+ it('正确拼接 git 目录和 index.lock 路径', () => {
83
+ mockedExecSync.mockReturnValue('.git\n');
84
+ const result = findGitIndexLockPath('/repo');
85
+ expect(result).toContain('.git');
86
+ expect(result).toContain('index.lock');
87
+ });
88
+
89
+ it('git rev-parse 失败时降级返回默认路径', () => {
90
+ mockedExecSync.mockImplementation(() => { throw new Error('not a git repo'); });
91
+ const result = findGitIndexLockPath('/repo');
92
+ expect(result).toContain('.git');
93
+ expect(result).toContain('index.lock');
94
+ });
95
+
96
+ it('不传 cwd 时使用 process.cwd()', () => {
97
+ mockedExecSync.mockReturnValue('.git\n');
98
+ const result = findGitIndexLockPath();
99
+ expect(result).toContain('index.lock');
100
+ });
101
+ });
102
+
103
+ describe('throwIfGitIndexLockError', () => {
104
+ it('从英文错误消息中解析 index.lock 路径(无需调用 git rev-parse)', () => {
105
+ const error = new Error(
106
+ "Command failed: git add .\n" +
107
+ "fatal: Unable to create '/repo/.git/index.lock': File exists.\n"
108
+ );
109
+ // 不设置 mockedExecSync 的返回值,确认不会调用 git rev-parse
110
+ mockedExecSync.mockImplementation(() => { throw new Error('should not be called'); });
111
+ expect(() => throwIfGitIndexLockError(error)).toThrow(/\/repo\/\.git\/index\.lock/);
112
+ });
113
+
114
+ it('从中文错误消息中解析 index.lock 路径', () => {
115
+ const error = new Error(
116
+ "Command failed: git add .\n" +
117
+ "致命错误:无法创建 '/repo/.git/index.lock':File exists。\n"
118
+ );
119
+ mockedExecSync.mockImplementation(() => { throw new Error('should not be called'); });
120
+ expect(() => throwIfGitIndexLockError(error)).toThrow(/\/repo\/\.git\/index\.lock/);
121
+ });
122
+
123
+ it('从子 worktree 错误消息中解析 index.lock 路径', () => {
124
+ const error = new Error(
125
+ "Command failed: git add .\n" +
126
+ "fatal: Unable to create '/repo/.git/worktrees/my-wt/index.lock': File exists.\n"
127
+ );
128
+ mockedExecSync.mockImplementation(() => { throw new Error('should not be called'); });
129
+ expect(() => throwIfGitIndexLockError(error)).toThrow(/\/repo\/\.git\/worktrees\/my-wt\/index\.lock/);
130
+ });
131
+
132
+ it('从 error.stderr 中解析路径', () => {
133
+ const error = new Error("Command failed: git merge feature");
134
+ (error as any).stderr = "error: Unable to create '/repo/.git/index.lock': File exists.\n";
135
+ mockedExecSync.mockImplementation(() => { throw new Error('should not be called'); });
136
+ expect(() => throwIfGitIndexLockError(error)).toThrow(/\/repo\/\.git\/index\.lock/);
137
+ });
138
+
139
+ it('错误消息不含路径时降级到 findGitIndexLockPath', () => {
140
+ const error = new Error("fatal: Unable to write index.");
141
+ mockedExecSync.mockReturnValue('.git\n' as any);
142
+ expect(() => throwIfGitIndexLockError(error, '/fallback')).toThrow(/index\.lock/);
143
+ });
144
+
145
+ it('非 index.lock 错误不抛出', () => {
146
+ const error = new Error("Command failed: git merge feature");
147
+ expect(() => throwIfGitIndexLockError(error)).not.toThrow();
148
+ });
149
+ });