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:
@@ -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`
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user