feat(git): support nested git repositories across the git surfaces (#2767)

feat(git): support nested git repositories across the git surfaces
This commit is contained in:
Bohdan Triapitsyn
2026-08-30 10:23:37 +03:00
committed by GitHub
30 changed files with 1586 additions and 246 deletions
+36 -5
View File
@@ -10,6 +10,7 @@ import { SyncActions } from '@/components/views/git/SyncActions';
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import type { GitStatus } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
import { generateCommitMessage, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from '@/lib/gitApi';
@@ -21,6 +22,8 @@ import {
useIsGitRepo,
useGitLoadingStatus,
} from '@/stores/useGitStore';
import { NestedRepoResolutionStates } from '@/components/views/git/NestedRepoResolutionStates';
import { NestedRepoPicker } from '@/components/views/git/NestedRepoPicker';
import { getRuntimeKey } from '@/lib/runtime-switch';
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
@@ -56,12 +59,18 @@ type MobileChangesSurfaceProps = {
export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onClose, initialDiffPath, initialDiffStaged = false }) => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
const currentDirectory = normalizePath(useEffectiveDirectory() ?? null);
const rootDirectory = normalizePath(useEffectiveDirectory() ?? null);
// When the root is not itself a repository, changes come from the resolved
// nested repository instead.
const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null);
const currentDirectory = gitDirectory ?? rootDirectory;
const status = useGitStatus(currentDirectory || null);
const isGitRepo = useIsGitRepo(currentDirectory || null);
const isLoadingStatus = useGitLoadingStatus(currentDirectory || null);
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const ensureAll = useGitStore((state) => state.ensureAll);
const ensureNestedRepos = useGitStore((state) => state.ensureNestedRepos);
const selectNestedRepo = useGitStore((state) => state.selectNestedRepo);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const fetchBranches = useGitStore((state) => state.fetchBranches);
const prefetchDiffs = useGitStore((state) => state.prefetchDiffs);
@@ -465,6 +474,16 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
{status?.current || currentDirectory || ''}
</p>
</div>
{rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0 ? (
<NestedRepoPicker
repositories={nestedRepos}
selectedRepository={gitDirectory ?? null}
onSelectRepository={(repository) => {
if (rootDirectory) selectNestedRepo(rootDirectory, repository);
}}
repositoryRoot={rootDirectory ?? undefined}
/>
) : null}
</header>
<div className="min-h-0 flex-1">{state}</div>
</div>
@@ -474,12 +493,24 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
return renderListState(<MobileChangesState message={t('gitView.empty.selectSessionOrDirectory')} />);
}
if (isLoadingStatus && isGitRepo === null) {
return renderListState(<MobileChangesState loading message={t('gitView.loading.checkingRepository')} />);
// Non-repo root: surface nested-repository resolution while the operating
// directory has not proven to be a repository (discovering, failed,
// unsupported, none found, or settling on the auto-selected one).
if (rootIsGitRepo === false && isGitRepo !== true) {
return renderListState(
<NestedRepoResolutionStates
rootIsGitRepo={rootIsGitRepo}
resolvedIsGitRepo={isGitRepo}
nestedRepos={nestedRepos}
onRetryDiscovery={() => {
if (rootDirectory) void ensureNestedRepos(rootDirectory, { force: true });
}}
/>
);
}
if (isGitRepo === false) {
return renderListState(<MobileChangesState icon message={t('gitView.empty.notGitRepository')} description={t('gitView.empty.notGitRepositoryDescription')} />);
if (isLoadingStatus && isGitRepo === null) {
return renderListState(<MobileChangesState loading message={t('gitView.loading.checkingRepository')} />);
}
if (route.type === 'diff') {
@@ -1291,7 +1291,7 @@ export const ContextPanel: React.FC = () => {
{hasWalkthroughTab ? (
<div className={cn('absolute inset-0', activeTab?.mode === 'walkthrough' ? 'block' : 'hidden')}>
<React.Suspense fallback={null}>
<WalkthroughView directory={effectiveDirectory} />
<WalkthroughView directory={effectiveDirectory} visible={activeTab?.mode === 'walkthrough'} />
</React.Suspense>
</div>
) : null}
+21 -4
View File
@@ -2,6 +2,8 @@ import React from 'react';
import { useUIStore } from '@/stores/useUIStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import { NestedRepoPicker } from '@/components/views/git/NestedRepoPicker';
import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore';
import { useGitBaseBranchStore, gitBaseBranchEntryKey } from '@/stores/useGitBaseBranchStore';
import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope';
@@ -997,7 +999,11 @@ export const DiffView: React.FC<DiffViewProps> = ({
}) => {
const { t } = useI18n();
const { git, files } = useRuntimeAPIs();
const effectiveDirectory = useEffectiveDirectory();
const rootDirectory = useEffectiveDirectory();
// Diffs belong to the repository being diffed: when the root is not
// itself a repository, operate on the resolved nested repository instead.
const { rootIsGitRepo, gitDirectory: nestedGitDirectory, nestedRepos: nestedRepoOptions } = useNestedGitDirectory(rootDirectory ?? null);
const effectiveDirectory = nestedGitDirectory ?? rootDirectory;
const openContextSurface = useUIStore((state) => state.openContextSurface);
const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource);
const { screenWidth, isMobile } = useDeviceInfo();
@@ -1007,6 +1013,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const isLoadingStatus = useGitLoadingStatus(effectiveDirectory ?? null);
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const ensureStatus = useGitStore((state) => state.ensureStatus);
const selectNestedRepo = useGitStore((state) => state.selectNestedRepo);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const fetchBranches = useGitStore((state) => state.fetchBranches);
const clearDiffCache = useGitStore((state) => state.clearDiffCache);
@@ -1038,7 +1045,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const setDiffWrapLines = useUIStore((state) => state.setDiffWrapLines);
const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionMessages = useSessionMessages(currentSessionId ?? '', effectiveDirectory ?? undefined);
const sessionMessages = useSessionMessages(currentSessionId ?? '', rootDirectory ?? undefined);
const diffWrapLines = diffWrapLinesStore;
const forcedStaged = activeDiffScope === 'staged' ? true : activeDiffScope === 'working' ? false : null;
const activeDiffStaged = forcedStaged ?? displayFileStaged;
@@ -1645,7 +1652,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const handleStartReviewFlow = React.useCallback(async (execution: ReviewFlowExecution) => {
if (!currentSessionId) return;
const directory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || effectiveDirectory || '';
const directory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || rootDirectory || '';
if (!directory) {
toast.error(t('diffView.reviewDialog.toast.noSessionDirectory'));
return;
@@ -1671,7 +1678,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
} finally {
setReviewFlowSubmitting(false);
}
}, [currentSessionId, effectiveDirectory, t]);
}, [currentSessionId, rootDirectory, t]);
const scrollToFile = React.useCallback((path: string): boolean => {
const node = fileSectionRefs.current.get(path);
@@ -2070,6 +2077,16 @@ export const DiffView: React.FC<DiffViewProps> = ({
return (
<div className="flex h-full flex-col overflow-hidden bg-background">
<div className="@container/diff-toolbar flex min-w-0 items-center gap-2 px-3 py-2 bg-background">
{rootIsGitRepo === false && Array.isArray(nestedRepoOptions) && nestedRepoOptions.length > 0 ? (
<NestedRepoPicker
repositories={nestedRepoOptions}
selectedRepository={nestedGitDirectory ?? null}
onSelectRepository={(repository) => {
if (rootDirectory) selectNestedRepo(rootDirectory, repository);
}}
repositoryRoot={rootDirectory ?? undefined}
/>
) : null}
{!isMobile && (
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' || activeDiffScope === 'branch' ? (
<ChangeScopeSelector
File diff suppressed because it is too large Load Diff
@@ -2,10 +2,11 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import { useDetectedWorktreeMetadata } from '@/hooks/useDetectedWorktreeRoot';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { useGitStatus, useGitBranches, useGitStore } from '@/stores/useGitStore';
import { useGitStatus, useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useShallow } from 'zustand/react/shallow';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { getRuntimeKey } from '@/lib/runtime-switch';
@@ -14,6 +15,8 @@ import { useI18n } from '@/lib/i18n';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { PullRequestSection } from './git/PullRequestSection';
import { NestedRepoResolutionStates } from './git/NestedRepoResolutionStates';
import { NestedRepoPicker } from './git/NestedRepoPicker';
import { deriveBaseBranch } from './git/baseBranch';
const normalizePath = (value?: string | null): string =>
@@ -36,9 +39,17 @@ export const PullRequestView: React.FC = () => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
const currentDirectory = useEffectiveDirectory();
const status = useGitStatus(currentDirectory ?? null);
const branches = useGitBranches(currentDirectory ?? null);
const { ensureAll } = useGitStore(useShallow((state) => ({ ensureAll: state.ensureAll })));
// When the root is not itself a repository, the pull-request workflow
// operates on the resolved nested repository instead.
const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(currentDirectory ?? null);
const status = useGitStatus(gitDirectory ?? null);
const branches = useGitBranches(gitDirectory ?? null);
const isGitRepo = useIsGitRepo(gitDirectory ?? null);
const { ensureAll, ensureNestedRepos, selectNestedRepo } = useGitStore(useShallow((state) => ({
ensureAll: state.ensureAll,
ensureNestedRepos: state.ensureNestedRepos,
selectNestedRepo: state.selectNestedRepo,
})));
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
@@ -89,11 +100,11 @@ export const PullRequestView: React.FC = () => {
const worktreeMetadata = useDetectedWorktreeMetadata(currentDirectory, storeWorktreeMetadata, status?.current ?? undefined);
React.useEffect(() => {
if (!currentDirectory || !git) {
if (!gitDirectory || !git) {
return;
}
void ensureAll(currentDirectory, git);
}, [currentDirectory, ensureAll, git]);
void ensureAll(gitDirectory, git);
}, [gitDirectory, ensureAll, git]);
const [rootBranchHint, setRootBranchHint] = React.useState<string | null>(null);
React.useEffect(() => {
@@ -122,52 +133,52 @@ export const PullRequestView: React.FC = () => {
}, [authoritativeProjectRoot, worktreeMetadata?.projectDirectory]);
const [remotes, setRemotes] = React.useState<GitRemote[]>(() =>
(currentDirectory ? remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) : undefined) ?? []
(gitDirectory ? remotesCacheByDirectory.get(remoteCacheKey(gitDirectory)) : undefined) ?? []
);
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(() =>
(currentDirectory ? remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) : undefined) ?? null
(gitDirectory ? remoteUrlCacheByDirectory.get(remoteCacheKey(gitDirectory)) : undefined) ?? null
);
React.useEffect(() => {
if (!currentDirectory || !git?.getRemotes) {
if (!gitDirectory || !git?.getRemotes) {
setRemotes([]);
return;
}
setRemotes(remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? []);
setRemotes(remotesCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? []);
let cancelled = false;
void git.getRemotes(currentDirectory)
void git.getRemotes(gitDirectory)
.then((remoteList) => {
if (cancelled) return;
remotesCacheByDirectory.set(remoteCacheKey(currentDirectory), remoteList ?? []);
remotesCacheByDirectory.set(remoteCacheKey(gitDirectory), remoteList ?? []);
setRemotes(remoteList ?? []);
})
.catch(() => { if (!cancelled) setRemotes(remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? []); });
.catch(() => { if (!cancelled) setRemotes(remotesCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? []); });
return () => {
cancelled = true;
};
}, [currentDirectory, git]);
}, [gitDirectory, git]);
React.useEffect(() => {
if (!currentDirectory || !git?.getRemoteUrl) {
if (!gitDirectory || !git?.getRemoteUrl) {
setRemoteUrl(null);
return;
}
setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? null);
setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? null);
let cancelled = false;
void git.getRemoteUrl(currentDirectory)
void git.getRemoteUrl(gitDirectory)
.then((url) => {
if (cancelled) return;
remoteUrlCacheByDirectory.set(remoteCacheKey(currentDirectory), url);
remoteUrlCacheByDirectory.set(remoteCacheKey(gitDirectory), url);
setRemoteUrl(url);
})
.catch(() => { if (!cancelled) setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? null); });
.catch(() => { if (!cancelled) setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? null); });
return () => {
cancelled = true;
};
}, [currentDirectory, git]);
}, [gitDirectory, git]);
const localBranches = React.useMemo(() => {
if (!branches?.all) return [];
@@ -240,7 +251,7 @@ export const PullRequestView: React.FC = () => {
worktreeMetadata?.createdFromBranch,
]);
if (!currentDirectory || !currentBranch) {
if (!currentDirectory) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<Icon name="git-pull-request" className="h-12 w-12 text-muted-foreground/50" />
@@ -250,22 +261,67 @@ export const PullRequestView: React.FC = () => {
);
}
return (
<ScrollableOverlay
as={ScrollShadow}
outerClassName="h-full min-h-0"
className="px-4 py-3"
disableHorizontal
preventOverscroll
>
<PullRequestSection
directory={currentDirectory}
branch={currentBranch}
baseBranch={baseBranch}
trackingBranch={status?.tracking ?? undefined}
remotes={remotes}
remoteBranches={remoteBranches}
// Non-repo root: surface nested-repository resolution while the operating
// directory has not proven to be a repository (discovering, failed,
// unsupported, none found, or settling on the auto-selected one).
if (rootIsGitRepo === false && isGitRepo !== true) {
return (
<NestedRepoResolutionStates
rootIsGitRepo={rootIsGitRepo}
resolvedIsGitRepo={isGitRepo}
nestedRepos={nestedRepos}
onRetryDiscovery={() => {
void ensureNestedRepos(currentDirectory, { force: true });
}}
/>
</ScrollableOverlay>
);
}
if (!currentBranch) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<Icon name="git-pull-request" className="h-12 w-12 text-muted-foreground/50" />
<div className="typography-ui-header text-foreground">{t('gitView.pullRequest.title')}</div>
<div className="max-w-sm typography-micro text-muted-foreground">{t('gitView.pullRequest.createHint')}</div>
</div>
);
}
// Repository switcher for non-repo roots with discovered nested
// repositories; the pick is shared per root across git surfaces.
const showRepositoryPicker =
rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0;
return (
<div className="flex h-full min-h-0 flex-col">
{showRepositoryPicker ? (
<div className="flex shrink-0 items-center border-b border-border/60 px-4 py-2">
<NestedRepoPicker
repositories={nestedRepos}
selectedRepository={gitDirectory ?? null}
onSelectRepository={(repository) => {
if (currentDirectory) selectNestedRepo(currentDirectory, repository);
}}
repositoryRoot={currentDirectory ?? undefined}
/>
</div>
) : null}
<ScrollableOverlay
as={ScrollShadow}
outerClassName="h-full min-h-0 flex-1"
className="px-4 py-3"
disableHorizontal
preventOverscroll
>
<PullRequestSection
directory={gitDirectory ?? currentDirectory}
branch={currentBranch}
baseBranch={baseBranch}
trackingBranch={status?.tracking ?? undefined}
remotes={remotes}
remoteBranches={remoteBranches}
/>
</ScrollableOverlay>
</div>
);
};
@@ -12,6 +12,7 @@ import type { IconName } from "@/components/icon/icons";
import { BranchSelector } from './BranchSelector';
import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
import { SyncActions } from './SyncActions';
import { NestedRepoPicker } from './NestedRepoPicker';
import type {
GitStatus,
GitIdentityProfile,
@@ -51,6 +52,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 +266,18 @@ 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 managementButtons = (
<div className="flex items-center gap-1 shrink-0">
{onOpenHistory || onOpenGraph || onOpenStashes || onOpenUpdateBranch ? (
@@ -410,7 +424,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 +441,14 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
remotes={remotes}
/>
)}
{repositoryOptionsForPicker.length > 0 && onSelectRepository ? (
<NestedRepoPicker
repositories={repositoryOptionsForPicker}
selectedRepository={selectedRepository ?? null}
onSelectRepository={onSelectRepository}
repositoryRoot={repositoryRoot}
/>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{identityControl}
@@ -0,0 +1,67 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
type NestedRepoPickerProps = {
/** Discovered repository paths under the project root. */
repositories: string[];
/** Currently selected repository path (the operating directory). */
selectedRepository: string | null;
onSelectRepository: (repository: string) => void;
/** Root the repository paths are relative to for display labels. */
repositoryRoot?: string;
};
/**
* Repository switcher shown on git surfaces when a project root is not itself
* a git repository but nested repositories were discovered under it.
*/
export const NestedRepoPicker: React.FC<NestedRepoPickerProps> = ({
repositories,
selectedRepository,
onSelectRepository,
repositoryRoot,
}) => {
const { t } = useI18n();
const relativePath = (repository: string): string => {
const rootPrefix = `${repositoryRoot ?? ''}/`;
return repository.startsWith(rootPrefix) ? repository.slice(rootPrefix.length) : repository;
};
return (
<Select
value={selectedRepository ?? undefined}
onValueChange={(value) => {
if (value) {
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">
{selectedRepository ? relativePath(selectedRepository) : ''}
</span>
</SelectTrigger>
<SelectContent align="start">
{repositories.map((repository) => (
<SelectItem key={repository} value={repository}>
<span className="truncate">{relativePath(repository)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
};
@@ -0,0 +1,69 @@
import React from 'react';
import { describe, expect, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '@/lib/i18n';
import { NestedRepoResolutionStates } from './NestedRepoResolutionStates';
const render = (props: React.ComponentProps<typeof NestedRepoResolutionStates>): string =>
renderToStaticMarkup(
<I18nProvider>
<NestedRepoResolutionStates {...props} />
</I18nProvider>,
);
const baseProps = {
onRetryDiscovery: () => {},
};
describe('NestedRepoResolutionStates', () => {
test('renders nothing while the root has not probed as a non-repository', () => {
for (const rootIsGitRepo of [null, true] as const) {
const markup = render({ ...baseProps, rootIsGitRepo, resolvedIsGitRepo: null, nestedRepos: undefined });
expect(markup).toBe('');
}
});
test('renders nothing once the operating directory resolved as a repository', () => {
const markup = render({
...baseProps,
rootIsGitRepo: false,
resolvedIsGitRepo: true,
nestedRepos: ['/root/one'],
});
expect(markup).toBe('');
});
test('shows the discovering state before discovery has run', () => {
const markup = render({ ...baseProps, rootIsGitRepo: false, resolvedIsGitRepo: null, nestedRepos: undefined });
expect(markup).toContain('Looking for Git repositories...');
});
test('shows the failure state with a retry when discovery failed', () => {
const markup = render({ ...baseProps, rootIsGitRepo: false, resolvedIsGitRepo: null, nestedRepos: null });
expect(markup).toContain('Could not scan for Git repositories');
expect(markup).toContain('Retry');
});
test('shows the plain not-a-repository state with no retry when unsupported', () => {
const markup = render({ ...baseProps, rootIsGitRepo: false, resolvedIsGitRepo: null, nestedRepos: 'unsupported' });
expect(markup).toContain('This directory is not a Git repository');
expect(markup).not.toContain('Retry');
});
test('treats an empty discovery like the not-a-repository state', () => {
const markup = render({ ...baseProps, rootIsGitRepo: false, resolvedIsGitRepo: null, nestedRepos: [] });
expect(markup).toContain('This directory is not a Git repository');
});
test('holds a checking state while repositories are found but unresolved', () => {
const markup = render({
...baseProps,
rootIsGitRepo: false,
resolvedIsGitRepo: null,
nestedRepos: ['/root/one', '/root/two'],
});
expect(markup).toContain('Checking repository...');
});
});
@@ -0,0 +1,96 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import type { NestedRepoDiscovery } from '@/stores/useGitStore';
type NestedRepoResolutionStatesProps = {
/** Probe of the project root: `false` means nested resolution applies. */
rootIsGitRepo: boolean | null;
/**
* Probe of the directory the consumer operates on (root or selected nested
* repository). `true` means resolution succeeded and the consumer should
* render its own content.
*/
resolvedIsGitRepo: boolean | null;
/** Discovery outcome for the root (`undefined` = not run yet). */
nestedRepos: NestedRepoDiscovery | undefined;
onRetryDiscovery: () => void;
/** Optional extra line under the not-a-repository description. */
emptyStateFooter?: React.ReactNode;
};
/**
* Shared empty/loading states for git surfaces while nested-repository
* resolution is pending, failed, or impossible. Renders null once resolution
* has finished — either the root is a repository or the operating directory
* probed as one — so the consumer can proceed into its own content.
*
* A runtime without the discovery route (VS Code) reports "unsupported": the
* honest state there is the plain not-a-repository empty state, without a
* retry that can never succeed.
*/
export const NestedRepoResolutionStates: React.FC<NestedRepoResolutionStatesProps> = ({
rootIsGitRepo,
resolvedIsGitRepo,
nestedRepos,
onRetryDiscovery,
emptyStateFooter,
}) => {
const { t } = useI18n();
if (rootIsGitRepo !== false) return null;
if (resolvedIsGitRepo === true) return null;
if (nestedRepos === undefined || nestedRepos === null) {
return (
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
<Icon name="loader-4" className="mb-3 size-6 animate-spin text-muted-foreground" />
<p className="typography-ui-label font-semibold text-foreground">
{nestedRepos === null
? t('gitView.empty.discoverFailed')
: t('gitView.empty.discoveringRepositories')}
</p>
{nestedRepos === null ? (
<Button
type="button"
variant="outline"
size="sm"
className="mt-3 gap-1.5"
onClick={onRetryDiscovery}
>
<Icon name="refresh" className="size-4" />
{t('gitView.empty.retryDiscovery')}
</Button>
) : null}
</div>
);
}
if (nestedRepos === 'unsupported' || nestedRepos.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
<Icon name="git-branch" className="mb-3 size-6 text-muted-foreground" />
<p className="typography-ui-label font-semibold text-foreground">
{t('gitView.empty.notGitRepository')}
</p>
<p className="typography-meta mt-1 text-muted-foreground">
{t('gitView.empty.notGitRepositoryDescription')}
</p>
{emptyStateFooter}
</div>
);
}
// Repositories were found and one is about to be auto-selected (or the
// selected repository is still probing) — hold a brief loading state.
return (
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
<Icon name="loader-4" className="mb-3 size-6 animate-spin text-muted-foreground" />
<p className="typography-ui-label font-semibold text-foreground">
{t('gitView.loading.checkingRepository')}
</p>
</div>
);
};
@@ -19,7 +19,7 @@ import { ModelSelector } from '@/components/sections/agents/ModelSelector';
import { deriveBaseBranch, hasResolvableBaseBranch } from '@/components/views/git/baseBranch';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { useConfigStore } from '@/stores/useConfigStore';
import { useGitBranches, useGitStatus, useGitStore } from '@/stores/useGitStore';
import { useGitBranches, useGitStatus, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import {
getFreshestPrStatusForBranch,
@@ -27,6 +27,7 @@ import {
useGitHubPrStatusStore,
} from '@/stores/useGitHubPrStatusStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import { useUIStore } from '@/stores/useUIStore';
import { useWalkthroughStore } from '@/stores/useWalkthroughStore';
import { cn } from '@/lib/utils';
@@ -36,9 +37,17 @@ import { WalkthroughStages } from './WalkthroughStages';
import { useWalkthroughStageProgress } from './useWalkthroughStageProgress';
import { WalkthroughStream } from './WalkthroughStream';
import { WalkthroughToc } from './WalkthroughToc';
import { NestedRepoResolutionStates } from '@/components/views/git/NestedRepoResolutionStates';
import { NestedRepoPicker } from '@/components/views/git/NestedRepoPicker';
interface WalkthroughViewProps {
directory: string;
/**
* The context panel keeps this view mounted but hidden via CSS, so work
* that should only run for a visible consumer has to be told. Defaults to
* true for mounts that have no visibility signal.
*/
visible?: boolean;
}
const SCOPES: WalkthroughWorkingTreeScope[] = ['all', 'staged', 'working'];
@@ -73,11 +82,17 @@ const TOC_MAX_FRACTION = 0.5;
// pickers, 32px action, 36px arrows) read as misalignment, not hierarchy.
const HEADER_COMPACT_WIDTH = 680;
export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
export const WalkthroughView = ({ directory: rootDirectory, visible = true }: WalkthroughViewProps) => {
const { t, locale, locales, label } = useI18n();
const rootRef = useRef<HTMLDivElement | null>(null);
const [panelWidth, setPanelWidth] = useState(0);
// The walkthrough documents one repository. When the root is not itself a
// repository, that is the resolved nested repository; everything below keys
// off `directory`.
const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null, { enabled: visible });
const directory = gitDirectory ?? rootDirectory;
// Panel width, not viewport width: this surface is resizable independently of
// the window.
useEffect(() => {
@@ -484,9 +499,38 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
[activeLanguage, directory, generate, generateDisabled, source]
);
const isGitRepo = useIsGitRepo(gitDirectory || null);
const ensureNestedRepos = useGitStore((state) => state.ensureNestedRepos);
const selectNestedRepo = useGitStore((state) => state.selectNestedRepo);
// Non-repo root: surface nested-repository resolution while the operating
// directory has not proven to be a repository (discovering, failed,
// unsupported, none found, or settling on the auto-selected one).
if (rootIsGitRepo === false && isGitRepo !== true) {
return (
<NestedRepoResolutionStates
rootIsGitRepo={rootIsGitRepo}
resolvedIsGitRepo={isGitRepo}
nestedRepos={nestedRepos}
onRetryDiscovery={() => {
if (rootDirectory) void ensureNestedRepos(rootDirectory, { force: true });
}}
/>
);
}
return (
<div ref={rootRef} className="flex h-full min-h-0 flex-col">
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
{rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0 ? (
<NestedRepoPicker
repositories={nestedRepos}
selectedRepository={gitDirectory ?? null}
onSelectRepository={(repository) => {
if (rootDirectory) selectNestedRepo(rootDirectory, repository);
}}
repositoryRoot={rootDirectory ?? undefined}
/>
) : null}
<DropdownMenu open={sourceMenuOpen} onOpenChange={setSourceMenuOpen}>
<DropdownMenuTrigger asChild>
<button
@@ -0,0 +1,119 @@
import React from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import {
useEffectiveGitDirectory,
useGitStore,
useIsGitRepo,
useNestedRepoSelection,
useNestedRepos,
useStaleClearedSelections,
} from '@/stores/useGitStore';
type UseNestedGitDirectoryOptions = {
/** False defers all probing/discovery work while the surface is hidden. */
enabled?: boolean;
};
/**
* Resolves the repository a git surface operates on when the project root may
* not itself be a git repository. Owns the full resolution flow: probing the
* root, discovering nested repositories, auto-selecting the first one, and
* dropping a selection whose repository disappeared.
*
* Consumers still fetch their own git data for the returned `gitDirectory`;
* this hook only owns who that directory is.
*/
export const useNestedGitDirectory = (
root: string | null,
options: UseNestedGitDirectoryOptions = {},
) => {
const { enabled = true } = options;
const { git } = useRuntimeAPIs();
const rootIsGitRepo = useIsGitRepo(root);
const gitDirectory = useEffectiveGitDirectory(root);
const nestedRepos = useNestedRepos(root);
const nestedRepoSelection = useNestedRepoSelection(root);
const staleClearedSelections = useStaleClearedSelections(root);
// Probe of the resolved repository, used to detect a stale selection. Null
// when there is nothing selected to probe.
const selectedIsGitRepo = useIsGitRepo(
gitDirectory && gitDirectory !== root ? gitDirectory : null,
);
const { ensureStatus, ensureNestedRepos, selectNestedRepo, clearNestedRepoSelection } = useGitStore(
useShallow((state) => ({
ensureStatus: state.ensureStatus,
ensureNestedRepos: state.ensureNestedRepos,
selectNestedRepo: state.selectNestedRepo,
clearNestedRepoSelection: state.clearNestedRepoSelection,
})),
);
// Probe the root itself so nested-repo resolution never depends on some
// other surface (e.g. the sidebar badge) having probed it first.
React.useEffect(() => {
if (!enabled || !root) return;
if (rootIsGitRepo !== null) return;
void ensureStatus(root, git);
}, [enabled, ensureStatus, git, root, rootIsGitRepo]);
// Discover nested repositories once the root probe confirms it is not one.
React.useEffect(() => {
if (!enabled || !root) return;
if (rootIsGitRepo !== false) return;
void ensureNestedRepos(root);
}, [enabled, ensureNestedRepos, root, rootIsGitRepo]);
// Auto-select the first nested repository so the surface opens straight
// into repository data; a picker (where rendered) switches between them.
// Repositories whose selection already failed a probe are skipped: without
// this, a corrupt repository (discovered via its .git entry but failing
// git status) would be re-picked right after every stale-clear and loop
// discovery + probe while the surface is visible. When every candidate has
// failed, no selection is made — surfaces settle into their unresolved
// state instead of churning requests. A manual picker pick is still free
// to select anything; it gets probed like any other.
React.useEffect(() => {
if (!enabled || !root) return;
if (rootIsGitRepo !== false) return;
if (!Array.isArray(nestedRepos) || nestedRepos.length === 0) return;
if (nestedRepoSelection) return;
const candidates = staleClearedSelections
? nestedRepos.filter((repository) => !staleClearedSelections.has(repository))
: nestedRepos;
if (candidates.length === 0) return;
selectNestedRepo(root, candidates[0]);
}, [
enabled,
nestedRepos,
nestedRepoSelection,
root,
rootIsGitRepo,
selectNestedRepo,
staleClearedSelections,
]);
// A selected repository that is no longer a git repository is stale: drop
// the selection and re-scan so resolution reflects the current tree.
React.useEffect(() => {
if (!enabled || !root || !nestedRepoSelection) return;
if (!gitDirectory || gitDirectory === root) return;
if (selectedIsGitRepo !== false) return;
clearNestedRepoSelection(root);
void ensureNestedRepos(root, { force: true });
}, [
clearNestedRepoSelection,
enabled,
ensureNestedRepos,
gitDirectory,
nestedRepoSelection,
root,
selectedIsGitRepo,
]);
return { rootIsGitRepo, gitDirectory, nestedRepos, nestedRepoSelection };
};
+30
View File
@@ -35,6 +35,7 @@ import type {
RevertCommitResponse,
ResetToCommitResponse,
} from './api/types';
import { normalizePath } from './pathNormalization';
import { runtimeFetch } from './runtime-fetch';
import { getRuntimeUrlResolver } from './runtime-url';
import { getRuntimeKey } from './runtime-switch';
@@ -131,6 +132,35 @@ export async function checkIsGitRepository(directory: string): Promise<boolean>
}
}
export class GitDirectoriesUnsupportedError extends Error {
constructor() {
super('Nested git repository discovery is not supported by this runtime');
this.name = 'GitDirectoriesUnsupportedError';
}
}
export async function listGitDirectories(root: string): Promise<string[]> {
const response = await runtimeFetch('/api/fs/git-dirs', { query: { path: root } });
if (response.status === 501) {
throw new GitDirectoriesUnsupportedError();
}
if (!response.ok) {
throw new Error(`Failed to list git directories: ${response.statusText}`);
}
// SAFETY: the route is ours (`GET /api/fs/git-dirs`) and answers this exact
// shape on every 2xx; a malformed body fails the array check below.
const data = await response.json() as { repositories?: Array<{ path?: string | null }> };
if (!Array.isArray(data?.repositories)) {
throw new Error('Unexpected git directories response');
}
// The server joins paths with the platform separator; every other git
// directory key in the UI is normalized, so match that here or a Windows
// repository never equals its own selection or root prefix.
return data.repositories
.map((entry) => normalizePath(entry?.path ?? null))
.filter((path): path is string => path !== null);
}
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus> {
const mode = options?.mode;
const runtimeKey = getRuntimeKey();
+4
View File
@@ -879,6 +879,10 @@ export const dict = {
'gitView.empty.worktreeFeaturesUnavailable': 'Worktree-Funktionen sind in diesem Arbeitsbereichsmodus nicht verfügbar.',
'gitView.empty.worktreeSetupDescription': 'Arbeitstruktur-Einrichtung wird abgeschlossen und Repository-Zustand wird vorbereitet.',
'gitView.empty.worktreeSetupInProgress': 'Worktree-Einrichtung läuft',
'gitView.empty.discoveringRepositories': 'Suche nach Git-Repositories...',
'gitView.empty.discoverFailed': 'Git-Repositories konnten nicht durchsucht werden',
'gitView.empty.retryDiscovery': 'Erneut versuchen',
'gitView.empty.selectRepositoryPlaceholder': 'Repository auswählen...',
'worktree.bootstrap.toast.failed': 'Worktree-Einrichtung fehlgeschlagen',
'worktree.bootstrap.toast.failedDescription': 'Die Worktree wurde erstellt, aber die Hintergrund-Einrichtung wurde nicht abgeschlossen.',
'worktree.bootstrap.toast.timeoutDescription': 'Die Worktree wurde erstellt, aber die Hintergrund-Einrichtung hat ein Timeout.',
+4
View File
@@ -982,6 +982,10 @@ export const dict = {
'gitView.empty.worktreeFeaturesUnavailable': 'Worktree features are unavailable in this workspace mode.',
'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.',
'gitView.empty.worktreeSetupInProgress': 'Worktree setup in progress',
'gitView.empty.discoveringRepositories': 'Looking for Git repositories...',
'gitView.empty.discoverFailed': 'Could not scan for Git repositories',
'gitView.empty.retryDiscovery': 'Retry',
'gitView.empty.selectRepositoryPlaceholder': 'Select a repository...',
'worktree.bootstrap.toast.failed': 'Worktree setup failed',
'worktree.bootstrap.toast.failedDescription': 'The worktree was created, but background setup did not finish.',
'worktree.bootstrap.toast.timeoutDescription': 'The worktree was created, but background setup timed out.',
+4
View File
@@ -983,6 +983,10 @@ export const dict: Record<I18nKey, string> = {
"gitView.empty.worktreeFeaturesUnavailable": "Las características de worktree no están disponibles en este modo de espacio de trabajo.",
"gitView.empty.worktreeSetupDescription": "Finalizando la configuración de worktree y preparando el estado del repositorio.",
"gitView.empty.worktreeSetupInProgress": "Configuración de worktree en progreso",
"gitView.empty.discoveringRepositories": "Buscando repositorios de Git...",
"gitView.empty.discoverFailed": "No se pudo escanear en busca de repositorios de Git",
"gitView.empty.retryDiscovery": "Reintentar",
"gitView.empty.selectRepositoryPlaceholder": "Selecciona un repositorio...",
"worktree.bootstrap.toast.failed": "Error al configurar el worktree",
"worktree.bootstrap.toast.failedDescription": "El worktree se creó, pero la configuración en segundo plano no terminó.",
"worktree.bootstrap.toast.timeoutDescription": "El worktree se creó, pero la configuración en segundo plano agotó el tiempo de espera.",
+4
View File
@@ -804,6 +804,10 @@ export const dict = {
'gitView.empty.worktreeFeaturesUnavailable': 'Les fonctionnalités Worktree ne sont pas disponibles dans ce mode despace de travail.',
'gitView.empty.worktreeSetupDescription': 'Termine la configuration du worktree et prépare l\'état du dépôt.',
'gitView.empty.worktreeSetupInProgress': 'Configuration de worktree en cours',
'gitView.empty.discoveringRepositories': 'Recherche des dépôts Git...',
'gitView.empty.discoverFailed': 'Impossible danalyser les dépôts Git',
'gitView.empty.retryDiscovery': 'Réessayer',
'gitView.empty.selectRepositoryPlaceholder': 'Sélectionnez un dépôt...',
'gitView.gitmoji.empty': 'Aucun gitmoji trouvé',
'gitView.gitmoji.searchPlaceholder': 'Rechercher des gitmoji...',
'gitView.gitmoji.title': 'Insérer un gitmoji',
+4
View File
@@ -979,6 +979,10 @@ export const dict: Record<I18nKey, string> = {
'gitView.empty.worktreeFeaturesUnavailable': 'このワークスペースモードではワークツリー機能は利用できません。',
'gitView.empty.worktreeSetupDescription': 'ワークツリーのセットアップを完了し、リポジトリ状態を準備中。',
'gitView.empty.worktreeSetupInProgress': 'ワークツリーのセットアップ進行中',
'gitView.empty.discoveringRepositories': 'Git リポジトリを検索しています...',
'gitView.empty.discoverFailed': 'Git リポジトリを検索できませんでした',
'gitView.empty.retryDiscovery': '再試行',
'gitView.empty.selectRepositoryPlaceholder': 'リポジトリを選択...',
'worktree.bootstrap.toast.failed': 'ワークツリーのセットアップに失敗しました',
'worktree.bootstrap.toast.failedDescription': 'ワークツリーは作成されましたが、バックグラウンドセットアップが完了しませんでした。',
'worktree.bootstrap.toast.timeoutDescription': 'ワークツリーは作成されましたが、バックグラウンドセットアップがタイムアウトしました。',
+4
View File
@@ -983,6 +983,10 @@ export const dict: Record<I18nKey, string> = {
'gitView.empty.worktreeFeaturesUnavailable': '이 워크스페이스 모드에서는 워크트리 기능을 사용할 수 없습니다.',
'gitView.empty.worktreeSetupDescription': '워크트리 설정을 마치고 레포지토리 상태를 준비하고 있습니다.',
'gitView.empty.worktreeSetupInProgress': '워크트리 설정 중',
'gitView.empty.discoveringRepositories': 'Git 저장소를 찾는 중...',
'gitView.empty.discoverFailed': 'Git 저장소를 검색할 수 없습니다',
'gitView.empty.retryDiscovery': '다시 시도',
'gitView.empty.selectRepositoryPlaceholder': '저장소 선택...',
'worktree.bootstrap.toast.failed': '워크트리 설정 실패',
'worktree.bootstrap.toast.failedDescription': '워크트리는 생성되었지만 백그라운드 설정이 완료되지 않았습니다.',
'worktree.bootstrap.toast.timeoutDescription': '워크트리는 생성되었지만 백그라운드 설정 시간이 초과되었습니다.',
+4
View File
@@ -2134,6 +2134,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.',
@@ -983,6 +983,10 @@ export const dict: Record<I18nKey, string> = {
"gitView.empty.worktreeFeaturesUnavailable": "Os recursos de worktree não estão disponíveis neste modo de workspace.",
"gitView.empty.worktreeSetupDescription": "Finalizando a configuração de worktree e preparando o status do repositório.",
"gitView.empty.worktreeSetupInProgress": "Configuração de worktree em andamento",
"gitView.empty.discoveringRepositories": "Procurando repositórios Git...",
"gitView.empty.discoverFailed": "Não foi possível verificar os repositórios Git",
"gitView.empty.retryDiscovery": "Tentar novamente",
"gitView.empty.selectRepositoryPlaceholder": "Selecione um repositório...",
"worktree.bootstrap.toast.failed": "Falha na configuração do worktree",
"worktree.bootstrap.toast.failedDescription": "O worktree foi criado, mas a configuração em segundo plano não terminou.",
"worktree.bootstrap.toast.timeoutDescription": "O worktree foi criado, mas a configuração em segundo plano atingiu o tempo limite.",
+4
View File
@@ -983,6 +983,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 створено, але час очікування фонового налаштування минув.",
@@ -983,6 +983,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': '工作树已创建,但后台设置超时。',
@@ -995,6 +995,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 已建立,但背景設定逾時。',
+2
View File
@@ -148,6 +148,8 @@ 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()`; status dedupe is scoped to the per-directory status mutation revision, so a refresh requested after a mutation never joins a pre-mutation in-flight request
- 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), a runtime without the discovery route (VS Code) commits an `'unsupported'` marker, and an in-flight discovery whose runtime switched is discarded at commit time instead of repopulating the cleared map. Selections are persisted per runtime + root, and `useEffectiveGitDirectory(root)` resolves the directory git surfaces operate on (`root` when the root is a repository, the selected nested repository otherwise). A selection whose repository fails its probe is dropped and remembered session-only (`staleClearedSelections`) so auto-select does not re-pick it and loop walk+probe; manual picker picks bypass the memory. `hooks/useNestedGitDirectory.ts` owns the resolution flow (root probe, discovery, auto-select, stale-selection recovery) for every consuming surface (Git tab, diff view, pull-request view, walkthrough view, mobile changes), and `git/NestedRepoResolutionStates.tsx` renders the shared pending/failed/unsupported/empty states
- worktree bootstrap polling and session/worktree machinery stay keyed on the project root even while a nested repository is selected; only git data and actions follow the selection
- 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
+135 -1
View File
@@ -1,9 +1,23 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import type { GitStatus } from '@/lib/api/types';
import { useGitStore } from './useGitStore';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { notifyGitStatusInvalidated } from '@/lib/gitStatusInvalidation';
// The real transport has no server in tests and fails as a generic error.
// Tests that exercise other failure modes swap this implementation; the
// default keeps every pre-existing expectation (generic failure → null).
const listGitDirectoriesControl: { impl: (root: string) => Promise<string[]> } = {
impl: async () => {
throw new Error('network unavailable');
},
};
class TestGitDirectoriesUnsupportedError extends Error {}
mock.module('@/lib/gitApiHttp', () => ({
GitDirectoriesUnsupportedError: TestGitDirectoriesUnsupportedError,
listGitDirectories: (root: string) => listGitDirectoriesControl.impl(root),
}));
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
@@ -415,3 +429,123 @@ describe('useGitStore', () => {
expect(useGitStore.getState().getDirectoryState('/repo')?.status).toBe(initialStatus);
});
});
describe('useGitStore nested repository discovery', () => {
beforeEach(() => {
listGitDirectoriesControl.impl = async () => {
throw new Error('network unavailable');
};
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('remembers a stale-cleared repository so auto-select can skip it', () => {
useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one');
useGitStore.getState().selectNestedRepo('/root-b', '/root-b/two');
useGitStore.getState().clearNestedRepoSelection('/root-a');
useGitStore.getState().clearNestedRepoSelection('/root-b');
useGitStore.getState().clearNestedRepoSelection('/root-b');
const clearedA = useGitStore.getState().staleClearedSelections.get('/root-a');
const clearedB = useGitStore.getState().staleClearedSelections.get('/root-b');
expect(clearedA).toEqual(new Set(['/root-a/one']));
// Repeated clears of the same path stay a set, not an ever-growing list.
expect(clearedB).toEqual(new Set(['/root-b/two']));
});
test('runtime switch clears stale-cleared memory with the rest', () => {
useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one');
useGitStore.getState().clearNestedRepoSelection('/root-a');
useGitStore.getState().resetForRuntimeSwitch('runtime-b');
expect(useGitStore.getState().staleClearedSelections.size).toBe(0);
});
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('discards an in-flight discovery result when the runtime switches', async () => {
const stale = useGitStore.getState().ensureNestedRepos('/root-a');
useGitStore.getState().resetForRuntimeSwitch('runtime-b');
await stale;
// The old runtime's late completion must not repopulate the cleared map.
expect(useGitStore.getState().nestedReposByRoot.has('/root-a')).toBe(false);
// Discovery started under the new runtime still commits normally.
await useGitStore.getState().ensureNestedRepos('/root-a');
expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toBeNull();
});
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('marks a 501 runtime as unsupported instead of failed', async () => {
listGitDirectoriesControl.impl = async () => {
throw new TestGitDirectoriesUnsupportedError();
};
await useGitStore.getState().ensureNestedRepos('/root-a');
expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toBe('unsupported');
});
test('unsupported does not clobber a previous successful discovery', async () => {
listGitDirectoriesControl.impl = async () => ['/root-a/one'];
await useGitStore.getState().ensureNestedRepos('/root-a');
listGitDirectoriesControl.impl = async () => {
throw new TestGitDirectoriesUnsupportedError();
};
await useGitStore.getState().ensureNestedRepos('/root-a', { force: true });
expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toEqual(['/root-a/one']);
});
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();
});
});
+214 -1
View File
@@ -9,6 +9,7 @@ import type {
} from '@/lib/api/types';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { GitDirectoriesUnsupportedError, listGitDirectories } from '@/lib/gitApiHttp';
import { subscribeGitStatusInvalidations } from '@/lib/gitStatusInvalidation';
const LOG_STALE_THRESHOLD = 10000;
@@ -28,6 +29,11 @@ const DIFF_CACHE_MAX_TOTAL_SIZE_BYTES = 20 * 1024 * 1024; // 20MB
const DIFF_CACHE_MAX_GLOBAL_ENTRIES = 200;
type GitStatusFetchMode = 'full' | 'light';
// Discovery outcome for a root that is not itself a git repository. The three
// states are mutually exclusive: a repository list (possibly empty), a failed
// scan (`null`), or a runtime without the discovery route (`'unsupported'`).
export type NestedRepoDiscovery = string[] | null | 'unsupported';
interface DirectoryGitState {
isGitRepo: boolean | null;
status: GitStatus | null;
@@ -78,6 +84,24 @@ 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 — `'unsupported'` when the runtime has no discovery
// route, and absent when discovery has not run yet.
nestedReposByRoot: Map<string, NestedRepoDiscovery>;
nestedRepoSelection: Map<string, string>;
/**
* Repositories whose selection was dropped because their probe reported
* them as no longer a repository (corrupt or missing gitdir). Session-only
* memory so auto-select does not immediately re-pick the same broken path
* and loop walk+probe. Not persisted: the next launch re-probes honestly.
*/
staleClearedSelections: Map<string, Set<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;
}
@@ -102,6 +126,7 @@ const inFlightDiffFetchesByDirectory = new Map<string, Set<string>>();
const diffFetchGenerationByDirectory = new Map<string, number>();
const inFlightStatusFetches = new Map<string, { promise: Promise<boolean>; statusMutationRevision: number }>();
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;
@@ -292,6 +317,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 }>,
@@ -565,6 +643,9 @@ export const useGitStore = create<GitStore>()(
runtimeKey: initialGitRuntimeKey,
directories: seedDirectoriesFromBranchCache(initialGitRuntimeKey),
activeDirectory: null,
nestedReposByRoot: new Map(),
nestedRepoSelection: seedNestedRepoSelection(initialGitRuntimeKey),
staleClearedSelections: new Map(),
resetForRuntimeSwitch: (runtimeKey) => {
gitRuntimeGeneration += 1;
@@ -573,9 +654,17 @@ 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),
staleClearedSelections: new Map(),
});
},
setActiveDirectory: (directory) => {
@@ -1154,6 +1243,90 @@ export const useGitStore = create<GitStore>()(
set({ directories: newDirectories });
},
ensureNestedRepos: async (root, options = {}) => {
if (!root) return;
const { force = false } = options;
const runtimeKey = getRuntimeKey();
const runtimeGeneration = gitRuntimeGeneration;
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;
let unsupported = false;
try {
repositories = await listGitDirectories(root);
} catch (error) {
if (error instanceof GitDirectoriesUnsupportedError) {
unsupported = true;
} else {
console.error('Failed to discover nested git repositories:', error);
}
repositories = null;
}
// A runtime switch invalidates the discovery: resetForRuntimeSwitch
// already cleared the map, and committing old-runtime data here would
// both leak it and suppress a fresh scan for this root.
if (runtimeKey !== getRuntimeKey() || runtimeGeneration !== gitRuntimeGeneration) return;
const previous = get().nestedReposByRoot.get(root);
// An authoritative "unsupported" answer replaces only unknown or
// failed state; like a failed retry, it must not clobber an earlier
// successful discovery.
const nextValue: NestedRepoDiscovery = unsupported
? (previous ?? 'unsupported')
: (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;
const cleared = get().nestedRepoSelection.get(root);
if (cleared === undefined) return;
const next = new Map(get().nestedRepoSelection);
next.delete(root);
set({ nestedRepoSelection: next });
// Remember the drop so auto-select does not re-pick the same path
// before its probe can tell the difference. Only stale-probe
// recovery clears, so every clear here is a failed selection.
const nextStale = new Map(get().staleClearedSelections);
const forRoot = new Set(nextStale.get(root));
forRoot.add(cleared);
nextStale.set(root, forRoot);
set({ staleClearedSelections: nextStale });
writeCachedNestedRepoSelection(getRuntimeKey(), Object.fromEntries(next));
},
ensureStatus: async (directory, git) => {
const dirState = get().directories.get(directory);
const now = Date.now();
@@ -1250,6 +1423,46 @@ 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,
// `'unsupported'` = the runtime has no discovery route, 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;
});
};
// Repositories of this root whose selection already failed its probe. Auto-
// select skips them; the picker does not (a manual re-pick is a user decision
// and gets probed like any other).
export const useStaleClearedSelections = (root: string | null) => {
return useGitStore((state) => {
if (!root) return null;
return state.staleClearedSelections.get(root) ?? null;
});
};
export const useGitBranchLabel = (directory: string | null) => {
return useGitStore((state) => {
if (!directory) return null;
+4
View File
@@ -388,6 +388,10 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
return unsupportedWebRouteResponse('Scheduled tasks');
}
if (normalizedPathname === '/api/fs/git-dirs') {
return unsupportedWebRouteResponse('Nested git repository discovery');
}
if (normalizedPathname === '/api/sessions/snapshot' && method === 'GET') {
const activity = await sendBridgeMessage<Record<string, { type: 'idle' | 'busy' | 'cooldown' }>>('api:session-activity:get')
.catch(() => ({}));
@@ -23,6 +23,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.
- The active project directory is validated with `fs.realpath`, so when the project root is itself a symlink the workspace base no longer matches the paths the client sends. Workspace resolution therefore retries against the raw directory the client requested (`requestedDirectory` from `resolveProjectDirectory`) before falling back to worktree roots. Symlinks are still resolved afterwards, and write/exec routes keep their canonical containment check against the resolved base.
+129
View File
@@ -297,6 +297,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 '';
@@ -1599,4 +1672,60 @@ export const registerFsRoutes = (app, dependencies) => {
return res.status(500).json({ error: (error && error.message) || 'Failed to list directory' });
}
});
app.get('/api/fs/git-dirs', async (req, res) => {
const rawPath = typeof req.query.path === 'string' && req.query.path.trim().length > 0
? req.query.path.trim()
: '';
if (!rawPath) {
return res.status(400).json({ error: 'Path is required' });
}
try {
const resolved = await resolveWorkspacePathFromContext({
req,
targetPath: rawPath,
resolveProjectDirectory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
if (!resolved.ok) {
return res.status(400).json({ error: resolved.error });
}
const stats = await fsPromises.stat(resolved.resolved);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Specified path is not a directory', reason: 'not-directory' });
}
const repositories = await findGitDirectories({
rootPath: resolved.resolved,
fsPromises,
path,
maxDepth: GIT_DIRS_MAX_DEPTH,
maxDirs: GIT_DIRS_MAX_DIRS,
});
return res.json({
path: resolved.resolved,
repositories: repositories.map((repoPath) => ({
path: repoPath,
name: path.basename(repoPath),
})),
});
} catch (error) {
const err = error;
const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
if (code === 'ENOENT') {
return res.status(404).json({ error: 'Directory not found', reason: 'not-found' });
}
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access to directory denied');
}
console.error('Failed to find git directories:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to find git directories' });
}
});
};
+219
View File
@@ -1136,6 +1136,225 @@ 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' }]);
});
});
describe('fs stat directory scope (issue 3019)', () => {
// Wires the real project-directory runtime so the stat route resolves the
// workspace exactly as the server does: explicit x-opencode-directory header