mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
feat(group): add auto-discover CLI command and MCP tool
Add `gitnexus group auto-discover [directory]` that scans a parent directory for indexed repos, reads their package manifests, builds package mappings, and creates a group with code-level dependency detection enabled. Also adds `group_discover` MCP tool for programmatic access and `groupDiscover()` method on GroupService. Includes 5 integration tests covering discovery, error handling, and non-indexed directory filtering. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
05636bf681
commit
03e0abf022
5 changed files with 501 additions and 1 deletions
|
|
@ -21,6 +21,84 @@ export function registerGroupCommands(program: Command): void {
|
|||
console.log('Edit group.yaml to add repos, then run: gitnexus group sync ' + name);
|
||||
});
|
||||
|
||||
group
|
||||
.command('auto-discover [directory]')
|
||||
.description(
|
||||
'Auto-discover indexed repos in a directory and create a group with code-level dependency detection',
|
||||
)
|
||||
.option('--name <name>', 'Group name', 'workspace')
|
||||
.option('--force', 'Overwrite existing group')
|
||||
.option('--skip-sync', 'Create group without running sync')
|
||||
.option('--json', 'JSON output')
|
||||
.action(
|
||||
async (
|
||||
directory: string | undefined,
|
||||
opts: { name: string; force?: boolean; skipSync?: boolean; json?: boolean },
|
||||
) => {
|
||||
const pathMod = await import('node:path');
|
||||
const { LocalBackend } = await import('../mcp/local/local-backend.js');
|
||||
|
||||
const resolvedDir = pathMod.resolve(directory || process.cwd());
|
||||
const backend = new LocalBackend();
|
||||
try {
|
||||
await backend.init();
|
||||
console.log(
|
||||
`Discovering indexed repos in ${resolvedDir}...\n`,
|
||||
);
|
||||
|
||||
const raw = await backend.getGroupService().groupDiscover({
|
||||
directory: resolvedDir,
|
||||
name: opts.name,
|
||||
force: Boolean(opts.force),
|
||||
skipSync: Boolean(opts.skipSync),
|
||||
});
|
||||
|
||||
const result = raw as {
|
||||
error?: string;
|
||||
group?: string;
|
||||
groupDir?: string;
|
||||
repos?: Array<{ name: string; packageName: string | null }>;
|
||||
repoCount?: number;
|
||||
packageMappings?: Record<string, string>;
|
||||
synced?: boolean;
|
||||
contracts?: number;
|
||||
crossLinks?: number;
|
||||
};
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(raw, null, 2));
|
||||
} else {
|
||||
console.log(`Group "${result.group}" created at ${result.groupDir}\n`);
|
||||
console.log(`Discovered repos (${result.repoCount}):`);
|
||||
for (const repo of result.repos || []) {
|
||||
const pkg = repo.packageName ? ` (${repo.packageName})` : '';
|
||||
console.log(` ${repo.name}${pkg}`);
|
||||
}
|
||||
const mappings = result.packageMappings || {};
|
||||
if (Object.keys(mappings).length > 0) {
|
||||
console.log(`\nPackage mappings:`);
|
||||
for (const [pkg, groupPath] of Object.entries(mappings)) {
|
||||
console.log(` ${pkg} → ${groupPath}`);
|
||||
}
|
||||
}
|
||||
if (result.synced) {
|
||||
console.log(
|
||||
`\nSync: ${result.contracts} contracts, ${result.crossLinks} cross-links`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await backend.dispose().catch(() => {});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
group
|
||||
.command('add <group> <groupPath> <registryName>')
|
||||
.description(
|
||||
|
|
|
|||
|
|
@ -7,12 +7,19 @@ import fsp from 'node:fs/promises';
|
|||
import path from 'node:path';
|
||||
import { checkStaleness } from '../git-staleness.js';
|
||||
import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
|
||||
import { readNpmManifest } from './extractors/manifest-reader.js';
|
||||
import {
|
||||
fileMatchesServicePrefix,
|
||||
normalizeServicePrefix,
|
||||
repoInSubgroup,
|
||||
} from './group-path-utils.js';
|
||||
import { getDefaultGitnexusDir, getGroupDir, listGroups, readContractRegistry } from './storage.js';
|
||||
import {
|
||||
createGroupDir,
|
||||
getDefaultGitnexusDir,
|
||||
getGroupDir,
|
||||
listGroups,
|
||||
readContractRegistry,
|
||||
} from './storage.js';
|
||||
import { syncGroup } from './sync.js';
|
||||
import type {
|
||||
ContractRegistry,
|
||||
|
|
@ -513,4 +520,147 @@ export class GroupService {
|
|||
repos: repoStatuses,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-discover indexed repos in a directory and create a group with code-level dependency detection.
|
||||
*/
|
||||
async groupDiscover(params: Record<string, unknown>): Promise<unknown> {
|
||||
const directory = typeof params.directory === 'string' ? params.directory.trim() : '';
|
||||
const groupName = typeof params.name === 'string' ? params.name.trim() : 'workspace';
|
||||
const force = Boolean(params.force);
|
||||
const skipSync = Boolean(params.skipSync);
|
||||
|
||||
if (!directory) return { error: 'directory is required' };
|
||||
|
||||
// List subdirectories and find indexed repos
|
||||
let entries: import('node:fs').Dirent[];
|
||||
try {
|
||||
entries = await fsp.readdir(directory, { withFileTypes: true });
|
||||
} catch {
|
||||
return { error: `Cannot read directory: ${directory}` };
|
||||
}
|
||||
|
||||
const repos: Record<string, string> = {};
|
||||
const packages: Record<string, Record<string, string>> = {};
|
||||
const discoveredRepos: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
packageName: string | null;
|
||||
}> = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const repoPath = path.join(directory, entry.name);
|
||||
const metaPath = path.join(repoPath, '.gitnexus', 'meta.json');
|
||||
|
||||
let metaExists = false;
|
||||
try {
|
||||
await fsp.access(metaPath);
|
||||
metaExists = true;
|
||||
} catch {
|
||||
// Not indexed
|
||||
}
|
||||
if (!metaExists) continue;
|
||||
|
||||
// Find the registry name for this repo
|
||||
let registryName: string | null = null;
|
||||
try {
|
||||
const repoHandle = await this.port.resolveRepo(entry.name);
|
||||
registryName = repoHandle.name;
|
||||
} catch {
|
||||
// Try resolving by path
|
||||
try {
|
||||
const repoHandle = await this.port.resolveRepo(repoPath);
|
||||
registryName = repoHandle.name;
|
||||
} catch {
|
||||
// Use directory name as fallback
|
||||
registryName = entry.name;
|
||||
}
|
||||
}
|
||||
|
||||
const groupPath = entry.name;
|
||||
repos[groupPath] = registryName;
|
||||
|
||||
// Read package manifest for auto-discovery
|
||||
const manifest = readNpmManifest(repoPath);
|
||||
discoveredRepos.push({
|
||||
name: registryName,
|
||||
path: repoPath,
|
||||
packageName: manifest?.packageName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(repos).length === 0) {
|
||||
return { error: `No indexed repos found in ${directory}. Run 'gitnexus analyze' in each repo first.` };
|
||||
}
|
||||
|
||||
// Build packages mapping from discovered manifests
|
||||
const pkgToGroupPath = new Map<string, string>();
|
||||
for (const repo of discoveredRepos) {
|
||||
if (repo.packageName) {
|
||||
const groupPath = Object.entries(repos).find(([, name]) => name === repo.name)?.[0];
|
||||
if (groupPath) pkgToGroupPath.set(repo.packageName, groupPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the group
|
||||
const gitnexusDir = getDefaultGitnexusDir();
|
||||
const groupDir = await createGroupDir(gitnexusDir, groupName, force);
|
||||
|
||||
// Write a populated group.yaml
|
||||
const { createRequire } = await import('node:module');
|
||||
const _require = createRequire(import.meta.url);
|
||||
const yaml = _require('js-yaml') as typeof import('js-yaml');
|
||||
|
||||
const config = {
|
||||
version: 1,
|
||||
name: groupName,
|
||||
description: `Auto-discovered from ${directory}`,
|
||||
repos,
|
||||
links: [],
|
||||
packages,
|
||||
detect: {
|
||||
http: true,
|
||||
grpc: true,
|
||||
topics: true,
|
||||
shared_libs: true,
|
||||
embedding_fallback: false,
|
||||
},
|
||||
matching: {
|
||||
bm25_threshold: 0.7,
|
||||
embedding_threshold: 0.65,
|
||||
max_candidates_per_step: 3,
|
||||
},
|
||||
};
|
||||
|
||||
await fsp.writeFile(path.join(groupDir, 'group.yaml'), yaml.dump(config), 'utf-8');
|
||||
|
||||
// Optionally run sync
|
||||
let syncResult = null;
|
||||
if (!skipSync) {
|
||||
syncResult = await syncGroup(config, {
|
||||
groupDir,
|
||||
exactOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
group: groupName,
|
||||
groupDir,
|
||||
repos: discoveredRepos.map((r) => ({
|
||||
name: r.name,
|
||||
packageName: r.packageName,
|
||||
})),
|
||||
repoCount: Object.keys(repos).length,
|
||||
packageMappings: Object.fromEntries(pkgToGroupPath),
|
||||
synced: !skipSync,
|
||||
...(syncResult
|
||||
? {
|
||||
contracts: syncResult.contracts.length,
|
||||
crossLinks: syncResult.crossLinks.length,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3040,6 +3040,14 @@ export class LocalBackend {
|
|||
return this.groupList(params);
|
||||
case 'group_sync':
|
||||
return this.groupSync(params);
|
||||
case 'group_contracts':
|
||||
return this.groupContracts(params);
|
||||
case 'group_query':
|
||||
return this.groupQuery(params);
|
||||
case 'group_status':
|
||||
return this.groupStatus(params);
|
||||
case 'group_discover':
|
||||
return this.groupDiscover(params);
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown group tool: ${method}. Removed tools: use repo "@<groupName>" on impact, query, or context (optional "/<memberPath>"), or MCP resources.`,
|
||||
|
|
@ -3186,6 +3194,11 @@ export class LocalBackend {
|
|||
return JSON.stringify(raw, null, 2);
|
||||
}
|
||||
|
||||
private async groupDiscover(params: Record<string, unknown>): Promise<unknown> {
|
||||
await this.refreshRepos();
|
||||
return this.getGroupService().groupDiscover(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Route nodes with their consumers in a single query.
|
||||
* Shared by routeMap and shapeCheck to avoid N+1 query patterns.
|
||||
|
|
|
|||
|
|
@ -508,4 +508,65 @@ WHEN TO USE: After changing group.yaml or re-indexing member repos.`,
|
|||
required: ['name'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'group_contracts',
|
||||
description: `Inspect contracts and cross-links from the group's contracts.json.
|
||||
|
||||
WHEN TO USE: Debug cross-repo links after group_sync.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Group name' },
|
||||
type: { type: 'string', description: 'Filter by contract type (http, topic, …)' },
|
||||
repo: { type: 'string', description: 'Filter by group repo path (e.g. app/backend)' },
|
||||
unmatchedOnly: { type: 'boolean', description: 'Only contracts with no cross-link' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'group_query',
|
||||
description: `Run the query tool across all repos in a group and merge process results via reciprocal rank fusion.
|
||||
|
||||
WHEN TO USE: Semantic / hybrid search across a whole product group.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Group name' },
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
subgroup: { type: 'string', description: 'Limit to repo paths under this prefix' },
|
||||
limit: { type: 'number', description: 'Max merged results (default 5)' },
|
||||
},
|
||||
required: ['name', 'query'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'group_status',
|
||||
description: `Report index staleness (commit vs HEAD) and Contract Registry staleness (indexedAt) for each repo in a group.
|
||||
|
||||
WHEN TO USE: Before group_sync or when agents should refresh indexes.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Group name' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'group_discover',
|
||||
description: `Auto-discover indexed repos in a directory and create a group with code-level dependency detection.
|
||||
|
||||
WHEN TO USE: When a user wants to see a combined knowledge graph from a parent directory containing multiple indexed repos. Scans for .gitnexus/ in subdirectories, reads package manifests, builds package mappings, creates a group, and optionally runs sync.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
directory: { type: 'string', description: 'Parent directory containing repos' },
|
||||
name: { type: 'string', description: 'Group name (default: workspace)' },
|
||||
force: { type: 'boolean', description: 'Overwrite existing group' },
|
||||
skipSync: { type: 'boolean', description: 'Skip running sync after creation' },
|
||||
},
|
||||
required: ['directory'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
198
gitnexus/test/integration/group/auto-discover.test.ts
Normal file
198
gitnexus/test/integration/group/auto-discover.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
/**
|
||||
* Integration test for group auto-discover.
|
||||
*
|
||||
* Tests scanning a parent directory to find indexed repos and create a group.
|
||||
* Uses mock GroupToolPort to avoid needing a real LocalBackend.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { GroupService, type GroupToolPort, type GroupRepoHandle } from '../../../src/core/group/service.js';
|
||||
import { listGroups, readContractRegistry } from '../../../src/core/group/storage.js';
|
||||
|
||||
describe('Group auto-discover integration', () => {
|
||||
let tmpDir: string;
|
||||
let gitnexusHome: string;
|
||||
let originalHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = path.join(os.tmpdir(), `gitnexus-discover-${Date.now()}`);
|
||||
gitnexusHome = path.join(tmpDir, '.gitnexus-home');
|
||||
fs.mkdirSync(gitnexusHome, { recursive: true });
|
||||
|
||||
// Override GITNEXUS_HOME so we don't pollute the real home dir
|
||||
originalHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = gitnexusHome;
|
||||
|
||||
// Create two mock repos with .gitnexus/meta.json
|
||||
const repoA = path.join(tmpDir, 'repos', 'shared-utils');
|
||||
const repoB = path.join(tmpDir, 'repos', 'web-app');
|
||||
|
||||
fs.mkdirSync(path.join(repoA, '.gitnexus'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoA, '.gitnexus', 'meta.json'),
|
||||
JSON.stringify({ indexedAt: '2026-04-01T00:00:00Z', lastCommit: 'abc123' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(repoA, 'package.json'),
|
||||
JSON.stringify({ name: '@test/shared-utils', version: '1.0.0', dependencies: {} }),
|
||||
);
|
||||
fs.mkdirSync(path.join(repoA, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoA, 'src', 'index.ts'),
|
||||
'export function formatDate(d: Date): string { return d.toISOString(); }',
|
||||
);
|
||||
|
||||
fs.mkdirSync(path.join(repoB, '.gitnexus'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoB, '.gitnexus', 'meta.json'),
|
||||
JSON.stringify({ indexedAt: '2026-04-01T00:00:00Z', lastCommit: 'def456' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(repoB, 'package.json'),
|
||||
JSON.stringify({
|
||||
name: '@test/web-app',
|
||||
version: '2.0.0',
|
||||
dependencies: { '@test/shared-utils': '^1.0.0' },
|
||||
}),
|
||||
);
|
||||
fs.mkdirSync(path.join(repoB, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoB, 'src', 'app.ts'),
|
||||
`import { formatDate } from '@test/shared-utils';
|
||||
console.log(formatDate(new Date()));`,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome !== undefined) {
|
||||
process.env.GITNEXUS_HOME = originalHome;
|
||||
} else {
|
||||
delete process.env.GITNEXUS_HOME;
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeMockPort(): GroupToolPort {
|
||||
const repoHandles: Record<string, GroupRepoHandle> = {
|
||||
'shared-utils': {
|
||||
id: 'shared-utils',
|
||||
name: 'shared-utils',
|
||||
repoPath: path.join(tmpDir, 'repos', 'shared-utils'),
|
||||
storagePath: path.join(tmpDir, 'repos', 'shared-utils', '.gitnexus'),
|
||||
},
|
||||
'web-app': {
|
||||
id: 'web-app',
|
||||
name: 'web-app',
|
||||
repoPath: path.join(tmpDir, 'repos', 'web-app'),
|
||||
storagePath: path.join(tmpDir, 'repos', 'web-app', '.gitnexus'),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
resolveRepo: async (nameOrPath?: string): Promise<GroupRepoHandle> => {
|
||||
if (nameOrPath && repoHandles[nameOrPath]) {
|
||||
return repoHandles[nameOrPath];
|
||||
}
|
||||
// Try matching by path
|
||||
for (const handle of Object.values(repoHandles)) {
|
||||
if (nameOrPath && (handle.repoPath === nameOrPath || handle.repoPath.includes(nameOrPath))) {
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
throw new Error(`Repo not found: ${nameOrPath}`);
|
||||
},
|
||||
impact: async () => ({}),
|
||||
query: async () => ({ processes: [] }),
|
||||
impactByUid: async () => null,
|
||||
};
|
||||
}
|
||||
|
||||
it('discovers repos and creates a group', async () => {
|
||||
const service = new GroupService(makeMockPort());
|
||||
const reposDir = path.join(tmpDir, 'repos');
|
||||
|
||||
const result = (await service.groupDiscover({
|
||||
directory: reposDir,
|
||||
name: 'test-discover',
|
||||
skipSync: true,
|
||||
})) as {
|
||||
group: string;
|
||||
groupDir: string;
|
||||
repos: Array<{ name: string; packageName: string | null }>;
|
||||
repoCount: number;
|
||||
packageMappings: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(result.group).toBe('test-discover');
|
||||
expect(result.repoCount).toBe(2);
|
||||
|
||||
// Check repos were discovered
|
||||
const repoNames = result.repos.map((r) => r.name).sort();
|
||||
expect(repoNames).toEqual(['shared-utils', 'web-app']);
|
||||
|
||||
// Check package names detected
|
||||
const sharedRepo = result.repos.find((r) => r.name === 'shared-utils');
|
||||
expect(sharedRepo?.packageName).toBe('@test/shared-utils');
|
||||
|
||||
const webRepo = result.repos.find((r) => r.name === 'web-app');
|
||||
expect(webRepo?.packageName).toBe('@test/web-app');
|
||||
|
||||
// Check package mappings
|
||||
expect(result.packageMappings['@test/shared-utils']).toBeDefined();
|
||||
expect(result.packageMappings['@test/web-app']).toBeDefined();
|
||||
|
||||
// Check group.yaml was created
|
||||
const groups = await listGroups(gitnexusHome);
|
||||
expect(groups).toContain('test-discover');
|
||||
});
|
||||
|
||||
it('returns error for empty directory', async () => {
|
||||
const emptyDir = path.join(tmpDir, 'empty');
|
||||
fs.mkdirSync(emptyDir, { recursive: true });
|
||||
|
||||
const service = new GroupService(makeMockPort());
|
||||
const result = (await service.groupDiscover({
|
||||
directory: emptyDir,
|
||||
name: 'test-empty',
|
||||
skipSync: true,
|
||||
})) as { error: string };
|
||||
|
||||
expect(result.error).toContain('No indexed repos found');
|
||||
});
|
||||
|
||||
it('returns error for non-existent directory', async () => {
|
||||
const service = new GroupService(makeMockPort());
|
||||
const result = (await service.groupDiscover({
|
||||
directory: '/nonexistent/path',
|
||||
name: 'test-missing',
|
||||
})) as { error: string };
|
||||
|
||||
expect(result.error).toContain('Cannot read directory');
|
||||
});
|
||||
|
||||
it('returns error when directory param is missing', async () => {
|
||||
const service = new GroupService(makeMockPort());
|
||||
const result = (await service.groupDiscover({})) as { error: string };
|
||||
expect(result.error).toBe('directory is required');
|
||||
});
|
||||
|
||||
it('skips non-indexed subdirectories', async () => {
|
||||
// Add a non-indexed directory
|
||||
const nonIndexed = path.join(tmpDir, 'repos', 'not-indexed');
|
||||
fs.mkdirSync(nonIndexed, { recursive: true });
|
||||
fs.writeFileSync(path.join(nonIndexed, 'package.json'), JSON.stringify({ name: 'not-indexed' }));
|
||||
// No .gitnexus/meta.json
|
||||
|
||||
const service = new GroupService(makeMockPort());
|
||||
const result = (await service.groupDiscover({
|
||||
directory: path.join(tmpDir, 'repos'),
|
||||
name: 'test-skip',
|
||||
skipSync: true,
|
||||
})) as { repoCount: number };
|
||||
|
||||
// Should only find 2 (shared-utils and web-app), not 3
|
||||
expect(result.repoCount).toBe(2);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue