clawt 3.9.3 → 3.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +66 -26
- package/dist/postinstall.js +3 -1
- package/docs/spec.md +1 -0
- package/docs/status.md +4 -4
- package/package.json +1 -1
- package/src/commands/status.ts +1 -1
- package/src/constants/git.ts +11 -0
- package/src/constants/messages/common.ts +2 -0
- package/src/constants/messages/interactive-panel.ts +1 -1
- package/src/utils/git-lock.ts +33 -1
- package/src/utils/shell.ts +52 -26
- package/tests/unit/utils/git-lock.test.ts +85 -1
package/dist/index.js
CHANGED
|
@@ -73,7 +73,9 @@ var COMMON_MESSAGES = {
|
|
|
73
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
74
|
\u9501\u6587\u4EF6\u8DEF\u5F84\uFF1A${lockFilePath}
|
|
75
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}
|
|
76
|
+
rm ${lockFilePath}`,
|
|
77
|
+
/** Git index.lock 重试中(简短提示) */
|
|
78
|
+
GIT_INDEX_LOCK_RETRYING: "Git index \u88AB\u9501\u5B9A\uFF0C\u6B63\u5728\u91CD\u8BD5..."
|
|
77
79
|
};
|
|
78
80
|
|
|
79
81
|
// src/constants/messages/run.ts
|
|
@@ -655,7 +657,7 @@ var PANEL_NOT_TTY = "\u4EA4\u4E92\u5F0F\u9762\u677F\u9700\u8981 TTY \u7EC8\u7AEF
|
|
|
655
657
|
var PANEL_TITLE = (projectName) => chalk.bold.cyan(`\u9879\u76EE\u72B6\u6001\u603B\u89C8: ${projectName}`);
|
|
656
658
|
var PANEL_CONFIGURED_BRANCH = (branchName) => chalk.gray(`\u4E3B\u5DE5\u4F5C\u5206\u652F: ${branchName}`);
|
|
657
659
|
var PANEL_CONFIGURED_BRANCH_DELETED = (branchName) => chalk.red(`\u2717 \u4E3B\u5DE5\u4F5C\u5206\u652F: ${branchName}\uFF08\u5DF2\u4E0D\u5B58\u5728\uFF09`);
|
|
658
|
-
var PANEL_CONFIGURED_BRANCH_MISMATCH = (branchName) => chalk.
|
|
660
|
+
var PANEL_CONFIGURED_BRANCH_MISMATCH = (branchName) => chalk.red(`\u26A0 \u4E3B\u5DE5\u4F5C\u5206\u652F: ${branchName}\uFF08\u4E0D\u4E00\u81F4\uFF09`);
|
|
659
661
|
var PANEL_NOT_INITIALIZED = chalk.gray("\u672A\u521D\u59CB\u5316\uFF08\u6267\u884C clawt init \u8BBE\u7F6E\u4E3B\u5DE5\u4F5C\u5206\u652F\uFF09");
|
|
660
662
|
var PANEL_UNKNOWN_DATE = "\u672A\u77E5\u65E5\u671F";
|
|
661
663
|
var PANEL_SYNCED_WITH_MAIN = "\u4E0E\u4E3B\u5206\u652F\u540C\u6B65";
|
|
@@ -798,6 +800,12 @@ var PROJECT_CONFIG_DESCRIPTIONS = deriveConfigDescriptions2(PROJECT_CONFIG_DEFIN
|
|
|
798
800
|
// src/constants/git.ts
|
|
799
801
|
var AUTO_SAVE_COMMIT_MESSAGE_PREFIX = "clawt: auto-save before merging";
|
|
800
802
|
var EXEC_MAX_BUFFER = 200 * 1024 * 1024;
|
|
803
|
+
var GIT_INDEX_LOCK_RETRY = {
|
|
804
|
+
/** 重试次数(用户反馈"重试一下就可以了",单次重试足够) */
|
|
805
|
+
MAX_RETRIES: 1,
|
|
806
|
+
/** 重试延迟毫秒数(让锁文件有时间被释放) */
|
|
807
|
+
DELAY_MS: 150
|
|
808
|
+
};
|
|
801
809
|
|
|
802
810
|
// src/constants/logger.ts
|
|
803
811
|
var DEBUG_TIMESTAMP_FORMAT = "HH:mm:ss.SSS";
|
|
@@ -1043,6 +1051,20 @@ function throwIfGitIndexLockError(error, cwd) {
|
|
|
1043
1051
|
throw new ClawtError(MESSAGES.GIT_INDEX_LOCKED(lockFilePath));
|
|
1044
1052
|
}
|
|
1045
1053
|
}
|
|
1054
|
+
function sleepSync(ms) {
|
|
1055
|
+
const sharedBuffer = new SharedArrayBuffer(4);
|
|
1056
|
+
const int32 = new Int32Array(sharedBuffer);
|
|
1057
|
+
Atomics.wait(int32, 0, 0, ms);
|
|
1058
|
+
}
|
|
1059
|
+
function shouldRetryGitIndexLockError(error, retryCount) {
|
|
1060
|
+
const errorMessage = extractFullErrorMessage(error);
|
|
1061
|
+
return isGitIndexLockError(errorMessage) && retryCount < GIT_INDEX_LOCK_RETRY.MAX_RETRIES;
|
|
1062
|
+
}
|
|
1063
|
+
function waitForGitIndexLockRetrySync() {
|
|
1064
|
+
process.stderr.write(`${MESSAGES.GIT_INDEX_LOCK_RETRYING}
|
|
1065
|
+
`);
|
|
1066
|
+
sleepSync(GIT_INDEX_LOCK_RETRY.DELAY_MS);
|
|
1067
|
+
}
|
|
1046
1068
|
|
|
1047
1069
|
// src/utils/shell.ts
|
|
1048
1070
|
function getEnvWithoutNestedSessionFlag() {
|
|
@@ -1051,17 +1073,26 @@ function getEnvWithoutNestedSessionFlag() {
|
|
|
1051
1073
|
}
|
|
1052
1074
|
function execCommand(command, options) {
|
|
1053
1075
|
logger.debug(`\u6267\u884C\u547D\u4EE4: ${command}${options?.cwd ? ` (cwd: ${options.cwd})` : ""}`);
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1076
|
+
let retryCount = 0;
|
|
1077
|
+
while (true) {
|
|
1078
|
+
try {
|
|
1079
|
+
const result = execSync2(command, {
|
|
1080
|
+
cwd: options?.cwd,
|
|
1081
|
+
encoding: "utf-8",
|
|
1082
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1083
|
+
maxBuffer: EXEC_MAX_BUFFER
|
|
1084
|
+
});
|
|
1085
|
+
return result.trim();
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
if (shouldRetryGitIndexLockError(error, retryCount)) {
|
|
1088
|
+
retryCount++;
|
|
1089
|
+
logger.debug(`\u68C0\u6D4B\u5230 index.lock \u9519\u8BEF\uFF0C\u7B2C ${retryCount} \u6B21\u91CD\u8BD5`);
|
|
1090
|
+
waitForGitIndexLockRetrySync();
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
throwIfGitIndexLockError(error, options?.cwd);
|
|
1094
|
+
throw error;
|
|
1095
|
+
}
|
|
1065
1096
|
}
|
|
1066
1097
|
}
|
|
1067
1098
|
function spawnProcess(command, args, options) {
|
|
@@ -1081,18 +1112,27 @@ function killAllChildProcesses(children) {
|
|
|
1081
1112
|
}
|
|
1082
1113
|
function execCommandWithInput(command, args, options) {
|
|
1083
1114
|
logger.debug(`\u6267\u884C\u547D\u4EE4(stdin): ${command} ${args.join(" ")}${options.cwd ? ` (cwd: ${options.cwd})` : ""}`);
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1115
|
+
let retryCount = 0;
|
|
1116
|
+
while (true) {
|
|
1117
|
+
try {
|
|
1118
|
+
const result = execFileSync(command, args, {
|
|
1119
|
+
cwd: options.cwd,
|
|
1120
|
+
input: options.input,
|
|
1121
|
+
encoding: "utf-8",
|
|
1122
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1123
|
+
maxBuffer: EXEC_MAX_BUFFER
|
|
1124
|
+
});
|
|
1125
|
+
return result.trim();
|
|
1126
|
+
} catch (error) {
|
|
1127
|
+
if (shouldRetryGitIndexLockError(error, retryCount)) {
|
|
1128
|
+
retryCount++;
|
|
1129
|
+
logger.debug(`\u68C0\u6D4B\u5230 index.lock \u9519\u8BEF\uFF0C\u7B2C ${retryCount} \u6B21\u91CD\u8BD5`);
|
|
1130
|
+
waitForGitIndexLockRetrySync();
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
throwIfGitIndexLockError(error, options.cwd);
|
|
1134
|
+
throw error;
|
|
1135
|
+
}
|
|
1096
1136
|
}
|
|
1097
1137
|
}
|
|
1098
1138
|
function runCommandInherited(command, options) {
|
|
@@ -5597,7 +5637,7 @@ function printMainSection(main2) {
|
|
|
5597
5637
|
if (main2.configuredBranchExists === false) {
|
|
5598
5638
|
printInfo(` ${chalk11.red(MESSAGES.STATUS_CONFIGURED_BRANCH_DELETED(main2.configuredMainBranch))}`);
|
|
5599
5639
|
} else if (main2.branch !== main2.configuredMainBranch && !main2.branch.startsWith(VALIDATE_BRANCH_PREFIX)) {
|
|
5600
|
-
printInfo(` ${chalk11.
|
|
5640
|
+
printInfo(` ${chalk11.red(MESSAGES.STATUS_CONFIGURED_BRANCH_MISMATCH(main2.configuredMainBranch))}`);
|
|
5601
5641
|
} else {
|
|
5602
5642
|
printInfo(` ${chalk11.gray(MESSAGES.STATUS_CONFIGURED_BRANCH(main2.configuredMainBranch))}`);
|
|
5603
5643
|
}
|
package/dist/postinstall.js
CHANGED
|
@@ -64,7 +64,9 @@ var COMMON_MESSAGES = {
|
|
|
64
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
65
|
\u9501\u6587\u4EF6\u8DEF\u5F84\uFF1A${lockFilePath}
|
|
66
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}
|
|
67
|
+
rm ${lockFilePath}`,
|
|
68
|
+
/** Git index.lock 重试中(简短提示) */
|
|
69
|
+
GIT_INDEX_LOCK_RETRYING: "Git index \u88AB\u9501\u5B9A\uFF0C\u6B63\u5728\u91CD\u8BD5..."
|
|
68
70
|
};
|
|
69
71
|
|
|
70
72
|
// src/constants/messages/run.ts
|
package/docs/spec.md
CHANGED
|
@@ -384,6 +384,7 @@ async function interactiveConfigEditor<T extends object>(
|
|
|
384
384
|
| Worktree 路径已存在 | 输出错误提示,退出 (exit code 1) |
|
|
385
385
|
| Git 命令执行失败 | 捕获 stderr,记录日志,输出错误提示,退出 (exit code 1) |
|
|
386
386
|
| 目标 worktree 不存在 | 输出错误提示(列出可用 worktree),退出 (exit code 1) |
|
|
387
|
+
| Git index.lock 被锁定 | 自动重试 1 次(延迟 150ms),重试失败则输出错误提示和修复方法 |
|
|
387
388
|
|
|
388
389
|
### 7.2 退出码
|
|
389
390
|
|
package/docs/status.md
CHANGED
|
@@ -50,7 +50,7 @@ clawt status [--json] [-i | --interactive]
|
|
|
50
50
|
|
|
51
51
|
主 Worktree 区块会显示配置的主工作分支信息,根据状态有以下三种展示:
|
|
52
52
|
- **正常**(灰色):`主工作分支: <branchName>`
|
|
53
|
-
-
|
|
53
|
+
- **当前分支不一致**(红色):`⚠ 主工作分支: <branchName>(当前分支不一致,如需更新请执行 clawt init)`
|
|
54
54
|
- **分支已不存在**(红色):`✗ 主工作分支: <branchName>(已不存在,请执行 clawt init 重新设置)`
|
|
55
55
|
|
|
56
56
|
注意:当项目未初始化(`configuredMainBranch` 为 null)时不展示配置分支信息;当主 worktree 当前处于验证分支(`VALIDATE_BRANCH_PREFIX` 前缀)时不显示不一致警告。
|
|
@@ -184,7 +184,7 @@ clawt status [--json] [-i | --interactive]
|
|
|
184
184
|
- `STATUS_SNAPSHOT_ORPHANED(count)`:孤立快照警告(接受数量参数)
|
|
185
185
|
- `STATUS_CONFIGURED_BRANCH(branchName)`:配置的主工作分支(正常状态,灰色)
|
|
186
186
|
- `STATUS_CONFIGURED_BRANCH_DELETED(branchName)`:配置的主工作分支已不存在(红色)
|
|
187
|
-
- `STATUS_CONFIGURED_BRANCH_MISMATCH(branchName)
|
|
187
|
+
- `STATUS_CONFIGURED_BRANCH_MISMATCH(branchName)`:当前分支与配置不一致(红色)
|
|
188
188
|
- `getWorktreeCreatedTime()` 工具函数(在 `src/utils/worktree-matcher.ts`),通过 `fs.statSync().birthtime` 获取 worktree 目录的创建时间,返回 ISO 8601 格式字符串或 null
|
|
189
189
|
- `getSnapshotModifiedTime()` 工具函数(在 `src/utils/validate-snapshot.ts`),通过 `fs.statSync` 获取快照文件的修改时间(mtime),返回 UTC 时区的 ISO 8601 格式字符串(`toISOString()` 格式)或 null
|
|
190
190
|
- `formatRelativeTime()` 格式化函数(在 `src/utils/formatter.ts`),将 ISO 8601 日期字符串转换为中文相对时间描述(如"3 天前"、"2 小时前"、"刚刚"),无效日期时返回 null
|
|
@@ -241,7 +241,7 @@ clawt status [--json] [-i | --interactive]
|
|
|
241
241
|
2. **配置分支信息行**:显示配置的主工作分支状态,有以下四种情况:
|
|
242
242
|
- 正常(灰色):`主工作分支: <branchName>`
|
|
243
243
|
- 分支已删除(红色):`✗ 主工作分支: <branchName>(已不存在)`
|
|
244
|
-
-
|
|
244
|
+
- 分支不一致(红色):`⚠ 主工作分支: <branchName>(不一致)`
|
|
245
245
|
- 未初始化(灰色):`未初始化(执行 clawt init 设置主工作分支)`
|
|
246
246
|
3. **工作区 diff 信息行**:显示主工作分支的工作区 diff 统计,有变更时格式为 `工作区: +N -M`(新增行数绿色,删除行数红色),无变更时显示 `工作区: 无变更`(绿色)
|
|
247
247
|
4. **顶部分隔线**:当存在向上溢出时,分隔线中间嵌入 `↑ 更多 worktree...` 提示
|
|
@@ -338,7 +338,7 @@ Worktree 按创建日期分组(复用 `groupWorktreesByDate()`),每组前
|
|
|
338
338
|
- `PANEL_TITLE(projectName)`:面板标题
|
|
339
339
|
- `PANEL_CONFIGURED_BRANCH(branchName)`:配置分支信息(正常状态,灰色)
|
|
340
340
|
- `PANEL_CONFIGURED_BRANCH_DELETED(branchName)`:配置分支信息(分支已删除,红色)
|
|
341
|
-
- `PANEL_CONFIGURED_BRANCH_MISMATCH(branchName)
|
|
341
|
+
- `PANEL_CONFIGURED_BRANCH_MISMATCH(branchName)`:配置分支信息(分支不一致,红色)
|
|
342
342
|
- `PANEL_NOT_INITIALIZED`:未初始化提示(灰色)
|
|
343
343
|
- `PanelLine` 接口(`src/utils/interactive-panel-render.ts`):面板行类型定义,包含 `type`(`'separator'` | `'worktree-content'`)、`text`、可选 `worktreeIndex`
|
|
344
344
|
- `collectStatus()` 函数已改为导出(`export`),以便 `InteractivePanel` 作为数据收集函数引用
|
package/package.json
CHANGED
package/src/commands/status.ts
CHANGED
|
@@ -272,7 +272,7 @@ function printMainSection(main: MainWorktreeStatus): void {
|
|
|
272
272
|
if (main.configuredBranchExists === false) {
|
|
273
273
|
printInfo(` ${chalk.red(MESSAGES.STATUS_CONFIGURED_BRANCH_DELETED(main.configuredMainBranch))}`);
|
|
274
274
|
} else if (main.branch !== main.configuredMainBranch && !main.branch.startsWith(VALIDATE_BRANCH_PREFIX)) {
|
|
275
|
-
printInfo(` ${chalk.
|
|
275
|
+
printInfo(` ${chalk.red(MESSAGES.STATUS_CONFIGURED_BRANCH_MISMATCH(main.configuredMainBranch))}`);
|
|
276
276
|
} else {
|
|
277
277
|
printInfo(` ${chalk.gray(MESSAGES.STATUS_CONFIGURED_BRANCH(main.configuredMainBranch))}`);
|
|
278
278
|
}
|
package/src/constants/git.ts
CHANGED
|
@@ -3,3 +3,14 @@ export const AUTO_SAVE_COMMIT_MESSAGE_PREFIX = 'clawt: auto-save before merging'
|
|
|
3
3
|
|
|
4
4
|
/** execSync 最大缓冲区大小(200MB),防止大分支 diff 时触发 ENOBUFS 错误 */
|
|
5
5
|
export const EXEC_MAX_BUFFER = 200 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Git index.lock 错误重试配置
|
|
9
|
+
* 当检测到 index.lock 错误时自动重试,避免因短暂竞争导致用户操作失败
|
|
10
|
+
*/
|
|
11
|
+
export const GIT_INDEX_LOCK_RETRY = {
|
|
12
|
+
/** 重试次数(用户反馈"重试一下就可以了",单次重试足够) */
|
|
13
|
+
MAX_RETRIES: 1,
|
|
14
|
+
/** 重试延迟毫秒数(让锁文件有时间被释放) */
|
|
15
|
+
DELAY_MS: 150,
|
|
16
|
+
} as const;
|
|
@@ -83,7 +83,7 @@ export const PANEL_CONFIGURED_BRANCH_DELETED = (branchName: string): string =>
|
|
|
83
83
|
* @returns {string} 格式化的分支信息
|
|
84
84
|
*/
|
|
85
85
|
export const PANEL_CONFIGURED_BRANCH_MISMATCH = (branchName: string): string =>
|
|
86
|
-
chalk.
|
|
86
|
+
chalk.red(`⚠ 主工作分支: ${branchName}(不一致)`);
|
|
87
87
|
|
|
88
88
|
/** 面板配置分支信息(未初始化) */
|
|
89
89
|
export const PANEL_NOT_INITIALIZED = chalk.gray('未初始化(执行 clawt init 设置主工作分支)');
|
package/src/utils/git-lock.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { execSync } from 'node:child_process';
|
|
|
3
3
|
import { logger } from '../logger/index.js';
|
|
4
4
|
import { ClawtError } from '../errors/index.js';
|
|
5
5
|
import { MESSAGES } from '../constants/index.js';
|
|
6
|
+
import { GIT_INDEX_LOCK_RETRY } from '../constants/git.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* index.lock 错误的关键词匹配模式
|
|
@@ -38,7 +39,7 @@ export function isGitIndexLockError(errorMessage: string): boolean {
|
|
|
38
39
|
* @param {unknown} error - 捕获的错误对象
|
|
39
40
|
* @returns {string} 合并后的错误消息
|
|
40
41
|
*/
|
|
41
|
-
function extractFullErrorMessage(error: unknown): string {
|
|
42
|
+
export function extractFullErrorMessage(error: unknown): string {
|
|
42
43
|
if (!(error instanceof Error)) return String(error);
|
|
43
44
|
const stderr = (error as { stderr?: string | Buffer }).stderr;
|
|
44
45
|
const stderrStr = stderr ? String(stderr) : '';
|
|
@@ -94,3 +95,34 @@ export function throwIfGitIndexLockError(error: unknown, cwd?: string): void {
|
|
|
94
95
|
throw new ClawtError(MESSAGES.GIT_INDEX_LOCKED(lockFilePath));
|
|
95
96
|
}
|
|
96
97
|
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 同步延迟函数(使用 Atomics.wait 实现真正的阻塞等待)
|
|
101
|
+
* 相比 busy-wait,不消耗 CPU 资源
|
|
102
|
+
* @param {number} ms - 延迟毫秒数
|
|
103
|
+
*/
|
|
104
|
+
function sleepSync(ms: number): void {
|
|
105
|
+
const sharedBuffer = new SharedArrayBuffer(4);
|
|
106
|
+
const int32 = new Int32Array(sharedBuffer);
|
|
107
|
+
Atomics.wait(int32, 0, 0, ms);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 检测是否应该重试 Git index.lock 错误
|
|
112
|
+
* @param {unknown} error - 捕获的错误对象
|
|
113
|
+
* @param {number} retryCount - 当前已重试次数
|
|
114
|
+
* @returns {boolean} 是否应重试(是 lock 错误且未超过重试上限)
|
|
115
|
+
*/
|
|
116
|
+
export function shouldRetryGitIndexLockError(error: unknown, retryCount: number): boolean {
|
|
117
|
+
const errorMessage = extractFullErrorMessage(error);
|
|
118
|
+
return isGitIndexLockError(errorMessage) && retryCount < GIT_INDEX_LOCK_RETRY.MAX_RETRIES;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 等待 index.lock 重试延迟(同步版本)
|
|
123
|
+
* 打印提示信息并等待指定时间
|
|
124
|
+
*/
|
|
125
|
+
export function waitForGitIndexLockRetrySync(): void {
|
|
126
|
+
process.stderr.write(`${MESSAGES.GIT_INDEX_LOCK_RETRYING}\n`);
|
|
127
|
+
sleepSync(GIT_INDEX_LOCK_RETRY.DELAY_MS);
|
|
128
|
+
}
|
package/src/utils/shell.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { execSync, execFileSync, spawn, spawnSync, type ChildProcess, type Spawn
|
|
|
2
2
|
import { logger } from '../logger/index.js';
|
|
3
3
|
import { EXEC_MAX_BUFFER } from '../constants/git.js';
|
|
4
4
|
import { CLAUDE_CODE_ENTRYPOINT_VALUE } from '../constants/index.js';
|
|
5
|
-
import { throwIfGitIndexLockError } from './git-lock.js';
|
|
5
|
+
import { throwIfGitIndexLockError, shouldRetryGitIndexLockError, waitForGitIndexLockRetrySync } from './git-lock.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* 获取移除了 CLAUDECODE 嵌套会话标记的环境变量副本,并注入 CLAUDE_CODE_ENTRYPOINT 标识
|
|
@@ -45,26 +45,39 @@ export interface ParallelCommandResultWithStderr extends ParallelCommandResult {
|
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
* 同步执行 shell 命令并返回 stdout
|
|
48
|
+
* 当检测到 Git index.lock 错误时,会自动重试一次
|
|
48
49
|
* @param {string} command - 要执行的命令
|
|
49
50
|
* @param {object} options - 可选配置
|
|
50
51
|
* @param {string} options.cwd - 工作目录
|
|
51
52
|
* @returns {string} 命令的标准输出(已 trim)
|
|
52
|
-
* @throws {ClawtError} 检测到 index.lock
|
|
53
|
+
* @throws {ClawtError} 检测到 index.lock 错误且重试失败时抛出中文友好提示
|
|
53
54
|
* @throws {Error} 其他命令执行失败时抛出
|
|
54
55
|
*/
|
|
55
56
|
export function execCommand(command: string, options?: { cwd?: string }): string {
|
|
56
57
|
logger.debug(`执行命令: ${command}${options?.cwd ? ` (cwd: ${options.cwd})` : ''}`);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
58
|
+
|
|
59
|
+
let retryCount = 0;
|
|
60
|
+
|
|
61
|
+
while (true) {
|
|
62
|
+
try {
|
|
63
|
+
const result = execSync(command, {
|
|
64
|
+
cwd: options?.cwd,
|
|
65
|
+
encoding: 'utf-8',
|
|
66
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
67
|
+
maxBuffer: EXEC_MAX_BUFFER,
|
|
68
|
+
});
|
|
69
|
+
return result.trim();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (shouldRetryGitIndexLockError(error, retryCount)) {
|
|
72
|
+
retryCount++;
|
|
73
|
+
logger.debug(`检测到 index.lock 错误,第 ${retryCount} 次重试`);
|
|
74
|
+
waitForGitIndexLockRetrySync();
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
throwIfGitIndexLockError(error, options?.cwd);
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
68
81
|
}
|
|
69
82
|
}
|
|
70
83
|
|
|
@@ -104,29 +117,42 @@ export function killAllChildProcesses(children: ChildProcess[]): void {
|
|
|
104
117
|
|
|
105
118
|
/**
|
|
106
119
|
* 同步执行命令,通过 stdin 传入数据
|
|
120
|
+
* 当检测到 Git index.lock 错误时,会自动重试一次
|
|
107
121
|
* @param {string} command - 要执行的命令
|
|
108
122
|
* @param {string[]} args - 命令参数
|
|
109
123
|
* @param {object} options - 配置
|
|
110
124
|
* @param {Buffer} options.input - 通过 stdin 传入的数据(Buffer 格式,保留二进制完整性)
|
|
111
125
|
* @param {string} [options.cwd] - 工作目录
|
|
112
126
|
* @returns {string} 命令的标准输出(已 trim)
|
|
113
|
-
* @throws {ClawtError} 检测到 index.lock
|
|
127
|
+
* @throws {ClawtError} 检测到 index.lock 错误且重试失败时抛出中文友好提示
|
|
114
128
|
* @throws {Error} 其他命令执行失败时抛出
|
|
115
129
|
*/
|
|
116
130
|
export function execCommandWithInput(command: string, args: string[], options: { input: Buffer; cwd?: string }): string {
|
|
117
131
|
logger.debug(`执行命令(stdin): ${command} ${args.join(' ')}${options.cwd ? ` (cwd: ${options.cwd})` : ''}`);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
132
|
+
|
|
133
|
+
let retryCount = 0;
|
|
134
|
+
|
|
135
|
+
while (true) {
|
|
136
|
+
try {
|
|
137
|
+
const result = execFileSync(command, args, {
|
|
138
|
+
cwd: options.cwd,
|
|
139
|
+
input: options.input,
|
|
140
|
+
encoding: 'utf-8',
|
|
141
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
142
|
+
maxBuffer: EXEC_MAX_BUFFER,
|
|
143
|
+
});
|
|
144
|
+
return result.trim();
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (shouldRetryGitIndexLockError(error, retryCount)) {
|
|
147
|
+
retryCount++;
|
|
148
|
+
logger.debug(`检测到 index.lock 错误,第 ${retryCount} 次重试`);
|
|
149
|
+
waitForGitIndexLockRetrySync();
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
throwIfGitIndexLockError(error, options.cwd);
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
130
156
|
}
|
|
131
157
|
}
|
|
132
158
|
|
|
@@ -24,11 +24,19 @@ vi.mock('../../../src/errors/index.js', () => ({
|
|
|
24
24
|
vi.mock('../../../src/constants/index.js', () => ({
|
|
25
25
|
MESSAGES: {
|
|
26
26
|
GIT_INDEX_LOCKED: (lockFilePath: string) => `Git index 被锁定,锁文件路径:${lockFilePath}`,
|
|
27
|
+
GIT_INDEX_LOCK_RETRYING: 'Git index 被锁定,正在重试...',
|
|
27
28
|
},
|
|
28
29
|
}));
|
|
29
30
|
|
|
30
31
|
import { execSync } from 'node:child_process';
|
|
31
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
isGitIndexLockError,
|
|
34
|
+
findGitIndexLockPath,
|
|
35
|
+
throwIfGitIndexLockError,
|
|
36
|
+
shouldRetryGitIndexLockError,
|
|
37
|
+
waitForGitIndexLockRetrySync,
|
|
38
|
+
extractFullErrorMessage,
|
|
39
|
+
} from '../../../src/utils/git-lock.js';
|
|
32
40
|
|
|
33
41
|
const mockedExecSync = vi.mocked(execSync);
|
|
34
42
|
|
|
@@ -159,3 +167,79 @@ describe('throwIfGitIndexLockError', () => {
|
|
|
159
167
|
expect(() => throwIfGitIndexLockError(error)).not.toThrow();
|
|
160
168
|
});
|
|
161
169
|
});
|
|
170
|
+
|
|
171
|
+
describe('extractFullErrorMessage', () => {
|
|
172
|
+
it('从 Error 对象提取 message', () => {
|
|
173
|
+
const error = new Error('test error');
|
|
174
|
+
expect(extractFullErrorMessage(error)).toBe('test error');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('合并 message 和 stderr', () => {
|
|
178
|
+
const error = new Error('Command failed');
|
|
179
|
+
(error as any).stderr = 'fatal: Unable to write index.';
|
|
180
|
+
expect(extractFullErrorMessage(error)).toBe('Command failed\nfatal: Unable to write index.');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('处理 Buffer 类型的 stderr', () => {
|
|
184
|
+
const error = new Error('Command failed');
|
|
185
|
+
(error as any).stderr = Buffer.from('fatal: Unable to write index.');
|
|
186
|
+
expect(extractFullErrorMessage(error)).toBe('Command failed\nfatal: Unable to write index.');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('非 Error 对象返回 String 转换结果', () => {
|
|
190
|
+
expect(extractFullErrorMessage('string error')).toBe('string error');
|
|
191
|
+
expect(extractFullErrorMessage(123)).toBe('123');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('shouldRetryGitIndexLockError', () => {
|
|
196
|
+
it('index.lock 错误且未达到重试上限时返回 true', () => {
|
|
197
|
+
const error = new Error("fatal: Unable to create '/repo/.git/index.lock': File exists.");
|
|
198
|
+
expect(shouldRetryGitIndexLockError(error, 0)).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('index.lock 错误但已达到重试上限时返回 false', () => {
|
|
202
|
+
const error = new Error("fatal: Unable to create '/repo/.git/index.lock': File exists.");
|
|
203
|
+
// MAX_RETRIES 默认为 1,所以 retryCount = 1 时不应再重试
|
|
204
|
+
expect(shouldRetryGitIndexLockError(error, 1)).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('非 index.lock 错误返回 false', () => {
|
|
208
|
+
const error = new Error('Command failed: git merge feature');
|
|
209
|
+
expect(shouldRetryGitIndexLockError(error, 0)).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('从 stderr 检测 index.lock 错误', () => {
|
|
213
|
+
const error = new Error('Command failed');
|
|
214
|
+
(error as any).stderr = "fatal: Unable to create '/repo/.git/index.lock': File exists.";
|
|
215
|
+
expect(shouldRetryGitIndexLockError(error, 0)).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('waitForGitIndexLockRetrySync', () => {
|
|
220
|
+
it('向 stderr 输出重试提示', () => {
|
|
221
|
+
const stderrWriteSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
222
|
+
|
|
223
|
+
// 执行函数(会有短暂延迟)
|
|
224
|
+
waitForGitIndexLockRetrySync();
|
|
225
|
+
|
|
226
|
+
// 验证输出了重试提示
|
|
227
|
+
expect(stderrWriteSpy).toHaveBeenCalledWith('Git index 被锁定,正在重试...\n');
|
|
228
|
+
|
|
229
|
+
stderrWriteSpy.mockRestore();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('执行时间接近配置的延迟时间', () => {
|
|
233
|
+
const stderrWriteSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
234
|
+
|
|
235
|
+
const startTime = Date.now();
|
|
236
|
+
waitForGitIndexLockRetrySync();
|
|
237
|
+
const elapsed = Date.now() - startTime;
|
|
238
|
+
|
|
239
|
+
// 延迟时间应该接近 150ms(允许 50ms 误差)
|
|
240
|
+
expect(elapsed).toBeGreaterThanOrEqual(100);
|
|
241
|
+
expect(elapsed).toBeLessThanOrEqual(300);
|
|
242
|
+
|
|
243
|
+
stderrWriteSpy.mockRestore();
|
|
244
|
+
});
|
|
245
|
+
});
|