clawt 1.2.0 → 1.4.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.
@@ -13,16 +13,24 @@ import {
13
13
  getProjectWorktreeDir,
14
14
  isWorkingDirClean,
15
15
  gitAddAll,
16
+ gitCommit,
16
17
  gitStashPush,
17
- gitStashApply,
18
- gitStashPop,
19
- gitStashList,
20
18
  gitRestoreStaged,
21
19
  gitResetHard,
22
20
  gitCleanForce,
23
- getStatusPorcelain,
21
+ gitDiffCachedBinary,
22
+ gitApplyCachedFromStdin,
23
+ gitDiffBinaryAgainstBranch,
24
+ gitApplyFromStdin,
25
+ gitResetSoft,
26
+ getHeadCommitHash,
27
+ hasLocalCommits,
28
+ hasSnapshot,
29
+ readSnapshot,
30
+ readSnapshotHead,
31
+ writeSnapshot,
32
+ removeSnapshot,
24
33
  printSuccess,
25
- printError,
26
34
  printWarning,
27
35
  printInfo,
28
36
  } from '../utils/index.js';
@@ -36,13 +44,14 @@ export function registerValidateCommand(program: Command): void {
36
44
  .command('validate')
37
45
  .description('在主 worktree 验证某个 worktree 分支的变更')
38
46
  .requiredOption('-b, --branch <branchName>', '要验证的分支名')
47
+ .option('--clean', '清理 validate 状态(重置主 worktree 并删除快照)')
39
48
  .action(async (options: ValidateOptions) => {
40
49
  await handleValidate(options);
41
50
  });
42
51
  }
43
52
 
44
53
  /**
45
- * 处理主 worktree 工作区有未提交更改的情况
54
+ * 处理主 worktree 工作区有未提交更改的情况(首次 validate 时使用)
46
55
  * @param {string} mainWorktreePath - 主 worktree 路径
47
56
  */
48
57
  async function handleDirtyMainWorktree(mainWorktreePath: string): Promise<void> {
@@ -86,11 +95,162 @@ async function handleDirtyMainWorktree(mainWorktreePath: string): Promise<void>
86
95
  }
87
96
  }
88
97
 
