feat(git): support nested git repositories in the Git tab

When the project root is not itself a git repository, discover nested
repositories (depth- and visit-capped readdir walk via a new
/api/fs/git-dirs route), auto-select the first one, and show a repository
picker next to the branch dropdown to switch. Selections persist per
runtime and root; discovery failure is a distinct marker with a retry
action, never an empty success.
This commit is contained in:
jaygupta17
2026-08-08 19:08:42 +05:30
parent 4faf660a38
commit 9c9c28a552
20 changed files with 989 additions and 191 deletions
@@ -22,6 +22,10 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
- `POST /api/fs/exec`
- `GET /api/fs/exec/:jobId`
- `GET /api/fs/list`
- `GET /api/fs/git-dirs` — shallow nested git repository discovery for the
Git tab (depth- and visit-capped readdir walk; `.git` directory, file, or
symlink marks a repository boundary; junk directories and symlinks are
never descended into)
- Owns exec job queue state (`execJobs`) and lifecycle/TTL pruning.
- Enforces workspace boundary checks with active project + worktree fallback support.
- `createFsSearchRuntime({ fsPromises, path, spawn, resolveGitBinaryForSpawn })` from `search.js`
+129
View File
@@ -249,6 +249,79 @@ const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProject
});
};
// Nested repository discovery bounds: only shallow walks are useful for the
// Git tab's "pick a repository" picker, and deep/monorepo trees can explode
// otherwise. Directories deeper than maxDepth or beyond the visit cap are
// silently not searched.
const GIT_DIRS_MAX_DEPTH = 3;
const GIT_DIRS_MAX_DIRS = 100;
const GIT_DIRS_SKIP_LIST = new Set(['node_modules', 'dist', 'build', '.venv', 'target', '.next']);
// Walks rootPath and returns every nested git repository path (a directory
// containing a `.git` entry — a directory, a worktree pointer file, or a
// symlink). A repository boundary stops descent: nested repos inside repos
// are not reported. The root itself, when it is a repo, yields no results.
const findGitDirectories = async ({ rootPath, fsPromises, path: pathModule, maxDepth, maxDirs }) => {
const results = [];
let visited = 0;
const walk = async (dir, depth) => {
if (visited >= maxDirs) {
return;
}
let dirents;
try {
dirents = await fsPromises.readdir(dir, { withFileTypes: true });
} catch (error) {
// Unreadable subtree — skip it unless it is the root itself, which the
// route maps to 403/404/500 through the shared error handling.
if (dir === rootPath) {
throw error;
}
return;
}
visited += 1;
let isRepoBoundary = false;
const subdirectories = [];
for (const dirent of dirents) {
if (dirent.name === '.git') {
isRepoBoundary = true;
continue;
}
if (!dirent.isDirectory() || dirent.isSymbolicLink()) {
continue;
}
if (GIT_DIRS_SKIP_LIST.has(dirent.name)) {
continue;
}
if (depth >= maxDepth) {
continue;
}
subdirectories.push(dirent.name);
}
if (isRepoBoundary) {
if (dir !== rootPath) {
results.push(dir);
}
return;
}
subdirectories.sort();
for (const name of subdirectories) {
if (visited >= maxDirs) {
break;
}
await walk(pathModule.join(dir, name), depth + 1);
}
};
await walk(rootPath, 0);
return results;
};
const deriveCloneDirectoryName = (remoteUrl) => {
const remote = typeof remoteUrl === 'string' ? remoteUrl.trim() : '';
if (!remote) return '';
@@ -1461,4 +1534,60 @@ export const registerFsRoutes = (app, dependencies) => {
return res.status(500).json({ error: (error && error.message) || 'Failed to list directory' });
}
});
app.get('/api/fs/git-dirs', async (req, res) => {
const rawPath = typeof req.query.path === 'string' && req.query.path.trim().length > 0
? req.query.path.trim()
: '';
if (!rawPath) {
return res.status(400).json({ error: 'Path is required' });
}
try {
const resolved = await resolveWorkspacePathFromContext({
req,
targetPath: rawPath,
resolveProjectDirectory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
if (!resolved.ok) {
return res.status(400).json({ error: resolved.error });
}
const stats = await fsPromises.stat(resolved.resolved);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Specified path is not a directory', reason: 'not-directory' });
}
const repositories = await findGitDirectories({
rootPath: resolved.resolved,
fsPromises,
path,
maxDepth: GIT_DIRS_MAX_DEPTH,
maxDirs: GIT_DIRS_MAX_DIRS,
});
return res.json({
path: resolved.resolved,
repositories: repositories.map((repoPath) => ({
path: repoPath,
name: path.basename(repoPath),
})),
});
} catch (error) {
const err = error;
const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
if (code === 'ENOENT') {
return res.status(404).json({ error: 'Directory not found', reason: 'not-found' });
}
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access to directory denied');
}
console.error('Failed to find git directories:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to find git directories' });
}
});
};
+219
View File
@@ -823,3 +823,222 @@ describe('fs list symlink path space (issue 2627)', () => {
});
}
});
describe('fs git-dirs', () => {
const createDirent = (name, type) => ({
name,
isDirectory: () => type === 'dir',
isFile: () => type === 'file',
isSymbolicLink: () => type === 'symlink',
});
// tree maps directory path -> [[name, type], ...]
const registerGitDirs = (tree, { stat, readdir: readdirOverride } = {}) => {
const { app, getRoute } = createRouteRegistry();
const readdir = readdirOverride ?? vi.fn(async (dirPath) => (tree[dirPath] ?? []).map(([name, type]) => createDirent(name, type)));
registerFsRoutes(app, {
os: { homedir: () => '/home/user' },
path: path.posix,
fsPromises: {
realpath: async (targetPath) => targetPath,
stat: stat ?? vi.fn(async (targetPath) => ({ isDirectory: () => Boolean(tree[targetPath]) })),
readdir,
},
spawn: vi.fn(),
crypto: { randomUUID: () => 'job-0' },
normalizeDirectoryPath: (p) => p,
resolveProjectDirectory: async () => ({ directory: '/workspace' }),
buildAugmentedPath: () => '/usr/bin',
resolveGitBinaryForSpawn: () => 'git',
openchamberUserConfigRoot: '/home/user/.config',
});
return { handler: getRoute('GET', '/api/fs/git-dirs'), readdir };
};
const callGitDirs = async (handler, query) => {
const res = createMockResponse();
await handler({ query: query ?? {} }, res);
return res;
};
it('returns an empty list when the root itself is a repository', async () => {
const { handler, readdir } = registerGitDirs({
'/workspace': [['.git', 'dir'], ['proj-a', 'dir']],
'/workspace/proj-a': [['.git', 'dir']],
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ path: '/workspace', repositories: [] });
expect(readdir).toHaveBeenCalledTimes(1);
});
it('finds nested repositories with a .git directory', async () => {
const { handler } = registerGitDirs({
'/workspace': [['proj-a', 'dir'], ['proj-b', 'dir']],
'/workspace/proj-a': [['.git', 'dir'], ['src', 'dir']],
'/workspace/proj-a/src': [['index.ts', 'file']],
'/workspace/proj-b': [['.git', 'dir']],
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.statusCode).toBe(200);
expect(res.body.repositories).toEqual([
{ path: '/workspace/proj-a', name: 'proj-a' },
{ path: '/workspace/proj-b', name: 'proj-b' },
]);
});
it('treats a .git file (linked worktree) as a repository boundary', async () => {
const { handler } = registerGitDirs({
'/workspace': [['worktree', 'dir']],
'/workspace/worktree': [['.git', 'file']],
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.statusCode).toBe(200);
expect(res.body.repositories).toEqual([{ path: '/workspace/worktree', name: 'worktree' }]);
});
it('stops descending at repository boundaries', async () => {
const { handler, readdir } = registerGitDirs({
'/workspace': [['outer', 'dir']],
'/workspace/outer': [['.git', 'dir'], ['inner', 'dir']],
'/workspace/outer/inner': [['.git', 'dir']],
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.statusCode).toBe(200);
expect(res.body.repositories).toEqual([{ path: '/workspace/outer', name: 'outer' }]);
expect(readdir).not.toHaveBeenCalledWith('/workspace/outer/inner', { withFileTypes: true });
});
it('does not descend past the depth cap', async () => {
const { handler } = registerGitDirs({
'/workspace': [['a', 'dir']],
'/workspace/a': [['b', 'dir']],
'/workspace/a/b': [['c', 'dir']],
'/workspace/a/b/c': [['.git', 'dir'], ['d', 'dir']],
'/workspace/a/b/c/d': [['.git', 'dir']],
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.statusCode).toBe(200);
expect(res.body.repositories).toEqual([{ path: '/workspace/a/b/c', name: 'c' }]);
});
it('skips junk directories', async () => {
const { handler, readdir } = registerGitDirs({
'/workspace': [['node_modules', 'dir'], ['dist', 'dir'], ['real', 'dir']],
'/workspace/node_modules': [['dep', 'dir']],
'/workspace/node_modules/dep': [['.git', 'dir']],
'/workspace/dist': [['.git', 'dir']],
'/workspace/real': [['.git', 'dir']],
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.statusCode).toBe(200);
expect(res.body.repositories).toEqual([{ path: '/workspace/real', name: 'real' }]);
expect(readdir).not.toHaveBeenCalledWith('/workspace/node_modules', { withFileTypes: true });
});
it('never descends into symbolic links', async () => {
const { handler } = registerGitDirs({
'/workspace': [['link', 'symlink'], ['real', 'dir']],
'/workspace/real': [['.git', 'dir']],
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.statusCode).toBe(200);
expect(res.body.repositories).toEqual([{ path: '/workspace/real', name: 'real' }]);
});
it('returns repositories in deterministic order', async () => {
const { handler } = registerGitDirs({
'/workspace': [['zebra', 'dir'], ['alpha', 'dir']],
'/workspace/zebra': [['.git', 'dir']],
'/workspace/alpha': [['.git', 'dir']],
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.body.repositories.map((repo) => repo.name)).toEqual(['alpha', 'zebra']);
});
it('returns 400 when path is missing', async () => {
const { handler } = registerGitDirs({});
const res = await callGitDirs(handler, {});
expect(res.statusCode).toBe(400);
expect(res.body.error).toBe('Path is required');
});
it('returns 400 when the path is not a directory', async () => {
const { handler } = registerGitDirs({
'/workspace': [['file.txt', 'file']],
}, {
stat: vi.fn(async (targetPath) => ({ isDirectory: () => targetPath !== '/workspace/file.txt' })),
});
const res = await callGitDirs(handler, { path: '/workspace/file.txt' });
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: 'Specified path is not a directory', reason: 'not-directory' });
});
it('returns 404 when the directory does not exist', async () => {
const error = Object.assign(new Error('missing'), { code: 'ENOENT' });
const { handler } = registerGitDirs({}, {
stat: vi.fn(async () => { throw error; }),
});
const res = await callGitDirs(handler, { path: '/workspace/missing' });
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Directory not found', reason: 'not-found' });
});
for (const code of ['EACCES', 'EPERM']) {
it(`maps root ${code} to the os-permission contract`, async () => {
const error = Object.assign(new Error('denied'), { code });
const { handler } = registerGitDirs({}, {
stat: vi.fn(async () => ({ isDirectory: () => true })),
readdir: vi.fn(async () => { throw error; }),
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: 'Access to directory denied', reason: 'os-permission' });
});
}
it('skips unreadable subtrees without failing the scan', async () => {
const tree = {
'/workspace': [['blocked', 'dir'], ['open', 'dir']],
'/workspace/open': [['.git', 'dir']],
};
const blockedError = Object.assign(new Error('denied'), { code: 'EACCES' });
const { handler } = registerGitDirs(tree, {
readdir: vi.fn(async (dirPath) => {
if (dirPath === '/workspace/blocked') {
throw blockedError;
}
return (tree[dirPath] ?? []).map(([name, type]) => createDirent(name, type));
}),
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.statusCode).toBe(200);
expect(res.body.repositories).toEqual([{ path: '/workspace/open', name: 'open' }]);
});
});