mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-29 01:41:42 +00:00
feat(group): support explicit --repos paths in auto-discover
Add --repos flag to `group auto-discover` so users can pass explicit repo paths instead of scanning a directory: gitnexus group auto-discover --repos /path/to/repo1 /path/to/repo2 Also removes unused buildPackageMap() and findSiblingDependencies() functions, and cleans up redundant inline comments across extractors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a33c26e298
commit
17294656b0
7 changed files with 131 additions and 204 deletions
|
|
@ -24,34 +24,51 @@ export function registerGroupCommands(program: Command): void {
|
|||
group
|
||||
.command('auto-discover [directory]')
|
||||
.description(
|
||||
'Auto-discover indexed repos in a directory and create a group with code-level dependency detection',
|
||||
'Auto-discover indexed repos in a directory, or create a group from explicit repo paths',
|
||||
)
|
||||
.option('--name <name>', 'Group name', 'workspace')
|
||||
.option('--repos <paths...>', 'Explicit repo paths (instead of scanning a directory)')
|
||||
.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 },
|
||||
opts: { name: string; repos?: 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),
|
||||
});
|
||||
let raw: unknown;
|
||||
if (opts.repos && opts.repos.length > 0) {
|
||||
// Explicit repo paths mode
|
||||
const resolvedPaths = opts.repos.map((p) => pathMod.resolve(p));
|
||||
console.log(
|
||||
`Creating group from ${resolvedPaths.length} repos...\n`,
|
||||
);
|
||||
raw = await backend.getGroupService().groupDiscover({
|
||||
repoPaths: resolvedPaths,
|
||||
name: opts.name,
|
||||
force: Boolean(opts.force),
|
||||
skipSync: Boolean(opts.skipSync),
|
||||
});
|
||||
} else {
|
||||
// Directory scan mode
|
||||
const resolvedDir = pathMod.resolve(directory || process.cwd());
|
||||
console.log(
|
||||
`Discovering indexed repos in ${resolvedDir}...\n`,
|
||||
);
|
||||
raw = await backend.getGroupService().groupDiscover({
|
||||
directory: resolvedDir,
|
||||
name: opts.name,
|
||||
force: Boolean(opts.force),
|
||||
skipSync: Boolean(opts.skipSync),
|
||||
});
|
||||
}
|
||||
|
||||
const result = raw as {
|
||||
error?: string;
|
||||
|
|
|
|||
|
|
@ -112,7 +112,6 @@ export class CodeDepExtractor implements ContractExtractor {
|
|||
|
||||
for (const imp of imports) {
|
||||
if (imp.isNamespaceImport) {
|
||||
// import * as X from 'pkg' — wildcard consumer
|
||||
const contractId = `lib::${imp.packageName}::*`;
|
||||
const key = `${contractId}|${imp.filePath}`;
|
||||
if (seen.has(key)) continue;
|
||||
|
|
@ -137,7 +136,6 @@ export class CodeDepExtractor implements ContractExtractor {
|
|||
}
|
||||
|
||||
if (imp.importedSymbols.length > 0) {
|
||||
// Named imports — one contract per symbol
|
||||
for (const symbol of imp.importedSymbols) {
|
||||
const contractId = `lib::${imp.packageName}::${symbol}`;
|
||||
const key = `${contractId}|${imp.filePath}`;
|
||||
|
|
@ -161,7 +159,6 @@ export class CodeDepExtractor implements ContractExtractor {
|
|||
});
|
||||
}
|
||||
} else if (imp.isDefaultImport) {
|
||||
// Default import — uses the package's default export
|
||||
const contractId = `lib::${imp.packageName}::default`;
|
||||
const key = `${contractId}|${imp.filePath}`;
|
||||
if (seen.has(key)) continue;
|
||||
|
|
@ -183,7 +180,6 @@ export class CodeDepExtractor implements ContractExtractor {
|
|||
},
|
||||
});
|
||||
}
|
||||
// Side-effect imports (no symbols, no default) are skipped — no symbol contract to create
|
||||
}
|
||||
|
||||
return contracts;
|
||||
|
|
|
|||
|
|
@ -17,18 +17,9 @@ export interface ScannedImport {
|
|||
isDefaultImport: boolean;
|
||||
}
|
||||
|
||||
// ES import patterns
|
||||
// Named: import { Foo, Bar } from 'pkg'
|
||||
// Default: import Foo from 'pkg'
|
||||
// Namespace: import * as Foo from 'pkg'
|
||||
// Side-effect: import 'pkg'
|
||||
// Re-export: export { Foo } from 'pkg'
|
||||
const ES_IMPORT_RE =
|
||||
/(?:import\s+(?:(\*\s+as\s+\w+)\s+from|(\w+)(?:\s*,\s*\{([^}]*)\})?\s+from|(?:type\s+)?\{([^}]*)\}\s+from)\s*['"]([^'"]+)['"]|import\s+['"]([^'"]+)['"]|export\s+(?:type\s+)?\{[^}]*\}\s+from\s+['"]([^'"]+)['"])/g;
|
||||
|
||||
// CommonJS require patterns
|
||||
// const { Foo } = require('pkg')
|
||||
// const Foo = require('pkg')
|
||||
const CJS_REQUIRE_RE =
|
||||
/(?:const|let|var)\s+(?:(\w+)|\{([^}]*)\})\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
|
||||
|
|
@ -93,17 +84,16 @@ export function scanFileForImports(
|
|||
const { packageName, subpath } = splitPackageSpecifier(specifier);
|
||||
if (!targetPackages.has(packageName)) continue;
|
||||
|
||||
const isNamespaceImport = !!match[1]; // import * as X
|
||||
const isDefaultImport = !!match[2]; // import X from
|
||||
const namedFromDefault = match[3] || ''; // import X, { Y, Z } from
|
||||
const namedOnly = match[4] || ''; // import { Y, Z } from
|
||||
const isNamespaceImport = !!match[1];
|
||||
const isDefaultImport = !!match[2];
|
||||
const namedFromDefault = match[3] || '';
|
||||
const namedOnly = match[4] || '';
|
||||
|
||||
const importedSymbols = [
|
||||
...parseNamedImports(namedFromDefault),
|
||||
...parseNamedImports(namedOnly),
|
||||
];
|
||||
|
||||
// Side-effect import (match[6]) — no symbols
|
||||
const isSideEffect = !!match[6];
|
||||
|
||||
results.push({
|
||||
|
|
@ -125,8 +115,8 @@ export function scanFileForImports(
|
|||
const { packageName, subpath } = splitPackageSpecifier(specifier);
|
||||
if (!targetPackages.has(packageName)) continue;
|
||||
|
||||
const defaultName = match[1]; // const X = require(...)
|
||||
const destructured = match[2]; // const { X, Y } = require(...)
|
||||
const defaultName = match[1];
|
||||
const destructured = match[2];
|
||||
|
||||
const importedSymbols = destructured ? parseNamedImports(destructured) : [];
|
||||
const isDefaultImport = !!defaultName;
|
||||
|
|
|
|||
|
|
@ -46,40 +46,3 @@ export function readNpmManifest(repoPath: string): ManifestInfo | null {
|
|||
dependencies: [...deps],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a package map from a set of repos: maps npm package name → group path.
|
||||
* Only includes repos that have a valid package.json with a name field.
|
||||
*/
|
||||
export function buildPackageMap(
|
||||
repos: Record<string, string>,
|
||||
resolveRepoPath: (registryName: string) => string | null,
|
||||
): Map<string, string> {
|
||||
const packageMap = new Map<string, string>();
|
||||
|
||||
for (const [groupPath, registryName] of Object.entries(repos)) {
|
||||
const repoPath = resolveRepoPath(registryName);
|
||||
if (!repoPath) continue;
|
||||
|
||||
const manifest = readNpmManifest(repoPath);
|
||||
if (manifest) {
|
||||
packageMap.set(manifest.packageName, groupPath);
|
||||
}
|
||||
}
|
||||
|
||||
return packageMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find which sibling packages a repo depends on.
|
||||
* Returns the subset of packageMap keys that appear in this repo's dependencies.
|
||||
*/
|
||||
export function findSiblingDependencies(
|
||||
repoPath: string,
|
||||
packageMap: Map<string, string>,
|
||||
): string[] {
|
||||
const manifest = readNpmManifest(repoPath);
|
||||
if (!manifest) return [];
|
||||
|
||||
return manifest.dependencies.filter((dep) => packageMap.has(dep));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -682,19 +682,12 @@ export class GroupService {
|
|||
*/
|
||||
async groupDiscover(params: Record<string, unknown>): Promise<unknown> {
|
||||
const directory = typeof params.directory === 'string' ? params.directory.trim() : '';
|
||||
const repoPaths = Array.isArray(params.repoPaths) ? (params.repoPaths as string[]) : [];
|
||||
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}` };
|
||||
}
|
||||
if (!directory && repoPaths.length === 0) return { error: 'directory or repoPaths is required' };
|
||||
|
||||
const repos: Record<string, string> = {};
|
||||
const packages: Record<string, Record<string, string>> = {};
|
||||
|
|
@ -704,51 +697,101 @@ export class GroupService {
|
|||
packageName: string | null;
|
||||
}> = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (repoPaths.length > 0) {
|
||||
// Explicit repo paths mode
|
||||
for (const repoPath of repoPaths) {
|
||||
const resolvedPath = path.resolve(repoPath);
|
||||
const metaPath = path.join(resolvedPath, '.gitnexus', 'meta.json');
|
||||
|
||||
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
|
||||
let metaExists = false;
|
||||
try {
|
||||
const repoHandle = await this.port.resolveRepo(repoPath);
|
||||
await fsp.access(metaPath);
|
||||
metaExists = true;
|
||||
} catch {
|
||||
// Not indexed
|
||||
}
|
||||
if (!metaExists) {
|
||||
return { error: `Repo at ${resolvedPath} is not indexed. Run 'gitnexus analyze' there first.` };
|
||||
}
|
||||
|
||||
const dirName = path.basename(resolvedPath);
|
||||
let registryName: string | null = null;
|
||||
try {
|
||||
const repoHandle = await this.port.resolveRepo(dirName);
|
||||
registryName = repoHandle.name;
|
||||
} catch {
|
||||
// Use directory name as fallback
|
||||
registryName = entry.name;
|
||||
try {
|
||||
const repoHandle = await this.port.resolveRepo(resolvedPath);
|
||||
registryName = repoHandle.name;
|
||||
} catch {
|
||||
registryName = dirName;
|
||||
}
|
||||
}
|
||||
|
||||
repos[dirName] = registryName;
|
||||
|
||||
const manifest = readNpmManifest(resolvedPath);
|
||||
discoveredRepos.push({
|
||||
name: registryName,
|
||||
path: resolvedPath,
|
||||
packageName: manifest?.packageName ?? null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Directory scan mode
|
||||
let entries: import('node:fs').Dirent[];
|
||||
try {
|
||||
entries = await fsp.readdir(directory, { withFileTypes: true });
|
||||
} catch {
|
||||
return { error: `Cannot read directory: ${directory}` };
|
||||
}
|
||||
|
||||
const groupPath = entry.name;
|
||||
repos[groupPath] = registryName;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
// Read package manifest for auto-discovery
|
||||
const manifest = readNpmManifest(repoPath);
|
||||
discoveredRepos.push({
|
||||
name: registryName,
|
||||
path: repoPath,
|
||||
packageName: manifest?.packageName ?? null,
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
} // end else (directory scan mode)
|
||||
|
||||
if (Object.keys(repos).length === 0) {
|
||||
return { error: `No indexed repos found in ${directory}. Run 'gitnexus analyze' in each repo first.` };
|
||||
return { error: `No indexed repos found. Run 'gitnexus analyze' in each repo first.` };
|
||||
}
|
||||
|
||||
// Build packages mapping from discovered manifests
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ console.log(formatDate(new Date()));`,
|
|||
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');
|
||||
expect(result.error).toBe('directory or repoPaths is required');
|
||||
});
|
||||
|
||||
it('skips non-indexed subdirectories', async () => {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,7 @@ 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 {
|
||||
readNpmManifest,
|
||||
buildPackageMap,
|
||||
findSiblingDependencies,
|
||||
} from '../../../src/core/group/extractors/manifest-reader.js';
|
||||
import { readNpmManifest } from '../../../src/core/group/extractors/manifest-reader.js';
|
||||
|
||||
describe('manifest-reader', () => {
|
||||
let tmpDir: string;
|
||||
|
|
@ -118,65 +114,8 @@ describe('manifest-reader', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('buildPackageMap', () => {
|
||||
it('maps package names to group paths', () => {
|
||||
const repoA = path.join(tmpDir, 'repo-a');
|
||||
const repoB = path.join(tmpDir, 'repo-b');
|
||||
fs.mkdirSync(repoA, { recursive: true });
|
||||
fs.mkdirSync(repoB, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoA, 'package.json'),
|
||||
JSON.stringify({ name: '@acme/shared', dependencies: {} }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(repoB, 'package.json'),
|
||||
JSON.stringify({ name: '@acme/web-app', dependencies: {} }),
|
||||
);
|
||||
|
||||
const repos = { 'libs/shared': 'repo-a', 'apps/web': 'repo-b' };
|
||||
const resolve = (name: string) => {
|
||||
if (name === 'repo-a') return repoA;
|
||||
if (name === 'repo-b') return repoB;
|
||||
return null;
|
||||
};
|
||||
|
||||
const map = buildPackageMap(repos, resolve);
|
||||
expect(map.get('@acme/shared')).toBe('libs/shared');
|
||||
expect(map.get('@acme/web-app')).toBe('apps/web');
|
||||
expect(map.size).toBe(2);
|
||||
});
|
||||
|
||||
it('skips repos that cannot be resolved', () => {
|
||||
const repoA = path.join(tmpDir, 'repo-a');
|
||||
fs.mkdirSync(repoA, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoA, 'package.json'),
|
||||
JSON.stringify({ name: '@acme/shared', dependencies: {} }),
|
||||
);
|
||||
|
||||
const repos = { 'libs/shared': 'repo-a', 'apps/missing': 'repo-missing' };
|
||||
const resolve = (name: string) => (name === 'repo-a' ? repoA : null);
|
||||
|
||||
const map = buildPackageMap(repos, resolve);
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.has('@acme/shared')).toBe(true);
|
||||
});
|
||||
|
||||
it('skips repos without package.json', () => {
|
||||
const repoA = path.join(tmpDir, 'repo-a');
|
||||
fs.mkdirSync(repoA, { recursive: true });
|
||||
// No package.json written
|
||||
|
||||
const repos = { 'libs/shared': 'repo-a' };
|
||||
const resolve = () => repoA;
|
||||
|
||||
const map = buildPackageMap(repos, resolve);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSiblingDependencies', () => {
|
||||
it('finds sibling packages in dependencies', () => {
|
||||
describe('readNpmManifest — dependency list for sibling detection', () => {
|
||||
it('lists all dependency names for cross-repo matching', () => {
|
||||
writePackageJson('.', {
|
||||
name: '@acme/web-app',
|
||||
dependencies: {
|
||||
|
|
@ -186,32 +125,11 @@ describe('manifest-reader', () => {
|
|||
},
|
||||
});
|
||||
|
||||
const packageMap = new Map([
|
||||
['@acme/shared', 'libs/shared'],
|
||||
['@acme/ui-kit', 'libs/ui-kit'],
|
||||
]);
|
||||
|
||||
const siblings = findSiblingDependencies(tmpDir, packageMap);
|
||||
expect(siblings).toContain('@acme/shared');
|
||||
expect(siblings).toContain('@acme/ui-kit');
|
||||
expect(siblings).not.toContain('lodash');
|
||||
});
|
||||
|
||||
it('returns empty when no siblings found', () => {
|
||||
writePackageJson('.', {
|
||||
name: '@acme/standalone',
|
||||
dependencies: { lodash: '^4.17.0' },
|
||||
});
|
||||
|
||||
const packageMap = new Map([['@acme/other', 'libs/other']]);
|
||||
const siblings = findSiblingDependencies(tmpDir, packageMap);
|
||||
expect(siblings).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty when no package.json', () => {
|
||||
const packageMap = new Map([['@acme/shared', 'libs/shared']]);
|
||||
const siblings = findSiblingDependencies(tmpDir, packageMap);
|
||||
expect(siblings).toEqual([]);
|
||||
const result = readNpmManifest(tmpDir);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.dependencies).toContain('@acme/shared');
|
||||
expect(result!.dependencies).toContain('@acme/ui-kit');
|
||||
expect(result!.dependencies).toContain('lodash');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue