clawt 2.20.0 → 3.1.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 (77) hide show
  1. package/.claude/agents/docs-sync-updater.md +29 -11
  2. package/README.md +19 -30
  3. package/dist/index.js +1127 -222
  4. package/dist/postinstall.js +73 -8
  5. package/docs/alias.md +108 -0
  6. package/docs/completion.md +55 -0
  7. package/docs/config-file.md +43 -0
  8. package/docs/config.md +91 -0
  9. package/docs/create.md +85 -0
  10. package/docs/init.md +65 -0
  11. package/docs/list.md +67 -0
  12. package/docs/log.md +67 -0
  13. package/docs/merge.md +137 -0
  14. package/docs/notification.md +94 -0
  15. package/docs/projects.md +135 -0
  16. package/docs/remove.md +79 -0
  17. package/docs/reset.md +35 -0
  18. package/docs/resume.md +99 -0
  19. package/docs/run.md +146 -0
  20. package/docs/spec.md +157 -1906
  21. package/docs/status.md +298 -0
  22. package/docs/sync.md +114 -0
  23. package/docs/update-check.md +95 -0
  24. package/docs/validate.md +368 -0
  25. package/package.json +1 -1
  26. package/src/commands/alias.ts +1 -1
  27. package/src/commands/create.ts +10 -5
  28. package/src/commands/init.ts +75 -0
  29. package/src/commands/list.ts +1 -1
  30. package/src/commands/merge.ts +11 -4
  31. package/src/commands/remove.ts +10 -3
  32. package/src/commands/reset.ts +3 -0
  33. package/src/commands/resume.ts +1 -1
  34. package/src/commands/run.ts +9 -3
  35. package/src/commands/status.ts +14 -5
  36. package/src/commands/sync.ts +18 -6
  37. package/src/commands/validate.ts +46 -52
  38. package/src/constants/branch.ts +3 -0
  39. package/src/constants/config.ts +1 -1
  40. package/src/constants/index.ts +14 -2
  41. package/src/constants/interactive-panel.ts +44 -0
  42. package/src/constants/messages/completion.ts +1 -1
  43. package/src/constants/messages/create.ts +3 -0
  44. package/src/constants/messages/index.ts +4 -0
  45. package/src/constants/messages/init.ts +18 -0
  46. package/src/constants/messages/interactive-panel.ts +61 -0
  47. package/src/constants/messages/remove.ts +2 -0
  48. package/src/constants/messages/sync.ts +3 -0
  49. package/src/constants/messages/validate.ts +6 -0
  50. package/src/constants/paths.ts +3 -0
  51. package/src/index.ts +2 -0
  52. package/src/types/command.ts +9 -1
  53. package/src/types/index.ts +2 -1
  54. package/src/types/projectConfig.ts +5 -0
  55. package/src/utils/config.ts +2 -1
  56. package/src/utils/git.ts +18 -0
  57. package/src/utils/index.ts +9 -1
  58. package/src/utils/interactive-panel-render.ts +315 -0
  59. package/src/utils/interactive-panel.ts +590 -0
  60. package/src/utils/json.ts +67 -0
  61. package/src/utils/project-config.ts +77 -0
  62. package/src/utils/validate-branch.ts +166 -0
  63. package/src/utils/worktree-matcher.ts +2 -2
  64. package/src/utils/worktree.ts +6 -2
  65. package/tests/unit/commands/create.test.ts +20 -16
  66. package/tests/unit/commands/init.test.ts +146 -0
  67. package/tests/unit/commands/merge.test.ts +7 -1
  68. package/tests/unit/commands/remove.test.ts +4 -0
  69. package/tests/unit/commands/reset.test.ts +2 -0
  70. package/tests/unit/commands/run.test.ts +2 -0
  71. package/tests/unit/commands/sync.test.ts +6 -0
  72. package/tests/unit/commands/validate.test.ts +13 -0
  73. package/tests/unit/utils/config.test.ts +2 -2
  74. package/tests/unit/utils/project-config.test.ts +136 -0
  75. package/tests/unit/utils/update-checker.test.ts +28 -7
  76. package/tests/unit/utils/validate-branch.test.ts +272 -0
  77. package/tests/unit/utils/worktree.test.ts +6 -0
