clawt 3.9.6 → 3.9.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/README.md +2 -2
- package/dist/index.js +275 -160
- package/dist/postinstall.js +8 -2
- package/docs/config-file.md +1 -1
- package/docs/init.md +3 -2
- package/docs/project-config.md +11 -2
- package/docs/tasks.md +1 -1
- package/package.json +1 -1
- package/src/commands/init.ts +3 -1
- package/src/commands/status.ts +73 -41
- package/src/constants/messages/tasks.ts +3 -2
- package/src/constants/project-config.ts +4 -0
- package/src/constants/tasks-template.ts +1 -1
- package/src/types/projectConfig.ts +2 -0
- package/src/utils/claude.ts +3 -3
- package/src/utils/git-branch.ts +46 -1
- package/src/utils/git-core.ts +22 -1
- package/src/utils/index.ts +6 -2
- 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/project-config.ts +37 -2
- package/src/utils/shell.ts +25 -1
- package/tests/unit/commands/init.test.ts +1 -0
- package/tests/unit/commands/status.test.ts +18 -25
- package/tests/unit/utils/project-config.test.ts +50 -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
|
// 渲染
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { PROJECTS_CONFIG_DIR, MESSAGES } from '../constants/index.js';
|
|
3
|
+
import { PROJECTS_CONFIG_DIR, MESSAGES, PROJECT_CONFIG_DEFINITIONS } from '../constants/index.js';
|
|
4
4
|
import { ClawtError } from '../errors/index.js';
|
|
5
5
|
import { logger } from '../logger/index.js';
|
|
6
6
|
import { getProjectName, checkBranchExists } from './git.js';
|
|
7
7
|
import { ensureDir } from './fs.js';
|
|
8
|
+
import { getConfigValue } from './config.js';
|
|
9
|
+
import { safeStringify } from './json.js';
|
|
8
10
|
import type { ProjectConfig } from '../types/index.js';
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -47,7 +49,7 @@ export function saveProjectConfig(config: ProjectConfig): void {
|
|
|
47
49
|
// 确保项目子目录存在
|
|
48
50
|
const projectDir = join(PROJECTS_CONFIG_DIR, projectName);
|
|
49
51
|
ensureDir(projectDir);
|
|
50
|
-
writeFileSync(configPath,
|
|
52
|
+
writeFileSync(configPath, safeStringify({ ...config }, 2), 'utf-8');
|
|
51
53
|
logger.info(`项目配置已保存: ${configPath}`);
|
|
52
54
|
}
|
|
53
55
|
|
|
@@ -99,3 +101,36 @@ export function getValidateRunCommand(): string | undefined {
|
|
|
99
101
|
const config = loadProjectConfig();
|
|
100
102
|
return config?.validateRunCommand || undefined;
|
|
101
103
|
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 解析当前项目生效的 Claude Code 启动指令
|
|
107
|
+
* 优先级:项目级配置 > 全局配置
|
|
108
|
+
* @returns {string} 生效的 Claude Code 启动指令
|
|
109
|
+
*/
|
|
110
|
+
export function resolveClaudeCodeCommand(): string {
|
|
111
|
+
const projectConfig = loadProjectConfig();
|
|
112
|
+
if (projectConfig?.claudeCodeCommand) {
|
|
113
|
+
return projectConfig.claudeCodeCommand;
|
|
114
|
+
}
|
|
115
|
+
return getConfigValue('claudeCodeCommand');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 归一化项目配置:可选字段的空字符串等同于未设置,从对象中删除该键
|
|
120
|
+
* 保持 JSON 文件整洁,避免出现 "field": "" 的冗余条目
|
|
121
|
+
* @param {ProjectConfig} config - 原始项目配置
|
|
122
|
+
* @param {string} key - 被修改的配置项键名
|
|
123
|
+
* @param {unknown} value - 被修改的配置项新值
|
|
124
|
+
* @returns {ProjectConfig} 归一化后的项目配置
|
|
125
|
+
*/
|
|
126
|
+
export function normalizeProjectConfig(config: ProjectConfig, key: string, value: unknown): ProjectConfig {
|
|
127
|
+
if (value === '') {
|
|
128
|
+
const def = PROJECT_CONFIG_DEFINITIONS[key as keyof typeof PROJECT_CONFIG_DEFINITIONS];
|
|
129
|
+
if (def?.defaultValue === undefined) {
|
|
130
|
+
const normalized = { ...config };
|
|
131
|
+
delete (normalized as unknown as Record<string, unknown>)[key];
|
|
132
|
+
return normalized;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return config;
|
|
136
|
+
}
|
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 - 要执行的命令
|
|
@@ -34,6 +34,7 @@ vi.mock('../../../src/utils/index.js', () => ({
|
|
|
34
34
|
interactiveConfigEditor: vi.fn(),
|
|
35
35
|
guardMainWorkBranch: vi.fn().mockResolvedValue(undefined),
|
|
36
36
|
guardMainWorkBranchExists: vi.fn(),
|
|
37
|
+
normalizeProjectConfig: vi.fn((config: unknown) => config),
|
|
37
38
|
}));
|
|
38
39
|
|
|
39
40
|
import { registerInitCommand } from '../../../src/commands/init.js';
|
|
@@ -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
|
|
|
@@ -42,6 +42,16 @@ vi.mock('../../../src/utils/fs.js', () => ({
|
|
|
42
42
|
ensureDir: vi.fn(),
|
|
43
43
|
}));
|
|
44
44
|
|
|
45
|
+
// mock config
|
|
46
|
+
vi.mock('../../../src/utils/config.js', () => ({
|
|
47
|
+
getConfigValue: vi.fn().mockReturnValue('claude'),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
// mock json
|
|
51
|
+
vi.mock('../../../src/utils/json.js', () => ({
|
|
52
|
+
safeStringify: (value: unknown, indent: number = 2) => JSON.stringify(value, null, indent),
|
|
53
|
+
}));
|
|
54
|
+
|
|
45
55
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
46
56
|
import {
|
|
47
57
|
getProjectConfigPath,
|
|
@@ -50,7 +60,9 @@ import {
|
|
|
50
60
|
requireProjectConfig,
|
|
51
61
|
getMainWorkBranch,
|
|
52
62
|
getValidateRunCommand,
|
|
63
|
+
resolveClaudeCodeCommand,
|
|
53
64
|
} from '../../../src/utils/project-config.js';
|
|
65
|
+
import { getConfigValue } from '../../../src/utils/config.js';
|
|
54
66
|
|
|
55
67
|
const mockedExistsSync = vi.mocked(existsSync);
|
|
56
68
|
const mockedReadFileSync = vi.mocked(readFileSync);
|
|
@@ -166,3 +178,41 @@ describe('getValidateRunCommand', () => {
|
|
|
166
178
|
expect(getValidateRunCommand()).toBeUndefined();
|
|
167
179
|
});
|
|
168
180
|
});
|
|
181
|
+
|
|
182
|
+
describe('resolveClaudeCodeCommand', () => {
|
|
183
|
+
const mockedGetConfigValue = vi.mocked(getConfigValue);
|
|
184
|
+
|
|
185
|
+
beforeEach(() => {
|
|
186
|
+
mockedGetConfigValue.mockReset();
|
|
187
|
+
mockedGetConfigValue.mockReturnValue('claude');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('项目配置中有 claudeCodeCommand 时返回项目级值', () => {
|
|
191
|
+
mockedExistsSync.mockReturnValue(true);
|
|
192
|
+
mockedReadFileSync.mockReturnValue(JSON.stringify({
|
|
193
|
+
clawtMainWorkBranch: 'main',
|
|
194
|
+
claudeCodeCommand: 'claude --model opus',
|
|
195
|
+
}));
|
|
196
|
+
expect(resolveClaudeCodeCommand()).toBe('claude --model opus');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('项目配置中无 claudeCodeCommand 时回退到全局配置', () => {
|
|
200
|
+
mockedExistsSync.mockReturnValue(true);
|
|
201
|
+
mockedReadFileSync.mockReturnValue(JSON.stringify({ clawtMainWorkBranch: 'main' }));
|
|
202
|
+
expect(resolveClaudeCodeCommand()).toBe('claude');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('项目配置中 claudeCodeCommand 为空字符串时回退到全局配置', () => {
|
|
206
|
+
mockedExistsSync.mockReturnValue(true);
|
|
207
|
+
mockedReadFileSync.mockReturnValue(JSON.stringify({
|
|
208
|
+
clawtMainWorkBranch: 'main',
|
|
209
|
+
claudeCodeCommand: '',
|
|
210
|
+
}));
|
|
211
|
+
expect(resolveClaudeCodeCommand()).toBe('claude');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('项目配置文件不存在时回退到全局配置', () => {
|
|
215
|
+
mockedExistsSync.mockReturnValue(false);
|
|
216
|
+
expect(resolveClaudeCodeCommand()).toBe('claude');
|
|
217
|
+
});
|
|
218
|
+
});
|