@hyperdrive.bot/gut 0.1.14 → 0.1.15-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ $ npm install -g @hyperdrive.bot/gut
18
18
  $ gut COMMAND
19
19
  running command...
20
20
  $ gut (--version)
21
- @hyperdrive.bot/gut/0.1.14-alpha.0 linux-x64 node-v22.22.2
21
+ @hyperdrive.bot/gut/0.1.15-alpha.0 linux-x64 node-v22.22.2
22
22
  $ gut --help [COMMAND]
23
23
  USAGE
24
24
  $ gut COMMAND
@@ -72,6 +72,11 @@ USAGE
72
72
  * [`gut used-by [ENTITY]`](#gut-used-by-entity)
73
73
  * [`gut workspace ACTION`](#gut-workspace-action)
74
74
  * [`gut worktree create NAME`](#gut-worktree-create-name)
75
+ * [`gut worktree gc`](#gut-worktree-gc)
76
+ * [`gut worktree list`](#gut-worktree-list)
77
+ * [`gut worktree prune`](#gut-worktree-prune)
78
+ * [`gut worktree remove NAME`](#gut-worktree-remove-name)
79
+ * [`gut worktree status [NAME]`](#gut-worktree-status-name)
75
80
 
76
81
  ## `gut add [PATH]`
77
82
 
@@ -1215,4 +1220,101 @@ EXAMPLES
1215
1220
 
1216
1221
  GUT_WORKTREE_DIR=/tmp/gut-worktrees gut worktree create feature/ci-run
1217
1222
  ```
1223
+
1224
+ ## `gut worktree gc`
1225
+
1226
+ Remove worktrees older than a given duration (garbage collection)
1227
+
1228
+ ```
1229
+ USAGE
1230
+ $ gut worktree gc -o <value> [-n] [-f]
1231
+
1232
+ FLAGS
1233
+ -f, --force Remove even worktrees with uncommitted changes
1234
+ -n, --dry-run List candidates without removing anything
1235
+ -o, --older-than=<value> (required) Duration threshold (e.g. '7d', '24h', '30m', '90s')
1236
+
1237
+ DESCRIPTION
1238
+ Remove worktrees older than a given duration (garbage collection)
1239
+
1240
+ EXAMPLES
1241
+ $ gut worktree gc --older-than 7d
1242
+
1243
+ $ gut worktree gc --older-than 14d --force
1244
+
1245
+ $ gut worktree gc --older-than 7d --dry-run
1246
+ ```
1247
+
1248
+ ## `gut worktree list`
1249
+
1250
+ List all active worktrees with metadata and staleness detection
1251
+
1252
+ ```
1253
+ USAGE
1254
+ $ gut worktree list
1255
+
1256
+ DESCRIPTION
1257
+ List all active worktrees with metadata and staleness detection
1258
+
1259
+ EXAMPLES
1260
+ $ gut worktree list
1261
+ ```
1262
+
1263
+ ## `gut worktree prune`
1264
+
1265
+ Remove worktree records whose paths no longer exist on disk
1266
+
1267
+ ```
1268
+ USAGE
1269
+ $ gut worktree prune
1270
+
1271
+ DESCRIPTION
1272
+ Remove worktree records whose paths no longer exist on disk
1273
+
1274
+ EXAMPLES
1275
+ $ gut worktree prune
1276
+ ```
1277
+
1278
+ ## `gut worktree remove NAME`
1279
+
1280
+ Remove a worktree and all entity worktrees
1281
+
1282
+ ```
1283
+ USAGE
1284
+ $ gut worktree remove NAME [-f]
1285
+
1286
+ ARGUMENTS
1287
+ NAME Worktree name to remove
1288
+
1289
+ FLAGS
1290
+ -f, --force Remove even with uncommitted changes
1291
+
1292
+ DESCRIPTION
1293
+ Remove a worktree and all entity worktrees
1294
+
1295
+ EXAMPLES
1296
+ $ gut worktree remove workflow/batch
1297
+
1298
+ $ gut worktree remove workflow/batch --force
1299
+ ```
1300
+
1301
+ ## `gut worktree status [NAME]`
1302
+
1303
+ Show per-entity git status for worktrees
1304
+
1305
+ ```
1306
+ USAGE
1307
+ $ gut worktree status [NAME]
1308
+
1309
+ ARGUMENTS
1310
+ NAME Worktree name (shows all if omitted)
1311
+
1312
+ DESCRIPTION
1313
+ Show per-entity git status for worktrees
1314
+
1315
+ EXAMPLES
1316
+ $ gut worktree status
1317
+
1318
+ $ gut worktree status workflow/deploy-batch
1319
+ ```
1218
1320
  <!-- commandsstop -->
@@ -0,0 +1,11 @@
1
+ import { BaseCommand } from '../../base-command.js';
2
+ export default class ClaudeInit extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
+ force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ };
9
+ protected get requiresInit(): boolean;
10
+ run(): Promise<void>;
11
+ }
@@ -0,0 +1,120 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import chalk from 'chalk';
6
+ import { BaseCommand } from '../../base-command.js';
7
+ const GUT_START = '<!-- gut:start -->';
8
+ const GUT_END = '<!-- gut:end -->';
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ const templateDir = join(__dirname, '..', '..', '..', 'templates', 'claude-init');
12
+ export default class ClaudeInit extends BaseCommand {
13
+ static description = 'Scaffold Claude Code configuration for gut';
14
+ static examples = [
15
+ '<%= config.bin %> claude init',
16
+ '<%= config.bin %> claude init --dry-run',
17
+ '<%= config.bin %> claude init --force',
18
+ ];
19
+ static flags = {
20
+ 'dry-run': Flags.boolean({ description: 'Preview changes without writing files' }),
21
+ force: Flags.boolean({ char: 'f', description: 'Overwrite existing gut section and command files' }),
22
+ };
23
+ get requiresInit() {
24
+ return false;
25
+ }
26
+ async run() {
27
+ const { flags } = await this.parse(ClaudeInit);
28
+ const dryRun = flags['dry-run'];
29
+ const force = flags.force;
30
+ // Validate template directory
31
+ if (!existsSync(templateDir)) {
32
+ this.error('Template directory not found at: ' + templateDir);
33
+ }
34
+ const results = [];
35
+ // --- CLAUDE.md handler ---
36
+ const claudeMdPath = join(process.cwd(), 'CLAUDE.md');
37
+ const templateContent = readFileSync(join(templateDir, 'claude-md-section.md'), 'utf-8');
38
+ if (!existsSync(claudeMdPath)) {
39
+ // No CLAUDE.md — create new file
40
+ if (dryRun) {
41
+ results.push({ action: 'created', file: 'CLAUDE.md' });
42
+ }
43
+ else {
44
+ writeFileSync(claudeMdPath, templateContent);
45
+ results.push({ action: 'created', file: 'CLAUDE.md' });
46
+ }
47
+ }
48
+ else {
49
+ const existing = readFileSync(claudeMdPath, 'utf-8');
50
+ if (existing.includes(GUT_START)) {
51
+ if (force) {
52
+ // Remove existing gut section and re-append
53
+ const cleaned = existing.replace(new RegExp(GUT_START + '[\\s\\S]*?' + GUT_END, 'g'), '').trimEnd();
54
+ if (!dryRun) {
55
+ writeFileSync(claudeMdPath, cleaned + '\n\n' + templateContent);
56
+ }
57
+ results.push({ action: 'modified', file: 'CLAUDE.md' });
58
+ }
59
+ else {
60
+ this.log('gut section already exists (use --force to overwrite)');
61
+ results.push({ action: 'skipped', file: 'CLAUDE.md' });
62
+ }
63
+ }
64
+ else {
65
+ // Append gut section
66
+ if (!dryRun) {
67
+ writeFileSync(claudeMdPath, existing + '\n\n' + templateContent);
68
+ }
69
+ results.push({ action: 'modified', file: 'CLAUDE.md' });
70
+ }
71
+ }
72
+ // --- .claude/commands/ handler ---
73
+ const commandsDir = join(process.cwd(), '.claude', 'commands');
74
+ const commandFiles = ['gut-status.md', 'gut-affected.md'];
75
+ if (!existsSync(commandsDir)) {
76
+ if (dryRun) {
77
+ this.log('.claude/commands/ → create directory');
78
+ }
79
+ else {
80
+ mkdirSync(commandsDir, { recursive: true });
81
+ }
82
+ }
83
+ for (const filename of commandFiles) {
84
+ const source = join(templateDir, 'commands', filename);
85
+ const target = join(commandsDir, filename);
86
+ if (existsSync(target) && !force) {
87
+ this.log(`${filename} → already exists (use --force to overwrite)`);
88
+ results.push({ action: 'skipped', file: `.claude/commands/${filename}` });
89
+ }
90
+ else {
91
+ if (!dryRun) {
92
+ // Ensure directory exists (in case only some files existed)
93
+ mkdirSync(commandsDir, { recursive: true });
94
+ copyFileSync(source, target);
95
+ }
96
+ results.push({ action: 'created', file: `.claude/commands/${filename}` });
97
+ }
98
+ }
99
+ // --- Summary ---
100
+ const heading = dryRun ? 'gut claude init (dry run)' : 'gut claude init';
101
+ this.log('');
102
+ this.log(chalk.bold(heading));
103
+ this.log('');
104
+ for (const { action, file } of results) {
105
+ const label = action === 'created'
106
+ ? chalk.green(action)
107
+ : action === 'modified'
108
+ ? chalk.yellow(action)
109
+ : chalk.dim(action);
110
+ this.log(` ${file} → ${label}`);
111
+ }
112
+ this.log('');
113
+ if (dryRun) {
114
+ this.log('No files were modified. Remove --dry-run to apply.');
115
+ }
116
+ else {
117
+ this.log('Run /gut-status in Claude Code to verify');
118
+ }
119
+ }
120
+ }
@@ -33,6 +33,14 @@ export default class WorktreeCreate extends BaseCommand {
33
33
  };
34
34
  async run() {
35
35
  const { args, flags } = await this.parse(WorktreeCreate);
36
+ const stale = this.worktreeService.findStale();
37
+ if (stale.length > 0) {
38
+ this.log(chalk.yellow('⚠️ Stale worktree records detected (path does not exist on disk):'));
39
+ for (const record of stale) {
40
+ this.log(chalk.yellow(` - ${record.name} ${record.path}`));
41
+ }
42
+ this.log(chalk.yellow('Run `gut worktree prune` to remove them.'));
43
+ }
36
44
  const workspaceRoot = this.configService.getWorkspaceRoot();
37
45
  const slug = args.name.replace(/\//g, '-');
38
46
  const wtPath = path.join(flags['base-dir'], slug);
@@ -0,0 +1,13 @@
1
+ import { BaseCommand } from '../../base-command.js';
2
+ export default class WorktreeGc extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
+ force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ 'older-than': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
+ };
10
+ run(): Promise<void>;
11
+ private countDirtyChanges;
12
+ private hasDirtyEntity;
13
+ }
@@ -0,0 +1,126 @@
1
+ import { Flags } from '@oclif/core';
2
+ import chalk from 'chalk';
3
+ import fs from 'node:fs';
4
+ import { BaseCommand } from '../../base-command.js';
5
+ import { parseDuration } from '../../utils/duration.js';
6
+ const DAY_MS = 86_400_000;
7
+ export default class WorktreeGc extends BaseCommand {
8
+ static description = 'Remove worktrees older than a given duration (garbage collection)';
9
+ static examples = [
10
+ '<%= config.bin %> worktree gc --older-than 7d',
11
+ '<%= config.bin %> worktree gc --older-than 14d --force',
12
+ '<%= config.bin %> worktree gc --older-than 7d --dry-run',
13
+ ];
14
+ static flags = {
15
+ 'dry-run': Flags.boolean({
16
+ char: 'n',
17
+ default: false,
18
+ description: 'List candidates without removing anything',
19
+ }),
20
+ force: Flags.boolean({
21
+ char: 'f',
22
+ default: false,
23
+ description: 'Remove even worktrees with uncommitted changes',
24
+ }),
25
+ 'older-than': Flags.string({
26
+ char: 'o',
27
+ description: "Duration threshold (e.g. '7d', '24h', '30m', '90s')",
28
+ required: true,
29
+ }),
30
+ };
31
+ async run() {
32
+ const { flags } = await this.parse(WorktreeGc);
33
+ const olderThanInput = flags['older-than'];
34
+ let thresholdMs;
35
+ try {
36
+ thresholdMs = parseDuration(olderThanInput);
37
+ }
38
+ catch (error) {
39
+ this.error(error instanceof Error ? error.message : String(error));
40
+ }
41
+ const state = this.worktreeService.loadState();
42
+ const now = Date.now();
43
+ const candidates = state.worktrees.filter(w => now - w.createdAt > thresholdMs);
44
+ if (candidates.length === 0) {
45
+ this.log(chalk.dim(`No worktrees older than ${olderThanInput}`));
46
+ return;
47
+ }
48
+ // Collect per-candidate dirty state up front (used by both dry-run and real run)
49
+ const dirtyFlags = await Promise.all(candidates.map(async (record) => this.hasDirtyEntity(record)));
50
+ if (flags['dry-run']) {
51
+ this.log(chalk.bold(`\nWorktrees older than ${olderThanInput}:`));
52
+ for (const [i, record] of candidates.entries()) {
53
+ const ageDays = Math.floor((now - record.createdAt) / DAY_MS);
54
+ const dirtyLabel = dirtyFlags[i] ? chalk.yellow(' [dirty]') : '';
55
+ this.log(` ${record.name} ${ageDays}d old${dirtyLabel}`);
56
+ }
57
+ this.log(chalk.dim(`\n${candidates.length} candidate(s)`));
58
+ return;
59
+ }
60
+ this.log(chalk.bold(`\n🧹 Garbage collecting worktrees older than ${olderThanInput}...`));
61
+ let removed = 0;
62
+ let skippedDirty = 0;
63
+ let skippedMissing = 0;
64
+ for (const [i, record] of candidates.entries()) {
65
+ if (dirtyFlags[i] && !flags.force) {
66
+ // eslint-disable-next-line no-await-in-loop
67
+ const changeCount = await this.countDirtyChanges(record);
68
+ this.log(chalk.yellow(` ⚠ ${record.name}: skipped (${changeCount} uncommitted changes)`));
69
+ skippedDirty++;
70
+ continue;
71
+ }
72
+ // eslint-disable-next-line no-await-in-loop
73
+ const result = await this.worktreeService.removeWorktreeRecord(record.name, {
74
+ force: flags.force,
75
+ logger: (msg) => {
76
+ if (msg.includes('⚠'))
77
+ this.log(chalk.yellow(msg));
78
+ else if (msg.includes('○'))
79
+ this.log(chalk.dim(msg));
80
+ else if (msg.includes('✓'))
81
+ this.log(chalk.green(msg));
82
+ else
83
+ this.log(msg);
84
+ },
85
+ });
86
+ if (result.skippedDueToDirty) {
87
+ skippedDirty++;
88
+ continue;
89
+ }
90
+ removed++;
91
+ if (result.removedEntities === 0 && result.skippedEntities > 0) {
92
+ skippedMissing++;
93
+ }
94
+ }
95
+ this.log(chalk.dim('─'.repeat(50)));
96
+ this.log(chalk.green(`✓ Garbage collected ${removed} worktree(s)`));
97
+ this.log(chalk.dim(` Removed: ${removed}`));
98
+ this.log(chalk.dim(` Skipped (dirty): ${skippedDirty}`));
99
+ this.log(chalk.dim(` Skipped (missing):${skippedMissing}`));
100
+ this.log(chalk.dim(` Candidates: ${candidates.length}`));
101
+ }
102
+ async countDirtyChanges(record) {
103
+ let total = 0;
104
+ for (const entity of record.entities) {
105
+ if (!fs.existsSync(entity.worktreePath))
106
+ continue;
107
+ // eslint-disable-next-line no-await-in-loop
108
+ const status = await this.gitService.getStatus(entity.worktreePath);
109
+ if (status.hasChanges) {
110
+ total += status.changes.length + status.untracked.length;
111
+ }
112
+ }
113
+ return total;
114
+ }
115
+ async hasDirtyEntity(record) {
116
+ for (const entity of record.entities) {
117
+ if (!fs.existsSync(entity.worktreePath))
118
+ continue;
119
+ // eslint-disable-next-line no-await-in-loop
120
+ const status = await this.gitService.getStatus(entity.worktreePath);
121
+ if (status.hasChanges)
122
+ return true;
123
+ }
124
+ return false;
125
+ }
126
+ }
@@ -0,0 +1,12 @@
1
+ import { BaseCommand } from '../../base-command.js';
2
+ export declare const MS_PER_DAY: number;
3
+ export declare const STALE_DAYS = 7;
4
+ export declare function formatWorktreeAge(createdAt: number, now: number): {
5
+ isStale: boolean;
6
+ text: string;
7
+ };
8
+ export default class WorktreeList extends BaseCommand {
9
+ static description: string;
10
+ static examples: string[];
11
+ run(): Promise<void>;
12
+ }
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs';
2
+ import chalk from 'chalk';
3
+ import { BaseCommand } from '../../base-command.js';
4
+ export const MS_PER_DAY = 1000 * 60 * 60 * 24;
5
+ export const STALE_DAYS = 7;
6
+ export function formatWorktreeAge(createdAt, now) {
7
+ if (typeof createdAt !== 'number' || Number.isNaN(createdAt)) {
8
+ return { isStale: false, text: 'unknown age' };
9
+ }
10
+ const ageMs = now - createdAt;
11
+ if (ageMs <= 0) {
12
+ return { isStale: false, text: 'less than a day old' };
13
+ }
14
+ const ageDays = Math.floor(ageMs / MS_PER_DAY);
15
+ const isStale = ageMs / MS_PER_DAY > STALE_DAYS;
16
+ let text;
17
+ if (ageDays === 0) {
18
+ text = 'less than a day old';
19
+ }
20
+ else if (ageDays === 1) {
21
+ text = '1 day old';
22
+ }
23
+ else {
24
+ text = `${ageDays} days old`;
25
+ }
26
+ return { isStale, text };
27
+ }
28
+ export default class WorktreeList extends BaseCommand {
29
+ static description = 'List all active worktrees with metadata and staleness detection';
30
+ static examples = ['<%= config.bin %> <%= command.id %>'];
31
+ async run() {
32
+ const stale = this.worktreeService.findStale();
33
+ if (stale.length > 0) {
34
+ this.log(chalk.yellow('⚠️ Stale worktree records detected (path does not exist on disk):'));
35
+ for (const record of stale) {
36
+ this.log(chalk.yellow(` - ${record.name} ${record.path}`));
37
+ }
38
+ this.log(chalk.yellow('Run `gut worktree prune` to remove them.'));
39
+ }
40
+ const worktrees = this.worktreeService.list();
41
+ if (worktrees.length === 0) {
42
+ this.log(`No active worktrees. Create one with: ${chalk.cyan('gut worktree create <branch-name>')}`);
43
+ return;
44
+ }
45
+ this.log(chalk.bold('\nActive worktrees:\n'));
46
+ for (const record of worktrees) {
47
+ const isStale = !fs.existsSync(record.path);
48
+ const formattedDate = new Date(record.createdAt).toISOString().replace('T', ' ').slice(0, 16);
49
+ const entityNames = record.entities.map(e => e.entityName).join(', ');
50
+ const age = formatWorktreeAge(record.createdAt, Date.now());
51
+ const ageLine = age.isStale
52
+ ? chalk.yellow(` Age: ⚠ (${age.text}) — consider removing`)
53
+ : ` Age: (${age.text})`;
54
+ this.log(` ${chalk.bold(record.name)}`);
55
+ this.log(` Path: ${record.path}${isStale ? chalk.yellow(' [stale]') : ''}`);
56
+ this.log(` Branch: ${chalk.cyan(record.name)}`);
57
+ this.log(` Created: ${chalk.dim(formattedDate)}`);
58
+ this.log(ageLine);
59
+ this.log(` Entities: ${entityNames}`);
60
+ this.log('');
61
+ }
62
+ }
63
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,182 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import { MS_PER_DAY, formatWorktreeAge } from './list.js';
4
+ // Mock fs.existsSync
5
+ vi.mock('node:fs', () => ({
6
+ default: {
7
+ existsSync: vi.fn(() => true),
8
+ },
9
+ existsSync: vi.fn(() => true),
10
+ }));
11
+ // Mock chalk to return raw strings for easy assertion
12
+ vi.mock('chalk', () => ({
13
+ default: {
14
+ bold: (s) => s,
15
+ cyan: (s) => s,
16
+ dim: (s) => s,
17
+ yellow: (s) => s,
18
+ },
19
+ }));
20
+ function makeRecord(overrides = {}) {
21
+ return {
22
+ baseBranch: 'master',
23
+ createdAt: new Date('2026-03-19T13:22:00Z').getTime(),
24
+ entities: [
25
+ { branch: 'workflow/deploy-batch', entityName: 'api', entityPath: 'packages/serverless/api', worktreePath: '/tmp/gut-worktrees/workflow-deploy-batch/packages/serverless/api' },
26
+ { branch: 'workflow/deploy-batch', entityName: 'sign', entityPath: 'packages/web-apps/sign', worktreePath: '/tmp/gut-worktrees/workflow-deploy-batch/packages/web-apps/sign' },
27
+ ],
28
+ name: 'workflow/deploy-batch',
29
+ path: '/tmp/gut-worktrees/workflow-deploy-batch',
30
+ ...overrides,
31
+ };
32
+ }
33
+ describe('WorktreeList', () => {
34
+ it('displays populated list with names, paths, branches, dates, entity names', async () => {
35
+ const record1 = makeRecord();
36
+ const record2 = makeRecord({
37
+ baseBranch: 'main',
38
+ createdAt: new Date('2026-03-19T14:05:00Z').getTime(),
39
+ entities: [{ branch: 'workflow/story-042', entityName: 'api', entityPath: 'packages/serverless/api', worktreePath: '/tmp/gut-worktrees/workflow-story-042/packages/serverless/api' }],
40
+ name: 'workflow/story-042',
41
+ path: '/tmp/gut-worktrees/workflow-story-042',
42
+ });
43
+ const logs = [];
44
+ // Import the command class
45
+ const { default: WorktreeList } = await import('./list.js');
46
+ // Create instance and override dependencies
47
+ const cmd = Object.create(WorktreeList.prototype);
48
+ cmd.log = (msg = '') => { logs.push(msg); };
49
+ cmd.worktreeService = { findStale: () => [], list: () => [record1, record2] };
50
+ vi.mocked(fs.existsSync).mockReturnValue(true);
51
+ await cmd.run();
52
+ const output = logs.join('\n');
53
+ expect(output).toContain('Active worktrees:');
54
+ expect(output).toContain('workflow/deploy-batch');
55
+ expect(output).toContain('workflow/story-042');
56
+ expect(output).toContain('/tmp/gut-worktrees/workflow-deploy-batch');
57
+ expect(output).toContain('/tmp/gut-worktrees/workflow-story-042');
58
+ expect(output).toContain('2026-03-19 13:22');
59
+ expect(output).toContain('2026-03-19 14:05');
60
+ expect(output).toContain('api, sign');
61
+ expect(output).toContain('api');
62
+ expect(output).not.toContain('[stale]');
63
+ });
64
+ it('displays empty state message when no worktrees exist', async () => {
65
+ const logs = [];
66
+ const { default: WorktreeList } = await import('./list.js');
67
+ const cmd = Object.create(WorktreeList.prototype);
68
+ cmd.log = (msg = '') => { logs.push(msg); };
69
+ cmd.worktreeService = { findStale: () => [], list: () => [] };
70
+ await cmd.run();
71
+ const output = logs.join('\n');
72
+ expect(output).toContain('No active worktrees');
73
+ expect(output).toContain('gut worktree create <branch-name>');
74
+ expect(output).not.toContain('Active worktrees:');
75
+ });
76
+ it('shows [stale] marker when worktree path does not exist on disk', async () => {
77
+ const record = makeRecord({
78
+ path: '/tmp/gut-worktrees/nonexistent-worktree',
79
+ });
80
+ const logs = [];
81
+ const { default: WorktreeList } = await import('./list.js');
82
+ const cmd = Object.create(WorktreeList.prototype);
83
+ cmd.log = (msg = '') => { logs.push(msg); };
84
+ cmd.worktreeService = { findStale: () => [record], list: () => [record] };
85
+ vi.mocked(fs.existsSync).mockReturnValue(false);
86
+ await cmd.run();
87
+ const output = logs.join('\n');
88
+ expect(output).toContain('[stale]');
89
+ expect(output).toContain('/tmp/gut-worktrees/nonexistent-worktree');
90
+ });
91
+ describe('stale warning block (Story 2.1)', () => {
92
+ it('prints warning block before the list when findStale returns records', async () => {
93
+ const staleA = makeRecord({ name: 'workflow/dead-a', path: '/tmp/gone-a' });
94
+ const staleB = makeRecord({ name: 'workflow/dead-b', path: '/tmp/gone-b' });
95
+ const alive = makeRecord({ name: 'workflow/alive', path: '/tmp/alive' });
96
+ const logs = [];
97
+ const { default: WorktreeList } = await import('./list.js');
98
+ const cmd = Object.create(WorktreeList.prototype);
99
+ cmd.log = (msg = '') => { logs.push(msg); };
100
+ cmd.worktreeService = {
101
+ findStale: () => [staleA, staleB],
102
+ list: () => [staleA, staleB, alive],
103
+ };
104
+ vi.mocked(fs.existsSync).mockImplementation((p) => p === '/tmp/alive');
105
+ await cmd.run();
106
+ const warningIdx = logs.findIndex(l => l.includes('⚠️ Stale worktree records detected (path does not exist on disk):'));
107
+ const activeIdx = logs.findIndex(l => l.includes('Active worktrees:'));
108
+ expect(warningIdx).toBeGreaterThanOrEqual(0);
109
+ expect(warningIdx).toBeLessThan(activeIdx);
110
+ expect(logs).toContain(' - workflow/dead-a /tmp/gone-a');
111
+ expect(logs).toContain(' - workflow/dead-b /tmp/gone-b');
112
+ expect(logs).toContain('Run `gut worktree prune` to remove them.');
113
+ });
114
+ it('omits the warning block entirely when findStale returns empty', async () => {
115
+ const alive = makeRecord({ name: 'workflow/alive', path: '/tmp/alive' });
116
+ const logs = [];
117
+ const { default: WorktreeList } = await import('./list.js');
118
+ const cmd = Object.create(WorktreeList.prototype);
119
+ cmd.log = (msg = '') => { logs.push(msg); };
120
+ cmd.worktreeService = { findStale: () => [], list: () => [alive] };
121
+ vi.mocked(fs.existsSync).mockReturnValue(true);
122
+ await cmd.run();
123
+ const output = logs.join('\n');
124
+ expect(output).not.toContain('⚠️');
125
+ expect(output).not.toContain('Stale worktree records detected');
126
+ expect(output).not.toContain('gut worktree prune');
127
+ });
128
+ });
129
+ describe('formatWorktreeAge (Story 3.1)', () => {
130
+ const NOW = new Date('2026-04-11T12:00:00Z').getTime();
131
+ it('returns "less than a day old", not stale, when created right now', () => {
132
+ const result = formatWorktreeAge(NOW, NOW);
133
+ expect(result.text).toContain('less than a day old');
134
+ expect(result.isStale).toBe(false);
135
+ });
136
+ it('returns "1 day old" at exactly 1 day', () => {
137
+ const result = formatWorktreeAge(NOW - MS_PER_DAY, NOW);
138
+ expect(result.text).toContain('1 day old');
139
+ expect(result.isStale).toBe(false);
140
+ });
141
+ it('returns "3 days old", not stale, at 3 days', () => {
142
+ const result = formatWorktreeAge(NOW - 3 * MS_PER_DAY, NOW);
143
+ expect(result.text).toContain('3 days old');
144
+ expect(result.isStale).toBe(false);
145
+ });
146
+ it('is NOT stale at exactly 7 days (strict >)', () => {
147
+ const result = formatWorktreeAge(NOW - 7 * MS_PER_DAY, NOW);
148
+ expect(result.isStale).toBe(false);
149
+ expect(result.text).toContain('7 days old');
150
+ });
151
+ it('IS stale at 7 days + 1ms', () => {
152
+ const result = formatWorktreeAge(NOW - (7 * MS_PER_DAY + 1), NOW);
153
+ expect(result.isStale).toBe(true);
154
+ });
155
+ it('returns "30 days old" and is stale at 30 days', () => {
156
+ const result = formatWorktreeAge(NOW - 30 * MS_PER_DAY, NOW);
157
+ expect(result.text).toContain('30 days old');
158
+ expect(result.isStale).toBe(true);
159
+ });
160
+ it('handles future createdAt (clock skew) as "less than a day old", not stale', () => {
161
+ const result = formatWorktreeAge(NOW + 5 * MS_PER_DAY, NOW);
162
+ expect(result.text).toContain('less than a day old');
163
+ expect(result.isStale).toBe(false);
164
+ });
165
+ it('handles NaN createdAt as "unknown age", not stale', () => {
166
+ const result = formatWorktreeAge(Number.NaN, NOW);
167
+ expect(result.text).toContain('unknown age');
168
+ expect(result.isStale).toBe(false);
169
+ });
170
+ });
171
+ it('handles missing worktrees.json (first-run) — same as empty state', async () => {
172
+ const logs = [];
173
+ const { default: WorktreeList } = await import('./list.js');
174
+ const cmd = Object.create(WorktreeList.prototype);
175
+ cmd.log = (msg = '') => { logs.push(msg); };
176
+ cmd.worktreeService = { findStale: () => [], list: () => [] };
177
+ await cmd.run();
178
+ const output = logs.join('\n');
179
+ expect(output).toContain('No active worktrees');
180
+ expect(output).toContain('gut worktree create <branch-name>');
181
+ });
182
+ });