clawt 1.4.0 → 2.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.
- package/CLAUDE.md +3 -3
- package/README.md +5 -3
- package/dist/index.js +62 -28
- package/docs/spec.md +26 -12
- package/package.json +1 -1
- package/src/commands/merge.ts +57 -1
- package/src/commands/remove.ts +2 -17
- package/src/commands/sync.ts +2 -2
- package/src/constants/git.ts +2 -0
- package/src/constants/index.ts +1 -0
- package/src/constants/messages.ts +8 -0
- package/src/types/command.ts +0 -2
- package/src/types/worktree.ts +2 -2
- package/src/utils/git.ts +44 -17
- package/src/utils/index.ts +3 -0
- package/src/utils/worktree.ts +1 -1
package/CLAUDE.md
CHANGED
|
@@ -62,7 +62,7 @@ run 命令有两种模式:
|
|
|
62
62
|
- 快照存储路径:`~/.clawt/validate-snapshots/<projectName>/<branchName>.patch`(patch 文件)+ `<branchName>.head`(主分支 HEAD hash)
|
|
63
63
|
- 变更检测:同时检测目标 worktree 的未提交修改和已提交 commit,两者均无则提示无需验证
|
|
64
64
|
- 未提交修改处理:有未提交修改时先做临时 commit,diff 完成后通过 `git reset --soft` 撤销恢复原状
|
|
65
|
-
- `merge`:检测目标 worktree 状态(有修改则需 `-m` 提交,已提交则跳过,无变更则报错)→ 合并到主 worktree → pull → push → 可选清理 worktree 和分支(受 `autoDeleteBranch` 配置或交互式确认控制)→ 清理对应的 validate 快照
|
|
65
|
+
- `merge`:检测目标 worktree 状态(有修改则需 `-m` 提交,已提交则跳过,无变更则报错)→ **squash 检测**(检查目标分支是否存在 `AUTO_SAVE_COMMIT_MESSAGE` 前缀的 auto-save commit,如有则提示用户是否压缩所有提交:用户确认后通过 `gitMergeBase` 计算分叉点、`gitResetSoftTo` 将所有 commit reset 到暂存区;有 `-m` 则直接提交继续流程,无 `-m` 则提示用户自行提交后退出)→ 合并到主 worktree → pull → push → 可选清理 worktree 和分支(受 `autoDeleteBranch` 配置或交互式确认控制)→ 清理对应的 validate 快照
|
|
66
66
|
- `run` 中断清理:Ctrl+C 终止所有子进程后,根据 `autoDeleteBranch` 配置自动清理或交互式确认清理本次创建的 worktree 和分支
|
|
67
67
|
|
|
68
68
|
### sync 命令流程
|
|
@@ -78,8 +78,8 @@ run 命令有两种模式:
|
|
|
78
78
|
### 目录层级
|
|
79
79
|
|
|
80
80
|
- `src/commands/` — 各命令的注册与处理逻辑
|
|
81
|
-
- `src/utils/` — 工具函数(git 操作(含三点 diff
|
|
82
|
-
- `src/constants/` — 常量定义(路径、退出码、消息模板、分支规则、配置默认值、终端控制序列、validate 快照目录、sync 相关消息)
|
|
81
|
+
- `src/utils/` — 工具函数(git 操作(含三点 diff、分支合并、冲突检测、merge-base 计算、commit message 检测、soft reset 到指定 commit 等)、shell 执行与子进程管理、分支名处理、worktree 管理与批量清理、配置、格式化输出、交互式输入、Claude Code 交互式启动、validate 快照管理(含 HEAD hash 一致性校验))
|
|
82
|
+
- `src/constants/` — 常量定义(路径、退出码、消息模板、分支规则、配置默认值、终端控制序列、validate 快照目录、sync 相关消息、git 常量(如 `AUTO_SAVE_COMMIT_MESSAGE`)、squash 相关消息)
|
|
83
83
|
- `src/types/` — TypeScript 类型定义
|
|
84
84
|
- `src/errors/` — 自定义 `ClawtError` 错误类(携带退出码)
|
|
85
85
|
- `src/logger/` — winston 日志(按日期滚动,写入 `~/.clawt/logs/`)
|
package/README.md
CHANGED
|
@@ -148,6 +148,8 @@ clawt merge -b <branchName> [-m <commitMessage>]
|
|
|
148
148
|
|
|
149
149
|
将目标 worktree 的变更合并到主 worktree 的当前分支,并推送到远程仓库。如果目标 worktree 工作区有未提交的修改,需要通过 `-m` 提供提交信息;如果目标 worktree 已经提交过(工作区干净但有本地提交),可以省略 `-m` 直接合并。merge 成功后会询问是否清理对应的 worktree 和分支(如果配置了 `autoDeleteBranch: true` 则自动清理)。
|
|
150
150
|
|
|
151
|
+
如果检测到目标分支存在 `clawt sync` 产生的临时提交(auto-save commit),会自动提示是否将所有提交压缩(squash)为一个。用户选择压缩后,所有 commit 会被 reset 到暂存区:如果提供了 `-m` 则直接提交并继续合并流程;如果未提供 `-m` 则提示用户前往目标 worktree 自行提交后重新执行 merge。
|
|
152
|
+
|
|
151
153
|
```bash
|
|
152
154
|
# 目标 worktree 有未提交修改,需提供 -m
|
|
153
155
|
clawt merge -b feature-scheme-1 -m "feat: 实现用户登录功能"
|
|
@@ -168,11 +170,11 @@ clawt remove [options]
|
|
|
168
170
|
# 移除当前项目下所有 worktree
|
|
169
171
|
clawt remove --all
|
|
170
172
|
|
|
171
|
-
# 移除指定分支名下的所有 worktree
|
|
173
|
+
# 移除指定分支名下的所有 worktree(匹配 feature-scheme 和 feature-scheme-*)
|
|
172
174
|
clawt remove -b feature-scheme
|
|
173
175
|
|
|
174
|
-
#
|
|
175
|
-
clawt remove -b feature-scheme
|
|
176
|
+
# 移除单个 worktree(直接写完整分支名)
|
|
177
|
+
clawt remove -b feature-scheme-2
|
|
176
178
|
```
|
|
177
179
|
|
|
178
180
|
移除时会询问是否同时删除对应的本地分支。
|
package/dist/index.js
CHANGED
|
@@ -102,7 +102,16 @@ var MESSAGES = {
|
|
|
102
102
|
\u89E3\u51B3\u51B2\u7A81\u540E\u6267\u884C git add . && git merge --continue`,
|
|
103
103
|
/** validate patch apply 失败,提示用户同步主分支 */
|
|
104
104
|
VALIDATE_PATCH_APPLY_FAILED: (branch) => `\u53D8\u66F4\u8FC1\u79FB\u5931\u8D25\uFF1A\u76EE\u6807\u5206\u652F\u4E0E\u4E3B\u5206\u652F\u5DEE\u5F02\u8FC7\u5927
|
|
105
|
-
\u8BF7\u5148\u6267\u884C clawt sync -b ${branch} \u540C\u6B65\u4E3B\u5206\u652F\u540E\u91CD\u8BD5
|
|
105
|
+
\u8BF7\u5148\u6267\u884C clawt sync -b ${branch} \u540C\u6B65\u4E3B\u5206\u652F\u540E\u91CD\u8BD5`,
|
|
106
|
+
/** merge 检测到 auto-save 提交,提示用户是否压缩 */
|
|
107
|
+
MERGE_SQUASH_PROMPT: "\u68C0\u6D4B\u5230 sync \u4EA7\u751F\u7684\u4E34\u65F6\u63D0\u4EA4\uFF0C\u662F\u5426\u5C06\u6240\u6709\u63D0\u4EA4\u538B\u7F29\u4E3A\u4E00\u4E2A\uFF1F\n \u538B\u7F29\u540E\u53D8\u66F4\u5C06\u4FDD\u7559\u5728\u76EE\u6807worktree\u7684\u6682\u5B58\u533A\uFF0C\u9700\u8981\u91CD\u65B0\u63D0\u4EA4\uFF08\u53EF\u4F7F\u7528 Claude Code Cli\u6216\u5176\u4ED6\u5DE5\u5177\u751F\u6210\u63D0\u4EA4\u4FE1\u606F\uFF09",
|
|
108
|
+
/** squash 完成且通过 -m 直接提交后的提示 */
|
|
109
|
+
MERGE_SQUASH_COMMITTED: (branch) => `\u2713 \u5DF2\u5C06\u5206\u652F ${branch} \u7684\u6240\u6709\u63D0\u4EA4\u538B\u7F29\u4E3A\u4E00\u4E2A`,
|
|
110
|
+
/** squash 完成但未提供 -m,提示用户自行提交 */
|
|
111
|
+
MERGE_SQUASH_PENDING: (worktreePath, branch) => `\u2713 \u5DF2\u5C06\u6240\u6709\u63D0\u4EA4\u538B\u7F29\u5230\u6682\u5B58\u533A
|
|
112
|
+
\u8BF7\u5728\u76EE\u6807 worktree \u4E2D\u63D0\u4EA4\u540E\u91CD\u65B0\u6267\u884C merge\uFF1A
|
|
113
|
+
cd ${worktreePath}
|
|
114
|
+
\u63D0\u4EA4\u5B8C\u6210\u540E\u6267\u884C\uFF1Aclawt merge -b ${branch}`
|
|
106
115
|
};
|
|
107
116
|
|
|
108
117
|
// src/constants/exitCodes.ts
|
|
@@ -146,6 +155,9 @@ function deriveConfigDescriptions(definitions) {
|
|
|
146
155
|
var DEFAULT_CONFIG = deriveDefaultConfig(CONFIG_DEFINITIONS);
|
|
147
156
|
var CONFIG_DESCRIPTIONS = deriveConfigDescriptions(CONFIG_DEFINITIONS);
|
|
148
157
|
|
|
158
|
+
// src/constants/git.ts
|
|
159
|
+
var AUTO_SAVE_COMMIT_MESSAGE = "chore: auto-save before sync";
|
|
160
|
+
|
|
149
161
|
// src/errors/index.ts
|
|
150
162
|
var ClawtError = class extends Error {
|
|
151
163
|
/** 退出码 */
|
|
@@ -325,15 +337,9 @@ function parseShortStat(output) {
|
|
|
325
337
|
}
|
|
326
338
|
return { insertions, deletions };
|
|
327
339
|
}
|
|
328
|
-
function getDiffStat(
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
const uncommittedOutput = execCommand("git diff --shortstat HEAD", { cwd: worktreePath });
|
|
332
|
-
const uncommitted = parseShortStat(uncommittedOutput);
|
|
333
|
-
return {
|
|
334
|
-
insertions: committed.insertions + uncommitted.insertions,
|
|
335
|
-
deletions: committed.deletions + uncommitted.deletions
|
|
336
|
-
};
|
|
340
|
+
function getDiffStat(worktreePath) {
|
|
341
|
+
const output = execCommand("git diff --shortstat HEAD", { cwd: worktreePath });
|
|
342
|
+
return parseShortStat(output);
|
|
337
343
|
}
|
|
338
344
|
function gitDiffCachedBinary(cwd) {
|
|
339
345
|
logger.debug(`\u6267\u884C\u547D\u4EE4: git diff --cached --binary${cwd ? ` (cwd: ${cwd})` : ""}`);
|
|
@@ -364,6 +370,21 @@ function gitApplyFromStdin(patchContent, cwd) {
|
|
|
364
370
|
function gitResetSoft(count = 1, cwd) {
|
|
365
371
|
execCommand(`git reset --soft HEAD~${count}`, { cwd });
|
|
366
372
|
}
|
|
373
|
+
function gitMergeBase(branchA, branchB, cwd) {
|
|
374
|
+
return execCommand(`git merge-base ${branchA} ${branchB}`, { cwd });
|
|
375
|
+
}
|
|
376
|
+
function hasCommitWithMessage(branchName, messagePrefix, cwd) {
|
|
377
|
+
try {
|
|
378
|
+
const output = execCommand(`git log HEAD..${branchName} --format=%s`, { cwd });
|
|
379
|
+
if (!output.trim()) return false;
|
|
380
|
+
return output.trim().split("\n").some((msg) => msg.startsWith(messagePrefix));
|
|
381
|
+
} catch {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function gitResetSoftTo(commitHash, cwd) {
|
|
386
|
+
execCommand(`git reset --soft ${commitHash}`, { cwd });
|
|
387
|
+
}
|
|
367
388
|
|
|
368
389
|
// src/utils/formatter.ts
|
|
369
390
|
import chalk from "chalk";
|
|
@@ -545,7 +566,7 @@ function cleanupWorktrees(worktrees) {
|
|
|
545
566
|
function getWorktreeStatus(worktree) {
|
|
546
567
|
try {
|
|
547
568
|
const commitCount = getCommitCountAhead(worktree.branch);
|
|
548
|
-
const { insertions, deletions } = getDiffStat(worktree.
|
|
569
|
+
const { insertions, deletions } = getDiffStat(worktree.path);
|
|
549
570
|
const hasDirtyFiles = !isWorkingDirClean(worktree.path);
|
|
550
571
|
return { commitCount, insertions, deletions, hasDirtyFiles };
|
|
551
572
|
} catch (error) {
|
|
@@ -718,14 +739,12 @@ function handleCreate(options) {
|
|
|
718
739
|
}
|
|
719
740
|
|
|
720
741
|
// src/commands/remove.ts
|
|
721
|
-
import { join as join4 } from "path";
|
|
722
742
|
function registerRemoveCommand(program2) {
|
|
723
|
-
program2.command("remove").description("\u79FB\u9664 worktree\uFF08\u652F\u6301\u5355\u4E2A/\u6279\u91CF/\u5168\u90E8\uFF09").option("--all", "\u79FB\u9664\u5F53\u524D\u9879\u76EE\u4E0B\u6240\u6709 worktree").option("-b, --branch <branchName>", "\u6307\u5B9A\u5206\u652F\u540D
|
|
743
|
+
program2.command("remove").description("\u79FB\u9664 worktree\uFF08\u652F\u6301\u5355\u4E2A/\u6279\u91CF/\u5168\u90E8\uFF09").option("--all", "\u79FB\u9664\u5F53\u524D\u9879\u76EE\u4E0B\u6240\u6709 worktree").option("-b, --branch <branchName>", "\u6307\u5B9A\u5206\u652F\u540D\uFF08\u5B8C\u6574\u5206\u652F\u540D\u7CBE\u786E\u5339\u914D\uFF09").action(async (options) => {
|
|
724
744
|
await handleRemove(options);
|
|
725
745
|
});
|
|
726
746
|
}
|
|
727
747
|
function resolveWorktreesToRemove(options) {
|
|
728
|
-
const projectDir = getProjectWorktreeDir();
|
|
729
748
|
const allWorktrees = getProjectWorktrees();
|
|
730
749
|
if (options.all) {
|
|
731
750
|
return allWorktrees;
|
|
@@ -733,15 +752,6 @@ function resolveWorktreesToRemove(options) {
|
|
|
733
752
|
if (!options.branch) {
|
|
734
753
|
throw new ClawtError("\u8BF7\u6307\u5B9A --all \u6216 -b <branchName> \u53C2\u6570");
|
|
735
754
|
}
|
|
736
|
-
if (options.index !== void 0) {
|
|
737
|
-
const targetName = `${options.branch}-${options.index}`;
|
|
738
|
-
const targetPath = join4(projectDir, targetName);
|
|
739
|
-
const found = allWorktrees.find((wt) => wt.path === targetPath);
|
|
740
|
-
if (!found) {
|
|
741
|
-
throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(targetName));
|
|
742
|
-
}
|
|
743
|
-
return [found];
|
|
744
|
-
}
|
|
745
755
|
const matched = allWorktrees.filter(
|
|
746
756
|
(wt) => wt.branch === options.branch || wt.branch.startsWith(`${options.branch}-`)
|
|
747
757
|
);
|
|
@@ -975,7 +985,7 @@ async function handleResume(options) {
|
|
|
975
985
|
}
|
|
976
986
|
|
|
977
987
|
// src/commands/validate.ts
|
|
978
|
-
import { join as
|
|
988
|
+
import { join as join4 } from "path";
|
|
979
989
|
import { existsSync as existsSync6 } from "fs";
|
|
980
990
|
import Enquirer2 from "enquirer";
|
|
981
991
|
function registerValidateCommand(program2) {
|
|
@@ -1096,7 +1106,7 @@ async function handleValidate(options) {
|
|
|
1096
1106
|
const projectName = getProjectName();
|
|
1097
1107
|
const mainWorktreePath = getGitTopLevel();
|
|
1098
1108
|
const projectDir = getProjectWorktreeDir();
|
|
1099
|
-
const targetWorktreePath =
|
|
1109
|
+
const targetWorktreePath = join4(projectDir, options.branch);
|
|
1100
1110
|
logger.info(`validate \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F: ${options.branch}`);
|
|
1101
1111
|
if (!existsSync6(targetWorktreePath)) {
|
|
1102
1112
|
throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(options.branch));
|
|
@@ -1131,13 +1141,33 @@ async function handleValidate(options) {
|
|
|
1131
1141
|
}
|
|
1132
1142
|
|
|
1133
1143
|
// src/commands/merge.ts
|
|
1134
|
-
import { join as
|
|
1144
|
+
import { join as join5 } from "path";
|
|
1135
1145
|
import { existsSync as existsSync7 } from "fs";
|
|
1136
1146
|
function registerMergeCommand(program2) {
|
|
1137
1147
|
program2.command("merge").description("\u5408\u5E76\u67D0\u4E2A\u5DF2\u9A8C\u8BC1\u7684 worktree \u5206\u652F\u5230\u4E3B worktree").requiredOption("-b, --branch <branchName>", "\u8981\u5408\u5E76\u7684\u5206\u652F\u540D").option("-m, --message <message>", "\u63D0\u4EA4\u4FE1\u606F\uFF08\u5DE5\u4F5C\u533A\u6709\u4FEE\u6539\u65F6\u5FC5\u586B\uFF09").action(async (options) => {
|
|
1138
1148
|
await handleMerge(options);
|
|
1139
1149
|
});
|
|
1140
1150
|
}
|
|
1151
|
+
async function handleSquashIfNeeded(targetWorktreePath, mainWorktreePath, branchName, commitMessage) {
|
|
1152
|
+
if (!hasCommitWithMessage(branchName, AUTO_SAVE_COMMIT_MESSAGE, mainWorktreePath)) {
|
|
1153
|
+
return false;
|
|
1154
|
+
}
|
|
1155
|
+
const shouldSquash = await confirmAction(MESSAGES.MERGE_SQUASH_PROMPT);
|
|
1156
|
+
if (!shouldSquash) {
|
|
1157
|
+
return false;
|
|
1158
|
+
}
|
|
1159
|
+
const mainBranch = getCurrentBranch(mainWorktreePath);
|
|
1160
|
+
const mergeBase = gitMergeBase(mainBranch, branchName, mainWorktreePath);
|
|
1161
|
+
logger.info(`squash: merge-base = ${mergeBase}, \u5206\u652F = ${branchName}`);
|
|
1162
|
+
gitResetSoftTo(mergeBase, targetWorktreePath);
|
|
1163
|
+
if (commitMessage) {
|
|
1164
|
+
gitCommit(commitMessage, targetWorktreePath);
|
|
1165
|
+
printSuccess(MESSAGES.MERGE_SQUASH_COMMITTED(branchName));
|
|
1166
|
+
return false;
|
|
1167
|
+
}
|
|
1168
|
+
printInfo(MESSAGES.MERGE_SQUASH_PENDING(targetWorktreePath, branchName));
|
|
1169
|
+
return true;
|
|
1170
|
+
}
|
|
1141
1171
|
async function shouldCleanupAfterMerge(branchName) {
|
|
1142
1172
|
const autoDelete = getConfigValue("autoDeleteBranch");
|
|
1143
1173
|
if (autoDelete) {
|
|
@@ -1154,7 +1184,7 @@ async function handleMerge(options) {
|
|
|
1154
1184
|
validateMainWorktree();
|
|
1155
1185
|
const mainWorktreePath = getGitTopLevel();
|
|
1156
1186
|
const projectDir = getProjectWorktreeDir();
|
|
1157
|
-
const targetWorktreePath =
|
|
1187
|
+
const targetWorktreePath = join5(projectDir, options.branch);
|
|
1158
1188
|
logger.info(`merge \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F: ${options.branch}\uFF0C\u63D0\u4EA4\u4FE1\u606F: ${options.message ?? "(\u672A\u63D0\u4F9B)"}`);
|
|
1159
1189
|
if (!existsSync7(targetWorktreePath)) {
|
|
1160
1190
|
throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(options.branch));
|
|
@@ -1166,6 +1196,10 @@ async function handleMerge(options) {
|
|
|
1166
1196
|
}
|
|
1167
1197
|
throw new ClawtError(MESSAGES.MAIN_WORKTREE_DIRTY);
|
|
1168
1198
|
}
|
|
1199
|
+
const shouldExit = await handleSquashIfNeeded(targetWorktreePath, mainWorktreePath, options.branch, options.message);
|
|
1200
|
+
if (shouldExit) {
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1169
1203
|
const shouldCleanup = await shouldCleanupAfterMerge(options.branch);
|
|
1170
1204
|
const targetClean = isWorkingDirClean(targetWorktreePath);
|
|
1171
1205
|
if (!targetClean) {
|
|
@@ -1252,7 +1286,7 @@ function registerSyncCommand(program2) {
|
|
|
1252
1286
|
}
|
|
1253
1287
|
function autoSaveChanges(worktreePath, branch) {
|
|
1254
1288
|
gitAddAll(worktreePath);
|
|
1255
|
-
gitCommit(
|
|
1289
|
+
gitCommit(AUTO_SAVE_COMMIT_MESSAGE, worktreePath);
|
|
1256
1290
|
printInfo(MESSAGES.SYNC_AUTO_COMMITTED(branch));
|
|
1257
1291
|
logger.info(`\u5DF2\u81EA\u52A8\u4FDD\u5B58 ${branch} \u5206\u652F\u7684\u672A\u63D0\u4EA4\u53D8\u66F4`);
|
|
1258
1292
|
}
|
package/docs/spec.md
CHANGED
|
@@ -522,8 +522,7 @@ clawt remove [options]
|
|
|
522
522
|
| 参数 | 说明 |
|
|
523
523
|
| --------- | ---------------------------------------------------------- |
|
|
524
524
|
| `--all` | 移除当前项目 (`~/.clawt/worktrees/<project>/`) 下所有 worktree |
|
|
525
|
-
| `-b <branchName>` |
|
|
526
|
-
| `-b <branchName> -i <index>` | 移除指定 branchName 的某一个 worktree (如 `branchName-2`) |
|
|
525
|
+
| `-b <branchName>` | 移除匹配 branchName 或 branchName-* 的 worktree |
|
|
527
526
|
|
|
528
527
|
**三种移除粒度:**
|
|
529
528
|
|
|
@@ -531,7 +530,7 @@ clawt remove [options]
|
|
|
531
530
|
| ---- | ---------------------------------------- | ------------------------------------------------------------- |
|
|
532
531
|
| 全部 | `clawt remove --all` | `~/.clawt/worktrees/<project>/` 下所有 worktree |
|
|
533
532
|
| 分支 | `clawt remove -b feature-scheme` | `~/.clawt/worktrees/<project>/feature-scheme-*` 的所有 worktree |
|
|
534
|
-
| 单个 | `clawt remove -b feature-scheme
|
|
533
|
+
| 单个 | `clawt remove -b feature-scheme-2` | 仅移除 `feature-scheme-2` 对应的 worktree(完整分支名精确匹配) |
|
|
535
534
|
|
|
536
535
|
**运行流程:**
|
|
537
536
|
|
|
@@ -590,7 +589,22 @@ clawt merge -b <branchName> [-m <commitMessage>]
|
|
|
590
589
|
- 如果存在该分支的 validate 快照(`~/.clawt/validate-snapshots/<project>/<branchName>.patch`),额外输出警告提示用户可先执行 `clawt validate -b <branchName> --clean` 清理
|
|
591
590
|
- 提示 `主 worktree 有未提交的更改,请先处理`,退出
|
|
592
591
|
- 无更改 → 继续
|
|
593
|
-
3.
|
|
592
|
+
3. **Squash 检测与执行(auto-save 临时提交压缩)**
|
|
593
|
+
- 通过 `git log HEAD..<branchName> --format=%s` 检查目标分支是否存在以 `AUTO_SAVE_COMMIT_MESSAGE`(`chore: auto-save before sync`)为前缀的 commit
|
|
594
|
+
- **不存在** → 跳过,进入步骤 4
|
|
595
|
+
- **存在** → 提示用户是否将所有提交压缩为一个:
|
|
596
|
+
```
|
|
597
|
+
检测到 sync 产生的临时提交,是否将所有提交压缩为一个?
|
|
598
|
+
压缩后变更将保留在目标worktree的暂存区,需要重新提交
|
|
599
|
+
```
|
|
600
|
+
- **用户选择不压缩** → 跳过,进入步骤 4
|
|
601
|
+
- **用户选择压缩** →
|
|
602
|
+
1. 获取主分支名(`git rev-parse --abbrev-ref HEAD`)
|
|
603
|
+
2. 计算分叉点:`git merge-base <mainBranch> <branchName>`
|
|
604
|
+
3. 在目标 worktree 中执行 `git reset --soft <merge-base>`,将所有 commit 撤销到暂存区
|
|
605
|
+
4. 如果用户提供了 `-m` → 直接在目标 worktree 执行 `git commit -m '<commitMessage>'`,输出成功提示,继续步骤 4
|
|
606
|
+
5. 如果用户未提供 `-m` → 提示用户前往目标 worktree 自行提交后重新执行 `clawt merge`,**退出流程**
|
|
607
|
+
4. **根据目标 worktree 状态决定是否需要提交**
|
|
594
608
|
- 检测目标 worktree 工作区是否干净(`git status --porcelain`)
|
|
595
609
|
- **工作区有未提交修改**:
|
|
596
610
|
- 如果用户未提供 `-m`,提示 `目标 worktree 有未提交的修改,请通过 -m 参数提供提交信息`,退出
|
|
@@ -604,21 +618,21 @@ clawt merge -b <branchName> [-m <commitMessage>]
|
|
|
604
618
|
- 检查目标分支相对于主分支是否有本地提交(`git log HEAD..<branchName> --oneline`)
|
|
605
619
|
- 有本地提交 → 跳过提交步骤,直接进入合并
|
|
606
620
|
- 无本地提交 → 提示 `目标 worktree 没有任何可合并的变更(工作区干净且无本地提交)`,退出
|
|
607
|
-
|
|
621
|
+
5. **回到主 worktree 进行合并**
|
|
608
622
|
```bash
|
|
609
623
|
cd <主 worktree 路径>
|
|
610
624
|
git merge <branchName>
|
|
611
625
|
```
|
|
612
|
-
|
|
626
|
+
6. **冲突检测**
|
|
613
627
|
- 检查 merge 退出码及 `git status` 是否存在冲突
|
|
614
628
|
- **有冲突** → 提示 `合并存在冲突,请手动处理`,退出
|
|
615
629
|
- **无冲突** → 继续
|
|
616
|
-
|
|
630
|
+
7. **推送**
|
|
617
631
|
```bash
|
|
618
632
|
git pull
|
|
619
633
|
git push
|
|
620
634
|
```
|
|
621
|
-
|
|
635
|
+
8. **输出成功提示**
|
|
622
636
|
|
|
623
637
|
```
|
|
624
638
|
# 提供了 -m 时
|
|
@@ -631,7 +645,7 @@ clawt merge -b <branchName> [-m <commitMessage>]
|
|
|
631
645
|
已推送到远程仓库
|
|
632
646
|
```
|
|
633
647
|
|
|
634
|
-
|
|
648
|
+
9. **merge 成功后清理 worktree 和分支(可选)**
|
|
635
649
|
- 如果配置文件中 `autoDeleteBranch` 为 `true`,自动执行清理
|
|
636
650
|
- 否则交互式询问用户是否清理
|
|
637
651
|
- 用户确认后,依次执行:
|
|
@@ -646,8 +660,8 @@ clawt merge -b <branchName> [-m <commitMessage>]
|
|
|
646
660
|
```
|
|
647
661
|
- 输出清理成功提示:`✓ 已清理 worktree 和分支: <branchName>`
|
|
648
662
|
|
|
649
|
-
|
|
650
|
-
|
|
663
|
+
10. **清理 validate 快照**
|
|
664
|
+
- merge 成功后,如果存在该分支的 validate 快照(`~/.clawt/validate-snapshots/<project>/<branchName>.patch`),自动删除该快照文件(merge 成功后快照已无意义)
|
|
651
665
|
|
|
652
666
|
> **注意:** 清理确认在 merge 操作之前询问(避免 merge 成功后因交互中断而遗留未清理的 worktree),但清理操作在 merge 成功后才执行。
|
|
653
667
|
|
|
@@ -852,7 +866,7 @@ clawt sync -b <branchName>
|
|
|
852
866
|
- 不存在 → 报错退出
|
|
853
867
|
3. **获取主分支名**:通过 `git rev-parse --abbrev-ref HEAD` 获取主 worktree 当前分支名(不硬编码 main/master)
|
|
854
868
|
4. **自动保存未提交变更**:检查目标 worktree 是否有未提交修改
|
|
855
|
-
- 有修改 → 自动执行 `git add . && git commit -m "chore: auto-save before sync
|
|
869
|
+
- 有修改 → 自动执行 `git add . && git commit -m "<AUTO_SAVE_COMMIT_MESSAGE>"` 保存变更(commit message 由常量 `AUTO_SAVE_COMMIT_MESSAGE` 定义,值为 `chore: auto-save before sync`,同时用于 merge 命令的 squash 检测)
|
|
856
870
|
- 无修改 → 跳过
|
|
857
871
|
5. **在目标 worktree 中合并主分支**:
|
|
858
872
|
```bash
|
package/package.json
CHANGED
package/src/commands/merge.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { logger } from '../logger/index.js';
|
|
5
5
|
import { ClawtError } from '../errors/index.js';
|
|
6
|
-
import { MESSAGES } from '../constants/index.js';
|
|
6
|
+
import { MESSAGES, AUTO_SAVE_COMMIT_MESSAGE } from '../constants/index.js';
|
|
7
7
|
import type { MergeOptions } from '../types/index.js';
|
|
8
8
|
import {
|
|
9
9
|
validateMainWorktree,
|
|
@@ -26,6 +26,10 @@ import {
|
|
|
26
26
|
getConfigValue,
|
|
27
27
|
confirmAction,
|
|
28
28
|
cleanupWorktrees,
|
|
29
|
+
hasCommitWithMessage,
|
|
30
|
+
gitMergeBase,
|
|
31
|
+
gitResetSoftTo,
|
|
32
|
+
getCurrentBranch,
|
|
29
33
|
} from '../utils/index.js';
|
|
30
34
|
|
|
31
35
|
/**
|
|
@@ -43,6 +47,52 @@ export function registerMergeCommand(program: Command): void {
|
|
|
43
47
|
});
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
/**
|
|
51
|
+
* 检测并处理目标分支的 auto-save 提交压缩
|
|
52
|
+
* 如果检测到 sync 产生的临时提交,提示用户是否将所有提交压缩为一个
|
|
53
|
+
* @param {string} targetWorktreePath - 目标 worktree 路径
|
|
54
|
+
* @param {string} mainWorktreePath - 主 worktree 路径
|
|
55
|
+
* @param {string} branchName - 分支名
|
|
56
|
+
* @param {string} [commitMessage] - 用户提供的提交信息
|
|
57
|
+
* @returns {Promise<boolean>} 是否需要退出 merge 流程(用户选择 squash 但未提供 -m 时返回 true)
|
|
58
|
+
*/
|
|
59
|
+
async function handleSquashIfNeeded(
|
|
60
|
+
targetWorktreePath: string,
|
|
61
|
+
mainWorktreePath: string,
|
|
62
|
+
branchName: string,
|
|
63
|
+
commitMessage?: string,
|
|
64
|
+
): Promise<boolean> {
|
|
65
|
+
// 检查目标分支是否存在 auto-save commit
|
|
66
|
+
if (!hasCommitWithMessage(branchName, AUTO_SAVE_COMMIT_MESSAGE, mainWorktreePath)) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 提示用户是否压缩
|
|
71
|
+
const shouldSquash = await confirmAction(MESSAGES.MERGE_SQUASH_PROMPT);
|
|
72
|
+
if (!shouldSquash) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 获取当前主分支名并计算分叉点
|
|
77
|
+
const mainBranch = getCurrentBranch(mainWorktreePath);
|
|
78
|
+
const mergeBase = gitMergeBase(mainBranch, branchName, mainWorktreePath);
|
|
79
|
+
logger.info(`squash: merge-base = ${mergeBase}, 分支 = ${branchName}`);
|
|
80
|
+
|
|
81
|
+
// 在目标 worktree 中执行 reset --soft 到分叉点
|
|
82
|
+
gitResetSoftTo(mergeBase, targetWorktreePath);
|
|
83
|
+
|
|
84
|
+
if (commitMessage) {
|
|
85
|
+
// 有 -m 参数,直接提交
|
|
86
|
+
gitCommit(commitMessage, targetWorktreePath);
|
|
87
|
+
printSuccess(MESSAGES.MERGE_SQUASH_COMMITTED(branchName));
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 没有 -m 参数,提示用户自行提交
|
|
92
|
+
printInfo(MESSAGES.MERGE_SQUASH_PENDING(targetWorktreePath, branchName));
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
46
96
|
/**
|
|
47
97
|
* 判断 merge 成功后是否需要清理 worktree 和分支
|
|
48
98
|
* 如果全局配置了 autoDeleteBranch 则直接返回 true,否则询问用户
|
|
@@ -97,6 +147,12 @@ async function handleMerge(options: MergeOptions): Promise<void> {
|
|
|
97
147
|
throw new ClawtError(MESSAGES.MAIN_WORKTREE_DIRTY);
|
|
98
148
|
}
|
|
99
149
|
|
|
150
|
+
// 步骤 3.5:检测是否需要 squash(sync 临时提交压缩)
|
|
151
|
+
const shouldExit = await handleSquashIfNeeded(targetWorktreePath, mainWorktreePath, options.branch, options.message);
|
|
152
|
+
if (shouldExit) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
100
156
|
// merge 前确认是否清理 worktree 和分支
|
|
101
157
|
const shouldCleanup = await shouldCleanupAfterMerge(options.branch);
|
|
102
158
|
|
package/src/commands/remove.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
4
2
|
import { logger } from '../logger/index.js';
|
|
5
3
|
import { ClawtError } from '../errors/index.js';
|
|
6
4
|
import { MESSAGES } from '../constants/index.js';
|
|
@@ -29,8 +27,7 @@ export function registerRemoveCommand(program: Command): void {
|
|
|
29
27
|
.command('remove')
|
|
30
28
|
.description('移除 worktree(支持单个/批量/全部)')
|
|
31
29
|
.option('--all', '移除当前项目下所有 worktree')
|
|
32
|
-
.option('-b, --branch <branchName>', '
|
|
33
|
-
.option('-i, --index <index>', '指定索引(配合 -b 使用)')
|
|
30
|
+
.option('-b, --branch <branchName>', '指定分支名(完整分支名精确匹配)')
|
|
34
31
|
.action(async (options: RemoveOptions) => {
|
|
35
32
|
await handleRemove(options);
|
|
36
33
|
});
|
|
@@ -42,7 +39,6 @@ export function registerRemoveCommand(program: Command): void {
|
|
|
42
39
|
* @returns {Array<{path: string, branch: string}>} 待移除的 worktree 列表
|
|
43
40
|
*/
|
|
44
41
|
function resolveWorktreesToRemove(options: RemoveOptions): Array<{ path: string; branch: string }> {
|
|
45
|
-
const projectDir = getProjectWorktreeDir();
|
|
46
42
|
const allWorktrees = getProjectWorktrees();
|
|
47
43
|
|
|
48
44
|
if (options.all) {
|
|
@@ -53,23 +49,12 @@ function resolveWorktreesToRemove(options: RemoveOptions): Array<{ path: string;
|
|
|
53
49
|
throw new ClawtError('请指定 --all 或 -b <branchName> 参数');
|
|
54
50
|
}
|
|
55
51
|
|
|
56
|
-
if (options.index !== undefined) {
|
|
57
|
-
// 单个移除:branchName-<index>
|
|
58
|
-
const targetName = `${options.branch}-${options.index}`;
|
|
59
|
-
const targetPath = join(projectDir, targetName);
|
|
60
|
-
const found = allWorktrees.find((wt) => wt.path === targetPath);
|
|
61
|
-
if (!found) {
|
|
62
|
-
throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(targetName));
|
|
63
|
-
}
|
|
64
|
-
return [found];
|
|
65
|
-
}
|
|
66
|
-
|
|
67
52
|
// 分支级移除:匹配 branchName 或 branchName-*
|
|
68
53
|
const matched = allWorktrees.filter(
|
|
69
54
|
(wt) => wt.branch === options.branch || wt.branch.startsWith(`${options.branch}-`),
|
|
70
55
|
);
|
|
71
56
|
if (matched.length === 0) {
|
|
72
|
-
throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(options.branch
|
|
57
|
+
throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(options.branch));
|
|
73
58
|
}
|
|
74
59
|
return matched;
|
|
75
60
|
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import type { Command } from 'commander';
|
|
3
3
|
import { logger } from '../logger/index.js';
|
|
4
4
|
import { ClawtError } from '../errors/index.js';
|
|
5
|
-
import { MESSAGES } from '../constants/index.js';
|
|
5
|
+
import { MESSAGES, AUTO_SAVE_COMMIT_MESSAGE } from '../constants/index.js';
|
|
6
6
|
import type { SyncOptions } from '../types/index.js';
|
|
7
7
|
import {
|
|
8
8
|
validateMainWorktree,
|
|
@@ -43,7 +43,7 @@ export function registerSyncCommand(program: Command): void {
|
|
|
43
43
|
*/
|
|
44
44
|
function autoSaveChanges(worktreePath: string, branch: string): void {
|
|
45
45
|
gitAddAll(worktreePath);
|
|
46
|
-
gitCommit(
|
|
46
|
+
gitCommit(AUTO_SAVE_COMMIT_MESSAGE, worktreePath);
|
|
47
47
|
printInfo(MESSAGES.SYNC_AUTO_COMMITTED(branch));
|
|
48
48
|
logger.info(`已自动保存 ${branch} 分支的未提交变更`);
|
|
49
49
|
}
|
package/src/constants/index.ts
CHANGED
|
@@ -4,3 +4,4 @@ export { MESSAGES } from './messages.js';
|
|
|
4
4
|
export { EXIT_CODES } from './exitCodes.js';
|
|
5
5
|
export { ENABLE_BRACKETED_PASTE, DISABLE_BRACKETED_PASTE, PASTE_THRESHOLD_MS } from './terminal.js';
|
|
6
6
|
export { DEFAULT_CONFIG, CONFIG_DESCRIPTIONS, APPEND_SYSTEM_PROMPT } from './config.js';
|
|
7
|
+
export { AUTO_SAVE_COMMIT_MESSAGE } from './git.js';
|
|
@@ -87,4 +87,12 @@ export const MESSAGES = {
|
|
|
87
87
|
/** validate patch apply 失败,提示用户同步主分支 */
|
|
88
88
|
VALIDATE_PATCH_APPLY_FAILED: (branch: string) =>
|
|
89
89
|
`变更迁移失败:目标分支与主分支差异过大\n 请先执行 clawt sync -b ${branch} 同步主分支后重试`,
|
|
90
|
+
/** merge 检测到 auto-save 提交,提示用户是否压缩 */
|
|
91
|
+
MERGE_SQUASH_PROMPT: '检测到 sync 产生的临时提交,是否将所有提交压缩为一个?\n 压缩后变更将保留在目标worktree的暂存区,需要重新提交(可使用 Claude Code Cli或其他工具生成提交信息)',
|
|
92
|
+
/** squash 完成且通过 -m 直接提交后的提示 */
|
|
93
|
+
MERGE_SQUASH_COMMITTED: (branch: string) =>
|
|
94
|
+
`✓ 已将分支 ${branch} 的所有提交压缩为一个`,
|
|
95
|
+
/** squash 完成但未提供 -m,提示用户自行提交 */
|
|
96
|
+
MERGE_SQUASH_PENDING: (worktreePath: string, branch: string) =>
|
|
97
|
+
`✓ 已将所有提交压缩到暂存区\n 请在目标 worktree 中提交后重新执行 merge:\n cd ${worktreePath}\n 提交完成后执行:clawt merge -b ${branch}`,
|
|
90
98
|
} as const;
|
package/src/types/command.ts
CHANGED
package/src/types/worktree.ts
CHANGED
|
@@ -10,9 +10,9 @@ export interface WorktreeInfo {
|
|
|
10
10
|
export interface WorktreeStatus {
|
|
11
11
|
/** 相对于主分支的新增提交数 */
|
|
12
12
|
commitCount: number;
|
|
13
|
-
/**
|
|
13
|
+
/** 工作区和暂存区的新增行数 */
|
|
14
14
|
insertions: number;
|
|
15
|
-
/**
|
|
15
|
+
/** 工作区和暂存区的删除行数 */
|
|
16
16
|
deletions: number;
|
|
17
17
|
/** 工作区是否有未提交修改 */
|
|
18
18
|
hasDirtyFiles: boolean;
|
package/src/utils/git.ts
CHANGED
|
@@ -286,25 +286,14 @@ function parseShortStat(output: string): { insertions: number; deletions: number
|
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
/**
|
|
289
|
-
*
|
|
290
|
-
* @param {string} branchName - 目标分支名
|
|
289
|
+
* 获取 worktree 中工作区和暂存区的变更统计
|
|
291
290
|
* @param {string} worktreePath - worktree 目录路径
|
|
292
|
-
* @
|
|
293
|
-
* @returns {{ insertions: number; deletions: number }} 聚合后的新增和删除行数
|
|
291
|
+
* @returns {{ insertions: number; deletions: number }} 新增和删除行数
|
|
294
292
|
*/
|
|
295
|
-
export function getDiffStat(
|
|
296
|
-
//
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
// 未提交的变更(在 worktree 内执行)
|
|
301
|
-
const uncommittedOutput = execCommand('git diff --shortstat HEAD', { cwd: worktreePath });
|
|
302
|
-
const uncommitted = parseShortStat(uncommittedOutput);
|
|
303
|
-
|
|
304
|
-
return {
|
|
305
|
-
insertions: committed.insertions + uncommitted.insertions,
|
|
306
|
-
deletions: committed.deletions + uncommitted.deletions,
|
|
307
|
-
};
|
|
293
|
+
export function getDiffStat(worktreePath: string): { insertions: number; deletions: number } {
|
|
294
|
+
// 工作区和暂存区相对于 HEAD 的变更
|
|
295
|
+
const output = execCommand('git diff --shortstat HEAD', { cwd: worktreePath });
|
|
296
|
+
return parseShortStat(output);
|
|
308
297
|
}
|
|
309
298
|
|
|
310
299
|
/**
|
|
@@ -380,3 +369,41 @@ export function gitApplyFromStdin(patchContent: Buffer, cwd?: string): void {
|
|
|
380
369
|
export function gitResetSoft(count: number = 1, cwd?: string): void {
|
|
381
370
|
execCommand(`git reset --soft HEAD~${count}`, { cwd });
|
|
382
371
|
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 获取两个分支的分叉点(merge-base)
|
|
375
|
+
* @param {string} branchA - 分支 A
|
|
376
|
+
* @param {string} branchB - 分支 B
|
|
377
|
+
* @param {string} [cwd] - 工作目录
|
|
378
|
+
* @returns {string} merge-base 的 commit hash
|
|
379
|
+
*/
|
|
380
|
+
export function gitMergeBase(branchA: string, branchB: string, cwd?: string): string {
|
|
381
|
+
return execCommand(`git merge-base ${branchA} ${branchB}`, { cwd });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* 检查目标分支相对于当前分支是否存在指定前缀的 commit message
|
|
386
|
+
* 通过 git log HEAD..<branch> 遍历所有新增 commit 的 message
|
|
387
|
+
* @param {string} branchName - 目标分支名
|
|
388
|
+
* @param {string} messagePrefix - 要匹配的 commit message 前缀
|
|
389
|
+
* @param {string} [cwd] - 工作目录
|
|
390
|
+
* @returns {boolean} 是否存在匹配的 commit
|
|
391
|
+
*/
|
|
392
|
+
export function hasCommitWithMessage(branchName: string, messagePrefix: string, cwd?: string): boolean {
|
|
393
|
+
try {
|
|
394
|
+
const output = execCommand(`git log HEAD..${branchName} --format=%s`, { cwd });
|
|
395
|
+
if (!output.trim()) return false;
|
|
396
|
+
return output.trim().split('\n').some((msg) => msg.startsWith(messagePrefix));
|
|
397
|
+
} catch {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* git reset --soft <commitHash>,将指定 commit 之后的所有提交撤销到暂存区
|
|
404
|
+
* @param {string} commitHash - 目标 commit hash(reset 到此处)
|
|
405
|
+
* @param {string} [cwd] - 工作目录
|
|
406
|
+
*/
|
|
407
|
+
export function gitResetSoftTo(commitHash: string, cwd?: string): void {
|
|
408
|
+
execCommand(`git reset --soft ${commitHash}`, { cwd });
|
|
409
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -35,6 +35,9 @@ export {
|
|
|
35
35
|
gitDiffBinaryAgainstBranch,
|
|
36
36
|
gitApplyFromStdin,
|
|
37
37
|
gitResetSoft,
|
|
38
|
+
gitMergeBase,
|
|
39
|
+
hasCommitWithMessage,
|
|
40
|
+
gitResetSoftTo,
|
|
38
41
|
} from './git.js';
|
|
39
42
|
export { sanitizeBranchName, generateBranchNames, validateBranchesNotExist } from './branch.js';
|
|
40
43
|
export { validateMainWorktree, validateGitInstalled, validateClaudeCodeInstalled } from './validation.js';
|
package/src/utils/worktree.ts
CHANGED
|
@@ -115,7 +115,7 @@ export function cleanupWorktrees(worktrees: WorktreeInfo[]): void {
|
|
|
115
115
|
export function getWorktreeStatus(worktree: WorktreeInfo): WorktreeStatus | null {
|
|
116
116
|
try {
|
|
117
117
|
const commitCount = getCommitCountAhead(worktree.branch);
|
|
118
|
-
const { insertions, deletions } = getDiffStat(worktree.
|
|
118
|
+
const { insertions, deletions } = getDiffStat(worktree.path);
|
|
119
119
|
const hasDirtyFiles = !isWorkingDirClean(worktree.path);
|
|
120
120
|
|
|
121
121
|
return { commitCount, insertions, deletions, hasDirtyFiles };
|