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.
@@ -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 = buildGroupedWorktreeLines(this.statusResult.worktrees, originalIndex);
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 Promise.resolve();
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.render();
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
- const previousBranch = this.stateManager.getSelectedBranch();
312
+ this.isRefreshing = true;
313
+ try {
314
+ // 记录当前选中分支名
315
+ const previousBranch = this.stateManager.getSelectedBranch();
308
316
 
309
- // 重新收集数据并更新状态
310
- this.stateManager.updateData(this.collectStatusFn(), previousBranch || undefined);
317
+ // 异步重新收集数据并更新状态
318
+ this.stateManager.updateData(await this.collectStatusFn(), previousBranch || undefined);
311
319
 
312
- // 在重绘前必须确保滚动状态正常
313
- this.stateManager.adjustScrollForSelection();
320
+ // 在重绘前必须确保滚动状态正常
321
+ this.stateManager.adjustScrollForSelection();
314
322
 
315
- // 重置倒计时
316
- this.refreshCountdown = PANEL_REFRESH_INTERVAL_MS / 1000;
323
+ // 重置倒计时
324
+ this.refreshCountdown = PANEL_REFRESH_INTERVAL_MS / 1000;
317
325
 
318
- this.render();
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, JSON.stringify(config, null, 2), 'utf-8');
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
+ }
@@ -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
- getCommitCountAhead: vi.fn(),
43
- getCommitCountBehind: vi.fn(),
42
+ getCommitDivergenceAsync: vi.fn(),
44
43
  getDiffStat: vi.fn(),
45
- hasMergeConflict: vi.fn(),
46
- hasLocalCommits: vi.fn(),
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
- getCommitCountAhead,
65
- getCommitCountBehind,
63
+ getCommitDivergenceAsync,
66
64
  getDiffStat,
67
- hasMergeConflict,
68
- hasLocalCommits,
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 mockedGetCommitCountAhead = vi.mocked(getCommitCountAhead);
81
- const mockedGetCommitCountBehind = vi.mocked(getCommitCountBehind);
78
+ const mockedGetCommitDivergenceAsync = vi.mocked(getCommitDivergenceAsync);
82
79
  const mockedGetDiffStat = vi.mocked(getDiffStat);
83
- const mockedHasMergeConflict = vi.mocked(hasMergeConflict);
84
- const mockedHasLocalCommits = vi.mocked(hasLocalCommits);
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
- mockedGetCommitCountAhead.mockReturnValue(0);
98
- mockedGetCommitCountBehind.mockReturnValue(0);
94
+ mockedGetCommitDivergenceAsync.mockResolvedValue({ ahead: 0, behind: 0 });
99
95
  mockedGetDiffStat.mockReturnValue({ insertions: 0, deletions: 0 });
100
- mockedHasMergeConflict.mockReturnValue(false);
101
- mockedHasLocalCommits.mockReturnValue(false);
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
- mockedGetCommitCountAhead.mockReturnValue(2);
132
- mockedGetDiffStat.mockReturnValue({ insertions: 10, deletions: 5 });
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
- mockedHasMergeConflict.mockReturnValue(true);
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
- mockedHasMergeConflict.mockReturnValue(false);
218
- mockedIsWorkingDirClean
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
+ });