diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index ba5b2ed3..8ef8a75f 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -45,6 +45,7 @@ import { import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useUIStore } from '@/stores/useUIStore'; +import { useDetectedWorktreeMetadata } from '@/hooks/useDetectedWorktreeRoot'; import { IntegrateCommitsSection } from './git/IntegrateCommitsSection'; import { GitHeader } from './git/GitHeader'; @@ -251,7 +252,7 @@ export const GitView: React.FC = () => { return undefined; }, [availableWorktrees, normalizedCurrentDirectory, worktreeMap]); - const worktreeMetadata = React.useMemo(() => { + const storeWorktreeMetadata = React.useMemo(() => { if (currentSessionId) { return worktreeMap.get(currentSessionId) ?? inferredWorktreeMetadata; } @@ -263,12 +264,13 @@ export const GitView: React.FC = () => { return undefined; }, [currentSessionId, inferredWorktreeMetadata, newSessionDraft?.open, worktreeMap]); - const { profiles, globalIdentity, defaultGitIdentityId, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId } = useGitIdentitiesStore(); const isGitRepo = useIsGitRepo(currentDirectory ?? null); const status = useGitStatus(currentDirectory ?? null); + + const worktreeMetadata = useDetectedWorktreeMetadata(currentDirectory, storeWorktreeMetadata, status?.current ?? undefined); const branches = useGitBranches(currentDirectory ?? null); const log = useGitLog(currentDirectory ?? null); const currentIdentity = useGitIdentity(currentDirectory ?? null); @@ -2078,6 +2080,7 @@ export const GitView: React.FC = () => {
{integrateCommitsProps ? ( { const ok = await isCherryPickInProgress(parsed.tempWorktreePath).catch(() => false); + if (cancelled) return; if (!ok) { window.localStorage.removeItem(conflictStorageKey); return; } const details = await getIntegrateConflictDetails(parsed.tempWorktreePath).catch(() => null); + if (cancelled) return; if (!details) { return; } @@ -114,6 +117,9 @@ export const IntegrateCommitsSection: React.FC<{ } catch { window.localStorage.removeItem(conflictStorageKey); } + return () => { + cancelled = true; + }; }, [conflictStorageKey, repoRoot]); React.useEffect(() => { diff --git a/packages/ui/src/hooks/useDetectedWorktreeRoot.ts b/packages/ui/src/hooks/useDetectedWorktreeRoot.ts new file mode 100644 index 00000000..cccf4e7d --- /dev/null +++ b/packages/ui/src/hooks/useDetectedWorktreeRoot.ts @@ -0,0 +1,110 @@ +import React from 'react'; +import { execCommand } from '@/lib/execCommands'; +import type { WorktreeMetadata } from '@/types/worktree'; + +const normalizePath = (value: string): string => { + if (!value) return ''; + const replaced = value.replace(/\\/g, '/'); + if (replaced === '/') return '/'; + return replaced.replace(/\/+$/, ''); +}; + +/** + * Derive the primary worktree (project) root from the absolute git directory. + * + * Secondary worktree: /project/.git/worktrees/ → /project + * Primary worktree: /project/.git → null (not a secondary) + */ +const deriveProjectRoot = (gitDir: string): string | null => { + const normalized = normalizePath(gitDir); + if (!normalized) return null; + + const marker = '/.git/worktrees/'; + const idx = normalized.indexOf(marker); + if (idx > 0) { + return normalized.slice(0, idx) || null; + } + + return null; +}; + +/** + * When the store-based WorktreeMetadata lookup fails, this hook falls back to + * a single `git rev-parse --absolute-git-dir` call to detect whether + * `currentDirectory` is a secondary worktree. If it is, a minimal + * WorktreeMetadata is synthesised so that "Re-integrate commits" and other + * worktree features can function without explicit store entries. + * + * @param currentDirectory Effective directory for the active session/tab. + * @param storeMetadata Result of the normal store-based lookup (may be undefined). + * @param currentBranch Current git branch (from status?.current in the parent). + */ +export function useDetectedWorktreeMetadata( + currentDirectory: string | undefined, + storeMetadata: WorktreeMetadata | undefined, + currentBranch: string | undefined, +): WorktreeMetadata | undefined { + const [detected, setDetected] = React.useState(); + + React.useEffect(() => { + if (storeMetadata) { + setDetected(undefined); + return; + } + + if (!currentDirectory) { + setDetected(undefined); + return; + } + + // Reset immediately so callers never see stale metadata from a previous directory. + setDetected(undefined); + + let cancelled = false; + void (async () => { + const [gitDirResult, toplevelResult] = await Promise.all([ + execCommand('git rev-parse --absolute-git-dir', currentDirectory), + execCommand('git rev-parse --show-toplevel', currentDirectory), + ]); + if (cancelled) return; + + if (!gitDirResult.success || !toplevelResult.success) { + return; + } + + const gitDir = normalizePath((gitDirResult.stdout || '').trim()); + const projectRoot = deriveProjectRoot(gitDir); + + if (!projectRoot) { + return; + } + + // Use the worktree toplevel, not the active sub-directory, so that + // worktree operations (e.g. `git worktree remove`) receive a valid root path. + const worktreePath = normalizePath((toplevelResult.stdout || '').trim()); + + // Sanity-check: secondary worktree path must differ from project root + if (!worktreePath || worktreePath === projectRoot) { + return; + } + + const branch = currentBranch || ''; + const name = worktreePath.split('/').filter(Boolean).pop() || worktreePath; + + setDetected({ + source: 'sdk', + path: worktreePath, + projectDirectory: projectRoot, + branch, + label: branch || name, + name, + }); + })(); + + return () => { + cancelled = true; + }; + }, [currentDirectory, storeMetadata, currentBranch]); + + return storeMetadata ?? detected; +} diff --git a/packages/ui/src/lib/git/integrateWorktreeCommits.ts b/packages/ui/src/lib/git/integrateWorktreeCommits.ts index a1fb8bae..507d8e53 100644 --- a/packages/ui/src/lib/git/integrateWorktreeCommits.ts +++ b/packages/ui/src/lib/git/integrateWorktreeCommits.ts @@ -355,5 +355,5 @@ export async function continueIntegrate(state: IntegrateInProgress): Promise undefined); - return { kind: 'success', moved: remaining.length }; + return { kind: 'success', moved: state.remainingCommits.length }; }