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
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,12 @@ import type { IconName } from "@/components/icon/icons";
import { BranchSelector } from './BranchSelector';
import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
import { SyncActions } from './SyncActions';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@/components/ui/select';
import type {
GitStatus,
GitIdentityProfile,
@@ -51,6 +57,13 @@ interface GitHeaderProps {
pullRequest?: GitHubPullRequest | null;
prChecks?: GitHubChecksSummary | null;
onOpenPullRequest?: () => void;
// Nested repository picker: shown when the Git tab operates on a repository
// nested inside a non-repository root. Options are absolute repository
// paths; `repositoryRoot` is the root those paths are relative to.
repositoryOptions?: string[];
selectedRepository?: string | null;
onSelectRepository?: (repository: string) => void;
repositoryRoot?: string;
}
const IDENTITY_ICON_MAP: Record<string, IconName> = {
@@ -258,12 +271,23 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
pullRequest,
prChecks,
onOpenPullRequest,
repositoryOptions,
selectedRepository,
onSelectRepository,
repositoryRoot,
}) => {
const { t } = useI18n();
if (!status) {
return null;
}
const repositoryOptionsForPicker = (repositoryOptions ?? []).filter(Boolean);
const repositoryRelativePath = (repository: string): string => {
const rootPrefix = `${repositoryRoot ?? ''}/`;
return repository.startsWith(rootPrefix) ? repository.slice(rootPrefix.length) : repository;
};
const repositoryLabel = selectedRepository ? repositoryRelativePath(selectedRepository) : '';
const managementButtons = (
<div className="flex items-center gap-1 shrink-0">
{onOpenHistory || onOpenGraph || onOpenStashes || onOpenUpdateBranch ? (
@@ -410,7 +434,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
return (
<header className="@container/git-header px-3 py-2 bg-transparent">
<div className="flex items-center justify-between gap-2 min-w-0">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-1 items-center gap-1">
{isWorktreeMode ? (
<WorktreeBranchDisplay
currentBranch={status.current}
@@ -427,6 +451,34 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
remotes={remotes}
/>
)}
{repositoryOptionsForPicker.length > 0 ? (
<Select
value={selectedRepository ?? undefined}
onValueChange={(value) => {
if (value && onSelectRepository) {
onSelectRepository(value);
}
}}
>
<SelectTrigger
size="sm"
className="max-w-[13rem] gap-1.5 px-2 py-1"
aria-label={t('gitView.empty.selectRepositoryPlaceholder')}
>
<Icon name="folder-3" className="size-4 text-muted-foreground" />
<span className="min-w-0 truncate font-medium text-left">
{repositoryLabel}
</span>
</SelectTrigger>
<SelectContent align="start">
{repositoryOptionsForPicker.map((repository) => (
<SelectItem key={repository} value={repository}>
<span className="truncate">{repositoryRelativePath(repository)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{identityControl}
+17
View File
@@ -115,6 +115,23 @@ export async function checkIsGitRepository(directory: string): Promise<boolean>
}
}
export async function listGitDirectories(root: string): Promise<string[]> {
const response = await runtimeFetch('/api/fs/git-dirs', { query: { path: root } });
if (!response.ok) {
throw new Error(`Failed to list git directories: ${response.statusText}`);
}
const data = await response.json();
if (!data || !Array.isArray(data.repositories)) {
throw new Error('Unexpected git directories response');
}
return data.repositories
.map((entry: unknown) => {
const path = entry && typeof entry === 'object' && 'path' in entry ? (entry as { path?: unknown }).path : undefined;
return typeof path === 'string' && path.trim() ? path.trim() : null;
})
.filter((path: string | null): path is string => path !== null);
}
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus> {
const mode = options?.mode;
const runtimeKey = getRuntimeKey();
+4
View File
@@ -859,6 +859,10 @@ export const dict = {
'gitView.empty.worktreeFeaturesUnavailable': 'Worktree-Funktionen sind in diesem Arbeitsbereichsmodus nicht verfügbar.',
'gitView.empty.worktreeSetupDescription': 'Arbeitstruktur-Einrichtung wird abgeschlossen und Repository-Zustand wird vorbereitet.',
'gitView.empty.worktreeSetupInProgress': 'Worktree-Einrichtung läuft',
'gitView.empty.discoveringRepositories': 'Suche nach Git-Repositories...',
'gitView.empty.discoverFailed': 'Git-Repositories konnten nicht durchsucht werden',
'gitView.empty.retryDiscovery': 'Erneut versuchen',
'gitView.empty.selectRepositoryPlaceholder': 'Repository auswählen...',
'worktree.bootstrap.toast.failed': 'Worktree-Einrichtung fehlgeschlagen',
'worktree.bootstrap.toast.failedDescription': 'Die Worktree wurde erstellt, aber die Hintergrund-Einrichtung wurde nicht abgeschlossen.',
'worktree.bootstrap.toast.timeoutDescription': 'Die Worktree wurde erstellt, aber die Hintergrund-Einrichtung hat ein Timeout.',
+4
View File
@@ -923,6 +923,10 @@ export const dict = {
'gitView.empty.worktreeFeaturesUnavailable': 'Worktree features are unavailable in this workspace mode.',
'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.',
'gitView.empty.worktreeSetupInProgress': 'Worktree setup in progress',
'gitView.empty.discoveringRepositories': 'Looking for Git repositories...',
'gitView.empty.discoverFailed': 'Could not scan for Git repositories',
'gitView.empty.retryDiscovery': 'Retry',
'gitView.empty.selectRepositoryPlaceholder': 'Select a repository...',
'worktree.bootstrap.toast.failed': 'Worktree setup failed',
'worktree.bootstrap.toast.failedDescription': 'The worktree was created, but background setup did not finish.',
'worktree.bootstrap.toast.timeoutDescription': 'The worktree was created, but background setup timed out.',
+4
View File
@@ -924,6 +924,10 @@ export const dict: Record<I18nKey, string> = {
"gitView.empty.worktreeFeaturesUnavailable": "Las características de worktree no están disponibles en este modo de espacio de trabajo.",
"gitView.empty.worktreeSetupDescription": "Finalizando la configuración de worktree y preparando el estado del repositorio.",
"gitView.empty.worktreeSetupInProgress": "Configuración de worktree en progreso",
"gitView.empty.discoveringRepositories": "Buscando repositorios de Git...",
"gitView.empty.discoverFailed": "No se pudo escanear en busca de repositorios de Git",
"gitView.empty.retryDiscovery": "Reintentar",
"gitView.empty.selectRepositoryPlaceholder": "Selecciona un repositorio...",
"worktree.bootstrap.toast.failed": "Error al configurar el worktree",
"worktree.bootstrap.toast.failedDescription": "El worktree se creó, pero la configuración en segundo plano no terminó.",
"worktree.bootstrap.toast.timeoutDescription": "El worktree se creó, pero la configuración en segundo plano agotó el tiempo de espera.",
+4
View File
@@ -751,6 +751,10 @@ export const dict = {
'gitView.empty.worktreeFeaturesUnavailable': 'Les fonctionnalités Worktree ne sont pas disponibles dans ce mode despace de travail.',
'gitView.empty.worktreeSetupDescription': 'Termine la configuration du worktree et prépare l\'état du dépôt.',
'gitView.empty.worktreeSetupInProgress': 'Configuration de worktree en cours',
'gitView.empty.discoveringRepositories': 'Recherche des dépôts Git...',
'gitView.empty.discoverFailed': 'Impossible danalyser les dépôts Git',
'gitView.empty.retryDiscovery': 'Réessayer',
'gitView.empty.selectRepositoryPlaceholder': 'Sélectionnez un dépôt...',
'gitView.gitmoji.empty': 'Aucun gitmoji trouvé',
'gitView.gitmoji.searchPlaceholder': 'Rechercher des gitmoji...',
'gitView.gitmoji.title': 'Insérer un gitmoji',
+4
View File
@@ -920,6 +920,10 @@ export const dict: Record<I18nKey, string> = {
'gitView.empty.worktreeFeaturesUnavailable': 'このワークスペースモードではワークツリー機能は利用できません。',
'gitView.empty.worktreeSetupDescription': 'ワークツリーのセットアップを完了し、リポジトリ状態を準備中。',
'gitView.empty.worktreeSetupInProgress': 'ワークツリーのセットアップ進行中',
'gitView.empty.discoveringRepositories': 'Git リポジトリを検索しています...',
'gitView.empty.discoverFailed': 'Git リポジトリを検索できませんでした',
'gitView.empty.retryDiscovery': '再試行',
'gitView.empty.selectRepositoryPlaceholder': 'リポジトリを選択...',
'worktree.bootstrap.toast.failed': 'ワークツリーのセットアップに失敗しました',
'worktree.bootstrap.toast.failedDescription': 'ワークツリーは作成されましたが、バックグラウンドセットアップが完了しませんでした。',
'worktree.bootstrap.toast.timeoutDescription': 'ワークツリーは作成されましたが、バックグラウンドセットアップがタイムアウトしました。',
+4
View File
@@ -924,6 +924,10 @@ export const dict: Record<I18nKey, string> = {
'gitView.empty.worktreeFeaturesUnavailable': '이 워크스페이스 모드에서는 워크트리 기능을 사용할 수 없습니다.',
'gitView.empty.worktreeSetupDescription': '워크트리 설정을 마치고 레포지토리 상태를 준비하고 있습니다.',
'gitView.empty.worktreeSetupInProgress': '워크트리 설정 중',
'gitView.empty.discoveringRepositories': 'Git 저장소를 찾는 중...',
'gitView.empty.discoverFailed': 'Git 저장소를 검색할 수 없습니다',
'gitView.empty.retryDiscovery': '다시 시도',
'gitView.empty.selectRepositoryPlaceholder': '저장소 선택...',
'worktree.bootstrap.toast.failed': '워크트리 설정 실패',
'worktree.bootstrap.toast.failedDescription': '워크트리는 생성되었지만 백그라운드 설정이 완료되지 않았습니다.',
'worktree.bootstrap.toast.timeoutDescription': '워크트리는 생성되었지만 백그라운드 설정 시간이 초과되었습니다.',
+4
View File
@@ -1958,6 +1958,10 @@ export const dict: Record<I18nKey, string> = {
'gitView.empty.worktreeFeaturesUnavailable': 'Worktree features are unavailable in this workspace mode.',
'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.',
'gitView.empty.worktreeSetupInProgress': 'Worktree setup in progress',
'gitView.empty.discoveringRepositories': 'Szukanie repozytoriów Git...',
'gitView.empty.discoverFailed': 'Nie udało się przeskanować repozytoriów Git',
'gitView.empty.retryDiscovery': 'Ponów',
'gitView.empty.selectRepositoryPlaceholder': 'Wybierz repozytorium...',
'worktree.bootstrap.toast.failed': 'Konfiguracja drzewa pracy nie powiodła się',
'worktree.bootstrap.toast.failedDescription': 'Drzewo pracy zostało utworzone, ale konfiguracja w tle nie została ukończona.',
'worktree.bootstrap.toast.timeoutDescription': 'Drzewo pracy zostało utworzone, ale konfiguracja w tle przekroczyła limit czasu.',
@@ -924,6 +924,10 @@ export const dict: Record<I18nKey, string> = {
"gitView.empty.worktreeFeaturesUnavailable": "Os recursos de worktree não estão disponíveis neste modo de workspace.",
"gitView.empty.worktreeSetupDescription": "Finalizando a configuração de worktree e preparando o status do repositório.",
"gitView.empty.worktreeSetupInProgress": "Configuração de worktree em andamento",
"gitView.empty.discoveringRepositories": "Procurando repositórios Git...",
"gitView.empty.discoverFailed": "Não foi possível verificar os repositórios Git",
"gitView.empty.retryDiscovery": "Tentar novamente",
"gitView.empty.selectRepositoryPlaceholder": "Selecione um repositório...",
"worktree.bootstrap.toast.failed": "Falha na configuração do worktree",
"worktree.bootstrap.toast.failedDescription": "O worktree foi criado, mas a configuração em segundo plano não terminou.",
"worktree.bootstrap.toast.timeoutDescription": "O worktree foi criado, mas a configuração em segundo plano atingiu o tempo limite.",
+4
View File
@@ -924,6 +924,10 @@ export const dict: Record<I18nKey, string> = {
"gitView.empty.worktreeFeaturesUnavailable": "У цьому режимі робочої області функції worktree недоступні.",
"gitView.empty.worktreeSetupDescription": "Завершення налаштування worktree та підготовка стану сховища.",
"gitView.empty.worktreeSetupInProgress": "Виконується налаштування worktree",
"gitView.empty.discoveringRepositories": "Пошук репозиторіїв Git...",
"gitView.empty.discoverFailed": "Не вдалося просканувати репозиторії Git",
"gitView.empty.retryDiscovery": "Повторити",
"gitView.empty.selectRepositoryPlaceholder": "Виберіть репозиторій...",
"worktree.bootstrap.toast.failed": "Не вдалося налаштувати worktree",
"worktree.bootstrap.toast.failedDescription": "Worktree створено, але фонове налаштування не завершилося.",
"worktree.bootstrap.toast.timeoutDescription": "Worktree створено, але час очікування фонового налаштування минув.",
@@ -924,6 +924,10 @@ export const dict: Record<I18nKey, string> = {
'gitView.empty.worktreeFeaturesUnavailable': '当前工作区模式下,工作树功能不可用。',
'gitView.empty.worktreeSetupDescription': '正在完成工作树设置并准备仓库状态。',
'gitView.empty.worktreeSetupInProgress': '工作树设置进行中',
'gitView.empty.discoveringRepositories': '正在查找 Git 仓库...',
'gitView.empty.discoverFailed': '无法扫描 Git 仓库',
'gitView.empty.retryDiscovery': '重试',
'gitView.empty.selectRepositoryPlaceholder': '选择仓库...',
'worktree.bootstrap.toast.failed': '工作树设置失败',
'worktree.bootstrap.toast.failedDescription': '工作树已创建,但后台设置未完成。',
'worktree.bootstrap.toast.timeoutDescription': '工作树已创建,但后台设置超时。',
@@ -936,6 +936,10 @@ export const dict: Record<I18nKey, string> = {
'gitView.empty.worktreeFeaturesUnavailable': '目前工作區模式下,worktree 功能無法使用。',
'gitView.empty.worktreeSetupDescription': '正在完成 worktree 設定並準備儲存庫狀態。',
'gitView.empty.worktreeSetupInProgress': 'worktree 設定進行中',
'gitView.empty.discoveringRepositories': '正在尋找 Git 儲存庫...',
'gitView.empty.discoverFailed': '無法掃描 Git 儲存庫',
'gitView.empty.retryDiscovery': '重試',
'gitView.empty.selectRepositoryPlaceholder': '選擇儲存庫...',
'worktree.bootstrap.toast.failed': 'worktree 設定失敗',
'worktree.bootstrap.toast.failedDescription': 'worktree 已建立,但背景設定未完成。',
'worktree.bootstrap.toast.timeoutDescription': 'worktree 已建立,但背景設定逾時。',
+1
View File
@@ -133,6 +133,7 @@ Important properties:
- loading state is per-directory, not global
- `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers
- in-flight dedupe exists for status and `ensureAll()`
- nested repository discovery (`nestedReposByRoot`, `nestedRepoSelection`, `ensureNestedRepos`) is per-root state for roots that are not themselves git repositories; discovery failure is a `null` marker (never a valid empty result), selections are persisted per runtime + root, and `useEffectiveGitDirectory(root)` resolves the directory the Git tab operates on (`root` when the root is a repository, the selected nested repository otherwise)
- runtime reset replaces all live entries with that runtime's persisted branch seeds and invalidates old completions
- status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations
- status mutations advance a revision so older refreshes cannot undo optimistic or confirmed index changes
@@ -335,3 +335,61 @@ describe('useGitStore', () => {
expect(useGitStore.getState().getDirectoryState('/repo')?.status).toBe(initialStatus);
});
});
describe('useGitStore nested repository discovery', () => {
beforeEach(() => {
useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey());
});
test('selects a nested repo per root and persists the selection', () => {
useGitStore.getState().selectNestedRepo('/root-a', '/root-a/repo-one');
expect(useGitStore.getState().nestedRepoSelection.get('/root-a')).toBe('/root-a/repo-one');
// Re-seeding from storage (as a page refresh would) restores the pick.
useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey());
expect(useGitStore.getState().nestedRepoSelection.get('/root-a')).toBe('/root-a/repo-one');
});
test('keeps selections isolated per root', () => {
useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one');
useGitStore.getState().selectNestedRepo('/root-b', '/root-b/two');
expect(useGitStore.getState().nestedRepoSelection.get('/root-a')).toBe('/root-a/one');
expect(useGitStore.getState().nestedRepoSelection.get('/root-b')).toBe('/root-b/two');
});
test('clears only the given root selection', () => {
useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one');
useGitStore.getState().selectNestedRepo('/root-b', '/root-b/two');
useGitStore.getState().clearNestedRepoSelection('/root-a');
expect(useGitStore.getState().nestedRepoSelection.has('/root-a')).toBe(false);
expect(useGitStore.getState().nestedRepoSelection.get('/root-b')).toBe('/root-b/two');
});
test('runtime switch does not leak selections or discovery across runtimes', () => {
useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one');
useGitStore.setState({ nestedReposByRoot: new Map([['/root-a', ['/root-a/one']]]) });
useGitStore.getState().resetForRuntimeSwitch('runtime-b');
expect(useGitStore.getState().nestedRepoSelection.size).toBe(0);
expect(useGitStore.getState().nestedReposByRoot.size).toBe(0);
});
test('marks discovery failure as a failed marker, not an empty success', async () => {
await useGitStore.getState().ensureNestedRepos('/root-a');
expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toBeNull();
});
test('dedupes concurrent discovery runs for the same root', async () => {
const first = useGitStore.getState().ensureNestedRepos('/root-a');
const second = useGitStore.getState().ensureNestedRepos('/root-a');
await Promise.all([first, second]);
expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toBeNull();
});
});
+164 -1
View File
@@ -9,6 +9,7 @@ import type {
} from '@/lib/api/types';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { listGitDirectories } from '@/lib/gitApiHttp';
const LOG_STALE_THRESHOLD = 10000;
const REPO_CHECK_STALE_THRESHOLD = 60_000;
@@ -77,6 +78,16 @@ interface GitStore {
setLogMaxCount: (directory: string, maxCount: number) => void;
// Nested repository discovery: when the root directory is not itself a git
// repository, these hold the discovered repositories and the user's pick.
// `nestedReposByRoot` values are `null` when discovery failed — never a
// valid empty result — and absent when discovery has not run yet.
nestedReposByRoot: Map<string, string[] | null>;
nestedRepoSelection: Map<string, string>;
ensureNestedRepos: (root: string, options?: { force?: boolean }) => Promise<void>;
selectNestedRepo: (root: string, repository: string) => void;
clearNestedRepoSelection: (root: string) => void;
refresh: (git: GitAPI, options?: { force?: boolean }) => Promise<void>;
resetForRuntimeSwitch: (runtimeKey: string) => void;
}
@@ -101,6 +112,7 @@ const inFlightDiffFetchesByDirectory = new Map<string, Set<string>>();
const diffFetchGenerationByDirectory = new Map<string, number>();
const inFlightStatusFetches = new Map<string, Promise<boolean>>();
const inFlightEnsureAllByDirectory = new Map<string, Promise<void>>();
const inFlightNestedRepoDiscovery = new Map<string, Promise<void>>();
const requestGenerationByChannel = new Map<string, number>();
const statusMutationRevisionByDirectory = new Map<string, number>();
let gitRuntimeGeneration = 0;
@@ -276,6 +288,59 @@ const seedDirectoriesFromBranchCache = (runtimeKey: string): Map<string, Directo
return directories;
};
// ---------------------------------------------------------------------------
// Persisted nested-repo selection (per runtime, per root)
//
// Only the user's pick is cached — never the discovery result, which is cheap
// to re-scan and must not go stale. Seeding the selection lets the Git tab
// target the right repository on cold start before discovery completes; a
// selection whose repository vanished falls back to discovery in GitView.
// ---------------------------------------------------------------------------
const GIT_NESTED_REPO_SELECTION_KEY = 'oc.gitNestedRepoSelection.v1';
const MAX_NESTED_REPO_RUNTIMES = 8;
const MAX_NESTED_REPO_ROOTS = 50;
type NestedRepoSelectionEnvelope = {
version: 1;
runtimes: Record<string, { updatedAt: number; roots: Record<string, string> }>;
};
const emptyNestedRepoSelection = (): NestedRepoSelectionEnvelope => ({ version: 1, runtimes: {} });
const readNestedRepoSelectionEnvelope = (): NestedRepoSelectionEnvelope => {
try {
const storage = getDeferredSafeStorage();
const raw = storage.getItem(GIT_NESTED_REPO_SELECTION_KEY);
const parsed = raw ? JSON.parse(raw) as Partial<NestedRepoSelectionEnvelope> : emptyNestedRepoSelection();
return parsed?.version === 1 && parsed.runtimes && typeof parsed.runtimes === 'object'
? { version: 1, runtimes: parsed.runtimes }
: emptyNestedRepoSelection();
} catch {
return emptyNestedRepoSelection();
}
};
const writeCachedNestedRepoSelection = (runtimeKey: string, roots: Record<string, string>): void => {
try {
const envelope = readNestedRepoSelectionEnvelope();
const now = Date.now();
const boundedRoots = Object.fromEntries(
Object.entries(roots).slice(0, MAX_NESTED_REPO_ROOTS)
);
envelope.runtimes[runtimeKey] = { updatedAt: now, roots: boundedRoots };
envelope.runtimes = Object.fromEntries(
Object.entries(envelope.runtimes).sort(([, left], [, right]) => right.updatedAt - left.updatedAt).slice(0, MAX_NESTED_REPO_RUNTIMES),
);
getDeferredSafeStorage().setItem(GIT_NESTED_REPO_SELECTION_KEY, JSON.stringify(envelope));
} catch {
// quota / serialization — ignore; the selection still lives in memory
}
};
const seedNestedRepoSelection = (runtimeKey: string): Map<string, string> => {
const roots = readNestedRepoSelectionEnvelope().runtimes[runtimeKey]?.roots ?? {};
return new Map(Object.entries(roots).filter(([root, repository]) => root && repository));
};
// LRU eviction helper for diff cache
const evictDiffCacheIfNeeded = (
diffCache: Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>,
@@ -549,6 +614,8 @@ export const useGitStore = create<GitStore>()(
runtimeKey: initialGitRuntimeKey,
directories: seedDirectoriesFromBranchCache(initialGitRuntimeKey),
activeDirectory: null,
nestedReposByRoot: new Map(),
nestedRepoSelection: seedNestedRepoSelection(initialGitRuntimeKey),
resetForRuntimeSwitch: (runtimeKey) => {
gitRuntimeGeneration += 1;
@@ -557,9 +624,16 @@ export const useGitStore = create<GitStore>()(
statusMutationRevisionByDirectory.clear();
inFlightStatusFetches.clear();
inFlightEnsureAllByDirectory.clear();
inFlightNestedRepoDiscovery.clear();
inFlightDiffFetchesByDirectory.clear();
diffFetchGenerationByDirectory.clear();
set({ runtimeKey, directories: seedDirectoriesFromBranchCache(runtimeKey), activeDirectory: null });
set({
runtimeKey,
directories: seedDirectoriesFromBranchCache(runtimeKey),
activeDirectory: null,
nestedReposByRoot: new Map(),
nestedRepoSelection: seedNestedRepoSelection(runtimeKey),
});
},
setActiveDirectory: (directory) => {
@@ -1125,6 +1199,66 @@ export const useGitStore = create<GitStore>()(
set({ directories: newDirectories });
},
ensureNestedRepos: async (root, options = {}) => {
if (!root) return;
const { force = false } = options;
const runtimeKey = getRuntimeKey();
const key = runtimeDirectoryKey(runtimeKey, root);
const current = get().nestedReposByRoot.get(root);
if (!force && (current !== undefined || inFlightNestedRepoDiscovery.has(key))) {
return;
}
const existing = inFlightNestedRepoDiscovery.get(key);
if (existing) {
await existing;
return;
}
const discovery = (async () => {
let repositories: string[] | null = null;
try {
repositories = await listGitDirectories(root);
} catch (error) {
console.error('Failed to discover nested git repositories:', error);
repositories = null;
}
// A failed retry must not clobber an earlier successful discovery.
const previous = get().nestedReposByRoot.get(root);
const nextValue = repositories ?? previous ?? null;
const next = new Map(get().nestedReposByRoot);
next.set(root, nextValue);
set({ nestedReposByRoot: next });
})();
inFlightNestedRepoDiscovery.set(key, discovery);
try {
await discovery;
} finally {
if (inFlightNestedRepoDiscovery.get(key) === discovery) {
inFlightNestedRepoDiscovery.delete(key);
}
}
},
selectNestedRepo: (root, repository) => {
if (!root || !repository) return;
const next = new Map(get().nestedRepoSelection);
next.set(root, repository);
set({ nestedRepoSelection: next });
writeCachedNestedRepoSelection(getRuntimeKey(), Object.fromEntries(next));
},
clearNestedRepoSelection: (root) => {
if (!root) return;
if (!get().nestedRepoSelection.has(root)) return;
const next = new Map(get().nestedRepoSelection);
next.delete(root);
set({ nestedRepoSelection: next });
writeCachedNestedRepoSelection(getRuntimeKey(), Object.fromEntries(next));
},
ensureStatus: async (directory, git) => {
const dirState = get().directories.get(directory);
const now = Date.now();
@@ -1221,6 +1355,35 @@ export const useIsGitRepo = (directory: string | null) => {
});
};
// Resolves the directory the Git tab operates on. A root that is itself a git
// repository is always used directly; otherwise a per-root nested-repo
// selection (when present) becomes the effective directory.
export const useEffectiveGitDirectory = (root: string | null) => {
return useGitStore((state) => {
if (!root) return null;
if (state.directories.get(root)?.isGitRepo === true) {
return root;
}
return state.nestedRepoSelection.get(root) ?? root;
});
};
// `undefined` = discovery not run yet, `null` = discovery failed, otherwise
// the discovered nested repository paths (possibly empty).
export const useNestedRepos = (root: string | null) => {
return useGitStore((state) => {
if (!root) return undefined;
return state.nestedReposByRoot.get(root);
});
};
export const useNestedRepoSelection = (root: string | null) => {
return useGitStore((state) => {
if (!root) return null;
return state.nestedRepoSelection.get(root) ?? null;
});
};
export const useGitBranchLabel = (directory: string | null) => {
return useGitStore((state) => {
if (!directory) return null;
@@ -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' }]);
});
});