@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 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.15-alpha.0 linux-x64 node-v22.22.2
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
@@ -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;
@@ -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
  }
@@ -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(entity.path);
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: entity.path })
78
- : this.gitService.commit(entity.path, message || '', options));
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 (results.success.length > 0) {
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
  }
@@ -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
- if (flags.all) {
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
- this.log(`✨ ${dirtyRepos} ${dirtyRepos === 1 ? 'repo' : 'repos'} with changes`);
155
- if (readyToCommit.length > 0) {
156
- this.log(`🚀 Ready to commit across: ${chalk.cyan(readyToCommit.join(', '))}`);
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 . # Stage all changes'));
164
- this.log(chalk.dim('• gut commit -m "..." # Commit across repos'));
165
- this.log(chalk.dim('• gut push # Push to remotes'));
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;