98
+ /**
99
+ * 通过 patch 将目标分支的全量变更(已提交 + 未提交)迁移到主 worktree
100
+ * 使用 git diff HEAD...branch --binary 获取变更,避免 stash 方式无法检测已提交 commit 的问题
101
+ * @param {string} targetWorktreePath - 目标 worktree 路径
102
+ * @param {string} mainWorktreePath - 主 worktree 路径
103
+ * @param {string} branchName - 分支名
104
+ * @param {boolean} hasUncommitted - 目标 worktree 是否有未提交修改
105
+ */
106
+ function migrateChangesViaPatch(targetWorktreePath: string, mainWorktreePath: string, branchName: string, hasUncommitted: boolean): void {
107
+ let didTempCommit = false;
108
+
109
+ try {
110
+ // 如果有未提交修改,先做临时 commit 以便 diff 能捕获全部变更
111
+ if (hasUncommitted) {
112
+ gitAddAll(targetWorktreePath);
113
+ gitCommit('clawt:temp-commit-for-validate', targetWorktreePath);
114
+ didTempCommit = true;
115
+ }
116
+
117
+ // 在主 worktree 执行三点 diff,获取目标分支自分叉点以来的全量变更
118
+ const patch = gitDiffBinaryAgainstBranch(branchName, mainWorktreePath);
119
+
120
+ // 应用 patch 到主 worktree 工作目录
121
+ if (patch.length > 0) {
122
+ try {
123
+ gitApplyFromStdin(patch, mainWorktreePath);
124
+ } catch (error) {
125
+ logger.warn(`patch apply 失败: ${error}`);
126
+ printWarning(MESSAGES.VALIDATE_PATCH_APPLY_FAILED(branchName));
127
+ throw error;
128
+ }
129
+ }
130
+ } finally {
131
+ // 确保临时 commit 一定会被撤销,恢复目标 worktree 原状
132
+ if (didTempCommit) {
133
+ gitResetSoft(1, targetWorktreePath);
134
+ gitRestoreStaged(targetWorktreePath);
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * 保存当前主 worktree 工作目录变更为纯净快照 patch
141
+ * 操作序列:git add . → git diff --cached --binary → git restore --staged .
142
+ * 同时记录主分支 HEAD hash,用于增量 validate 一致性校验
143
+ * @param {string} mainWorktreePath - 主 worktree 路径
144
+ * @param {string} projectName - 项目名
145
+ * @param {string} branchName - 分支名
146
+ * @returns {Buffer} 生成的 patch 内容
147
+ */
148
+ function saveCurrentSnapshotPatch(mainWorktreePath: string, projectName: string, branchName: string): Buffer {
149
+ gitAddAll(mainWorktreePath);
150
+ const patch = gitDiffCachedBinary(mainWorktreePath);
151
+ gitRestoreStaged(mainWorktreePath);
152
+ const headHash = getHeadCommitHash(mainWorktreePath);
153
+ writeSnapshot(projectName, branchName, patch, headHash);
154
+ return patch;
155
+ }
156
+
157
+ /**
158
+ * 处理 --clean 选项:清理 validate 状态
159
+ * @param {ValidateOptions} options - 命令选项
160
+ */
161
+ function handleValidateClean(options: ValidateOptions): void {
162
+ validateMainWorktree();
163
+
164
+ const projectName = getProjectName();
165
+ const mainWorktreePath = getGitTopLevel();
166
+
167
+ logger.info(`validate --clean 执行,分支: ${options.branch}`);
168
+
169
+ // 清空主 worktree
170
+ if (!isWorkingDirClean(mainWorktreePath)) {
171
+ gitResetHard(mainWorktreePath);
172
+ gitCleanForce(mainWorktreePath);
173
+ }
174
+
175
+ // 删除对应的 patch 文件
176
+ removeSnapshot(projectName, options.branch);
177
+
178
+ printSuccess(MESSAGES.VALIDATE_CLEANED(options.branch));
179
+ }
180
+
181
+ /**
182
+ * 首次 validate 逻辑(无历史快照)
183
+ * @param {string} targetWorktreePath - 目标 worktree 路径
184
+ * @param {string} mainWorktreePath - 主 worktree 路径
185
+ * @param {string} projectName - 项目名
186
+ * @param {string} branchName - 分支名
187
+ * @param {boolean} hasUncommitted - 目标 worktree 是否有未提交修改
188
+ */
189
+ function handleFirstValidate(targetWorktreePath: string, mainWorktreePath: string, projectName: string, branchName: string, hasUncommitted: boolean): void {
190
+ // 通过 patch 迁移目标分支全量变更到主 worktree
191
+ migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName, hasUncommitted);
192
+
193
+ // 保存纯净快照到 patch 文件
194
+ saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName);
195
+
196
+ // 结果:暂存区=空,工作目录=全量变更
197
+ printSuccess(MESSAGES.VALIDATE_SUCCESS(branchName));
198
+ }
199
+
200
+ /**
201
+ * 增量 validate 逻辑(存在历史快照)
202
+ * @param {string} targetWorktreePath - 目标 worktree 路径
203
+ * @param {string} mainWorktreePath - 主 worktree 路径
204
+ * @param {string} projectName - 项目名
205
+ * @param {string} branchName - 分支名
206
+ * @param {boolean} hasUncommitted - 目标 worktree 是否有未提交修改
207
+ */
208
+ function handleIncrementalValidate(targetWorktreePath: string, mainWorktreePath: string, projectName: string, branchName: string, hasUncommitted: boolean): void {
209
+ // 步骤 1:读取旧 patch(在清空前读取)
210
+ const oldPatch = readSnapshot(projectName, branchName);
211
+
212
+ // 步骤 2:确保主 worktree 干净(调用方已通过 handleDirtyMainWorktree 处理)
213
+ // 这里做兜底清理,防止 handleDirtyMainWorktree 之后仍有残留
214
+ if (!isWorkingDirClean(mainWorktreePath)) {
215
+ gitResetHard(mainWorktreePath);
216
+ gitCleanForce(mainWorktreePath);
217
+ }
218
+
219
+ // 步骤 3:通过 patch 从目标分支获取最新全量变更
220
+ migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName, hasUncommitted);
221
+
222
+ // 步骤 4:保存最新快照
223
+ saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName);
224
+
225
+ // 步骤 5:将旧 patch 应用到暂存区
226
+ if (oldPatch.length > 0) {
227
+ try {
228
+ gitApplyCachedFromStdin(oldPatch, mainWorktreePath);
229
+ } catch (error) {
230
+ // 旧 patch 无法应用(可能文件结构变化太大),降级为全量模式
231
+ logger.warn(`增量 apply 失败: ${error}`);
232
+ printWarning(MESSAGES.INCREMENTAL_VALIDATE_FALLBACK);
233
+ // 降级后暂存区保持为空,工作目录为最新全量变更,与首次 validate 一致
234
+ printSuccess(MESSAGES.VALIDATE_SUCCESS(branchName));
235
+ return;
236
+ }
237
+ }
238
+
239
+ // 结果:暂存区=上次快照,工作目录=最新全量变更
240
+ printSuccess(MESSAGES.INCREMENTAL_VALIDATE_SUCCESS(branchName));
241
+ }
242
+
89
243
  /**
90
244
  * 执行 validate 命令的核心逻辑
91
245
  * @param {ValidateOptions} options - 命令选项
92
246
  */
