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 { BranchSelector } from './BranchSelector';
|
||||||
import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
|
import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
|
||||||
import { SyncActions } from './SyncActions';
|
import { SyncActions } from './SyncActions';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import type {
|
import type {
|
||||||
GitStatus,
|
GitStatus,
|
||||||
GitIdentityProfile,
|
GitIdentityProfile,
|
||||||
@@ -51,6 +57,13 @@ interface GitHeaderProps {
|
|||||||
pullRequest?: GitHubPullRequest | null;
|
pullRequest?: GitHubPullRequest | null;
|
||||||
prChecks?: GitHubChecksSummary | null;
|
prChecks?: GitHubChecksSummary | null;
|
||||||
onOpenPullRequest?: () => void;
|
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> = {
|
const IDENTITY_ICON_MAP: Record<string, IconName> = {
|
||||||
@@ -258,12 +271,23 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
pullRequest,
|
pullRequest,
|
||||||
prChecks,
|
prChecks,
|
||||||
onOpenPullRequest,
|
onOpenPullRequest,
|
||||||
|
repositoryOptions,
|
||||||
|
selectedRepository,
|
||||||
|
onSelectRepository,
|
||||||
|
repositoryRoot,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return null;
|
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 = (
|
const managementButtons = (
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{onOpenHistory || onOpenGraph || onOpenStashes || onOpenUpdateBranch ? (
|
{onOpenHistory || onOpenGraph || onOpenStashes || onOpenUpdateBranch ? (
|
||||||
@@ -410,7 +434,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
return (
|
return (
|
||||||
<header className="@container/git-header px-3 py-2 bg-transparent">
|
<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="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 ? (
|
{isWorktreeMode ? (
|
||||||
<WorktreeBranchDisplay
|
<WorktreeBranchDisplay
|
||||||
currentBranch={status.current}
|
currentBranch={status.current}
|
||||||
@@ -427,6 +451,34 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
remotes={remotes}
|
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>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
{identityControl}
|
{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> {
|
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus> {
|
||||||
const mode = options?.mode;
|
const mode = options?.mode;
|
||||||
const runtimeKey = getRuntimeKey();
|
const runtimeKey = getRuntimeKey();
|
||||||
|
|||||||
@@ -859,6 +859,10 @@ export const dict = {
|
|||||||
'gitView.empty.worktreeFeaturesUnavailable': 'Worktree-Funktionen sind in diesem Arbeitsbereichsmodus nicht verfügbar.',
|
'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.worktreeSetupDescription': 'Arbeitstruktur-Einrichtung wird abgeschlossen und Repository-Zustand wird vorbereitet.',
|
||||||
'gitView.empty.worktreeSetupInProgress': 'Worktree-Einrichtung läuft',
|
'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.failed': 'Worktree-Einrichtung fehlgeschlagen',
|
||||||
'worktree.bootstrap.toast.failedDescription': 'Die Worktree wurde erstellt, aber die Hintergrund-Einrichtung wurde nicht abgeschlossen.',
|
'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.',
|
'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.worktreeFeaturesUnavailable': 'Worktree features are unavailable in this workspace mode.',
|
||||||
'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.',
|
'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.',
|
||||||
'gitView.empty.worktreeSetupInProgress': 'Worktree setup in progress',
|
'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.failed': 'Worktree setup failed',
|
||||||
'worktree.bootstrap.toast.failedDescription': 'The worktree was created, but background setup did not finish.',
|
'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.',
|
'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.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.worktreeSetupDescription": "Finalizando la configuración de worktree y preparando el estado del repositorio.",
|
||||||
"gitView.empty.worktreeSetupInProgress": "Configuración de worktree en progreso",
|
"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.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.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.",
|
"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.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.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.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.empty': 'Aucun gitmoji trouvé',
|
||||||
'gitView.gitmoji.searchPlaceholder': 'Rechercher des gitmoji...',
|
'gitView.gitmoji.searchPlaceholder': 'Rechercher des gitmoji...',
|
||||||
'gitView.gitmoji.title': 'Insérer un gitmoji',
|
'gitView.gitmoji.title': 'Insérer un gitmoji',
|
||||||
|
|||||||
@@ -920,6 +920,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.empty.worktreeFeaturesUnavailable': 'このワークスペースモードではワークツリー機能は利用できません。',
|
'gitView.empty.worktreeFeaturesUnavailable': 'このワークスペースモードではワークツリー機能は利用できません。',
|
||||||
'gitView.empty.worktreeSetupDescription': 'ワークツリーのセットアップを完了し、リポジトリ状態を準備中。',
|
'gitView.empty.worktreeSetupDescription': 'ワークツリーのセットアップを完了し、リポジトリ状態を準備中。',
|
||||||
'gitView.empty.worktreeSetupInProgress': 'ワークツリーのセットアップ進行中',
|
'gitView.empty.worktreeSetupInProgress': 'ワークツリーのセットアップ進行中',
|
||||||
|
'gitView.empty.discoveringRepositories': 'Git リポジトリを検索しています...',
|
||||||
|
'gitView.empty.discoverFailed': 'Git リポジトリを検索できませんでした',
|
||||||
|
'gitView.empty.retryDiscovery': '再試行',
|
||||||
|
'gitView.empty.selectRepositoryPlaceholder': 'リポジトリを選択...',
|
||||||
'worktree.bootstrap.toast.failed': 'ワークツリーのセットアップに失敗しました',
|
'worktree.bootstrap.toast.failed': 'ワークツリーのセットアップに失敗しました',
|
||||||
'worktree.bootstrap.toast.failedDescription': 'ワークツリーは作成されましたが、バックグラウンドセットアップが完了しませんでした。',
|
'worktree.bootstrap.toast.failedDescription': 'ワークツリーは作成されましたが、バックグラウンドセットアップが完了しませんでした。',
|
||||||
'worktree.bootstrap.toast.timeoutDescription': 'ワークツリーは作成されましたが、バックグラウンドセットアップがタイムアウトしました。',
|
'worktree.bootstrap.toast.timeoutDescription': 'ワークツリーは作成されましたが、バックグラウンドセットアップがタイムアウトしました。',
|
||||||
|
|||||||
@@ -924,6 +924,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.empty.worktreeFeaturesUnavailable': '이 워크스페이스 모드에서는 워크트리 기능을 사용할 수 없습니다.',
|
'gitView.empty.worktreeFeaturesUnavailable': '이 워크스페이스 모드에서는 워크트리 기능을 사용할 수 없습니다.',
|
||||||
'gitView.empty.worktreeSetupDescription': '워크트리 설정을 마치고 레포지토리 상태를 준비하고 있습니다.',
|
'gitView.empty.worktreeSetupDescription': '워크트리 설정을 마치고 레포지토리 상태를 준비하고 있습니다.',
|
||||||
'gitView.empty.worktreeSetupInProgress': '워크트리 설정 중',
|
'gitView.empty.worktreeSetupInProgress': '워크트리 설정 중',
|
||||||
|
'gitView.empty.discoveringRepositories': 'Git 저장소를 찾는 중...',
|
||||||
|
'gitView.empty.discoverFailed': 'Git 저장소를 검색할 수 없습니다',
|
||||||
|
'gitView.empty.retryDiscovery': '다시 시도',
|
||||||
|
'gitView.empty.selectRepositoryPlaceholder': '저장소 선택...',
|
||||||
'worktree.bootstrap.toast.failed': '워크트리 설정 실패',
|
'worktree.bootstrap.toast.failed': '워크트리 설정 실패',
|
||||||
'worktree.bootstrap.toast.failedDescription': '워크트리는 생성되었지만 백그라운드 설정이 완료되지 않았습니다.',
|
'worktree.bootstrap.toast.failedDescription': '워크트리는 생성되었지만 백그라운드 설정이 완료되지 않았습니다.',
|
||||||
'worktree.bootstrap.toast.timeoutDescription': '워크트리는 생성되었지만 백그라운드 설정 시간이 초과되었습니다.',
|
'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.worktreeFeaturesUnavailable': 'Worktree features are unavailable in this workspace mode.',
|
||||||
'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.',
|
'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.',
|
||||||
'gitView.empty.worktreeSetupInProgress': 'Worktree setup in progress',
|
'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.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.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.',
|
'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.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.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.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.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.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.",
|
"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.worktreeFeaturesUnavailable": "У цьому режимі робочої області функції worktree недоступні.",
|
||||||
"gitView.empty.worktreeSetupDescription": "Завершення налаштування worktree та підготовка стану сховища.",
|
"gitView.empty.worktreeSetupDescription": "Завершення налаштування worktree та підготовка стану сховища.",
|
||||||
"gitView.empty.worktreeSetupInProgress": "Виконується налаштування 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.failed": "Не вдалося налаштувати worktree",
|
||||||
"worktree.bootstrap.toast.failedDescription": "Worktree створено, але фонове налаштування не завершилося.",
|
"worktree.bootstrap.toast.failedDescription": "Worktree створено, але фонове налаштування не завершилося.",
|
||||||
"worktree.bootstrap.toast.timeoutDescription": "Worktree створено, але час очікування фонового налаштування минув.",
|
"worktree.bootstrap.toast.timeoutDescription": "Worktree створено, але час очікування фонового налаштування минув.",
|
||||||
|
|||||||
@@ -924,6 +924,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.empty.worktreeFeaturesUnavailable': '当前工作区模式下,工作树功能不可用。',
|
'gitView.empty.worktreeFeaturesUnavailable': '当前工作区模式下,工作树功能不可用。',
|
||||||
'gitView.empty.worktreeSetupDescription': '正在完成工作树设置并准备仓库状态。',
|
'gitView.empty.worktreeSetupDescription': '正在完成工作树设置并准备仓库状态。',
|
||||||
'gitView.empty.worktreeSetupInProgress': '工作树设置进行中',
|
'gitView.empty.worktreeSetupInProgress': '工作树设置进行中',
|
||||||
|
'gitView.empty.discoveringRepositories': '正在查找 Git 仓库...',
|
||||||
|
'gitView.empty.discoverFailed': '无法扫描 Git 仓库',
|
||||||
|
'gitView.empty.retryDiscovery': '重试',
|
||||||
|
'gitView.empty.selectRepositoryPlaceholder': '选择仓库...',
|
||||||
'worktree.bootstrap.toast.failed': '工作树设置失败',
|
'worktree.bootstrap.toast.failed': '工作树设置失败',
|
||||||
'worktree.bootstrap.toast.failedDescription': '工作树已创建,但后台设置未完成。',
|
'worktree.bootstrap.toast.failedDescription': '工作树已创建,但后台设置未完成。',
|
||||||
'worktree.bootstrap.toast.timeoutDescription': '工作树已创建,但后台设置超时。',
|
'worktree.bootstrap.toast.timeoutDescription': '工作树已创建,但后台设置超时。',
|
||||||
|
|||||||
@@ -936,6 +936,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.empty.worktreeFeaturesUnavailable': '目前工作區模式下,worktree 功能無法使用。',
|
'gitView.empty.worktreeFeaturesUnavailable': '目前工作區模式下,worktree 功能無法使用。',
|
||||||
'gitView.empty.worktreeSetupDescription': '正在完成 worktree 設定並準備儲存庫狀態。',
|
'gitView.empty.worktreeSetupDescription': '正在完成 worktree 設定並準備儲存庫狀態。',
|
||||||
'gitView.empty.worktreeSetupInProgress': '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.failed': 'worktree 設定失敗',
|
||||||
'worktree.bootstrap.toast.failedDescription': 'worktree 已建立,但背景設定未完成。',
|
'worktree.bootstrap.toast.failedDescription': 'worktree 已建立,但背景設定未完成。',
|
||||||
'worktree.bootstrap.toast.timeoutDescription': 'worktree 已建立,但背景設定逾時。',
|
'worktree.bootstrap.toast.timeoutDescription': 'worktree 已建立,但背景設定逾時。',
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ Important properties:
|
|||||||
- loading state is per-directory, not global
|
- loading state is per-directory, not global
|
||||||
- `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers
|
- `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers
|
||||||
- in-flight dedupe exists for status and `ensureAll()`
|
- 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
|
- 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, 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
|
- 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);
|
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';
|
} from '@/lib/api/types';
|
||||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||||
|
import { listGitDirectories } from '@/lib/gitApiHttp';
|
||||||
|
|
||||||
const LOG_STALE_THRESHOLD = 10000;
|
const LOG_STALE_THRESHOLD = 10000;
|
||||||
const REPO_CHECK_STALE_THRESHOLD = 60_000;
|
const REPO_CHECK_STALE_THRESHOLD = 60_000;
|
||||||
@@ -77,6 +78,16 @@ interface GitStore {
|
|||||||
|
|
||||||
setLogMaxCount: (directory: string, maxCount: number) => void;
|
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>;
|
refresh: (git: GitAPI, options?: { force?: boolean }) => Promise<void>;
|
||||||
resetForRuntimeSwitch: (runtimeKey: string) => void;
|
resetForRuntimeSwitch: (runtimeKey: string) => void;
|
||||||
}
|
}
|
||||||
@@ -101,6 +112,7 @@ const inFlightDiffFetchesByDirectory = new Map<string, Set<string>>();
|
|||||||
const diffFetchGenerationByDirectory = new Map<string, number>();
|
const diffFetchGenerationByDirectory = new Map<string, number>();
|
||||||
const inFlightStatusFetches = new Map<string, Promise<boolean>>();
|
const inFlightStatusFetches = new Map<string, Promise<boolean>>();
|
||||||
const inFlightEnsureAllByDirectory = new Map<string, Promise<void>>();
|
const inFlightEnsureAllByDirectory = new Map<string, Promise<void>>();
|
||||||
|
const inFlightNestedRepoDiscovery = new Map<string, Promise<void>>();
|
||||||
const requestGenerationByChannel = new Map<string, number>();
|
const requestGenerationByChannel = new Map<string, number>();
|
||||||
const statusMutationRevisionByDirectory = new Map<string, number>();
|
const statusMutationRevisionByDirectory = new Map<string, number>();
|
||||||
let gitRuntimeGeneration = 0;
|
let gitRuntimeGeneration = 0;
|
||||||
@@ -276,6 +288,59 @@ const seedDirectoriesFromBranchCache = (runtimeKey: string): Map<string, Directo
|
|||||||
return directories;
|
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
|
// LRU eviction helper for diff cache
|
||||||
const evictDiffCacheIfNeeded = (
|
const evictDiffCacheIfNeeded = (
|
||||||
diffCache: Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>,
|
diffCache: Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>,
|
||||||
@@ -549,6 +614,8 @@ export const useGitStore = create<GitStore>()(
|
|||||||
runtimeKey: initialGitRuntimeKey,
|
runtimeKey: initialGitRuntimeKey,
|
||||||
directories: seedDirectoriesFromBranchCache(initialGitRuntimeKey),
|
directories: seedDirectoriesFromBranchCache(initialGitRuntimeKey),
|
||||||
activeDirectory: null,
|
activeDirectory: null,
|
||||||
|
nestedReposByRoot: new Map(),
|
||||||
|
nestedRepoSelection: seedNestedRepoSelection(initialGitRuntimeKey),
|
||||||
|
|
||||||
resetForRuntimeSwitch: (runtimeKey) => {
|
resetForRuntimeSwitch: (runtimeKey) => {
|
||||||
gitRuntimeGeneration += 1;
|
gitRuntimeGeneration += 1;
|
||||||
@@ -557,9 +624,16 @@ export const useGitStore = create<GitStore>()(
|
|||||||
statusMutationRevisionByDirectory.clear();
|
statusMutationRevisionByDirectory.clear();
|
||||||
inFlightStatusFetches.clear();
|
inFlightStatusFetches.clear();
|
||||||
inFlightEnsureAllByDirectory.clear();
|
inFlightEnsureAllByDirectory.clear();
|
||||||
|
inFlightNestedRepoDiscovery.clear();
|
||||||
inFlightDiffFetchesByDirectory.clear();
|
inFlightDiffFetchesByDirectory.clear();
|
||||||
diffFetchGenerationByDirectory.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) => {
|
setActiveDirectory: (directory) => {
|
||||||
@@ -1125,6 +1199,66 @@ export const useGitStore = create<GitStore>()(
|
|||||||
set({ directories: newDirectories });
|
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) => {
|
ensureStatus: async (directory, git) => {
|
||||||
const dirState = get().directories.get(directory);
|
const dirState = get().directories.get(directory);
|
||||||
const now = Date.now();
|
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) => {
|
export const useGitBranchLabel = (directory: string | null) => {
|
||||||
return useGitStore((state) => {
|
return useGitStore((state) => {
|
||||||
if (!directory) return null;
|
if (!directory) return null;
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
|
|||||||
- `POST /api/fs/exec`
|
- `POST /api/fs/exec`
|
||||||
- `GET /api/fs/exec/:jobId`
|
- `GET /api/fs/exec/:jobId`
|
||||||
- `GET /api/fs/list`
|
- `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.
|
- Owns exec job queue state (`execJobs`) and lifecycle/TTL pruning.
|
||||||
- Enforces workspace boundary checks with active project + worktree fallback support.
|
- Enforces workspace boundary checks with active project + worktree fallback support.
|
||||||
- `createFsSearchRuntime({ fsPromises, path, spawn, resolveGitBinaryForSpawn })` from `search.js`
|
- `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 deriveCloneDirectoryName = (remoteUrl) => {
|
||||||
const remote = typeof remoteUrl === 'string' ? remoteUrl.trim() : '';
|
const remote = typeof remoteUrl === 'string' ? remoteUrl.trim() : '';
|
||||||
if (!remote) return '';
|
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' });
|
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