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 +135 -69
- package/dist/postinstall.js +7 -1
- package/package.json +1 -1
- package/src/constants/messages/common.ts +7 -0
- package/src/utils/git-lock.ts +90 -0
- package/src/utils/git.ts +1 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/interactive-panel.ts +21 -16
- package/src/utils/shell.ts +30 -17
- package/tests/unit/utils/git-lock.test.ts +149 -0
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
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
2149
|
+
return join7(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.tree`);
|
|
2088
2150
|
}
|
|
2089
2151
|
function getSnapshotHeadPath(projectName, branchName) {
|
|
2090
|
-
return
|
|
2152
|
+
return join7(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.head`);
|
|
2091
2153
|
}
|
|
2092
2154
|
function getSnapshotStagedPath(projectName, branchName) {
|
|
2093
|
-
return
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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) =>
|
|
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 =
|
|
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
|
|
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 ?
|
|
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 =
|
|
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
|
|
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 ??
|
|
6139
|
+
const filePath = path2 ?? join12(TASK_TEMPLATE_OUTPUT_DIR, generateTaskFilename(TASK_TEMPLATE_FILENAME_PREFIX));
|
|
6074
6140
|
await handleTasksInit(filePath);
|
|
6075
6141
|
});
|
|
6076
6142
|
}
|
package/dist/postinstall.js
CHANGED
|
@@ -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
|
@@ -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
package/src/utils/index.ts
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/src/utils/shell.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 {
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
});
|