From 9c9c28a552d86209cc66fc75fd52eaa3d1d3bba0 Mon Sep 17 00:00:00 2001 From: jaygupta17 Date: Sat, 8 Aug 2026 19:08:42 +0530 Subject: [PATCH 1/9] feat(git): support nested git repositories in the Git tab When the project root is not itself a git repository, discover nested repositories (depth- and visit-capped readdir walk via a new /api/fs/git-dirs route), auto-select the first one, and show a repository picker next to the branch dropdown to switch. Selections persist per runtime and root; discovery failure is a distinct marker with a retry action, never an empty success. --- packages/ui/src/components/views/GitView.tsx | 489 +++++++++++------- .../ui/src/components/views/git/GitHeader.tsx | 54 +- packages/ui/src/lib/gitApiHttp.ts | 17 + packages/ui/src/lib/i18n/messages/de.ts | 4 + packages/ui/src/lib/i18n/messages/en.ts | 4 + packages/ui/src/lib/i18n/messages/es.ts | 4 + packages/ui/src/lib/i18n/messages/fr.ts | 4 + packages/ui/src/lib/i18n/messages/ja.ts | 4 + packages/ui/src/lib/i18n/messages/ko.ts | 4 + packages/ui/src/lib/i18n/messages/pl.ts | 4 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 4 + packages/ui/src/lib/i18n/messages/uk.ts | 4 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 4 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 4 + packages/ui/src/stores/DOCUMENTATION.md | 1 + packages/ui/src/stores/useGitStore.test.ts | 58 +++ packages/ui/src/stores/useGitStore.ts | 165 +++++- packages/web/server/lib/fs/DOCUMENTATION.md | 4 + packages/web/server/lib/fs/routes.js | 129 +++++ packages/web/server/lib/fs/routes.test.js | 219 ++++++++ 20 files changed, 989 insertions(+), 191 deletions(-) diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 28fcf9f1..99e9ac46 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -17,6 +17,9 @@ import { useIsGitRepo, useGitLoadingStatus, useGitLoadingLog, + useEffectiveGitDirectory, + useNestedRepos, + useNestedRepoSelection, } from '@/stores/useGitStore'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; @@ -249,8 +252,16 @@ export const GitView: React.FC = ({ isActive }) => { loadDefaultGitIdentityId: s.loadDefaultGitIdentityId, }))); - const isGitRepo = useIsGitRepo(currentDirectory ?? null); - const status = useGitStatus(currentDirectory ?? null); + // The root the view is anchored to (session/worktree context stays keyed on + // it). When the root is not itself a repository and the user picked a nested + // one, `gitDirectory` is the effective repository all git data and actions + // operate on. + const rootIsGitRepo = useIsGitRepo(currentDirectory ?? null); + const gitDirectory = useEffectiveGitDirectory(currentDirectory ?? null); + const isGitRepo = useIsGitRepo(gitDirectory ?? null); + const status = useGitStatus(gitDirectory ?? null); + const nestedRepos = useNestedRepos(currentDirectory ?? null); + const nestedRepoSelection = useNestedRepoSelection(currentDirectory ?? null); // Authoritative session↔worktree attachment for repair action display const worktreeAttachment = useSessionWorktreeStore((s) => @@ -265,11 +276,11 @@ export const GitView: React.FC = ({ isActive }) => { : undefined; const worktreeMetadata = useDetectedWorktreeMetadata(currentDirectory, storeWorktreeMetadata, status?.current ?? undefined); - const branches = useGitBranches(currentDirectory ?? null); - const log = useGitLog(currentDirectory ?? null); - const currentIdentity = useGitIdentity(currentDirectory ?? null); - const isLoading = useGitLoadingStatus(currentDirectory ?? null); - const isLogLoading = useGitLoadingLog(currentDirectory ?? null); + const branches = useGitBranches(gitDirectory ?? null); + const log = useGitLog(gitDirectory ?? null); + const currentIdentity = useGitIdentity(gitDirectory ?? null); + const isLoading = useGitLoadingStatus(gitDirectory ?? null); + const isLogLoading = useGitLoadingLog(gitDirectory ?? null); const { setActiveDirectory, fetchAll, @@ -284,6 +295,9 @@ export const GitView: React.FC = ({ isActive }) => { moveStatusPathsOptimistically, restoreStatus, bumpIndexRevision, + ensureNestedRepos, + selectNestedRepo, + clearNestedRepoSelection, } = useGitStore(useShallow((state) => ({ setActiveDirectory: state.setActiveDirectory, fetchAll: state.fetchAll, @@ -298,6 +312,9 @@ export const GitView: React.FC = ({ isActive }) => { moveStatusPathsOptimistically: state.moveStatusPathsOptimistically, restoreStatus: state.restoreStatus, bumpIndexRevision: state.bumpIndexRevision, + ensureNestedRepos: state.ensureNestedRepos, + selectNestedRepo: state.selectNestedRepo, + clearNestedRepoSelection: state.clearNestedRepoSelection, }))); const isMobile = useUIStore((state) => state.isMobile); const openContextDiff = useUIStore((state) => state.openContextDiff); @@ -305,10 +322,10 @@ export const GitView: React.FC = ({ isActive }) => { const prStatusBranch = status?.current ?? null; const prChipStatus = useGitHubPrStatusStore((state) => { - if (!currentDirectory || !prStatusBranch) { + if (!gitDirectory || !prStatusBranch) { return null; } - return getFreshestPrStatusForBranch(state.entries, currentDirectory, prStatusBranch); + return getFreshestPrStatusForBranch(state.entries, gitDirectory, prStatusBranch); }); const navigateToDiff = useUIStore((state) => state.navigateToDiff); @@ -332,12 +349,12 @@ export const GitView: React.FC = ({ isActive }) => { clearScheduledGitReconcile(); gitReconcileTimeoutRef.current = window.setTimeout(() => { gitReconcileTimeoutRef.current = null; - if (normalizePath(directory) !== normalizePath(currentDirectory)) { + if (normalizePath(directory) !== normalizePath(gitDirectory)) { return; } void fetchStatus(directory, git, { silent: true }); }, GIT_RECONCILE_DELAY_MS); - }, [clearScheduledGitReconcile, currentDirectory, fetchStatus, git]); + }, [clearScheduledGitReconcile, gitDirectory, fetchStatus, git]); React.useEffect(() => clearScheduledGitReconcile, [clearScheduledGitReconcile]); @@ -491,9 +508,9 @@ export const GitView: React.FC = ({ isActive }) => { const shouldHideNotGitState = isPendingWorktreeSetup || isWaitingForGitRefreshAfterBootstrap; const initialSnapshot = React.useMemo(() => { - if (!currentDirectory) return null; - return gitViewSnapshots.get(currentDirectory) ?? null; - }, [currentDirectory]); + if (!gitDirectory) return null; + return gitViewSnapshots.get(gitDirectory) ?? null; + }, [gitDirectory]); const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled); const [rootBranchHint, setRootBranchHint] = React.useState(null); @@ -663,7 +680,7 @@ export const GitView: React.FC = ({ isActive }) => { // Restore conflict state from localStorage on mount React.useEffect(() => { - if (!conflictStorageKey || typeof window === 'undefined' || !currentDirectory) return; + if (!conflictStorageKey || typeof window === 'undefined' || !gitDirectory) return; const raw = window.localStorage.getItem(conflictStorageKey); if (!raw) return; @@ -675,8 +692,8 @@ export const GitView: React.FC = ({ isActive }) => { operation: 'merge' | 'rebase'; }; - // Validate the stored state matches current directory - if (parsed.directory !== currentDirectory) { + // Validate the stored state matches the effective repository + if (parsed.directory !== gitDirectory) { window.localStorage.removeItem(conflictStorageKey); return; } @@ -688,7 +705,7 @@ export const GitView: React.FC = ({ isActive }) => { } catch { window.localStorage.removeItem(conflictStorageKey); } - }, [conflictStorageKey, currentDirectory]); + }, [conflictStorageKey, gitDirectory]); const [stashDialogOpen, setStashDialogOpen] = React.useState(false); const [stashDialogOperation, setStashDialogOperation] = React.useState<'merge' | 'rebase'>('merge'); const [stashDialogBranch, setStashDialogBranch] = React.useState(''); @@ -724,7 +741,7 @@ export const GitView: React.FC = ({ isActive }) => { }, [loadingCommitHashes]); React.useEffect(() => { - if (!currentDirectory || !git) return; + if (!gitDirectory || !git) return; // Find hashes that are expanded but not yet loaded or loading const hashesToLoad = Array.from(expandedCommitHashes).filter( @@ -747,7 +764,7 @@ export const GitView: React.FC = ({ isActive }) => { void Promise.all( hashesToLoad.map((hash) => git - .getCommitFiles(currentDirectory, hash) + .getCommitFiles(gitDirectory, hash) .then((response) => ({ hash, files: response.files })) .catch((error) => { console.error('Failed to fetch commit files:', error); @@ -791,16 +808,26 @@ export const GitView: React.FC = ({ isActive }) => { return next; }); }; - }, [expandedCommitHashes, currentDirectory, git]); + }, [expandedCommitHashes, gitDirectory, git]); + + // Restore the per-repository draft when the effective repository changes + // (e.g. the user picks a different nested repository from the picker), + // mirroring the fresh-mount behavior of a directory switch. + React.useEffect(() => { + if (!gitDirectory) return; + const snapshot = gitViewSnapshots.get(gitDirectory) ?? null; + setCommitMessage(snapshot?.commitMessage ?? ''); + setGeneratedHighlights(snapshot?.generatedHighlights ?? []); + }, [gitDirectory]); React.useEffect(() => { - if (!currentDirectory) return; - rememberSnapshot(currentDirectory, { - directory: currentDirectory, + if (!gitDirectory) return; + rememberSnapshot(gitDirectory, { + directory: gitDirectory, commitMessage, generatedHighlights, }); - }, [commitMessage, currentDirectory, generatedHighlights]); + }, [commitMessage, gitDirectory, generatedHighlights]); React.useEffect(() => { if (!isActive) return; @@ -811,25 +838,25 @@ export const GitView: React.FC = ({ isActive }) => { React.useEffect(() => { if (!isActive) return; - if (!currentDirectory || !git?.getRemoteUrl) { + if (!gitDirectory || !git?.getRemoteUrl || isGitRepo !== true) { setRemoteUrl(null); return; } let cancelled = false; git - .getRemoteUrl(currentDirectory) + .getRemoteUrl(gitDirectory) .then((url) => { if (!cancelled) setRemoteUrl(url); }) .catch(() => { if (!cancelled) setRemoteUrl(null); }); return () => { cancelled = true; }; - }, [isActive, currentDirectory, git]); + }, [isActive, gitDirectory, git, isGitRepo]); const refreshRemotes = React.useCallback(async () => { - if (!currentDirectory || !git?.getRemotes) { + if (!gitDirectory || !git?.getRemotes || isGitRepo !== true) { setRemotes([]); return; } try { - const remoteList = await git.getRemotes(currentDirectory); + const remoteList = await git.getRemotes(gitDirectory); if (mountedRef.current) { setRemotes(remoteList); } @@ -838,7 +865,7 @@ export const GitView: React.FC = ({ isActive }) => { setRemotes([]); } } - }, [currentDirectory, git]); + }, [gitDirectory, git, isGitRepo]); React.useEffect(() => { if (!isActive) return; @@ -847,37 +874,68 @@ export const GitView: React.FC = ({ isActive }) => { React.useEffect(() => { if (!isActive) return; - if (currentDirectory) { + if (currentDirectory && gitDirectory) { setActiveDirectory(currentDirectory); - void ensureAll(currentDirectory, git); + void ensureAll(gitDirectory, git); } - }, [isActive, currentDirectory, setActiveDirectory, ensureAll, git]); + }, [isActive, currentDirectory, gitDirectory, setActiveDirectory, ensureAll, git]); React.useEffect(() => { if (!isActive) return; - if (!currentDirectory) { + if (!gitDirectory) { return; } return sessionEvents.onGitRefreshHint((hint) => { - if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) { + if (normalizePath(hint.directory) !== normalizePath(gitDirectory)) { return; } if (hint.paths?.length) { - clearDiffCache(currentDirectory, hint.paths); + clearDiffCache(gitDirectory, hint.paths); } - void fetchStatus(currentDirectory, git, { silent: true }); + void fetchStatus(gitDirectory, git, { silent: true }); }); - }, [isActive, clearDiffCache, currentDirectory, fetchStatus, git]); + }, [isActive, clearDiffCache, gitDirectory, fetchStatus, git]); + + // Discover nested repositories once the root probe confirms it is not one. + React.useEffect(() => { + if (!isActive) return; + if (!currentDirectory) return; + if (rootIsGitRepo !== false) return; + void ensureNestedRepos(currentDirectory); + }, [currentDirectory, ensureNestedRepos, isActive, rootIsGitRepo]); + + // Auto-select the first nested repository so the tab opens straight into + // repository data; the header picker switches between repositories. + React.useEffect(() => { + if (!isActive) return; + if (!currentDirectory) return; + if (rootIsGitRepo !== false) return; + if (!nestedRepos || nestedRepos.length === 0) return; + if (nestedRepoSelection) return; + selectNestedRepo(currentDirectory, nestedRepos[0]); + }, [currentDirectory, isActive, nestedRepos, nestedRepoSelection, rootIsGitRepo, selectNestedRepo]); + + // A selected repository that is no longer a git repository is stale: drop + // the selection and re-scan so the picker reflects the current tree. + React.useEffect(() => { + if (!isActive) return; + if (!currentDirectory) return; + if (!nestedRepoSelection) return; + if (gitDirectory === currentDirectory) return; + if (isGitRepo !== false) return; + clearNestedRepoSelection(currentDirectory); + void ensureNestedRepos(currentDirectory, { force: true }); + }, [clearNestedRepoSelection, currentDirectory, ensureNestedRepos, gitDirectory, isActive, isGitRepo, nestedRepoSelection]); const refreshStatusAndBranches = React.useCallback( async (showErrors = true) => { - if (!currentDirectory) return; + if (!gitDirectory) return; try { await Promise.all([ - fetchStatus(currentDirectory, git), - fetchBranches(currentDirectory, git), + fetchStatus(gitDirectory, git), + fetchBranches(gitDirectory, git), ]); } catch (err) { if (showErrors) { @@ -887,42 +945,42 @@ export const GitView: React.FC = ({ isActive }) => { } } }, - [currentDirectory, git, fetchStatus, fetchBranches, t] + [gitDirectory, git, fetchStatus, fetchBranches, t] ); const refreshLog = React.useCallback(async () => { - if (!currentDirectory) return; - await fetchLog(currentDirectory, git, logMaxCountLocal); - }, [currentDirectory, git, fetchLog, logMaxCountLocal]); + if (!gitDirectory) return; + await fetchLog(gitDirectory, git, logMaxCountLocal); + }, [gitDirectory, git, fetchLog, logMaxCountLocal]); const refreshIdentity = React.useCallback(async () => { - if (!currentDirectory) return; - await fetchIdentity(currentDirectory, git); - }, [currentDirectory, git, fetchIdentity]); + if (!gitDirectory) return; + await fetchIdentity(gitDirectory, git); + }, [gitDirectory, git, fetchIdentity]); React.useEffect(() => { if (!isActive) return; - if (!currentDirectory) return; + if (!gitDirectory) return; if (!git?.hasLocalIdentity) return; if (isGitRepo !== true) return; const defaultId = typeof defaultGitIdentityId === 'string' ? defaultGitIdentityId.trim() : ''; if (!defaultId || defaultId === 'global') return; - const previousAttempt = autoAppliedDefaultRef.current.get(currentDirectory); + const previousAttempt = autoAppliedDefaultRef.current.get(gitDirectory); if (previousAttempt === defaultId) return; let cancelled = false; const run = async () => { try { - const hasLocal = await git.hasLocalIdentity?.(currentDirectory); + const hasLocal = await git.hasLocalIdentity?.(gitDirectory); if (cancelled) return; if (hasLocal === true) return; beginIdentityApply(); - await git.setGitIdentity(currentDirectory, defaultId); - autoAppliedDefaultRef.current.set(currentDirectory, defaultId); + await git.setGitIdentity(gitDirectory, defaultId); + autoAppliedDefaultRef.current.set(gitDirectory, defaultId); await refreshIdentity(); } catch (error) { console.warn('Failed to auto-apply default git identity:', error); @@ -938,7 +996,7 @@ export const GitView: React.FC = ({ isActive }) => { return () => { cancelled = true; }; - }, [isActive, beginIdentityApply, currentDirectory, defaultGitIdentityId, endIdentityApply, git, isGitRepo, refreshIdentity]); + }, [isActive, beginIdentityApply, gitDirectory, defaultGitIdentityId, endIdentityApply, git, isGitRepo, refreshIdentity]); const changeEntries = React.useMemo(() => { if (!status) return []; @@ -959,7 +1017,7 @@ export const GitView: React.FC = ({ isActive }) => { ); React.useEffect(() => { - if (!currentDirectory || changeEntries.length === 0) { + if (!gitDirectory || changeEntries.length === 0) { return; } @@ -983,13 +1041,13 @@ export const GitView: React.FC = ({ isActive }) => { } const timeoutId = window.setTimeout(() => { - void prefetchDiffs(currentDirectory, git, orderedPaths, { maxFiles: GIT_DIFF_PRIORITY_PREFETCH_LIMIT }); + void prefetchDiffs(gitDirectory, git, orderedPaths, { maxFiles: GIT_DIFF_PRIORITY_PREFETCH_LIMIT }); }, 120); return () => { window.clearTimeout(timeoutId); }; - }, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]); + }, [changeEntries, gitDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]); const getPushedRemoteName = (result?: Awaited>) => { return result?.pushed[0]?.remote @@ -1000,7 +1058,7 @@ export const GitView: React.FC = ({ isActive }) => { }; const handleSyncAction = async (action: Exclude, remote?: GitRemote) => { - if (!currentDirectory) return; + if (!gitDirectory) return; setSyncAction(action); try { @@ -1020,20 +1078,20 @@ export const GitView: React.FC = ({ isActive }) => { if (!remote) { throw new Error('No remote available for fetch'); } - await git.gitFetch(currentDirectory, { remote: remote.name }); + await git.gitFetch(gitDirectory, { remote: remote.name }); toast.success(t('gitView.toast.fetchedFromRemote', { name: remote.name })); } else if (action === 'pull') { if (!remote) { throw new Error('No remote available for pull'); } - const result = await git.gitPull(currentDirectory, getPullOptions(remote)); + const result = await git.gitPull(gitDirectory, getPullOptions(remote)); toast.success( result.files.length === 1 ? t('gitView.toast.pulledFilesSingle', { count: result.files.length, name: remote.name }) : t('gitView.toast.pulledFilesPlural', { count: result.files.length, name: remote.name }) ); } else if (action === 'push') { - const result = await git.gitPush(currentDirectory); + const result = await git.gitPush(gitDirectory); toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) })); } else if (action === 'sync') { if (!remote) { @@ -1041,21 +1099,21 @@ export const GitView: React.FC = ({ isActive }) => { } let pulledFileCount = 0; let pushedChanges = false; - await git.gitFetch(currentDirectory, { remote: remote.name }); - const afterFetch = await git.getGitStatus(currentDirectory); + await git.gitFetch(gitDirectory, { remote: remote.name }); + const afterFetch = await git.getGitStatus(gitDirectory); if ((afterFetch.behind ?? 0) > 0) { if ((afterFetch.files?.length ?? 0) > 0) { toast.error(t('gitView.toast.commitOrStashBeforeSync')); return; } - const pullResult = await git.gitPull(currentDirectory, getPullOptions(remote)); + const pullResult = await git.gitPull(gitDirectory, getPullOptions(remote)); pulledFileCount = pullResult.files.length; } - const afterPull = await git.getGitStatus(currentDirectory); + const afterPull = await git.getGitStatus(gitDirectory); if ((afterPull.ahead ?? 0) > 0) { - await git.gitPush(currentDirectory); + await git.gitPush(gitDirectory); pushedChanges = true; } if (pulledFileCount > 0 && pushedChanges) { @@ -1091,7 +1149,7 @@ export const GitView: React.FC = ({ isActive }) => { }; const handleRemoveRemote = React.useCallback(async (remote: GitRemote) => { - if (!currentDirectory) return; + if (!gitDirectory) return; const remoteName = remote.name.trim(); if (!remoteName) { @@ -1105,7 +1163,7 @@ export const GitView: React.FC = ({ isActive }) => { setRemovingRemoteName(remoteName); try { - await git.removeRemote(currentDirectory, { remote: remoteName }); + await git.removeRemote(gitDirectory, { remote: remoteName }); toast.success(t('gitView.toast.removedRemote', { name: remoteName })); await Promise.all([ refreshStatusAndBranches(false), @@ -1117,10 +1175,10 @@ export const GitView: React.FC = ({ isActive }) => { } finally { setRemovingRemoteName(null); } - }, [currentDirectory, git, refreshRemotes, refreshStatusAndBranches, t]); + }, [gitDirectory, git, refreshRemotes, refreshStatusAndBranches, t]); const handleCommit = async (options: { pushAfter?: boolean } = {}) => { - if (!currentDirectory) return; + if (!gitDirectory) return; if (!commitMessage.trim()) { toast.error(t('gitView.toast.enterCommitMessage')); return; @@ -1136,11 +1194,11 @@ export const GitView: React.FC = ({ isActive }) => { setCommitAction(action); try { - await git.createGitCommit(currentDirectory, commitMessage.trim(), { + await git.createGitCommit(gitDirectory, commitMessage.trim(), { files: filesToCommit, stageFiles: [], }); - bumpIndexRevision(currentDirectory); + bumpIndexRevision(gitDirectory); toast.success(t('gitView.toast.commitCreated')); setCommitMessage(''); clearGeneratedHighlights(); @@ -1160,21 +1218,21 @@ export const GitView: React.FC = ({ isActive }) => { ? status.tracking.slice(trackingPrefix.length) : undefined; - await git.gitFetch(currentDirectory, { remote: remote.name }); - const afterFetch = await git.getGitStatus(currentDirectory); + await git.gitFetch(gitDirectory, { remote: remote.name }); + const afterFetch = await git.getGitStatus(gitDirectory); if ((afterFetch.behind ?? 0) > 0) { if ((afterFetch.files?.length ?? 0) > 0) { toast.error(t('gitView.toast.commitOrStashBeforeSync')); await refreshStatusAndBranches(false); return; } - await git.gitPull(currentDirectory, { remote: remote.name, branch: trackedBranch, rebase: true }); + await git.gitPull(gitDirectory, { remote: remote.name, branch: trackedBranch, rebase: true }); } - const afterPull = await git.getGitStatus(currentDirectory); + const afterPull = await git.getGitStatus(gitDirectory); let result: Awaited> | undefined; if ((afterPull.ahead ?? 0) > 0) { - result = await git.gitPush(currentDirectory); + result = await git.gitPush(gitDirectory); } toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) })); triggerFireworks(); @@ -1197,7 +1255,7 @@ export const GitView: React.FC = ({ isActive }) => { }; const handleGenerateCommitMessage = React.useCallback(async () => { - if (!currentDirectory) return; + if (!gitDirectory) return; const selectedFilePaths = stagedChangeEntries.map((file) => file.path).sort(); if (selectedFilePaths.length === 0) { toast.error(t('gitView.toast.stageFileToDescribe')); @@ -1205,13 +1263,13 @@ export const GitView: React.FC = ({ isActive }) => { } console.error('[git-generation][browser] generate button clicked', { - directory: currentDirectory, + directory: gitDirectory, selectedFiles: selectedFilePaths.length, }); setIsGeneratingMessage(true); try { - const { message } = await generateSessionCommitMessage(currentDirectory, selectedFilePaths); + const { message } = await generateSessionCommitMessage(gitDirectory, selectedFilePaths); const subject = message.subject?.trim() ?? ''; const highlights = Array.isArray(message.highlights) ? message.highlights : []; @@ -1242,7 +1300,7 @@ export const GitView: React.FC = ({ isActive }) => { } finally { setIsGeneratingMessage(false); } - }, [currentDirectory, stagedChangeEntries, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom, t]); + }, [gitDirectory, stagedChangeEntries, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom, t]); const formatBlockingReason = (reason: ReturnType[number]): string => { if (reason.reason === 'attention') { @@ -1255,7 +1313,7 @@ export const GitView: React.FC = ({ isActive }) => { }; const handleCreateBranch = async (branchName: string, remote?: GitRemote) => { - if (!currentDirectory || !status) return; + if (!gitDirectory || !status) return; const blockingReasons = getMutationBlockingReasons(worktreeAttachment); if (blockingReasons.length > 0) { @@ -1267,15 +1325,15 @@ export const GitView: React.FC = ({ isActive }) => { const remoteName = remote?.name ?? 'origin'; try { - await git.createBranch(currentDirectory, branchName, checkoutBase ?? 'HEAD'); + await git.createBranch(gitDirectory, branchName, checkoutBase ?? 'HEAD'); toast.success(t('gitView.toast.createdBranch', { name: branchName })); // Checkout the new branch and stay on it - await git.checkoutBranch(currentDirectory, branchName); + await git.checkoutBranch(gitDirectory, branchName); let pushSucceeded = false; try { - await git.gitPush(currentDirectory, { + await git.gitPush(gitDirectory, { remote: remoteName, branch: branchName, options: ['--set-upstream'], @@ -1309,7 +1367,7 @@ export const GitView: React.FC = ({ isActive }) => { }; const handleRenameBranch = async (oldName: string, newName: string) => { - if (!currentDirectory) return; + if (!gitDirectory) return; const blockingReasons = getMutationBlockingReasons(worktreeAttachment); if (blockingReasons.length > 0) { @@ -1318,7 +1376,7 @@ export const GitView: React.FC = ({ isActive }) => { } try { - await git.renameBranch(currentDirectory, oldName, newName); + await git.renameBranch(gitDirectory, oldName, newName); toast.success(t('gitView.toast.renamedBranch', { oldName, newName })); await refreshStatusAndBranches(); await refreshLog(); @@ -1330,7 +1388,7 @@ export const GitView: React.FC = ({ isActive }) => { }; const handleCheckoutBranch = async (branch: string) => { - if (!currentDirectory) return; + if (!gitDirectory) return; // Block mutation if worktree is in an attention-required state const blockingReasons = getMutationBlockingReasons(worktreeAttachment); @@ -1346,7 +1404,7 @@ export const GitView: React.FC = ({ isActive }) => { } try { - await git.checkoutBranch(currentDirectory, normalized); + await git.checkoutBranch(gitDirectory, normalized); toast.success(t('gitView.toast.checkedOut', { name: normalized })); await refreshStatusAndBranches(); await refreshLog(); @@ -1358,11 +1416,11 @@ export const GitView: React.FC = ({ isActive }) => { }; const handleApplyIdentity = async (profile: GitIdentityProfile) => { - if (!currentDirectory) return; + if (!gitDirectory) return; beginIdentityApply(); try { - await git.setGitIdentity(currentDirectory, profile.id); + await git.setGitIdentity(gitDirectory, profile.id); toast.success(t('gitView.toast.appliedIdentity', { name: profile.name })); await refreshIdentity(); } catch (err) { @@ -1545,7 +1603,7 @@ export const GitView: React.FC = ({ isActive }) => { : null; React.useEffect(() => { - if (!currentDirectory || !git || !log?.all?.length || !currentBranch || !baseBranch || currentBranch === baseBranch) { + if (!gitDirectory || !git || !log?.all?.length || !currentBranch || !baseBranch || currentBranch === baseBranch) { setHistoryBranchDivider(null); return; } @@ -1554,7 +1612,7 @@ export const GitView: React.FC = ({ isActive }) => { const resolveBranchDivider = async () => { try { - const branchOnlyLog = await git.getGitLog(currentDirectory, { + const branchOnlyLog = await git.getGitLog(gitDirectory, { from: baseBranch, to: 'HEAD', maxCount: logMaxCountLocal, @@ -1607,21 +1665,21 @@ export const GitView: React.FC = ({ isActive }) => { return () => { cancelled = true; }; - }, [baseBranch, currentBranch, currentDirectory, git, log, logMaxCountLocal]); + }, [baseBranch, currentBranch, gitDirectory, git, log, logMaxCountLocal]); // Clear graph log when directory changes React.useEffect(() => { setGraphLog(null); - }, [currentDirectory]); + }, [gitDirectory]); React.useEffect(() => { - if (gitLogDialogMode !== 'graph' || !currentDirectory) { + if (gitLogDialogMode !== 'graph' || !gitDirectory) { if (gitLogDialogMode !== 'graph') setGraphLog(null); return; } let cancelled = false; setGraphLogLoading(true); - git.getGitLog(currentDirectory, { maxCount: graphLogMaxCount, all: true }) + git.getGitLog(gitDirectory, { maxCount: graphLogMaxCount, all: true }) .then((result) => { if (!cancelled) setGraphLog(result); }) @@ -1632,33 +1690,33 @@ export const GitView: React.FC = ({ isActive }) => { if (!cancelled) setGraphLogLoading(false); }); return () => { cancelled = true; }; - }, [gitLogDialogMode, currentDirectory, graphLogMaxCount, graphLogRefreshToken, git]); + }, [gitLogDialogMode, gitDirectory, graphLogMaxCount, graphLogRefreshToken, git]); // Keep these sections stable in layout; individual cards render placeholders when unavailable. const moveChangePaths = React.useCallback((paths: string[], direction: GitIndexMutationDirection) => { - if (!currentDirectory || paths.length === 0) return; + if (!gitDirectory || paths.length === 0) return; const uniquePaths = Array.from(new Set(paths)); setMovingChangePaths((previous) => { const next = new Set(previous); uniquePaths.forEach((path) => next.add(path)); return next; }); - const previousStatus = moveStatusPathsOptimistically(currentDirectory, uniquePaths, direction); + const previousStatus = moveStatusPathsOptimistically(gitDirectory, uniquePaths, direction); gitIndexMutationQueue.enqueue({ - directory: currentDirectory, + directory: gitDirectory, direction, paths: new Set(uniquePaths), - rollback: () => restoreStatus(currentDirectory, previousStatus), + rollback: () => restoreStatus(gitDirectory, previousStatus), }); scheduleGitMutationFlush(); - }, [currentDirectory, gitIndexMutationQueue, moveStatusPathsOptimistically, restoreStatus, scheduleGitMutationFlush]); + }, [gitDirectory, gitIndexMutationQueue, moveStatusPathsOptimistically, restoreStatus, scheduleGitMutationFlush]); const handleRevertFile = React.useCallback( async (filePath: string) => { - if (!currentDirectory) return; + if (!gitDirectory) return; setRevertingPaths((previous) => { const next = new Set(previous); @@ -1667,7 +1725,7 @@ export const GitView: React.FC = ({ isActive }) => { }); try { - await git.revertGitFile(currentDirectory, filePath, { scope: 'working' }); + await git.revertGitFile(gitDirectory, filePath, { scope: 'working' }); toast.success(t('gitView.toast.revertedFile', { path: filePath })); await refreshStatusAndBranches(false); } catch (err) { @@ -1681,12 +1739,12 @@ export const GitView: React.FC = ({ isActive }) => { }); } }, - [currentDirectory, refreshStatusAndBranches, git, t] + [gitDirectory, refreshStatusAndBranches, git, t] ); const handleRevertPaths = React.useCallback( async (paths: string[], setGlobalReverting: boolean, scope: 'all' | 'working' = 'all') => { - if (!currentDirectory || paths.length === 0) { + if (!gitDirectory || paths.length === 0) { return; } @@ -1712,7 +1770,7 @@ export const GitView: React.FC = ({ isActive }) => { try { await Promise.all(uniquePaths.map(async (filePath) => { try { - await git.revertGitFile(currentDirectory, filePath, { scope }); + await git.revertGitFile(gitDirectory, filePath, { scope }); } catch (err) { failed.push({ path: filePath, @@ -1722,7 +1780,7 @@ export const GitView: React.FC = ({ isActive }) => { })); if (touchesStagedIndex && failed.length < uniquePaths.length) { - bumpIndexRevision(currentDirectory); + bumpIndexRevision(gitDirectory); } await refreshStatusAndBranches(false); @@ -1754,7 +1812,7 @@ export const GitView: React.FC = ({ isActive }) => { } } }, - [bumpIndexRevision, currentDirectory, git, isRevertingAll, refreshStatusAndBranches, revertingPaths, stagedChangeEntries, t] + [bumpIndexRevision, gitDirectory, git, isRevertingAll, refreshStatusAndBranches, revertingPaths, stagedChangeEntries, t] ); const handleRevertAll = React.useCallback( @@ -1772,12 +1830,12 @@ export const GitView: React.FC = ({ isActive }) => { ); const handleViewChangeDiff = React.useCallback((path: string, staged: boolean) => { - if (currentDirectory && !isMobile) { - openContextDiff(currentDirectory, path, staged); + if (gitDirectory && !isMobile) { + openContextDiff(gitDirectory, path, staged); return; } navigateToDiff(path, staged); - }, [currentDirectory, isMobile, navigateToDiff, openContextDiff]); + }, [gitDirectory, isMobile, navigateToDiff, openContextDiff]); const openStashes = React.useCallback(() => setIsStashesDialogOpen(true), []); @@ -1925,7 +1983,7 @@ export const GitView: React.FC = ({ isActive }) => { const handleMerge = React.useCallback( async (branch: string) => { - if (!currentDirectory) return; + if (!gitDirectory) return; setBranchOperation('merge'); resetOperationLogs(); @@ -1936,19 +1994,19 @@ export const GitView: React.FC = ({ isActive }) => { try { if (target.remote && target.remoteBranch) { addOperationLog(`Fetching ${target.remote}/${target.remoteBranch}...`, 'running'); - await git.gitFetch(currentDirectory, { remote: target.remote, branch: target.remoteBranch }); + await git.gitFetch(gitDirectory, { remote: target.remote, branch: target.remoteBranch }); updateLastLog('done', `Fetched ${target.remote}/${target.remoteBranch}`); } addOperationLog(`Merging ${target.branch} into ${currentBranch}...`, 'running'); - const result = await git.merge(currentDirectory, { branch: target.branch }); + const result = await git.merge(gitDirectory, { branch: target.branch }); if (result.conflict) { updateLastLog('error', `Merge conflicts detected`); setConflictFiles(result.conflictFiles ?? []); setConflictOperation('merge'); setConflictDialogOpen(true); - persistConflictState(currentDirectory, result.conflictFiles ?? [], 'merge'); + persistConflictState(gitDirectory, result.conflictFiles ?? [], 'merge'); } else { updateLastLog('done', `Merged ${target.branch} into ${currentBranch}`); clearConflictState(); @@ -1970,12 +2028,12 @@ export const GitView: React.FC = ({ isActive }) => { } // Note: branchOperation is cleared when dialog closes via handleOperationComplete }, - [currentDirectory, git, status, resolveIntegrationTarget, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs] + [gitDirectory, git, status, resolveIntegrationTarget, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs] ); const handleRebase = React.useCallback( async (branch: string) => { - if (!currentDirectory) return; + if (!gitDirectory) return; setBranchOperation('rebase'); resetOperationLogs(); @@ -1986,19 +2044,19 @@ export const GitView: React.FC = ({ isActive }) => { try { if (target.remote && target.remoteBranch) { addOperationLog(`Fetching ${target.remote}/${target.remoteBranch}...`, 'running'); - await git.gitFetch(currentDirectory, { remote: target.remote, branch: target.remoteBranch }); + await git.gitFetch(gitDirectory, { remote: target.remote, branch: target.remoteBranch }); updateLastLog('done', `Fetched ${target.remote}/${target.remoteBranch}`); } addOperationLog(`Rebasing ${currentBranch} onto ${target.branch}...`, 'running'); - const result = await git.rebase(currentDirectory, { onto: target.branch }); + const result = await git.rebase(gitDirectory, { onto: target.branch }); if (result.conflict) { updateLastLog('error', `Rebase conflicts detected`); setConflictFiles(result.conflictFiles ?? []); setConflictOperation('rebase'); setConflictDialogOpen(true); - persistConflictState(currentDirectory, result.conflictFiles ?? [], 'rebase'); + persistConflictState(gitDirectory, result.conflictFiles ?? [], 'rebase'); } else { updateLastLog('done', `Rebased ${currentBranch} onto ${target.branch}`); clearConflictState(); @@ -2020,18 +2078,18 @@ export const GitView: React.FC = ({ isActive }) => { } // Note: branchOperation is cleared when dialog closes via handleOperationComplete }, - [currentDirectory, git, status, resolveIntegrationTarget, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs] + [gitDirectory, git, status, resolveIntegrationTarget, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs] ); const handleAbortConflict = React.useCallback(async () => { - if (!currentDirectory) return; + if (!gitDirectory) return; try { if (conflictOperation === 'merge') { - await git.abortMerge(currentDirectory); + await git.abortMerge(gitDirectory); toast.success(t('gitView.toast.mergeAborted')); } else { - await git.abortRebase(currentDirectory); + await git.abortRebase(gitDirectory); toast.success(t('gitView.toast.rebaseAborted')); } clearConflictState(); @@ -2041,7 +2099,7 @@ export const GitView: React.FC = ({ isActive }) => { const message = err instanceof Error ? err.message : `Failed to abort ${conflictOperation}`; toast.error(message); } - }, [currentDirectory, git, conflictOperation, refreshStatusAndBranches, refreshLog, clearConflictState, t]); + }, [gitDirectory, git, conflictOperation, refreshStatusAndBranches, refreshLog, clearConflictState, t]); // Count unresolved conflicts (files with 'U' status) const conflictCount = React.useMemo(() => { @@ -2054,19 +2112,19 @@ export const GitView: React.FC = ({ isActive }) => { }, [status?.files]); const handleContinueOperation = React.useCallback(async () => { - if (!currentDirectory) return; + if (!gitDirectory) return; try { const isMerge = !!status?.mergeInProgress?.head; const isRebase = !!(status?.rebaseInProgress?.headName || status?.rebaseInProgress?.onto); if (isMerge) { - const result = await git.continueMerge(currentDirectory); + const result = await git.continueMerge(gitDirectory); if (result.conflict) { setConflictFiles(result.conflictFiles ?? []); setConflictOperation('merge'); setConflictDialogOpen(true); - persistConflictState(currentDirectory, result.conflictFiles ?? [], 'merge'); + persistConflictState(gitDirectory, result.conflictFiles ?? [], 'merge'); toast.error(t('gitView.toast.mergeConflictsDetected')); } else { clearConflictState(); @@ -2075,12 +2133,12 @@ export const GitView: React.FC = ({ isActive }) => { await refreshLog(); } } else if (isRebase) { - const result = await git.continueRebase(currentDirectory); + const result = await git.continueRebase(gitDirectory); if (result.conflict) { setConflictFiles(result.conflictFiles ?? []); setConflictOperation('rebase'); setConflictDialogOpen(true); - persistConflictState(currentDirectory, result.conflictFiles ?? [], 'rebase'); + persistConflictState(gitDirectory, result.conflictFiles ?? [], 'rebase'); toast.error(t('gitView.toast.rebaseConflictsDetected')); } else { clearConflictState(); @@ -2093,18 +2151,18 @@ export const GitView: React.FC = ({ isActive }) => { const message = err instanceof Error ? err.message : t('gitView.toast.continueOperationFailed'); toast.error(message); } - }, [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, persistConflictState, clearConflictState, t]); + }, [gitDirectory, git, status, refreshStatusAndBranches, refreshLog, persistConflictState, clearConflictState, t]); const handleAbortOperation = React.useCallback(async () => { - if (!currentDirectory) return; + if (!gitDirectory) return; try { const isMerge = !!status?.mergeInProgress?.head; if (isMerge) { - await git.abortMerge(currentDirectory); + await git.abortMerge(gitDirectory); toast.success(t('gitView.toast.mergeAborted')); } else { - await git.abortRebase(currentDirectory); + await git.abortRebase(gitDirectory); toast.success(t('gitView.toast.rebaseAborted')); } clearConflictState(); @@ -2114,10 +2172,10 @@ export const GitView: React.FC = ({ isActive }) => { const message = err instanceof Error ? err.message : t('gitView.toast.abortOperationFailed'); toast.error(message); } - }, [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, clearConflictState, t]); + }, [gitDirectory, git, status, refreshStatusAndBranches, refreshLog, clearConflictState, t]); const handleResolveWithAIFromBanner = React.useCallback(() => { - if (!currentDirectory) return; + if (!gitDirectory) return; // Determine operation type from status const isMerge = !!status?.mergeInProgress?.head; @@ -2134,11 +2192,11 @@ export const GitView: React.FC = ({ isActive }) => { } setConflictOperation(operation); setConflictDialogOpen(true); - }, [currentDirectory, status]); + }, [gitDirectory, status]); const handleStashAndRetry = React.useCallback( async (restoreAfter: boolean) => { - if (!currentDirectory) return; + if (!gitDirectory) return; const currentBranch = status?.current; const operation = stashDialogOperation; @@ -2147,12 +2205,12 @@ export const GitView: React.FC = ({ isActive }) => { // Stash changes try { - await git.stash(currentDirectory, { + await git.stash(gitDirectory, { message: `Auto-stash before ${operation} with ${branch}`, includeUntracked: true, }); if (hadStagedChanges) { - bumpIndexRevision(currentDirectory); + bumpIndexRevision(gitDirectory); } } catch (stashErr) { const msg = stashErr instanceof Error ? stashErr.message : 'Failed to stash changes'; @@ -2166,7 +2224,7 @@ export const GitView: React.FC = ({ isActive }) => { try { // Perform the operation if (operation === 'merge') { - const result = await git.merge(currentDirectory, { branch }); + const result = await git.merge(gitDirectory, { branch }); if (result.conflict) { hasConflict = true; setConflictFiles(result.conflictFiles ?? []); @@ -2177,7 +2235,7 @@ export const GitView: React.FC = ({ isActive }) => { toast.success(t('gitView.toast.mergedIntoBranch', { branch, currentBranch: currentBranch || '' })); } } else { - const result = await git.rebase(currentDirectory, { onto: branch }); + const result = await git.rebase(gitDirectory, { onto: branch }); if (result.conflict) { hasConflict = true; setConflictFiles(result.conflictFiles ?? []); @@ -2192,8 +2250,8 @@ export const GitView: React.FC = ({ isActive }) => { // Restore stashed changes if requested and operation succeeded if (restoreAfter && operationSucceeded) { try { - await git.stashPop(currentDirectory); - bumpIndexRevision(currentDirectory); + await git.stashPop(gitDirectory); + bumpIndexRevision(gitDirectory); toast.success(t('gitView.toast.stashedRestored')); } catch (popErr) { const popMessage = popErr instanceof Error ? popErr.message : t('gitView.toast.restoreStashFailed'); @@ -2209,8 +2267,8 @@ export const GitView: React.FC = ({ isActive }) => { // If the operation failed (not due to conflicts), try to restore stash if (restoreAfter) { try { - await git.stashPop(currentDirectory); - bumpIndexRevision(currentDirectory); + await git.stashPop(gitDirectory); + bumpIndexRevision(gitDirectory); } catch { // Ignore stash pop errors in this case } @@ -2218,18 +2276,18 @@ export const GitView: React.FC = ({ isActive }) => { throw err; } }, - [bumpIndexRevision, currentDirectory, git, status, stashDialogOperation, stashDialogBranch, refreshStatusAndBranches, refreshLog, t] + [bumpIndexRevision, gitDirectory, git, status, stashDialogOperation, stashDialogBranch, refreshStatusAndBranches, refreshLog, t] ); const handleLogMaxCountChange = React.useCallback( (count: number) => { setLogMaxCountLocal(count); - if (currentDirectory) { - setLogMaxCount(currentDirectory, count); - fetchLog(currentDirectory, git, count); + if (gitDirectory) { + setLogMaxCount(gitDirectory, count); + fetchLog(gitDirectory, git, count); } }, - [currentDirectory, fetchLog, git, setLogMaxCount] + [gitDirectory, fetchLog, git, setLogMaxCount] ); const handleGraphLogMaxCountChange = React.useCallback((count: number) => { @@ -2238,12 +2296,12 @@ export const GitView: React.FC = ({ isActive }) => { const handleGraphActionSuccess = React.useCallback(() => { setGitLogDialogMode(null); - if (currentDirectory) { - fetchStatus(currentDirectory, git); - fetchBranches(currentDirectory, git); - fetchLog(currentDirectory, git, logMaxCountLocal); + if (gitDirectory) { + fetchStatus(gitDirectory, git); + fetchBranches(gitDirectory, git); + fetchLog(gitDirectory, git, logMaxCountLocal); } - }, [currentDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]); + }, [gitDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]); const handleGraphConflict = React.useCallback((result: { conflict: boolean; @@ -2260,10 +2318,10 @@ export const GitView: React.FC = ({ isActive }) => { files: result.conflictFiles?.join(', ') ?? 'unknown files', }), }); - if (currentDirectory) { - fetchStatus(currentDirectory, git); - fetchBranches(currentDirectory, git); - fetchLog(currentDirectory, git, logMaxCountLocal); + if (gitDirectory) { + fetchStatus(gitDirectory, git); + fetchBranches(gitDirectory, git); + fetchLog(gitDirectory, git, logMaxCountLocal); } return; } @@ -2271,10 +2329,10 @@ export const GitView: React.FC = ({ isActive }) => { setConflictFiles(result.conflictFiles ?? []); setConflictOperation(result.operation); setConflictDialogOpen(true); - if (currentDirectory) { - persistConflictState(currentDirectory, result.conflictFiles ?? [], result.operation); + if (gitDirectory) { + persistConflictState(gitDirectory, result.conflictFiles ?? [], result.operation); } - }, [t, setConflictFiles, setConflictOperation, setConflictDialogOpen, persistConflictState, currentDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]); + }, [t, setConflictFiles, setConflictOperation, setConflictDialogOpen, persistConflictState, gitDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]); if (!currentDirectory) { return ( @@ -2312,20 +2370,65 @@ export const GitView: React.FC = ({ isActive }) => { ); } + // Nested repository discovery: while unknown or failed keep a loading + // state with the failure signal; the picker appears once repositories are + // found (a single repository is auto-selected by an effect above). + if (nestedRepos === undefined || nestedRepos === null) { + return ( +
+ +

