@hyperdrive.bot/gut 0.1.16 → 0.2.1

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
@@ -8,9 +8,39 @@ Git Unified Tooling - Enhanced git with workspace intelligence for entity-based
8
8
  [![Downloads/week](https://img.shields.io/npm/dw/@devsquad/gut.svg)](https://npmjs.org/package/@devsquad/gut)
9
9
 
10
10
  <!-- toc -->
11
+ * [Untracked Entities](#untracked-entities)
12
+ * [Commit all changes in focused entities only (default — unchanged)](#commit-all-changes-in-focused-entities-only-default--unchanged)
13
+ * [Also commit in super-repo root + any unregistered nested repos](#also-commit-in-super-repo-root--any-unregistered-nested-repos)
11
14
  * [Usage](#usage)
12
15
  * [Commands](#commands)
13
16
  <!-- tocstop -->
17
+
18
+ # Untracked Entities
19
+
20
+ `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:
21
+
22
+ - **The super-repo root itself** (the git repo containing `.gut/`)
23
+ - **Nested repos** that exist on disk but have not been run through `gut entity add`
24
+
25
+ Discovery is always-on for reading (`gut status --all`). For writing, it's opt-in via a flag on `gut commit`:
26
+
27
+ ```sh
28
+ # Commit all changes in focused entities only (default — unchanged)
29
+ gut commit -a -m "ship feature"
30
+
31
+ # Also commit in super-repo root + any unregistered nested repos
32
+ gut commit -a -u -m "ship feature and sync workspace"
33
+ ```
34
+
35
+ Safety guards on the `-u` path:
36
+
37
+ - **Refuses to commit on** `main`, `master`, `live`, `prod`, `production`
38
+ - **Skips** repos in an in-progress merge, rebase, or cherry-pick
39
+ - **Skips** repos with detached `HEAD`
40
+ - **Stages untracked files** (equivalent of `git add -A`) when `-a` is present, so newly-created files land in the commit
41
+
42
+ Escape hatch: set `GUT_SKIP_DISCOVERY=1` to disable the walk entirely.
43
+
14
44
  # Usage
15
45
  <!-- usage -->
16
46
  ```sh-session
@@ -18,7 +48,7 @@ $ npm install -g @hyperdrive.bot/gut
18
48
  $ gut COMMAND
19
49
  running command...
20
50
  $ gut (--version)
21
- @hyperdrive.bot/gut/0.1.16-alpha.0 linux-x64 node-v22.22.2
51
+ @hyperdrive.bot/gut/0.2.1-alpha.0 linux-x64 node-v22.22.2
22
52
  $ gut --help [COMMAND]
23
53
  USAGE
24
54
  $ gut COMMAND
@@ -295,12 +325,14 @@ Commit changes in focused repositories
295
325
 
296
326
  ```
297
327
  USAGE
298
- $ gut commit [-a] [--amend] [-m <value>]
328
+ $ gut commit [-a] [--amend] [-u] [-m <value>]
299
329
 
300
330
  FLAGS
301
- -a, --all Stage all changes before committing
302
- -m, --message=<value> Commit message
303
- --amend Amend the previous commit
331
+ -a, --all Stage all changes before committing
332
+ -m, --message=<value> Commit message
333
+ -u, --include-untracked Also commit in discovered .git repos not registered as entities (e.g. super-repo root, nested
334
+ repos). Fails closed on main/master/live/prod.
335
+ --amend Amend the previous commit
304
336
 
305
337
  DESCRIPTION
306
338
  Commit changes in focused repositories
@@ -310,6 +342,8 @@ EXAMPLES
310
342
 
311
343
  $ gut commit --message "Add feature"
312
344
 
345
+ $ gut commit -a -u -m "Ship WIP across workspace"
346
+
313
347
  $ gut commit
314
348
  ```
315
349
 
@@ -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);
@@ -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;
@@ -0,0 +1,14 @@
1
+ import { DiscoveredRepo } from '../models/entity.model.js';
2
+ import { ConfigService } from './config.service.js';
3
+ import { EntityService } from './entity.service.js';
4
+ export declare class DiscoveryService {
5
+ private configService;
6
+ private entityService;
7
+ constructor(configService: ConfigService, entityService: EntityService);
8
+ discover(maxDepth?: number): Promise<DiscoveredRepo[]>;
9
+ discoverUntracked(maxDepth?: number): Promise<DiscoveredRepo[]>;
10
+ private buildRegisteredPathIndex;
11
+ private hasGitEntry;
12
+ private relativize;
13
+ private shouldSkipDir;
14
+ }
@@ -0,0 +1,115 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const DEFAULT_MAX_DEPTH = 5;
4
+ const IGNORE_DIRS = new Set([
5
+ '.cache',
6
+ '.next',
7
+ '.serverless',
8
+ '.specstory',
9
+ '.turbo',
10
+ 'build',
11
+ 'coverage',
12
+ 'dist',
13
+ 'node_modules',
14
+ 'out',
15
+ ]);
16
+ const IGNORE_DIR_PATTERNS = [
17
+ /^\.serverless-/,
18
+ /^\.composer-stage-/,
19
+ ];
20
+ export class DiscoveryService {
21
+ configService;
22
+ entityService;
23
+ constructor(configService, entityService) {
24
+ this.configService = configService;
25
+ this.entityService = entityService;
26
+ }
27
+ async discover(maxDepth = DEFAULT_MAX_DEPTH) {
28
+ const workspaceRoot = this.configService.getWorkspaceRoot();
29
+ const registeredPaths = this.buildRegisteredPathIndex();
30
+ const results = [];
31
+ const queue = [{ absPath: workspaceRoot, depth: 0 }];
32
+ while (queue.length > 0) {
33
+ const { absPath, depth } = queue.shift();
34
+ const isWorkspaceRoot = absPath === workspaceRoot;
35
+ if (this.hasGitEntry(absPath)) {
36
+ const relativePath = this.relativize(workspaceRoot, absPath);
37
+ const registered = registeredPaths.get(path.resolve(absPath));
38
+ results.push({
39
+ entityName: registered,
40
+ isRegistered: Boolean(registered),
41
+ isWorkspaceRoot,
42
+ path: absPath,
43
+ relativePath,
44
+ });
45
+ // A nested repo's subtree is its own concern — stop descending.
46
+ // Exception: the workspace root. The super-repo itself owns submodules
47
+ // and nested untracked repos; we must descend past it to find them.
48
+ if (!isWorkspaceRoot)
49
+ continue;
50
+ }
51
+ if (depth >= maxDepth) {
52
+ continue;
53
+ }
54
+ let children;
55
+ try {
56
+ children = fs.readdirSync(absPath);
57
+ }
58
+ catch {
59
+ continue;
60
+ }
61
+ for (const child of children) {
62
+ if (this.shouldSkipDir(child))
63
+ continue;
64
+ const childPath = path.join(absPath, child);
65
+ let stat;
66
+ try {
67
+ stat = fs.lstatSync(childPath);
68
+ }
69
+ catch {
70
+ continue;
71
+ }
72
+ // Skip symlinks to avoid loops and leaking outside the workspace.
73
+ if (stat.isSymbolicLink())
74
+ continue;
75
+ if (!stat.isDirectory())
76
+ continue;
77
+ queue.push({ absPath: childPath, depth: depth + 1 });
78
+ }
79
+ }
80
+ return results;
81
+ }
82
+ async discoverUntracked(maxDepth = DEFAULT_MAX_DEPTH) {
83
+ const all = await this.discover(maxDepth);
84
+ return all.filter(repo => !repo.isRegistered);
85
+ }
86
+ buildRegisteredPathIndex() {
87
+ const index = new Map();
88
+ for (const entity of this.entityService.getAllEntities()) {
89
+ const absolute = path.resolve(this.entityService.resolveEntityPath(entity));
90
+ index.set(absolute, entity.name);
91
+ }
92
+ return index;
93
+ }
94
+ hasGitEntry(dirPath) {
95
+ // .git can be a directory (normal repo) or a file (submodule / worktree).
96
+ const gitPath = path.join(dirPath, '.git');
97
+ try {
98
+ return fs.existsSync(gitPath);
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
104
+ relativize(workspaceRoot, absPath) {
105
+ if (absPath === workspaceRoot)
106
+ return '.';
107
+ const rel = path.relative(workspaceRoot, absPath);
108
+ return rel === '' ? '.' : rel;
109
+ }
110
+ shouldSkipDir(name) {
111
+ if (IGNORE_DIRS.has(name))
112
+ return true;
113
+ return IGNORE_DIR_PATTERNS.some(pattern => pattern.test(name));
114
+ }
115
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,140 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { ConfigService } from './config.service.js';
6
+ import { DiscoveryService } from './discovery.service.js';
7
+ import { EntityService } from './entity.service.js';
8
+ function mkdir(p) {
9
+ fs.mkdirSync(p, { recursive: true });
10
+ }
11
+ function mkGitDir(p) {
12
+ mkdir(p);
13
+ mkdir(path.join(p, '.git'));
14
+ }
15
+ function mkGitFile(p, content = 'gitdir: ../.git/worktrees/x\n') {
16
+ mkdir(p);
17
+ fs.writeFileSync(path.join(p, '.git'), content);
18
+ }
19
+ describe('DiscoveryService', () => {
20
+ let workspaceRoot;
21
+ let configService;
22
+ let entityService;
23
+ let discoveryService;
24
+ beforeEach(() => {
25
+ workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gut-discovery-'));
26
+ mkdir(path.join(workspaceRoot, '.gut'));
27
+ fs.writeFileSync(path.join(workspaceRoot, '.gut', 'config.json'), JSON.stringify({ entities: [], initialized: true, workspace: workspaceRoot }));
28
+ mkGitDir(workspaceRoot);
29
+ configService = new ConfigService(workspaceRoot);
30
+ entityService = new EntityService(configService);
31
+ discoveryService = new DiscoveryService(configService, entityService);
32
+ });
33
+ afterEach(() => {
34
+ fs.rmSync(workspaceRoot, { force: true, recursive: true });
35
+ });
36
+ describe('discover()', () => {
37
+ it('always returns the workspace root as a discovered repo', async () => {
38
+ const result = await discoveryService.discover();
39
+ const workspaceRootHit = result.find(r => r.isWorkspaceRoot);
40
+ expect(workspaceRootHit).toBeDefined();
41
+ expect(workspaceRootHit.relativePath).toBe('.');
42
+ expect(workspaceRootHit.path).toBe(workspaceRoot);
43
+ expect(workspaceRootHit.isRegistered).toBe(false);
44
+ });
45
+ it('finds nested .git dirs as directories', async () => {
46
+ mkGitDir(path.join(workspaceRoot, 'packages', 'cli', 'foo'));
47
+ const result = await discoveryService.discover();
48
+ const foo = result.find(r => r.relativePath === path.join('packages', 'cli', 'foo'));
49
+ expect(foo).toBeDefined();
50
+ expect(foo.isRegistered).toBe(false);
51
+ });
52
+ it('treats .git files (worktree/submodule) as repo markers', async () => {
53
+ mkGitFile(path.join(workspaceRoot, 'packages', 'cli', 'worktree'));
54
+ const result = await discoveryService.discover();
55
+ const hit = result.find(r => r.relativePath === path.join('packages', 'cli', 'worktree'));
56
+ expect(hit).toBeDefined();
57
+ });
58
+ it('does not descend into a repo subtree once a .git is found', async () => {
59
+ // A repo at packages/cli/foo, and a nested .git inside its subdir should NOT be found.
60
+ mkGitDir(path.join(workspaceRoot, 'packages', 'cli', 'foo'));
61
+ mkGitDir(path.join(workspaceRoot, 'packages', 'cli', 'foo', 'vendored', 'nested'));
62
+ const result = await discoveryService.discover();
63
+ const nested = result.find(r => r.relativePath === path.join('packages', 'cli', 'foo', 'vendored', 'nested'));
64
+ expect(nested).toBeUndefined();
65
+ });
66
+ it('ignores node_modules/**, dist/**, build/**, .serverless/**, .serverless-*/', async () => {
67
+ mkGitDir(path.join(workspaceRoot, 'node_modules', 'pkg'));
68
+ mkGitDir(path.join(workspaceRoot, 'dist', 'hidden'));
69
+ mkGitDir(path.join(workspaceRoot, 'build', 'hidden'));
70
+ mkGitDir(path.join(workspaceRoot, '.serverless', 'hidden'));
71
+ mkGitDir(path.join(workspaceRoot, '.serverless-contacts', 'hidden'));
72
+ mkGitDir(path.join(workspaceRoot, '.composer-stage-1', 'hidden'));
73
+ const result = await discoveryService.discover();
74
+ expect(result.filter(r => !r.isWorkspaceRoot)).toHaveLength(0);
75
+ });
76
+ it('respects maxDepth — deeper repos are not found', async () => {
77
+ // Repo at depth 6: packages/a/b/c/d/e/.git
78
+ const deepPath = path.join(workspaceRoot, 'packages', 'a', 'b', 'c', 'd', 'e');
79
+ mkGitDir(deepPath);
80
+ const resultShallow = await discoveryService.discover(5);
81
+ expect(resultShallow.find(r => r.relativePath.endsWith('e'))).toBeUndefined();
82
+ const resultDeep = await discoveryService.discover(10);
83
+ expect(resultDeep.find(r => r.relativePath.endsWith('e'))).toBeDefined();
84
+ });
85
+ it('marks registered entities with isRegistered + entityName', async () => {
86
+ const entityDir = path.join(workspaceRoot, 'packages', 'cli', 'foo');
87
+ mkGitDir(entityDir);
88
+ fs.writeFileSync(path.join(workspaceRoot, '.gut', 'config.json'), JSON.stringify({
89
+ entities: [{ name: 'foo', path: 'packages/cli/foo', type: 'tool' }],
90
+ initialized: true,
91
+ workspace: workspaceRoot,
92
+ }));
93
+ // Rebuild services so the new config is picked up.
94
+ configService = new ConfigService(workspaceRoot);
95
+ entityService = new EntityService(configService);
96
+ discoveryService = new DiscoveryService(configService, entityService);
97
+ const result = await discoveryService.discover();
98
+ const foo = result.find(r => r.relativePath === path.join('packages', 'cli', 'foo'));
99
+ expect(foo).toBeDefined();
100
+ expect(foo.isRegistered).toBe(true);
101
+ expect(foo.entityName).toBe('foo');
102
+ });
103
+ it('skips symlinks to avoid loops and leaking outside workspace', async () => {
104
+ // Create an external "other" repo and symlink it inside the workspace.
105
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gut-outside-'));
106
+ try {
107
+ mkGitDir(outside);
108
+ fs.symlinkSync(outside, path.join(workspaceRoot, 'linked'), 'dir');
109
+ const result = await discoveryService.discover();
110
+ expect(result.find(r => r.relativePath === 'linked')).toBeUndefined();
111
+ }
112
+ finally {
113
+ fs.rmSync(outside, { force: true, recursive: true });
114
+ }
115
+ });
116
+ });
117
+ describe('discoverUntracked()', () => {
118
+ it('returns only repos not covered by a registered entity', async () => {
119
+ mkGitDir(path.join(workspaceRoot, 'packages', 'cli', 'registered'));
120
+ mkGitDir(path.join(workspaceRoot, 'packages', 'cli', 'untracked'));
121
+ fs.writeFileSync(path.join(workspaceRoot, '.gut', 'config.json'), JSON.stringify({
122
+ entities: [{ name: 'registered', path: 'packages/cli/registered', type: 'tool' }],
123
+ initialized: true,
124
+ workspace: workspaceRoot,
125
+ }));
126
+ configService = new ConfigService(workspaceRoot);
127
+ entityService = new EntityService(configService);
128
+ discoveryService = new DiscoveryService(configService, entityService);
129
+ const result = await discoveryService.discoverUntracked();
130
+ const relPaths = result.map(r => r.relativePath).sort();
131
+ expect(relPaths).toEqual(['.', path.join('packages', 'cli', 'untracked')].sort());
132
+ });
133
+ it('super-repo root is always untracked (never in the entity list)', async () => {
134
+ const result = await discoveryService.discoverUntracked();
135
+ const root = result.find(r => r.isWorkspaceRoot);
136
+ expect(root).toBeDefined();
137
+ expect(root.isRegistered).toBe(false);
138
+ });
139
+ });
140
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperdrive.bot/gut",
3
- "version": "0.1.16",
3
+ "version": "0.2.1",
4
4
  "description": "Git Unified Tooling - Enhanced git with workspace intelligence for entity-based organization",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",