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:
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -804,6 +804,10 @@ export const dict = {
|
||||
'gitView.empty.worktreeFeaturesUnavailable': 'Les fonctionnalités Worktree ne sont pas disponibles dans ce mode d’espace de travail.',
|
||||
'gitView.empty.worktreeSetupDescription': 'Termine la configuration du worktree et prépare l\'état du dépôt.',
|
||||
'gitView.empty.worktreeSetupInProgress': 'Configuration de worktree en cours',
|
||||
'gitView.empty.discoveringRepositories': 'Recherche des dépôts Git...',
|
||||
'gitView.empty.discoverFailed': 'Impossible d’analyser les dépôts Git',
|
||||
'gitView.empty.retryDiscovery': 'Réessayer',
|
||||
'gitView.empty.selectRepositoryPlaceholder': 'Sélectionnez un dépôt...',
|
||||
'gitView.gitmoji.empty': 'Aucun gitmoji trouvé',
|
||||
'gitView.gitmoji.searchPlaceholder': 'Rechercher des gitmoji...',
|
||||
'gitView.gitmoji.title': 'Insérer un gitmoji',
|
||||
|
||||
@@ -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': 'ワークツリーは作成されましたが、バックグラウンドセットアップがタイムアウトしました。',
|
||||
|
||||
@@ -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': '워크트리는 생성되었지만 백그라운드 설정 시간이 초과되었습니다.',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 已建立,但背景設定逾時。',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user