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' }]); + }); +});