93
247
  async function handleValidate(options: ValidateOptions): Promise<void> {
248
+ // 处理 --clean 选项
249
+ if (options.clean) {
250
+ handleValidateClean(options);
251
+ return;
252
+ }
253
+
94
254
  validateMainWorktree();
95
255
 
96
256
  const projectName = getProjectName();
@@ -105,33 +265,41 @@ async function handleValidate(options: ValidateOptions): Promise<void> {
105
265
  throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(options.branch));
106
266
  }
107
267
 
108
- // 步骤 1:检测主 worktree 工作区状态
109
- if (!isWorkingDirClean(mainWorktreePath)) {
110
- await handleDirtyMainWorktree(mainWorktreePath);
111
- }
268
+ // 统一检测未提交修改 + 已提交 commit
269
+ const hasUncommitted = !isWorkingDirClean(targetWorktreePath);
270
+ const hasCommitted = hasLocalCommits(options.branch, mainWorktreePath);
112
271
 
113
- // 步骤 2:在目标 worktree 中创建 stash
114
- if (isWorkingDirClean(targetWorktreePath)) {
272
+ if (!hasUncommitted && !hasCommitted) {
115
273
  printInfo(MESSAGES.TARGET_WORKTREE_CLEAN);
116
274
  return;
117
275
  }
118
276
 
119
- const stashMessage = `clawt:validate:${options.branch}`;
120
- gitAddAll(targetWorktreePath);
121
- gitStashPush(stashMessage, targetWorktreePath);
122
- gitStashApply(targetWorktreePath);
123
- gitRestoreStaged(targetWorktreePath);
124
-
125
- // 步骤 3:在主 worktree 应用 stash
126
- const stashList = gitStashList(mainWorktreePath);
127
- const firstLine = stashList.split('\n')[0] || '';
277
+ // 判断是否为增量 validate
278
+ let isIncremental = hasSnapshot(projectName, options.branch);
128
279
 
129
- if (!firstLine.includes(stashMessage)) {
130
- throw new ClawtError(MESSAGES.STASH_CHANGED);
280
+ // 主分支 HEAD 发生变化或旧快照无 .head 记录时,清除后走首次全量模式
281
+ if (isIncremental) {
282
+ const savedHead = readSnapshotHead(projectName, options.branch);
283
+ const currentHead = getHeadCommitHash(mainWorktreePath);
284
+ if (!savedHead || savedHead !== currentHead) {
285
+ logger.info(`主分支 HEAD 不匹配 (${savedHead ?? 'null'} → ${currentHead}),清除旧快照`);
286
+ removeSnapshot(projectName, options.branch);
287
+ isIncremental = false;
288
+ }
131
289
  }
132
290
 
133
- gitStashPop(0, mainWorktreePath);
291
+ if (isIncremental) {
292
+ // 增量模式:主 worktree 有残留状态时让用户选择处理方式
293
+ if (!isWorkingDirClean(mainWorktreePath)) {
294
+ await handleDirtyMainWorktree(mainWorktreePath);
295
+ }
296
+ handleIncrementalValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch, hasUncommitted);
297
+ } else {
298
+ // 首次模式:先确保主 worktree 干净
299
+ if (!isWorkingDirClean(mainWorktreePath)) {
300
+ await handleDirtyMainWorktree(mainWorktreePath);
301
+ }
134
302
 
135
- // 步骤 4:输出成功提示
136
- printSuccess(MESSAGES.VALIDATE_SUCCESS(options.branch));
303
+ handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch, hasUncommitted);
304
+ }
137
305
  }
