@hyperdrive.bot/gut 0.1.14 → 0.1.15
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 +103 -1
- package/dist/commands/worktree/create.js +8 -0
- package/dist/commands/worktree/gc.d.ts +13 -0
- package/dist/commands/worktree/gc.js +126 -0
- package/dist/commands/worktree/list.d.ts +12 -0
- package/dist/commands/worktree/list.js +63 -0
- package/dist/commands/worktree/list.test.d.ts +1 -0
- package/dist/commands/worktree/list.test.js +182 -0
- package/dist/commands/worktree/prune.d.ts +6 -0
- package/dist/commands/worktree/prune.js +28 -0
- package/dist/commands/worktree/prune.test.d.ts +1 -0
- package/dist/commands/worktree/prune.test.js +105 -0
- package/dist/commands/worktree/remove.d.ts +12 -0
- package/dist/commands/worktree/remove.js +48 -0
- package/dist/commands/worktree/status.d.ts +9 -0
- package/dist/commands/worktree/status.js +58 -0
- package/dist/services/worktree.service.d.ts +24 -0
- package/dist/services/worktree.service.js +90 -0
- package/dist/utils/duration.d.ts +1 -0
- package/dist/utils/duration.js +26 -0
- package/package.json +1 -1
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.
|
|
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 -->
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { BaseCommand } from '../../base-command.js';
|
|
4
|
+
export default class WorktreePrune extends BaseCommand {
|
|
5
|
+
static description = 'Remove worktree records whose paths no longer exist on disk';
|
|
6
|
+
static examples = ['<%= config.bin %> worktree prune'];
|
|
7
|
+
async run() {
|
|
8
|
+
const state = this.worktreeService.loadState();
|
|
9
|
+
const stale = [];
|
|
10
|
+
const kept = [];
|
|
11
|
+
for (const w of state.worktrees) {
|
|
12
|
+
if (fs.existsSync(w.path))
|
|
13
|
+
kept.push(w);
|
|
14
|
+
else
|
|
15
|
+
stale.push(w);
|
|
16
|
+
}
|
|
17
|
+
if (stale.length === 0) {
|
|
18
|
+
this.log(chalk.dim('No stale worktree records found'));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
for (const record of stale) {
|
|
22
|
+
this.log(` ${chalk.yellow('✗')} ${record.name} ${chalk.dim('→')} ${record.path}`);
|
|
23
|
+
}
|
|
24
|
+
this.worktreeService.saveState({ worktrees: kept });
|
|
25
|
+
await this.gitService.worktreePrune(this.configService.getWorkspaceRoot());
|
|
26
|
+
this.log(chalk.green(`✓ Pruned ${stale.length} stale worktree record(s)`));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
vi.mock('node:fs', () => ({
|
|
4
|
+
default: {
|
|
5
|
+
existsSync: vi.fn(() => true),
|
|
6
|
+
},
|
|
7
|
+
existsSync: vi.fn(() => true),
|
|
8
|
+
}));
|
|
9
|
+
vi.mock('chalk', () => ({
|
|
10
|
+
default: {
|
|
11
|
+
bold: (s) => s,
|
|
12
|
+
cyan: (s) => s,
|
|
13
|
+
dim: (s) => s,
|
|
14
|
+
green: (s) => s,
|
|
15
|
+
yellow: (s) => s,
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
function makeRecord(overrides = {}) {
|
|
19
|
+
return {
|
|
20
|
+
baseBranch: 'master',
|
|
21
|
+
createdAt: new Date('2026-03-19T13:22:00Z').getTime(),
|
|
22
|
+
entities: [
|
|
23
|
+
{ branch: 'workflow/deploy-batch', entityName: 'api', entityPath: 'packages/serverless/api', worktreePath: '/tmp/gut-worktrees/workflow-deploy-batch/packages/serverless/api' },
|
|
24
|
+
],
|
|
25
|
+
name: 'workflow/deploy-batch',
|
|
26
|
+
path: '/tmp/gut-worktrees/workflow-deploy-batch',
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
async function makeCmd(state) {
|
|
31
|
+
const logs = [];
|
|
32
|
+
const { default: WorktreePrune } = await import('./prune.js');
|
|
33
|
+
const cmd = Object.create(WorktreePrune.prototype);
|
|
34
|
+
cmd.log = (msg = '') => { logs.push(msg); };
|
|
35
|
+
const stubs = {
|
|
36
|
+
configService: { getWorkspaceRoot: vi.fn(() => '/fake/workspace') },
|
|
37
|
+
gitService: { worktreePrune: vi.fn(async () => { }) },
|
|
38
|
+
worktreeService: {
|
|
39
|
+
loadState: vi.fn(() => state),
|
|
40
|
+
saveState: vi.fn(),
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
cmd.configService = stubs.configService;
|
|
44
|
+
cmd.gitService = stubs.gitService;
|
|
45
|
+
cmd.worktreeService = stubs.worktreeService;
|
|
46
|
+
return { cmd, logs, stubs };
|
|
47
|
+
}
|
|
48
|
+
describe('WorktreePrune', () => {
|
|
49
|
+
it('mixed stale+healthy: removes only stale, logs, calls saveState(kept) and worktreePrune once', async () => {
|
|
50
|
+
const healthy = makeRecord({ name: 'workflow/alive', path: '/tmp/alive' });
|
|
51
|
+
const stale = makeRecord({ name: 'workflow/dead', path: '/tmp/dead' });
|
|
52
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === '/tmp/alive');
|
|
53
|
+
const { cmd, logs, stubs } = await makeCmd({ worktrees: [healthy, stale] });
|
|
54
|
+
await cmd.run();
|
|
55
|
+
expect(stubs.worktreeService.saveState).toHaveBeenCalledTimes(1);
|
|
56
|
+
expect(stubs.worktreeService.saveState).toHaveBeenCalledWith({ worktrees: [healthy] });
|
|
57
|
+
expect(stubs.gitService.worktreePrune).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(stubs.gitService.worktreePrune).toHaveBeenCalledWith('/fake/workspace');
|
|
59
|
+
const output = logs.join('\n');
|
|
60
|
+
expect(output).toContain('✗');
|
|
61
|
+
expect(output).toContain('workflow/dead');
|
|
62
|
+
expect(output).toContain('/tmp/dead');
|
|
63
|
+
expect(output).not.toContain('workflow/alive');
|
|
64
|
+
expect(output).toContain('✓ Pruned 1 stale worktree record(s)');
|
|
65
|
+
});
|
|
66
|
+
it('all stale: removes all, saveState called with empty, summary reports full count', async () => {
|
|
67
|
+
const a = makeRecord({ name: 'workflow/dead-a', path: '/tmp/dead-a' });
|
|
68
|
+
const b = makeRecord({ name: 'workflow/dead-b', path: '/tmp/dead-b' });
|
|
69
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
70
|
+
const { cmd, logs, stubs } = await makeCmd({ worktrees: [a, b] });
|
|
71
|
+
await cmd.run();
|
|
72
|
+
expect(stubs.worktreeService.saveState).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(stubs.worktreeService.saveState).toHaveBeenCalledWith({ worktrees: [] });
|
|
74
|
+
expect(stubs.gitService.worktreePrune).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(stubs.gitService.worktreePrune).toHaveBeenCalledWith('/fake/workspace');
|
|
76
|
+
const output = logs.join('\n');
|
|
77
|
+
expect(output).toContain('workflow/dead-a');
|
|
78
|
+
expect(output).toContain('/tmp/dead-a');
|
|
79
|
+
expect(output).toContain('workflow/dead-b');
|
|
80
|
+
expect(output).toContain('/tmp/dead-b');
|
|
81
|
+
expect(output).toContain('✓ Pruned 2 stale worktree record(s)');
|
|
82
|
+
});
|
|
83
|
+
it('all healthy: no save, no git prune, logs empty-state message', async () => {
|
|
84
|
+
const a = makeRecord({ name: 'workflow/alive-a', path: '/tmp/alive-a' });
|
|
85
|
+
const b = makeRecord({ name: 'workflow/alive-b', path: '/tmp/alive-b' });
|
|
86
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
87
|
+
const { cmd, logs, stubs } = await makeCmd({ worktrees: [a, b] });
|
|
88
|
+
await cmd.run();
|
|
89
|
+
expect(stubs.worktreeService.saveState).not.toHaveBeenCalled();
|
|
90
|
+
expect(stubs.gitService.worktreePrune).not.toHaveBeenCalled();
|
|
91
|
+
const output = logs.join('\n');
|
|
92
|
+
expect(output).toContain('No stale worktree records found');
|
|
93
|
+
expect(output).not.toContain('Pruned');
|
|
94
|
+
});
|
|
95
|
+
it('empty state: no save, no git prune, logs empty-state message', async () => {
|
|
96
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
97
|
+
const { cmd, logs, stubs } = await makeCmd({ worktrees: [] });
|
|
98
|
+
await cmd.run();
|
|
99
|
+
expect(stubs.worktreeService.saveState).not.toHaveBeenCalled();
|
|
100
|
+
expect(stubs.gitService.worktreePrune).not.toHaveBeenCalled();
|
|
101
|
+
const output = logs.join('\n');
|
|
102
|
+
expect(output).toContain('No stale worktree records found');
|
|
103
|
+
expect(output).not.toContain('Pruned');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { BaseCommand } from '../../base-command.js';
|
|
2
|
+
export default class WorktreeRemove extends BaseCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
};
|
|
11
|
+
run(): Promise<void>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { BaseCommand } from '../../base-command.js';
|
|
4
|
+
export default class WorktreeRemove extends BaseCommand {
|
|
5
|
+
static args = {
|
|
6
|
+
name: Args.string({ description: 'Worktree name to remove', required: true }),
|
|
7
|
+
};
|
|
8
|
+
static description = 'Remove a worktree and all entity worktrees';
|
|
9
|
+
static examples = [
|
|
10
|
+
'<%= config.bin %> worktree remove workflow/batch',
|
|
11
|
+
'<%= config.bin %> worktree remove workflow/batch --force',
|
|
12
|
+
];
|
|
13
|
+
static flags = {
|
|
14
|
+
force: Flags.boolean({ char: 'f', default: false, description: 'Remove even with uncommitted changes' }),
|
|
15
|
+
};
|
|
16
|
+
async run() {
|
|
17
|
+
const { args, flags } = await this.parse(WorktreeRemove);
|
|
18
|
+
const { name } = args;
|
|
19
|
+
const record = this.worktreeService.get(name);
|
|
20
|
+
if (!record) {
|
|
21
|
+
this.error(`Worktree '${name}' not found`);
|
|
22
|
+
}
|
|
23
|
+
this.log(chalk.bold('\n🗑️ Removing worktree...'));
|
|
24
|
+
const result = await this.worktreeService.removeWorktreeRecord(name, {
|
|
25
|
+
force: flags.force,
|
|
26
|
+
logger: (msg) => {
|
|
27
|
+
if (msg.includes('⚠'))
|
|
28
|
+
this.log(chalk.yellow(msg));
|
|
29
|
+
else if (msg.includes('○'))
|
|
30
|
+
this.log(chalk.dim(msg));
|
|
31
|
+
else if (msg.includes('✓'))
|
|
32
|
+
this.log(chalk.green(msg));
|
|
33
|
+
else
|
|
34
|
+
this.log(msg);
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
if (result.skippedDueToDirty) {
|
|
38
|
+
const dirtyList = result.dirtyEntities
|
|
39
|
+
.map(d => ` ${d.entityName}: ${d.changeCount} uncommitted changes`)
|
|
40
|
+
.join('\n');
|
|
41
|
+
this.error(`Cannot remove worktree with uncommitted changes:\n${dirtyList}\n\nUse --force to remove anyway.`);
|
|
42
|
+
}
|
|
43
|
+
this.log(chalk.dim('─'.repeat(50)));
|
|
44
|
+
this.log(chalk.green(`✓ Removed worktree '${name}'`));
|
|
45
|
+
this.log(` Path: ${record.path}`);
|
|
46
|
+
this.log(` Entities: ${result.removedEntities} removed${result.skippedEntities > 0 ? `, ${result.skippedEntities} skipped` : ''}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { BaseCommand } from '../../base-command.js';
|
|
2
|
+
export default class WorktreeStatus extends BaseCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
name: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
run(): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import Table from 'cli-table3';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { BaseCommand } from '../../base-command.js';
|
|
6
|
+
export default class WorktreeStatus extends BaseCommand {
|
|
7
|
+
static args = {
|
|
8
|
+
name: Args.string({ description: 'Worktree name (shows all if omitted)', required: false }),
|
|
9
|
+
};
|
|
10
|
+
static description = 'Show per-entity git status for worktrees';
|
|
11
|
+
static examples = [
|
|
12
|
+
'<%= config.bin %> worktree status',
|
|
13
|
+
'<%= config.bin %> worktree status workflow/deploy-batch',
|
|
14
|
+
];
|
|
15
|
+
async run() {
|
|
16
|
+
const { args } = await this.parse(WorktreeStatus);
|
|
17
|
+
let worktrees;
|
|
18
|
+
if (args.name) {
|
|
19
|
+
const record = this.worktreeService.get(args.name);
|
|
20
|
+
if (!record) {
|
|
21
|
+
this.error(`Worktree "${args.name}" not found. Run "gut worktree list" to see active worktrees.`);
|
|
22
|
+
}
|
|
23
|
+
worktrees = [record];
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
worktrees = this.worktreeService.list();
|
|
27
|
+
if (worktrees.length === 0) {
|
|
28
|
+
this.log(`No active worktrees. Create one with: ${chalk.cyan('gut worktree create <branch-name>')}`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const worktree of worktrees) {
|
|
33
|
+
this.log(chalk.bold(`\n${worktree.name}`));
|
|
34
|
+
this.log(chalk.dim(worktree.path));
|
|
35
|
+
const table = new Table({
|
|
36
|
+
head: [chalk.cyan('Entity'), chalk.cyan('Branch'), chalk.cyan('Status'), chalk.cyan('Changes'), chalk.cyan('Ahead'), chalk.cyan('Behind')],
|
|
37
|
+
style: { border: [], head: [] },
|
|
38
|
+
});
|
|
39
|
+
for (const entity of worktree.entities) {
|
|
40
|
+
if (!fs.existsSync(entity.worktreePath)) {
|
|
41
|
+
table.push([entity.entityName, '-', chalk.yellow('[stale - path missing]'), '-', '-', '-']);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const status = await this.gitService.getStatus(entity.worktreePath);
|
|
45
|
+
const changeCount = status.changes.length + status.untracked.length;
|
|
46
|
+
table.push([
|
|
47
|
+
entity.entityName,
|
|
48
|
+
status.branch,
|
|
49
|
+
status.hasChanges ? chalk.yellow('dirty') : chalk.green('clean'),
|
|
50
|
+
status.hasChanges ? chalk.yellow(`${changeCount} changes`) : chalk.green('clean'),
|
|
51
|
+
status.ahead.toString(),
|
|
52
|
+
status.behind.toString(),
|
|
53
|
+
]);
|
|
54
|
+
}
|
|
55
|
+
this.log(table.toString());
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -2,6 +2,20 @@ import type { WorktreeRecord, WorktreeState } from '../models/entity.model.js';
|
|
|
2
2
|
import type { ConfigService } from './config.service.js';
|
|
3
3
|
import type { FocusService } from './focus.service.js';
|
|
4
4
|
import type { GitService } from './git.service.js';
|
|
5
|
+
export interface RemoveWorktreeOptions {
|
|
6
|
+
force: boolean;
|
|
7
|
+
logger?: (message: string) => void;
|
|
8
|
+
}
|
|
9
|
+
export interface RemoveWorktreeResult {
|
|
10
|
+
dirtyEntities: Array<{
|
|
11
|
+
changeCount: number;
|
|
12
|
+
entityName: string;
|
|
13
|
+
}>;
|
|
14
|
+
removed: boolean;
|
|
15
|
+
removedEntities: number;
|
|
16
|
+
skippedDueToDirty: boolean;
|
|
17
|
+
skippedEntities: number;
|
|
18
|
+
}
|
|
5
19
|
export declare class WorktreeService {
|
|
6
20
|
private readonly configService;
|
|
7
21
|
private readonly gitService;
|
|
@@ -11,6 +25,16 @@ export declare class WorktreeService {
|
|
|
11
25
|
constructor(configService: ConfigService, gitService: GitService, focusService: FocusService);
|
|
12
26
|
get(name: string): WorktreeRecord | null;
|
|
13
27
|
list(): WorktreeRecord[];
|
|
28
|
+
findStale(): WorktreeRecord[];
|
|
14
29
|
loadState(): WorktreeState;
|
|
30
|
+
/**
|
|
31
|
+
* Remove a worktree record: all entity worktrees in reverse order, then the
|
|
32
|
+
* super-repo worktree, pruning refs along the way, then splice the record
|
|
33
|
+
* out of state. Shared between `worktree remove` and `worktree gc`.
|
|
34
|
+
*
|
|
35
|
+
* If any entity has uncommitted changes and `force` is false, nothing is
|
|
36
|
+
* removed and `skippedDueToDirty` is returned true.
|
|
37
|
+
*/
|
|
38
|
+
removeWorktreeRecord(name: string, options: RemoveWorktreeOptions): Promise<RemoveWorktreeResult>;
|
|
15
39
|
saveState(state: WorktreeState): void;
|
|
16
40
|
}
|
|
@@ -20,6 +20,9 @@ export class WorktreeService {
|
|
|
20
20
|
list() {
|
|
21
21
|
return this.loadState().worktrees;
|
|
22
22
|
}
|
|
23
|
+
findStale() {
|
|
24
|
+
return this.loadState().worktrees.filter(w => !fs.existsSync(w.path));
|
|
25
|
+
}
|
|
23
26
|
loadState() {
|
|
24
27
|
if (!fs.existsSync(this.stateFile)) {
|
|
25
28
|
return { worktrees: [] };
|
|
@@ -37,6 +40,93 @@ export class WorktreeService {
|
|
|
37
40
|
return { worktrees: [] };
|
|
38
41
|
}
|
|
39
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Remove a worktree record: all entity worktrees in reverse order, then the
|
|
45
|
+
* super-repo worktree, pruning refs along the way, then splice the record
|
|
46
|
+
* out of state. Shared between `worktree remove` and `worktree gc`.
|
|
47
|
+
*
|
|
48
|
+
* If any entity has uncommitted changes and `force` is false, nothing is
|
|
49
|
+
* removed and `skippedDueToDirty` is returned true.
|
|
50
|
+
*/
|
|
51
|
+
async removeWorktreeRecord(name, options) {
|
|
52
|
+
const { force, logger } = options;
|
|
53
|
+
const log = (message) => {
|
|
54
|
+
if (logger)
|
|
55
|
+
logger(message);
|
|
56
|
+
};
|
|
57
|
+
const record = this.get(name);
|
|
58
|
+
if (!record) {
|
|
59
|
+
throw new Error(`Worktree '${name}' not found`);
|
|
60
|
+
}
|
|
61
|
+
const workspaceRoot = this.configService.getWorkspaceRoot();
|
|
62
|
+
// Dirty-state pre-check (mirrors remove.ts:39-50)
|
|
63
|
+
const dirtyEntities = [];
|
|
64
|
+
for (const entity of record.entities) {
|
|
65
|
+
if (!fs.existsSync(entity.worktreePath))
|
|
66
|
+
continue;
|
|
67
|
+
// eslint-disable-next-line no-await-in-loop
|
|
68
|
+
const status = await this.gitService.getStatus(entity.worktreePath);
|
|
69
|
+
if (status.hasChanges) {
|
|
70
|
+
dirtyEntities.push({
|
|
71
|
+
changeCount: status.changes.length + status.untracked.length,
|
|
72
|
+
entityName: entity.entityName,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (dirtyEntities.length > 0 && !force) {
|
|
77
|
+
return {
|
|
78
|
+
dirtyEntities,
|
|
79
|
+
removed: false,
|
|
80
|
+
removedEntities: 0,
|
|
81
|
+
skippedDueToDirty: true,
|
|
82
|
+
skippedEntities: 0,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (dirtyEntities.length > 0 && force) {
|
|
86
|
+
for (const d of dirtyEntities) {
|
|
87
|
+
log(` ⚠ ${d.entityName}: ${d.changeCount} uncommitted changes will be lost`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Entity worktrees in reverse order
|
|
91
|
+
let removedEntities = 0;
|
|
92
|
+
let skippedEntities = 0;
|
|
93
|
+
const reversed = [...record.entities].reverse();
|
|
94
|
+
for (const entity of reversed) {
|
|
95
|
+
const mainRepoPath = path.join(workspaceRoot, entity.entityPath);
|
|
96
|
+
if (!fs.existsSync(entity.worktreePath)) {
|
|
97
|
+
log(` ○ ${entity.entityName}: already removed (skipped)`);
|
|
98
|
+
skippedEntities++;
|
|
99
|
+
// eslint-disable-next-line no-await-in-loop
|
|
100
|
+
await this.gitService.worktreePrune(mainRepoPath).catch(() => { });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// eslint-disable-next-line no-await-in-loop
|
|
104
|
+
await this.gitService.worktreeRemove(mainRepoPath, entity.worktreePath, force);
|
|
105
|
+
// eslint-disable-next-line no-await-in-loop
|
|
106
|
+
await this.gitService.worktreePrune(mainRepoPath);
|
|
107
|
+
log(` ✓ ${entity.entityName}: worktree removed`);
|
|
108
|
+
removedEntities++;
|
|
109
|
+
}
|
|
110
|
+
// Super-repo worktree
|
|
111
|
+
if (fs.existsSync(record.path)) {
|
|
112
|
+
await this.gitService.worktreeRemove(workspaceRoot, record.path, force);
|
|
113
|
+
}
|
|
114
|
+
await this.gitService.worktreePrune(workspaceRoot);
|
|
115
|
+
// Splice the record out of state
|
|
116
|
+
const state = this.loadState();
|
|
117
|
+
const idx = state.worktrees.findIndex(w => w.name === name);
|
|
118
|
+
if (idx !== -1) {
|
|
119
|
+
state.worktrees.splice(idx, 1);
|
|
120
|
+
}
|
|
121
|
+
this.saveState(state);
|
|
122
|
+
return {
|
|
123
|
+
dirtyEntities,
|
|
124
|
+
removed: true,
|
|
125
|
+
removedEntities,
|
|
126
|
+
skippedDueToDirty: false,
|
|
127
|
+
skippedEntities,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
40
130
|
saveState(state) {
|
|
41
131
|
const serialized = JSON.stringify(state, null, 2);
|
|
42
132
|
try {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function parseDuration(input: string): number;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const SUFFIX_MS = {
|
|
2
|
+
d: 86_400_000,
|
|
3
|
+
h: 3_600_000,
|
|
4
|
+
m: 60_000,
|
|
5
|
+
s: 1000,
|
|
6
|
+
};
|
|
7
|
+
export function parseDuration(input) {
|
|
8
|
+
const invalid = () => {
|
|
9
|
+
throw new Error(`Invalid duration: '${input}'. Expected format like '7d', '24h', '30m', or '90s'.`);
|
|
10
|
+
};
|
|
11
|
+
if (typeof input !== 'string' || input.length === 0)
|
|
12
|
+
invalid();
|
|
13
|
+
if (/\s/.test(input))
|
|
14
|
+
invalid();
|
|
15
|
+
const match = /^(\d+)([a-zA-Z])$/.exec(input);
|
|
16
|
+
if (!match)
|
|
17
|
+
invalid();
|
|
18
|
+
const [, numStr, suffix] = match;
|
|
19
|
+
const multiplier = SUFFIX_MS[suffix];
|
|
20
|
+
if (multiplier === undefined)
|
|
21
|
+
invalid();
|
|
22
|
+
const value = Number(numStr);
|
|
23
|
+
if (!Number.isFinite(value) || value < 0)
|
|
24
|
+
invalid();
|
|
25
|
+
return value * multiplier;
|
|
26
|
+
}
|
package/package.json
CHANGED