@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
|
@@ -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
|
+
});
|