@hyperdrive.bot/gut 0.2.2 → 0.2.3-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,7 +48,7 @@ $ npm install -g @hyperdrive.bot/gut
48
48
  $ gut COMMAND
49
49
  running command...
50
50
  $ gut (--version)
51
- @hyperdrive.bot/gut/0.2.2-alpha.0 linux-x64 node-v22.22.2
51
+ @hyperdrive.bot/gut/0.2.2 linux-x64 node-v22.22.2
52
52
  $ gut --help [COMMAND]
53
53
  USAGE
54
54
  $ gut COMMAND
@@ -88,6 +88,25 @@ export default class WorktreeCreate extends BaseCommand {
88
88
  this.error('No entities focused and no --entity flag provided. Use "gut focus <entity>" first, or pass --entity <name> (repeatable).');
89
89
  }
90
90
  }
91
+ // Pre-flight: each entity must be its OWN git repo. A plain directory
92
+ // tracked by the super-repo (no .git of its own) would make per-entity
93
+ // `git worktree add` walk up to the super-repo's .git and collide with
94
+ // the super-repo branch we'd create one step earlier — the failure
95
+ // happens AFTER the super-repo branch is created, leaving an orphan
96
+ // `<name>` branch behind. Catching it here errors cleanly and never
97
+ // touches super-repo state.
98
+ const nonRepoEntities = selectedEntities.filter((entity) => {
99
+ const entityMainPath = path.join(workspaceRoot, entity.path.replace(/^\.\//, ''));
100
+ return !this.gitService.isStandaloneRepository(entityMainPath);
101
+ });
102
+ if (nonRepoEntities.length > 0) {
103
+ const details = nonRepoEntities.map((e) => ` • ${e.name} @ ${e.path}`).join('\n');
104
+ this.error(`Cannot create worktree — these entities are not standalone git repositories:\n${details}\n\n` +
105
+ 'They are plain directories tracked by the super-repo (no .git of their own), so each\n' +
106
+ 'per-entity `git worktree add` would collide with the super-repo\'s branch namespace.\n' +
107
+ 'Pass --entity pointing at entities that have their own .git, or remove the offenders\n' +
108
+ 'from your focus set. Run `gut entity list` to see all entities + their paths.');
109
+ }
91
110
  // --- Create worktrees with atomic rollback (AC: 1, 2, 9) ---
92
111
  const createdWorktrees = [];
93
112
  const entityRecords = [];
@@ -136,6 +155,14 @@ export default class WorktreeCreate extends BaseCommand {
136
155
  const entityMainPath = path.join(workspaceRoot, entityRelativePath);
137
156
  await this.gitService.worktreePrune(entityMainPath).catch(() => { });
138
157
  }
158
+ // Delete the super-repo branch we created at step 1 (otherwise it
159
+ // lingers as an orphan after every partial failure — confusing trail
160
+ // that grows on each retry). Derived from createdWorktrees so the flag
161
+ // and the list of created worktrees can't drift.
162
+ const superRepoBranchCreated = createdWorktrees.some((w) => w.repoPath === workspaceRoot);
163
+ if (superRepoBranchCreated) {
164
+ await this.gitService.branchDelete(workspaceRoot, args.name, true).catch(() => { });
165
+ }
139
166
  this.log(chalk.red('✗ Worktree creation failed. All partial worktrees cleaned up.'));
140
167
  const message = error instanceof Error ? error.message : String(error);
141
168
  this.error(message);
@@ -40,6 +40,26 @@ export declare class GitService {
40
40
  hasRemote(repoPath: string, remote?: string): Promise<boolean>;
41
41
  init(repoPath: string): Promise<void>;
42
42
  isRepository(repoPath: string): Promise<boolean>;
43
+ /**
44
+ * True if `repoPath` is its OWN git repo (has a `.git` directory or file
45
+ * at the exact path) — distinct from `isRepository` which walks up via
46
+ * `git rev-parse --git-dir` and would falsely succeed for a plain dir
47
+ * tracked by an ancestor repo.
48
+ *
49
+ * Used by `worktree create` to reject entities that are plain directories
50
+ * in the super-repo (no .git of their own) — those would make per-entity
51
+ * `git worktree add` walk up to the super-repo's .git and collide with
52
+ * the super-repo branch.
53
+ */
54
+ isStandaloneRepository(repoPath: string): boolean;
55
+ /**
56
+ * Delete a local branch. With `force=true` deletes even if not fully merged
57
+ * (`-D`); otherwise refuses if the branch has unmerged commits (`-d`).
58
+ * Used by `worktree create`'s rollback to remove the super-repo branch
59
+ * created in step 1 when a later per-entity step fails — without this, every
60
+ * partial failure leaves an orphan `<name>` branch on the super-repo.
61
+ */
62
+ branchDelete(repoPath: string, branch: string, force?: boolean): Promise<void>;
43
63
  pull(repoPath: string, options?: PullOptions): Promise<void>;
44
64
  push(repoPath: string, options?: PushOptions): Promise<void>;
45
65
  worktreeAdd(repoPath: string, worktreePath: string, branch: string, baseBranch?: string): Promise<void>;
@@ -1,4 +1,5 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  export class GitService {
4
5
  async add(repoPath, files) {
@@ -167,6 +168,30 @@ export class GitService {
167
168
  return false;
168
169
  }
169
170
  }
171
+ /**
172
+ * True if `repoPath` is its OWN git repo (has a `.git` directory or file
173
+ * at the exact path) — distinct from `isRepository` which walks up via
174
+ * `git rev-parse --git-dir` and would falsely succeed for a plain dir
175
+ * tracked by an ancestor repo.
176
+ *
177
+ * Used by `worktree create` to reject entities that are plain directories
178
+ * in the super-repo (no .git of their own) — those would make per-entity
179
+ * `git worktree add` walk up to the super-repo's .git and collide with
180
+ * the super-repo branch.
181
+ */
182
+ isStandaloneRepository(repoPath) {
183
+ return fs.existsSync(path.join(repoPath, '.git'));
184
+ }
185
+ /**
186
+ * Delete a local branch. With `force=true` deletes even if not fully merged
187
+ * (`-D`); otherwise refuses if the branch has unmerged commits (`-d`).
188
+ * Used by `worktree create`'s rollback to remove the super-repo branch
189
+ * created in step 1 when a later per-entity step fails — without this, every
190
+ * partial failure leaves an orphan `<name>` branch on the super-repo.
191
+ */
192
+ async branchDelete(repoPath, branch, force = false) {
193
+ await this.exec(['branch', force ? '-D' : '-d', branch], { cwd: repoPath });
194
+ }
170
195
  async pull(repoPath, options) {
171
196
  const args = ['pull'];
172
197
  if (options?.rebase) {
@@ -22,6 +22,21 @@ describe('GitService worktree methods', () => {
22
22
  .rejects.toThrow('Git command failed');
23
23
  });
24
24
  });
25
+ describe('branchDelete', () => {
26
+ it('calls exec with -d when force is false (or omitted)', async () => {
27
+ await gitService.branchDelete('/repo', 'feat/x');
28
+ expect(execSpy).toHaveBeenCalledWith(['branch', '-d', 'feat/x'], { cwd: '/repo' });
29
+ });
30
+ it('calls exec with -D when force is true', async () => {
31
+ await gitService.branchDelete('/repo', 'feat/x', true);
32
+ expect(execSpy).toHaveBeenCalledWith(['branch', '-D', 'feat/x'], { cwd: '/repo' });
33
+ });
34
+ it('rejects when git exits non-zero (e.g. branch is checked out)', async () => {
35
+ execSpy.mockRejectedValue(new Error("Git command failed: error: Cannot delete branch 'feat/x' checked out at '/wt'"));
36
+ await expect(gitService.branchDelete('/repo', 'feat/x', true))
37
+ .rejects.toThrow('Git command failed');
38
+ });
39
+ });
25
40
  describe('worktreeList', () => {
26
41
  it('parses multi-entry porcelain output', async () => {
27
42
  const porcelainOutput = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperdrive.bot/gut",
3
- "version": "0.2.2",
3
+ "version": "0.2.3-alpha.0",
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",