clawt 2.19.0 → 3.0.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.
Files changed (75) hide show
  1. package/README.md +12 -2
  2. package/dist/index.js +626 -219
  3. package/dist/postinstall.js +39 -6
  4. package/docs/alias.md +108 -0
  5. package/docs/completion.md +55 -0
  6. package/docs/config-file.md +43 -0
  7. package/docs/config.md +91 -0
  8. package/docs/create.md +85 -0
  9. package/docs/init.md +65 -0
  10. package/docs/list.md +67 -0
  11. package/docs/log.md +67 -0
  12. package/docs/merge.md +137 -0
  13. package/docs/notification.md +94 -0
  14. package/docs/projects.md +135 -0
  15. package/docs/remove.md +79 -0
  16. package/docs/reset.md +35 -0
  17. package/docs/resume.md +99 -0
  18. package/docs/run.md +146 -0
  19. package/docs/spec.md +156 -1879
  20. package/docs/status.md +155 -0
  21. package/docs/sync.md +114 -0
  22. package/docs/update-check.md +95 -0
  23. package/docs/validate.md +368 -0
  24. package/package.json +1 -1
  25. package/src/commands/alias.ts +1 -1
  26. package/src/commands/create.ts +10 -5
  27. package/src/commands/init.ts +75 -0
  28. package/src/commands/list.ts +1 -1
  29. package/src/commands/merge.ts +11 -4
  30. package/src/commands/remove.ts +10 -3
  31. package/src/commands/reset.ts +3 -0
  32. package/src/commands/resume.ts +9 -3
  33. package/src/commands/run.ts +9 -3
  34. package/src/commands/status.ts +1 -1
  35. package/src/commands/sync.ts +18 -6
  36. package/src/commands/validate.ts +46 -52
  37. package/src/constants/branch.ts +3 -0
  38. package/src/constants/config.ts +1 -1
  39. package/src/constants/index.ts +3 -3
  40. package/src/constants/messages/completion.ts +1 -1
  41. package/src/constants/messages/create.ts +3 -0
  42. package/src/constants/messages/index.ts +2 -0
  43. package/src/constants/messages/init.ts +18 -0
  44. package/src/constants/messages/remove.ts +2 -0
  45. package/src/constants/messages/sync.ts +3 -0
  46. package/src/constants/messages/validate.ts +6 -0
  47. package/src/constants/paths.ts +3 -0
  48. package/src/constants/prompt.ts +28 -0
  49. package/src/index.ts +2 -0
  50. package/src/types/command.ts +7 -1
  51. package/src/types/index.ts +2 -1
  52. package/src/types/projectConfig.ts +5 -0
  53. package/src/utils/config.ts +2 -1
  54. package/src/utils/git.ts +18 -0
  55. package/src/utils/index.ts +6 -1
  56. package/src/utils/json.ts +67 -0
  57. package/src/utils/project-config.ts +77 -0
  58. package/src/utils/validate-branch.ts +166 -0
  59. package/src/utils/worktree-matcher.ts +268 -1
  60. package/src/utils/worktree.ts +6 -2
  61. package/tests/unit/commands/create.test.ts +20 -16
  62. package/tests/unit/commands/init.test.ts +146 -0
  63. package/tests/unit/commands/merge.test.ts +7 -1
  64. package/tests/unit/commands/remove.test.ts +4 -0
  65. package/tests/unit/commands/reset.test.ts +2 -0
  66. package/tests/unit/commands/resume.test.ts +29 -8
  67. package/tests/unit/commands/run.test.ts +2 -0
  68. package/tests/unit/commands/sync.test.ts +6 -0
  69. package/tests/unit/commands/validate.test.ts +13 -0
  70. package/tests/unit/utils/config.test.ts +2 -2
  71. package/tests/unit/utils/project-config.test.ts +136 -0
  72. package/tests/unit/utils/update-checker.test.ts +28 -7
  73. package/tests/unit/utils/validate-branch.test.ts +272 -0
  74. package/tests/unit/utils/worktree-matcher.test.ts +142 -1
  75. package/tests/unit/utils/worktree.test.ts +6 -0
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 非对象类型的值直接转为字符串表示
3
+ * @param {unknown} value - 任意值
4
+ * @returns {string} 字符串表示
5
+ */
6
+ function primitiveToString(value: unknown): string {
7
+ if (value === undefined) {
8
+ return 'undefined';
9
+ }
10
+ if (value === null) {
11
+ return 'null';
12
+ }
13
+ if (typeof value === 'symbol') {
14
+ return value.toString();
15
+ }
16
+ if (typeof value === 'function') {
17
+ return `[Function: ${value.name || 'anonymous'}]`;
18
+ }
19
+ return String(value);
20
+ }
21
+
22
+ /**
23
+ * 安全的 JSON 序列化,兼容非 JSON 安全类型(undefined、function、Symbol、BigInt、循环引用等)
24
+ * @param {unknown} value - 要序列化的值
25
+ * @param {number} [indent=2] - 缩进空格数
26
+ * @returns {string} 序列化后的 JSON 字符串,失败时返回兜底描述
27
+ */
28
+ export function safeStringify(value: unknown, indent: number = 2): string {
29
+ // 非对象类型直接转字符串,无需走 JSON.stringify
30
+ if (value === null || typeof value !== 'object') {
31
+ return primitiveToString(value);
32
+ }
33
+
34
+ try {
35
+ // 利用 WeakSet 检测循环引用
36
+ const seen = new WeakSet();
37
+ return JSON.stringify(
38
+ value,
39
+ (_key: string, val: unknown) => {
40
+ // 处理 BigInt 类型(JSON.stringify 默认不支持)
41
+ if (typeof val === 'bigint') {
42
+ return val.toString();
43
+ }
44
+ // 将 undefined、function、Symbol 转为可读字符串,避免被 JSON.stringify 丢弃
45
+ if (typeof val === 'undefined' || typeof val === 'function' || typeof val === 'symbol') {
46
+ return primitiveToString(val);
47
+ }
48
+ // 检测循环引用:对象类型且非 null 时才需要检查
49
+ if (typeof val === 'object' && val !== null) {
50
+ if (seen.has(val)) {
51
+ return '[Circular]';
52
+ }
53
+ seen.add(val);
54
+ }
55
+ return val;
56
+ },
57
+ indent,
58
+ );
59
+ } catch {
60
+ // 极端情况兜底:尝试用 util.inspect 风格输出
61
+ try {
62
+ return JSON.stringify(String(value), null, indent);
63
+ } catch {
64
+ return '[Unserializable]';
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,77 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { PROJECTS_CONFIG_DIR, MESSAGES } from '../constants/index.js';
4
+ import { ClawtError } from '../errors/index.js';
5
+ import { logger } from '../logger/index.js';
6
+ import { getProjectName } from './git.js';
7
+ import { ensureDir } from './fs.js';
8
+ import type { ProjectConfig } from '../types/index.js';
9
+
10
+ /**
11
+ * 获取项目配置文件路径
12
+ * @param {string} projectName - 项目名
13
+ * @returns {string} 配置文件路径
14
+ */
15
+ export function getProjectConfigPath(projectName: string): string {
16
+ return join(PROJECTS_CONFIG_DIR, projectName, 'config.json');
17
+ }
18
+
19
+ /**
20
+ * 加载当前项目的配置
21
+ * @returns {ProjectConfig | null} 项目配置,不存在时返回 null
22
+ */
23
+ export function loadProjectConfig(): ProjectConfig | null {
24
+ const projectName = getProjectName();
25
+ const configPath = getProjectConfigPath(projectName);
26
+
27
+ if (!existsSync(configPath)) {
28
+ return null;
29
+ }
30
+
31
+ try {
32
+ const raw = readFileSync(configPath, 'utf-8');
33
+ return JSON.parse(raw) as ProjectConfig;
34
+ } catch (error) {
35
+ logger.warn(`项目配置文件解析失败: ${error}`);
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * 保存项目配置
42
+ * @param {ProjectConfig} config - 要保存的项目配置
43
+ */
44
+ export function saveProjectConfig(config: ProjectConfig): void {
45
+ const projectName = getProjectName();
46
+ const configPath = getProjectConfigPath(projectName);
47
+ // 确保项目子目录存在
48
+ const projectDir = join(PROJECTS_CONFIG_DIR, projectName);
49
+ ensureDir(projectDir);
50
+ writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
51
+ logger.info(`项目配置已保存: ${configPath}`);
52
+ }
53
+
54
+ /**
55
+ * 获取当前项目配置,不存在则抛出错误
56
+ * @returns {ProjectConfig} 项目配置
57
+ */
58
+ export function requireProjectConfig(): ProjectConfig {
59
+ const config = loadProjectConfig();
60
+ if (!config) {
61
+ throw new ClawtError(MESSAGES.PROJECT_NOT_INITIALIZED);
62
+ }
63
+ // 校验配置文件中是否包含 clawtMainWorkBranch 字段
64
+ if (!config.clawtMainWorkBranch) {
65
+ throw new ClawtError(MESSAGES.PROJECT_CONFIG_MISSING_BRANCH);
66
+ }
67
+ return config;
68
+ }
69
+
70
+ /**
71
+ * 从项目配置中获取主工作分支名
72
+ * @returns {string} 主工作分支名
73
+ */
74
+ export function getMainWorkBranch(): string {
75
+ const config = requireProjectConfig();
76
+ return config.clawtMainWorkBranch;
77
+ }
@@ -0,0 +1,166 @@
1
+ import Enquirer from 'enquirer';
2
+ import { VALIDATE_BRANCH_PREFIX } from '../constants/index.js';
3
+ import { logger } from '../logger/index.js';
4
+ import { checkBranchExists, createBranch, deleteBranch, getCurrentBranch, gitCheckout, gitResetHard, gitCleanForce, isWorkingDirClean, gitAddAll, gitStashPush } from './git.js';
5
+ import { getMainWorkBranch } from './project-config.js';
6
+ import { printWarning } from './formatter.js';
7
+ import { ClawtError } from '../errors/index.js';
8
+
9
+ /**
10
+ * 生成验证分支名
11
+ * @param {string} branchName - 原始分支名
12
+ * @returns {string} 验证分支名
13
+ */
14
+ export function getValidateBranchName(branchName: string): string {
15
+ return `${VALIDATE_BRANCH_PREFIX}${branchName}`;
16
+ }
17
+
18
+ /**
19
+ * 创建验证分支(如果不存在)
20
+ * @param {string} branchName - 原始分支名
21
+ * @param {string} [cwd] - 工作目录
22
+ */
23
+ export function createValidateBranch(branchName: string, cwd?: string): void {
24
+ const validateBranchName = getValidateBranchName(branchName);
25
+
26
+ if (checkBranchExists(validateBranchName, cwd)) {
27
+ logger.info(`验证分支已存在,跳过创建: ${validateBranchName}`);
28
+ return;
29
+ }
30
+
31
+ createBranch(validateBranchName, cwd);
32
+ logger.info(`验证分支已创建: ${validateBranchName}`);
33
+ }
34
+
35
+ /**
36
+ * 删除验证分支(如果存在)
37
+ * @param {string} branchName - 原始分支名
38
+ * @param {string} [cwd] - 工作目录
39
+ */
40
+ export function deleteValidateBranch(branchName: string, cwd?: string): void {
41
+ const validateBranchName = getValidateBranchName(branchName);
42
+
43
+ if (!checkBranchExists(validateBranchName, cwd)) {
44
+ logger.info(`验证分支不存在,跳过删除: ${validateBranchName}`);
45
+ return;
46
+ }
47
+
48
+ deleteBranch(validateBranchName, cwd);
49
+ logger.info(`验证分支已删除: ${validateBranchName}`);
50
+ }
51
+
52
+ /**
53
+ * 重建验证分支(先删除再创建)
54
+ * 确保在主工作分支上创建验证分支,处理三种情况:
55
+ * 1. 已在主工作分支上 → 直接重建
56
+ * 2. 在验证分支上 → 清理工作区后自动切回主工作分支
57
+ * 3. 在其他分支上 → 检查工作区是否干净,不干净则交互处理后切回主工作分支
58
+ * @param {string} branchName - 原始分支名
59
+ * @param {string} [cwd] - 工作目录
60
+ */
61
+ export async function rebuildValidateBranch(branchName: string, cwd?: string): Promise<void> {
62
+ const mainBranch = getMainWorkBranch();
63
+ const currentBranch = getCurrentBranch(cwd);
64
+
65
+ if (currentBranch === mainBranch) {
66
+ // 已在主工作分支上,无需切换
67
+ } else if (currentBranch.startsWith(VALIDATE_BRANCH_PREFIX)) {
68
+ // 在验证分支上,验证分支的修改是可丢弃的,直接清理后切回
69
+ gitResetHard(cwd);
70
+ gitCleanForce(cwd);
71
+ gitCheckout(mainBranch, cwd);
72
+ } else {
73
+ // 在其他分支上,需确保工作区干净后切回主工作分支
74
+ if (!isWorkingDirClean(cwd)) {
75
+ await handleDirtyWorkingDir(cwd);
76
+ }
77
+ gitCheckout(mainBranch, cwd);
78
+ }
79
+
80
+ deleteValidateBranch(branchName, cwd);
81
+ createValidateBranch(branchName, cwd);
82
+ logger.info(`验证分支已重建: ${getValidateBranchName(branchName)}`);
83
+ }
84
+
85
+ /**
86
+ * 处理工作区有未提交更改的情况
87
+ * 提供交互式选择:reset(丢弃所有更改)、stash(暂存更改)、exit(退出让用户手动处理)
88
+ * @param {string} [cwd] - 工作目录
89
+ */
90
+ export async function handleDirtyWorkingDir(cwd?: string): Promise<void> {
91
+ printWarning('当前分支有未提交的更改,请选择处理方式:\n');
92
+
93
+ // @ts-expect-error enquirer 类型声明未导出 Select 类,但运行时存在
94
+ const choice = await new Enquirer.Select({
95
+ message: '选择处理方式',
96
+ choices: [
97
+ {
98
+ name: 'reset',
99
+ message: 'reset - 丢弃所有更改 (git reset --hard HEAD && git clean -fd)',
100
+ },
101
+ {
102
+ name: 'stash',
103
+ message: 'stash - 暂存更改 (git add . && git stash)',
104
+ },
105
+ {
106
+ name: 'exit',
107
+ message: 'exit - 退出,手动处理',
108
+ },
109
+ ],
110
+ initial: 0,
111
+ }).run();
112
+
113
+ if (choice === 'exit') {
114
+ throw new ClawtError('用户选择退出,请手动处理工作区更改后重试');
115
+ }
116
+
117
+ if (choice === 'reset') {
118
+ gitResetHard(cwd);
119
+ gitCleanForce(cwd);
120
+ } else if (choice === 'stash') {
121
+ gitAddAll(cwd);
122
+ gitStashPush('clawt:auto-stash', cwd);
123
+ }
124
+
125
+ // 再次检查是否干净
126
+ if (!isWorkingDirClean(cwd)) {
127
+ throw new ClawtError('工作区仍然不干净,请手动处理');
128
+ }
129
+ }
130
+
131
+ /**
132
+ * 确保当前在主工作分支上
133
+ * 三种情况:
134
+ * 1. 已在主工作分支上 → 直接返回
135
+ * 2. 在验证分支上 → 清理工作区后自动切回主工作分支
136
+ * 3. 在其他分支上 → 处理脏工作区后切换到主工作分支
137
+ * @param {string} [cwd] - 工作目录
138
+ */
139
+ export async function ensureOnMainWorkBranch(cwd?: string): Promise<void> {
140
+ const mainBranch = getMainWorkBranch();
141
+ const currentBranch = getCurrentBranch(cwd);
142
+
143
+ // 已在主工作分支上,无需操作
144
+ if (currentBranch === mainBranch) {
145
+ return;
146
+ }
147
+
148
+ // 当前在验证分支上,清理工作区后自动切回
149
+ if (currentBranch.startsWith(VALIDATE_BRANCH_PREFIX)) {
150
+ logger.info(`当前在验证分支 ${currentBranch} 上,自动切回主工作分支 ${mainBranch}`);
151
+ // 验证分支上的修改是可丢弃的,直接清理
152
+ if (!isWorkingDirClean(cwd)) {
153
+ gitResetHard(cwd);
154
+ gitCleanForce(cwd);
155
+ }
156
+ gitCheckout(mainBranch, cwd);
157
+ return;
158
+ }
159
+
160
+ // 当前在其他分支上,处理脏工作区后切换
161
+ logger.info(`当前在分支 ${currentBranch} 上,需切换到主工作分支 ${mainBranch}`);
162
+ if (!isWorkingDirClean(cwd)) {
163
+ await handleDirtyWorkingDir(cwd);
164
+ }
165
+ gitCheckout(mainBranch, cwd);
166
+ }
@@ -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
+ }
@@ -5,6 +5,7 @@ import { logger } from '../logger/index.js';
5
5
  import { createWorktree as gitCreateWorktree, getProjectName, gitWorktreeList, removeWorktreeByPath, deleteBranch, gitWorktreePrune, getCommitCountAhead, getDiffStat, isWorkingDirClean } from './git.js';
6
6
  import { sanitizeBranchName, generateBranchNames, validateBranchesNotExist } from './branch.js';
7
7
  import { ensureDir, removeEmptyDir } from './fs.js';
8
+ import { createValidateBranch, deleteValidateBranch } from './validate-branch.js';
8
9
  import type { WorktreeInfo, WorktreeStatus } from '../types/index.js';
9
10
 
10
11
  /**
@@ -37,11 +38,12 @@ export function createWorktrees(branchName: string, count: number): WorktreeInfo
37
38
  const projectDir = getProjectWorktreeDir();
38
39
  ensureDir(projectDir);
39
40
 
40
- // 5. 串行创建 worktree
41
+ // 5. 串行创建 worktree 及对应验证分支
41
42
  const results: WorktreeInfo[] = [];
42
43
  for (const name of branchNames) {
43
44
  const worktreePath = join(projectDir, name);
44
45
  gitCreateWorktree(name, worktreePath);
46
+ createValidateBranch(name);
45
47
  results.push({ path: worktreePath, branch: name });
46
48
  logger.info(`worktree 创建完成: ${worktreePath} (分支: ${name})`);
47
49
  }
@@ -64,11 +66,12 @@ export function createWorktreesByBranches(branchNames: string[]): WorktreeInfo[]
64
66
  const projectDir = getProjectWorktreeDir();
65
67
  ensureDir(projectDir);
66
68
 
67
- // 3. 串行创建 worktree
69
+ // 3. 串行创建 worktree 及对应验证分支
68
70
  const results: WorktreeInfo[] = [];
69
71
  for (const name of branchNames) {
70
72
  const worktreePath = join(projectDir, name);
71
73
  gitCreateWorktree(name, worktreePath);
74
+ createValidateBranch(name);
72
75
  results.push({ path: worktreePath, branch: name });
73
76
  logger.info(`worktree 创建完成: ${worktreePath} (分支: ${name})`);
74
77
  }
@@ -123,6 +126,7 @@ export function cleanupWorktrees(worktrees: WorktreeInfo[]): void {
123
126
  try {
124
127
  removeWorktreeByPath(wt.path);
125
128
  deleteBranch(wt.branch);
129
+ deleteValidateBranch(wt.branch);
126
130
  logger.info(`已清理 worktree 和分支: ${wt.branch}`);
127
131
  } catch (error) {
128
132
  logger.error(`清理 worktree 失败: ${wt.path} - ${error}`);