@hyperdrive.bot/gut 0.1.15 → 0.2.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 +53 -1
- package/dist/base-command.d.ts +2 -0
- package/dist/base-command.js +3 -0
- package/dist/commands/claude/init.d.ts +11 -0
- package/dist/commands/claude/init.js +120 -0
- package/dist/commands/commit.d.ts +4 -0
- package/dist/commands/commit.js +145 -6
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +92 -18
- package/dist/models/entity.model.d.ts +7 -0
- package/dist/services/discovery.service.d.ts +14 -0
- package/dist/services/discovery.service.js +115 -0
- package/dist/services/discovery.service.test.d.ts +1 -0
- package/dist/services/discovery.service.test.js +140 -0
- package/oclif.manifest.json +2465 -0
- package/package.json +2 -2
- package/templates/claude-init/claude-md-section.md +179 -0
- package/templates/claude-init/commands/gut-affected.md +40 -0
- package/templates/claude-init/commands/gut-status.md +33 -0
package/README.md
CHANGED
|
@@ -9,8 +9,36 @@ Git Unified Tooling - Enhanced git with workspace intelligence for entity-based
|
|
|
9
9
|
|
|
10
10
|
<!-- toc -->
|
|
11
11
|
* [Usage](#usage)
|
|
12
|
+
* [Untracked Entities](#untracked-entities)
|
|
12
13
|
* [Commands](#commands)
|
|
13
14
|
<!-- tocstop -->
|
|
15
|
+
|
|
16
|
+
# Untracked Entities
|
|
17
|
+
|
|
18
|
+
`gut status --all` surfaces dirty state for **any** `.git` repo inside the workspace tree, whether or not it's registered as an entity in `.gut/config.json`. This includes:
|
|
19
|
+
|
|
20
|
+
- **The super-repo root itself** (the git repo containing `.gut/`)
|
|
21
|
+
- **Nested repos** that exist on disk but have not been run through `gut entity add`
|
|
22
|
+
|
|
23
|
+
Discovery is always-on for reading (`gut status --all`). For writing, it's opt-in via a flag on `gut commit`:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
# Commit all changes in focused entities only (default — unchanged)
|
|
27
|
+
gut commit -a -m "ship feature"
|
|
28
|
+
|
|
29
|
+
# Also commit in super-repo root + any unregistered nested repos
|
|
30
|
+
gut commit -a -u -m "ship feature and sync workspace"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Safety guards on the `-u` path:
|
|
34
|
+
|
|
35
|
+
- **Refuses to commit on** `main`, `master`, `live`, `prod`, `production`
|
|
36
|
+
- **Skips** repos in an in-progress merge, rebase, or cherry-pick
|
|
37
|
+
- **Skips** repos with detached `HEAD`
|
|
38
|
+
- **Stages untracked files** (equivalent of `git add -A`) when `-a` is present, so newly-created files land in the commit
|
|
39
|
+
|
|
40
|
+
Escape hatch: set `GUT_SKIP_DISCOVERY=1` to disable the walk entirely.
|
|
41
|
+
|
|
14
42
|
# Usage
|
|
15
43
|
<!-- usage -->
|
|
16
44
|
```sh-session
|
|
@@ -18,7 +46,7 @@ $ npm install -g @hyperdrive.bot/gut
|
|
|
18
46
|
$ gut COMMAND
|
|
19
47
|
running command...
|
|
20
48
|
$ gut (--version)
|
|
21
|
-
@hyperdrive.bot/gut/0.1.
|
|
49
|
+
@hyperdrive.bot/gut/0.1.16 linux-x64 node-v22.22.2
|
|
22
50
|
$ gut --help [COMMAND]
|
|
23
51
|
USAGE
|
|
24
52
|
$ gut COMMAND
|
|
@@ -35,6 +63,7 @@ USAGE
|
|
|
35
63
|
* [`gut auth status`](#gut-auth-status)
|
|
36
64
|
* [`gut back`](#gut-back)
|
|
37
65
|
* [`gut checkout BRANCH`](#gut-checkout-branch)
|
|
66
|
+
* [`gut claude init`](#gut-claude-init)
|
|
38
67
|
* [`gut commit`](#gut-commit)
|
|
39
68
|
* [`gut context`](#gut-context)
|
|
40
69
|
* [`gut contexts`](#gut-contexts)
|
|
@@ -265,6 +294,29 @@ EXAMPLES
|
|
|
265
294
|
$ gut checkout -b PRD-123 --from main
|
|
266
295
|
```
|
|
267
296
|
|
|
297
|
+
## `gut claude init`
|
|
298
|
+
|
|
299
|
+
Scaffold Claude Code configuration for gut
|
|
300
|
+
|
|
301
|
+
```
|
|
302
|
+
USAGE
|
|
303
|
+
$ gut claude init [--dry-run] [-f]
|
|
304
|
+
|
|
305
|
+
FLAGS
|
|
306
|
+
-f, --force Overwrite existing gut section and command files
|
|
307
|
+
--dry-run Preview changes without writing files
|
|
308
|
+
|
|
309
|
+
DESCRIPTION
|
|
310
|
+
Scaffold Claude Code configuration for gut
|
|
311
|
+
|
|
312
|
+
EXAMPLES
|
|
313
|
+
$ gut claude init
|
|
314
|
+
|
|
315
|
+
$ gut claude init --dry-run
|
|
316
|
+
|
|
317
|
+
$ gut claude init --force
|
|
318
|
+
```
|
|
319
|
+
|
|
268
320
|
## `gut commit`
|
|
269
321
|
|
|
270
322
|
Commit changes in focused repositories
|
package/dist/base-command.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from '@oclif/core';
|
|
2
2
|
import { Entity, EntityType } from './models/entity.model.js';
|
|
3
3
|
import { ConfigService } from './services/config.service.js';
|
|
4
|
+
import { DiscoveryService } from './services/discovery.service.js';
|
|
4
5
|
import { EntityService } from './services/entity.service.js';
|
|
5
6
|
import { FocusService } from './services/focus.service.js';
|
|
6
7
|
import { GitService } from './services/git.service.js';
|
|
@@ -8,6 +9,7 @@ import { TicketService } from './services/ticket.service.js';
|
|
|
8
9
|
import { WorktreeService } from './services/worktree.service.js';
|
|
9
10
|
export declare abstract class BaseCommand extends Command {
|
|
10
11
|
protected configService: ConfigService;
|
|
12
|
+
protected discoveryService: DiscoveryService;
|
|
11
13
|
protected entityService: EntityService;
|
|
12
14
|
protected focusService: FocusService;
|
|
13
15
|
protected gitService: GitService;
|
package/dist/base-command.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Command } from '@oclif/core';
|
|
2
2
|
import { ConfigService } from './services/config.service.js';
|
|
3
|
+
import { DiscoveryService } from './services/discovery.service.js';
|
|
3
4
|
import { EntityService } from './services/entity.service.js';
|
|
4
5
|
import { FocusService } from './services/focus.service.js';
|
|
5
6
|
import { GitService } from './services/git.service.js';
|
|
@@ -19,6 +20,7 @@ const ENTITY_TYPE_EMOJI = {
|
|
|
19
20
|
const DEFAULT_ENTITY_EMOJI = '📁';
|
|
20
21
|
export class BaseCommand extends Command {
|
|
21
22
|
configService;
|
|
23
|
+
discoveryService;
|
|
22
24
|
entityService;
|
|
23
25
|
focusService;
|
|
24
26
|
gitService;
|
|
@@ -46,6 +48,7 @@ export class BaseCommand extends Command {
|
|
|
46
48
|
// Initialize services
|
|
47
49
|
this.configService = new ConfigService();
|
|
48
50
|
this.entityService = new EntityService(this.configService);
|
|
51
|
+
this.discoveryService = new DiscoveryService(this.configService, this.entityService);
|
|
49
52
|
this.focusService = new FocusService(this.configService);
|
|
50
53
|
this.gitService = new GitService();
|
|
51
54
|
this.ticketService = new TicketService(this.configService);
|
|
@@ -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
|
+
}
|
|
@@ -5,7 +5,11 @@ export default class Commit extends BaseCommand {
|
|
|
5
5
|
static flags: {
|
|
6
6
|
all: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
7
|
amend: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
'include-untracked': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
9
|
message: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
10
|
};
|
|
10
11
|
run(): Promise<void>;
|
|
12
|
+
private commitUntrackedRepo;
|
|
13
|
+
private filterDirty;
|
|
14
|
+
private hasInProgressMergeOrRebase;
|
|
11
15
|
}
|
package/dist/commands/commit.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { Flags } from '@oclif/core';
|
|
2
4
|
import chalk from 'chalk';
|
|
3
5
|
import inquirer from 'inquirer';
|
|
4
6
|
import ora from 'ora';
|
|
5
7
|
import { BaseCommand } from '../base-command.js';
|
|
8
|
+
const PROTECTED_BRANCHES = new Set(['live', 'main', 'master', 'prod', 'production']);
|
|
6
9
|
export default class Commit extends BaseCommand {
|
|
7
10
|
static description = 'Commit changes in focused repositories';
|
|
8
11
|
static examples = [
|
|
9
12
|
'<%= config.bin %> <%= command.id %> -m "Fix bug"',
|
|
10
13
|
'<%= config.bin %> <%= command.id %> --message "Add feature"',
|
|
14
|
+
'<%= config.bin %> <%= command.id %> -a -u -m "Ship WIP across workspace"',
|
|
11
15
|
'<%= config.bin %> <%= command.id %>', // Interactive mode
|
|
12
16
|
];
|
|
13
17
|
static flags = {
|
|
@@ -20,6 +24,12 @@ export default class Commit extends BaseCommand {
|
|
|
20
24
|
default: false,
|
|
21
25
|
description: 'Amend the previous commit',
|
|
22
26
|
}),
|
|
27
|
+
'include-untracked': Flags.boolean({
|
|
28
|
+
char: 'u',
|
|
29
|
+
default: false,
|
|
30
|
+
description: 'Also commit in discovered .git repos not registered as entities '
|
|
31
|
+
+ '(e.g. super-repo root, nested repos). Fails closed on main/master/live/prod.',
|
|
32
|
+
}),
|
|
23
33
|
message: Flags.string({
|
|
24
34
|
char: 'm',
|
|
25
35
|
description: 'Commit message',
|
|
@@ -29,8 +39,8 @@ export default class Commit extends BaseCommand {
|
|
|
29
39
|
async run() {
|
|
30
40
|
const { flags } = await this.parse(Commit);
|
|
31
41
|
const focusedEntities = await this.focusService.getFocusedEntities();
|
|
32
|
-
if (focusedEntities.length === 0) {
|
|
33
|
-
this.error('No entities are focused. Use "gut focus <entity>" first.');
|
|
42
|
+
if (focusedEntities.length === 0 && !flags['include-untracked']) {
|
|
43
|
+
this.error('No entities are focused. Use "gut focus <entity>" first, or pass -u to commit untracked repos.');
|
|
34
44
|
}
|
|
35
45
|
let { message } = flags;
|
|
36
46
|
// Interactive prompt for commit message if not provided
|
|
@@ -59,8 +69,9 @@ export default class Commit extends BaseCommand {
|
|
|
59
69
|
for (const entity of focusedEntities) {
|
|
60
70
|
const spinner = ora(`Committing in ${chalk.cyan(entity.name)}`).start();
|
|
61
71
|
try {
|
|
72
|
+
const entityPath = this.entityService.resolveEntityPath(entity);
|
|
62
73
|
// Check if there are changes to commit
|
|
63
|
-
const status = await this.gitService.getStatus(
|
|
74
|
+
const status = await this.gitService.getStatus(entityPath);
|
|
64
75
|
const hasChanges = status.changes.length > 0 || status.untracked.length > 0;
|
|
65
76
|
if (!hasChanges && !flags.amend) {
|
|
66
77
|
spinner.info(chalk.yellow(`${entity.name}: No changes to commit`));
|
|
@@ -74,8 +85,8 @@ export default class Commit extends BaseCommand {
|
|
|
74
85
|
};
|
|
75
86
|
// Amend without changing message, or commit with new message
|
|
76
87
|
await (!message && flags.amend
|
|
77
|
-
? this.gitService.exec(['commit', '--amend', '--no-edit'], { cwd:
|
|
78
|
-
: this.gitService.commit(
|
|
88
|
+
? this.gitService.exec(['commit', '--amend', '--no-edit'], { cwd: entityPath })
|
|
89
|
+
: this.gitService.commit(entityPath, message || '', options));
|
|
79
90
|
spinner.succeed(chalk.green(`✓ ${entity.name}: Committed successfully`));
|
|
80
91
|
results.success.push(entity.name);
|
|
81
92
|
}
|
|
@@ -85,6 +96,16 @@ export default class Commit extends BaseCommand {
|
|
|
85
96
|
results.failed.push({ error: errorMessage, repo: entity.name });
|
|
86
97
|
}
|
|
87
98
|
}
|
|
99
|
+
// Discovered-untracked-repos loop (opt-in via -u / --include-untracked)
|
|
100
|
+
const untrackedResults = [];
|
|
101
|
+
if (flags['include-untracked']) {
|
|
102
|
+
this.log(chalk.bold('\n🔎 Committing in discovered untracked repos\n'));
|
|
103
|
+
const discovered = await this.discoveryService.discoverUntracked();
|
|
104
|
+
for (const repo of discovered) {
|
|
105
|
+
const result = await this.commitUntrackedRepo(repo, message || '', flags);
|
|
106
|
+
untrackedResults.push(result);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
88
109
|
// Summary
|
|
89
110
|
this.log(chalk.bold('\n📊 Commit Summary'));
|
|
90
111
|
this.log(chalk.dim('─'.repeat(50)));
|
|
@@ -100,8 +121,126 @@ export default class Commit extends BaseCommand {
|
|
|
100
121
|
this.log(chalk.red(` - ${failure.repo}: ${failure.error}`));
|
|
101
122
|
}
|
|
102
123
|
}
|
|
103
|
-
if (
|
|
124
|
+
if (flags['include-untracked']) {
|
|
125
|
+
const committed = untrackedResults.filter(r => r.committed);
|
|
126
|
+
const skipped = untrackedResults.filter(r => !r.committed);
|
|
127
|
+
if (committed.length > 0) {
|
|
128
|
+
this.log(chalk.green(`✓ Untracked committed: ${committed.length} repos`));
|
|
129
|
+
for (const r of committed) {
|
|
130
|
+
this.log(chalk.green(` - ${r.relativePath}: ${r.sha?.slice(0, 10)} on ${r.branch}`));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (skipped.length > 0) {
|
|
134
|
+
this.log(chalk.yellow(`○ Untracked skipped: ${skipped.length} repos`));
|
|
135
|
+
for (const r of skipped) {
|
|
136
|
+
this.log(chalk.yellow(` - ${r.relativePath}: ${r.reason}`));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else if (results.success.length > 0 || results.noChanges.length > 0) {
|
|
141
|
+
// Nudge if there's workspace-root or nested dirt waiting to be committed.
|
|
142
|
+
const anyUntracked = await this.discoveryService.discoverUntracked();
|
|
143
|
+
const dirty = await this.filterDirty(anyUntracked);
|
|
144
|
+
if (dirty.length > 0) {
|
|
145
|
+
this.log('');
|
|
146
|
+
this.log(chalk.dim(`💡 ${dirty.length} untracked ${dirty.length === 1 ? 'repo has' : 'repos have'} `
|
|
147
|
+
+ `uncommitted changes (super-repo root or nested). Pass ${chalk.cyan('-u')} to include them.`));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (results.success.length > 0 || untrackedResults.some(r => r.committed)) {
|
|
104
151
|
this.log(chalk.dim('\nTip: Use "gut push" to push commits to remote'));
|
|
105
152
|
}
|
|
153
|
+
// Hint to register any newly-committed untracked repo
|
|
154
|
+
const newlyCommitted = untrackedResults.filter(r => r.committed && !r.path.endsWith(this.configService.getWorkspaceRoot()));
|
|
155
|
+
if (newlyCommitted.length > 0) {
|
|
156
|
+
this.log('');
|
|
157
|
+
this.log(chalk.dim(`💡 Committed on ${newlyCommitted.length} untracked ${newlyCommitted.length === 1 ? 'repo' : 'repos'}. `
|
|
158
|
+
+ 'Consider registering with `gut entity add <name> <type> <path>` to track long-term.'));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async commitUntrackedRepo(repo, message, flags) {
|
|
162
|
+
const spinner = ora(`Committing in ${chalk.cyan(repo.relativePath)}`).start();
|
|
163
|
+
const base = {
|
|
164
|
+
branch: '',
|
|
165
|
+
committed: false,
|
|
166
|
+
path: repo.path,
|
|
167
|
+
relativePath: repo.relativePath,
|
|
168
|
+
};
|
|
169
|
+
try {
|
|
170
|
+
// Guard: in-progress merge/rebase
|
|
171
|
+
if (this.hasInProgressMergeOrRebase(repo.path)) {
|
|
172
|
+
spinner.warn(chalk.yellow(`${repo.relativePath}: in-progress merge/rebase — skipped`));
|
|
173
|
+
return { ...base, reason: 'merge or rebase in progress' };
|
|
174
|
+
}
|
|
175
|
+
const status = await this.gitService.getStatus(repo.path);
|
|
176
|
+
base.branch = status.branch;
|
|
177
|
+
// Guard: detached HEAD
|
|
178
|
+
if (!status.branch || status.branch === 'HEAD') {
|
|
179
|
+
spinner.warn(chalk.yellow(`${repo.relativePath}: detached HEAD — skipped`));
|
|
180
|
+
return { ...base, reason: 'detached HEAD' };
|
|
181
|
+
}
|
|
182
|
+
// Guard: protected branch
|
|
183
|
+
if (PROTECTED_BRANCHES.has(status.branch)) {
|
|
184
|
+
spinner.warn(chalk.yellow(`${repo.relativePath}: refusing to commit on ${status.branch}`));
|
|
185
|
+
return { ...base, reason: `protected branch: ${status.branch}` };
|
|
186
|
+
}
|
|
187
|
+
const hasChanges = status.changes.length > 0 || status.untracked.length > 0;
|
|
188
|
+
if (!hasChanges && !flags.amend) {
|
|
189
|
+
spinner.info(chalk.dim(`${repo.relativePath}: no changes`));
|
|
190
|
+
return { ...base, reason: 'no changes' };
|
|
191
|
+
}
|
|
192
|
+
// In untracked-repo mode, -a means "stage everything including new files"
|
|
193
|
+
// (bmad-workflow / Pirlo writes new files). Plain `git commit -a` only
|
|
194
|
+
// stages modified tracked files and would miss the new code.
|
|
195
|
+
if (flags.all && !flags.amend) {
|
|
196
|
+
await this.gitService.exec(['add', '-A'], { cwd: repo.path });
|
|
197
|
+
}
|
|
198
|
+
const options = { all: false, amend: flags.amend };
|
|
199
|
+
await (!message && flags.amend
|
|
200
|
+
? this.gitService.exec(['commit', '--amend', '--no-edit'], { cwd: repo.path })
|
|
201
|
+
: this.gitService.commit(repo.path, message, options));
|
|
202
|
+
const sha = (await this.gitService.exec(['rev-parse', 'HEAD'], { cwd: repo.path })).trim();
|
|
203
|
+
spinner.succeed(chalk.green(`✓ ${repo.relativePath}: committed ${sha.slice(0, 10)} on ${status.branch}`));
|
|
204
|
+
return { ...base, committed: true, sha };
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
208
|
+
spinner.fail(chalk.red(`✗ ${repo.relativePath}: ${errorMessage}`));
|
|
209
|
+
return { ...base, reason: errorMessage };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async filterDirty(repos) {
|
|
213
|
+
const dirty = [];
|
|
214
|
+
for (const repo of repos) {
|
|
215
|
+
try {
|
|
216
|
+
const status = await this.gitService.getStatus(repo.path);
|
|
217
|
+
if (status.hasChanges)
|
|
218
|
+
dirty.push(repo);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// Ignore unreadable repos in the hint path.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return dirty;
|
|
225
|
+
}
|
|
226
|
+
hasInProgressMergeOrRebase(repoPath) {
|
|
227
|
+
const gitDir = path.join(repoPath, '.git');
|
|
228
|
+
let resolvedGitDir = gitDir;
|
|
229
|
+
try {
|
|
230
|
+
// Handle .git files (worktrees / submodules) — `gitdir: <path>` format
|
|
231
|
+
const stat = fs.statSync(gitDir);
|
|
232
|
+
if (stat.isFile()) {
|
|
233
|
+
const content = fs.readFileSync(gitDir, 'utf8').trim();
|
|
234
|
+
const match = /^gitdir:\s*(.+)$/m.exec(content);
|
|
235
|
+
if (match) {
|
|
236
|
+
resolvedGitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(repoPath, match[1]);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
const markers = ['MERGE_HEAD', 'rebase-merge', 'rebase-apply', 'CHERRY_PICK_HEAD'];
|
|
244
|
+
return markers.some(marker => fs.existsSync(path.join(resolvedGitDir, marker)));
|
|
106
245
|
}
|
|
107
246
|
}
|
|
@@ -8,6 +8,8 @@ export default class Status extends BaseCommand {
|
|
|
8
8
|
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
9
|
};
|
|
10
10
|
run(): Promise<void>;
|
|
11
|
+
private displayDiscoveredRepo;
|
|
11
12
|
private displayEnhancedStatus;
|
|
12
13
|
private displayEntityStatus;
|
|
14
|
+
private safeGetStatus;
|
|
13
15
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Flags } from '@oclif/core';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { BaseCommand } from '../base-command.js';
|
|
4
|
+
const SKIP_DISCOVERY_ENV = 'GUT_SKIP_DISCOVERY';
|
|
4
5
|
export default class Status extends BaseCommand {
|
|
5
6
|
static description = 'Show git status for focused entities';
|
|
6
7
|
static examples = [
|
|
@@ -27,13 +28,8 @@ export default class Status extends BaseCommand {
|
|
|
27
28
|
const entities = flags.all
|
|
28
29
|
? this.entityService.getAllEntities()
|
|
29
30
|
: await this.focusService.getFocusedEntities();
|
|
30
|
-
if (entities.length === 0) {
|
|
31
|
-
|
|
32
|
-
this.error('No entities configured');
|
|
33
|
-
}
|
|
34
|
-
else {
|
|
35
|
-
this.error('No entities focused. Use "gut focus <entity>" first.');
|
|
36
|
-
}
|
|
31
|
+
if (entities.length === 0 && !flags.all) {
|
|
32
|
+
this.error('No entities focused. Use "gut focus <entity>" first.');
|
|
37
33
|
}
|
|
38
34
|
// Collect status for each entity
|
|
39
35
|
const statuses = [];
|
|
@@ -82,15 +78,47 @@ export default class Status extends BaseCommand {
|
|
|
82
78
|
});
|
|
83
79
|
}
|
|
84
80
|
}
|
|
81
|
+
// Discover untracked repos (super-repo root + unregistered nested repos).
|
|
82
|
+
// Only meaningful when --all is set; focus-scoped status stays focus-scoped.
|
|
83
|
+
const untracked = [];
|
|
84
|
+
if (flags.all && process.env[SKIP_DISCOVERY_ENV] !== '1') {
|
|
85
|
+
const discovered = await this.discoveryService.discoverUntracked();
|
|
86
|
+
for (const repo of discovered) {
|
|
87
|
+
const statusResult = await this.safeGetStatus(repo.path);
|
|
88
|
+
untracked.push({ ...repo, ...statusResult });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
85
91
|
// Output results
|
|
86
92
|
if (flags.json) {
|
|
87
|
-
this.log(JSON.stringify(statuses, null, 2));
|
|
93
|
+
this.log(JSON.stringify({ statuses, untracked }, null, 2));
|
|
88
94
|
return;
|
|
89
95
|
}
|
|
90
96
|
// Enhanced workspace-style display
|
|
91
|
-
await this.displayEnhancedStatus(statuses, flags.verbose);
|
|
97
|
+
await this.displayEnhancedStatus(statuses, untracked, flags.verbose);
|
|
98
|
+
}
|
|
99
|
+
displayDiscoveredRepo(repo, verbose) {
|
|
100
|
+
const label = repo.isWorkspaceRoot ? '. (super-repo root)' : repo.relativePath;
|
|
101
|
+
const branch = repo.branch || '(empty)';
|
|
102
|
+
const changesText = repo.changes.length > 0 ? `${repo.changes.length} changes` : '';
|
|
103
|
+
const untrackedText = repo.untracked.length > 0 ? `${repo.untracked.length} untracked` : '';
|
|
104
|
+
const statusText = [changesText, untrackedText].filter(Boolean).join(', ') || 'clean';
|
|
105
|
+
this.log(` 📁 ${label} — branch: ${chalk.cyan(branch)} — ${statusText}`);
|
|
106
|
+
if (verbose) {
|
|
107
|
+
for (const change of repo.changes.slice(0, 3)) {
|
|
108
|
+
this.log(` ${change}`);
|
|
109
|
+
}
|
|
110
|
+
if (repo.changes.length > 3) {
|
|
111
|
+
this.log(` ... and ${repo.changes.length - 3} more`);
|
|
112
|
+
}
|
|
113
|
+
for (const file of repo.untracked.slice(0, 2)) {
|
|
114
|
+
this.log(` ?? ${file}`);
|
|
115
|
+
}
|
|
116
|
+
if (repo.untracked.length > 2) {
|
|
117
|
+
this.log(` ... and ${repo.untracked.length - 2} more`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
92
120
|
}
|
|
93
|
-
async displayEnhancedStatus(statuses, verbose) {
|
|
121
|
+
async displayEnhancedStatus(statuses, untracked, verbose) {
|
|
94
122
|
const currentFocus = await this.focusService.getCurrentFocus();
|
|
95
123
|
// Header with workspace context
|
|
96
124
|
this.log(chalk.bold('\n=== DevSquad Workspace Status ==='));
|
|
@@ -142,27 +170,49 @@ export default class Status extends BaseCommand {
|
|
|
142
170
|
this.log('');
|
|
143
171
|
}
|
|
144
172
|
}
|
|
173
|
+
// Untracked entities section (discovered .git repos not in .gut/config.json)
|
|
174
|
+
const dirtyUntracked = untracked.filter(u => u.hasChanges);
|
|
175
|
+
const hasUntrackedToShow = dirtyUntracked.length > 0 || (verbose && untracked.length > 0);
|
|
176
|
+
if (hasUntrackedToShow) {
|
|
177
|
+
this.log(chalk.bold(chalk.yellow('⚠ UNTRACKED ENTITIES:')));
|
|
178
|
+
const toShow = verbose ? untracked : dirtyUntracked;
|
|
179
|
+
for (const repo of toShow) {
|
|
180
|
+
this.displayDiscoveredRepo(repo, verbose);
|
|
181
|
+
}
|
|
182
|
+
this.log(chalk.dim(' 💡 Run `gut entity add <name> <type> <path>` to track, or pass'));
|
|
183
|
+
this.log(chalk.dim(' `gut commit --include-untracked` to commit in place.'));
|
|
184
|
+
this.log('');
|
|
185
|
+
}
|
|
145
186
|
// Enhanced summary
|
|
146
187
|
const dirtyRepos = statuses.filter(s => s.hasChanges).length;
|
|
147
188
|
const totalRepos = statuses.filter(s => !s.error).length;
|
|
148
189
|
const readyToCommit = statuses.filter(s => s.hasChanges).map(s => s.entity);
|
|
149
190
|
this.log(chalk.bold('=== Summary ==='));
|
|
150
|
-
if (dirtyRepos === 0) {
|
|
191
|
+
if (dirtyRepos === 0 && dirtyUntracked.length === 0) {
|
|
151
192
|
this.log(`✨ All ${totalRepos} repositories are clean`);
|
|
152
193
|
}
|
|
153
194
|
else {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
195
|
+
if (dirtyRepos > 0) {
|
|
196
|
+
this.log(`✨ ${dirtyRepos} ${dirtyRepos === 1 ? 'repo' : 'repos'} with changes`);
|
|
197
|
+
if (readyToCommit.length > 0) {
|
|
198
|
+
this.log(`🚀 Ready to commit across: ${chalk.cyan(readyToCommit.join(', '))}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (dirtyUntracked.length > 0) {
|
|
202
|
+
this.log(`⚠ ${dirtyUntracked.length} untracked ${dirtyUntracked.length === 1 ? 'repo' : 'repos'} with changes `
|
|
203
|
+
+ `(pass ${chalk.cyan('-u')} to gut commit)`);
|
|
157
204
|
}
|
|
158
205
|
}
|
|
159
206
|
// Suggested actions
|
|
160
|
-
if (dirtyRepos > 0) {
|
|
207
|
+
if (dirtyRepos > 0 || dirtyUntracked.length > 0) {
|
|
161
208
|
this.log('');
|
|
162
209
|
this.log(chalk.dim('Suggested actions:'));
|
|
163
|
-
this.log(chalk.dim('• gut add .
|
|
164
|
-
this.log(chalk.dim('• gut commit -m "..."
|
|
165
|
-
|
|
210
|
+
this.log(chalk.dim('• gut add . # Stage all changes'));
|
|
211
|
+
this.log(chalk.dim('• gut commit -m "..." # Commit across focused entities'));
|
|
212
|
+
if (dirtyUntracked.length > 0) {
|
|
213
|
+
this.log(chalk.dim('• gut commit -a -u -m "..." # Also commit untracked repos'));
|
|
214
|
+
}
|
|
215
|
+
this.log(chalk.dim('• gut push # Push to remotes'));
|
|
166
216
|
}
|
|
167
217
|
}
|
|
168
218
|
displayEntityStatus(status, verbose) {
|
|
@@ -190,4 +240,28 @@ export default class Status extends BaseCommand {
|
|
|
190
240
|
}
|
|
191
241
|
}
|
|
192
242
|
}
|
|
243
|
+
async safeGetStatus(repoPath) {
|
|
244
|
+
try {
|
|
245
|
+
const status = await this.gitService.getStatus(repoPath);
|
|
246
|
+
return {
|
|
247
|
+
ahead: status.ahead,
|
|
248
|
+
behind: status.behind,
|
|
249
|
+
branch: status.branch,
|
|
250
|
+
changes: status.changes,
|
|
251
|
+
hasChanges: status.hasChanges,
|
|
252
|
+
untracked: status.untracked,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
return {
|
|
257
|
+
ahead: 0,
|
|
258
|
+
behind: 0,
|
|
259
|
+
branch: '',
|
|
260
|
+
changes: [],
|
|
261
|
+
error: error instanceof Error ? error.message : String(error),
|
|
262
|
+
hasChanges: false,
|
|
263
|
+
untracked: [],
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
193
267
|
}
|
|
@@ -232,6 +232,13 @@ export interface RepoStatus {
|
|
|
232
232
|
path: string;
|
|
233
233
|
untracked: string[];
|
|
234
234
|
}
|
|
235
|
+
export interface DiscoveredRepo {
|
|
236
|
+
entityName?: string;
|
|
237
|
+
isRegistered: boolean;
|
|
238
|
+
isWorkspaceRoot: boolean;
|
|
239
|
+
path: string;
|
|
240
|
+
relativePath: string;
|
|
241
|
+
}
|
|
235
242
|
export interface WorktreeEntityRecord {
|
|
236
243
|
branch: string;
|
|
237
244
|
entityName: string;
|