Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions src/commands/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ vi.mock('../projects/projects.js', () => ({
pruneClones: vi.fn(),
}));

vi.mock('../daemon/config.js', () => ({
loadConfig: vi.fn().mockReturnValue({
agents: { default: '/home/u/agents', additional: [] },
projects: { default: '/home/u/projects', additional: [] },
}),
getProjectsDirs: vi.fn().mockReturnValue(['/home/u/projects']),
getConfigDir: vi.fn().mockReturnValue('/mock/config'),
}));

import {
loadProjects,
addProject,
Expand Down Expand Up @@ -164,20 +173,20 @@ describe('projects command', () => {

await program.parseAsync(['node', 'agentage', 'projects', 'discover', '/tmp/root']);

expect(mockDiscoverProjects).toHaveBeenCalledWith('/tmp/root');
expect(mockDiscoverProjects).toHaveBeenCalledWith(['/tmp/root']);
expect(logs.some((l) => l.includes('Discovered 2 new project(s)'))).toBe(true);
expect(logs.some((l) => l.includes('proj-a'))).toBe(true);
expect(logs.some((l) => l.includes('proj-b'))).toBe(true);
expect(mockExit).toHaveBeenCalledWith(0);
});

it('defaults to cwd when no path given', async () => {
it('defaults to configured projects.dirs when no path given', async () => {
mockLoadProjects.mockReturnValue([]);
mockDiscoverProjects.mockReturnValue([]);

await program.parseAsync(['node', 'agentage', 'projects', 'discover']);

expect(mockDiscoverProjects).toHaveBeenCalledWith(expect.any(String));
expect(mockDiscoverProjects).toHaveBeenCalledWith(['/home/u/projects']);
expect(logs.some((l) => l.includes('No new projects discovered'))).toBe(true);
expect(mockExit).toHaveBeenCalledWith(0);
});
Expand Down
6 changes: 4 additions & 2 deletions src/commands/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getWorktrees,
pruneClones,
} from '../projects/projects.js';
import { loadConfig, getProjectsDirs } from '../daemon/config.js';

export const registerProjects = (program: Command): void => {
const cmd = program.command('projects').description('Manage tracked projects');
Expand Down Expand Up @@ -142,13 +143,14 @@ const handleRemove = (name: string): void => {
};

const handleDiscover = (path?: string): void => {
const resolvedPath = resolve(path ?? process.cwd());
const roots = path ? [resolve(path)] : getProjectsDirs(loadConfig());
const before = loadProjects();
const after = discoverProjects(resolvedPath);
const after = discoverProjects(roots);
const newProjects = after.filter((p) => !before.some((b) => b.path === p.path));

if (newProjects.length === 0) {
console.log(chalk.gray('No new projects discovered.'));
console.log(chalk.dim(`Searched: ${roots.join(', ')}`));
} else {
console.log(chalk.green(`Discovered ${newProjects.length} new project(s):`));
for (const p of newProjects) {
Expand Down
34 changes: 34 additions & 0 deletions src/discovery/scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,40 @@ describe('scanner', () => {
expect(warnings[0].message).toContain('Import failed');
});

it('skips agents inside IGNORE_DIRS (node_modules, .git, dist, etc.)', async () => {
const root = join(testDir, 'scan-ignore');
mkdirSync(join(root, 'real'), { recursive: true });
mkdirSync(join(root, 'node_modules', 'pkg'), { recursive: true });
mkdirSync(join(root, 'dist'), { recursive: true });
mkdirSync(join(root, '.git'), { recursive: true });

writeFileSync(join(root, 'real', 'keep.agent.md'), '---\nname: keep\n---\n');
writeFileSync(join(root, 'node_modules', 'pkg', 'ghost.agent.md'), '---\nname: ghost\n---\n');
writeFileSync(join(root, 'dist', 'built.agent.md'), '---\nname: built\n---\n');
writeFileSync(join(root, '.git', 'hooked.agent.md'), '---\nname: hooked\n---\n');

const factory: AgentFactory = async (path) => {
if (!path.endsWith('.agent.md')) return null;
const name = path.split('/').pop()!.replace('.agent.md', '');
return {
manifest: { name, path },
async run() {
return {
runId: 'x',
events: (async function* () {})(),
cancel: () => {},
sendInput: () => {},
};
},
};
};

const { scanAgents } = await import('./scanner.js');
const agents = await scanAgents([root], [factory]);
const names = agents.map((a) => a.manifest.name).sort();
expect(names).toEqual(['keep']);
});

it('clears warnings on each scan', async () => {
const agentsDir = join(testDir, 'agents-clear');
mkdirSync(agentsDir, { recursive: true });
Expand Down
15 changes: 15 additions & 0 deletions src/discovery/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ let lastWarnings: ScanWarning[] = [];

export const getLastScanWarnings = (): ScanWarning[] => lastWarnings;

export const IGNORE_DIRS = new Set([
'node_modules',
'.git',
'.github',
'.github-private',
'.claude',
'dist',
'build',
'.next',
'coverage',
'.turbo',
'.cache',
]);

const getAllFiles = (dir: string): string[] => {
const results: string[] = [];

Expand All @@ -27,6 +41,7 @@ const getAllFiles = (dir: string): string[] => {

const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && IGNORE_DIRS.has(entry.name)) continue;
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...getAllFiles(fullPath));
Expand Down
92 changes: 92 additions & 0 deletions src/projects/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,98 @@ describe('discoverProjects', () => {
expect(result[0].remote).toBeUndefined();
});

it('accepts an array of roots and dedupes results', () => {
mockExistsSync.mockImplementation((p) => {
const s = String(p);
if (s.endsWith('projects.json')) return false;
if (s === '/a/repo-x/.git') return true;
if (s === '/b/repo-x/.git') return true;
return false;
});
mockReaddirSync.mockImplementation((p) => {
const s = String(p);
if (s === '/a') return [{ name: 'repo-x', isDirectory: () => true }] as never;
if (s === '/b') return [{ name: 'repo-x', isDirectory: () => true }] as never;
return [];
});
mockStatSync.mockReturnValue({ isDirectory: () => true } as never);

const result = discoverProjects(['/a', '/b']);
expect(result.map((r) => r.path).sort()).toEqual(['/a/repo-x', '/b/repo-x']);
});

it('skips node_modules, .git, and other ignored dirs', () => {
mockExistsSync.mockImplementation((p) => {
const s = String(p);
if (s.endsWith('projects.json')) return false;
if (s === '/root/keep/.git') return true;
return false;
});
mockReaddirSync.mockImplementation((p) => {
const s = String(p);
if (s === '/root')
return [
{ name: 'keep', isDirectory: () => true },
{ name: 'node_modules', isDirectory: () => true },
{ name: '.github', isDirectory: () => true },
{ name: '.claude', isDirectory: () => true },
{ name: 'dist', isDirectory: () => true },
] as never;
return [];
});
mockStatSync.mockReturnValue({ isDirectory: () => true } as never);

const result = discoverProjects('/root');

expect(result).toHaveLength(1);
expect(result[0].path).toBe('/root/keep');
// Sanity: readdirSync never called on ignored paths
const readCalls = mockReaddirSync.mock.calls.map((c) => String(c[0]));
expect(readCalls).not.toContain('/root/node_modules');
expect(readCalls).not.toContain('/root/.github');
});

it('walks recursively to find nested git repos', () => {
mockExistsSync.mockImplementation((p) => {
const s = String(p);
if (s.endsWith('projects.json')) return false;
if (s === '/root/group/inner/.git') return true;
return false;
});
mockReaddirSync.mockImplementation((p) => {
const s = String(p);
if (s === '/root') return [{ name: 'group', isDirectory: () => true }] as never;
if (s === '/root/group') return [{ name: 'inner', isDirectory: () => true }] as never;
return [];
});
mockStatSync.mockReturnValue({ isDirectory: () => true } as never);

const result = discoverProjects('/root');
expect(result).toHaveLength(1);
expect(result[0].path).toBe('/root/group/inner');
});

it('stops descending into a matched repo', () => {
mockExistsSync.mockImplementation((p) => {
const s = String(p);
if (s.endsWith('projects.json')) return false;
if (s === '/root/outer/.git') return true;
if (s === '/root/outer/sub/.git') return true;
return false;
});
mockReaddirSync.mockImplementation((p) => {
const s = String(p);
if (s === '/root') return [{ name: 'outer', isDirectory: () => true }] as never;
if (s === '/root/outer') return [{ name: 'sub', isDirectory: () => true }] as never;
return [];
});
mockStatSync.mockReturnValue({ isDirectory: () => true } as never);

const result = discoverProjects('/root');
expect(result).toHaveLength(1);
expect(result[0].path).toBe('/root/outer');
});

it('backfills remote on existing entries that are missing it', () => {
const existing: Project[] = [{ name: 'cli', path: '/root/cli', discovered: true }];
mockExistsSync.mockImplementation((p) => {
Expand Down
62 changes: 55 additions & 7 deletions src/projects/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,28 +144,76 @@ export const removeProject = (name: string): boolean => {
return true;
};

export const discoverProjects = (rootDir: string): Project[] => {
const entries = readdirSync(rootDir, { withFileTypes: true });
const projects = loadProjects();
export const PROJECTS_IGNORE_DIRS = new Set([
'node_modules',
'.github',
'.github-private',
'.claude',
'dist',
'build',
'.next',
'coverage',
'.turbo',
'.cache',
]);

const MAX_DISCOVERY_DEPTH = 5;

const walkForGitRepos = (dir: string, depth: number, out: string[]): void => {
if (depth > MAX_DISCOVERY_DEPTH) return;

let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}

for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (PROJECTS_IGNORE_DIRS.has(entry.name)) continue;

const dirPath = join(rootDir, entry.name);
const dirPath = join(dir, entry.name);
const gitPath = join(dirPath, '.git');

if (!existsSync(gitPath)) continue;
if (!statSync(gitPath).isDirectory()) continue;
let isRepo = false;
try {
isRepo = existsSync(gitPath) && statSync(gitPath).isDirectory();
} catch {
isRepo = false;
}

if (isRepo) {
out.push(dirPath);
// Don't descend into a matched repo — we treat it as the project boundary.
continue;
}

walkForGitRepos(dirPath, depth + 1, out);
}
};

export const discoverProjects = (rootDirs: string | string[]): Project[] => {
const roots = Array.isArray(rootDirs) ? rootDirs : [rootDirs];
const found: string[] = [];
for (const root of roots) {
walkForGitRepos(root, 0, found);
}

const projects = loadProjects();
const seen = new Set(projects.map((p) => p.path));

for (const dirPath of found) {
const existing = projects.find((p) => p.path === dirPath);
if (existing) {
// Backfill remote on previously-discovered entries that predate remote capture.
if (!existing.remote) {
const remote = getOriginUrl(dirPath);
if (remote) existing.remote = remote;
}
continue;
}
if (seen.has(dirPath)) continue;
seen.add(dirPath);

const name = deriveNameFromDir(dirPath);
const remote = getOriginUrl(dirPath);
Expand Down