clawt 2.16.2 → 2.16.3
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/README.md +33 -0
- package/dist/index.js +100 -28
- package/dist/postinstall.js +10 -2
- package/docs/spec.md +41 -22
- package/package.json +1 -1
- package/src/commands/status.ts +68 -39
- package/src/constants/messages/status.ts +10 -2
- package/src/types/index.ts +1 -1
- package/src/types/status.ts +14 -4
- package/src/utils/formatter.ts +42 -0
- package/src/utils/git.ts +20 -0
- package/src/utils/index.ts +3 -2
- package/src/utils/validate-snapshot.ts +14 -1
- package/tests/unit/commands/status.test.ts +128 -9
- package/tests/unit/utils/formatter.test.ts +42 -1
- package/tests/unit/utils/git.test.ts +36 -0
- package/tests/unit/utils/validate-snapshot.test.ts +21 -1
package/README.md
CHANGED
|
@@ -171,6 +171,39 @@ clawt status # 文本格式
|
|
|
171
171
|
clawt status --json # JSON 格式
|
|
172
172
|
```
|
|
173
173
|
|
|
174
|
+
展示主 worktree 状态、各 worktree 的变更详情(含分支创建时间和验证状态)以及 validate 快照摘要:
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
════════════════════════════════════════
|
|
178
|
+
项目状态总览: my-project
|
|
179
|
+
════════════════════════════════════════
|
|
180
|
+
|
|
181
|
+
◆ 主 Worktree
|
|
182
|
+
分支: main
|
|
183
|
+
状态: ✓ 干净
|
|
184
|
+
|
|
185
|
+
────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
◆ Worktree 列表 (2 个)
|
|
188
|
+
|
|
189
|
+
● feature-login [已提交]
|
|
190
|
+
+120 -30 3 个本地提交 与主分支同步
|
|
191
|
+
创建于 3 天前
|
|
192
|
+
上次验证: 2 小时前
|
|
193
|
+
|
|
194
|
+
● feature-signup [未提交修改]
|
|
195
|
+
+45 -10 1 个本地提交 落后主分支 2 个提交
|
|
196
|
+
创建于 1 天前
|
|
197
|
+
✗ 未验证
|
|
198
|
+
|
|
199
|
+
────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
◆ Validate 快照 (3 个)
|
|
202
|
+
其中 1 个快照对应的 worktree 已不存在
|
|
203
|
+
|
|
204
|
+
════════════════════════════════════════
|
|
205
|
+
```
|
|
206
|
+
|
|
174
207
|
### `clawt reset` — 重置主 worktree 到干净状态
|
|
175
208
|
|
|
176
209
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -323,7 +323,7 @@ var STATUS_MESSAGES = {
|
|
|
323
323
|
/** status worktrees 区块标题 */
|
|
324
324
|
STATUS_WORKTREES_SECTION: "Worktree \u5217\u8868",
|
|
325
325
|
/** status 快照区块标题 */
|
|
326
|
-
STATUS_SNAPSHOTS_SECTION: "
|
|
326
|
+
STATUS_SNAPSHOTS_SECTION: "Validate \u5FEB\u7167",
|
|
327
327
|
/** status 无 worktree */
|
|
328
328
|
STATUS_NO_WORKTREES: "(\u65E0\u6D3B\u8DC3 worktree)",
|
|
329
329
|
/** status 无未清理快照 */
|
|
@@ -337,7 +337,15 @@ var STATUS_MESSAGES = {
|
|
|
337
337
|
/** status 变更状态:无变更 */
|
|
338
338
|
STATUS_CHANGE_CLEAN: "\u65E0\u53D8\u66F4",
|
|
339
339
|
/** status 快照对应 worktree 已不存在 */
|
|
340
|
-
STATUS_SNAPSHOT_ORPHANED:
|
|
340
|
+
STATUS_SNAPSHOT_ORPHANED: (count) => `\u5176\u4E2D ${count} \u4E2A\u5FEB\u7167\u5BF9\u5E94\u7684 worktree \u5DF2\u4E0D\u5B58\u5728`,
|
|
341
|
+
/** status 分支创建时间标签 */
|
|
342
|
+
STATUS_CREATED_AT: (relativeTime) => `\u521B\u5EFA\u4E8E ${relativeTime}`,
|
|
343
|
+
/** status 分支无分叉提交时的提示 */
|
|
344
|
+
STATUS_NO_DIVERGED_COMMITS: "\u5C1A\u65E0\u5206\u53C9\u63D0\u4EA4",
|
|
345
|
+
/** status 上次验证时间标签 */
|
|
346
|
+
STATUS_LAST_VALIDATED: (relativeTime) => `\u4E0A\u6B21\u9A8C\u8BC1: ${relativeTime}`,
|
|
347
|
+
/** status 未验证警示 */
|
|
348
|
+
STATUS_NOT_VALIDATED: "\u2717 \u672A\u9A8C\u8BC1"
|
|
341
349
|
};
|
|
342
350
|
|
|
343
351
|
// src/constants/messages/alias.ts
|
|
@@ -787,6 +795,17 @@ function gitApplyCachedCheck(patchContent, cwd) {
|
|
|
787
795
|
return false;
|
|
788
796
|
}
|
|
789
797
|
}
|
|
798
|
+
function getBranchCreatedAt(branchName, cwd) {
|
|
799
|
+
try {
|
|
800
|
+
const output = execCommand(`git reflog show ${branchName} --format=%cI`, { cwd });
|
|
801
|
+
if (!output.trim()) return null;
|
|
802
|
+
const lines = output.trim().split("\n");
|
|
803
|
+
const lastLine = lines[lines.length - 1];
|
|
804
|
+
return lastLine || null;
|
|
805
|
+
} catch {
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
790
809
|
|
|
791
810
|
// src/utils/formatter.ts
|
|
792
811
|
import chalk2 from "chalk";
|
|
@@ -856,6 +875,35 @@ function formatDuration(ms) {
|
|
|
856
875
|
const seconds = Math.floor(totalSeconds % 60);
|
|
857
876
|
return `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
|
858
877
|
}
|
|
878
|
+
function formatRelativeTime(isoDateString) {
|
|
879
|
+
const date = new Date(isoDateString);
|
|
880
|
+
const now = /* @__PURE__ */ new Date();
|
|
881
|
+
const diffMs = now.getTime() - date.getTime();
|
|
882
|
+
if (isNaN(diffMs)) {
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
if (diffMs < 0 || diffMs < 60 * 1e3) {
|
|
886
|
+
return "\u521A\u521A";
|
|
887
|
+
}
|
|
888
|
+
const diffMinutes = Math.floor(diffMs / (1e3 * 60));
|
|
889
|
+
const diffHours = Math.floor(diffMs / (1e3 * 60 * 60));
|
|
890
|
+
const diffDays = Math.floor(diffMs / (1e3 * 60 * 60 * 24));
|
|
891
|
+
if (diffHours < 1) {
|
|
892
|
+
return `${diffMinutes} \u5206\u949F\u524D`;
|
|
893
|
+
}
|
|
894
|
+
if (diffDays < 1) {
|
|
895
|
+
return `${diffHours} \u5C0F\u65F6\u524D`;
|
|
896
|
+
}
|
|
897
|
+
if (diffDays < 30) {
|
|
898
|
+
return `${diffDays} \u5929\u524D`;
|
|
899
|
+
}
|
|
900
|
+
if (diffDays < 365) {
|
|
901
|
+
const months = Math.floor(diffDays / 30);
|
|
902
|
+
return `${months} \u4E2A\u6708\u524D`;
|
|
903
|
+
}
|
|
904
|
+
const years = Math.floor(diffDays / 365);
|
|
905
|
+
return `${years} \u5E74\u524D`;
|
|
906
|
+
}
|
|
859
907
|
|
|
860
908
|
// src/utils/branch.ts
|
|
861
909
|
function sanitizeBranchName(branchName) {
|
|
@@ -1199,7 +1247,7 @@ function launchInteractiveClaudeInNewTerminal(worktree, hasPreviousSession) {
|
|
|
1199
1247
|
|
|
1200
1248
|
// src/utils/validate-snapshot.ts
|
|
1201
1249
|
import { join as join4 } from "path";
|
|
1202
|
-
import { existsSync as existsSync7, readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync, readdirSync as readdirSync4, rmdirSync as rmdirSync2 } from "fs";
|
|
1250
|
+
import { existsSync as existsSync7, readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync, readdirSync as readdirSync4, rmdirSync as rmdirSync2, statSync } from "fs";
|
|
1203
1251
|
function getSnapshotPath(projectName, branchName) {
|
|
1204
1252
|
return join4(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.tree`);
|
|
1205
1253
|
}
|
|
@@ -1209,6 +1257,12 @@ function getSnapshotHeadPath(projectName, branchName) {
|
|
|
1209
1257
|
function hasSnapshot(projectName, branchName) {
|
|
1210
1258
|
return existsSync7(getSnapshotPath(projectName, branchName));
|
|
1211
1259
|
}
|
|
1260
|
+
function getSnapshotModifiedTime(projectName, branchName) {
|
|
1261
|
+
const snapshotPath = getSnapshotPath(projectName, branchName);
|
|
1262
|
+
if (!existsSync7(snapshotPath)) return null;
|
|
1263
|
+
const stat = statSync(snapshotPath);
|
|
1264
|
+
return stat.mtime.toISOString();
|
|
1265
|
+
}
|
|
1212
1266
|
function readSnapshot(projectName, branchName) {
|
|
1213
1267
|
const snapshotPath = getSnapshotPath(projectName, branchName);
|
|
1214
1268
|
const headPath = getSnapshotHeadPath(projectName, branchName);
|
|
@@ -2849,15 +2903,17 @@ function collectWorktreeDetailedStatus(worktree, projectName) {
|
|
|
2849
2903
|
const changeStatus = detectChangeStatus(worktree);
|
|
2850
2904
|
const { commitsAhead, commitsBehind } = countCommitDivergence(worktree.branch);
|
|
2851
2905
|
const { insertions, deletions } = countDiffStat(worktree.path);
|
|
2906
|
+
const createdAt = resolveBranchCreatedAt(worktree.branch);
|
|
2852
2907
|
return {
|
|
2853
2908
|
path: worktree.path,
|
|
2854
2909
|
branch: worktree.branch,
|
|
2855
2910
|
changeStatus,
|
|
2856
2911
|
commitsAhead,
|
|
2857
2912
|
commitsBehind,
|
|
2858
|
-
|
|
2913
|
+
snapshotTime: resolveSnapshotTime(projectName, worktree.branch),
|
|
2859
2914
|
insertions,
|
|
2860
|
-
deletions
|
|
2915
|
+
deletions,
|
|
2916
|
+
createdAt
|
|
2861
2917
|
};
|
|
2862
2918
|
}
|
|
2863
2919
|
function detectChangeStatus(worktree) {
|
|
@@ -2893,13 +2949,28 @@ function countDiffStat(worktreePath) {
|
|
|
2893
2949
|
return { insertions: 0, deletions: 0 };
|
|
2894
2950
|
}
|
|
2895
2951
|
}
|
|
2952
|
+
function resolveBranchCreatedAt(branchName) {
|
|
2953
|
+
try {
|
|
2954
|
+
return getBranchCreatedAt(branchName);
|
|
2955
|
+
} catch {
|
|
2956
|
+
return null;
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
function resolveSnapshotTime(projectName, branchName) {
|
|
2960
|
+
try {
|
|
2961
|
+
return getSnapshotModifiedTime(projectName, branchName);
|
|
2962
|
+
} catch {
|
|
2963
|
+
return null;
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2896
2966
|
function collectSnapshots(projectName, worktrees) {
|
|
2897
2967
|
const snapshotBranches = getProjectSnapshotBranches(projectName);
|
|
2898
2968
|
const worktreeBranchSet = new Set(worktrees.map((wt) => wt.branch));
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2969
|
+
const orphaned = snapshotBranches.filter((branch) => !worktreeBranchSet.has(branch)).length;
|
|
2970
|
+
return {
|
|
2971
|
+
total: snapshotBranches.length,
|
|
2972
|
+
orphaned
|
|
2973
|
+
};
|
|
2903
2974
|
}
|
|
2904
2975
|
function printStatusAsJson(result) {
|
|
2905
2976
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -2942,21 +3013,30 @@ function printWorktreesSection(worktrees, total) {
|
|
|
2942
3013
|
function printWorktreeItem(wt) {
|
|
2943
3014
|
const statusLabel = formatChangeStatusLabel(wt.changeStatus);
|
|
2944
3015
|
printInfo(` ${chalk8.bold("\u25CF")} ${chalk8.bold(wt.branch)} [${statusLabel}]`);
|
|
2945
|
-
const parts = [];
|
|
2946
3016
|
if (wt.insertions > 0 || wt.deletions > 0) {
|
|
2947
|
-
|
|
3017
|
+
printInfo(` ${chalk8.green(`+${wt.insertions}`)} ${chalk8.red(`-${wt.deletions}`)}`);
|
|
2948
3018
|
}
|
|
2949
3019
|
if (wt.commitsAhead > 0) {
|
|
2950
|
-
|
|
3020
|
+
printInfo(` ${chalk8.yellow(`${wt.commitsAhead} \u4E2A\u672C\u5730\u63D0\u4EA4`)}`);
|
|
2951
3021
|
}
|
|
2952
3022
|
if (wt.commitsBehind > 0) {
|
|
2953
|
-
|
|
3023
|
+
printInfo(` ${chalk8.yellow(`\u843D\u540E\u4E3B\u5206\u652F ${wt.commitsBehind} \u4E2A\u63D0\u4EA4`)}`);
|
|
2954
3024
|
} else {
|
|
2955
|
-
|
|
3025
|
+
printInfo(` ${chalk8.green("\u4E0E\u4E3B\u5206\u652F\u540C\u6B65")}`);
|
|
3026
|
+
}
|
|
3027
|
+
if (wt.createdAt) {
|
|
3028
|
+
const relativeTime = formatRelativeTime(wt.createdAt);
|
|
3029
|
+
if (relativeTime) {
|
|
3030
|
+
printInfo(` ${chalk8.gray(MESSAGES.STATUS_CREATED_AT(relativeTime))}`);
|
|
3031
|
+
}
|
|
2956
3032
|
}
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
3033
|
+
if (wt.snapshotTime) {
|
|
3034
|
+
const relativeTime = formatRelativeTime(wt.snapshotTime);
|
|
3035
|
+
if (relativeTime) {
|
|
3036
|
+
printInfo(` ${chalk8.green(MESSAGES.STATUS_LAST_VALIDATED(relativeTime))}`);
|
|
3037
|
+
}
|
|
3038
|
+
} else {
|
|
3039
|
+
printInfo(` ${chalk8.red(MESSAGES.STATUS_NOT_VALIDATED)}`);
|
|
2960
3040
|
}
|
|
2961
3041
|
printInfo("");
|
|
2962
3042
|
}
|
|
@@ -2973,17 +3053,9 @@ function formatChangeStatusLabel(status) {
|
|
|
2973
3053
|
}
|
|
2974
3054
|
}
|
|
2975
3055
|
function printSnapshotsSection(snapshots) {
|
|
2976
|
-
printInfo(` ${chalk8.bold("\u25C6")} ${chalk8.bold(MESSAGES.STATUS_SNAPSHOTS_SECTION)} (${snapshots.
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
printInfo(` ${MESSAGES.STATUS_NO_SNAPSHOTS}`);
|
|
2980
|
-
printInfo("");
|
|
2981
|
-
return;
|
|
2982
|
-
}
|
|
2983
|
-
for (const snap of snapshots) {
|
|
2984
|
-
const orphanLabel = snap.worktreeExists ? "" : ` ${chalk8.yellow(MESSAGES.STATUS_SNAPSHOT_ORPHANED)}`;
|
|
2985
|
-
const icon = snap.worktreeExists ? chalk8.blue("\u25CF") : chalk8.yellow("\u26A0");
|
|
2986
|
-
printInfo(` ${icon} ${snap.branch}${orphanLabel}`);
|
|
3056
|
+
printInfo(` ${chalk8.bold("\u25C6")} ${chalk8.bold(MESSAGES.STATUS_SNAPSHOTS_SECTION)} (${snapshots.total} \u4E2A)`);
|
|
3057
|
+
if (snapshots.orphaned > 0) {
|
|
3058
|
+
printInfo(` ${chalk8.yellow(MESSAGES.STATUS_SNAPSHOT_ORPHANED(snapshots.orphaned))}`);
|
|
2987
3059
|
}
|
|
2988
3060
|
printInfo("");
|
|
2989
3061
|
}
|
package/dist/postinstall.js
CHANGED
|
@@ -314,7 +314,7 @@ var STATUS_MESSAGES = {
|
|
|
314
314
|
/** status worktrees 区块标题 */
|
|
315
315
|
STATUS_WORKTREES_SECTION: "Worktree \u5217\u8868",
|
|
316
316
|
/** status 快照区块标题 */
|
|
317
|
-
STATUS_SNAPSHOTS_SECTION: "
|
|
317
|
+
STATUS_SNAPSHOTS_SECTION: "Validate \u5FEB\u7167",
|
|
318
318
|
/** status 无 worktree */
|
|
319
319
|
STATUS_NO_WORKTREES: "(\u65E0\u6D3B\u8DC3 worktree)",
|
|
320
320
|
/** status 无未清理快照 */
|
|
@@ -328,7 +328,15 @@ var STATUS_MESSAGES = {
|
|
|
328
328
|
/** status 变更状态:无变更 */
|
|
329
329
|
STATUS_CHANGE_CLEAN: "\u65E0\u53D8\u66F4",
|
|
330
330
|
/** status 快照对应 worktree 已不存在 */
|
|
331
|
-
STATUS_SNAPSHOT_ORPHANED:
|
|
331
|
+
STATUS_SNAPSHOT_ORPHANED: (count) => `\u5176\u4E2D ${count} \u4E2A\u5FEB\u7167\u5BF9\u5E94\u7684 worktree \u5DF2\u4E0D\u5B58\u5728`,
|
|
332
|
+
/** status 分支创建时间标签 */
|
|
333
|
+
STATUS_CREATED_AT: (relativeTime) => `\u521B\u5EFA\u4E8E ${relativeTime}`,
|
|
334
|
+
/** status 分支无分叉提交时的提示 */
|
|
335
|
+
STATUS_NO_DIVERGED_COMMITS: "\u5C1A\u65E0\u5206\u53C9\u63D0\u4EA4",
|
|
336
|
+
/** status 上次验证时间标签 */
|
|
337
|
+
STATUS_LAST_VALIDATED: (relativeTime) => `\u4E0A\u6B21\u9A8C\u8BC1: ${relativeTime}`,
|
|
338
|
+
/** status 未验证警示 */
|
|
339
|
+
STATUS_NOT_VALIDATED: "\u2717 \u672A\u9A8C\u8BC1"
|
|
332
340
|
};
|
|
333
341
|
|
|
334
342
|
// src/constants/messages/alias.ts
|
package/docs/spec.md
CHANGED
|
@@ -1431,7 +1431,7 @@ clawt status [--json]
|
|
|
1431
1431
|
|
|
1432
1432
|
**使用场景:**
|
|
1433
1433
|
|
|
1434
|
-
在管理多个 worktree 时,快速了解项目全局状态:主 worktree 当前分支及干净状态、所有 worktree
|
|
1434
|
+
在管理多个 worktree 时,快速了解项目全局状态:主 worktree 当前分支及干净状态、所有 worktree 的变更情况和与主分支的同步状态、validate 快照摘要。
|
|
1435
1435
|
|
|
1436
1436
|
**运行流程:**
|
|
1437
1437
|
|
|
@@ -1446,17 +1446,18 @@ clawt status [--json]
|
|
|
1446
1446
|
- **变更状态**(优先级:合并冲突 > 未提交修改 > 已提交 > 无变更)
|
|
1447
1447
|
- **行数差异**(新增/删除行数,通过 `getDiffStat()` 获取)
|
|
1448
1448
|
- **提交差异**(相对于主分支的领先提交数 `getCommitCountAhead()` 和落后提交数 `getCommitCountBehind()`)
|
|
1449
|
-
-
|
|
1450
|
-
|
|
1449
|
+
- **快照时间**(validate 快照文件的 mtime,通过 `getSnapshotModifiedTime()` 获取,返回 ISO 8601 时间字符串或 null)
|
|
1450
|
+
- **分支创建时间**(通过 `getBranchCreatedAt()` 从 git reflog 获取分支创建时的时间戳)
|
|
1451
|
+
4. **收集 validate 快照摘要**:
|
|
1451
1452
|
- 通过 `getProjectSnapshotBranches()` 扫描快照目录下的 `.tree` 文件获取所有存在快照的分支名
|
|
1452
|
-
-
|
|
1453
|
+
- 统计快照总数和孤立快照数(对应 worktree 已不存在的快照)
|
|
1453
1454
|
5. **输出状态信息**:
|
|
1454
1455
|
- 指定 `--json` → 以 JSON 格式输出完整状态数据(`JSON.stringify`)
|
|
1455
1456
|
- 未指定 → 以文本格式输出
|
|
1456
1457
|
|
|
1457
1458
|
**文本输出格式(默认):**
|
|
1458
1459
|
|
|
1459
|
-
输出分为三个区块:主 Worktree、Worktree
|
|
1460
|
+
输出分为三个区块:主 Worktree、Worktree 列表、Validate 快照摘要。每个 worktree 条目每行展示一种信息。
|
|
1460
1461
|
|
|
1461
1462
|
```
|
|
1462
1463
|
════════════════════════════════════════
|
|
@@ -1473,16 +1474,18 @@ clawt status [--json]
|
|
|
1473
1474
|
|
|
1474
1475
|
● feature-login [已提交]
|
|
1475
1476
|
+120 -30 3 个本地提交 与主分支同步
|
|
1476
|
-
|
|
1477
|
+
创建于 3 天前
|
|
1478
|
+
上次验证: 2 小时前
|
|
1477
1479
|
|
|
1478
1480
|
● feature-signup [未提交修改]
|
|
1479
1481
|
+45 -10 1 个本地提交 落后主分支 2 个提交
|
|
1482
|
+
创建于 1 天前
|
|
1483
|
+
✗ 未验证
|
|
1480
1484
|
|
|
1481
1485
|
────────────────────────────────────────
|
|
1482
1486
|
|
|
1483
|
-
◆
|
|
1484
|
-
|
|
1485
|
-
⚠ old-feature (对应 worktree 已不存在)
|
|
1487
|
+
◆ Validate 快照 (3 个)
|
|
1488
|
+
其中 1 个快照对应的 worktree 已不存在
|
|
1486
1489
|
|
|
1487
1490
|
════════════════════════════════════════
|
|
1488
1491
|
```
|
|
@@ -1502,11 +1505,21 @@ clawt status [--json]
|
|
|
1502
1505
|
- 本地提交数(`N 个本地提交`)仅在有提交时展示
|
|
1503
1506
|
- 与主分支同步状态始终展示(落后时显示黄色,同步时显示绿色)
|
|
1504
1507
|
|
|
1508
|
+
**分支创建时间行:**
|
|
1509
|
+
|
|
1510
|
+
- 通过 `getBranchCreatedAt()` 从 git reflog 获取分支创建时间,以 `formatRelativeTime()` 格式化为中文相对时间(如"3 天前"、"2 小时前"、"刚刚")
|
|
1511
|
+
- 展示为灰色文本 `创建于 X前`,无法获取时不展示
|
|
1512
|
+
|
|
1513
|
+
**验证状态行:**
|
|
1514
|
+
|
|
1515
|
+
- 有快照时:显示绿色 `上次验证: X前`(通过 `getSnapshotModifiedTime()` 获取快照文件 mtime,再用 `formatRelativeTime()` 格式化)
|
|
1516
|
+
- 无快照时:显示红色 `✗ 未验证` 警示
|
|
1517
|
+
|
|
1505
1518
|
**快照区块:**
|
|
1506
1519
|
|
|
1507
|
-
-
|
|
1508
|
-
-
|
|
1509
|
-
-
|
|
1520
|
+
- 标题显示快照总数
|
|
1521
|
+
- 如果存在孤立快照(对应 worktree 已不存在),显示黄色警告 `其中 N 个快照对应的 worktree 已不存在`
|
|
1522
|
+
- 无孤立快照时不显示额外信息
|
|
1510
1523
|
|
|
1511
1524
|
**JSON 输出格式(`--json`):**
|
|
1512
1525
|
|
|
@@ -1524,25 +1537,31 @@ clawt status [--json]
|
|
|
1524
1537
|
"changeStatus": "committed",
|
|
1525
1538
|
"commitsAhead": 3,
|
|
1526
1539
|
"commitsBehind": 0,
|
|
1527
|
-
"
|
|
1540
|
+
"snapshotTime": "2025-02-06T12:30:00.000Z",
|
|
1528
1541
|
"insertions": 120,
|
|
1529
|
-
"deletions": 30
|
|
1530
|
-
|
|
1531
|
-
],
|
|
1532
|
-
"snapshots": [
|
|
1533
|
-
{
|
|
1534
|
-
"branch": "old-feature",
|
|
1535
|
-
"worktreeExists": false
|
|
1542
|
+
"deletions": 30,
|
|
1543
|
+
"createdAt": "2025-02-03T10:00:00.000Z"
|
|
1536
1544
|
}
|
|
1537
1545
|
],
|
|
1546
|
+
"snapshots": {
|
|
1547
|
+
"total": 3,
|
|
1548
|
+
"orphaned": 1
|
|
1549
|
+
},
|
|
1538
1550
|
"totalWorktrees": 1
|
|
1539
1551
|
}
|
|
1540
1552
|
```
|
|
1541
1553
|
|
|
1542
1554
|
**实现要点:**
|
|
1543
1555
|
|
|
1544
|
-
- 类型定义在 `src/types/status.ts`:`WorktreeDetailedStatus
|
|
1545
|
-
- 消息常量在 `MESSAGES.STATUS_*`
|
|
1556
|
+
- 类型定义在 `src/types/status.ts`:`WorktreeDetailedStatus`(`hasSnapshot` 已改为 `snapshotTime: string | null`,新增 `createdAt: string | null`)、`MainWorktreeStatus`、`SnapshotInfo`、`SnapshotSummary`(新增,包含 `total` 和 `orphaned`)、`StatusResult`(`snapshots` 已从 `SnapshotInfo[]` 改为 `SnapshotSummary`)
|
|
1557
|
+
- 消息常量在 `MESSAGES.STATUS_*` 系列,新增:
|
|
1558
|
+
- `STATUS_LAST_VALIDATED`:上次验证时间标签(如 `上次验证: 2 小时前`)
|
|
1559
|
+
- `STATUS_NOT_VALIDATED`:未验证红色警示文本(`✗ 未验证`)
|
|
1560
|
+
- `STATUS_CREATED_AT`:分支创建时间标签(如 `创建于 3 天前`)
|
|
1561
|
+
- `STATUS_SNAPSHOT_ORPHANED`:改为接受数量参数的函数(如 `其中 1 个快照对应的 worktree 已不存在`)
|
|
1562
|
+
- `getBranchCreatedAt()` 是新增的工具函数(在 `src/utils/git.ts`),通过 `git reflog show <branch> --format=%cI` 获取 reflog 最后一条记录的时间戳(即分支创建时间),返回 ISO 8601 格式字符串或 null
|
|
1563
|
+
- `getSnapshotModifiedTime()` 是新增的工具函数(在 `src/utils/validate-snapshot.ts`),通过 `fs.statSync` 获取快照文件的修改时间(mtime),返回 ISO 8601 格式字符串或 null
|
|
1564
|
+
- `formatRelativeTime()` 是新增的格式化函数(在 `src/utils/formatter.ts`),将 ISO 8601 日期字符串转换为中文相对时间描述(如"3 天前"、"2 小时前"、"刚刚"),无效日期时返回 null
|
|
1546
1565
|
- `getCommitCountBehind()` 是新增的工具函数(在 `src/utils/git.ts`),通过 `git rev-list --count <branch>..HEAD` 计算落后提交数
|
|
1547
1566
|
- `getProjectSnapshotBranches()` 是新增的工具函数(在 `src/utils/validate-snapshot.ts`),通过扫描快照目录下的 `.tree` 文件提取分支名列表
|
|
1548
1567
|
|
package/package.json
CHANGED
package/src/commands/status.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Command } from 'commander';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { MESSAGES } from '../constants/index.js';
|
|
4
4
|
import { logger } from '../logger/index.js';
|
|
5
|
-
import type { StatusOptions, WorktreeDetailedStatus, MainWorktreeStatus,
|
|
5
|
+
import type { StatusOptions, WorktreeDetailedStatus, MainWorktreeStatus, SnapshotSummary, StatusResult, WorktreeInfo } from '../types/index.js';
|
|
6
6
|
import {
|
|
7
7
|
validateMainWorktree,
|
|
8
8
|
getProjectName,
|
|
@@ -14,8 +14,10 @@ import {
|
|
|
14
14
|
getDiffStat,
|
|
15
15
|
hasMergeConflict,
|
|
16
16
|
hasLocalCommits,
|
|
17
|
-
|
|
17
|
+
getSnapshotModifiedTime,
|
|
18
18
|
getProjectSnapshotBranches,
|
|
19
|
+
getBranchCreatedAt,
|
|
20
|
+
formatRelativeTime,
|
|
19
21
|
printInfo,
|
|
20
22
|
printDoubleSeparator,
|
|
21
23
|
printSeparator,
|
|
@@ -96,6 +98,7 @@ function collectWorktreeDetailedStatus(worktree: WorktreeInfo, projectName: stri
|
|
|
96
98
|
const changeStatus = detectChangeStatus(worktree);
|
|
97
99
|
const { commitsAhead, commitsBehind } = countCommitDivergence(worktree.branch);
|
|
98
100
|
const { insertions, deletions } = countDiffStat(worktree.path);
|
|
101
|
+
const createdAt = resolveBranchCreatedAt(worktree.branch);
|
|
99
102
|
|
|
100
103
|
return {
|
|
101
104
|
path: worktree.path,
|
|
@@ -103,9 +106,10 @@ function collectWorktreeDetailedStatus(worktree: WorktreeInfo, projectName: stri
|
|
|
103
106
|
changeStatus,
|
|
104
107
|
commitsAhead,
|
|
105
108
|
commitsBehind,
|
|
106
|
-
|
|
109
|
+
snapshotTime: resolveSnapshotTime(projectName, worktree.branch),
|
|
107
110
|
insertions,
|
|
108
111
|
deletions,
|
|
112
|
+
createdAt,
|
|
109
113
|
};
|
|
110
114
|
}
|
|
111
115
|
|
|
@@ -162,20 +166,47 @@ function countDiffStat(worktreePath: string): { insertions: number; deletions: n
|
|
|
162
166
|
}
|
|
163
167
|
|
|
164
168
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
169
|
+
* 获取分支的创建时间
|
|
170
|
+
* @param {string} branchName - 分支名
|
|
171
|
+
* @returns {string | null} ISO 8601 格式的创建时间,获取失败时返回 null
|
|
172
|
+
*/
|
|
173
|
+
function resolveBranchCreatedAt(branchName: string): string | null {
|
|
174
|
+
try {
|
|
175
|
+
return getBranchCreatedAt(branchName);
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 获取分支的 validate 快照修改时间
|
|
183
|
+
* @param {string} projectName - 项目名
|
|
184
|
+
* @param {string} branchName - 分支名
|
|
185
|
+
* @returns {string | null} ISO 8601 格式的快照时间,无快照时返回 null
|
|
186
|
+
*/
|
|
187
|
+
function resolveSnapshotTime(projectName: string, branchName: string): string | null {
|
|
188
|
+
try {
|
|
189
|
+
return getSnapshotModifiedTime(projectName, branchName);
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 收集 validate 快照摘要信息
|
|
167
197
|
* @param {string} projectName - 项目名
|
|
168
198
|
* @param {WorktreeInfo[]} worktrees - 当前有效的 worktree 列表
|
|
169
|
-
* @returns {
|
|
199
|
+
* @returns {SnapshotSummary} 快照摘要
|
|
170
200
|
*/
|
|
171
|
-
function collectSnapshots(projectName: string, worktrees: WorktreeInfo[]):
|
|
201
|
+
function collectSnapshots(projectName: string, worktrees: WorktreeInfo[]): SnapshotSummary {
|
|
172
202
|
const snapshotBranches = getProjectSnapshotBranches(projectName);
|
|
173
203
|
const worktreeBranchSet = new Set(worktrees.map((wt) => wt.branch));
|
|
204
|
+
const orphaned = snapshotBranches.filter((branch) => !worktreeBranchSet.has(branch)).length;
|
|
174
205
|
|
|
175
|
-
return
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
206
|
+
return {
|
|
207
|
+
total: snapshotBranches.length,
|
|
208
|
+
orphaned,
|
|
209
|
+
};
|
|
179
210
|
}
|
|
180
211
|
|
|
181
212
|
/**
|
|
@@ -256,31 +287,39 @@ function printWorktreeItem(wt: WorktreeDetailedStatus): void {
|
|
|
256
287
|
const statusLabel = formatChangeStatusLabel(wt.changeStatus);
|
|
257
288
|
printInfo(` ${chalk.bold('●')} ${chalk.bold(wt.branch)} [${statusLabel}]`);
|
|
258
289
|
|
|
259
|
-
//
|
|
260
|
-
const parts: string[] = [];
|
|
261
|
-
|
|
262
|
-
// 行数变更(仅在有变更时展示)
|
|
290
|
+
// 变更行数
|
|
263
291
|
if (wt.insertions > 0 || wt.deletions > 0) {
|
|
264
|
-
|
|
292
|
+
printInfo(` ${chalk.green(`+${wt.insertions}`)} ${chalk.red(`-${wt.deletions}`)}`);
|
|
265
293
|
}
|
|
266
294
|
|
|
267
295
|
// 本地提交数
|
|
268
296
|
if (wt.commitsAhead > 0) {
|
|
269
|
-
|
|
297
|
+
printInfo(` ${chalk.yellow(`${wt.commitsAhead} 个本地提交`)}`);
|
|
270
298
|
}
|
|
271
299
|
|
|
272
300
|
// 与主分支的同步状态
|
|
273
301
|
if (wt.commitsBehind > 0) {
|
|
274
|
-
|
|
302
|
+
printInfo(` ${chalk.yellow(`落后主分支 ${wt.commitsBehind} 个提交`)}`);
|
|
275
303
|
} else {
|
|
276
|
-
|
|
304
|
+
printInfo(` ${chalk.green('与主分支同步')}`);
|
|
277
305
|
}
|
|
278
306
|
|
|
279
|
-
|
|
307
|
+
// 分支创建时间
|
|
308
|
+
if (wt.createdAt) {
|
|
309
|
+
const relativeTime = formatRelativeTime(wt.createdAt);
|
|
310
|
+
if (relativeTime) {
|
|
311
|
+
printInfo(` ${chalk.gray(MESSAGES.STATUS_CREATED_AT(relativeTime))}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
280
314
|
|
|
281
|
-
//
|
|
282
|
-
if (wt.
|
|
283
|
-
|
|
315
|
+
// 验证状态
|
|
316
|
+
if (wt.snapshotTime) {
|
|
317
|
+
const relativeTime = formatRelativeTime(wt.snapshotTime);
|
|
318
|
+
if (relativeTime) {
|
|
319
|
+
printInfo(` ${chalk.green(MESSAGES.STATUS_LAST_VALIDATED(relativeTime))}`);
|
|
320
|
+
}
|
|
321
|
+
} else {
|
|
322
|
+
printInfo(` ${chalk.red(MESSAGES.STATUS_NOT_VALIDATED)}`);
|
|
284
323
|
}
|
|
285
324
|
|
|
286
325
|
printInfo('');
|
|
@@ -305,23 +344,13 @@ function formatChangeStatusLabel(status: WorktreeDetailedStatus['changeStatus'])
|
|
|
305
344
|
}
|
|
306
345
|
|
|
307
346
|
/**
|
|
308
|
-
*
|
|
309
|
-
* @param {
|
|
347
|
+
* 输出快照摘要区块
|
|
348
|
+
* @param {SnapshotSummary} snapshots - 快照摘要信息
|
|
310
349
|
*/
|
|
311
|
-
function printSnapshotsSection(snapshots:
|
|
312
|
-
printInfo(` ${chalk.bold('◆')} ${chalk.bold(MESSAGES.STATUS_SNAPSHOTS_SECTION)} (${snapshots.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (snapshots.length === 0) {
|
|
316
|
-
printInfo(` ${MESSAGES.STATUS_NO_SNAPSHOTS}`);
|
|
317
|
-
printInfo('');
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
for (const snap of snapshots) {
|
|
322
|
-
const orphanLabel = snap.worktreeExists ? '' : ` ${chalk.yellow(MESSAGES.STATUS_SNAPSHOT_ORPHANED)}`;
|
|
323
|
-
const icon = snap.worktreeExists ? chalk.blue('●') : chalk.yellow('⚠');
|
|
324
|
-
printInfo(` ${icon} ${snap.branch}${orphanLabel}`);
|
|
350
|
+
function printSnapshotsSection(snapshots: SnapshotSummary): void {
|
|
351
|
+
printInfo(` ${chalk.bold('◆')} ${chalk.bold(MESSAGES.STATUS_SNAPSHOTS_SECTION)} (${snapshots.total} 个)`);
|
|
352
|
+
if (snapshots.orphaned > 0) {
|
|
353
|
+
printInfo(` ${chalk.yellow(MESSAGES.STATUS_SNAPSHOT_ORPHANED(snapshots.orphaned))}`);
|
|
325
354
|
}
|
|
326
355
|
printInfo('');
|
|
327
356
|
}
|
|
@@ -7,7 +7,7 @@ export const STATUS_MESSAGES = {
|
|
|
7
7
|
/** status worktrees 区块标题 */
|
|
8
8
|
STATUS_WORKTREES_SECTION: 'Worktree 列表',
|
|
9
9
|
/** status 快照区块标题 */
|
|
10
|
-
STATUS_SNAPSHOTS_SECTION: '
|
|
10
|
+
STATUS_SNAPSHOTS_SECTION: 'Validate 快照',
|
|
11
11
|
/** status 无 worktree */
|
|
12
12
|
STATUS_NO_WORKTREES: '(无活跃 worktree)',
|
|
13
13
|
/** status 无未清理快照 */
|
|
@@ -21,5 +21,13 @@ export const STATUS_MESSAGES = {
|
|
|
21
21
|
/** status 变更状态:无变更 */
|
|
22
22
|
STATUS_CHANGE_CLEAN: '无变更',
|
|
23
23
|
/** status 快照对应 worktree 已不存在 */
|
|
24
|
-
STATUS_SNAPSHOT_ORPHANED:
|
|
24
|
+
STATUS_SNAPSHOT_ORPHANED: (count: number) => `其中 ${count} 个快照对应的 worktree 已不存在`,
|
|
25
|
+
/** status 分支创建时间标签 */
|
|
26
|
+
STATUS_CREATED_AT: (relativeTime: string) => `创建于 ${relativeTime}`,
|
|
27
|
+
/** status 分支无分叉提交时的提示 */
|
|
28
|
+
STATUS_NO_DIVERGED_COMMITS: '尚无分叉提交',
|
|
29
|
+
/** status 上次验证时间标签 */
|
|
30
|
+
STATUS_LAST_VALIDATED: (relativeTime: string) => `上次验证: ${relativeTime}`,
|
|
31
|
+
/** status 未验证警示 */
|
|
32
|
+
STATUS_NOT_VALIDATED: '✗ 未验证',
|
|
25
33
|
} as const;
|
package/src/types/index.ts
CHANGED
|
@@ -3,5 +3,5 @@ export type { CreateOptions, RunOptions, ValidateOptions, MergeOptions, RemoveOp
|
|
|
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';
|
|
6
|
-
export type { WorktreeDetailedStatus, MainWorktreeStatus, SnapshotInfo, StatusResult } from './status.js';
|
|
6
|
+
export type { WorktreeDetailedStatus, MainWorktreeStatus, SnapshotInfo, SnapshotSummary, StatusResult } from './status.js';
|
|
7
7
|
export type { TaskFileEntry, ParseTaskFileOptions } from './taskFile.js';
|
package/src/types/status.ts
CHANGED
|
@@ -10,12 +10,14 @@ export interface WorktreeDetailedStatus {
|
|
|
10
10
|
commitsAhead: number;
|
|
11
11
|
/** 落后于主分支的提交数 */
|
|
12
12
|
commitsBehind: number;
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/** 上次 validate 验证时间(ISO 8601 时间字符串),无快照时为 null */
|
|
14
|
+
snapshotTime: string | null;
|
|
15
15
|
/** 工作区和暂存区的新增行数 */
|
|
16
16
|
insertions: number;
|
|
17
17
|
/** 工作区和暂存区的删除行数 */
|
|
18
18
|
deletions: number;
|
|
19
|
+
/** 分支创建时间(首次分叉提交的 ISO 8601 时间字符串),无分叉提交时为 null */
|
|
20
|
+
createdAt: string | null;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/** 主 worktree 状态信息 */
|
|
@@ -36,14 +38,22 @@ export interface SnapshotInfo {
|
|
|
36
38
|
worktreeExists: boolean;
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
/** validate 快照摘要信息 */
|
|
42
|
+
export interface SnapshotSummary {
|
|
43
|
+
/** 快照总数 */
|
|
44
|
+
total: number;
|
|
45
|
+
/** 孤立快照数(对应 worktree 已不存在) */
|
|
46
|
+
orphaned: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
/** status 命令的完整输出结构 */
|
|
40
50
|
export interface StatusResult {
|
|
41
51
|
/** 主 worktree 状态 */
|
|
42
52
|
main: MainWorktreeStatus;
|
|
43
53
|
/** 各 worktree 的详细状态 */
|
|
44
54
|
worktrees: WorktreeDetailedStatus[];
|
|
45
|
-
/**
|
|
46
|
-
snapshots:
|
|
55
|
+
/** validate 快照摘要 */
|
|
56
|
+
snapshots: SnapshotSummary;
|
|
47
57
|
/** worktree 总数 */
|
|
48
58
|
totalWorktrees: number;
|
|
49
59
|
}
|
package/src/utils/formatter.ts
CHANGED
|
@@ -142,3 +142,45 @@ export function formatDuration(ms: number): string {
|
|
|
142
142
|
const seconds = Math.floor(totalSeconds % 60);
|
|
143
143
|
return `${minutes}m${String(seconds).padStart(2, '0')}s`;
|
|
144
144
|
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 将 ISO 8601 日期字符串格式化为中文相对时间描述
|
|
148
|
+
* 例如: "3 天前"、"2 小时前"、"刚刚"
|
|
149
|
+
* @param {string} isoDateString - ISO 8601 格式的日期字符串
|
|
150
|
+
* @returns {string | null} 中文相对时间描述,无效日期时返回 null
|
|
151
|
+
*/
|
|
152
|
+
export function formatRelativeTime(isoDateString: string): string | null {
|
|
153
|
+
const date = new Date(isoDateString);
|
|
154
|
+
const now = new Date();
|
|
155
|
+
const diffMs = now.getTime() - date.getTime();
|
|
156
|
+
|
|
157
|
+
// 无效日期返回 null
|
|
158
|
+
if (isNaN(diffMs)) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 未来时间或不到 1 分钟
|
|
163
|
+
if (diffMs < 0 || diffMs < 60 * 1000) {
|
|
164
|
+
return '刚刚';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
|
168
|
+
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
169
|
+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
170
|
+
|
|
171
|
+
if (diffHours < 1) {
|
|
172
|
+
return `${diffMinutes} 分钟前`;
|
|
173
|
+
}
|
|
174
|
+
if (diffDays < 1) {
|
|
175
|
+
return `${diffHours} 小时前`;
|
|
176
|
+
}
|
|
177
|
+
if (diffDays < 30) {
|
|
178
|
+
return `${diffDays} 天前`;
|
|
179
|
+
}
|
|
180
|
+
if (diffDays < 365) {
|
|
181
|
+
const months = Math.floor(diffDays / 30);
|
|
182
|
+
return `${months} 个月前`;
|
|
183
|
+
}
|
|
184
|
+
const years = Math.floor(diffDays / 365);
|
|
185
|
+
return `${years} 年前`;
|
|
186
|
+
}
|
package/src/utils/git.ts
CHANGED
|
@@ -481,3 +481,23 @@ export function gitApplyCachedCheck(patchContent: Buffer, cwd?: string): boolean
|
|
|
481
481
|
return false;
|
|
482
482
|
}
|
|
483
483
|
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* 获取分支的创建时间(通过 reflog 获取分支创建时的时间戳)
|
|
487
|
+
* reflog 的最后一条记录即为分支创建时的记录
|
|
488
|
+
* @param {string} branchName - 目标分支名
|
|
489
|
+
* @param {string} [cwd] - 工作目录
|
|
490
|
+
* @returns {string | null} ISO 8601 格式的时间字符串,无法获取时返回 null
|
|
491
|
+
*/
|
|
492
|
+
export function getBranchCreatedAt(branchName: string, cwd?: string): string | null {
|
|
493
|
+
try {
|
|
494
|
+
const output = execCommand(`git reflog show ${branchName} --format=%cI`, { cwd });
|
|
495
|
+
if (!output.trim()) return null;
|
|
496
|
+
// 取最后一行,即分支创建时的 reflog 记录
|
|
497
|
+
const lines = output.trim().split('\n');
|
|
498
|
+
const lastLine = lines[lines.length - 1];
|
|
499
|
+
return lastLine || null;
|
|
500
|
+
} catch {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -45,16 +45,17 @@ export {
|
|
|
45
45
|
getCommitTreeHash,
|
|
46
46
|
gitDiffTree,
|
|
47
47
|
gitApplyCachedCheck,
|
|
48
|
+
getBranchCreatedAt,
|
|
48
49
|
} from './git.js';
|
|
49
50
|
export { sanitizeBranchName, generateBranchNames, validateBranchesNotExist } from './branch.js';
|
|
50
51
|
export { validateMainWorktree, validateGitInstalled, validateClaudeCodeInstalled } from './validation.js';
|
|
51
52
|
export { createWorktrees, getProjectWorktrees, getProjectWorktreeDir, cleanupWorktrees, getWorktreeStatus, createWorktreesByBranches } from './worktree.js';
|
|
52
53
|
export { loadConfig, writeDefaultConfig, writeConfig, saveConfig, getConfigValue, ensureClawtDirs, parseConcurrency } from './config.js';
|
|
53
|
-
export { printSuccess, printError, printWarning, printInfo, printHint, printSeparator, printDoubleSeparator, confirmAction, confirmDestructiveAction, formatWorktreeStatus, isWorktreeIdle, formatDuration } from './formatter.js';
|
|
54
|
+
export { printSuccess, printError, printWarning, printInfo, printHint, printSeparator, printDoubleSeparator, confirmAction, confirmDestructiveAction, formatWorktreeStatus, isWorktreeIdle, formatDuration, formatRelativeTime } from './formatter.js';
|
|
54
55
|
export { ensureDir, removeEmptyDir } from './fs.js';
|
|
55
56
|
export { multilineInput } from './prompt.js';
|
|
56
57
|
export { launchInteractiveClaude, hasClaudeSessionHistory, launchInteractiveClaudeInNewTerminal } from './claude.js';
|
|
57
|
-
export { getSnapshotPath, hasSnapshot, readSnapshotTreeHash, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots, getProjectSnapshotBranches } from './validate-snapshot.js';
|
|
58
|
+
export { getSnapshotPath, hasSnapshot, getSnapshotModifiedTime, readSnapshotTreeHash, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots, getProjectSnapshotBranches } from './validate-snapshot.js';
|
|
58
59
|
export { findExactMatch, findFuzzyMatches, promptSelectBranch, promptMultiSelectBranches, resolveTargetWorktree, resolveTargetWorktrees } from './worktree-matcher.js';
|
|
59
60
|
export type { WorktreeResolveMessages, WorktreeMultiResolveMessages } from './worktree-matcher.js';
|
|
60
61
|
export { ProgressRenderer } from './progress.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, rmdirSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, rmdirSync, statSync } from 'node:fs';
|
|
3
3
|
import { VALIDATE_SNAPSHOTS_DIR } from '../constants/index.js';
|
|
4
4
|
import { ensureDir } from './fs.js';
|
|
5
5
|
import { logger } from '../logger/index.js';
|
|
@@ -34,6 +34,19 @@ export function hasSnapshot(projectName: string, branchName: string): boolean {
|
|
|
34
34
|
return existsSync(getSnapshotPath(projectName, branchName));
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 获取指定项目和分支的 validate 快照文件修改时间
|
|
39
|
+
* @param {string} projectName - 项目名
|
|
40
|
+
* @param {string} branchName - 分支名
|
|
41
|
+
* @returns {string | null} ISO 8601 格式的修改时间,快照不存在时返回 null
|
|
42
|
+
*/
|
|
43
|
+
export function getSnapshotModifiedTime(projectName: string, branchName: string): string | null {
|
|
44
|
+
const snapshotPath = getSnapshotPath(projectName, branchName);
|
|
45
|
+
if (!existsSync(snapshotPath)) return null;
|
|
46
|
+
const stat = statSync(snapshotPath);
|
|
47
|
+
return stat.mtime.toISOString();
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
/**
|
|
38
51
|
* 读取指定项目和分支的 validate 快照中存储的 tree hash
|
|
39
52
|
* @param {string} projectName - 项目名
|
|
@@ -17,7 +17,11 @@ vi.mock('../../../src/constants/index.js', () => ({
|
|
|
17
17
|
STATUS_CHANGE_UNCOMMITTED: '未提交',
|
|
18
18
|
STATUS_CHANGE_CONFLICT: '冲突',
|
|
19
19
|
STATUS_CHANGE_CLEAN: '干净',
|
|
20
|
-
STATUS_SNAPSHOT_ORPHANED:
|
|
20
|
+
STATUS_SNAPSHOT_ORPHANED: (count: number) => `其中 ${count} 个快照对应的 worktree 已不存在`,
|
|
21
|
+
STATUS_CREATED_AT: (relativeTime: string) => `创建于 ${relativeTime}`,
|
|
22
|
+
STATUS_NO_DIVERGED_COMMITS: '尚无分叉提交',
|
|
23
|
+
STATUS_LAST_VALIDATED: (relativeTime: string) => `上次验证: ${relativeTime}`,
|
|
24
|
+
STATUS_NOT_VALIDATED: '✗ 未验证',
|
|
21
25
|
},
|
|
22
26
|
}));
|
|
23
27
|
|
|
@@ -32,8 +36,10 @@ vi.mock('../../../src/utils/index.js', () => ({
|
|
|
32
36
|
getDiffStat: vi.fn(),
|
|
33
37
|
hasMergeConflict: vi.fn(),
|
|
34
38
|
hasLocalCommits: vi.fn(),
|
|
35
|
-
|
|
39
|
+
getSnapshotModifiedTime: vi.fn(),
|
|
36
40
|
getProjectSnapshotBranches: vi.fn(),
|
|
41
|
+
getBranchCreatedAt: vi.fn(),
|
|
42
|
+
formatRelativeTime: vi.fn(),
|
|
37
43
|
printInfo: vi.fn(),
|
|
38
44
|
printDoubleSeparator: vi.fn(),
|
|
39
45
|
printSeparator: vi.fn(),
|
|
@@ -50,8 +56,10 @@ import {
|
|
|
50
56
|
getDiffStat,
|
|
51
57
|
hasMergeConflict,
|
|
52
58
|
hasLocalCommits,
|
|
53
|
-
|
|
59
|
+
getSnapshotModifiedTime,
|
|
54
60
|
getProjectSnapshotBranches,
|
|
61
|
+
getBranchCreatedAt,
|
|
62
|
+
formatRelativeTime,
|
|
55
63
|
printInfo,
|
|
56
64
|
} from '../../../src/utils/index.js';
|
|
57
65
|
|
|
@@ -64,8 +72,10 @@ const mockedGetCommitCountBehind = vi.mocked(getCommitCountBehind);
|
|
|
64
72
|
const mockedGetDiffStat = vi.mocked(getDiffStat);
|
|
65
73
|
const mockedHasMergeConflict = vi.mocked(hasMergeConflict);
|
|
66
74
|
const mockedHasLocalCommits = vi.mocked(hasLocalCommits);
|
|
67
|
-
const
|
|
75
|
+
const mockedGetSnapshotModifiedTime = vi.mocked(getSnapshotModifiedTime);
|
|
68
76
|
const mockedGetProjectSnapshotBranches = vi.mocked(getProjectSnapshotBranches);
|
|
77
|
+
const mockedGetBranchCreatedAt = vi.mocked(getBranchCreatedAt);
|
|
78
|
+
const mockedFormatRelativeTime = vi.mocked(formatRelativeTime);
|
|
69
79
|
const mockedPrintInfo = vi.mocked(printInfo);
|
|
70
80
|
|
|
71
81
|
beforeEach(() => {
|
|
@@ -79,7 +89,9 @@ beforeEach(() => {
|
|
|
79
89
|
mockedGetDiffStat.mockReturnValue({ insertions: 0, deletions: 0 });
|
|
80
90
|
mockedHasMergeConflict.mockReturnValue(false);
|
|
81
91
|
mockedHasLocalCommits.mockReturnValue(false);
|
|
82
|
-
|
|
92
|
+
mockedGetSnapshotModifiedTime.mockReturnValue(null);
|
|
93
|
+
mockedGetBranchCreatedAt.mockReturnValue(null);
|
|
94
|
+
mockedFormatRelativeTime.mockReturnValue('3 天前');
|
|
83
95
|
mockedPrintInfo.mockReset();
|
|
84
96
|
});
|
|
85
97
|
|
|
@@ -167,7 +179,7 @@ describe('handleStatus', () => {
|
|
|
167
179
|
expect(parsed.main.isClean).toBe(false);
|
|
168
180
|
});
|
|
169
181
|
|
|
170
|
-
it('
|
|
182
|
+
it('快照摘要包含总数和孤立数', () => {
|
|
171
183
|
mockedGetProjectSnapshotBranches.mockReturnValue(['feature', 'deleted-branch']);
|
|
172
184
|
mockedGetProjectWorktrees.mockReturnValue([
|
|
173
185
|
{ path: '/path/feature', branch: 'feature' },
|
|
@@ -184,9 +196,8 @@ describe('handleStatus', () => {
|
|
|
184
196
|
try { JSON.parse(call[0]); return true; } catch { return false; }
|
|
185
197
|
});
|
|
186
198
|
const parsed = JSON.parse(jsonCall![0]);
|
|
187
|
-
expect(parsed.snapshots).
|
|
188
|
-
expect(parsed.snapshots
|
|
189
|
-
expect(parsed.snapshots[1]).toEqual({ branch: 'deleted-branch', worktreeExists: false });
|
|
199
|
+
expect(parsed.snapshots.total).toBe(2);
|
|
200
|
+
expect(parsed.snapshots.orphaned).toBe(1);
|
|
190
201
|
});
|
|
191
202
|
|
|
192
203
|
it('uncommitted 变更状态正确检测', () => {
|
|
@@ -211,4 +222,112 @@ describe('handleStatus', () => {
|
|
|
211
222
|
const parsed = JSON.parse(jsonCall![0]);
|
|
212
223
|
expect(parsed.worktrees[0].changeStatus).toBe('uncommitted');
|
|
213
224
|
});
|
|
225
|
+
|
|
226
|
+
it('createdAt 字段包含在 JSON 输出中', () => {
|
|
227
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
228
|
+
{ path: '/path/feature', branch: 'feature' },
|
|
229
|
+
]);
|
|
230
|
+
mockedGetBranchCreatedAt.mockReturnValue('2026-02-20T14:30:00+08:00');
|
|
231
|
+
|
|
232
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
233
|
+
|
|
234
|
+
const program = new Command();
|
|
235
|
+
program.exitOverride();
|
|
236
|
+
registerStatusCommand(program);
|
|
237
|
+
program.parse(['status', '--json'], { from: 'user' });
|
|
238
|
+
|
|
239
|
+
const jsonCall = consoleSpy.mock.calls.find((call) => {
|
|
240
|
+
try { JSON.parse(call[0]); return true; } catch { return false; }
|
|
241
|
+
});
|
|
242
|
+
const parsed = JSON.parse(jsonCall![0]);
|
|
243
|
+
expect(parsed.worktrees[0].createdAt).toBe('2026-02-20T14:30:00+08:00');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('snapshotTime 字段包含在 JSON 输出中', () => {
|
|
247
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
248
|
+
{ path: '/path/feature', branch: 'feature' },
|
|
249
|
+
]);
|
|
250
|
+
mockedGetSnapshotModifiedTime.mockReturnValue('2026-02-22T10:00:00.000Z');
|
|
251
|
+
|
|
252
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
253
|
+
|
|
254
|
+
const program = new Command();
|
|
255
|
+
program.exitOverride();
|
|
256
|
+
registerStatusCommand(program);
|
|
257
|
+
program.parse(['status', '--json'], { from: 'user' });
|
|
258
|
+
|
|
259
|
+
const jsonCall = consoleSpy.mock.calls.find((call) => {
|
|
260
|
+
try { JSON.parse(call[0]); return true; } catch { return false; }
|
|
261
|
+
});
|
|
262
|
+
const parsed = JSON.parse(jsonCall![0]);
|
|
263
|
+
expect(parsed.worktrees[0].snapshotTime).toBe('2026-02-22T10:00:00.000Z');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('文本模式显示分支创建时间', () => {
|
|
267
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
268
|
+
{ path: '/path/feature', branch: 'feature' },
|
|
269
|
+
]);
|
|
270
|
+
mockedGetBranchCreatedAt.mockReturnValue('2026-02-20T14:30:00+08:00');
|
|
271
|
+
mockedFormatRelativeTime.mockReturnValue('2 天前');
|
|
272
|
+
|
|
273
|
+
const program = new Command();
|
|
274
|
+
program.exitOverride();
|
|
275
|
+
registerStatusCommand(program);
|
|
276
|
+
program.parse(['status'], { from: 'user' });
|
|
277
|
+
|
|
278
|
+
const printedLines = mockedPrintInfo.mock.calls.map((call) => call[0]);
|
|
279
|
+
const createdAtLine = printedLines.find((line) => line.includes('创建于'));
|
|
280
|
+
expect(createdAtLine).toBeDefined();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('文本模式 createdAt 为 null 时不显示创建时间', () => {
|
|
284
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
285
|
+
{ path: '/path/feature', branch: 'feature' },
|
|
286
|
+
]);
|
|
287
|
+
mockedGetBranchCreatedAt.mockReturnValue(null);
|
|
288
|
+
|
|
289
|
+
const program = new Command();
|
|
290
|
+
program.exitOverride();
|
|
291
|
+
registerStatusCommand(program);
|
|
292
|
+
program.parse(['status'], { from: 'user' });
|
|
293
|
+
|
|
294
|
+
const printedLines = mockedPrintInfo.mock.calls.map((call) => call[0]);
|
|
295
|
+
const createdAtLine = printedLines.find((line) => line.includes('创建于'));
|
|
296
|
+
expect(createdAtLine).toBeUndefined();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('文本模式无快照时显示未验证警示', () => {
|
|
300
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
301
|
+
{ path: '/path/feature', branch: 'feature' },
|
|
302
|
+
]);
|
|
303
|
+
mockedGetSnapshotModifiedTime.mockReturnValue(null);
|
|
304
|
+
|
|
305
|
+
const program = new Command();
|
|
306
|
+
program.exitOverride();
|
|
307
|
+
registerStatusCommand(program);
|
|
308
|
+
program.parse(['status'], { from: 'user' });
|
|
309
|
+
|
|
310
|
+
const printedLines = mockedPrintInfo.mock.calls.map((call) => call[0]);
|
|
311
|
+
const unverifiedLine = printedLines.find((line) => line.includes('未验证'));
|
|
312
|
+
expect(unverifiedLine).toBeDefined();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('文本模式有快照时显示上次验证时间', () => {
|
|
316
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
317
|
+
{ path: '/path/feature', branch: 'feature' },
|
|
318
|
+
]);
|
|
319
|
+
mockedGetSnapshotModifiedTime.mockReturnValue('2026-02-22T10:00:00.000Z');
|
|
320
|
+
mockedFormatRelativeTime.mockReturnValue('5 小时前');
|
|
321
|
+
|
|
322
|
+
const program = new Command();
|
|
323
|
+
program.exitOverride();
|
|
324
|
+
registerStatusCommand(program);
|
|
325
|
+
program.parse(['status'], { from: 'user' });
|
|
326
|
+
|
|
327
|
+
const printedLines = mockedPrintInfo.mock.calls.map((call) => call[0]);
|
|
328
|
+
const validatedLine = printedLines.find((line) => line.includes('上次验证'));
|
|
329
|
+
expect(validatedLine).toBeDefined();
|
|
330
|
+
const unverifiedLine = printedLines.find((line) => line.includes('未验证'));
|
|
331
|
+
expect(unverifiedLine).toBeUndefined();
|
|
332
|
+
});
|
|
214
333
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { formatWorktreeStatus, printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, isWorktreeIdle, formatDuration } from '../../../src/utils/formatter.js';
|
|
2
|
+
import { formatWorktreeStatus, printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, isWorktreeIdle, formatDuration, formatRelativeTime } from '../../../src/utils/formatter.js';
|
|
3
3
|
import { createWorktreeStatus } from '../../helpers/fixtures.js';
|
|
4
4
|
|
|
5
5
|
describe('formatWorktreeStatus', () => {
|
|
@@ -136,3 +136,44 @@ describe('formatDuration', () => {
|
|
|
136
136
|
expect(formatDuration(3661000)).toBe('61m01s');
|
|
137
137
|
});
|
|
138
138
|
});
|
|
139
|
+
|
|
140
|
+
describe('formatRelativeTime', () => {
|
|
141
|
+
it('不到 1 分钟时返回"刚刚"', () => {
|
|
142
|
+
const now = new Date();
|
|
143
|
+
expect(formatRelativeTime(now.toISOString())).toBe('刚刚');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('数分钟前', () => {
|
|
147
|
+
const date = new Date(Date.now() - 5 * 60 * 1000);
|
|
148
|
+
expect(formatRelativeTime(date.toISOString())).toBe('5 分钟前');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('数小时前', () => {
|
|
152
|
+
const date = new Date(Date.now() - 3 * 60 * 60 * 1000);
|
|
153
|
+
expect(formatRelativeTime(date.toISOString())).toBe('3 小时前');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('数天前', () => {
|
|
157
|
+
const date = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
158
|
+
expect(formatRelativeTime(date.toISOString())).toBe('7 天前');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('数月前', () => {
|
|
162
|
+
const date = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000);
|
|
163
|
+
expect(formatRelativeTime(date.toISOString())).toBe('2 个月前');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('数年前', () => {
|
|
167
|
+
const date = new Date(Date.now() - 400 * 24 * 60 * 60 * 1000);
|
|
168
|
+
expect(formatRelativeTime(date.toISOString())).toBe('1 年前');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('无效日期返回 null', () => {
|
|
172
|
+
expect(formatRelativeTime('invalid-date')).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('未来时间返回"刚刚"', () => {
|
|
176
|
+
const future = new Date(Date.now() + 60 * 60 * 1000);
|
|
177
|
+
expect(formatRelativeTime(future.toISOString())).toBe('刚刚');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
getCommitTreeHash,
|
|
64
64
|
gitDiffTree,
|
|
65
65
|
gitApplyCachedCheck,
|
|
66
|
+
getBranchCreatedAt,
|
|
66
67
|
} from '../../../src/utils/git.js';
|
|
67
68
|
|
|
68
69
|
const mockedExecCommand = vi.mocked(execCommand);
|
|
@@ -574,3 +575,38 @@ describe('getCommitCountBehind', () => {
|
|
|
574
575
|
expect(getCommitCountBehind('feature')).toBe(0);
|
|
575
576
|
});
|
|
576
577
|
});
|
|
578
|
+
|
|
579
|
+
describe('getBranchCreatedAt', () => {
|
|
580
|
+
it('多条 reflog 记录时返回最后一条(分支创建时间)', () => {
|
|
581
|
+
mockedExecCommand.mockReturnValue('2026-02-21T10:00:00+08:00\n2026-02-20T14:30:00+08:00');
|
|
582
|
+
expect(getBranchCreatedAt('feature')).toBe('2026-02-20T14:30:00+08:00');
|
|
583
|
+
expect(mockedExecCommand).toHaveBeenCalledWith(
|
|
584
|
+
'git reflog show feature --format=%cI',
|
|
585
|
+
{ cwd: undefined },
|
|
586
|
+
);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('单条 reflog 记录时返回该时间', () => {
|
|
590
|
+
mockedExecCommand.mockReturnValue('2026-02-20T14:30:00+08:00');
|
|
591
|
+
expect(getBranchCreatedAt('feature')).toBe('2026-02-20T14:30:00+08:00');
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('无 reflog 记录时返回 null', () => {
|
|
595
|
+
mockedExecCommand.mockReturnValue('');
|
|
596
|
+
expect(getBranchCreatedAt('feature')).toBeNull();
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('命令失败时返回 null', () => {
|
|
600
|
+
mockedExecCommand.mockImplementation(() => { throw new Error('fail'); });
|
|
601
|
+
expect(getBranchCreatedAt('feature')).toBeNull();
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('传递 cwd 参数', () => {
|
|
605
|
+
mockedExecCommand.mockReturnValue('2026-02-20T14:30:00+08:00');
|
|
606
|
+
getBranchCreatedAt('feature', '/repo');
|
|
607
|
+
expect(mockedExecCommand).toHaveBeenCalledWith(
|
|
608
|
+
'git reflog show feature --format=%cI',
|
|
609
|
+
{ cwd: '/repo' },
|
|
610
|
+
);
|
|
611
|
+
});
|
|
612
|
+
});
|
|
@@ -8,6 +8,7 @@ vi.mock('node:fs', () => ({
|
|
|
8
8
|
unlinkSync: vi.fn(),
|
|
9
9
|
readdirSync: vi.fn(),
|
|
10
10
|
rmdirSync: vi.fn(),
|
|
11
|
+
statSync: vi.fn(),
|
|
11
12
|
}));
|
|
12
13
|
|
|
13
14
|
// mock logger
|
|
@@ -29,11 +30,12 @@ vi.mock('../../../src/constants/index.js', async (importOriginal) => {
|
|
|
29
30
|
};
|
|
30
31
|
});
|
|
31
32
|
|
|
32
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, rmdirSync } from 'node:fs';
|
|
33
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, rmdirSync, statSync } from 'node:fs';
|
|
33
34
|
import { ensureDir } from '../../../src/utils/fs.js';
|
|
34
35
|
import {
|
|
35
36
|
getSnapshotPath,
|
|
36
37
|
hasSnapshot,
|
|
38
|
+
getSnapshotModifiedTime,
|
|
37
39
|
readSnapshot,
|
|
38
40
|
readSnapshotTreeHash,
|
|
39
41
|
writeSnapshot,
|
|
@@ -48,6 +50,7 @@ const mockedWriteFileSync = vi.mocked(writeFileSync);
|
|
|
48
50
|
const mockedUnlinkSync = vi.mocked(unlinkSync);
|
|
49
51
|
const mockedReaddirSync = vi.mocked(readdirSync);
|
|
50
52
|
const mockedRmdirSync = vi.mocked(rmdirSync);
|
|
53
|
+
const mockedStatSync = vi.mocked(statSync);
|
|
51
54
|
const mockedEnsureDir = vi.mocked(ensureDir);
|
|
52
55
|
|
|
53
56
|
describe('getSnapshotPath', () => {
|
|
@@ -174,3 +177,20 @@ describe('getProjectSnapshotBranches', () => {
|
|
|
174
177
|
expect(result).toEqual([]);
|
|
175
178
|
});
|
|
176
179
|
});
|
|
180
|
+
|
|
181
|
+
describe('getSnapshotModifiedTime', () => {
|
|
182
|
+
it('快照存在时返回 ISO 时间字符串', () => {
|
|
183
|
+
mockedExistsSync.mockReturnValue(true);
|
|
184
|
+
const testDate = new Date('2026-02-22T10:00:00.000Z');
|
|
185
|
+
// @ts-expect-error statSync 返回类型简化
|
|
186
|
+
mockedStatSync.mockReturnValue({ mtime: testDate });
|
|
187
|
+
const result = getSnapshotModifiedTime('proj', 'branch');
|
|
188
|
+
expect(result).toBe('2026-02-22T10:00:00.000Z');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('快照不存在时返回 null', () => {
|
|
192
|
+
mockedExistsSync.mockReturnValue(false);
|
|
193
|
+
const result = getSnapshotModifiedTime('proj', 'branch');
|
|
194
|
+
expect(result).toBeNull();
|
|
195
|
+
});
|
|
196
|
+
});
|