clawt 2.19.0 → 2.20.0

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.
@@ -487,6 +487,10 @@ var CONFIG_DESCRIPTIONS = deriveConfigDescriptions(CONFIG_DEFINITIONS);
487
487
  // src/constants/update.ts
488
488
  var UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
489
489
 
490
+ // src/constants/prompt.ts
491
+ import chalk from "chalk";
492
+ var UNKNOWN_DATE_SEPARATOR_LABEL = `\u2550\u2550\u2550\u2550 ${chalk.bold.hex("#FF8C00")("\u672A\u77E5\u65E5\u671F")} \u2550\u2550\u2550\u2550`;
493
+
490
494
  // scripts/postinstall.ts
491
495
  function ensureDirectory(dirPath) {
492
496
  if (!existsSync(dirPath)) {
package/docs/spec.md CHANGED
@@ -1346,13 +1346,13 @@ clawt resume
1346
1346
 
1347
1347
  1. **主 worktree 校验** (2.1)
1348
1348
  2. **Claude Code CLI 校验**:确认 `claude` CLI 可用
1349
- 3. **解析目标 worktree(多选模式)**:统一使用 `resolveTargetWorktrees`(多选版本)解析目标 worktree,匹配策略如下:
1349
+ 3. **解析目标 worktree**:根据是否传入 `-b` 参数以及 worktree 数量,采用不同的解析策略:
1350
1350
  - **未传 `-b` 参数**:
1351
1351
  - 获取当前项目所有 worktree
1352
1352
  - 无可用 worktree → 报错退出
1353
- - 仅 1 个 worktree → 直接使用,无需选择
1354
- - 多个 worktree → 通过交互式多选列表(Enquirer.MultiSelect)让用户选择(空格选择,回车确认),顶部提供「全选」选项
1355
- - **传了 `-b` 参数**:
1353
+ - 仅 1 个 worktree → 通过 `resolveTargetWorktrees` 直接使用,无需选择
1354
+ - 多个 worktree → 通过 `promptGroupedMultiSelectBranches` 展示**按日期分组的交互式多选列表**(详见下文「按日期分组多选」)
1355
+ - **传了 `-b` 参数**:通过 `resolveTargetWorktrees` 解析,匹配策略如下:
1356
1356
  1. **精确匹配优先**:在 worktree 列表中查找分支名完全相同的 worktree,找到则直接使用
1357
1357
  2. **模糊匹配**(子串匹配,大小写不敏感):
1358
1358
  - 唯一匹配 → 直接使用
@@ -1387,6 +1387,32 @@ clawt resume
1387
1387
 
1388
1388
  启动命令通过配置项 `claudeCodeCommand`(默认值 `claude`)指定,与 `clawt run` 不传 `--tasks` 时的交互式界面行为一致。
1389
1389
 
1390
+ **按日期分组多选:**
1391
+
1392
+ 当未传 `-b` 且有多个 worktree 时,使用 `promptGroupedMultiSelectBranches` 展示按创建日期分组的交互式多选列表,实现流程如下:
1393
+
1394
+ 1. **日期分组**(`groupWorktreesByDate`):通过 `statSync` 获取各 worktree 目录的文件系统创建时间(`birthtime`),按本地时区格式化为 `YYYY-MM-DD` 作为分组键。无法获取创建时间的分支归入「未知日期」组。分组按日期降序排列,未知日期组在最后。
1395
+ 2. **构建选项列表**(`buildGroupedChoices`):生成包含以下元素的 Enquirer MultiSelect choices 数组:
1396
+ - 顶部:全局全选选项 `[select-all]`
1397
+ - 每组:日期分隔线(显示日期和相对时间,如「2026-02-26(昨天)」)→ 组级全选选项 `[select-all: YYYY-MM-DD]` → 该组内各分支
1398
+ 3. **三级联动选择**:通过继承 Enquirer MultiSelect 并覆写 `space()` 方法实现:
1399
+ - **全局全选**:toggle 所有 choices(含组全选)
1400
+ - **组级全选**:toggle 该组内所有分支,并同步全局全选状态
1401
+ - **普通分支**:toggle 该分支,同步所属组全选和全局全选状态
1402
+ 4. **过滤结果**:返回时过滤掉全选项和组全选项,只返回实际选中的 worktree
1403
+
1404
+ 相对日期显示规则:`formatRelativeDate` 基于自然日差值计算——今天 / 昨天 / N 天前 / N 个月前 / N 年前。
1405
+
1406
+ 相关常量定义在 `src/constants/prompt.ts`:
1407
+
1408
+ | 常量 | 说明 |
1409
+ | ---- | ---- |
1410
+ | `GROUP_SELECT_ALL_PREFIX` | 组级全选选项的 name 前缀(`__group_select_all_`) |
1411
+ | `GROUP_SELECT_ALL_LABEL(dateLabel)` | 生成组级全选选项的显示文本 |
1412
+ | `GROUP_SEPARATOR_LABEL(dateLabel, relativeTime)` | 生成日期分隔线的显示文本(含 chalk 高亮) |
1413
+ | `UNKNOWN_DATE_GROUP` | 无法获取创建日期时的默认分组名称(`未知日期`) |
1414
+ | `UNKNOWN_DATE_SEPARATOR_LABEL` | 未知日期分组的分隔线显示文本 |
1415
+
1390
1416
  **会话自动续接:** 启动前会自动检测该 worktree 是否存在 Claude Code 历史会话(通过检查 `~/.claude/projects/<encoded-path>/` 下是否有 `.jsonl` 文件判断),如果存在则自动追加 `--continue` 参数继续上次对话,否则打开新对话。启动信息中会显示当前模式("继续上次对话"或"新对话")。路径编码规则:将绝对路径中所有非字母数字字符替换为 `-`(与 Claude Code 源码的编码逻辑一致)。
1391
1417
 
1392
1418
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "2.19.0",
3
+ "version": "2.20.0",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -11,6 +11,7 @@ import {
11
11
  launchInteractiveClaudeInNewTerminal,
12
12
  hasClaudeSessionHistory,
13
13
  resolveTargetWorktrees,
14
+ promptGroupedMultiSelectBranches,
14
15
  printInfo,
15
16
  printSuccess,
16
17
  confirmAction,
@@ -51,8 +52,13 @@ async function handleResume(options: ResumeOptions): Promise<void> {
51
52
  logger.info(`resume 命令执行,分支过滤: ${options.branch ?? '(无)'}`);
52
53
  const worktrees = getProjectWorktrees();
53
54
 
54
- // 统一走多选解析(精确匹配 / 模糊匹配 / 交互多选)
55
- const targetWorktrees = await resolveTargetWorktrees(worktrees, RESUME_RESOLVE_MESSAGES, options.branch);
55
+ // 未指定 -b 且有多个 worktree 时,默认使用按日期分组的多选交互
56
+ let targetWorktrees: WorktreeInfo[];
57
+ if (!options.branch && worktrees.length > 1) {
58
+ targetWorktrees = await promptGroupedMultiSelectBranches(worktrees, RESUME_RESOLVE_MESSAGES.selectBranch);
59
+ } else {
60
+ targetWorktrees = await resolveTargetWorktrees(worktrees, RESUME_RESOLVE_MESSAGES, options.branch);
61
+ }
56
62
 
57
63
  // 用户未选择任何分支时直接退出
58
64
  if (targetWorktrees.length === 0) {
@@ -29,4 +29,4 @@ export {
29
29
  CLEAR_SCREEN,
30
30
  CURSOR_HOME,
31
31
  } from './progress.js';
32
- export { SELECT_ALL_NAME, SELECT_ALL_LABEL } from './prompt.js';
32
+ export { SELECT_ALL_NAME, SELECT_ALL_LABEL, GROUP_SELECT_ALL_PREFIX, GROUP_SELECT_ALL_LABEL, GROUP_SEPARATOR_LABEL, UNKNOWN_DATE_GROUP, UNKNOWN_DATE_SEPARATOR_LABEL } from './prompt.js';
@@ -1,5 +1,33 @@
1
+ import chalk from 'chalk';
2
+
1
3
  /** 多选列表中全选选项的标识名称 */
2
4
  export const SELECT_ALL_NAME = '__select_all__';
3
5
 
4
6
  /** 多选列表中全选选项的显示文本 */
5
7
  export const SELECT_ALL_LABEL = '[select-all]';
8
+
9
+ /** 组级全选选项的 name 前缀 */
10
+ export const GROUP_SELECT_ALL_PREFIX = '__group_select_all_';
11
+
12
+ /**
13
+ * 生成组级全选选项的显示文本
14
+ * @param {string} dateLabel - 日期标签
15
+ * @returns {string} 格式化的组全选显示文本
16
+ */
17
+ export const GROUP_SELECT_ALL_LABEL = (dateLabel: string): string => `[select-all: ${dateLabel}]`;
18
+
19
+ /**
20
+ * 生成日期分组分隔线的显示文本
21
+ * 日期部分高亮显示,两侧使用 === 分隔线增强视觉区分
22
+ * @param {string} dateLabel - 日期标签
23
+ * @param {string} relativeTime - 相对时间描述
24
+ * @returns {string} 格式化的分隔线文本
25
+ */
26
+ export const GROUP_SEPARATOR_LABEL = (dateLabel: string, relativeTime: string): string =>
27
+ `════ ${chalk.bold.hex('#FF8C00')(dateLabel)}(${chalk.hex('#FF8C00')(relativeTime)}) ════`;
28
+
29
+ /** 无法获取创建日期时的默认分组名称 */
30
+ export const UNKNOWN_DATE_GROUP = '未知日期';
31
+
32
+ /** 未知日期分组的分隔线显示文本 */
33
+ export const UNKNOWN_DATE_SEPARATOR_LABEL = `════ ${chalk.bold.hex('#FF8C00')('未知日期')} ════`;
@@ -56,7 +56,7 @@ export { ensureDir, removeEmptyDir, calculateDirSize } from './fs.js';
56
56
  export { multilineInput } from './prompt.js';
57
57
  export { launchInteractiveClaude, hasClaudeSessionHistory, launchInteractiveClaudeInNewTerminal } from './claude.js';
58
58
  export { getSnapshotPath, hasSnapshot, getSnapshotModifiedTime, readSnapshotTreeHash, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots, getProjectSnapshotBranches } from './validate-snapshot.js';
59
- export { findExactMatch, findFuzzyMatches, promptSelectBranch, promptMultiSelectBranches, resolveTargetWorktree, resolveTargetWorktrees } from './worktree-matcher.js';
59
+ export { findExactMatch, findFuzzyMatches, promptSelectBranch, promptMultiSelectBranches, promptGroupedMultiSelectBranches, resolveTargetWorktree, resolveTargetWorktrees, groupWorktreesByDate, buildGroupedChoices, buildGroupMembershipMap } from './worktree-matcher.js';
60
60
  export type { WorktreeResolveMessages, WorktreeMultiResolveMessages } from './worktree-matcher.js';
61
61
  export { ProgressRenderer } from './progress.js';
62
62
  export { parseTaskFile, loadTaskFile, parseTasksFromOptions } from './task-file.js';
@@ -1,6 +1,15 @@
1
1
  import Enquirer from 'enquirer';
2
+ import { statSync } from 'node:fs';
2
3
  import { ClawtError } from '../errors/index.js';
3
- import { SELECT_ALL_NAME, SELECT_ALL_LABEL } from '../constants/index.js';
4
+ import {
5
+ SELECT_ALL_NAME,
6
+ SELECT_ALL_LABEL,
7
+ GROUP_SELECT_ALL_PREFIX,
8
+ GROUP_SELECT_ALL_LABEL,
9
+ GROUP_SEPARATOR_LABEL,
10
+ UNKNOWN_DATE_GROUP,
11
+ UNKNOWN_DATE_SEPARATOR_LABEL,
12
+ } from '../constants/index.js';
4
13
  import type { WorktreeInfo } from '../types/index.js';
5
14
 
6
15
  /** enquirer MultiSelect 选项条目的运行时结构 */
@@ -264,3 +273,261 @@ export async function resolveTargetWorktree(
264
273
  const allBranches = worktrees.map((wt) => wt.branch);
265
274
  throw new ClawtError(messages.noMatch(branchName, allBranches));
266
275
  }
276
+
277
+ /** enquirer MultiSelect 分隔线条目结构 */
278
+ interface MultiSelectSeparator {
279
+ role: 'separator';
280
+ message: string;
281
+ }
282
+
283
+ /** enquirer MultiSelect choices 数组的条目类型 */
284
+ type GroupedChoice = { name: string; message: string } | MultiSelectSeparator;
285
+
286
+ /**
287
+ * 将 Date 对象格式化为本地时区的 YYYY-MM-DD 字符串
288
+ * @param {Date} date - 日期对象
289
+ * @returns {string} YYYY-MM-DD 格式的本地日期字符串
290
+ */
291
+ function formatLocalDate(date: Date): string {
292
+ const year = date.getFullYear();
293
+ const month = String(date.getMonth() + 1).padStart(2, '0');
294
+ const day = String(date.getDate()).padStart(2, '0');
295
+ return `${year}-${month}-${day}`;
296
+ }
297
+
298
+ /**
299
+ * 获取 worktree 目录的创建日期(本地时区)
300
+ * 通过文件系统的 birthtime 获取目录实际创建时间,比 git reflog 更准确
301
+ * @param {string} dirPath - worktree 目录路径
302
+ * @returns {string | null} YYYY-MM-DD 格式的本地日期字符串,无法获取时返回 null
303
+ */
304
+ function getWorktreeCreatedDate(dirPath: string): string | null {
305
+ try {
306
+ const stat = statSync(dirPath);
307
+ return formatLocalDate(stat.birthtime);
308
+ } catch {
309
+ return null;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * 将 YYYY-MM-DD 日期字符串格式化为中文相对日期描述
315
+ * 基于自然日计算,适用于日期分组场景
316
+ * @param {string} dateStr - YYYY-MM-DD 格式的日期字符串
317
+ * @returns {string} 中文相对日期描述,如"今天"、"昨天"、"3 天前"
318
+ */
319
+ function formatRelativeDate(dateStr: string): string {
320
+ const today = formatLocalDate(new Date());
321
+ const todayMs = new Date(today).getTime();
322
+ const targetMs = new Date(dateStr).getTime();
323
+ const diffDays = Math.round((todayMs - targetMs) / (1000 * 60 * 60 * 24));
324
+
325
+ if (diffDays === 0) return '今天';
326
+ if (diffDays === 1) return '昨天';
327
+ if (diffDays < 30) return `${diffDays} 天前`;
328
+ if (diffDays < 365) {
329
+ const months = Math.floor(diffDays / 30);
330
+ return `${months} 个月前`;
331
+ }
332
+ const years = Math.floor(diffDays / 365);
333
+ return `${years} 年前`;
334
+ }
335
+
336
+ /**
337
+ * 按创建日期对 worktree 列表进行分组
338
+ * 通过 worktree 目录的文件系统创建时间进行分组
339
+ * 无法获取日期的分支归入"未知日期"组
340
+ * @param {WorktreeInfo[]} worktrees - worktree 列表
341
+ * @returns {Map<string, WorktreeInfo[]>} 日期 → worktree 列表的映射,按日期降序排列,未知日期在最后
342
+ */
343
+ export function groupWorktreesByDate(worktrees: WorktreeInfo[]): Map<string, WorktreeInfo[]> {
344
+ const groups = new Map<string, WorktreeInfo[]>();
345
+
346
+ for (const wt of worktrees) {
347
+ const dateKey = getWorktreeCreatedDate(wt.path) ?? UNKNOWN_DATE_GROUP;
348
+
349
+ if (!groups.has(dateKey)) {
350
+ groups.set(dateKey, []);
351
+ }
352
+ groups.get(dateKey)!.push(wt);
353
+ }
354
+
355
+ // 按日期降序排列,未知日期放最后
356
+ const sortedEntries = [...groups.entries()].sort((a, b) => {
357
+ if (a[0] === UNKNOWN_DATE_GROUP) return 1;
358
+ if (b[0] === UNKNOWN_DATE_GROUP) return -1;
359
+ return b[0].localeCompare(a[0]);
360
+ });
361
+
362
+ return new Map(sortedEntries);
363
+ }
364
+
365
+ /**
366
+ * 根据分组数据构建 enquirer MultiSelect 的 choices 数组
367
+ * 包含全局全选、每组的分隔线和组全选、以及各组内的分支选项
368
+ * @param {Map<string, WorktreeInfo[]>} groups - 日期分组数据
369
+ * @returns {GroupedChoice[]} enquirer choices 数组
370
+ */
371
+ export function buildGroupedChoices(groups: Map<string, WorktreeInfo[]>): GroupedChoice[] {
372
+ const choices: GroupedChoice[] = [];
373
+
374
+ // 顶部插入全局全选
375
+ choices.push({ name: SELECT_ALL_NAME, message: SELECT_ALL_LABEL });
376
+
377
+ for (const [dateKey, worktreeList] of groups) {
378
+ // 分隔线
379
+ if (dateKey === UNKNOWN_DATE_GROUP) {
380
+ choices.push({ role: 'separator', message: UNKNOWN_DATE_SEPARATOR_LABEL });
381
+ } else {
382
+ const relativeTime = formatRelativeDate(dateKey);
383
+ choices.push({ role: 'separator', message: GROUP_SEPARATOR_LABEL(dateKey, relativeTime) });
384
+ }
385
+
386
+ // 组级全选
387
+ const groupSelectAllName = `${GROUP_SELECT_ALL_PREFIX}${dateKey}`;
388
+ choices.push({ name: groupSelectAllName, message: GROUP_SELECT_ALL_LABEL(dateKey) });
389
+
390
+ // 该组内各分支
391
+ for (const wt of worktreeList) {
392
+ choices.push({ name: wt.branch, message: wt.branch });
393
+ }
394
+ }
395
+
396
+ return choices;
397
+ }
398
+
399
+ /**
400
+ * 构建组全选 name 到该组分支 name 列表的映射
401
+ * 用于 space() 方法中快速查找某个组全选项对应的所有分支
402
+ * @param {Map<string, WorktreeInfo[]>} groups - 日期分组数据
403
+ * @returns {Map<string, string[]>} 组全选 name → 分支 name 列表的映射
404
+ */
405
+ export function buildGroupMembershipMap(groups: Map<string, WorktreeInfo[]>): Map<string, string[]> {
406
+ const map = new Map<string, string[]>();
407
+
408
+ for (const [dateKey, worktreeList] of groups) {
409
+ const groupSelectAllName = `${GROUP_SELECT_ALL_PREFIX}${dateKey}`;
410
+ map.set(groupSelectAllName, worktreeList.map((wt) => wt.branch));
411
+ }
412
+
413
+ return map;
414
+ }
415
+
416
+ /**
417
+ * 通过交互式多选列表(按日期分组)让用户选择多个分支
418
+ * 提供三级联动:全局全选、组级全选、单个分支
419
+ * @param {WorktreeInfo[]} worktrees - 可供选择的 worktree 列表
420
+ * @param {string} message - 选择提示信息
421
+ * @returns {Promise<WorktreeInfo[]>} 用户选择的 worktree 列表
422
+ */
423
+ export async function promptGroupedMultiSelectBranches(
424
+ worktrees: WorktreeInfo[],
425
+ message: string,
426
+ ): Promise<WorktreeInfo[]> {
427
+ const groups = groupWorktreesByDate(worktrees);
428
+ const choices = buildGroupedChoices(groups);
429
+ const groupMembershipMap = buildGroupMembershipMap(groups);
430
+
431
+ // 收集所有组全选的 name,用于判断某个 choice 是否为组全选项
432
+ const groupSelectAllNames = new Set(groupMembershipMap.keys());
433
+
434
+ // 收集所有实际分支的 name
435
+ const allBranchNames = new Set(worktrees.map((wt) => wt.branch));
436
+
437
+ // @ts-expect-error enquirer 类型声明未导出 MultiSelect 类,但运行时存在
438
+ const MultiSelect: new (options: Record<string, unknown>) => MultiSelectInstance = Enquirer.MultiSelect;
439
+
440
+ /**
441
+ * 扩展 MultiSelect,实现三级联动的 space() 覆写
442
+ * - 全局全选:切换所有 choices(含组全选)
443
+ * - 组级全选:切换该组内所有分支,同步全局全选状态
444
+ * - 普通分支:toggle 该分支,同步所属组全选和全局全选状态
445
+ */
446
+ class MultiSelectWithGroupSelectAll extends MultiSelect {
447
+ space(this: MultiSelectInstance) {
448
+ if (!this.focused) return;
449
+
450
+ const focusedName = this.focused.name;
451
+
452
+ if (focusedName === SELECT_ALL_NAME) {
453
+ // 全局全选:切换所有 choices
454
+ const willEnable = !this.focused.enabled;
455
+ for (const ch of this.choices) {
456
+ ch.enabled = willEnable;
457
+ }
458
+ return this.render();
459
+ }
460
+
461
+ if (groupSelectAllNames.has(focusedName)) {
462
+ // 组级全选:切换该组内所有分支
463
+ const willEnable = !this.focused.enabled;
464
+ const memberNames = groupMembershipMap.get(focusedName)!;
465
+ // 切换组全选自身
466
+ this.focused.enabled = willEnable;
467
+ // 切换该组的所有分支
468
+ for (const ch of this.choices) {
469
+ if (memberNames.includes(ch.name)) {
470
+ ch.enabled = willEnable;
471
+ }
472
+ }
473
+ // 同步全局全选状态:检查所有实际分支是否全选
474
+ syncGlobalSelectAll(this.choices);
475
+ return this.render();
476
+ }
477
+
478
+ // 普通分支:toggle 该分支
479
+ this.toggle(this.focused);
480
+
481
+ // 同步所属组全选状态
482
+ syncGroupSelectAll(this.choices, focusedName);
483
+ // 同步全局全选状态
484
+ syncGlobalSelectAll(this.choices);
485
+
486
+ return this.render();
487
+ }
488
+ }
489
+
490
+ /**
491
+ * 同步全局全选状态
492
+ * 根据所有实际分支的选中状态更新全局全选项
493
+ * @param {MultiSelectChoice[]} choiceList - choices 列表
494
+ */
495
+ function syncGlobalSelectAll(choiceList: MultiSelectChoice[]): void {
496
+ const selectAllChoice = choiceList.find((ch) => ch.name === SELECT_ALL_NAME);
497
+ if (!selectAllChoice) return;
498
+
499
+ const branchItems = choiceList.filter((ch) => allBranchNames.has(ch.name));
500
+ selectAllChoice.enabled = branchItems.length > 0 && branchItems.every((ch) => ch.enabled);
501
+ }
502
+
503
+ /**
504
+ * 同步指定分支所属组的全选状态
505
+ * 根据该组内所有分支的选中状态更新组全选项
506
+ * @param {MultiSelectChoice[]} choiceList - choices 列表
507
+ * @param {string} branchName - 刚被 toggle 的分支名
508
+ */
509
+ function syncGroupSelectAll(choiceList: MultiSelectChoice[], branchName: string): void {
510
+ for (const [groupName, memberNames] of groupMembershipMap) {
511
+ if (!memberNames.includes(branchName)) continue;
512
+
513
+ const groupChoice = choiceList.find((ch) => ch.name === groupName);
514
+ if (!groupChoice) continue;
515
+
516
+ const memberChoices = choiceList.filter((ch) => memberNames.includes(ch.name));
517
+ groupChoice.enabled = memberChoices.length > 0 && memberChoices.every((ch) => ch.enabled);
518
+ break;
519
+ }
520
+ }
521
+
522
+ const selectedBranches: string[] = await new MultiSelectWithGroupSelectAll({
523
+ message,
524
+ choices,
525
+ // 使用空心圆/实心圆作为选中指示符
526
+ symbols: {
527
+ indicator: { on: '●', off: '○' },
528
+ },
529
+ }).run();
530
+
531
+ // 过滤掉全选项和组全选项,只返回实际选中的 worktree
532
+ return worktrees.filter((wt) => selectedBranches.includes(wt.branch));
533
+ }
@@ -20,6 +20,7 @@ vi.mock('../../../src/utils/index.js', () => ({
20
20
  getProjectWorktrees: vi.fn(),
21
21
  launchInteractiveClaude: vi.fn(),
22
22
  resolveTargetWorktrees: vi.fn(),
23
+ promptGroupedMultiSelectBranches: vi.fn(),
23
24
  }));
24
25
 
25
26
  import { registerResumeCommand } from '../../../src/commands/resume.js';
@@ -29,6 +30,7 @@ import {
29
30
  getProjectWorktrees,
30
31
  launchInteractiveClaude,
31
32
  resolveTargetWorktrees,
33
+ promptGroupedMultiSelectBranches,
32
34
  } from '../../../src/utils/index.js';
33
35
 
34
36
  const mockedValidateMainWorktree = vi.mocked(validateMainWorktree);
@@ -36,6 +38,7 @@ const mockedValidateClaudeCodeInstalled = vi.mocked(validateClaudeCodeInstalled)
36
38
  const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
37
39
  const mockedLaunchInteractiveClaude = vi.mocked(launchInteractiveClaude);
38
40
  const mockedResolveTargetWorktrees = vi.mocked(resolveTargetWorktrees);
41
+ const mockedPromptGroupedMultiSelectBranches = vi.mocked(promptGroupedMultiSelectBranches);
39
42
 
40
43
  beforeEach(() => {
41
44
  mockedValidateMainWorktree.mockReset();
@@ -43,6 +46,7 @@ beforeEach(() => {
43
46
  mockedGetProjectWorktrees.mockReset();
44
47
  mockedLaunchInteractiveClaude.mockReset();
45
48
  mockedResolveTargetWorktrees.mockReset();
49
+ mockedPromptGroupedMultiSelectBranches.mockReset();
46
50
  });
47
51
 
48
52
  describe('registerResumeCommand', () => {
@@ -55,7 +59,7 @@ describe('registerResumeCommand', () => {
55
59
  });
56
60
 
57
61
  describe('handleResume', () => {
58
- it('成功恢复 Claude Code 会话', async () => {
62
+ it(' -b 时走标准解析流程', async () => {
59
63
  const worktree = { path: '/path/feature', branch: 'feature' };
60
64
  mockedGetProjectWorktrees.mockReturnValue([worktree]);
61
65
  mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
@@ -68,10 +72,31 @@ describe('handleResume', () => {
68
72
  expect(mockedValidateMainWorktree).toHaveBeenCalled();
69
73
  expect(mockedValidateClaudeCodeInstalled).toHaveBeenCalled();
70
74
  expect(mockedResolveTargetWorktrees).toHaveBeenCalled();
75
+ expect(mockedPromptGroupedMultiSelectBranches).not.toHaveBeenCalled();
71
76
  expect(mockedLaunchInteractiveClaude).toHaveBeenCalledWith(worktree, { autoContinue: true });
72
77
  });
73
78
 
74
- it('不传 -b 时也能调用 resolveTargetWorktrees', async () => {
79
+ it('不传 -b 且多个 worktree 时默认使用分组多选', async () => {
80
+ const worktrees = [
81
+ { path: '/path/feature-a', branch: 'feature-a' },
82
+ { path: '/path/feature-b', branch: 'feature-b' },
83
+ ];
84
+ mockedGetProjectWorktrees.mockReturnValue(worktrees);
85
+ mockedPromptGroupedMultiSelectBranches.mockResolvedValue([worktrees[0]]);
86
+
87
+ const program = new Command();
88
+ program.exitOverride();
89
+ registerResumeCommand(program);
90
+ await program.parseAsync(['resume'], { from: 'user' });
91
+
92
+ expect(mockedPromptGroupedMultiSelectBranches).toHaveBeenCalledWith(
93
+ worktrees,
94
+ expect.any(String),
95
+ );
96
+ expect(mockedResolveTargetWorktrees).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it('仅 1 个 worktree 时走标准流程', async () => {
75
100
  const worktree = { path: '/path/feature', branch: 'feature' };
76
101
  mockedGetProjectWorktrees.mockReturnValue([worktree]);
77
102
  mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
@@ -81,11 +106,7 @@ describe('handleResume', () => {
81
106
  registerResumeCommand(program);
82
107
  await program.parseAsync(['resume'], { from: 'user' });
83
108
 
84
- // branchName 参数为 undefined
85
- expect(mockedResolveTargetWorktrees).toHaveBeenCalledWith(
86
- expect.any(Array),
87
- expect.any(Object),
88
- undefined,
89
- );
109
+ expect(mockedResolveTargetWorktrees).toHaveBeenCalled();
110
+ expect(mockedPromptGroupedMultiSelectBranches).not.toHaveBeenCalled();
90
111
  });
91
112
  });
@@ -1,7 +1,8 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { findExactMatch, findFuzzyMatches, resolveTargetWorktree, resolveTargetWorktrees } from '../../../src/utils/worktree-matcher.js';
2
+ import { findExactMatch, findFuzzyMatches, resolveTargetWorktree, resolveTargetWorktrees, groupWorktreesByDate, buildGroupedChoices, buildGroupMembershipMap } from '../../../src/utils/worktree-matcher.js';
3
3
  import { createWorktreeInfo, createWorktreeList } from '../../helpers/fixtures.js';
4
4
  import { ClawtError } from '../../../src/errors/index.js';
5
+ import { SELECT_ALL_NAME, GROUP_SELECT_ALL_PREFIX, UNKNOWN_DATE_GROUP } from '../../../src/constants/index.js';
5
6
  import type { WorktreeResolveMessages, WorktreeMultiResolveMessages } from '../../../src/utils/worktree-matcher.js';
6
7
 
7
8
  // mock enquirer
@@ -16,6 +17,19 @@ vi.mock('enquirer', () => ({
16
17
  },
17
18
  }));
18
19
 
20
+ // mock getBranchCreatedAt,分组测试中按需控制返回值
21
+ vi.mock('../../../src/utils/git.js', () => ({
22
+ getBranchCreatedAt: vi.fn().mockReturnValue(null),
23
+ }));
24
+
25
+ // mock node:fs 的 statSync,分组测试中按需控制返回值
26
+ vi.mock('node:fs', () => ({
27
+ statSync: vi.fn().mockReturnValue({ birthtime: new Date('2026-02-27T10:00:00') }),
28
+ }));
29
+
30
+ import { statSync } from 'node:fs';
31
+ const mockedStatSync = vi.mocked(statSync);
32
+
19
33
  /** 测试用消息配置 */
20
34
  const testMessages: WorktreeResolveMessages = {
21
35
  noWorktrees: '无可用 worktree',
@@ -190,3 +204,130 @@ describe('resolveTargetWorktrees', () => {
190
204
  await expect(resolveTargetWorktrees(worktrees, testMultiMessages, 'xyz')).rejects.toThrow(ClawtError);
191
205
  });
192
206
  });
207
+
208
+ describe('groupWorktreesByDate', () => {
209
+ it('多日期分组:不同日期的分支被正确分组,最新日期在前', () => {
210
+ const worktrees = [
211
+ createWorktreeInfo({ path: '/worktrees/feature-auth', branch: 'feature-auth' }),
212
+ createWorktreeInfo({ path: '/worktrees/feature-login', branch: 'feature-login' }),
213
+ createWorktreeInfo({ path: '/worktrees/bugfix-nav', branch: 'bugfix-nav' }),
214
+ ];
215
+
216
+ mockedStatSync
217
+ .mockReturnValueOnce({ birthtime: new Date('2026-02-26T10:00:00') } as any)
218
+ .mockReturnValueOnce({ birthtime: new Date('2026-02-26T14:00:00') } as any)
219
+ .mockReturnValueOnce({ birthtime: new Date('2026-02-25T09:00:00') } as any);
220
+
221
+ const groups = groupWorktreesByDate(worktrees);
222
+ const keys = [...groups.keys()];
223
+
224
+ // 最新日期在前
225
+ expect(keys).toEqual(['2026-02-26', '2026-02-25']);
226
+ // 2026-02-26 组有 2 个分支
227
+ expect(groups.get('2026-02-26')).toHaveLength(2);
228
+ expect(groups.get('2026-02-26')!.map((wt) => wt.branch)).toEqual(['feature-auth', 'feature-login']);
229
+ // 2026-02-25 组有 1 个分支
230
+ expect(groups.get('2026-02-25')).toHaveLength(1);
231
+ expect(groups.get('2026-02-25')![0].branch).toBe('bugfix-nav');
232
+ });
233
+
234
+ it('statSync 异常时归入"未知日期"组', () => {
235
+ const worktrees = [
236
+ createWorktreeInfo({ path: '/worktrees/feature-auth', branch: 'feature-auth' }),
237
+ createWorktreeInfo({ path: '/worktrees/old-branch', branch: 'old-branch' }),
238
+ ];
239
+
240
+ mockedStatSync
241
+ .mockReturnValueOnce({ birthtime: new Date('2026-02-26T10:00:00') } as any)
242
+ .mockImplementationOnce(() => { throw new Error('ENOENT'); });
243
+
244
+ const groups = groupWorktreesByDate(worktrees);
245
+ const keys = [...groups.keys()];
246
+
247
+ // 未知日期在最后
248
+ expect(keys).toEqual(['2026-02-26', UNKNOWN_DATE_GROUP]);
249
+ expect(groups.get(UNKNOWN_DATE_GROUP)).toHaveLength(1);
250
+ expect(groups.get(UNKNOWN_DATE_GROUP)![0].branch).toBe('old-branch');
251
+ });
252
+
253
+ it('单日期分组:所有分支同一天', () => {
254
+ const worktrees = [
255
+ createWorktreeInfo({ path: '/worktrees/feature-a', branch: 'feature-a' }),
256
+ createWorktreeInfo({ path: '/worktrees/feature-b', branch: 'feature-b' }),
257
+ ];
258
+
259
+ mockedStatSync
260
+ .mockReturnValueOnce({ birthtime: new Date('2026-02-26T10:00:00') } as any)
261
+ .mockReturnValueOnce({ birthtime: new Date('2026-02-26T15:00:00') } as any);
262
+
263
+ const groups = groupWorktreesByDate(worktrees);
264
+ const keys = [...groups.keys()];
265
+
266
+ expect(keys).toEqual(['2026-02-26']);
267
+ expect(groups.get('2026-02-26')).toHaveLength(2);
268
+ });
269
+ });
270
+
271
+ describe('buildGroupedChoices', () => {
272
+ it('构建的 choices 包含全局全选、分隔线、组全选和分支', () => {
273
+ const groups = new Map<string, Array<{ path: string; branch: string }>>([
274
+ ['2026-02-26', [
275
+ createWorktreeInfo({ branch: 'feature-auth' }),
276
+ createWorktreeInfo({ branch: 'feature-login' }),
277
+ ]],
278
+ [UNKNOWN_DATE_GROUP, [
279
+ createWorktreeInfo({ branch: 'old-branch' }),
280
+ ]],
281
+ ]);
282
+
283
+ const choices = buildGroupedChoices(groups);
284
+
285
+ // 全局全选在顶部
286
+ expect(choices[0]).toEqual({ name: SELECT_ALL_NAME, message: '[select-all]' });
287
+
288
+ // 第一组分隔线
289
+ expect(choices[1]).toHaveProperty('role', 'separator');
290
+
291
+ // 第一组组全选
292
+ expect(choices[2]).toEqual({
293
+ name: `${GROUP_SELECT_ALL_PREFIX}2026-02-26`,
294
+ message: '[select-all: 2026-02-26]',
295
+ });
296
+
297
+ // 第一组分支
298
+ expect(choices[3]).toEqual({ name: 'feature-auth', message: 'feature-auth' });
299
+ expect(choices[4]).toEqual({ name: 'feature-login', message: 'feature-login' });
300
+
301
+ // 未知日期组分隔线(含 chalk 高亮,使用 stringContaining 匹配)
302
+ expect(choices[5]).toHaveProperty('role', 'separator');
303
+ expect((choices[5] as { message: string }).message).toContain('未知日期');
304
+
305
+ // 未知日期组全选
306
+ expect(choices[6]).toEqual({
307
+ name: `${GROUP_SELECT_ALL_PREFIX}${UNKNOWN_DATE_GROUP}`,
308
+ message: `[select-all: ${UNKNOWN_DATE_GROUP}]`,
309
+ });
310
+
311
+ // 未知日期组分支
312
+ expect(choices[7]).toEqual({ name: 'old-branch', message: 'old-branch' });
313
+ });
314
+ });
315
+
316
+ describe('buildGroupMembershipMap', () => {
317
+ it('构建组全选 name 到分支 name 列表的正确映射', () => {
318
+ const groups = new Map<string, Array<{ path: string; branch: string }>>([
319
+ ['2026-02-26', [
320
+ createWorktreeInfo({ branch: 'feature-auth' }),
321
+ createWorktreeInfo({ branch: 'feature-login' }),
322
+ ]],
323
+ ['2026-02-25', [
324
+ createWorktreeInfo({ branch: 'bugfix-nav' }),
325
+ ]],
326
+ ]);
327
+
328
+ const membershipMap = buildGroupMembershipMap(groups);
329
+
330
+ expect(membershipMap.get(`${GROUP_SELECT_ALL_PREFIX}2026-02-26`)).toEqual(['feature-auth', 'feature-login']);
331
+ expect(membershipMap.get(`${GROUP_SELECT_ALL_PREFIX}2026-02-25`)).toEqual(['bugfix-nav']);
332
+ });
333
+ });