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 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: "\u672A\u6E05\u7406\u7684 Validate \u5FEB\u7167",
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: "(\u5BF9\u5E94 worktree \u5DF2\u4E0D\u5B58\u5728)"
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
- hasSnapshot: hasSnapshot(projectName, worktree.branch),
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
- return snapshotBranches.map((branch) => ({
2900
- branch,
2901
- worktreeExists: worktreeBranchSet.has(branch)
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
- parts.push(`${chalk8.green(`+${wt.insertions}`)} ${chalk8.red(`-${wt.deletions}`)}`);
3017
+ printInfo(` ${chalk8.green(`+${wt.insertions}`)} ${chalk8.red(`-${wt.deletions}`)}`);
2948
3018
  }
2949
3019
  if (wt.commitsAhead > 0) {
2950
- parts.push(chalk8.yellow(`${wt.commitsAhead} \u4E2A\u672C\u5730\u63D0\u4EA4`));
3020
+ printInfo(` ${chalk8.yellow(`${wt.commitsAhead} \u4E2A\u672C\u5730\u63D0\u4EA4`)}`);
2951
3021
  }
2952
3022
  if (wt.commitsBehind > 0) {
2953
- parts.push(chalk8.yellow(`\u843D\u540E\u4E3B\u5206\u652F ${wt.commitsBehind} \u4E2A\u63D0\u4EA4`));
3023
+ printInfo(` ${chalk8.yellow(`\u843D\u540E\u4E3B\u5206\u652F ${wt.commitsBehind} \u4E2A\u63D0\u4EA4`)}`);
2954
3024
  } else {
2955
- parts.push(chalk8.green("\u4E0E\u4E3B\u5206\u652F\u540C\u6B65"));
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
- printInfo(` ${parts.join(" ")}`);
2958
- if (wt.hasSnapshot) {
2959
- printInfo(` ${chalk8.blue("\u6709 validate \u5FEB\u7167")}`);
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.length} \u4E2A)`);
2977
- printInfo("");
2978
- if (snapshots.length === 0) {
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
  }
@@ -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: "\u672A\u6E05\u7406\u7684 Validate \u5FEB\u7167",
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: "(\u5BF9\u5E94 worktree \u5DF2\u4E0D\u5B58\u5728)"
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 的变更情况和与主分支的同步状态、未清理的 validate 快照。
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
- - **快照状态**(是否存在 validate 快照)
1450
- 4. **收集未清理的 validate 快照**:
1449
+ - **快照时间**(validate 快照文件的 mtime,通过 `getSnapshotModifiedTime()` 获取,返回 ISO 8601 时间字符串或 null)
1450
+ - **分支创建时间**(通过 `getBranchCreatedAt()` 从 git reflog 获取分支创建时的时间戳)
1451
+ 4. **收集 validate 快照摘要**:
1451
1452
  - 通过 `getProjectSnapshotBranches()` 扫描快照目录下的 `.tree` 文件获取所有存在快照的分支名
1452
- - 对比现有 worktree 分支列表,标识孤立快照(对应 worktree 已不存在的快照)
1453
+ - 统计快照总数和孤立快照数(对应 worktree 已不存在的快照)
1453
1454
  5. **输出状态信息**:
1454
1455
  - 指定 `--json` → 以 JSON 格式输出完整状态数据(`JSON.stringify`)
1455
1456
  - 未指定 → 以文本格式输出
1456
1457
 
1457
1458
  **文本输出格式(默认):**
1458
1459
 
1459
- 输出分为三个区块:主 Worktree、Worktree 列表、未清理的 Validate 快照。
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
- validate 快照
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
- 未清理的 Validate 快照 (1 个)
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
- - 如果对应的 worktree 仍存在,显示蓝色圆点图标
1509
- - 如果对应的 worktree 已不存在(孤立快照),显示黄色警告图标并标注 `(对应 worktree 已不存在)`
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
- "hasSnapshot": true,
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`、`MainWorktreeStatus`、`SnapshotInfo`、`StatusResult`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "2.16.2",
3
+ "version": "2.16.3",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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, SnapshotInfo, StatusResult, WorktreeInfo } from '../types/index.js';
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
- hasSnapshot,
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
- hasSnapshot: hasSnapshot(projectName, worktree.branch),
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
- * 收集未清理的 validate 快照信息
166
- * 对比快照分支与现有 worktree 分支,标识孤立快照
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 {SnapshotInfo[]} 快照信息列表
199
+ * @returns {SnapshotSummary} 快照摘要
170
200
  */
171
- function collectSnapshots(projectName: string, worktrees: WorktreeInfo[]): SnapshotInfo[] {
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 snapshotBranches.map((branch) => ({
176
- branch,
177
- worktreeExists: worktreeBranchSet.has(branch),
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
- parts.push(`${chalk.green(`+${wt.insertions}`)} ${chalk.red(`-${wt.deletions}`)}`);
292
+ printInfo(` ${chalk.green(`+${wt.insertions}`)} ${chalk.red(`-${wt.deletions}`)}`);
265
293
  }
266
294
 
267
295
  // 本地提交数
268
296
  if (wt.commitsAhead > 0) {
269
- parts.push(chalk.yellow(`${wt.commitsAhead} 个本地提交`));
297
+ printInfo(` ${chalk.yellow(`${wt.commitsAhead} 个本地提交`)}`);
270
298
  }
271
299
 
272
300
  // 与主分支的同步状态
273
301
  if (wt.commitsBehind > 0) {
274
- parts.push(chalk.yellow(`落后主分支 ${wt.commitsBehind} 个提交`));
302
+ printInfo(` ${chalk.yellow(`落后主分支 ${wt.commitsBehind} 个提交`)}`);
275
303
  } else {
276
- parts.push(chalk.green('与主分支同步'));
304
+ printInfo(` ${chalk.green('与主分支同步')}`);
277
305
  }
278
306
 
279
- printInfo(` ${parts.join(' ')}`);
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.hasSnapshot) {
283
- printInfo(` ${chalk.blue('有 validate 快照')}`);
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 {SnapshotInfo[]} snapshots - 快照信息列表
347
+ * 输出快照摘要区块
348
+ * @param {SnapshotSummary} snapshots - 快照摘要信息
310
349
  */
311
- function printSnapshotsSection(snapshots: SnapshotInfo[]): void {
312
- printInfo(` ${chalk.bold('◆')} ${chalk.bold(MESSAGES.STATUS_SNAPSHOTS_SECTION)} (${snapshots.length} 个)`);
313
- printInfo('');
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: '未清理的 Validate 快照',
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: '(对应 worktree 已不存在)',
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;
@@ -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';
@@ -10,12 +10,14 @@ export interface WorktreeDetailedStatus {
10
10
  commitsAhead: number;
11
11
  /** 落后于主分支的提交数 */
12
12
  commitsBehind: number;
13
- /** 是否存在 validate 快照 */
14
- hasSnapshot: boolean;
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
- /** 未清理的 validate 快照列表 */
46
- snapshots: SnapshotInfo[];
55
+ /** validate 快照摘要 */
56
+ snapshots: SnapshotSummary;
47
57
  /** worktree 总数 */
48
58
  totalWorktrees: number;
49
59
  }
@@ -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
+ }
@@ -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
- hasSnapshot: vi.fn(),
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
- hasSnapshot,
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 mockedHasSnapshot = vi.mocked(hasSnapshot);
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
- mockedHasSnapshot.mockReturnValue(false);
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).toHaveLength(2);
188
- expect(parsed.snapshots[0]).toEqual({ branch: 'feature', worktreeExists: true });
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
+ });