@hyperdrive.bot/gut 0.1.12 โ 0.1.14-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 +21 -6
- package/dist/commands/context.d.ts +4 -0
- package/dist/commands/context.js +91 -6
- package/dist/commands/focus.js +3 -0
- package/dist/commands/worktree/create.d.ts +1 -0
- package/dist/commands/worktree/create.js +64 -12
- 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/dist/utils/entity-spec.d.ts +15 -0
- package/dist/utils/entity-spec.js +29 -0
- package/oclif.manifest.json +193 -6
- 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.14-alpha.0 linux-x64 node-v22.22.2
|
|
22
22
|
$ gut --help [COMMAND]
|
|
23
23
|
USAGE
|
|
24
24
|
$ gut COMMAND
|
|
@@ -290,13 +290,21 @@ Show current focus context with entity details
|
|
|
290
290
|
|
|
291
291
|
```
|
|
292
292
|
USAGE
|
|
293
|
-
$ gut context
|
|
293
|
+
$ gut context [--all] [--json]
|
|
294
|
+
|
|
295
|
+
FLAGS
|
|
296
|
+
--all Include all registered entities, not just focused ones (implies --json or adds to text output)
|
|
297
|
+
--json Emit structured JSON for programmatic consumption (includes per-entity branch + hasUncommitted)
|
|
294
298
|
|
|
295
299
|
DESCRIPTION
|
|
296
300
|
Show current focus context with entity details
|
|
297
301
|
|
|
298
302
|
EXAMPLES
|
|
299
303
|
$ gut context
|
|
304
|
+
|
|
305
|
+
$ gut context --json
|
|
306
|
+
|
|
307
|
+
$ gut context --json --all
|
|
300
308
|
```
|
|
301
309
|
|
|
302
310
|
## `gut contexts`
|
|
@@ -1180,15 +1188,18 @@ Create mirrored worktrees for super-repo and focused entities
|
|
|
1180
1188
|
|
|
1181
1189
|
```
|
|
1182
1190
|
USAGE
|
|
1183
|
-
$ gut worktree create NAME [--base-dir <value>] [--from <value>] [--install]
|
|
1191
|
+
$ gut worktree create NAME [--base-dir <value>] [--entity <value>...] [--from <value>] [--install]
|
|
1184
1192
|
|
|
1185
1193
|
ARGUMENTS
|
|
1186
1194
|
NAME Branch name for the worktree
|
|
1187
1195
|
|
|
1188
1196
|
FLAGS
|
|
1189
|
-
--base-dir=<value>
|
|
1190
|
-
|
|
1191
|
-
--
|
|
1197
|
+
--base-dir=<value> [default: /root/.local/share/gut/worktrees] Root directory for worktrees (default:
|
|
1198
|
+
~/.local/share/gut/worktrees, override via GUT_WORKTREE_DIR env var; explicit --base-dir wins)
|
|
1199
|
+
--entity=<value>... Entity to include, optionally pinned to a branch via "name:branch". Repeatable. Overrides current
|
|
1200
|
+
focus when provided.
|
|
1201
|
+
--from=<value> Base branch to create from (defaults to current branch)
|
|
1202
|
+
--install Run pnpm install after creation
|
|
1192
1203
|
|
|
1193
1204
|
DESCRIPTION
|
|
1194
1205
|
Create mirrored worktrees for super-repo and focused entities
|
|
@@ -1199,5 +1210,9 @@ EXAMPLES
|
|
|
1199
1210
|
$ gut worktree create workflow/deploy-batch --from master --install
|
|
1200
1211
|
|
|
1201
1212
|
$ gut worktree create feature/story-42 --base-dir /home/user/worktrees
|
|
1213
|
+
|
|
1214
|
+
$ gut worktree create seo-lake --entity serverless-api:feature/seo-lake --entity sign
|
|
1215
|
+
|
|
1216
|
+
GUT_WORKTREE_DIR=/tmp/gut-worktrees gut worktree create feature/ci-run
|
|
1202
1217
|
```
|
|
1203
1218
|
<!-- commandsstop -->
|
|
@@ -2,5 +2,9 @@ import { BaseCommand } from '../base-command.js';
|
|
|
2
2
|
export default class Context extends BaseCommand {
|
|
3
3
|
static description: string;
|
|
4
4
|
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
all: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
};
|
|
5
9
|
run(): Promise<void>;
|
|
6
10
|
}
|
package/dist/commands/context.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
1
2
|
import chalk from 'chalk';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { BaseCommand } from '../base-command.js';
|
|
@@ -5,22 +6,106 @@ export default class Context extends BaseCommand {
|
|
|
5
6
|
static description = 'Show current focus context with entity details';
|
|
6
7
|
static examples = [
|
|
7
8
|
'<%= config.bin %> <%= command.id %>',
|
|
9
|
+
'<%= config.bin %> <%= command.id %> --json',
|
|
10
|
+
'<%= config.bin %> <%= command.id %> --json --all',
|
|
8
11
|
];
|
|
12
|
+
static flags = {
|
|
13
|
+
all: Flags.boolean({
|
|
14
|
+
default: false,
|
|
15
|
+
description: 'Include all registered entities, not just focused ones (implies --json or adds to text output)',
|
|
16
|
+
}),
|
|
17
|
+
json: Flags.boolean({
|
|
18
|
+
default: false,
|
|
19
|
+
description: 'Emit structured JSON for programmatic consumption (includes per-entity branch + hasUncommitted)',
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
9
22
|
async run() {
|
|
10
|
-
await this.parse(Context);
|
|
23
|
+
const { flags } = await this.parse(Context);
|
|
24
|
+
const workspaceRoot = this.configService.getWorkspaceRoot();
|
|
11
25
|
const focusedEntities = await this.focusService.getFocusedEntities();
|
|
12
|
-
|
|
26
|
+
const focus = await this.focusService.getCurrentFocus();
|
|
27
|
+
const focusedNames = new Set(focusedEntities.map(e => e.name));
|
|
28
|
+
// For JSON or --all, resolve the full entity set. For normal text output,
|
|
29
|
+
// keep the existing behavior (focused only).
|
|
30
|
+
const includeAll = flags.json || flags.all;
|
|
31
|
+
const entitiesToReport = includeAll
|
|
32
|
+
? this.entityService.getAllEntities()
|
|
33
|
+
: focusedEntities;
|
|
34
|
+
// Enrich each entity with branch + uncommitted status. Entities may not
|
|
35
|
+
// exist on disk (not cloned), so be defensive.
|
|
36
|
+
const enriched = [];
|
|
37
|
+
for (const entity of entitiesToReport) {
|
|
38
|
+
const relativePath = entity.path.replace(/^\.\//, '');
|
|
39
|
+
const absolutePath = path.isAbsolute(entity.path)
|
|
40
|
+
? entity.path
|
|
41
|
+
: path.join(workspaceRoot, relativePath);
|
|
42
|
+
let currentBranch = null;
|
|
43
|
+
let hasUncommitted = false;
|
|
44
|
+
let pathExists = false;
|
|
45
|
+
try {
|
|
46
|
+
const { existsSync } = await import('node:fs');
|
|
47
|
+
pathExists = existsSync(path.join(absolutePath, '.git')) || existsSync(absolutePath);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
pathExists = false;
|
|
51
|
+
}
|
|
52
|
+
if (pathExists) {
|
|
53
|
+
try {
|
|
54
|
+
currentBranch = await this.gitService.getCurrentBranch(absolutePath);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
currentBranch = null;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
hasUncommitted = await this.gitService.hasChanges(absolutePath);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
hasUncommitted = false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
enriched.push({
|
|
67
|
+
currentBranch,
|
|
68
|
+
focused: focusedNames.has(entity.name),
|
|
69
|
+
hasUncommitted,
|
|
70
|
+
name: entity.name,
|
|
71
|
+
path: relativePath,
|
|
72
|
+
pathExists,
|
|
73
|
+
type: entity.type,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (flags.json) {
|
|
77
|
+
const payload = {
|
|
78
|
+
entities: enriched,
|
|
79
|
+
focus: {
|
|
80
|
+
entities: [...focusedNames],
|
|
81
|
+
mode: focus?.mode ?? null,
|
|
82
|
+
},
|
|
83
|
+
workspaceRoot,
|
|
84
|
+
};
|
|
85
|
+
// Emit compact, machine-readable JSON on stdout. No chalk, no decoration.
|
|
86
|
+
this.log(JSON.stringify(payload, null, 2));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (focusedEntities.length === 0 && !flags.all) {
|
|
13
90
|
this.log(chalk.yellow('No entities are currently focused'));
|
|
14
91
|
this.log(chalk.dim('Use "gut focus <entity>" to set focus'));
|
|
15
92
|
return;
|
|
16
93
|
}
|
|
17
|
-
const workspaceRoot = this.configService.getWorkspaceRoot();
|
|
18
94
|
this.log(chalk.bold('\n๐ Current Focus Context'));
|
|
19
95
|
this.log(chalk.dim('โ'.repeat(50)));
|
|
20
|
-
for (const entity of
|
|
21
|
-
|
|
96
|
+
for (const entity of enriched) {
|
|
97
|
+
if (!flags.all && !entity.focused)
|
|
98
|
+
continue;
|
|
99
|
+
const marker = entity.focused ? chalk.green('โธ') : chalk.dim('ยท');
|
|
100
|
+
this.log(`\n${marker} ${chalk.bold(entity.name)}`);
|
|
22
101
|
this.log(` ${chalk.dim('Type:')} ${entity.type}`);
|
|
23
|
-
this.log(` ${chalk.dim('Path:')} ${
|
|
102
|
+
this.log(` ${chalk.dim('Path:')} ${entity.path}`);
|
|
103
|
+
if (entity.pathExists) {
|
|
104
|
+
this.log(` ${chalk.dim('Branch:')} ${entity.currentBranch ?? chalk.red('(unknown)')}${entity.hasUncommitted ? chalk.yellow(' *dirty') : ''}`);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
this.log(` ${chalk.dim('Branch:')} ${chalk.dim('(not checked out)')}`);
|
|
108
|
+
}
|
|
24
109
|
}
|
|
25
110
|
this.log(chalk.dim('\nโ'.repeat(50)));
|
|
26
111
|
this.log(`${chalk.dim('Total focused entities:')} ${focusedEntities.length}`);
|
package/dist/commands/focus.js
CHANGED
|
@@ -125,6 +125,9 @@ export default class Focus extends BaseCommand {
|
|
|
125
125
|
this.log(` ${this.getTypeEmoji(entity.type)} ${entity.name} (${entity.type})`);
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
|
+
// Machine-readable confirmation line for programmatic callers (e.g.,
|
|
129
|
+
// bmad-workflow execSync). Always printed, grep-friendly format.
|
|
130
|
+
this.log(`FOCUSED: ${entities.map(e => e.name).join(',')}`);
|
|
128
131
|
}
|
|
129
132
|
catch (error) {
|
|
130
133
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -7,6 +7,7 @@ export default class WorktreeCreate extends BaseCommand {
|
|
|
7
7
|
static examples: string[];
|
|
8
8
|
static flags: {
|
|
9
9
|
'base-dir': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
entity: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
11
|
from: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
12
|
install: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
13
|
};
|
|
@@ -2,8 +2,11 @@ import { Args, Flags } from '@oclif/core';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { BaseCommand } from '../../base-command.js';
|
|
8
|
+
import { parseEntitySpecs } from '../../utils/entity-spec.js';
|
|
9
|
+
const xdgDefaultBaseDir = path.join(os.homedir(), '.local', 'share', 'gut', 'worktrees');
|
|
7
10
|
export default class WorktreeCreate extends BaseCommand {
|
|
8
11
|
static args = {
|
|
9
12
|
name: Args.string({ description: 'Branch name for the worktree', required: true }),
|
|
@@ -13,17 +16,37 @@ export default class WorktreeCreate extends BaseCommand {
|
|
|
13
16
|
'<%= config.bin %> worktree create workflow/deploy-batch',
|
|
14
17
|
'<%= config.bin %> worktree create workflow/deploy-batch --from master --install',
|
|
15
18
|
'<%= config.bin %> worktree create feature/story-42 --base-dir /home/user/worktrees',
|
|
19
|
+
'<%= config.bin %> worktree create seo-lake --entity serverless-api:feature/seo-lake --entity sign',
|
|
20
|
+
'GUT_WORKTREE_DIR=/tmp/gut-worktrees <%= config.bin %> worktree create feature/ci-run',
|
|
16
21
|
];
|
|
17
22
|
static flags = {
|
|
18
|
-
'base-dir': Flags.string({
|
|
23
|
+
'base-dir': Flags.string({
|
|
24
|
+
default: async () => process.env.GUT_WORKTREE_DIR ?? xdgDefaultBaseDir,
|
|
25
|
+
description: 'Root directory for worktrees (default: ~/.local/share/gut/worktrees, override via GUT_WORKTREE_DIR env var; explicit --base-dir wins)',
|
|
26
|
+
}),
|
|
27
|
+
entity: Flags.string({
|
|
28
|
+
description: 'Entity to include, optionally pinned to a branch via "name:branch". Repeatable. Overrides current focus when provided.',
|
|
29
|
+
multiple: true,
|
|
30
|
+
}),
|
|
19
31
|
from: Flags.string({ description: 'Base branch to create from (defaults to current branch)' }),
|
|
20
32
|
install: Flags.boolean({ default: false, description: 'Run pnpm install after creation' }),
|
|
21
33
|
};
|
|
22
34
|
async run() {
|
|
23
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
|
+
}
|
|
24
44
|
const workspaceRoot = this.configService.getWorkspaceRoot();
|
|
25
45
|
const slug = args.name.replace(/\//g, '-');
|
|
26
46
|
const wtPath = path.join(flags['base-dir'], slug);
|
|
47
|
+
// Ensure the base directory exists โ git worktree add creates the target
|
|
48
|
+
// dir, but the parent (the base dir itself) must already exist.
|
|
49
|
+
fs.mkdirSync(flags['base-dir'], { recursive: true });
|
|
27
50
|
const baseBranch = flags.from ?? await this.gitService.getCurrentBranch(workspaceRoot);
|
|
28
51
|
// --- Validation (AC: 7) ---
|
|
29
52
|
const existing = this.worktreeService.get(args.name);
|
|
@@ -35,10 +58,35 @@ export default class WorktreeCreate extends BaseCommand {
|
|
|
35
58
|
if (branchCollision) {
|
|
36
59
|
this.error(`Branch "${args.name}" already has a worktree at ${branchCollision.path}. Remove it first or use a different name.`);
|
|
37
60
|
}
|
|
38
|
-
// ---
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
61
|
+
// --- Resolve entity set: --entity flag overrides focus ---
|
|
62
|
+
let entitySpecs;
|
|
63
|
+
try {
|
|
64
|
+
entitySpecs = parseEntitySpecs(flags.entity);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
68
|
+
this.error(message);
|
|
69
|
+
}
|
|
70
|
+
let selectedEntities;
|
|
71
|
+
const branchOverrides = new Map();
|
|
72
|
+
if (entitySpecs.length > 0) {
|
|
73
|
+
selectedEntities = [];
|
|
74
|
+
for (const spec of entitySpecs) {
|
|
75
|
+
const entity = this.entityService.findEntity(spec.name);
|
|
76
|
+
if (!entity) {
|
|
77
|
+
this.error(`Entity "${spec.name}" not found in workspace config. Run \`gut entity list\` to see available entities.`);
|
|
78
|
+
}
|
|
79
|
+
selectedEntities.push(entity);
|
|
80
|
+
if (spec.branch) {
|
|
81
|
+
branchOverrides.set(entity.name, spec.branch);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
selectedEntities = await this.focusService.getFocusedEntities();
|
|
87
|
+
if (selectedEntities.length === 0) {
|
|
88
|
+
this.error('No entities focused and no --entity flag provided. Use "gut focus <entity>" first, or pass --entity <name> (repeatable).');
|
|
89
|
+
}
|
|
42
90
|
}
|
|
43
91
|
// --- Create worktrees with atomic rollback (AC: 1, 2, 9) ---
|
|
44
92
|
const createdWorktrees = [];
|
|
@@ -49,14 +97,18 @@ export default class WorktreeCreate extends BaseCommand {
|
|
|
49
97
|
createdWorktrees.push({ repoPath: workspaceRoot, wtPath });
|
|
50
98
|
this.log(chalk.green('โ Super-repo worktree created at ' + wtPath));
|
|
51
99
|
// Per-entity worktrees (AC: 2)
|
|
52
|
-
for (const entity of
|
|
100
|
+
for (const entity of selectedEntities) {
|
|
53
101
|
const entityRelativePath = entity.path.replace(/^\.\//, '');
|
|
54
102
|
const entityWtPath = path.join(wtPath, entityRelativePath);
|
|
55
103
|
const entityMainPath = path.join(workspaceRoot, entityRelativePath);
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
const
|
|
59
|
-
|
|
104
|
+
// Branch override from --entity name:branch wins; otherwise use entity's
|
|
105
|
+
// current branch in the main repo.
|
|
106
|
+
const override = branchOverrides.get(entity.name);
|
|
107
|
+
const entityBaseBranch = override ?? await this.gitService.getCurrentBranch(entityMainPath);
|
|
108
|
+
if (override) {
|
|
109
|
+
this.log(chalk.cyan(` Branch override: ${entity.name} โ ${override}`));
|
|
110
|
+
}
|
|
111
|
+
else if (entityBaseBranch !== baseBranch) {
|
|
60
112
|
this.log(chalk.yellow(`โ Entity "${entity.name}" is on "${entityBaseBranch}" (super-repo: "${baseBranch}")`));
|
|
61
113
|
}
|
|
62
114
|
// Remove submodule stub
|
|
@@ -64,7 +116,7 @@ export default class WorktreeCreate extends BaseCommand {
|
|
|
64
116
|
// Create entity worktree
|
|
65
117
|
await this.gitService.worktreeAdd(entityMainPath, entityWtPath, args.name, entityBaseBranch);
|
|
66
118
|
createdWorktrees.push({ repoPath: entityMainPath, wtPath: entityWtPath });
|
|
67
|
-
this.log(chalk.green(`โ Entity worktree: ${entity.name} โ ${args.name}`));
|
|
119
|
+
this.log(chalk.green(`โ Entity worktree: ${entity.name} โ ${args.name} (from ${entityBaseBranch})`));
|
|
68
120
|
entityRecords.push({
|
|
69
121
|
branch: args.name,
|
|
70
122
|
entityName: entity.name,
|
|
@@ -79,7 +131,7 @@ export default class WorktreeCreate extends BaseCommand {
|
|
|
79
131
|
await this.gitService.worktreeRemove(entry.repoPath, entry.wtPath, true).catch(() => { });
|
|
80
132
|
}
|
|
81
133
|
await this.gitService.worktreePrune(workspaceRoot).catch(() => { });
|
|
82
|
-
for (const entity of
|
|
134
|
+
for (const entity of selectedEntities) {
|
|
83
135
|
const entityRelativePath = entity.path.replace(/^\.\//, '');
|
|
84
136
|
const entityMainPath = path.join(workspaceRoot, entityRelativePath);
|
|
85
137
|
await this.gitService.worktreePrune(entityMainPath).catch(() => { });
|
|
@@ -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 {};
|