@@ -1,4 +1,4 @@
1
- export { CLAWT_HOME, CONFIG_PATH, LOGS_DIR, WORKTREES_DIR } from './paths.js';
1
+ export { CLAWT_HOME, CONFIG_PATH, LOGS_DIR, WORKTREES_DIR, VALIDATE_SNAPSHOTS_DIR } from './paths.js';
2
2
  export { INVALID_BRANCH_CHARS } from './branch.js';
3
3
  export { MESSAGES } from './messages.js';
4
4
  export { EXIT_CODES } from './exitCodes.js';
@@ -23,8 +23,6 @@ export const MESSAGES = {
23
23
  MAIN_WORKTREE_DIRTY: '主 worktree 有未提交的更改,请先处理',
24
24
  /** 目标 worktree 无更改 */
25
25
  TARGET_WORKTREE_CLEAN: '该 worktree 的分支上没有任何更改,无需验证',
26
- /** stash 已变更 */
27
- STASH_CHANGED: 'git stash list 已变更,请重新执行',
28
26
  /** validate 成功 */
29
27
  VALIDATE_SUCCESS: (branch: string) =>
30
28
  `✓ 已将分支 ${branch} 的变更应用到主 worktree\n 可以开始验证了`,
@@ -64,4 +62,29 @@ export const MESSAGES = {
64
62
  INVALID_COUNT: (value: string) => `无效的创建数量: "${value}",请输入正整数`,
65
63
  /** worktree 状态获取失败 */
66
64
  WORKTREE_STATUS_UNAVAILABLE: '(状态不可用)',
65
+ /** 增量 validate 成功提示 */
66
+ INCREMENTAL_VALIDATE_SUCCESS: (branch: string) =>
67
+ `✓ 已将分支 ${branch} 的最新变更应用到主 worktree(增量模式)\n 暂存区 = 上次快照,工作目录 = 最新变更`,
68
+ /** 增量 validate 降级为全量模式提示 */
69
+ INCREMENTAL_VALIDATE_FALLBACK: '增量对比失败,已降级为全量模式',
70
+ /** validate 状态已清理 */
71
+ VALIDATE_CLEANED: (branch: string) => `✓ 分支 ${branch} 的 validate 状态已清理`,
72
+ /** merge 命令检测到 validate 状态的提示 */
73
+ MERGE_VALIDATE_STATE_HINT: (branch: string) =>
74
+ `主 worktree 可能存在 validate 残留状态,可先执行 clawt validate -b ${branch} --clean 清理`,
75
+ /** sync 自动保存未提交变更 */
76
+ SYNC_AUTO_COMMITTED: (branch: string) =>
77
+ `已自动保存 ${branch} 分支的未提交变更`,
78
+ /** sync 开始合并 */
79
+ SYNC_MERGING: (targetBranch: string, mainBranch: string) =>
80
+ `正在将 ${mainBranch} 合并到 ${targetBranch} ...`,
81
+ /** sync 成功 */
82
+ SYNC_SUCCESS: (targetBranch: string, mainBranch: string) =>
83
+ `✓ 已将 ${mainBranch} 的最新代码同步到 ${targetBranch}`,
84
+ /** sync 冲突 */
85
+ SYNC_CONFLICT: (worktreePath: string) =>
86
+ `合并存在冲突,请进入目标 worktree 手动解决:\n cd ${worktreePath}\n 解决冲突后执行 git add . && git merge --continue`,
87
+ /** validate patch apply 失败,提示用户同步主分支 */
88
+ VALIDATE_PATCH_APPLY_FAILED: (branch: string) =>
89
+ `变更迁移失败:目标分支与主分支差异过大\n 请先执行 clawt sync -b ${branch} 同步主分支后重试`,
67
90
  } as const;
@@ -12,3 +12,6 @@ export const LOGS_DIR = join(CLAWT_HOME, 'logs');
12
12
 
13
13
  /** worktree 统一存放目录 ~/.clawt/worktrees/ */
14
14
  export const WORKTREES_DIR = join(CLAWT_HOME, 'worktrees');
15
+
16
+ /** validate 快照目录 ~/.clawt/validate-snapshots/ */
17
+ export const VALIDATE_SNAPSHOTS_DIR = join(CLAWT_HOME, 'validate-snapshots');
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ import { registerResumeCommand } from './commands/resume.js';
12
12
  import { registerValidateCommand } from './commands/validate.js';
13
13
  import { registerMergeCommand } from './commands/merge.js';
14
14
  import { registerConfigCommand } from './commands/config.js';
15
+ import { registerSyncCommand } from './commands/sync.js';
15
16
 
16
17
  // 从 package.json 读取版本号,避免硬编码
17
18
  const require = createRequire(import.meta.url);
@@ -36,6 +37,7 @@ registerResumeCommand(program);
36
37
  registerValidateCommand(program);
37
38
  registerMergeCommand(program);
38
39
  registerConfigCommand(program);
40
+ registerSyncCommand(program);
39
41
 
40
42
  // 全局未捕获异常处理
41
43
  process.on('uncaughtException', (error) => {
@@ -18,6 +18,8 @@ export interface RunOptions {
18
18
  export interface ValidateOptions {
19
19
  /** 要验证的分支名 */
20
20
  branch: string;
21
+ /** 清理 validate 状态 */
22
+ clean?: boolean;
21
23
  }
22
24
 
23
25
  /** merge 命令选项 */
@@ -43,3 +45,9 @@ export interface ResumeOptions {
43
45
  /** 要恢复的分支名 */
44
46
  branch: string;
45
47
  }
48
+
49
+ /** sync 命令选项 */
50
+ export interface SyncOptions {
51
+ /** 要同步的分支名 */
52
+ branch: string;
53
+ }
@@ -1,5 +1,5 @@
1
1
  export type { ClawtConfig, ConfigItemDefinition, ConfigDefinitions } from './config.js';
2
- export type { CreateOptions, RunOptions, ValidateOptions, MergeOptions, RemoveOptions, ResumeOptions } from './command.js';
2
+ export type { CreateOptions, RunOptions, ValidateOptions, MergeOptions, RemoveOptions, ResumeOptions, SyncOptions } from './command.js';
3
3
  export type { WorktreeInfo, WorktreeStatus } from './worktree.js';
4
4
  export type { ClaudeCodeResult } from './claudeCode.js';
5
5
  export type { TaskResult, TaskSummary } from './taskResult.js';
package/src/utils/git.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { basename } from 'node:path';
2
- import { execCommand } from './shell.js';
2
+ import { execSync } from 'node:child_process';
3
+ import { execCommand, execCommandWithInput } from './shell.js';
3
4
  import { logger } from '../logger/index.js';
4
5
 
5
6
  /**
@@ -189,6 +190,15 @@ export function gitStashPop(index: number = 0, cwd?: string): void {
189
190
  execCommand(`git stash pop stash@{${index}}`, { cwd });
190
191
  }
191
192
 
193
+ /**
194
+ * git stash drop stash@{index}
195
+ * @param {number} index - stash 索引
196
+ * @param {string} [cwd] - 工作目录
197
+ */
198
+ export function gitStashDrop(index: number = 0, cwd?: string): void {
199
+ execCommand(`git stash drop stash@{${index}}`, { cwd });
200
+ }
201
+
192
202
  /**
193
203
  * git stash list
194
204
  * @param {string} cwd - 工作目录
@@ -296,3 +306,77 @@ export function getDiffStat(branchName: string, worktreePath: string, cwd?: stri
296
306
  deletions: committed.deletions + uncommitted.deletions,
297
307
  };
298
308
  }
309
+
310
+ /**
311
+ * 获取暂存区相对于 HEAD 的完整 diff(含二进制文件)
312
+ * 注意:返回原始输出不做 trim,保留 patch 格式完整性
313
+ * @param {string} [cwd] - 工作目录
314
+ * @returns {Buffer} diff 原始输出(Buffer 格式,保留二进制数据完整性)
315
+ */
316
+ export function gitDiffCachedBinary(cwd?: string): Buffer {
317
+ logger.debug(`执行命令: git diff --cached --binary${cwd ? ` (cwd: ${cwd})` : ''}`);
318
+ return execSync('git diff --cached --binary', {
319
+ cwd,
320
+ stdio: ['pipe', 'pipe', 'pipe'],
321
+ });
322
+ }
323
+
324
+ /**
325
+ * 将 patch 内容通过 stdin 应用到暂存区
326
+ * @param {Buffer} patchContent - patch 内容(Buffer 格式)
327
+ * @param {string} [cwd] - 工作目录
328
+ */
329
+ export function gitApplyCachedFromStdin(patchContent: Buffer, cwd?: string): void {
330
+ execCommandWithInput('git', ['apply', '--cached'], { input: patchContent, cwd });
331
+ }
332
+
333
+ /**
334
+ * 获取当前分支名
335
+ * @param {string} [cwd] - 工作目录
336
+ * @returns {string} 当前分支名
337
+ */
338
+ export function getCurrentBranch(cwd?: string): string {
339
+ return execCommand('git rev-parse --abbrev-ref HEAD', { cwd });
340
+ }
341
+
342
+ /**
343
+ * 获取当前 HEAD 的 commit hash
344
+ * @param {string} [cwd] - 工作目录
345
+ * @returns {string} commit hash
346
+ */
347
+ export function getHeadCommitHash(cwd?: string): string {
348
+ return execCommand('git rev-parse HEAD', { cwd });
349
+ }
350
+
351
+ /**
352
+ * 获取目标分支相对于当前分支的已提交变更(含二进制文件)
353
+ * 使用三点 diff(HEAD...branchName)获取自分叉点以来的变更
354
+ * @param {string} branchName - 目标分支名
355
+ * @param {string} [cwd] - 工作目录(应在主 worktree 中执行)
356
+ * @returns {Buffer} diff 原始输出
357
+ */
358
+ export function gitDiffBinaryAgainstBranch(branchName: string, cwd?: string): Buffer {
359
+ logger.debug(`执行命令: git diff HEAD...${branchName} --binary${cwd ? ` (cwd: ${cwd})` : ''}`);
360
+ return execSync(`git diff HEAD...${branchName} --binary`, {
361
+ cwd,
362
+ stdio: ['pipe', 'pipe', 'pipe'],
363
+ });
364
+ }
365
+
366
+ /**
367
+ * 将 patch 内容通过 stdin 应用到工作目录(不带 --cached)
368
+ * @param {Buffer} patchContent - patch 内容
369
+ * @param {string} [cwd] - 工作目录
370
+ */
371
+ export function gitApplyFromStdin(patchContent: Buffer, cwd?: string): void {
372
+ execCommandWithInput('git', ['apply'], { input: patchContent, cwd });
373
+ }
374
+
375
+ /**
376
+ * git reset --soft HEAD~<count>,撤销 commit 但保留变更在暂存区
377
+ * @param {number} count - 撤销的 commit 数量
378
+ * @param {string} [cwd] - 工作目录
379
+ */
380
+ export function gitResetSoft(count: number = 1, cwd?: string): void {
381
+ execCommand(`git reset --soft HEAD~${count}`, { cwd });
382
+ }
@@ -1,4 +1,4 @@
1
- export { execCommand, spawnProcess, killAllChildProcesses } from './shell.js';
1
+ export { execCommand, spawnProcess, killAllChildProcesses, execCommandWithInput } from './shell.js';
2
2
  export {
3
3
  getGitCommonDir,
4
4
  getGitTopLevel,
@@ -20,6 +20,7 @@ export {
20
20
  gitStashPush,
21
21
  gitStashApply,
22
22
  gitStashPop,
23
+ gitStashDrop,
23
24
  gitStashList,
24
25
  gitRestoreStaged,
25
26
  gitWorktreeList,
@@ -27,6 +28,13 @@ export {
27
28
  hasLocalCommits,
28
29
  getCommitCountAhead,
29
30
  getDiffStat,
31
+ gitDiffCachedBinary,
32
+ gitApplyCachedFromStdin,
33
+ getCurrentBranch,
34
+ getHeadCommitHash,
35
+ gitDiffBinaryAgainstBranch,
36
+ gitApplyFromStdin,
37
+ gitResetSoft,
30
38
  } from './git.js';
31
39
  export { sanitizeBranchName, generateBranchNames, validateBranchesNotExist } from './branch.js';
32
40
  export { validateMainWorktree, validateGitInstalled, validateClaudeCodeInstalled } from './validation.js';
@@ -36,3 +44,4 @@ export { printSuccess, printError, printWarning, printInfo, printSeparator, prin
36
44
  export { ensureDir, removeEmptyDir } from './fs.js';
37
45
  export { multilineInput } from './prompt.js';
38
46
  export { launchInteractiveClaude } from './claude.js';
47
+ export { getSnapshotPath, hasSnapshot, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots, readSnapshotHead } from './validate-snapshot.js';
@@ -1,4 +1,4 @@
1
- import { execSync, spawn, type ChildProcess, type StdioOptions } from 'node:child_process';
1
+ import { execSync, execFileSync, spawn, type ChildProcess, type StdioOptions } from 'node:child_process';
2
2
  import { logger } from '../logger/index.js';
3
3
 
4
4
  /**
@@ -51,3 +51,24 @@ export function killAllChildProcesses(children: ChildProcess[]): void {
51
51
  }
52
52
  }
53
53
  }
54
+
55
+ /**
56
+ * 同步执行命令,通过 stdin 传入数据
57
+ * @param {string} command - 要执行的命令
58
+ * @param {string[]} args - 命令参数
59
+ * @param {object} options - 配置
60
+ * @param {Buffer} options.input - 通过 stdin 传入的数据(Buffer 格式,保留二进制完整性)
61
+ * @param {string} [options.cwd] - 工作目录
62
+ * @returns {string} 命令的标准输出(已 trim)
63
+ * @throws {Error} 命令执行失败时抛出
64
+ */
65
+ export function execCommandWithInput(command: string, args: string[], options: { input: Buffer; cwd?: string }): string {
66
+ logger.debug(`执行命令(stdin): ${command} ${args.join(' ')}${options.cwd ? ` (cwd: ${options.cwd})` : ''}`);
67
+ const result = execFileSync(command, args, {
68
+ cwd: options.cwd,
69
+ input: options.input,
70
+ encoding: 'utf-8',
71
+ stdio: ['pipe', 'pipe', 'pipe'],
72
+ });
73
+ return result.trim();
74
+ }
@@ -0,0 +1,123 @@
1
+ import { join } from 'node:path';
2
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, rmdirSync } from 'node:fs';
3
+ import { VALIDATE_SNAPSHOTS_DIR } from '../constants/index.js';
4
+ import { ensureDir } from './fs.js';
5
+ import { logger } from '../logger/index.js';
6
+
7
+ /**
8
+ * 获取指定项目和分支的 validate 快照文件路径
9
+ * @param {string} projectName - 项目名
10
+ * @param {string} branchName - 分支名
11
+ * @returns {string} patch 文件的绝对路径
12
+ */
13
+ export function getSnapshotPath(projectName: string, branchName: string): string {
14
+ return join(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.patch`);
15
+ }
16
+
17
+ /**
18
+ * 获取指定项目和分支的快照 HEAD hash 文件路径
19
+ * @param {string} projectName - 项目名
20
+ * @param {string} branchName - 分支名
21
+ * @returns {string} head 文件的绝对路径
22
+ */
23
+ function getSnapshotHeadPath(projectName: string, branchName: string): string {
24
+ return join(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.head`);
25
+ }
26
+
27
+ /**
28
+ * 判断指定项目和分支是否存在 validate 快照
29
+ * @param {string} projectName - 项目名
30
+ * @param {string} branchName - 分支名
31
+ * @returns {boolean} 快照是否存在
32
+ */
33
+ export function hasSnapshot(projectName: string, branchName: string): boolean {
34
+ return existsSync(getSnapshotPath(projectName, branchName));
35
+ }
36
+
37
+ /**
38
+ * 读取指定项目和分支的 validate 快照内容
39
+ * @param {string} projectName - 项目名
40
+ * @param {string} branchName - 分支名
41
+ * @returns {Buffer} patch 文件内容(Buffer 格式,保留二进制完整性)
42
+ */
43
+ export function readSnapshot(projectName: string, branchName: string): Buffer {
44
+ const snapshotPath = getSnapshotPath(projectName, branchName);
45
+ logger.debug(`读取 validate 快照: ${snapshotPath}`);
46
+ return readFileSync(snapshotPath);
47
+ }
48
+
49
+ /**
50
+ * 写入 validate 快照内容(自动创建目录)
51
+ * @param {string} projectName - 项目名
52
+ * @param {string} branchName - 分支名
53
+ * @param {Buffer} patch - patch 内容(Buffer 格式)
54
+ * @param {string} [headHash] - 主分支 HEAD commit hash(用于增量 validate 一致性校验)
55
+ */
56
+ export function writeSnapshot(projectName: string, branchName: string, patch: Buffer, headHash?: string): void {
57
+ const snapshotPath = getSnapshotPath(projectName, branchName);
58
+ const snapshotDir = join(VALIDATE_SNAPSHOTS_DIR, projectName);
59
+ ensureDir(snapshotDir);
60
+ writeFileSync(snapshotPath, patch);
61
+ // 保存主分支 HEAD hash,用于下次增量 validate 时校验一致性
62
+ if (headHash) {
63
+ writeFileSync(getSnapshotHeadPath(projectName, branchName), headHash, 'utf-8');
64
+ }
65
+ logger.info(`已保存 validate 快照: ${snapshotPath}`);
66
+ }
67
+
68
+ /**
69
+ * 删除指定项目和分支的 validate 快照
70
+ * @param {string} projectName - 项目名
71
+ * @param {string} branchName - 分支名
72
+ */
73
+ export function removeSnapshot(projectName: string, branchName: string): void {
74
+ const snapshotPath = getSnapshotPath(projectName, branchName);
75
+ if (existsSync(snapshotPath)) {
76
+ unlinkSync(snapshotPath);
77
+ logger.info(`已删除 validate 快照: ${snapshotPath}`);
78
+ }
79
+ // 同时删除对应的 .head 文件
80
+ const headPath = getSnapshotHeadPath(projectName, branchName);
81
+ if (existsSync(headPath)) {
82
+ unlinkSync(headPath);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * 读取快照保存时的主分支 HEAD commit hash
88
+ * @param {string} projectName - 项目名
89
+ * @param {string} branchName - 分支名
90
+ * @returns {string | null} HEAD hash,不存在则返回 null
91
+ */
92
+ export function readSnapshotHead(projectName: string, branchName: string): string | null {
93
+ const headPath = getSnapshotHeadPath(projectName, branchName);
94
+ if (!existsSync(headPath)) {
95
+ return null;
96
+ }
97
+ return readFileSync(headPath, 'utf-8').trim();
98
+ }
99
+
100
+ /**
101
+ * 删除指定项目的所有 validate 快照
102
+ * @param {string} projectName - 项目名
103
+ */
104
+ export function removeProjectSnapshots(projectName: string): void {
105
+ const projectDir = join(VALIDATE_SNAPSHOTS_DIR, projectName);
106
+ if (!existsSync(projectDir)) {
107
+ return;
108
+ }
109
+
110
+ const files = readdirSync(projectDir);
111
+ for (const file of files) {
112
+ unlinkSync(join(projectDir, file));
113
+ }
114
+
115
+ // 尝试删除空目录
116
+ try {
117
+ rmdirSync(projectDir);
118
+ } catch {
119
+ // 目录非空或其他原因,忽略
120
+ }
121
+
122
+ logger.info(`已删除项目 ${projectName} 的所有 validate 快照`);
123
+ }