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:
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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -751,6 +751,10 @@ export const dict = {
|
||||
'gitView.empty.worktreeFeaturesUnavailable': 'Les fonctionnalités Worktree ne sont pas disponibles dans ce mode d’espace 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 d’analyser 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',
|
||||
|
||||
@@ -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': 'ワークツリーは作成されましたが、バックグラウンドセットアップがタイムアウトしました。',
|
||||
|
||||
@@ -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': '워크트리는 생성되었지만 백그라운드 설정 시간이 초과되었습니다.',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 已建立,但背景設定逾時。',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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