@@ -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
+ }
@@ -301,7 +301,7 @@ function formatLocalDate(date: Date): string {
301
301
  * @param {string} dirPath - worktree 目录路径
302
302
  * @returns {string | null} YYYY-MM-DD 格式的本地日期字符串,无法获取时返回 null
303
303
  */
304
- function getWorktreeCreatedDate(dirPath: string): string | null {
304
+ export function getWorktreeCreatedDate(dirPath: string): string | null {
305
305
  try {
306
306
  const stat = statSync(dirPath);
307
307
  return formatLocalDate(stat.birthtime);
@@ -316,7 +316,7 @@ function getWorktreeCreatedDate(dirPath: string): string | null {
316
316
  * @param {string} dateStr - YYYY-MM-DD 格式的日期字符串
317
317
  * @returns {string} 中文相对日期描述,如"今天"、"昨天"、"3 天前"
318
318
  */
319
- function formatRelativeDate(dateStr: string): string {
319
+ export function formatRelativeDate(dateStr: string): string {
320
320
  const today = formatLocalDate(new Date());
321
321
  const todayMs = new Date(today).getTime();
322
322
  const targetMs = new Date(dateStr).getTime();
@@ -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}`);
@@ -26,6 +26,10 @@ vi.mock('../../../src/constants/index.js', () => ({
26
26
  vi.mock('../../../src/utils/index.js', () => ({
27
27
  validateMainWorktree: vi.fn(),
28
28
  createWorktrees: vi.fn(),
29
+ getConfigValue: vi.fn().mockReturnValue(true),
30
+ requireProjectConfig: vi.fn().mockReturnValue({ clawtMainWorkBranch: 'main' }),
31
+ ensureOnMainWorkBranch: vi.fn().mockResolvedValue(undefined),
32
+ getValidateBranchName: vi.fn((name: string) => `clawt-validate-${name}`),
29
33
  printSuccess: vi.fn(),
30
34
  printInfo: vi.fn(),
31
35
  printSeparator: vi.fn(),
@@ -54,7 +58,7 @@ describe('registerCreateCommand', () => {
54
58
  });
55
59
 
56
60
  describe('handleCreate', () => {
57
- it('成功创建 worktree', () => {
61
+ it('成功创建 worktree', async () => {
58
62
  mockedCreateWorktrees.mockReturnValue([
59
63
  { path: '/path/feature', branch: 'feature' },
60
64
  ]);
@@ -62,14 +66,14 @@ describe('handleCreate', () => {
62
66
  const program = new Command();
63
67
  program.exitOverride();
64
68
  registerCreateCommand(program);
65
- program.parse(['create', '-b', 'feature'], { from: 'user' });
69
+ await program.parseAsync(['create', '-b', 'feature'], { from: 'user' });
66
70
 
67
71
  expect(mockedValidateMainWorktree).toHaveBeenCalled();
68
72
  expect(mockedCreateWorktrees).toHaveBeenCalledWith('feature', 1);
69
73
  expect(mockedPrintSuccess).toHaveBeenCalled();
70
74
  });
71
75
 
72
- it('支持 -n 指定创建数量', () => {
76
+ it('支持 -n 指定创建数量', async () => {
73
77
  mockedCreateWorktrees.mockReturnValue([
74
78
  { path: '/path/feature-1', branch: 'feature-1' },
75
79
  { path: '/path/feature-2', branch: 'feature-2' },
@@ -78,38 +82,38 @@ describe('handleCreate', () => {
78
82
  const program = new Command();
79
83
  program.exitOverride();
80
84
  registerCreateCommand(program);
81
- program.parse(['create', '-b', 'feature', '-n', '2'], { from: 'user' });
85
+ await program.parseAsync(['create', '-b', 'feature', '-n', '2'], { from: 'user' });
82
86
 
83
87
  expect(mockedCreateWorktrees).toHaveBeenCalledWith('feature', 2);
84
88
  });
85
89
 
86
- it('无效数量抛出 ClawtError', () => {
90
+ it('无效数量抛出 ClawtError', async () => {
87
91
  const program = new Command();
88
92
  program.exitOverride();
89
93
  registerCreateCommand(program);
90
94
 
91
- expect(() => {
92
- program.parse(['create', '-b', 'feature', '-n', 'abc'], { from: 'user' });
93
- }).toThrow();
95
+ await expect(
96
+ program.parseAsync(['create', '-b', 'feature', '-n', 'abc'], { from: 'user' }),
97
+ ).rejects.toThrow();
94
98
  });
95
99
 
96
- it('数量为 0 时抛出 ClawtError', () => {
100
+ it('数量为 0 时抛出 ClawtError', async () => {
97
101
  const program = new Command();
98
102
  program.exitOverride();
99
103
  registerCreateCommand(program);
100
104
 
101
- expect(() => {
102
- program.parse(['create', '-b', 'feature', '-n', '0'], { from: 'user' });
103
- }).toThrow();
105
+ await expect(
106
+ program.parseAsync(['create', '-b', 'feature', '-n', '0'], { from: 'user' }),
107
+ ).rejects.toThrow();
104
108
  });
105
109
 
106
- it('负数数量抛出 ClawtError', () => {
110
+ it('负数数量抛出 ClawtError', async () => {
107
111
  const program = new Command();
108
112
  program.exitOverride();
109
113
  registerCreateCommand(program);
110
114
 
111
- expect(() => {
112
- program.parse(['create', '-b', 'feature', '-n', '-1'], { from: 'user' });
113
- }).toThrow();
115
+ await expect(
116
+ program.parseAsync(['create', '-b', 'feature', '-n', '-1'], { from: 'user' }),
117
+ ).rejects.toThrow();
114
118
  });
115
119
  });
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Command } from 'commander';
3
+
4
+ vi.mock('../../../src/logger/index.js', () => ({
5
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
6
+ }));
7
+
8
+ vi.mock('../../../src/constants/index.js', () => ({
9
+ MESSAGES: {
10
+ INIT_SUCCESS: (branch: string) => `✓ 项目初始化成功,主工作分支设置为: ${branch}`,
11
+ INIT_UPDATED: (oldBranch: string, newBranch: string) => `✓ 已将主工作分支从 ${oldBranch} 更新为 ${newBranch}`,
12
+ INIT_SHOW: (configJson: string) => `当前项目配置:\n${configJson}`,
13
+ PROJECT_NOT_INITIALIZED: '项目尚未初始化,请先执行 clawt init 设置主工作分支',
14
+ PROJECT_CONFIG_MISSING_BRANCH: '项目配置缺少主工作分支信息,请重新执行 clawt init 设置主工作分支',
15
+ },
16
+ }));
17
+
18
+ vi.mock('../../../src/utils/index.js', () => ({
19
+ validateMainWorktree: vi.fn(),
20
+ getCurrentBranch: vi.fn().mockReturnValue('main'),
21
+ loadProjectConfig: vi.fn(),
22
+ saveProjectConfig: vi.fn(),
23
+ requireProjectConfig: vi.fn().mockReturnValue({ clawtMainWorkBranch: 'main' }),
24
+ printSuccess: vi.fn(),
25
+ printInfo: vi.fn(),
26
+ safeStringify: vi.fn((value: unknown, indent: number = 2) => JSON.stringify(value, null, indent)),
27
+ }));
28
+
29
+ import { registerInitCommand } from '../../../src/commands/init.js';
30
+ import {
31
+ loadProjectConfig,
32
+ saveProjectConfig,
33
+ requireProjectConfig,
34
+ printSuccess,
35
+ printInfo,
36
+ getCurrentBranch,
37
+ } from '../../../src/utils/index.js';
38
+
39
+ const mockedLoadProjectConfig = vi.mocked(loadProjectConfig);
40
+ const mockedSaveProjectConfig = vi.mocked(saveProjectConfig);
41
+ const mockedRequireProjectConfig = vi.mocked(requireProjectConfig);
42
+ const mockedPrintSuccess = vi.mocked(printSuccess);
43
+ const mockedPrintInfo = vi.mocked(printInfo);
44
+ const mockedGetCurrentBranch = vi.mocked(getCurrentBranch);
45
+
46
+ beforeEach(() => {
47
+ mockedLoadProjectConfig.mockReset();
48
+ mockedSaveProjectConfig.mockReset();
49
+ mockedRequireProjectConfig.mockReset();
50
+ mockedRequireProjectConfig.mockReturnValue({ clawtMainWorkBranch: 'main' });
51
+ mockedPrintSuccess.mockReset();
52
+ mockedPrintInfo.mockReset();
53
+ mockedGetCurrentBranch.mockReturnValue('main');
54
+ });
55
+
56
+ describe('registerInitCommand', () => {
57
+ it('注册 init 命令', () => {
58
+ const program = new Command();
59
+ registerInitCommand(program);
60
+ const cmd = program.commands.find((c) => c.name() === 'init');
61
+ expect(cmd).toBeDefined();
62
+ });
63
+
64
+ it('注册 init show 子命令', () => {
65
+ const program = new Command();
66
+ registerInitCommand(program);
67
+ const initCmd = program.commands.find((c) => c.name() === 'init');
68
+ const showCmd = initCmd?.commands.find((c) => c.name() === 'show');
69
+ expect(showCmd).toBeDefined();
70
+ });
71
+ });
72
+
73
+ describe('handleInit', () => {
74
+ it('无参数且已初始化时使用当前分支切换主工作分支', async () => {
75
+ mockedLoadProjectConfig.mockReturnValue({ clawtMainWorkBranch: 'develop' });
76
+
77
+ const program = new Command();
78
+ program.exitOverride();
79
+ registerInitCommand(program);
80
+ await program.parseAsync(['init'], { from: 'user' });
81
+
82
+ expect(mockedSaveProjectConfig).toHaveBeenCalledWith({ clawtMainWorkBranch: 'main' });
83
+ expect(mockedPrintSuccess).toHaveBeenCalledWith(
84
+ expect.stringContaining('develop'),
85
+ );
86
+ expect(mockedPrintSuccess).toHaveBeenCalledWith(
87
+ expect.stringContaining('main'),
88
+ );
89
+ });
90
+
91
+ it('无参数且未初始化时使用当前分支初始化', async () => {
92
+ mockedLoadProjectConfig.mockReturnValue(null);
93
+
94
+ const program = new Command();
95
+ program.exitOverride();
96
+ registerInitCommand(program);
97
+ await program.parseAsync(['init'], { from: 'user' });
98
+
99
+ expect(mockedSaveProjectConfig).toHaveBeenCalledWith({ clawtMainWorkBranch: 'main' });
100
+ expect(mockedPrintSuccess).toHaveBeenCalled();
101
+ });
102
+
103
+ it('有 -b 参数时设置指定分支', async () => {
104
+ mockedLoadProjectConfig.mockReturnValue(null);
105
+
106
+ const program = new Command();
107
+ program.exitOverride();
108
+ registerInitCommand(program);
109
+ await program.parseAsync(['init', '-b', 'develop'], { from: 'user' });
110
+
111
+ expect(mockedSaveProjectConfig).toHaveBeenCalledWith({ clawtMainWorkBranch: 'develop' });
112
+ expect(mockedPrintSuccess).toHaveBeenCalled();
113
+ });
114
+
115
+ it('有 -b 参数且已初始化时更新配置', async () => {
116
+ mockedLoadProjectConfig.mockReturnValue({ clawtMainWorkBranch: 'main' });
117
+
118
+ const program = new Command();
119
+ program.exitOverride();
120
+ registerInitCommand(program);
121
+ await program.parseAsync(['init', '-b', 'develop'], { from: 'user' });
122
+
123
+ expect(mockedSaveProjectConfig).toHaveBeenCalledWith({ clawtMainWorkBranch: 'develop' });
124
+ // 验证 INIT_UPDATED 传入了旧分支名和新分支名
125
+ expect(mockedPrintSuccess).toHaveBeenCalledWith(
126
+ expect.stringContaining('main'),
127
+ );
128
+ expect(mockedPrintSuccess).toHaveBeenCalledWith(
129
+ expect.stringContaining('develop'),
130
+ );
131
+ });
132
+ });
133
+
134
+ describe('handleInitShow (show 子命令)', () => {
135
+ it('clawt init show 展示当前配置', async () => {
136
+ mockedRequireProjectConfig.mockReturnValue({ clawtMainWorkBranch: 'develop' });
137
+
138
+ const program = new Command();
139
+ program.exitOverride();
140
+ registerInitCommand(program);
141
+ await program.parseAsync(['init', 'show'], { from: 'user' });
142
+
143
+ expect(mockedPrintInfo).toHaveBeenCalled();
144
+ expect(mockedSaveProjectConfig).not.toHaveBeenCalled();
145
+ });
146
+ });
@@ -64,7 +64,13 @@ vi.mock('../../../src/utils/index.js', () => ({
64
64
  gitMergeBase: vi.fn(),
65
65
  gitResetSoftTo: vi.fn(),
66
66
  getCurrentBranch: vi.fn(),
67
+ gitResetHard: vi.fn(),
68
+ gitCleanForce: vi.fn(),
69
+ gitCheckout: vi.fn(),
67
70
  resolveTargetWorktree: vi.fn(),
71
+ requireProjectConfig: vi.fn().mockReturnValue({ clawtMainWorkBranch: 'main' }),
72
+ getMainWorkBranch: vi.fn().mockReturnValue('main'),
73
+ ensureOnMainWorkBranch: vi.fn(),
68
74
  }));
69
75
 
70
76
  import { registerMergeCommand } from '../../../src/commands/merge.js';
@@ -175,7 +181,7 @@ describe('handleMerge', () => {
175
181
 
176
182
  it('目标 worktree 有未提交修改且提供 -m 时先提交再合并', async () => {
177
183
  mockedIsWorkingDirClean
178
- .mockReturnValueOnce(true) // 主 worktree 干净
184
+ .mockReturnValueOnce(true) // 主 worktree 状态检测:干净
179
185
  .mockReturnValueOnce(false); // 目标 worktree 不干净
180
186
  mockedConfirmAction.mockResolvedValue(false); // 不清理
181
187
 
@@ -46,6 +46,10 @@ vi.mock('../../../src/utils/index.js', () => ({
46
46
  removeSnapshot: vi.fn(),
47
47
  removeProjectSnapshots: vi.fn(),
48
48
  resolveTargetWorktrees: vi.fn(),
49
+ requireProjectConfig: vi.fn().mockReturnValue({ clawtMainWorkBranch: 'main' }),
50
+ getValidateBranchName: vi.fn((name: string) => `clawt-validate-${name}`),
51
+ deleteValidateBranch: vi.fn(),
52
+ ensureOnMainWorkBranch: vi.fn(),
49
53
  }));
50
54
 
51
55
  import { registerRemoveCommand } from '../../../src/commands/remove.js';
@@ -23,6 +23,8 @@ vi.mock('../../../src/utils/index.js', () => ({
23
23
  confirmDestructiveAction: vi.fn(),
24
24
  printSuccess: vi.fn(),
25
25
  printInfo: vi.fn(),
26
+ requireProjectConfig: vi.fn().mockReturnValue({ clawtMainWorkBranch: 'main' }),
27
+ switchBackIfOnValidateBranch: vi.fn(),
26
28
  }));
27
29
 
28
30
  import { registerResetCommand } from '../../../src/commands/reset.js';
@@ -71,6 +71,8 @@ vi.mock('../../../src/utils/index.js', async (importOriginal) => {
71
71
  parseTasksFromOptions: vi.fn(),
72
72
  createWorktreesByBranches: vi.fn(),
73
73
  printDryRunPreview: vi.fn(),
74
+ requireProjectConfig: vi.fn().mockReturnValue({ clawtMainWorkBranch: 'main' }),
75
+ ensureOnMainWorkBranch: vi.fn().mockResolvedValue(undefined),
74
76
  };
75
77
  });
76
78
 
@@ -25,6 +25,7 @@ vi.mock('../../../src/constants/index.js', () => ({
25
25
  SYNC_MERGING: (branch: string, mainBranch: string) => `正在将 ${mainBranch} 合并到 ${branch}...`,
26
26
  SYNC_CONFLICT: (path: string) => `合并冲突,请手动解决: ${path}`,
27
27
  SYNC_SUCCESS: (branch: string, mainBranch: string) => `✓ 已将 ${mainBranch} 同步到 ${branch}`,
28
+ SYNC_VALIDATE_BRANCH_REBUILT: (validateBranch: string) => `验证分支 ${validateBranch} 已重建`,
28
29
  },
29
30
  AUTO_SAVE_COMMIT_MESSAGE: 'clawt:auto-save',
30
31
  }));
@@ -46,6 +47,11 @@ vi.mock('../../../src/utils/index.js', () => ({
46
47
  printInfo: vi.fn(),
47
48
  printWarning: vi.fn(),
48
49
  resolveTargetWorktree: vi.fn(),
50
+ requireProjectConfig: vi.fn().mockReturnValue({ clawtMainWorkBranch: 'main' }),
51
+ getMainWorkBranch: vi.fn().mockReturnValue('main'),
52
+ rebuildValidateBranch: vi.fn(),
53
+ getValidateBranchName: vi.fn((name: string) => `clawt-validate-${name}`),
54
+ ensureOnMainWorkBranch: vi.fn(),
49
55
  }));
50
56
 
51
57
  import { registerSyncCommand } from '../../../src/commands/sync.js';
@@ -40,6 +40,11 @@ vi.mock('../../../src/constants/index.js', () => ({
40
40
  VALIDATE_PARALLEL_CMD_FAILED: (cmd: string, code: number) => ` ✗ ${cmd}(退出码: ${code})`,
41
41
  VALIDATE_PARALLEL_CMD_ERROR: (cmd: string, msg: string) => ` ✗ ${cmd}(错误: ${msg})`,
42
42
  SEPARATOR: '────',
43
+ VALIDATE_BRANCH_NOT_FOUND: (validateBranch: string, branch: string) => `验证分支 ${validateBranch} 不存在`,
44
+ VALIDATE_SUCCESS_WITH_BRANCH: (branch: string, validateBranch: string) => `✓ 已切换到验证分支 ${validateBranch} 并验证 ${branch}`,
45
+ VALIDATE_CONFIRM_AUTO_SYNC: (branch: string) => `是否自动 sync ${branch}`,
46
+ VALIDATE_AUTO_SYNC_DECLINED: (branch: string) => `已跳过 ${branch} 的自动 sync`,
47
+ VALIDATE_AUTO_SYNC_START: (branch: string) => `正在自动 sync ${branch}`,
43
48
  },
44
49
  }));
45
50
 
@@ -79,6 +84,7 @@ vi.mock('../../../src/utils/index.js', () => ({
79
84
  writeSnapshot: vi.fn(),
80
85
  removeSnapshot: vi.fn(),
81
86
  confirmDestructiveAction: vi.fn(),
87
+ confirmAction: vi.fn(),
82
88
  printSuccess: vi.fn(),
83
89
  printWarning: vi.fn(),
84
90
  printInfo: vi.fn(),
@@ -88,6 +94,13 @@ vi.mock('../../../src/utils/index.js', () => ({
88
94
  printSeparator: vi.fn(),
89
95
  parseParallelCommands: vi.fn(),
90
96
  runParallelCommands: vi.fn(),
97
+ requireProjectConfig: vi.fn().mockReturnValue({ clawtMainWorkBranch: 'main' }),
98
+ getValidateBranchName: vi.fn((name: string) => `clawt-validate-${name}`),
99
+ gitCheckout: vi.fn(),
100
+ ensureOnMainWorkBranch: vi.fn(),
101
+ handleDirtyWorkingDir: vi.fn(),
102
+ checkBranchExists: vi.fn().mockReturnValue(true),
103
+ getCurrentBranch: vi.fn().mockReturnValue('main'),
91
104
  }));
92
105
 
93
106
  import { registerValidateCommand } from '../../../src/commands/validate.js';
@@ -90,9 +90,9 @@ describe('writeConfig', () => {
90
90
  });
91
91
 
92
92
  describe('ensureClawtDirs', () => {
93
- it('确保三个全局目录存在', () => {
93
+ it('确保四个全局目录存在', () => {
94
94
  ensureClawtDirs();
95
- expect(mockedEnsureDir).toHaveBeenCalledTimes(3);
95
+ expect(mockedEnsureDir).toHaveBeenCalledTimes(4);
96
96
  });
97
97
  });
98
98