clawt 3.9.6 → 3.9.7
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 +148 -56
- package/dist/postinstall.js +4 -2
- package/docs/tasks.md +1 -1
- package/package.json +1 -1
- package/src/commands/status.ts +73 -41
- package/src/constants/messages/tasks.ts +3 -2
- package/src/constants/tasks-template.ts +1 -1
- package/src/utils/git-branch.ts +46 -1
- package/src/utils/git-core.ts +22 -1
- package/src/utils/index.ts +5 -1
- package/src/utils/interactive-panel-render.ts +4 -2
- package/src/utils/interactive-panel-state.ts +36 -2
- package/src/utils/interactive-panel.ts +63 -27
- package/src/utils/shell.ts +25 -1
- package/tests/unit/commands/status.test.ts +18 -25
package/dist/index.js
CHANGED
|
@@ -574,8 +574,10 @@ var TASKS_CMD_MESSAGES = {
|
|
|
574
574
|
TASK_INIT_FILE_EXISTS: (path2) => `\u6587\u4EF6\u5DF2\u5B58\u5728: ${path2}\uFF0C\u5982\u9700\u8986\u76D6\u8BF7\u5148\u5220\u9664`,
|
|
575
575
|
/** 任务模板生成成功 */
|
|
576
576
|
TASK_INIT_SUCCESS: (path2) => `\u2713 \u4EFB\u52A1\u6A21\u677F\u5DF2\u751F\u6210: ${path2}`,
|
|
577
|
-
/**
|
|
578
|
-
TASK_INIT_HINT: (path2) => `\
|
|
577
|
+
/** 任务模板使用提示(分行列出 run 和 resume 两种用法) */
|
|
578
|
+
TASK_INIT_HINT: (path2) => `\u6267\u884C\u4EFB\u52A1:
|
|
579
|
+
clawt run -f ${path2} # \u521B\u5EFA worktree \u5E76\u6267\u884C\uFF08\u5206\u652F\u540D\u9700\u4E0D\u5B58\u5728\uFF09
|
|
580
|
+
clawt resume -f ${path2} # \u5728\u5DF2\u6709 worktree \u4E2D\u8FFD\u95EE\uFF08\u5206\u652F\u540D\u9700\u5DF2\u5B58\u5728\uFF09`
|
|
579
581
|
};
|
|
580
582
|
|
|
581
583
|
// src/constants/messages/post-create.ts
|
|
@@ -912,7 +914,7 @@ var TASK_TEMPLATE_CONTENT = `# Clawt \u4EFB\u52A1\u6587\u4EF6
|
|
|
912
914
|
# \u683C\u5F0F\u8BF4\u660E: \u6807\u7B7E\u5916\u7684\u6587\u672C\u4F1A\u88AB\u5FFD\u7565\uFF0C\u6BCF\u4E2A\u4EFB\u52A1\u7528 START/END \u6807\u7B7E\u5305\u88F9
|
|
913
915
|
#
|
|
914
916
|
# \u89C4\u5219:
|
|
915
|
-
# 1. \u6BCF\u4E2A\u4EFB\u52A1\u5757\u7528
|
|
917
|
+
# 1. \u6BCF\u4E2A\u4EFB\u52A1\u5757\u7528 <START> \u548C <END> \u6807\u7B7E\u5305\u88F9\uFF08\u5B9E\u9645\u6807\u7B7E\u89C1\u4E0B\u65B9\u793A\u4F8B\uFF09
|
|
916
918
|
# 2. \u5757\u5185 # branch: <\u5206\u652F\u540D> \u58F0\u660E\u5206\u652F\u540D\uFF08\u4F7F\u7528 -b \u53C2\u6570\u65F6\u53EF\u7701\u7565\uFF09
|
|
917
919
|
# 3. \u5757\u5185\u5176\u4F59\u884C\u4E3A\u4EFB\u52A1\u63CF\u8FF0\uFF08\u652F\u6301\u591A\u884C\uFF09
|
|
918
920
|
|
|
@@ -1001,7 +1003,8 @@ function enableConsoleTransport() {
|
|
|
1001
1003
|
}
|
|
1002
1004
|
|
|
1003
1005
|
// src/utils/shell.ts
|
|
1004
|
-
import { execSync as execSync2, execFileSync, spawn, spawnSync } from "child_process";
|
|
1006
|
+
import { exec, execSync as execSync2, execFileSync, spawn, spawnSync } from "child_process";
|
|
1007
|
+
import { promisify } from "util";
|
|
1005
1008
|
|
|
1006
1009
|
// src/utils/git-lock.ts
|
|
1007
1010
|
import { join as join2, isAbsolute } from "path";
|
|
@@ -1067,6 +1070,7 @@ function waitForGitIndexLockRetrySync() {
|
|
|
1067
1070
|
}
|
|
1068
1071
|
|
|
1069
1072
|
// src/utils/shell.ts
|
|
1073
|
+
var execPromise = promisify(exec);
|
|
1070
1074
|
function getEnvWithoutNestedSessionFlag() {
|
|
1071
1075
|
const { CLAUDECODE: _, ...env } = process.env;
|
|
1072
1076
|
return { ...env, CLAUDE_CODE_ENTRYPOINT: CLAUDE_CODE_ENTRYPOINT_VALUE };
|
|
@@ -1095,6 +1099,15 @@ function execCommand(command, options) {
|
|
|
1095
1099
|
}
|
|
1096
1100
|
}
|
|
1097
1101
|
}
|
|
1102
|
+
async function execCommandAsync(command, options) {
|
|
1103
|
+
logger.debug(`\u6267\u884C\u5F02\u6B65\u547D\u4EE4: ${command}${options?.cwd ? ` (cwd: ${options.cwd})` : ""}`);
|
|
1104
|
+
const { stdout } = await execPromise(command, {
|
|
1105
|
+
cwd: options?.cwd,
|
|
1106
|
+
encoding: "utf-8",
|
|
1107
|
+
maxBuffer: EXEC_MAX_BUFFER
|
|
1108
|
+
});
|
|
1109
|
+
return stdout.trim();
|
|
1110
|
+
}
|
|
1098
1111
|
function spawnProcess(command, args, options) {
|
|
1099
1112
|
logger.debug(`\u542F\u52A8\u5B50\u8FDB\u7A0B: ${command} ${args.join(" ")}${options?.cwd ? ` (cwd: ${options.cwd})` : ""}`);
|
|
1100
1113
|
return spawn(command, args, {
|
|
@@ -1262,6 +1275,9 @@ function getProjectName(cwd) {
|
|
|
1262
1275
|
function getStatusPorcelain(cwd) {
|
|
1263
1276
|
return execCommand("git status --porcelain", { cwd });
|
|
1264
1277
|
}
|
|
1278
|
+
async function getStatusPorcelainAsync(cwd) {
|
|
1279
|
+
return execCommandAsync("git status --porcelain", { cwd });
|
|
1280
|
+
}
|
|
1265
1281
|
function isWorkingDirClean(cwd) {
|
|
1266
1282
|
return getStatusPorcelain(cwd) === "";
|
|
1267
1283
|
}
|
|
@@ -1316,6 +1332,10 @@ function getDiffStat(worktreePath) {
|
|
|
1316
1332
|
const output = execCommand("git diff --shortstat HEAD", { cwd: worktreePath });
|
|
1317
1333
|
return parseShortStat(output);
|
|
1318
1334
|
}
|
|
1335
|
+
async function getDiffStatAsync(worktreePath) {
|
|
1336
|
+
const output = await execCommandAsync("git diff --shortstat HEAD", { cwd: worktreePath });
|
|
1337
|
+
return parseShortStat(output);
|
|
1338
|
+
}
|
|
1319
1339
|
function gitApplyCachedFromStdin(patchContent, cwd) {
|
|
1320
1340
|
execCommandWithInput("git", ["apply", "--cached"], { input: patchContent, cwd });
|
|
1321
1341
|
}
|
|
@@ -1423,12 +1443,16 @@ function getCommitCountAhead(branchName, cwd) {
|
|
|
1423
1443
|
const output = execCommand(`git rev-list --count HEAD..${branchName}`, { cwd });
|
|
1424
1444
|
return parseInt(output, 10) || 0;
|
|
1425
1445
|
}
|
|
1426
|
-
function
|
|
1446
|
+
function parseDivergenceOutput(output) {
|
|
1447
|
+
const [leftStr, rightStr] = output.trim().split(/\s+/);
|
|
1448
|
+
return { ahead: parseInt(rightStr, 10) || 0, behind: parseInt(leftStr, 10) || 0 };
|
|
1449
|
+
}
|
|
1450
|
+
async function getCommitDivergenceAsync(branchName, cwd) {
|
|
1427
1451
|
try {
|
|
1428
|
-
const output =
|
|
1429
|
-
return
|
|
1452
|
+
const output = await execCommandAsync(`git rev-list --left-right --count HEAD...${branchName}`, { cwd });
|
|
1453
|
+
return parseDivergenceOutput(output);
|
|
1430
1454
|
} catch {
|
|
1431
|
-
return 0;
|
|
1455
|
+
return { ahead: 0, behind: 0 };
|
|
1432
1456
|
}
|
|
1433
1457
|
}
|
|
1434
1458
|
function getCurrentBranch(cwd) {
|
|
@@ -3795,7 +3819,7 @@ function buildSeparatorWithHint(cols, hint) {
|
|
|
3795
3819
|
const rightLen = remaining - leftLen;
|
|
3796
3820
|
return `${chalk9.gray("\u2500".repeat(leftLen))} ${hint} ${chalk9.gray("\u2500".repeat(rightLen))}`;
|
|
3797
3821
|
}
|
|
3798
|
-
function buildPanelFrame(statusResult, selectedIndex, scrollOffset, rows, cols, countdown) {
|
|
3822
|
+
function buildPanelFrame(statusResult, selectedIndex, scrollOffset, rows, cols, countdown, cachedPanelLines) {
|
|
3799
3823
|
const lines = [];
|
|
3800
3824
|
lines.push(PANEL_TITLE(statusResult.main.projectName));
|
|
3801
3825
|
lines.push(renderConfiguredBranchLine(statusResult.main));
|
|
@@ -3806,7 +3830,7 @@ function buildPanelFrame(statusResult, selectedIndex, scrollOffset, rows, cols,
|
|
|
3806
3830
|
lines.push(PANEL_NO_WORKTREES);
|
|
3807
3831
|
lines.push(buildSeparatorWithHint(cols, ""));
|
|
3808
3832
|
} else {
|
|
3809
|
-
const panelLines = buildGroupedWorktreeLines(statusResult.worktrees, selectedIndex);
|
|
3833
|
+
const panelLines = cachedPanelLines ?? buildGroupedWorktreeLines(statusResult.worktrees, selectedIndex);
|
|
3810
3834
|
const hasOverflowUp = scrollOffset > 0;
|
|
3811
3835
|
const hasOverflowDown = scrollOffset + visibleRows < panelLines.length;
|
|
3812
3836
|
lines.push(buildSeparatorWithHint(cols, hasOverflowUp ? PANEL_OVERFLOW_UP_HINT : ""));
|
|
@@ -4002,8 +4026,11 @@ var PanelStateManager = class {
|
|
|
4002
4026
|
displayOrder = [];
|
|
4003
4027
|
/** 滚动偏移(基于行数) */
|
|
4004
4028
|
scrollOffset = 0;
|
|
4029
|
+
/** 缓存的面板行列表,在 updateData 和导航时更新 */
|
|
4030
|
+
cachedPanelLines = [];
|
|
4005
4031
|
/**
|
|
4006
4032
|
* 更新状态数据
|
|
4033
|
+
* 一次性计算 displayOrder 和 cachedPanelLines,后续 adjustScrollForSelection 和 render 复用缓存
|
|
4007
4034
|
* @param {StatusResult} newStatus - 新的状态数据
|
|
4008
4035
|
* @param {string} [previousBranch] - 刷新前选中的分支名
|
|
4009
4036
|
*/
|
|
@@ -4022,6 +4049,7 @@ var PanelStateManager = class {
|
|
|
4022
4049
|
} else {
|
|
4023
4050
|
this.selectedDisplayIndex = 0;
|
|
4024
4051
|
}
|
|
4052
|
+
this.rebuildCachedPanelLines();
|
|
4025
4053
|
}
|
|
4026
4054
|
/** 获取当前状态数据 */
|
|
4027
4055
|
getStatusResult() {
|
|
@@ -4035,6 +4063,13 @@ var PanelStateManager = class {
|
|
|
4035
4063
|
getScrollOffset() {
|
|
4036
4064
|
return this.scrollOffset;
|
|
4037
4065
|
}
|
|
4066
|
+
/**
|
|
4067
|
+
* 获取缓存的面板行列表
|
|
4068
|
+
* @returns {PanelLine[]} 缓存的面板行列表
|
|
4069
|
+
*/
|
|
4070
|
+
getCachedPanelLines() {
|
|
4071
|
+
return this.cachedPanelLines;
|
|
4072
|
+
}
|
|
4038
4073
|
/**
|
|
4039
4074
|
* 向上导航
|
|
4040
4075
|
* @returns {boolean} 是否发生变化
|
|
@@ -4043,6 +4078,7 @@ var PanelStateManager = class {
|
|
|
4043
4078
|
if (!this.statusResult || this.displayOrder.length === 0) return false;
|
|
4044
4079
|
if (this.selectedDisplayIndex > 0) {
|
|
4045
4080
|
this.selectedDisplayIndex--;
|
|
4081
|
+
this.rebuildCachedPanelLines();
|
|
4046
4082
|
this.adjustScrollForSelection();
|
|
4047
4083
|
return true;
|
|
4048
4084
|
}
|
|
@@ -4056,6 +4092,7 @@ var PanelStateManager = class {
|
|
|
4056
4092
|
if (!this.statusResult || this.displayOrder.length === 0) return false;
|
|
4057
4093
|
if (this.selectedDisplayIndex < this.displayOrder.length - 1) {
|
|
4058
4094
|
this.selectedDisplayIndex++;
|
|
4095
|
+
this.rebuildCachedPanelLines();
|
|
4059
4096
|
this.adjustScrollForSelection();
|
|
4060
4097
|
return true;
|
|
4061
4098
|
}
|
|
@@ -4072,13 +4109,14 @@ var PanelStateManager = class {
|
|
|
4072
4109
|
}
|
|
4073
4110
|
/**
|
|
4074
4111
|
* 调整滚动位置以确保选中项在可见区域内
|
|
4112
|
+
* 复用 cachedPanelLines,不再重新调用 buildGroupedWorktreeLines
|
|
4075
4113
|
*/
|
|
4076
4114
|
adjustScrollForSelection() {
|
|
4077
4115
|
if (!this.statusResult || this.displayOrder.length === 0) return;
|
|
4078
4116
|
const originalIndex = this.getSelectedOriginalIndex();
|
|
4079
4117
|
const rows = process.stdout.rows || 24;
|
|
4080
4118
|
const visibleRows = calculateVisibleRows(rows);
|
|
4081
|
-
const panelLines =
|
|
4119
|
+
const panelLines = this.cachedPanelLines;
|
|
4082
4120
|
let firstLine = -1;
|
|
4083
4121
|
let lastLine = -1;
|
|
4084
4122
|
for (let i = 0; i < panelLines.length; i++) {
|
|
@@ -4102,6 +4140,18 @@ var PanelStateManager = class {
|
|
|
4102
4140
|
this.scrollOffset = groupStart;
|
|
4103
4141
|
}
|
|
4104
4142
|
}
|
|
4143
|
+
/**
|
|
4144
|
+
* 重建缓存的 panelLines
|
|
4145
|
+
* 在数据更新或导航变化时调用
|
|
4146
|
+
*/
|
|
4147
|
+
rebuildCachedPanelLines() {
|
|
4148
|
+
if (!this.statusResult) {
|
|
4149
|
+
this.cachedPanelLines = [];
|
|
4150
|
+
return;
|
|
4151
|
+
}
|
|
4152
|
+
const originalIndex = this.getSelectedOriginalIndex();
|
|
4153
|
+
this.cachedPanelLines = buildGroupedWorktreeLines(this.statusResult.worktrees, originalIndex);
|
|
4154
|
+
}
|
|
4105
4155
|
};
|
|
4106
4156
|
|
|
4107
4157
|
// src/utils/interactive-panel.ts
|
|
@@ -4126,13 +4176,17 @@ var InteractivePanel = class {
|
|
|
4126
4176
|
exitHandler;
|
|
4127
4177
|
/** 操作锁(防止操作期间响应按键) */
|
|
4128
4178
|
isOperating;
|
|
4179
|
+
/** 刷新锁(防止异步刷新期间触发重复刷新) */
|
|
4180
|
+
isRefreshing;
|
|
4129
4181
|
/** Promise resolve 函数(stop 时调用以完成 start 返回的 Promise) */
|
|
4130
4182
|
resolveStart;
|
|
4131
|
-
/**
|
|
4183
|
+
/** 数据收集函数引用(异步,支持并行收集 worktree 数据) */
|
|
4132
4184
|
collectStatusFn;
|
|
4185
|
+
/** 上一帧的总行数,用于 footer-only 渲染时定位最后一行 */
|
|
4186
|
+
lastFrameLineCount = 0;
|
|
4133
4187
|
/**
|
|
4134
4188
|
* 创建交互式面板
|
|
4135
|
-
* @param {() => StatusResult} collectStatusFn -
|
|
4189
|
+
* @param {() => Promise<StatusResult>} collectStatusFn - 异步数据收集函数
|
|
4136
4190
|
*/
|
|
4137
4191
|
constructor(collectStatusFn) {
|
|
4138
4192
|
this.stateManager = new PanelStateManager();
|
|
@@ -4145,6 +4199,7 @@ var InteractivePanel = class {
|
|
|
4145
4199
|
this.resizeHandler = null;
|
|
4146
4200
|
this.exitHandler = null;
|
|
4147
4201
|
this.isOperating = false;
|
|
4202
|
+
this.isRefreshing = false;
|
|
4148
4203
|
this.resolveStart = null;
|
|
4149
4204
|
this.collectStatusFn = collectStatusFn;
|
|
4150
4205
|
}
|
|
@@ -4153,14 +4208,14 @@ var InteractivePanel = class {
|
|
|
4153
4208
|
* 非 TTY 时打印提示并退出
|
|
4154
4209
|
* @returns {Promise<void>} 面板关闭时 resolve
|
|
4155
4210
|
*/
|
|
4156
|
-
start() {
|
|
4211
|
+
async start() {
|
|
4157
4212
|
if (!this.isTTY) {
|
|
4158
4213
|
console.log(PANEL_NOT_TTY);
|
|
4159
|
-
return
|
|
4214
|
+
return;
|
|
4160
4215
|
}
|
|
4216
|
+
this.stateManager.updateData(await this.collectStatusFn());
|
|
4161
4217
|
return new Promise((resolve4) => {
|
|
4162
4218
|
this.resolveStart = resolve4;
|
|
4163
|
-
this.stateManager.updateData(this.collectStatusFn());
|
|
4164
4219
|
this.initTerminal();
|
|
4165
4220
|
this.keyboardController.start();
|
|
4166
4221
|
this.startAutoRefresh();
|
|
@@ -4290,7 +4345,7 @@ var InteractivePanel = class {
|
|
|
4290
4345
|
if (this.refreshCountdown > 0) {
|
|
4291
4346
|
this.refreshCountdown--;
|
|
4292
4347
|
}
|
|
4293
|
-
this.
|
|
4348
|
+
this.renderFooterOnly();
|
|
4294
4349
|
}, PANEL_COUNTDOWN_INTERVAL_MS);
|
|
4295
4350
|
if (this.refreshTimer.unref) this.refreshTimer.unref();
|
|
4296
4351
|
if (this.countdownTimer.unref) this.countdownTimer.unref();
|
|
@@ -4309,19 +4364,25 @@ var InteractivePanel = class {
|
|
|
4309
4364
|
}
|
|
4310
4365
|
}
|
|
4311
4366
|
/**
|
|
4312
|
-
* 刷新数据:记录当前选中分支 →
|
|
4367
|
+
* 刷新数据:记录当前选中分支 → 异步重新收集 → 恢复选中位置 → 重置倒计时 → 重绘
|
|
4368
|
+
* 使用 isRefreshing 锁防止异步刷新期间触发重复刷新
|
|
4313
4369
|
*/
|
|
4314
|
-
refreshData() {
|
|
4315
|
-
if (this.stopped || this.isOperating) return;
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4370
|
+
async refreshData() {
|
|
4371
|
+
if (this.stopped || this.isOperating || this.isRefreshing) return;
|
|
4372
|
+
this.isRefreshing = true;
|
|
4373
|
+
try {
|
|
4374
|
+
const previousBranch = this.stateManager.getSelectedBranch();
|
|
4375
|
+
this.stateManager.updateData(await this.collectStatusFn(), previousBranch || void 0);
|
|
4376
|
+
this.stateManager.adjustScrollForSelection();
|
|
4377
|
+
this.refreshCountdown = PANEL_REFRESH_INTERVAL_MS / 1e3;
|
|
4378
|
+
this.render();
|
|
4379
|
+
} finally {
|
|
4380
|
+
this.isRefreshing = false;
|
|
4381
|
+
}
|
|
4321
4382
|
}
|
|
4322
4383
|
/**
|
|
4323
4384
|
* 渲染一帧面板内容
|
|
4324
|
-
*
|
|
4385
|
+
* 使用同步输出防止闪烁,复用缓存的 panelLines 避免重复 groupWorktreesByDate 计算
|
|
4325
4386
|
*/
|
|
4326
4387
|
render() {
|
|
4327
4388
|
const statusResult = this.stateManager.getStatusResult();
|
|
@@ -4334,7 +4395,8 @@ var InteractivePanel = class {
|
|
|
4334
4395
|
this.stateManager.getScrollOffset(),
|
|
4335
4396
|
rows,
|
|
4336
4397
|
cols,
|
|
4337
|
-
this.refreshCountdown
|
|
4398
|
+
this.refreshCountdown,
|
|
4399
|
+
this.stateManager.getCachedPanelLines()
|
|
4338
4400
|
);
|
|
4339
4401
|
process.stdout.write(SYNC_OUTPUT_START);
|
|
4340
4402
|
process.stdout.write(CLEAR_SCREEN);
|
|
@@ -4343,6 +4405,22 @@ var InteractivePanel = class {
|
|
|
4343
4405
|
const suffix = i < frameLines.length - 1 ? "\n" : "";
|
|
4344
4406
|
process.stdout.write(`${truncateToTerminalWidth(frameLines[i], cols)}${suffix}`);
|
|
4345
4407
|
}
|
|
4408
|
+
this.lastFrameLineCount = frameLines.length;
|
|
4409
|
+
process.stdout.write(SYNC_OUTPUT_END);
|
|
4410
|
+
}
|
|
4411
|
+
/**
|
|
4412
|
+
* 仅更新 footer 行(倒计时文本)
|
|
4413
|
+
* 使用 ANSI 光标定位直接覆写最后一行,避免全量重绘
|
|
4414
|
+
*/
|
|
4415
|
+
renderFooterOnly() {
|
|
4416
|
+
if (this.stopped || this.isOperating || this.lastFrameLineCount === 0) return;
|
|
4417
|
+
const cols = process.stdout.columns || DEFAULT_TERMINAL_COLUMNS;
|
|
4418
|
+
const footerText = renderFooter(this.refreshCountdown);
|
|
4419
|
+
const truncated = truncateToTerminalWidth(footerText, cols);
|
|
4420
|
+
process.stdout.write(SYNC_OUTPUT_START);
|
|
4421
|
+
process.stdout.write(`\x1B[${this.lastFrameLineCount};1H`);
|
|
4422
|
+
process.stdout.write("\x1B[2K");
|
|
4423
|
+
process.stdout.write(truncated);
|
|
4346
4424
|
process.stdout.write(SYNC_OUTPUT_END);
|
|
4347
4425
|
}
|
|
4348
4426
|
/**
|
|
@@ -4363,7 +4441,7 @@ var InteractivePanel = class {
|
|
|
4363
4441
|
this.initTerminal();
|
|
4364
4442
|
this.keyboardController.start();
|
|
4365
4443
|
this.isOperating = false;
|
|
4366
|
-
this.refreshData();
|
|
4444
|
+
await this.refreshData();
|
|
4367
4445
|
this.startAutoRefresh();
|
|
4368
4446
|
this.render();
|
|
4369
4447
|
}
|
|
@@ -5507,7 +5585,7 @@ async function handleStatus(options) {
|
|
|
5507
5585
|
await panel.start();
|
|
5508
5586
|
return;
|
|
5509
5587
|
}
|
|
5510
|
-
const statusResult = collectStatus();
|
|
5588
|
+
const statusResult = await collectStatus();
|
|
5511
5589
|
logger.info(`status \u547D\u4EE4\u6267\u884C\uFF0C\u9879\u76EE: ${statusResult.main.projectName}\uFF0C\u5171 ${statusResult.totalWorktrees} \u4E2A worktree`);
|
|
5512
5590
|
if (options.json) {
|
|
5513
5591
|
printStatusAsJson(statusResult);
|
|
@@ -5515,7 +5593,7 @@ async function handleStatus(options) {
|
|
|
5515
5593
|
}
|
|
5516
5594
|
printStatusAsText(statusResult);
|
|
5517
5595
|
}
|
|
5518
|
-
function collectStatus() {
|
|
5596
|
+
async function collectStatus() {
|
|
5519
5597
|
const projectName = getProjectName();
|
|
5520
5598
|
const currentBranch = getCurrentBranch();
|
|
5521
5599
|
const isClean = isWorkingDirClean();
|
|
@@ -5533,7 +5611,9 @@ function collectStatus() {
|
|
|
5533
5611
|
deletions
|
|
5534
5612
|
};
|
|
5535
5613
|
const worktrees = getProjectWorktrees();
|
|
5536
|
-
const worktreeStatuses =
|
|
5614
|
+
const worktreeStatuses = await Promise.all(
|
|
5615
|
+
worktrees.map((wt) => collectWorktreeDetailedStatusAsync(wt, projectName))
|
|
5616
|
+
);
|
|
5537
5617
|
const snapshots = collectSnapshots(projectName, worktrees);
|
|
5538
5618
|
return {
|
|
5539
5619
|
main: main2,
|
|
@@ -5542,49 +5622,61 @@ function collectStatus() {
|
|
|
5542
5622
|
totalWorktrees: worktrees.length
|
|
5543
5623
|
};
|
|
5544
5624
|
}
|
|
5545
|
-
function
|
|
5546
|
-
const
|
|
5547
|
-
|
|
5548
|
-
|
|
5625
|
+
async function collectWorktreeDetailedStatusAsync(worktree, projectName) {
|
|
5626
|
+
const [divergence, porcelain, diffStat] = await Promise.all([
|
|
5627
|
+
countCommitDivergenceAsync(worktree.branch),
|
|
5628
|
+
detectStatusPorcelainAsync(worktree.path),
|
|
5629
|
+
countDiffStatAsync(worktree.path)
|
|
5630
|
+
]);
|
|
5631
|
+
const changeStatus = detectChangeStatusFromPorcelain(porcelain, divergence.commitsAhead);
|
|
5549
5632
|
const createdAt = getWorktreeCreatedTime(worktree.path);
|
|
5550
5633
|
return {
|
|
5551
5634
|
path: worktree.path,
|
|
5552
5635
|
branch: worktree.branch,
|
|
5553
5636
|
changeStatus,
|
|
5554
|
-
commitsAhead,
|
|
5555
|
-
commitsBehind,
|
|
5637
|
+
commitsAhead: divergence.commitsAhead,
|
|
5638
|
+
commitsBehind: divergence.commitsBehind,
|
|
5556
5639
|
snapshotTime: resolveSnapshotTime(projectName, worktree.branch),
|
|
5557
|
-
insertions,
|
|
5558
|
-
deletions,
|
|
5640
|
+
insertions: diffStat.insertions,
|
|
5641
|
+
deletions: diffStat.deletions,
|
|
5559
5642
|
createdAt
|
|
5560
5643
|
};
|
|
5561
5644
|
}
|
|
5562
|
-
function
|
|
5645
|
+
function detectChangeStatusFromPorcelain(porcelain, commitsAhead) {
|
|
5646
|
+
const hasConflict = porcelain.split("\n").some((line) => /^(UU|AA|DD|DU|UD|AU|UA)/.test(line));
|
|
5647
|
+
if (hasConflict) {
|
|
5648
|
+
return "conflict";
|
|
5649
|
+
}
|
|
5650
|
+
if (porcelain !== "") {
|
|
5651
|
+
return "uncommitted";
|
|
5652
|
+
}
|
|
5653
|
+
if (commitsAhead > 0) {
|
|
5654
|
+
return "committed";
|
|
5655
|
+
}
|
|
5656
|
+
return "clean";
|
|
5657
|
+
}
|
|
5658
|
+
async function detectStatusPorcelainAsync(worktreePath) {
|
|
5563
5659
|
try {
|
|
5564
|
-
|
|
5565
|
-
return "conflict";
|
|
5566
|
-
}
|
|
5567
|
-
if (!isWorkingDirClean(worktree.path)) {
|
|
5568
|
-
return "uncommitted";
|
|
5569
|
-
}
|
|
5570
|
-
if (hasLocalCommits(worktree.branch)) {
|
|
5571
|
-
return "committed";
|
|
5572
|
-
}
|
|
5573
|
-
return "clean";
|
|
5660
|
+
return await getStatusPorcelainAsync(worktreePath);
|
|
5574
5661
|
} catch {
|
|
5575
|
-
return "
|
|
5662
|
+
return "";
|
|
5576
5663
|
}
|
|
5577
5664
|
}
|
|
5578
|
-
function
|
|
5665
|
+
async function countCommitDivergenceAsync(branchName) {
|
|
5579
5666
|
try {
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
commitsBehind: getCommitCountBehind(branchName)
|
|
5583
|
-
};
|
|
5667
|
+
const { ahead, behind } = await getCommitDivergenceAsync(branchName);
|
|
5668
|
+
return { commitsAhead: ahead, commitsBehind: behind };
|
|
5584
5669
|
} catch {
|
|
5585
5670
|
return { commitsAhead: 0, commitsBehind: 0 };
|
|
5586
5671
|
}
|
|
5587
5672
|
}
|
|
5673
|
+
async function countDiffStatAsync(worktreePath) {
|
|
5674
|
+
try {
|
|
5675
|
+
return await getDiffStatAsync(worktreePath);
|
|
5676
|
+
} catch {
|
|
5677
|
+
return { insertions: 0, deletions: 0 };
|
|
5678
|
+
}
|
|
5679
|
+
}
|
|
5588
5680
|
function countDiffStat(worktreePath) {
|
|
5589
5681
|
try {
|
|
5590
5682
|
return getDiffStat(worktreePath);
|
package/dist/postinstall.js
CHANGED
|
@@ -551,8 +551,10 @@ var TASKS_CMD_MESSAGES = {
|
|
|
551
551
|
TASK_INIT_FILE_EXISTS: (path) => `\u6587\u4EF6\u5DF2\u5B58\u5728: ${path}\uFF0C\u5982\u9700\u8986\u76D6\u8BF7\u5148\u5220\u9664`,
|
|
552
552
|
/** 任务模板生成成功 */
|
|
553
553
|
TASK_INIT_SUCCESS: (path) => `\u2713 \u4EFB\u52A1\u6A21\u677F\u5DF2\u751F\u6210: ${path}`,
|
|
554
|
-
/**
|
|
555
|
-
TASK_INIT_HINT: (path) => `\
|
|
554
|
+
/** 任务模板使用提示(分行列出 run 和 resume 两种用法) */
|
|
555
|
+
TASK_INIT_HINT: (path) => `\u6267\u884C\u4EFB\u52A1:
|
|
556
|
+
clawt run -f ${path} # \u521B\u5EFA worktree \u5E76\u6267\u884C\uFF08\u5206\u652F\u540D\u9700\u4E0D\u5B58\u5728\uFF09
|
|
557
|
+
clawt resume -f ${path} # \u5728\u5DF2\u6709 worktree \u4E2D\u8FFD\u95EE\uFF08\u5206\u652F\u540D\u9700\u5DF2\u5B58\u5728\uFF09`
|
|
556
558
|
};
|
|
557
559
|
|
|
558
560
|
// src/constants/messages/post-create.ts
|
package/docs/tasks.md
CHANGED
|
@@ -46,7 +46,7 @@ clawt tasks init [path]
|
|
|
46
46
|
# 格式说明: 标签外的文本会被忽略,每个任务用 START/END 标签包裹
|
|
47
47
|
#
|
|
48
48
|
# 规则:
|
|
49
|
-
# 1. 每个任务块用
|
|
49
|
+
# 1. 每个任务块用 <START> 和 <END> 标签包裹(实际标签见下方示例)
|
|
50
50
|
# 2. 块内 # branch: <分支名> 声明分支名(使用 -b 参数时可省略)
|
|
51
51
|
# 3. 块内其余行为任务描述(支持多行)
|
|
52
52
|
|
package/package.json
CHANGED
package/src/commands/status.ts
CHANGED
|
@@ -9,11 +9,10 @@ import {
|
|
|
9
9
|
getCurrentBranch,
|
|
10
10
|
isWorkingDirClean,
|
|
11
11
|
getProjectWorktrees,
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
getCommitDivergenceAsync,
|
|
13
|
+
getDiffStatAsync,
|
|
14
|
+
getStatusPorcelainAsync,
|
|
14
15
|
getDiffStat,
|
|
15
|
-
hasMergeConflict,
|
|
16
|
-
hasLocalCommits,
|
|
17
16
|
getSnapshotModifiedTime,
|
|
18
17
|
getProjectSnapshotBranches,
|
|
19
18
|
getWorktreeCreatedTime,
|
|
@@ -55,7 +54,7 @@ async function handleStatus(options: StatusOptions): Promise<void> {
|
|
|
55
54
|
return;
|
|
56
55
|
}
|
|
57
56
|
|
|
58
|
-
const statusResult = collectStatus();
|
|
57
|
+
const statusResult = await collectStatus();
|
|
59
58
|
|
|
60
59
|
logger.info(`status 命令执行,项目: ${statusResult.main.projectName},共 ${statusResult.totalWorktrees} 个 worktree`);
|
|
61
60
|
|
|
@@ -69,9 +68,10 @@ async function handleStatus(options: StatusOptions): Promise<void> {
|
|
|
69
68
|
|
|
70
69
|
/**
|
|
71
70
|
* 收集项目全局状态信息
|
|
72
|
-
*
|
|
71
|
+
* 各 worktree 的数据通过 Promise.all 并行收集,避免串行阻塞
|
|
72
|
+
* @returns {Promise<StatusResult>} 完整的状态数据
|
|
73
73
|
*/
|
|
74
|
-
export function collectStatus(): StatusResult {
|
|
74
|
+
export async function collectStatus(): Promise<StatusResult> {
|
|
75
75
|
const projectName = getProjectName();
|
|
76
76
|
const currentBranch = getCurrentBranch();
|
|
77
77
|
const isClean = isWorkingDirClean();
|
|
@@ -95,9 +95,11 @@ export function collectStatus(): StatusResult {
|
|
|
95
95
|
deletions,
|
|
96
96
|
};
|
|
97
97
|
|
|
98
|
-
// 各 worktree
|
|
98
|
+
// 各 worktree 详细状态(异步并行收集)
|
|
99
99
|
const worktrees = getProjectWorktrees();
|
|
100
|
-
const worktreeStatuses =
|
|
100
|
+
const worktreeStatuses = await Promise.all(
|
|
101
|
+
worktrees.map((wt) => collectWorktreeDetailedStatusAsync(wt, projectName)),
|
|
102
|
+
);
|
|
101
103
|
|
|
102
104
|
// 未清理的 validate 快照
|
|
103
105
|
const snapshots = collectSnapshots(projectName, worktrees);
|
|
@@ -111,70 +113,100 @@ export function collectStatus(): StatusResult {
|
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
+
* 异步收集单个 worktree 的详细状态
|
|
117
|
+
* 内部 3 个 git 命令通过 Promise.all 并行执行,每个 worktree 内部也是并行的
|
|
116
118
|
* @param {WorktreeInfo} worktree - worktree 信息
|
|
117
119
|
* @param {string} projectName - 项目名
|
|
118
|
-
* @returns {WorktreeDetailedStatus} 详细状态
|
|
120
|
+
* @returns {Promise<WorktreeDetailedStatus>} 详细状态
|
|
119
121
|
*/
|
|
120
|
-
function
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
122
|
+
async function collectWorktreeDetailedStatusAsync(worktree: WorktreeInfo, projectName: string): Promise<WorktreeDetailedStatus> {
|
|
123
|
+
// 3 个 git 命令并行执行:提交差异、工作区状态、diff 统计
|
|
124
|
+
const [divergence, porcelain, diffStat] = await Promise.all([
|
|
125
|
+
countCommitDivergenceAsync(worktree.branch),
|
|
126
|
+
detectStatusPorcelainAsync(worktree.path),
|
|
127
|
+
countDiffStatAsync(worktree.path),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const changeStatus = detectChangeStatusFromPorcelain(porcelain, divergence.commitsAhead);
|
|
124
131
|
const createdAt = getWorktreeCreatedTime(worktree.path);
|
|
125
132
|
|
|
126
133
|
return {
|
|
127
134
|
path: worktree.path,
|
|
128
135
|
branch: worktree.branch,
|
|
129
136
|
changeStatus,
|
|
130
|
-
commitsAhead,
|
|
131
|
-
commitsBehind,
|
|
137
|
+
commitsAhead: divergence.commitsAhead,
|
|
138
|
+
commitsBehind: divergence.commitsBehind,
|
|
132
139
|
snapshotTime: resolveSnapshotTime(projectName, worktree.branch),
|
|
133
|
-
insertions,
|
|
134
|
-
deletions,
|
|
140
|
+
insertions: diffStat.insertions,
|
|
141
|
+
deletions: diffStat.deletions,
|
|
135
142
|
createdAt,
|
|
136
143
|
};
|
|
137
144
|
}
|
|
138
145
|
|
|
139
146
|
/**
|
|
140
|
-
*
|
|
147
|
+
* 从 porcelain 输出判断变更状态
|
|
141
148
|
* 优先级:冲突 > 未提交 > 已提交 > 干净
|
|
142
|
-
* @param {
|
|
149
|
+
* @param {string} porcelain - git status --porcelain 输出
|
|
150
|
+
* @param {number} commitsAhead - 领先提交数
|
|
143
151
|
* @returns {WorktreeDetailedStatus['changeStatus']} 变更状态
|
|
144
152
|
*/
|
|
145
|
-
function
|
|
153
|
+
function detectChangeStatusFromPorcelain(porcelain: string, commitsAhead: number): WorktreeDetailedStatus['changeStatus'] {
|
|
154
|
+
// 检测合并冲突(UU/AA/DD/DU/UD/AU/UA 开头的行)
|
|
155
|
+
const hasConflict = porcelain.split('\n').some((line) => /^(UU|AA|DD|DU|UD|AU|UA)/.test(line));
|
|
156
|
+
if (hasConflict) {
|
|
157
|
+
return 'conflict';
|
|
158
|
+
}
|
|
159
|
+
// 检测未提交修改(porcelain 非空即有未提交变更)
|
|
160
|
+
if (porcelain !== '') {
|
|
161
|
+
return 'uncommitted';
|
|
162
|
+
}
|
|
163
|
+
// 用 commitsAhead > 0 判断是否有本地提交
|
|
164
|
+
if (commitsAhead > 0) {
|
|
165
|
+
return 'committed';
|
|
166
|
+
}
|
|
167
|
+
return 'clean';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 异步获取工作区 porcelain 状态,出错时返回空字符串
|
|
172
|
+
* @param {string} worktreePath - worktree 目录路径
|
|
173
|
+
* @returns {Promise<string>} porcelain 格式输出
|
|
174
|
+
*/
|
|
175
|
+
async function detectStatusPorcelainAsync(worktreePath: string): Promise<string> {
|
|
146
176
|
try {
|
|
147
|
-
|
|
148
|
-
return 'conflict';
|
|
149
|
-
}
|
|
150
|
-
if (!isWorkingDirClean(worktree.path)) {
|
|
151
|
-
return 'uncommitted';
|
|
152
|
-
}
|
|
153
|
-
if (hasLocalCommits(worktree.branch)) {
|
|
154
|
-
return 'committed';
|
|
155
|
-
}
|
|
156
|
-
return 'clean';
|
|
177
|
+
return await getStatusPorcelainAsync(worktreePath);
|
|
157
178
|
} catch {
|
|
158
|
-
return '
|
|
179
|
+
return '';
|
|
159
180
|
}
|
|
160
181
|
}
|
|
161
182
|
|
|
162
183
|
/**
|
|
163
|
-
*
|
|
184
|
+
* 异步统计分支与主分支的提交差异(领先/落后数)
|
|
164
185
|
* @param {string} branchName - 分支名
|
|
165
|
-
* @returns {{ commitsAhead: number; commitsBehind: number }} 领先和落后的提交数
|
|
186
|
+
* @returns {Promise<{ commitsAhead: number; commitsBehind: number }>} 领先和落后的提交数
|
|
166
187
|
*/
|
|
167
|
-
function
|
|
188
|
+
async function countCommitDivergenceAsync(branchName: string): Promise<{ commitsAhead: number; commitsBehind: number }> {
|
|
168
189
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
commitsBehind: getCommitCountBehind(branchName),
|
|
172
|
-
};
|
|
190
|
+
const { ahead, behind } = await getCommitDivergenceAsync(branchName);
|
|
191
|
+
return { commitsAhead: ahead, commitsBehind: behind };
|
|
173
192
|
} catch {
|
|
174
193
|
return { commitsAhead: 0, commitsBehind: 0 };
|
|
175
194
|
}
|
|
176
195
|
}
|
|
177
196
|
|
|
197
|
+
/**
|
|
198
|
+
* 异步统计 worktree 的差异行数
|
|
199
|
+
* @param {string} worktreePath - worktree 目录路径
|
|
200
|
+
* @returns {Promise<{ insertions: number; deletions: number }>} 新增和删除行数
|
|
201
|
+
*/
|
|
202
|
+
async function countDiffStatAsync(worktreePath: string): Promise<{ insertions: number; deletions: number }> {
|
|
203
|
+
try {
|
|
204
|
+
return await getDiffStatAsync(worktreePath);
|
|
205
|
+
} catch {
|
|
206
|
+
return { insertions: 0, deletions: 0 };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
178
210
|
/**
|
|
179
211
|
* 统计 worktree 的差异行数
|
|
180
212
|
* @param {string} worktreePath - worktree 路径
|
|
@@ -4,6 +4,7 @@ export const TASKS_CMD_MESSAGES = {
|
|
|
4
4
|
TASK_INIT_FILE_EXISTS: (path: string) => `文件已存在: ${path},如需覆盖请先删除`,
|
|
5
5
|
/** 任务模板生成成功 */
|
|
6
6
|
TASK_INIT_SUCCESS: (path: string) => `✓ 任务模板已生成: ${path}`,
|
|
7
|
-
/**
|
|
8
|
-
TASK_INIT_HINT: (path: string) =>
|
|
7
|
+
/** 任务模板使用提示(分行列出 run 和 resume 两种用法) */
|
|
8
|
+
TASK_INIT_HINT: (path: string) =>
|
|
9
|
+
`执行任务:\n clawt run -f ${path} # 创建 worktree 并执行(分支名需不存在)\n clawt resume -f ${path} # 在已有 worktree 中追问(分支名需已存在)`,
|
|
9
10
|
} as const;
|
|
@@ -11,7 +11,7 @@ export const TASK_TEMPLATE_CONTENT = `# Clawt 任务文件
|
|
|
11
11
|
# 格式说明: 标签外的文本会被忽略,每个任务用 START/END 标签包裹
|
|
12
12
|
#
|
|
13
13
|
# 规则:
|
|
14
|
-
# 1. 每个任务块用
|
|
14
|
+
# 1. 每个任务块用 <START> 和 <END> 标签包裹(实际标签见下方示例)
|
|
15
15
|
# 2. 块内 # branch: <分支名> 声明分支名(使用 -b 参数时可省略)
|
|
16
16
|
# 3. 块内其余行为任务描述(支持多行)
|
|
17
17
|
|
package/src/utils/git-branch.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execCommand } from './shell.js';
|
|
1
|
+
import { execCommand, execCommandAsync } from './shell.js';
|
|
2
2
|
import { logger } from '../logger/index.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -68,6 +68,51 @@ export function getCommitCountBehind(branchName: string, cwd?: string): number {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* 解析 git rev-list --left-right --count 的输出
|
|
73
|
+
* 输出格式:<left_count>\t<right_count>
|
|
74
|
+
* left = HEAD 侧独有提交数(即 behind),right = branch 侧独有提交数(即 ahead)
|
|
75
|
+
* @param {string} output - rev-list 命令输出
|
|
76
|
+
* @returns {{ ahead: number; behind: number }} 领先和落后的提交数
|
|
77
|
+
*/
|
|
78
|
+
function parseDivergenceOutput(output: string): { ahead: number; behind: number } {
|
|
79
|
+
const [leftStr, rightStr] = output.trim().split(/\s+/);
|
|
80
|
+
return { ahead: parseInt(rightStr, 10) || 0, behind: parseInt(leftStr, 10) || 0 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 获取当前分支与目标分支的双向提交差异
|
|
85
|
+
* 使用单条 git rev-list --left-right --count 命令同时获取领先和落后提交数,
|
|
86
|
+
* 替代分别调用 getCommitCountAhead 和 getCommitCountBehind 两条命令
|
|
87
|
+
* @param {string} branchName - 目标分支名
|
|
88
|
+
* @param {string} [cwd] - 工作目录
|
|
89
|
+
* @returns {{ ahead: number; behind: number }} 领先和落后的提交数
|
|
90
|
+
*/
|
|
91
|
+
export function getCommitDivergence(branchName: string, cwd?: string): { ahead: number; behind: number } {
|
|
92
|
+
try {
|
|
93
|
+
const output = execCommand(`git rev-list --left-right --count HEAD...${branchName}`, { cwd });
|
|
94
|
+
return parseDivergenceOutput(output);
|
|
95
|
+
} catch {
|
|
96
|
+
return { ahead: 0, behind: 0 };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 异步获取当前分支与目标分支的双向提交差异
|
|
102
|
+
* 用于并行收集多个 worktree 的提交差异时避免串行阻塞
|
|
103
|
+
* @param {string} branchName - 目标分支名
|
|
104
|
+
* @param {string} [cwd] - 工作目录
|
|
105
|
+
* @returns {Promise<{ ahead: number; behind: number }>} 领先和落后的提交数
|
|
106
|
+
*/
|
|
107
|
+
export async function getCommitDivergenceAsync(branchName: string, cwd?: string): Promise<{ ahead: number; behind: number }> {
|
|
108
|
+
try {
|
|
109
|
+
const output = await execCommandAsync(`git rev-list --left-right --count HEAD...${branchName}`, { cwd });
|
|
110
|
+
return parseDivergenceOutput(output);
|
|
111
|
+
} catch {
|
|
112
|
+
return { ahead: 0, behind: 0 };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
71
116
|
/**
|
|
72
117
|
* 获取当前分支名
|
|
73
118
|
* @param {string} [cwd] - 工作目录
|
package/src/utils/git-core.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
2
|
import { execSync, execFileSync } from 'node:child_process';
|
|
3
|
-
import { execCommand, execCommandWithInput } from './shell.js';
|
|
3
|
+
import { execCommand, execCommandAsync, execCommandWithInput } from './shell.js';
|
|
4
4
|
import { logger } from '../logger/index.js';
|
|
5
5
|
import { EXEC_MAX_BUFFER, AUTO_SAVE_COMMIT_MESSAGE_PREFIX } from '../constants/git.js';
|
|
6
6
|
|
|
@@ -81,6 +81,16 @@ export function getStatusPorcelain(cwd?: string): string {
|
|
|
81
81
|
return execCommand('git status --porcelain', { cwd });
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* 异步获取工作区状态(git status --porcelain)
|
|
86
|
+
* 用于并行收集多个 worktree 状态时避免串行阻塞
|
|
87
|
+
* @param {string} cwd - 工作目录
|
|
88
|
+
* @returns {Promise<string>} porcelain 格式输出,为空表示干净
|
|
89
|
+
*/
|
|
90
|
+
export async function getStatusPorcelainAsync(cwd?: string): Promise<string> {
|
|
91
|
+
return execCommandAsync('git status --porcelain', { cwd });
|
|
92
|
+
}
|
|
93
|
+
|
|
84
94
|
/**
|
|
85
95
|
* 判断工作区是否干净
|
|
86
96
|
* @param {string} cwd - 工作目录
|
|
@@ -256,6 +266,17 @@ export function getDiffStat(worktreePath: string): { insertions: number; deletio
|
|
|
256
266
|
return parseShortStat(output);
|
|
257
267
|
}
|
|
258
268
|
|
|
269
|
+
/**
|
|
270
|
+
* 异步获取 worktree 中工作区和暂存区的变更统计
|
|
271
|
+
* 用于并行收集多个 worktree 的 diff 统计时避免串行阻塞
|
|
272
|
+
* @param {string} worktreePath - worktree 目录路径
|
|
273
|
+
* @returns {Promise<{ insertions: number; deletions: number }>} 新增和删除行数
|
|
274
|
+
*/
|
|
275
|
+
export async function getDiffStatAsync(worktreePath: string): Promise<{ insertions: number; deletions: number }> {
|
|
276
|
+
const output = await execCommandAsync('git diff --shortstat HEAD', { cwd: worktreePath });
|
|
277
|
+
return parseShortStat(output);
|
|
278
|
+
}
|
|
279
|
+
|
|
259
280
|
/**
|
|
260
281
|
* 获取暂存区相对于 HEAD 的完整 diff(含二进制文件)
|
|
261
282
|
* 注意:返回原始输出不做 trim,保留 patch 格式完整性
|
package/src/utils/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { execCommand, spawnProcess, killAllChildProcesses, execCommandWithInput, runCommandInherited, parseParallelCommands, runParallelCommands, runCommandWithStderrCapture, runParallelCommandsWithStderrCapture } from './shell.js';
|
|
1
|
+
export { execCommand, execCommandAsync, spawnProcess, killAllChildProcesses, execCommandWithInput, runCommandInherited, parseParallelCommands, runParallelCommands, runCommandWithStderrCapture, runParallelCommandsWithStderrCapture } from './shell.js';
|
|
2
2
|
export type { ParallelCommandResult, CommandResultWithStderr, ParallelCommandResultWithStderr } from './shell.js';
|
|
3
3
|
export { copyToClipboard } from './clipboard.js';
|
|
4
4
|
export {
|
|
@@ -13,6 +13,7 @@ export {
|
|
|
13
13
|
removeWorktreeByPath,
|
|
14
14
|
deleteBranch,
|
|
15
15
|
getStatusPorcelain,
|
|
16
|
+
getStatusPorcelainAsync,
|
|
16
17
|
isWorkingDirClean,
|
|
17
18
|
gitAddAll,
|
|
18
19
|
gitCommit,
|
|
@@ -34,7 +35,10 @@ export {
|
|
|
34
35
|
hasLocalCommits,
|
|
35
36
|
getCommitCountAhead,
|
|
36
37
|
getCommitCountBehind,
|
|
38
|
+
getCommitDivergence,
|
|
39
|
+
getCommitDivergenceAsync,
|
|
37
40
|
getDiffStat,
|
|
41
|
+
getDiffStatAsync,
|
|
38
42
|
gitDiffCachedBinary,
|
|
39
43
|
gitApplyCachedFromStdin,
|
|
40
44
|
getCurrentBranch,
|
|
@@ -69,6 +69,7 @@ function buildSeparatorWithHint(cols: number, hint: string): string {
|
|
|
69
69
|
* @param {number} rows - 终端行数
|
|
70
70
|
* @param {number} cols - 终端列数
|
|
71
71
|
* @param {number} countdown - 刷新倒计时秒数
|
|
72
|
+
* @param {PanelLine[]} [cachedPanelLines] - 缓存的面板行列表(传入时复用,不传则重新构建)
|
|
72
73
|
* @returns {string[]} 帧内容行数组
|
|
73
74
|
*/
|
|
74
75
|
export function buildPanelFrame(
|
|
@@ -78,6 +79,7 @@ export function buildPanelFrame(
|
|
|
78
79
|
rows: number,
|
|
79
80
|
cols: number,
|
|
80
81
|
countdown: number,
|
|
82
|
+
cachedPanelLines?: PanelLine[],
|
|
81
83
|
): string[] {
|
|
82
84
|
const lines: string[] = [];
|
|
83
85
|
|
|
@@ -100,8 +102,8 @@ export function buildPanelFrame(
|
|
|
100
102
|
// 底部分隔线(无溢出提示)
|
|
101
103
|
lines.push(buildSeparatorWithHint(cols, ''));
|
|
102
104
|
} else {
|
|
103
|
-
// 构建分组的 worktree
|
|
104
|
-
const panelLines = buildGroupedWorktreeLines(statusResult.worktrees, selectedIndex);
|
|
105
|
+
// 构建分组的 worktree 行列表(优先使用缓存)
|
|
106
|
+
const panelLines = cachedPanelLines ?? buildGroupedWorktreeLines(statusResult.worktrees, selectedIndex);
|
|
105
107
|
|
|
106
108
|
// 判断溢出状态
|
|
107
109
|
const hasOverflowUp = scrollOffset > 0;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { StatusResult } from '../types/index.js';
|
|
2
2
|
import { buildDisplayOrder, calculateVisibleRows, buildGroupedWorktreeLines } from './interactive-panel-render.js';
|
|
3
|
+
import type { PanelLine } from './interactive-panel-render.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* 面板状态管理器
|
|
6
7
|
* 负责维护面板的数据状态、滚动偏移和选中项
|
|
8
|
+
* 缓存 panelLines 和 displayOrder 避免重复计算 groupWorktreesByDate
|
|
7
9
|
*/
|
|
8
10
|
export class PanelStateManager {
|
|
9
11
|
/** 当前状态数据 */
|
|
@@ -14,16 +16,19 @@ export class PanelStateManager {
|
|
|
14
16
|
private displayOrder: number[] = [];
|
|
15
17
|
/** 滚动偏移(基于行数) */
|
|
16
18
|
private scrollOffset: number = 0;
|
|
19
|
+
/** 缓存的面板行列表,在 updateData 和导航时更新 */
|
|
20
|
+
private cachedPanelLines: PanelLine[] = [];
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
23
|
* 更新状态数据
|
|
24
|
+
* 一次性计算 displayOrder 和 cachedPanelLines,后续 adjustScrollForSelection 和 render 复用缓存
|
|
20
25
|
* @param {StatusResult} newStatus - 新的状态数据
|
|
21
26
|
* @param {string} [previousBranch] - 刷新前选中的分支名
|
|
22
27
|
*/
|
|
23
28
|
updateData(newStatus: StatusResult, previousBranch?: string): void {
|
|
24
29
|
this.statusResult = newStatus;
|
|
25
30
|
this.displayOrder = buildDisplayOrder(this.statusResult.worktrees);
|
|
26
|
-
|
|
31
|
+
|
|
27
32
|
if (previousBranch && this.displayOrder.length > 0) {
|
|
28
33
|
const newDisplayIndex = this.displayOrder.findIndex(
|
|
29
34
|
(origIdx) => this.statusResult!.worktrees[origIdx]?.branch === previousBranch,
|
|
@@ -36,6 +41,9 @@ export class PanelStateManager {
|
|
|
36
41
|
} else {
|
|
37
42
|
this.selectedDisplayIndex = 0;
|
|
38
43
|
}
|
|
44
|
+
|
|
45
|
+
// 一次性构建缓存的 panelLines
|
|
46
|
+
this.rebuildCachedPanelLines();
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
/** 获取当前状态数据 */
|
|
@@ -53,6 +61,14 @@ export class PanelStateManager {
|
|
|
53
61
|
return this.scrollOffset;
|
|
54
62
|
}
|
|
55
63
|
|
|
64
|
+
/**
|
|
65
|
+
* 获取缓存的面板行列表
|
|
66
|
+
* @returns {PanelLine[]} 缓存的面板行列表
|
|
67
|
+
*/
|
|
68
|
+
getCachedPanelLines(): PanelLine[] {
|
|
69
|
+
return this.cachedPanelLines;
|
|
70
|
+
}
|
|
71
|
+
|
|
56
72
|
/**
|
|
57
73
|
* 向上导航
|
|
58
74
|
* @returns {boolean} 是否发生变化
|
|
@@ -62,6 +78,8 @@ export class PanelStateManager {
|
|
|
62
78
|
|
|
63
79
|
if (this.selectedDisplayIndex > 0) {
|
|
64
80
|
this.selectedDisplayIndex--;
|
|
81
|
+
// 导航后重建缓存(选中标记变化)
|
|
82
|
+
this.rebuildCachedPanelLines();
|
|
65
83
|
this.adjustScrollForSelection();
|
|
66
84
|
return true;
|
|
67
85
|
}
|
|
@@ -77,6 +95,8 @@ export class PanelStateManager {
|
|
|
77
95
|
|
|
78
96
|
if (this.selectedDisplayIndex < this.displayOrder.length - 1) {
|
|
79
97
|
this.selectedDisplayIndex++;
|
|
98
|
+
// 导航后重建缓存(选中标记变化)
|
|
99
|
+
this.rebuildCachedPanelLines();
|
|
80
100
|
this.adjustScrollForSelection();
|
|
81
101
|
return true;
|
|
82
102
|
}
|
|
@@ -95,6 +115,7 @@ export class PanelStateManager {
|
|
|
95
115
|
|
|
96
116
|
/**
|
|
97
117
|
* 调整滚动位置以确保选中项在可见区域内
|
|
118
|
+
* 复用 cachedPanelLines,不再重新调用 buildGroupedWorktreeLines
|
|
98
119
|
*/
|
|
99
120
|
adjustScrollForSelection(): void {
|
|
100
121
|
if (!this.statusResult || this.displayOrder.length === 0) return;
|
|
@@ -102,7 +123,7 @@ export class PanelStateManager {
|
|
|
102
123
|
const originalIndex = this.getSelectedOriginalIndex();
|
|
103
124
|
const rows = process.stdout.rows || 24;
|
|
104
125
|
const visibleRows = calculateVisibleRows(rows);
|
|
105
|
-
const panelLines =
|
|
126
|
+
const panelLines = this.cachedPanelLines;
|
|
106
127
|
|
|
107
128
|
// 找到选中 worktree 对应的第一行和最后一行
|
|
108
129
|
let firstLine = -1;
|
|
@@ -134,4 +155,17 @@ export class PanelStateManager {
|
|
|
134
155
|
this.scrollOffset = groupStart;
|
|
135
156
|
}
|
|
136
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 重建缓存的 panelLines
|
|
161
|
+
* 在数据更新或导航变化时调用
|
|
162
|
+
*/
|
|
163
|
+
private rebuildCachedPanelLines(): void {
|
|
164
|
+
if (!this.statusResult) {
|
|
165
|
+
this.cachedPanelLines = [];
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const originalIndex = this.getSelectedOriginalIndex();
|
|
169
|
+
this.cachedPanelLines = buildGroupedWorktreeLines(this.statusResult.worktrees, originalIndex);
|
|
170
|
+
}
|
|
137
171
|
}
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from '../constants/index.js';
|
|
21
21
|
import { PANEL_NOT_TTY, PANEL_PRESS_ENTER_TO_RETURN } from '../constants/messages/index.js';
|
|
22
22
|
import { runCommandInherited } from './shell.js';
|
|
23
|
-
import { buildPanelFrame } from './interactive-panel-render.js';
|
|
23
|
+
import { buildPanelFrame, renderFooter } from './interactive-panel-render.js';
|
|
24
24
|
import { truncateToTerminalWidth } from './progress-render.js';
|
|
25
25
|
import type { StatusResult } from '../types/index.js';
|
|
26
26
|
import { KeyboardController } from './keyboard-controller.js';
|
|
@@ -53,16 +53,20 @@ export class InteractivePanel {
|
|
|
53
53
|
private exitHandler: (() => void) | null;
|
|
54
54
|
/** 操作锁(防止操作期间响应按键) */
|
|
55
55
|
private isOperating: boolean;
|
|
56
|
+
/** 刷新锁(防止异步刷新期间触发重复刷新) */
|
|
57
|
+
private isRefreshing: boolean;
|
|
56
58
|
/** Promise resolve 函数(stop 时调用以完成 start 返回的 Promise) */
|
|
57
59
|
private resolveStart: (() => void) | null;
|
|
58
|
-
/**
|
|
59
|
-
private collectStatusFn: () => StatusResult
|
|
60
|
+
/** 数据收集函数引用(异步,支持并行收集 worktree 数据) */
|
|
61
|
+
private collectStatusFn: () => Promise<StatusResult>;
|
|
62
|
+
/** 上一帧的总行数,用于 footer-only 渲染时定位最后一行 */
|
|
63
|
+
private lastFrameLineCount: number = 0;
|
|
60
64
|
|
|
61
65
|
/**
|
|
62
66
|
* 创建交互式面板
|
|
63
|
-
* @param {() => StatusResult} collectStatusFn -
|
|
67
|
+
* @param {() => Promise<StatusResult>} collectStatusFn - 异步数据收集函数
|
|
64
68
|
*/
|
|
65
|
-
constructor(collectStatusFn: () => StatusResult) {
|
|
69
|
+
constructor(collectStatusFn: () => Promise<StatusResult>) {
|
|
66
70
|
this.stateManager = new PanelStateManager();
|
|
67
71
|
this.keyboardController = new KeyboardController(this.handleKeypress.bind(this));
|
|
68
72
|
this.refreshTimer = null;
|
|
@@ -73,6 +77,7 @@ export class InteractivePanel {
|
|
|
73
77
|
this.resizeHandler = null;
|
|
74
78
|
this.exitHandler = null;
|
|
75
79
|
this.isOperating = false;
|
|
80
|
+
this.isRefreshing = false;
|
|
76
81
|
this.resolveStart = null;
|
|
77
82
|
this.collectStatusFn = collectStatusFn;
|
|
78
83
|
}
|
|
@@ -82,19 +87,19 @@ export class InteractivePanel {
|
|
|
82
87
|
* 非 TTY 时打印提示并退出
|
|
83
88
|
* @returns {Promise<void>} 面板关闭时 resolve
|
|
84
89
|
*/
|
|
85
|
-
start(): Promise<void> {
|
|
90
|
+
async start(): Promise<void> {
|
|
86
91
|
// 非 TTY 降级
|
|
87
92
|
if (!this.isTTY) {
|
|
88
93
|
console.log(PANEL_NOT_TTY);
|
|
89
|
-
return
|
|
94
|
+
return;
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
// 异步收集初始数据(在创建 Promise 之前完成,避免 async executor 反模式)
|
|
98
|
+
this.stateManager.updateData(await this.collectStatusFn());
|
|
99
|
+
|
|
92
100
|
return new Promise<void>((resolve) => {
|
|
93
101
|
this.resolveStart = resolve;
|
|
94
102
|
|
|
95
|
-
// 收集初始数据
|
|
96
|
-
this.stateManager.updateData(this.collectStatusFn());
|
|
97
|
-
|
|
98
103
|
// 初始化终端
|
|
99
104
|
this.initTerminal();
|
|
100
105
|
|
|
@@ -270,12 +275,12 @@ export class InteractivePanel {
|
|
|
270
275
|
this.refreshData();
|
|
271
276
|
}, PANEL_REFRESH_INTERVAL_MS);
|
|
272
277
|
|
|
273
|
-
//
|
|
278
|
+
// 倒计时定时器(每秒仅更新 footer 行,不触发全量重绘)
|
|
274
279
|
this.countdownTimer = setInterval(() => {
|
|
275
280
|
if (this.refreshCountdown > 0) {
|
|
276
281
|
this.refreshCountdown--;
|
|
277
282
|
}
|
|
278
|
-
this.
|
|
283
|
+
this.renderFooterOnly();
|
|
279
284
|
}, PANEL_COUNTDOWN_INTERVAL_MS);
|
|
280
285
|
|
|
281
286
|
// 确保定时器不阻止进程退出
|
|
@@ -298,29 +303,35 @@ export class InteractivePanel {
|
|
|
298
303
|
}
|
|
299
304
|
|
|
300
305
|
/**
|
|
301
|
-
* 刷新数据:记录当前选中分支 →
|
|
306
|
+
* 刷新数据:记录当前选中分支 → 异步重新收集 → 恢复选中位置 → 重置倒计时 → 重绘
|
|
307
|
+
* 使用 isRefreshing 锁防止异步刷新期间触发重复刷新
|
|
302
308
|
*/
|
|
303
|
-
private refreshData(): void {
|
|
304
|
-
if (this.stopped || this.isOperating) return;
|
|
309
|
+
private async refreshData(): Promise<void> {
|
|
310
|
+
if (this.stopped || this.isOperating || this.isRefreshing) return;
|
|
305
311
|
|
|
306
|
-
|
|
307
|
-
|
|
312
|
+
this.isRefreshing = true;
|
|
313
|
+
try {
|
|
314
|
+
// 记录当前选中分支名
|
|
315
|
+
const previousBranch = this.stateManager.getSelectedBranch();
|
|
308
316
|
|
|
309
|
-
|
|
310
|
-
|
|
317
|
+
// 异步重新收集数据并更新状态
|
|
318
|
+
this.stateManager.updateData(await this.collectStatusFn(), previousBranch || undefined);
|
|
311
319
|
|
|
312
|
-
|
|
313
|
-
|
|
320
|
+
// 在重绘前必须确保滚动状态正常
|
|
321
|
+
this.stateManager.adjustScrollForSelection();
|
|
314
322
|
|
|
315
|
-
|
|
316
|
-
|
|
323
|
+
// 重置倒计时
|
|
324
|
+
this.refreshCountdown = PANEL_REFRESH_INTERVAL_MS / 1000;
|
|
317
325
|
|
|
318
|
-
|
|
326
|
+
this.render();
|
|
327
|
+
} finally {
|
|
328
|
+
this.isRefreshing = false;
|
|
329
|
+
}
|
|
319
330
|
}
|
|
320
331
|
|
|
321
332
|
/**
|
|
322
333
|
* 渲染一帧面板内容
|
|
323
|
-
*
|
|
334
|
+
* 使用同步输出防止闪烁,复用缓存的 panelLines 避免重复 groupWorktreesByDate 计算
|
|
324
335
|
*/
|
|
325
336
|
private render(): void {
|
|
326
337
|
const statusResult = this.stateManager.getStatusResult();
|
|
@@ -336,6 +347,7 @@ export class InteractivePanel {
|
|
|
336
347
|
rows,
|
|
337
348
|
cols,
|
|
338
349
|
this.refreshCountdown,
|
|
350
|
+
this.stateManager.getCachedPanelLines(),
|
|
339
351
|
);
|
|
340
352
|
|
|
341
353
|
// 同步输出开始
|
|
@@ -349,10 +361,34 @@ export class InteractivePanel {
|
|
|
349
361
|
process.stdout.write(`${truncateToTerminalWidth(frameLines[i], cols)}${suffix}`);
|
|
350
362
|
}
|
|
351
363
|
|
|
364
|
+
// 记录帧行数,供 renderFooterOnly 定位最后一行
|
|
365
|
+
this.lastFrameLineCount = frameLines.length;
|
|
366
|
+
|
|
352
367
|
// 同步输出结束
|
|
353
368
|
process.stdout.write(SYNC_OUTPUT_END);
|
|
354
369
|
}
|
|
355
370
|
|
|
371
|
+
/**
|
|
372
|
+
* 仅更新 footer 行(倒计时文本)
|
|
373
|
+
* 使用 ANSI 光标定位直接覆写最后一行,避免全量重绘
|
|
374
|
+
*/
|
|
375
|
+
private renderFooterOnly(): void {
|
|
376
|
+
if (this.stopped || this.isOperating || this.lastFrameLineCount === 0) return;
|
|
377
|
+
|
|
378
|
+
const cols = process.stdout.columns || DEFAULT_TERMINAL_COLUMNS;
|
|
379
|
+
const footerText = renderFooter(this.refreshCountdown);
|
|
380
|
+
const truncated = truncateToTerminalWidth(footerText, cols);
|
|
381
|
+
|
|
382
|
+
// 使用 ANSI 转义序列定位到最后一行并覆写
|
|
383
|
+
// \x1b[<row>;1H 移动光标到第 <row> 行第 1 列
|
|
384
|
+
// \x1b[2K 清除当前行
|
|
385
|
+
process.stdout.write(SYNC_OUTPUT_START);
|
|
386
|
+
process.stdout.write(`\x1b[${this.lastFrameLineCount};1H`);
|
|
387
|
+
process.stdout.write('\x1b[2K');
|
|
388
|
+
process.stdout.write(truncated);
|
|
389
|
+
process.stdout.write(SYNC_OUTPUT_END);
|
|
390
|
+
}
|
|
391
|
+
|
|
356
392
|
/**
|
|
357
393
|
* 执行操作:暂停面板 → 恢复终端 → 执行命令 → 等待回车 → 恢复面板
|
|
358
394
|
* @param {() => void} action - 要执行的操作
|
|
@@ -389,8 +425,8 @@ export class InteractivePanel {
|
|
|
389
425
|
|
|
390
426
|
this.isOperating = false;
|
|
391
427
|
|
|
392
|
-
//
|
|
393
|
-
this.refreshData();
|
|
428
|
+
// 异步刷新数据并重新启动自动刷新
|
|
429
|
+
await this.refreshData();
|
|
394
430
|
this.startAutoRefresh();
|
|
395
431
|
|
|
396
432
|
// 渲染
|
package/src/utils/shell.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import { execSync, execFileSync, spawn, spawnSync, type ChildProcess, type SpawnSyncReturns, type StdioOptions } from 'node:child_process';
|
|
1
|
+
import { exec, execSync, execFileSync, spawn, spawnSync, type ChildProcess, type SpawnSyncReturns, type StdioOptions } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
2
3
|
import { logger } from '../logger/index.js';
|
|
3
4
|
import { EXEC_MAX_BUFFER } from '../constants/git.js';
|
|
4
5
|
import { CLAUDE_CODE_ENTRYPOINT_VALUE } from '../constants/index.js';
|
|
5
6
|
import { throwIfGitIndexLockError, shouldRetryGitIndexLockError, waitForGitIndexLockRetrySync } from './git-lock.js';
|
|
6
7
|
|
|
8
|
+
/** promisified 版本的 exec */
|
|
9
|
+
const execPromise = promisify(exec);
|
|
10
|
+
|
|
7
11
|
/**
|
|
8
12
|
* 获取移除了 CLAUDECODE 嵌套会话标记的环境变量副本,并注入 CLAUDE_CODE_ENTRYPOINT 标识
|
|
9
13
|
* 仅用于 claude -p 等非交互式子进程:
|
|
@@ -81,6 +85,26 @@ export function execCommand(command: string, options?: { cwd?: string }): string
|
|
|
81
85
|
}
|
|
82
86
|
}
|
|
83
87
|
|
|
88
|
+
/**
|
|
89
|
+
* 异步执行 shell 命令并返回 stdout
|
|
90
|
+
* 基于 child_process.exec 的 promisified 版本,适用于只读 git 命令的并行执行场景
|
|
91
|
+
* 不包含 index.lock 重试逻辑(只读命令不触发 index.lock)
|
|
92
|
+
* @param {string} command - 要执行的命令
|
|
93
|
+
* @param {object} options - 可选配置
|
|
94
|
+
* @param {string} options.cwd - 工作目录
|
|
95
|
+
* @returns {Promise<string>} 命令的标准输出(已 trim)
|
|
96
|
+
* @throws {Error} 命令执行失败时抛出
|
|
97
|
+
*/
|
|
98
|
+
export async function execCommandAsync(command: string, options?: { cwd?: string }): Promise<string> {
|
|
99
|
+
logger.debug(`执行异步命令: ${command}${options?.cwd ? ` (cwd: ${options.cwd})` : ''}`);
|
|
100
|
+
const { stdout } = await execPromise(command, {
|
|
101
|
+
cwd: options?.cwd,
|
|
102
|
+
encoding: 'utf-8',
|
|
103
|
+
maxBuffer: EXEC_MAX_BUFFER,
|
|
104
|
+
});
|
|
105
|
+
return (stdout as string).trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
84
108
|
/**
|
|
85
109
|
* 以子进程方式异步执行命令
|
|
86
110
|
* @param {string} command - 要执行的命令
|
|
@@ -39,11 +39,10 @@ vi.mock('../../../src/utils/index.js', () => ({
|
|
|
39
39
|
getCurrentBranch: vi.fn(),
|
|
40
40
|
isWorkingDirClean: vi.fn(),
|
|
41
41
|
getProjectWorktrees: vi.fn(),
|
|
42
|
-
|
|
43
|
-
getCommitCountBehind: vi.fn(),
|
|
42
|
+
getCommitDivergenceAsync: vi.fn(),
|
|
44
43
|
getDiffStat: vi.fn(),
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
getDiffStatAsync: vi.fn(),
|
|
45
|
+
getStatusPorcelainAsync: vi.fn(),
|
|
47
46
|
getSnapshotModifiedTime: vi.fn(),
|
|
48
47
|
getProjectSnapshotBranches: vi.fn(),
|
|
49
48
|
getWorktreeCreatedTime: vi.fn(),
|
|
@@ -61,11 +60,10 @@ import {
|
|
|
61
60
|
getCurrentBranch,
|
|
62
61
|
isWorkingDirClean,
|
|
63
62
|
getProjectWorktrees,
|
|
64
|
-
|
|
65
|
-
getCommitCountBehind,
|
|
63
|
+
getCommitDivergenceAsync,
|
|
66
64
|
getDiffStat,
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
getDiffStatAsync,
|
|
66
|
+
getStatusPorcelainAsync,
|
|
69
67
|
getSnapshotModifiedTime,
|
|
70
68
|
getProjectSnapshotBranches,
|
|
71
69
|
getWorktreeCreatedTime,
|
|
@@ -77,11 +75,10 @@ const mockedGetProjectName = vi.mocked(getProjectName);
|
|
|
77
75
|
const mockedGetCurrentBranch = vi.mocked(getCurrentBranch);
|
|
78
76
|
const mockedIsWorkingDirClean = vi.mocked(isWorkingDirClean);
|
|
79
77
|
const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
|
|
80
|
-
const
|
|
81
|
-
const mockedGetCommitCountBehind = vi.mocked(getCommitCountBehind);
|
|
78
|
+
const mockedGetCommitDivergenceAsync = vi.mocked(getCommitDivergenceAsync);
|
|
82
79
|
const mockedGetDiffStat = vi.mocked(getDiffStat);
|
|
83
|
-
const
|
|
84
|
-
const
|
|
80
|
+
const mockedGetDiffStatAsync = vi.mocked(getDiffStatAsync);
|
|
81
|
+
const mockedGetStatusPorcelainAsync = vi.mocked(getStatusPorcelainAsync);
|
|
85
82
|
const mockedGetSnapshotModifiedTime = vi.mocked(getSnapshotModifiedTime);
|
|
86
83
|
const mockedGetProjectSnapshotBranches = vi.mocked(getProjectSnapshotBranches);
|
|
87
84
|
const mockedGetWorktreeCreatedTime = vi.mocked(getWorktreeCreatedTime);
|
|
@@ -94,11 +91,10 @@ beforeEach(() => {
|
|
|
94
91
|
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
95
92
|
mockedGetProjectWorktrees.mockReturnValue([]);
|
|
96
93
|
mockedGetProjectSnapshotBranches.mockReturnValue([]);
|
|
97
|
-
|
|
98
|
-
mockedGetCommitCountBehind.mockReturnValue(0);
|
|
94
|
+
mockedGetCommitDivergenceAsync.mockResolvedValue({ ahead: 0, behind: 0 });
|
|
99
95
|
mockedGetDiffStat.mockReturnValue({ insertions: 0, deletions: 0 });
|
|
100
|
-
|
|
101
|
-
|
|
96
|
+
mockedGetDiffStatAsync.mockResolvedValue({ insertions: 0, deletions: 0 });
|
|
97
|
+
mockedGetStatusPorcelainAsync.mockResolvedValue('');
|
|
102
98
|
mockedGetSnapshotModifiedTime.mockReturnValue(null);
|
|
103
99
|
mockedGetWorktreeCreatedTime.mockReturnValue(null);
|
|
104
100
|
mockedFormatRelativeTime.mockReturnValue('3 天前');
|
|
@@ -128,9 +124,8 @@ describe('handleStatus', () => {
|
|
|
128
124
|
mockedGetProjectWorktrees.mockReturnValue([
|
|
129
125
|
{ path: '/path/feature', branch: 'feature' },
|
|
130
126
|
]);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
mockedHasLocalCommits.mockReturnValue(true);
|
|
127
|
+
mockedGetCommitDivergenceAsync.mockResolvedValue({ ahead: 2, behind: 0 });
|
|
128
|
+
mockedGetDiffStatAsync.mockResolvedValue({ insertions: 10, deletions: 5 });
|
|
134
129
|
|
|
135
130
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
136
131
|
|
|
@@ -155,8 +150,8 @@ describe('handleStatus', () => {
|
|
|
155
150
|
mockedGetProjectWorktrees.mockReturnValue([
|
|
156
151
|
{ path: '/path/feature', branch: 'feature' },
|
|
157
152
|
]);
|
|
158
|
-
//
|
|
159
|
-
|
|
153
|
+
// 模拟冲突状态:porcelain 输出包含 UU 前缀的行
|
|
154
|
+
mockedGetStatusPorcelainAsync.mockResolvedValue('UU src/conflict-file.ts');
|
|
160
155
|
|
|
161
156
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
162
157
|
|
|
@@ -214,10 +209,8 @@ describe('handleStatus', () => {
|
|
|
214
209
|
mockedGetProjectWorktrees.mockReturnValue([
|
|
215
210
|
{ path: '/path/feature', branch: 'feature' },
|
|
216
211
|
]);
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
.mockReturnValueOnce(true) // 主 worktree
|
|
220
|
-
.mockReturnValueOnce(false); // 目标 worktree 不干净
|
|
212
|
+
// 模拟未提交修改:porcelain 输出包含修改但非冲突的行
|
|
213
|
+
mockedGetStatusPorcelainAsync.mockResolvedValue(' M src/file.ts'); // 目标 worktree 有未提交修改
|
|
221
214
|
|
|
222
215
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
223
216
|
|