+ {nestedRepos === null + ? t('gitView.empty.discoverFailed') + : t('gitView.empty.discoveringRepositories')} +

+ {nestedRepos === null ? ( + + ) : null} +
+ ); + } + + if (nestedRepos.length === 0) { + return ( +
+ +

+ {t('gitView.empty.notGitRepository')} +

+

+ {t('gitView.empty.notGitRepositoryDescription')} +

+ {repairActions.includes('open-without-worktree-features') ? ( +

+ {t('gitView.empty.worktreeFeaturesUnavailable')} +

+ ) : null} +
+ ); + } + + // Repositories were found and are about to be auto-selected (or the + // selected repository is still probing) — hold a brief loading state. return (
- +

- {t('gitView.empty.notGitRepository')} + {t('gitView.loading.checkingRepository')}

-

- {t('gitView.empty.notGitRepositoryDescription')} -

- {repairActions.includes('open-without-worktree-features') ? ( -

- {t('gitView.empty.worktreeFeaturesUnavailable')} -

- ) : null}
); } @@ -2359,8 +2462,16 @@ export const GitView: React.FC = ({ isActive }) => { pullRequest={prChipStatus?.pr ?? null} prChecks={prChipStatus?.checks ?? null} onOpenPullRequest={ - currentDirectory ? () => openContextSurface(currentDirectory, 'pr') : undefined + gitDirectory ? () => openContextSurface(gitDirectory, 'pr') : undefined } + repositoryOptions={gitDirectory !== currentDirectory ? (nestedRepos ?? undefined) : undefined} + selectedRepository={gitDirectory !== currentDirectory ? gitDirectory : null} + onSelectRepository={ + gitDirectory !== currentDirectory && currentDirectory + ? (repository) => selectNestedRepo(currentDirectory, repository) + : undefined + } + repositoryRoot={gitDirectory !== currentDirectory ? currentDirectory : undefined} /> {/* In-progress operation banner */} @@ -2493,10 +2604,10 @@ export const GitView: React.FC = ({ isActive }) => { refreshKey={integrateRefreshKey} showHeader={false} onRefresh={() => { - if (!currentDirectory) return; - fetchStatus(currentDirectory, git); - fetchBranches(currentDirectory, git); - fetchLog(currentDirectory, git, logMaxCountLocal); + if (!gitDirectory) return; + fetchStatus(gitDirectory, git); + fetchBranches(gitDirectory, git); + fetchLog(gitDirectory, git, logMaxCountLocal); }} /> ) : null} @@ -2520,8 +2631,8 @@ export const GitView: React.FC = ({ isActive }) => { setGraphLogRefreshToken((token) => token + 1); return; } - if (!currentDirectory) return; - void fetchLog(currentDirectory, git, logMaxCountLocal); + if (!gitDirectory) return; + void fetchLog(gitDirectory, git, logMaxCountLocal); }} disabled={gitLogDialogMode === 'graph' ? graphLogLoading : isLogLoading} title={t('gitView.history.refresh')} @@ -2553,7 +2664,7 @@ export const GitView: React.FC = ({ isActive }) => { commitFilesMap={commitFilesMap} loadingCommitHashes={loadingCommitHashes} onCopyHash={handleCopyCommitHash} - directory={currentDirectory ?? undefined} + directory={gitDirectory ?? undefined} showHeader={false} contentMaxHeightClassName="h-full max-h-none" branchDivider={gitLogDialogMode === 'graph' ? null : historyBranchDivider} @@ -2567,13 +2678,13 @@ export const GitView: React.FC = ({ isActive }) => { 0} hasStagedChanges={stagedChangeEntries.length > 0} uncommittedFileCount={status?.files?.length ?? 0} onChanged={async (change) => { - if (currentDirectory && change?.affectsIndex) { - bumpIndexRevision(currentDirectory); + if (gitDirectory && change?.affectsIndex) { + bumpIndexRevision(gitDirectory); } await refreshStatusAndBranches(false); await refreshLog(); @@ -2621,12 +2732,12 @@ export const GitView: React.FC = ({ isActive }) => { - {currentDirectory && ( + {gitDirectory && ( 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 = { @@ -258,12 +271,23 @@ export const GitHeader: React.FC = ({ pullRequest, prChecks, onOpenPullRequest, + repositoryOptions, + selectedRepository, + onSelectRepository, + repositoryRoot, }) => { const { t } = useI18n(); if (!status) { return null; } + const repositoryOptionsForPicker = (repositoryOptions ?? []).filter(Boolean); + const repositoryRelativePath = (repository: string): string => { + const rootPrefix = `${repositoryRoot ?? ''}/`; + return repository.startsWith(rootPrefix) ? repository.slice(rootPrefix.length) : repository; + }; + const repositoryLabel = selectedRepository ? repositoryRelativePath(selectedRepository) : ''; + const managementButtons = (
{onOpenHistory || onOpenGraph || onOpenStashes || onOpenUpdateBranch ? ( @@ -410,7 +434,7 @@ export const GitHeader: React.FC = ({ return (
-
+
{isWorktreeMode ? ( = ({ remotes={remotes} /> )} + {repositoryOptionsForPicker.length > 0 ? ( + + ) : null}
{identityControl} diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 24317b24..24aeda2a 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -115,6 +115,23 @@ export async function checkIsGitRepository(directory: string): Promise } } +export async function listGitDirectories(root: string): Promise { + const response = await runtimeFetch('/api/fs/git-dirs', { query: { path: root } }); + if (!response.ok) { + throw new Error(`Failed to list git directories: ${response.statusText}`); + } + const data = await response.json(); + if (!data || !Array.isArray(data.repositories)) { + throw new Error('Unexpected git directories response'); + } + return data.repositories + .map((entry: unknown) => { + const path = entry && typeof entry === 'object' && 'path' in entry ? (entry as { path?: unknown }).path : undefined; + return typeof path === 'string' && path.trim() ? path.trim() : null; + }) + .filter((path: string | null): path is string => path !== null); +} + export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise { const mode = options?.mode; const runtimeKey = getRuntimeKey(); diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index d26bc6c7..ebf16346 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -859,6 +859,10 @@ export const dict = { 'gitView.empty.worktreeFeaturesUnavailable': 'Worktree-Funktionen sind in diesem Arbeitsbereichsmodus nicht verfügbar.', 'gitView.empty.worktreeSetupDescription': 'Arbeitstruktur-Einrichtung wird abgeschlossen und Repository-Zustand wird vorbereitet.', 'gitView.empty.worktreeSetupInProgress': 'Worktree-Einrichtung läuft', + 'gitView.empty.discoveringRepositories': 'Suche nach Git-Repositories...', + 'gitView.empty.discoverFailed': 'Git-Repositories konnten nicht durchsucht werden', + 'gitView.empty.retryDiscovery': 'Erneut versuchen', + 'gitView.empty.selectRepositoryPlaceholder': 'Repository auswählen...', 'worktree.bootstrap.toast.failed': 'Worktree-Einrichtung fehlgeschlagen', 'worktree.bootstrap.toast.failedDescription': 'Die Worktree wurde erstellt, aber die Hintergrund-Einrichtung wurde nicht abgeschlossen.', 'worktree.bootstrap.toast.timeoutDescription': 'Die Worktree wurde erstellt, aber die Hintergrund-Einrichtung hat ein Timeout.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index fd3b05b1..c77b4e06 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -923,6 +923,10 @@ export const dict = { 'gitView.empty.worktreeFeaturesUnavailable': 'Worktree features are unavailable in this workspace mode.', 'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.', 'gitView.empty.worktreeSetupInProgress': 'Worktree setup in progress', + 'gitView.empty.discoveringRepositories': 'Looking for Git repositories...', + 'gitView.empty.discoverFailed': 'Could not scan for Git repositories', + 'gitView.empty.retryDiscovery': 'Retry', + 'gitView.empty.selectRepositoryPlaceholder': 'Select a repository...', 'worktree.bootstrap.toast.failed': 'Worktree setup failed', 'worktree.bootstrap.toast.failedDescription': 'The worktree was created, but background setup did not finish.', 'worktree.bootstrap.toast.timeoutDescription': 'The worktree was created, but background setup timed out.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 3d362620..4667493a 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -924,6 +924,10 @@ export const dict: Record = { "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.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index d85c260c..5eb2d123 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -751,6 +751,10 @@ export const dict = { 'gitView.empty.worktreeFeaturesUnavailable': 'Les fonctionnalités Worktree ne sont pas disponibles dans ce mode d’espace de travail.', 'gitView.empty.worktreeSetupDescription': 'Termine la configuration du worktree et prépare l\'état du dépôt.', 'gitView.empty.worktreeSetupInProgress': 'Configuration de worktree en cours', + 'gitView.empty.discoveringRepositories': 'Recherche des dépôts Git...', + 'gitView.empty.discoverFailed': 'Impossible d’analyser les dépôts Git', + 'gitView.empty.retryDiscovery': 'Réessayer', + 'gitView.empty.selectRepositoryPlaceholder': 'Sélectionnez un dépôt...', 'gitView.gitmoji.empty': 'Aucun gitmoji trouvé', 'gitView.gitmoji.searchPlaceholder': 'Rechercher des gitmoji...', 'gitView.gitmoji.title': 'Insérer un gitmoji', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index e2da8965..ab1df185 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -920,6 +920,10 @@ export const dict: Record = { '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': 'ワークツリーは作成されましたが、バックグラウンドセットアップがタイムアウトしました。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index d9d211d9..587cc4e6 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -924,6 +924,10 @@ export const dict: Record = { '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': '워크트리는 생성되었지만 백그라운드 설정 시간이 초과되었습니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 26b5b47c..18cad7b2 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1958,6 +1958,10 @@ export const dict: Record = { '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.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 2ad738e9..212292b9 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -924,6 +924,10 @@ export const dict: Record = { "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.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 2760c77a..0e016a00 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -924,6 +924,10 @@ export const dict: Record = { "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 створено, але час очікування фонового налаштування минув.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 72aa8057..a4134c69 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -924,6 +924,10 @@ export const dict: Record = { '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': '工作树已创建,但后台设置超时。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index d5106633..e46004cc 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -936,6 +936,10 @@ export const dict: Record = { '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 已建立,但背景設定逾時。', diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index a578d61f..3a7df025 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -133,6 +133,7 @@ Important properties: - loading state is per-directory, not global - `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers - in-flight dedupe exists for status and `ensureAll()` +- nested repository discovery (`nestedReposByRoot`, `nestedRepoSelection`, `ensureNestedRepos`) is per-root state for roots that are not themselves git repositories; discovery failure is a `null` marker (never a valid empty result), selections are persisted per runtime + root, and `useEffectiveGitDirectory(root)` resolves the directory the Git tab operates on (`root` when the root is a repository, the selected nested repository otherwise) - runtime reset replaces all live entries with that runtime's persisted branch seeds and invalidates old completions - status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations - status mutations advance a revision so older refreshes cannot undo optimistic or confirmed index changes diff --git a/packages/ui/src/stores/useGitStore.test.ts b/packages/ui/src/stores/useGitStore.test.ts index 2512989a..a1dd6902 100644 --- a/packages/ui/src/stores/useGitStore.test.ts +++ b/packages/ui/src/stores/useGitStore.test.ts @@ -335,3 +335,61 @@ describe('useGitStore', () => { expect(useGitStore.getState().getDirectoryState('/repo')?.status).toBe(initialStatus); }); }); + +describe('useGitStore nested repository discovery', () => { + beforeEach(() => { + useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey()); + }); + + test('selects a nested repo per root and persists the selection', () => { + useGitStore.getState().selectNestedRepo('/root-a', '/root-a/repo-one'); + + expect(useGitStore.getState().nestedRepoSelection.get('/root-a')).toBe('/root-a/repo-one'); + + // Re-seeding from storage (as a page refresh would) restores the pick. + useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey()); + expect(useGitStore.getState().nestedRepoSelection.get('/root-a')).toBe('/root-a/repo-one'); + }); + + test('keeps selections isolated per root', () => { + useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one'); + useGitStore.getState().selectNestedRepo('/root-b', '/root-b/two'); + + expect(useGitStore.getState().nestedRepoSelection.get('/root-a')).toBe('/root-a/one'); + expect(useGitStore.getState().nestedRepoSelection.get('/root-b')).toBe('/root-b/two'); + }); + + test('clears only the given root selection', () => { + useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one'); + useGitStore.getState().selectNestedRepo('/root-b', '/root-b/two'); + + useGitStore.getState().clearNestedRepoSelection('/root-a'); + + expect(useGitStore.getState().nestedRepoSelection.has('/root-a')).toBe(false); + expect(useGitStore.getState().nestedRepoSelection.get('/root-b')).toBe('/root-b/two'); + }); + + test('runtime switch does not leak selections or discovery across runtimes', () => { + useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one'); + useGitStore.setState({ nestedReposByRoot: new Map([['/root-a', ['/root-a/one']]]) }); + + useGitStore.getState().resetForRuntimeSwitch('runtime-b'); + + expect(useGitStore.getState().nestedRepoSelection.size).toBe(0); + expect(useGitStore.getState().nestedReposByRoot.size).toBe(0); + }); + + test('marks discovery failure as a failed marker, not an empty success', async () => { + await useGitStore.getState().ensureNestedRepos('/root-a'); + + expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toBeNull(); + }); + + test('dedupes concurrent discovery runs for the same root', async () => { + const first = useGitStore.getState().ensureNestedRepos('/root-a'); + const second = useGitStore.getState().ensureNestedRepos('/root-a'); + await Promise.all([first, second]); + + expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toBeNull(); + }); +}); diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index aec942ba..2ec728df 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -9,6 +9,7 @@ import type { } from '@/lib/api/types'; import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { getRuntimeKey } from '@/lib/runtime-switch'; +import { listGitDirectories } from '@/lib/gitApiHttp'; const LOG_STALE_THRESHOLD = 10000; const REPO_CHECK_STALE_THRESHOLD = 60_000; @@ -77,6 +78,16 @@ interface GitStore { setLogMaxCount: (directory: string, maxCount: number) => void; + // Nested repository discovery: when the root directory is not itself a git + // repository, these hold the discovered repositories and the user's pick. + // `nestedReposByRoot` values are `null` when discovery failed — never a + // valid empty result — and absent when discovery has not run yet. + nestedReposByRoot: Map; + nestedRepoSelection: Map; + ensureNestedRepos: (root: string, options?: { force?: boolean }) => Promise; + selectNestedRepo: (root: string, repository: string) => void; + clearNestedRepoSelection: (root: string) => void; + refresh: (git: GitAPI, options?: { force?: boolean }) => Promise; resetForRuntimeSwitch: (runtimeKey: string) => void; } @@ -101,6 +112,7 @@ const inFlightDiffFetchesByDirectory = new Map>(); const diffFetchGenerationByDirectory = new Map(); const inFlightStatusFetches = new Map>(); const inFlightEnsureAllByDirectory = new Map>(); +const inFlightNestedRepoDiscovery = new Map>(); const requestGenerationByChannel = new Map(); const statusMutationRevisionByDirectory = new Map(); let gitRuntimeGeneration = 0; @@ -276,6 +288,59 @@ const seedDirectoriesFromBranchCache = (runtimeKey: string): Map }>; +}; + +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 : 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): 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 => { + 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, @@ -549,6 +614,8 @@ export const useGitStore = create()( runtimeKey: initialGitRuntimeKey, directories: seedDirectoriesFromBranchCache(initialGitRuntimeKey), activeDirectory: null, + nestedReposByRoot: new Map(), + nestedRepoSelection: seedNestedRepoSelection(initialGitRuntimeKey), resetForRuntimeSwitch: (runtimeKey) => { gitRuntimeGeneration += 1; @@ -557,9 +624,16 @@ export const useGitStore = create()( statusMutationRevisionByDirectory.clear(); inFlightStatusFetches.clear(); inFlightEnsureAllByDirectory.clear(); + inFlightNestedRepoDiscovery.clear(); inFlightDiffFetchesByDirectory.clear(); diffFetchGenerationByDirectory.clear(); - set({ runtimeKey, directories: seedDirectoriesFromBranchCache(runtimeKey), activeDirectory: null }); + set({ + runtimeKey, + directories: seedDirectoriesFromBranchCache(runtimeKey), + activeDirectory: null, + nestedReposByRoot: new Map(), + nestedRepoSelection: seedNestedRepoSelection(runtimeKey), + }); }, setActiveDirectory: (directory) => { @@ -1125,6 +1199,66 @@ export const useGitStore = create()( set({ directories: newDirectories }); }, + ensureNestedRepos: async (root, options = {}) => { + if (!root) return; + const { force = false } = options; + const runtimeKey = getRuntimeKey(); + const key = runtimeDirectoryKey(runtimeKey, root); + const current = get().nestedReposByRoot.get(root); + if (!force && (current !== undefined || inFlightNestedRepoDiscovery.has(key))) { + return; + } + + const existing = inFlightNestedRepoDiscovery.get(key); + if (existing) { + await existing; + return; + } + + const discovery = (async () => { + let repositories: string[] | null = null; + try { + repositories = await listGitDirectories(root); + } catch (error) { + console.error('Failed to discover nested git repositories:', error); + repositories = null; + } + + // A failed retry must not clobber an earlier successful discovery. + const previous = get().nestedReposByRoot.get(root); + const nextValue = repositories ?? previous ?? null; + const next = new Map(get().nestedReposByRoot); + next.set(root, nextValue); + set({ nestedReposByRoot: next }); + })(); + + inFlightNestedRepoDiscovery.set(key, discovery); + try { + await discovery; + } finally { + if (inFlightNestedRepoDiscovery.get(key) === discovery) { + inFlightNestedRepoDiscovery.delete(key); + } + } + }, + + selectNestedRepo: (root, repository) => { + if (!root || !repository) return; + const next = new Map(get().nestedRepoSelection); + next.set(root, repository); + set({ nestedRepoSelection: next }); + writeCachedNestedRepoSelection(getRuntimeKey(), Object.fromEntries(next)); + }, + + clearNestedRepoSelection: (root) => { + if (!root) return; + if (!get().nestedRepoSelection.has(root)) return; + const next = new Map(get().nestedRepoSelection); + next.delete(root); + set({ nestedRepoSelection: next }); + writeCachedNestedRepoSelection(getRuntimeKey(), Object.fromEntries(next)); + }, + ensureStatus: async (directory, git) => { const dirState = get().directories.get(directory); const now = Date.now(); @@ -1221,6 +1355,35 @@ export const useIsGitRepo = (directory: string | null) => { }); }; +// Resolves the directory the Git tab operates on. A root that is itself a git +// repository is always used directly; otherwise a per-root nested-repo +// selection (when present) becomes the effective directory. +export const useEffectiveGitDirectory = (root: string | null) => { + return useGitStore((state) => { + if (!root) return null; + if (state.directories.get(root)?.isGitRepo === true) { + return root; + } + return state.nestedRepoSelection.get(root) ?? root; + }); +}; + +// `undefined` = discovery not run yet, `null` = discovery failed, otherwise +// the discovered nested repository paths (possibly empty). +export const useNestedRepos = (root: string | null) => { + return useGitStore((state) => { + if (!root) return undefined; + return state.nestedReposByRoot.get(root); + }); +}; + +export const useNestedRepoSelection = (root: string | null) => { + return useGitStore((state) => { + if (!root) return null; + return state.nestedRepoSelection.get(root) ?? null; + }); +}; + export const useGitBranchLabel = (directory: string | null) => { return useGitStore((state) => { if (!directory) return null; diff --git a/packages/web/server/lib/fs/DOCUMENTATION.md b/packages/web/server/lib/fs/DOCUMENTATION.md index 4629135b..511b3108 100644 --- a/packages/web/server/lib/fs/DOCUMENTATION.md +++ b/packages/web/server/lib/fs/DOCUMENTATION.md @@ -22,6 +22,10 @@ Own filesystem API behavior for the web server runtime, including workspace-boun - `POST /api/fs/exec` - `GET /api/fs/exec/:jobId` - `GET /api/fs/list` + - `GET /api/fs/git-dirs` — shallow nested git repository discovery for the + Git tab (depth- and visit-capped readdir walk; `.git` directory, file, or + symlink marks a repository boundary; junk directories and symlinks are + never descended into) - Owns exec job queue state (`execJobs`) and lifecycle/TTL pruning. - Enforces workspace boundary checks with active project + worktree fallback support. - `createFsSearchRuntime({ fsPromises, path, spawn, resolveGitBinaryForSpawn })` from `search.js` diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js index 10842ef3..44294554 100644 --- a/packages/web/server/lib/fs/routes.js +++ b/packages/web/server/lib/fs/routes.js @@ -249,6 +249,79 @@ const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProject }); }; +// Nested repository discovery bounds: only shallow walks are useful for the +// Git tab's "pick a repository" picker, and deep/monorepo trees can explode +// otherwise. Directories deeper than maxDepth or beyond the visit cap are +// silently not searched. +const GIT_DIRS_MAX_DEPTH = 3; +const GIT_DIRS_MAX_DIRS = 100; +const GIT_DIRS_SKIP_LIST = new Set(['node_modules', 'dist', 'build', '.venv', 'target', '.next']); + +// Walks rootPath and returns every nested git repository path (a directory +// containing a `.git` entry — a directory, a worktree pointer file, or a +// symlink). A repository boundary stops descent: nested repos inside repos +// are not reported. The root itself, when it is a repo, yields no results. +const findGitDirectories = async ({ rootPath, fsPromises, path: pathModule, maxDepth, maxDirs }) => { + const results = []; + let visited = 0; + + const walk = async (dir, depth) => { + if (visited >= maxDirs) { + return; + } + + let dirents; + try { + dirents = await fsPromises.readdir(dir, { withFileTypes: true }); + } catch (error) { + // Unreadable subtree — skip it unless it is the root itself, which the + // route maps to 403/404/500 through the shared error handling. + if (dir === rootPath) { + throw error; + } + return; + } + visited += 1; + + let isRepoBoundary = false; + const subdirectories = []; + for (const dirent of dirents) { + if (dirent.name === '.git') { + isRepoBoundary = true; + continue; + } + if (!dirent.isDirectory() || dirent.isSymbolicLink()) { + continue; + } + if (GIT_DIRS_SKIP_LIST.has(dirent.name)) { + continue; + } + if (depth >= maxDepth) { + continue; + } + subdirectories.push(dirent.name); + } + + if (isRepoBoundary) { + if (dir !== rootPath) { + results.push(dir); + } + return; + } + + subdirectories.sort(); + for (const name of subdirectories) { + if (visited >= maxDirs) { + break; + } + await walk(pathModule.join(dir, name), depth + 1); + } + }; + + await walk(rootPath, 0); + return results; +}; + const deriveCloneDirectoryName = (remoteUrl) => { const remote = typeof remoteUrl === 'string' ? remoteUrl.trim() : ''; if (!remote) return ''; @@ -1461,4 +1534,60 @@ export const registerFsRoutes = (app, dependencies) => { return res.status(500).json({ error: (error && error.message) || 'Failed to list directory' }); } }); + + app.get('/api/fs/git-dirs', async (req, res) => { + const rawPath = typeof req.query.path === 'string' && req.query.path.trim().length > 0 + ? req.query.path.trim() + : ''; + if (!rawPath) { + return res.status(400).json({ error: 'Path is required' }); + } + + try { + const resolved = await resolveWorkspacePathFromContext({ + req, + targetPath: rawPath, + resolveProjectDirectory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); + } + + const stats = await fsPromises.stat(resolved.resolved); + if (!stats.isDirectory()) { + return res.status(400).json({ error: 'Specified path is not a directory', reason: 'not-directory' }); + } + + const repositories = await findGitDirectories({ + rootPath: resolved.resolved, + fsPromises, + path, + maxDepth: GIT_DIRS_MAX_DEPTH, + maxDirs: GIT_DIRS_MAX_DIRS, + }); + + return res.json({ + path: resolved.resolved, + repositories: repositories.map((repoPath) => ({ + path: repoPath, + name: path.basename(repoPath), + })), + }); + } catch (error) { + const err = error; + const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined; + if (code === 'ENOENT') { + return res.status(404).json({ error: 'Directory not found', reason: 'not-found' }); + } + if (isOsPermissionError(err)) { + return sendOsPermissionDenied(res, 'Access to directory denied'); + } + console.error('Failed to find git directories:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to find git directories' }); + } + }); }; diff --git a/packages/web/server/lib/fs/routes.test.js b/packages/web/server/lib/fs/routes.test.js index 1112e333..be8a7cef 100644 --- a/packages/web/server/lib/fs/routes.test.js +++ b/packages/web/server/lib/fs/routes.test.js @@ -823,3 +823,222 @@ describe('fs list symlink path space (issue 2627)', () => { }); } }); + +describe('fs git-dirs', () => { + const createDirent = (name, type) => ({ + name, + isDirectory: () => type === 'dir', + isFile: () => type === 'file', + isSymbolicLink: () => type === 'symlink', + }); + + // tree maps directory path -> [[name, type], ...] + const registerGitDirs = (tree, { stat, readdir: readdirOverride } = {}) => { + const { app, getRoute } = createRouteRegistry(); + const readdir = readdirOverride ?? vi.fn(async (dirPath) => (tree[dirPath] ?? []).map(([name, type]) => createDirent(name, type))); + registerFsRoutes(app, { + os: { homedir: () => '/home/user' }, + path: path.posix, + fsPromises: { + realpath: async (targetPath) => targetPath, + stat: stat ?? vi.fn(async (targetPath) => ({ isDirectory: () => Boolean(tree[targetPath]) })), + readdir, + }, + spawn: vi.fn(), + crypto: { randomUUID: () => 'job-0' }, + normalizeDirectoryPath: (p) => p, + resolveProjectDirectory: async () => ({ directory: '/workspace' }), + buildAugmentedPath: () => '/usr/bin', + resolveGitBinaryForSpawn: () => 'git', + openchamberUserConfigRoot: '/home/user/.config', + }); + return { handler: getRoute('GET', '/api/fs/git-dirs'), readdir }; + }; + + const callGitDirs = async (handler, query) => { + const res = createMockResponse(); + await handler({ query: query ?? {} }, res); + return res; + }; + + it('returns an empty list when the root itself is a repository', async () => { + const { handler, readdir } = registerGitDirs({ + '/workspace': [['.git', 'dir'], ['proj-a', 'dir']], + '/workspace/proj-a': [['.git', 'dir']], + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ path: '/workspace', repositories: [] }); + expect(readdir).toHaveBeenCalledTimes(1); + }); + + it('finds nested repositories with a .git directory', async () => { + const { handler } = registerGitDirs({ + '/workspace': [['proj-a', 'dir'], ['proj-b', 'dir']], + '/workspace/proj-a': [['.git', 'dir'], ['src', 'dir']], + '/workspace/proj-a/src': [['index.ts', 'file']], + '/workspace/proj-b': [['.git', 'dir']], + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.statusCode).toBe(200); + expect(res.body.repositories).toEqual([ + { path: '/workspace/proj-a', name: 'proj-a' }, + { path: '/workspace/proj-b', name: 'proj-b' }, + ]); + }); + + it('treats a .git file (linked worktree) as a repository boundary', async () => { + const { handler } = registerGitDirs({ + '/workspace': [['worktree', 'dir']], + '/workspace/worktree': [['.git', 'file']], + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.statusCode).toBe(200); + expect(res.body.repositories).toEqual([{ path: '/workspace/worktree', name: 'worktree' }]); + }); + + it('stops descending at repository boundaries', async () => { + const { handler, readdir } = registerGitDirs({ + '/workspace': [['outer', 'dir']], + '/workspace/outer': [['.git', 'dir'], ['inner', 'dir']], + '/workspace/outer/inner': [['.git', 'dir']], + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.statusCode).toBe(200); + expect(res.body.repositories).toEqual([{ path: '/workspace/outer', name: 'outer' }]); + expect(readdir).not.toHaveBeenCalledWith('/workspace/outer/inner', { withFileTypes: true }); + }); + + it('does not descend past the depth cap', async () => { + const { handler } = registerGitDirs({ + '/workspace': [['a', 'dir']], + '/workspace/a': [['b', 'dir']], + '/workspace/a/b': [['c', 'dir']], + '/workspace/a/b/c': [['.git', 'dir'], ['d', 'dir']], + '/workspace/a/b/c/d': [['.git', 'dir']], + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.statusCode).toBe(200); + expect(res.body.repositories).toEqual([{ path: '/workspace/a/b/c', name: 'c' }]); + }); + + it('skips junk directories', async () => { + const { handler, readdir } = registerGitDirs({ + '/workspace': [['node_modules', 'dir'], ['dist', 'dir'], ['real', 'dir']], + '/workspace/node_modules': [['dep', 'dir']], + '/workspace/node_modules/dep': [['.git', 'dir']], + '/workspace/dist': [['.git', 'dir']], + '/workspace/real': [['.git', 'dir']], + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.statusCode).toBe(200); + expect(res.body.repositories).toEqual([{ path: '/workspace/real', name: 'real' }]); + expect(readdir).not.toHaveBeenCalledWith('/workspace/node_modules', { withFileTypes: true }); + }); + + it('never descends into symbolic links', async () => { + const { handler } = registerGitDirs({ + '/workspace': [['link', 'symlink'], ['real', 'dir']], + '/workspace/real': [['.git', 'dir']], + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.statusCode).toBe(200); + expect(res.body.repositories).toEqual([{ path: '/workspace/real', name: 'real' }]); + }); + + it('returns repositories in deterministic order', async () => { + const { handler } = registerGitDirs({ + '/workspace': [['zebra', 'dir'], ['alpha', 'dir']], + '/workspace/zebra': [['.git', 'dir']], + '/workspace/alpha': [['.git', 'dir']], + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.body.repositories.map((repo) => repo.name)).toEqual(['alpha', 'zebra']); + }); + + it('returns 400 when path is missing', async () => { + const { handler } = registerGitDirs({}); + + const res = await callGitDirs(handler, {}); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('Path is required'); + }); + + it('returns 400 when the path is not a directory', async () => { + const { handler } = registerGitDirs({ + '/workspace': [['file.txt', 'file']], + }, { + stat: vi.fn(async (targetPath) => ({ isDirectory: () => targetPath !== '/workspace/file.txt' })), + }); + + const res = await callGitDirs(handler, { path: '/workspace/file.txt' }); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ error: 'Specified path is not a directory', reason: 'not-directory' }); + }); + + it('returns 404 when the directory does not exist', async () => { + const error = Object.assign(new Error('missing'), { code: 'ENOENT' }); + const { handler } = registerGitDirs({}, { + stat: vi.fn(async () => { throw error; }), + }); + + const res = await callGitDirs(handler, { path: '/workspace/missing' }); + + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ error: 'Directory not found', reason: 'not-found' }); + }); + + for (const code of ['EACCES', 'EPERM']) { + it(`maps root ${code} to the os-permission contract`, async () => { + const error = Object.assign(new Error('denied'), { code }); + const { handler } = registerGitDirs({}, { + stat: vi.fn(async () => ({ isDirectory: () => true })), + readdir: vi.fn(async () => { throw error; }), + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.statusCode).toBe(403); + expect(res.body).toEqual({ error: 'Access to directory denied', reason: 'os-permission' }); + }); + } + + it('skips unreadable subtrees without failing the scan', async () => { + const tree = { + '/workspace': [['blocked', 'dir'], ['open', 'dir']], + '/workspace/open': [['.git', 'dir']], + }; + const blockedError = Object.assign(new Error('denied'), { code: 'EACCES' }); + const { handler } = registerGitDirs(tree, { + readdir: vi.fn(async (dirPath) => { + if (dirPath === '/workspace/blocked') { + throw blockedError; + } + return (tree[dirPath] ?? []).map(([name, type]) => createDirent(name, type)); + }), + }); + + const res = await callGitDirs(handler, { path: '/workspace' }); + + expect(res.statusCode).toBe(200); + expect(res.body.repositories).toEqual([{ path: '/workspace/open', name: 'open' }]); + }); +}); From 5e3f9d1ba273985ca68038ca27b889513fb5847e Mon Sep 17 00:00:00 2001 From: jaygupta17 Date: Tue, 25 Aug 2026 19:15:35 +0530 Subject: [PATCH 2/9] fix(git): harden nested repository discovery - discard an in-flight discovery when the runtime switches, mirroring the isRequestCurrent pattern, so a late completion cannot repopulate the cleared map and suppress a fresh scan - treat a 501 from /api/fs/git-dirs as an explicit 'unsupported' marker instead of a generic failure; the VS Code webview now answers the route with unsupportedWebRouteResponse so non-repo roots show the honest not-a-repository state without a futile Retry --- packages/ui/src/lib/gitApiHttp.ts | 10 ++++ packages/ui/src/stores/useGitStore.test.ts | 54 +++++++++++++++++++++- packages/ui/src/stores/useGitStore.ts | 38 +++++++++++---- packages/vscode/webview/main.tsx | 4 ++ 4 files changed, 97 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 05ff58b2..0ca0cc8d 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -116,8 +116,18 @@ export async function checkIsGitRepository(directory: string): Promise } } +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 { 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}`); } diff --git a/packages/ui/src/stores/useGitStore.test.ts b/packages/ui/src/stores/useGitStore.test.ts index a1dd6902..20296dba 100644 --- a/packages/ui/src/stores/useGitStore.test.ts +++ b/packages/ui/src/stores/useGitStore.test.ts @@ -1,8 +1,22 @@ -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'; +// 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 } = { + 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 = { promise: Promise; resolve: (value: T) => void; @@ -338,6 +352,9 @@ describe('useGitStore', () => { describe('useGitStore nested repository discovery', () => { beforeEach(() => { + listGitDirectoriesControl.impl = async () => { + throw new Error('network unavailable'); + }; useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey()); }); @@ -379,12 +396,47 @@ describe('useGitStore nested repository discovery', () => { 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'); diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index 2ec728df..0d1dcf2e 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -9,7 +9,7 @@ import type { } from '@/lib/api/types'; import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { getRuntimeKey } from '@/lib/runtime-switch'; -import { listGitDirectories } from '@/lib/gitApiHttp'; +import { GitDirectoriesUnsupportedError, listGitDirectories } from '@/lib/gitApiHttp'; const LOG_STALE_THRESHOLD = 10000; const REPO_CHECK_STALE_THRESHOLD = 60_000; @@ -28,6 +28,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; @@ -81,8 +86,9 @@ interface GitStore { // Nested repository discovery: when the root directory is not itself a git // repository, these hold the discovered repositories and the user's pick. // `nestedReposByRoot` values are `null` when discovery failed — never a - // valid empty result — and absent when discovery has not run yet. - nestedReposByRoot: Map; + // valid empty result — `'unsupported'` when the runtime has no discovery + // route, and absent when discovery has not run yet. + nestedReposByRoot: Map; nestedRepoSelection: Map; ensureNestedRepos: (root: string, options?: { force?: boolean }) => Promise; selectNestedRepo: (root: string, repository: string) => void; @@ -1203,6 +1209,7 @@ export const useGitStore = create()( 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))) { @@ -1217,16 +1224,30 @@ export const useGitStore = create()( const discovery = (async () => { let repositories: string[] | null = null; + let unsupported = false; try { repositories = await listGitDirectories(root); } catch (error) { - console.error('Failed to discover nested git repositories:', error); + if (error instanceof GitDirectoriesUnsupportedError) { + unsupported = true; + } else { + console.error('Failed to discover nested git repositories:', error); + } repositories = null; } - // A failed retry must not clobber an earlier successful discovery. + // 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); - const nextValue = repositories ?? previous ?? null; + // 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 }); @@ -1368,8 +1389,9 @@ export const useEffectiveGitDirectory = (root: string | null) => { }); }; -// `undefined` = discovery not run yet, `null` = discovery failed, otherwise -// the discovered nested repository paths (possibly empty). +// `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; diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 3a83a155..a49ba39f 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -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>('api:session-activity:get') .catch(() => ({})); From cda273d69fdec8dc41abe9cf90e94b41ef582544 Mon Sep 17 00:00:00 2001 From: jaygupta17 Date: Tue, 25 Aug 2026 19:15:53 +0530 Subject: [PATCH 3/9] feat(git): resolve nested repositories in the other git surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract GitView's resolution flow into hooks/useNestedGitDirectory (root probe, discovery, auto-select, stale-selection recovery) so the flow no longer depends on SessionSidebar probing the root first, and reuse it in the pull-request view, walkthrough view, and mobile changes surface — all three now operate on the selected nested repository instead of dead-ending on a non-repo root. Shared pending/failed/unsupported/empty states live in git/NestedRepoResolutionStates; desktop changes inherits the behavior through GitView. Selection stays shared per root, so the picker's pick carries across surfaces. --- packages/ui/src/apps/MobileChangesSurface.tsx | 27 +++- packages/ui/src/components/views/GitView.tsx | 126 ++++-------------- .../src/components/views/PullRequestView.tsx | 79 +++++++---- .../views/git/NestedRepoResolutionStates.tsx | 87 ++++++++++++ .../views/walkthrough/WalkthroughView.tsx | 26 +++- .../ui/src/hooks/useNestedGitDirectory.ts | 98 ++++++++++++++ packages/ui/src/stores/DOCUMENTATION.md | 2 +- 7 files changed, 315 insertions(+), 130 deletions(-) create mode 100644 packages/ui/src/components/views/git/NestedRepoResolutionStates.tsx create mode 100644 packages/ui/src/hooks/useNestedGitDirectory.ts diff --git a/packages/ui/src/apps/MobileChangesSurface.tsx b/packages/ui/src/apps/MobileChangesSurface.tsx index b8e98b59..9d12dd69 100644 --- a/packages/ui/src/apps/MobileChangesSurface.tsx +++ b/packages/ui/src/apps/MobileChangesSurface.tsx @@ -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,7 @@ import { useIsGitRepo, useGitLoadingStatus, } from '@/stores/useGitStore'; +import { NestedRepoResolutionStates } from '@/components/views/git/NestedRepoResolutionStates'; import { getRuntimeKey } from '@/lib/runtime-switch'; type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; @@ -56,12 +58,17 @@ type MobileChangesSurfaceProps = { export const MobileChangesSurface: React.FC = ({ 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 fetchStatus = useGitStore((state) => state.fetchStatus); const fetchBranches = useGitStore((state) => state.fetchBranches); const prefetchDiffs = useGitStore((state) => state.prefetchDiffs); @@ -474,12 +481,22 @@ export const MobileChangesSurface: React.FC = ({ onCl return renderListState(); } - if (isLoadingStatus && isGitRepo === null) { - return renderListState(); + // Non-repo root: surface nested-repository resolution (discovering, failed, + // unsupported, none found, or settling on the auto-selected repository). + if (rootIsGitRepo === false || isGitRepo === false) { + return renderListState( + { + if (rootDirectory) void ensureNestedRepos(rootDirectory, { force: true }); + }} + /> + ); } - if (isGitRepo === false) { - return renderListState(); + if (isLoadingStatus && isGitRepo === null) { + return renderListState(); } if (route.type === 'diff') { diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 0603452c..5317c041 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -18,10 +18,9 @@ import { useIsGitRepo, useGitLoadingStatus, useGitLoadingLog, - useEffectiveGitDirectory, - useNestedRepos, - useNestedRepoSelection, } from '@/stores/useGitStore'; +import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; +import { NestedRepoResolutionStates } from './git/NestedRepoResolutionStates'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { toast } from '@/components/ui'; @@ -256,13 +255,14 @@ export const GitView: React.FC = ({ isActive }) => { // The root the view is anchored to (session/worktree context stays keyed on // it). When the root is not itself a repository and the user picked a nested // one, `gitDirectory` is the effective repository all git data and actions - // operate on. - const rootIsGitRepo = useIsGitRepo(currentDirectory ?? null); - const gitDirectory = useEffectiveGitDirectory(currentDirectory ?? null); + // operate on. The hook owns probing, discovery, auto-select, and + // stale-selection recovery; data fetching below keys off its result. + const { rootIsGitRepo, gitDirectory, nestedRepos, nestedRepoSelection } = useNestedGitDirectory( + currentDirectory ?? null, + { enabled: isActive }, + ); const isGitRepo = useIsGitRepo(gitDirectory ?? null); const status = useGitStatus(gitDirectory ?? null); - const nestedRepos = useNestedRepos(currentDirectory ?? null); - const nestedRepoSelection = useNestedRepoSelection(currentDirectory ?? null); // Authoritative session↔worktree attachment for repair action display const worktreeAttachment = useSessionWorktreeStore((s) => @@ -298,7 +298,6 @@ export const GitView: React.FC = ({ isActive }) => { bumpIndexRevision, ensureNestedRepos, selectNestedRepo, - clearNestedRepoSelection, } = useGitStore(useShallow((state) => ({ setActiveDirectory: state.setActiveDirectory, fetchAll: state.fetchAll, @@ -315,7 +314,6 @@ export const GitView: React.FC = ({ isActive }) => { bumpIndexRevision: state.bumpIndexRevision, ensureNestedRepos: state.ensureNestedRepos, selectNestedRepo: state.selectNestedRepo, - clearNestedRepoSelection: state.clearNestedRepoSelection, }))); const isMobile = useUIStore((state) => state.isMobile); const openContextDiff = useUIStore((state) => state.openContextDiff); @@ -898,37 +896,6 @@ export const GitView: React.FC = ({ isActive }) => { }); }, [isActive, clearDiffCache, gitDirectory, fetchStatus, git]); - // Discover nested repositories once the root probe confirms it is not one. - React.useEffect(() => { - if (!isActive) return; - if (!currentDirectory) return; - if (rootIsGitRepo !== false) return; - void ensureNestedRepos(currentDirectory); - }, [currentDirectory, ensureNestedRepos, isActive, rootIsGitRepo]); - - // Auto-select the first nested repository so the tab opens straight into - // repository data; the header picker switches between repositories. - React.useEffect(() => { - if (!isActive) return; - if (!currentDirectory) return; - if (rootIsGitRepo !== false) return; - if (!nestedRepos || nestedRepos.length === 0) return; - if (nestedRepoSelection) return; - selectNestedRepo(currentDirectory, nestedRepos[0]); - }, [currentDirectory, isActive, nestedRepos, nestedRepoSelection, rootIsGitRepo, selectNestedRepo]); - - // A selected repository that is no longer a git repository is stale: drop - // the selection and re-scan so the picker reflects the current tree. - React.useEffect(() => { - if (!isActive) return; - if (!currentDirectory) return; - if (!nestedRepoSelection) return; - if (gitDirectory === currentDirectory) return; - if (isGitRepo !== false) return; - clearNestedRepoSelection(currentDirectory); - void ensureNestedRepos(currentDirectory, { force: true }); - }, [clearNestedRepoSelection, currentDirectory, ensureNestedRepos, gitDirectory, isActive, isGitRepo, nestedRepoSelection]); - const refreshStatusAndBranches = React.useCallback( async (showErrors = true) => { if (!gitDirectory) return; @@ -2371,66 +2338,25 @@ export const GitView: React.FC = ({ isActive }) => { ); } - // Nested repository discovery: while unknown or failed keep a loading - // state with the failure signal; the picker appears once repositories are - // found (a single repository is auto-selected by an effect above). - if (nestedRepos === undefined || nestedRepos === null) { - return ( -
- -

- {nestedRepos === null - ? t('gitView.empty.discoverFailed') - : t('gitView.empty.discoveringRepositories')} -

- {nestedRepos === null ? ( - - ) : null} -
- ); - } - - if (nestedRepos.length === 0) { - return ( -
- -

- {t('gitView.empty.notGitRepository')} -

-

- {t('gitView.empty.notGitRepositoryDescription')} -

- {repairActions.includes('open-without-worktree-features') ? ( + // Nested repository discovery states (discovering, failed, unsupported, + // none found, or settling on the auto-selected repository). + return ( + { + if (currentDirectory) { + void ensureNestedRepos(currentDirectory, { force: true }); + } + }} + emptyStateFooter={ + repairActions.includes('open-without-worktree-features') ? (

{t('gitView.empty.worktreeFeaturesUnavailable')}

- ) : null} -
- ); - } - - // Repositories were found and are about to be auto-selected (or the - // selected repository is still probing) — hold a brief loading state. - return ( -
- -

- {t('gitView.loading.checkingRepository')} -

-
+ ) : undefined + } + /> ); } @@ -2465,7 +2391,9 @@ export const GitView: React.FC = ({ isActive }) => { onOpenPullRequest={ gitDirectory ? () => openContextSurface(gitDirectory, 'pr') : undefined } - repositoryOptions={gitDirectory !== currentDirectory ? (nestedRepos ?? undefined) : undefined} + repositoryOptions={ + gitDirectory !== currentDirectory && Array.isArray(nestedRepos) ? nestedRepos : undefined + } selectedRepository={gitDirectory !== currentDirectory ? gitDirectory : null} onSelectRepository={ gitDirectory !== currentDirectory && currentDirectory diff --git a/packages/ui/src/components/views/PullRequestView.tsx b/packages/ui/src/components/views/PullRequestView.tsx index baade489..a597b11e 100644 --- a/packages/ui/src/components/views/PullRequestView.tsx +++ b/packages/ui/src/components/views/PullRequestView.tsx @@ -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,7 @@ 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 { deriveBaseBranch } from './git/baseBranch'; const normalizePath = (value?: string | null): string => @@ -36,9 +38,16 @@ 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 } = useGitStore(useShallow((state) => ({ + ensureAll: state.ensureAll, + ensureNestedRepos: state.ensureNestedRepos, + }))); const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); @@ -89,11 +98,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(null); React.useEffect(() => { @@ -122,52 +131,52 @@ export const PullRequestView: React.FC = () => { }, [authoritativeProjectRoot, worktreeMetadata?.projectDirectory]); const [remotes, setRemotes] = React.useState(() => - (currentDirectory ? remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) : undefined) ?? [] + (gitDirectory ? remotesCacheByDirectory.get(remoteCacheKey(gitDirectory)) : undefined) ?? [] ); const [remoteUrl, setRemoteUrl] = React.useState(() => - (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 +249,31 @@ export const PullRequestView: React.FC = () => { worktreeMetadata?.createdFromBranch, ]); - if (!currentDirectory || !currentBranch) { + if (!currentDirectory) { + return ( +
+ +
{t('gitView.pullRequest.title')}
+
{t('gitView.pullRequest.createHint')}
+
+ ); + } + + // Non-repo root: surface nested-repository resolution (discovering, failed, + // unsupported, none found, or settling on the auto-selected repository). + if (rootIsGitRepo === false || isGitRepo === false) { + return ( + { + void ensureNestedRepos(currentDirectory, { force: true }); + }} + /> + ); + } + + if (!currentBranch) { return (
@@ -259,7 +292,7 @@ export const PullRequestView: React.FC = () => { preventOverscroll > 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 + * repositories are resolved 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 = ({ + rootIsGitRepo, + nestedRepos, + onRetryDiscovery, + emptyStateFooter, +}) => { + const { t } = useI18n(); + + if (rootIsGitRepo !== false) return null; + + if (nestedRepos === undefined || nestedRepos === null) { + return ( +
+ +

+ {nestedRepos === null + ? t('gitView.empty.discoverFailed') + : t('gitView.empty.discoveringRepositories')} +

+ {nestedRepos === null ? ( + + ) : null} +
+ ); + } + + if (nestedRepos === 'unsupported' || nestedRepos.length === 0) { + return ( +
+ +

+ {t('gitView.empty.notGitRepository')} +

+

+ {t('gitView.empty.notGitRepositoryDescription')} +

+ {emptyStateFooter} +
+ ); + } + + // Repositories were found and one is about to be auto-selected (or the + // selected repository is still probing) — hold a brief loading state. + return ( +
+ +

+ {t('gitView.loading.checkingRepository')} +

+
+ ); +}; diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index 51b20387..86eada61 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -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,6 +37,7 @@ import { WalkthroughStages } from './WalkthroughStages'; import { useWalkthroughStageProgress } from './useWalkthroughStageProgress'; import { WalkthroughStream } from './WalkthroughStream'; import { WalkthroughToc } from './WalkthroughToc'; +import { NestedRepoResolutionStates } from '@/components/views/git/NestedRepoResolutionStates'; interface WalkthroughViewProps { directory: string; @@ -73,11 +75,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 }: WalkthroughViewProps) => { const { t, locale, locales, label } = useI18n(); const rootRef = useRef(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); + const directory = gitDirectory ?? rootDirectory; + // Panel width, not viewport width: this surface is resizable independently of // the window. useEffect(() => { @@ -484,6 +492,20 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { [activeLanguage, directory, generate, generateDisabled, source] ); + const isGitRepo = useIsGitRepo(gitDirectory || null); + const ensureNestedRepos = useGitStore((state) => state.ensureNestedRepos); + if (rootIsGitRepo === false || isGitRepo === false) { + return ( + { + if (rootDirectory) void ensureNestedRepos(rootDirectory, { force: true }); + }} + /> + ); + } + return (
diff --git a/packages/ui/src/hooks/useNestedGitDirectory.ts b/packages/ui/src/hooks/useNestedGitDirectory.ts new file mode 100644 index 00000000..c0855805 --- /dev/null +++ b/packages/ui/src/hooks/useNestedGitDirectory.ts @@ -0,0 +1,98 @@ +import React from 'react'; +import { useShallow } from 'zustand/react/shallow'; + +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { + useEffectiveGitDirectory, + useGitStore, + useIsGitRepo, + useNestedRepoSelection, + useNestedRepos, +} 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); + + // 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. + React.useEffect(() => { + if (!enabled || !root) return; + if (rootIsGitRepo !== false) return; + if (!nestedRepos || nestedRepos.length === 0) return; + if (nestedRepoSelection) return; + selectNestedRepo(root, nestedRepos[0]); + }, [enabled, nestedRepos, nestedRepoSelection, root, rootIsGitRepo, selectNestedRepo]); + + // 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 }; +}; diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 8dae236a..1897e087 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -148,7 +148,7 @@ Important properties: - loading state is per-directory, not global - `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers - in-flight dedupe exists for status and `ensureAll()` -- nested repository discovery (`nestedReposByRoot`, `nestedRepoSelection`, `ensureNestedRepos`) is per-root state for roots that are not themselves git repositories; discovery failure is a `null` marker (never a valid empty result), selections are persisted per runtime + root, and `useEffectiveGitDirectory(root)` resolves the directory the Git tab operates on (`root` when the root is a repository, the selected nested repository otherwise) +- 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). `hooks/useNestedGitDirectory.ts` owns the resolution flow (root probe, discovery, auto-select, stale-selection recovery) for every consuming surface, and `git/NestedRepoResolutionStates.tsx` renders the shared pending/failed/unsupported/empty states - 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 From e26b55e0677f552e2188f77a23863b21c12a21b9 Mon Sep 17 00:00:00 2001 From: jaygupta17 Date: Tue, 25 Aug 2026 19:55:00 +0530 Subject: [PATCH 4/9] fix(git): exit nested-repo states once resolution succeeds NestedRepoResolutionStates had no success exit: on a non-repo root rootIsGitRepo stays false forever, so once repositories were found the pull-request and walkthrough tabs kept showing 'Checking repository' even after the selected repository probed as a repository. GitView never hit this because its call site sits inside its own isGitRepo === false branch. The component now takes the operating directory's probe and returns null when it resolves true. DiffView also still keyed its not-a-repository gate and every diff fetch off the raw project root, so opening a change from a nested repository showed 'This directory is not a Git repository'. It now resolves the nested repository for git data and diff operations while session-scoped lookups (session messages, review-flow directory) stay on the root. --- packages/ui/src/apps/MobileChangesSurface.tsx | 1 + packages/ui/src/components/views/DiffView.tsx | 11 ++++++++--- packages/ui/src/components/views/GitView.tsx | 1 + .../ui/src/components/views/PullRequestView.tsx | 1 + .../views/git/NestedRepoResolutionStates.tsx | 13 +++++++++++-- .../views/walkthrough/WalkthroughView.tsx | 1 + 6 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/apps/MobileChangesSurface.tsx b/packages/ui/src/apps/MobileChangesSurface.tsx index 9d12dd69..7fa06cbb 100644 --- a/packages/ui/src/apps/MobileChangesSurface.tsx +++ b/packages/ui/src/apps/MobileChangesSurface.tsx @@ -487,6 +487,7 @@ export const MobileChangesSurface: React.FC = ({ onCl return renderListState( { if (rootDirectory) void ensureNestedRepos(rootDirectory, { force: true }); diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 28a15721..823adf72 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useUIStore } from '@/stores/useUIStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; 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 +998,11 @@ export const DiffView: React.FC = ({ }) => { 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 { gitDirectory: nestedGitDirectory } = useNestedGitDirectory(rootDirectory ?? null); + const effectiveDirectory = nestedGitDirectory ?? rootDirectory; const openContextSurface = useUIStore((state) => state.openContextSurface); const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource); const { screenWidth, isMobile } = useDeviceInfo(); @@ -1038,7 +1043,7 @@ export const DiffView: React.FC = ({ 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 +1650,7 @@ export const DiffView: React.FC = ({ 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; diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 5317c041..1d599aef 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -2343,6 +2343,7 @@ export const GitView: React.FC = ({ isActive }) => { return ( { if (currentDirectory) { diff --git a/packages/ui/src/components/views/PullRequestView.tsx b/packages/ui/src/components/views/PullRequestView.tsx index a597b11e..43d6ccbb 100644 --- a/packages/ui/src/components/views/PullRequestView.tsx +++ b/packages/ui/src/components/views/PullRequestView.tsx @@ -265,6 +265,7 @@ export const PullRequestView: React.FC = () => { return ( { void ensureNestedRepos(currentDirectory, { force: true }); diff --git a/packages/ui/src/components/views/git/NestedRepoResolutionStates.tsx b/packages/ui/src/components/views/git/NestedRepoResolutionStates.tsx index 1798c34e..3e9e1e6d 100644 --- a/packages/ui/src/components/views/git/NestedRepoResolutionStates.tsx +++ b/packages/ui/src/components/views/git/NestedRepoResolutionStates.tsx @@ -8,6 +8,12 @@ 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; @@ -17,8 +23,9 @@ type NestedRepoResolutionStatesProps = { /** * Shared empty/loading states for git surfaces while nested-repository - * resolution is pending, failed, or impossible. Renders null once - * repositories are resolved so the consumer can proceed into its own content. + * 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 @@ -26,6 +33,7 @@ type NestedRepoResolutionStatesProps = { */ export const NestedRepoResolutionStates: React.FC = ({ rootIsGitRepo, + resolvedIsGitRepo, nestedRepos, onRetryDiscovery, emptyStateFooter, @@ -33,6 +41,7 @@ export const NestedRepoResolutionStates: React.FC { if (rootDirectory) void ensureNestedRepos(rootDirectory, { force: true }); From 11a48958e84a59148244b7408df850fabc38377f Mon Sep 17 00:00:00 2001 From: jaygupta17 Date: Tue, 25 Aug 2026 20:47:02 +0530 Subject: [PATCH 5/9] fix(git): resolve blank nested-repo surfaces; add repository pickers The resolution gate on the pull-request, walkthrough, and mobile changes surfaces stayed on forever (rootIsGitRepo stays false on a non-repo root) while NestedRepoResolutionStates exits once the selected repository probes as a repository, so those surfaces rendered nothing. The gate now shows resolution states only while the operating directory has not proven to be a repository, matching GitView. Extract GitHeader's repository switcher into git/NestedRepoPicker and mount it in the diff toolbar, a new slim header in the pull-request view, the walkthrough header, and the mobile changes header. The pick is shared per root, so every surface follows. The walkthrough tab mounts keep-alive and hidden; it now receives a visible prop so discovery waits until the tab is actually opened. Add component tests for the shared resolution states. --- packages/ui/src/apps/MobileChangesSurface.tsx | 19 ++++- .../ui/src/components/layout/ContextPanel.tsx | 2 +- packages/ui/src/components/views/DiffView.tsx | 14 +++- .../src/components/views/PullRequestView.tsx | 62 +++++++++++------ .../ui/src/components/views/git/GitHeader.tsx | 46 +++---------- .../components/views/git/NestedRepoPicker.tsx | 67 ++++++++++++++++++ .../git/NestedRepoResolutionStates.test.tsx | 69 +++++++++++++++++++ .../views/walkthrough/WalkthroughView.tsx | 27 +++++++- packages/ui/src/stores/DOCUMENTATION.md | 2 +- 9 files changed, 241 insertions(+), 67 deletions(-) create mode 100644 packages/ui/src/components/views/git/NestedRepoPicker.tsx create mode 100644 packages/ui/src/components/views/git/NestedRepoResolutionStates.test.tsx diff --git a/packages/ui/src/apps/MobileChangesSurface.tsx b/packages/ui/src/apps/MobileChangesSurface.tsx index 7fa06cbb..463886b3 100644 --- a/packages/ui/src/apps/MobileChangesSurface.tsx +++ b/packages/ui/src/apps/MobileChangesSurface.tsx @@ -23,6 +23,7 @@ import { 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; @@ -69,6 +70,7 @@ export const MobileChangesSurface: React.FC = ({ onCl 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); @@ -472,6 +474,16 @@ export const MobileChangesSurface: React.FC = ({ onCl {status?.current || currentDirectory || ''}

+ {rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0 ? ( + { + if (rootDirectory) selectNestedRepo(rootDirectory, repository); + }} + repositoryRoot={rootDirectory ?? undefined} + /> + ) : null}
{state}
@@ -481,9 +493,10 @@ export const MobileChangesSurface: React.FC = ({ onCl return renderListState(); } - // Non-repo root: surface nested-repository resolution (discovering, failed, - // unsupported, none found, or settling on the auto-selected repository). - if (rootIsGitRepo === false || isGitRepo === false) { + // 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( { {hasWalkthroughTab ? (
- +
) : null} diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 823adf72..8791e460 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -3,6 +3,7 @@ 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'; @@ -1001,7 +1002,7 @@ export const DiffView: React.FC = ({ 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 { gitDirectory: nestedGitDirectory } = useNestedGitDirectory(rootDirectory ?? null); + 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); @@ -1012,6 +1013,7 @@ export const DiffView: React.FC = ({ 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); @@ -2044,6 +2046,16 @@ export const DiffView: React.FC = ({ return (
+ {rootIsGitRepo === false && Array.isArray(nestedRepoOptions) && nestedRepoOptions.length > 0 ? ( + { + if (rootDirectory) selectNestedRepo(rootDirectory, repository); + }} + repositoryRoot={rootDirectory ?? undefined} + /> + ) : null} {!isMobile && ( activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' || activeDiffScope === 'branch' ? ( @@ -44,9 +45,10 @@ export const PullRequestView: React.FC = () => { const status = useGitStatus(gitDirectory ?? null); const branches = useGitBranches(gitDirectory ?? null); const isGitRepo = useIsGitRepo(gitDirectory ?? null); - const { ensureAll, ensureNestedRepos } = useGitStore(useShallow((state) => ({ + const { ensureAll, ensureNestedRepos, selectNestedRepo } = useGitStore(useShallow((state) => ({ ensureAll: state.ensureAll, ensureNestedRepos: state.ensureNestedRepos, + selectNestedRepo: state.selectNestedRepo, }))); const currentSessionId = useSessionUIStore((s) => s.currentSessionId); @@ -259,9 +261,10 @@ export const PullRequestView: React.FC = () => { ); } - // Non-repo root: surface nested-repository resolution (discovering, failed, - // unsupported, none found, or settling on the auto-selected repository). - if (rootIsGitRepo === false || isGitRepo === false) { + // 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 ( { ); } + // 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 ( - - - +
+ {showRepositoryPicker ? ( +
+ { + if (currentDirectory) selectNestedRepo(currentDirectory, repository); + }} + repositoryRoot={currentDirectory ?? undefined} + /> +
+ ) : null} + + + +
); }; diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index bf80a40d..8324f7a3 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -12,12 +12,7 @@ import type { IconName } from "@/components/icon/icons"; import { BranchSelector } from './BranchSelector'; import { WorktreeBranchDisplay } from './WorktreeBranchDisplay'; import { SyncActions } from './SyncActions'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, -} from '@/components/ui/select'; +import { NestedRepoPicker } from './NestedRepoPicker'; import type { GitStatus, GitIdentityProfile, @@ -282,11 +277,6 @@ export const GitHeader: React.FC = ({ } const repositoryOptionsForPicker = (repositoryOptions ?? []).filter(Boolean); - const repositoryRelativePath = (repository: string): string => { - const rootPrefix = `${repositoryRoot ?? ''}/`; - return repository.startsWith(rootPrefix) ? repository.slice(rootPrefix.length) : repository; - }; - const repositoryLabel = selectedRepository ? repositoryRelativePath(selectedRepository) : ''; const managementButtons = (
@@ -451,33 +441,13 @@ export const GitHeader: React.FC = ({ remotes={remotes} /> )} - {repositoryOptionsForPicker.length > 0 ? ( - + {repositoryOptionsForPicker.length > 0 && onSelectRepository ? ( + ) : null}
diff --git a/packages/ui/src/components/views/git/NestedRepoPicker.tsx b/packages/ui/src/components/views/git/NestedRepoPicker.tsx new file mode 100644 index 00000000..69b009f4 --- /dev/null +++ b/packages/ui/src/components/views/git/NestedRepoPicker.tsx @@ -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 = ({ + 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 ( + + ); +}; diff --git a/packages/ui/src/components/views/git/NestedRepoResolutionStates.test.tsx b/packages/ui/src/components/views/git/NestedRepoResolutionStates.test.tsx new file mode 100644 index 00000000..8e685852 --- /dev/null +++ b/packages/ui/src/components/views/git/NestedRepoResolutionStates.test.tsx @@ -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): string => + renderToStaticMarkup( + + + , + ); + +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...'); + }); +}); diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index c085fdaa..a504eb34 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -38,9 +38,16 @@ 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']; @@ -75,7 +82,7 @@ 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: rootDirectory }: WalkthroughViewProps) => { +export const WalkthroughView = ({ directory: rootDirectory, visible = true }: WalkthroughViewProps) => { const { t, locale, locales, label } = useI18n(); const rootRef = useRef(null); const [panelWidth, setPanelWidth] = useState(0); @@ -83,7 +90,7 @@ export const WalkthroughView = ({ directory: rootDirectory }: WalkthroughViewPro // 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); + const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null, { enabled: visible }); const directory = gitDirectory ?? rootDirectory; // Panel width, not viewport width: this surface is resizable independently of @@ -494,7 +501,11 @@ export const WalkthroughView = ({ directory: rootDirectory }: WalkthroughViewPro const isGitRepo = useIsGitRepo(gitDirectory || null); const ensureNestedRepos = useGitStore((state) => state.ensureNestedRepos); - if (rootIsGitRepo === false || isGitRepo === false) { + 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 (
+ {rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0 ? ( + { + if (rootDirectory) selectNestedRepo(rootDirectory, repository); + }} + repositoryRoot={rootDirectory ?? undefined} + /> + ) : null}