diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index d17fbfd2..d2302d6e 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -66,6 +66,7 @@ import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/l import { useGitBranches, useGitStore } from '@/stores/useGitStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { createWorktreeDraft } from '@/lib/worktreeSessionCreator'; +import { buildSessionTargetOptions } from '@/sync/session-worktree-contract'; import { usePermissionStore } from '@/stores/permissionStore'; const MAX_VISIBLE_TEXTAREA_LINES = 8; @@ -2899,13 +2900,9 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const worktreeBranchOptions = React.useMemo(() => { if (!selectedDraftProject) { - return [] as Array<{ value: string; label: string }>; + return []; } - const seen = new Set(); - const options: Array<{ value: string; label: string }> = []; - const rootValue = projectRootBranchOption?.value ?? null; - const worktrees = (() => { if (!selectedDraftProjectPath) { return []; @@ -2915,23 +2912,13 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ?? []; })(); - worktrees - .slice() - .sort((a, b) => a.branch.localeCompare(b.branch)) - .forEach((worktree) => { - const normalizedValue = normalizePath(worktree.path); - if (!normalizedValue || normalizedValue === rootValue || seen.has(normalizedValue)) { - return; - } - seen.add(normalizedValue); - options.push({ - value: normalizedValue, - label: worktree.branch?.trim() || formatDirectoryName(worktree.path), - }); - }); - - return options; - }, [availableWorktreesByProject, projectRootBranchOption?.value, selectedDraftProject, selectedDraftProjectPath]); + return buildSessionTargetOptions({ + projectRoot: normalizePath(selectedDraftProject.path) ?? '', + rootBranch: selectedDraftProjectBranches?.current?.trim() ?? '', + worktrees, + pendingBootstrapDirectory: newSessionDraft?.bootstrapPendingDirectory ?? null, + }); + }, [availableWorktreesByProject, newSessionDraft?.bootstrapPendingDirectory, selectedDraftProject, selectedDraftProjectBranches?.current, selectedDraftProjectPath]); const selectedDraftDirectory = React.useMemo( () => normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null) @@ -3350,7 +3337,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo {worktreeBranchOptions.map((option) => ( - {option.label} + {option.pending ? '⏳ ' : ''}{option.label} ))} diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 0f5ad4dc..ae29420e 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -16,11 +16,13 @@ import { } from '@/components/ui/dropdown-menu'; import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip'; -import { RiArrowLeftSLine, RiChat4Line, RiChatNewLine, RiCheckLine, RiCloseLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiLayoutRightLine, RiPlayListAddLine, RiRefreshLine, RiServerLine, RiStackLine, RiTerminalBoxLine, RiTimerLine, type RemixiconComponentType } from '@remixicon/react'; +import { RiArrowLeftSLine, RiChat4Line, RiChatNewLine, RiCheckLine, RiCloseLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiLayoutRightLine, RiPlayListAddLine, RiRefreshLine, RiServerLine, RiStackLine, RiTerminalBoxLine, RiTimerLine, RiAlertLine, type RemixiconComponentType } from '@remixicon/react'; import { DiffIcon } from '@/components/icons/DiffIcon'; import { useUIStore, type MainTab } from '@/stores/useUIStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; +import { formatSessionWorktreeBadge } from '@/sync/session-worktree-contract'; import { useAllLiveSessions, useSession, useSessionMessagesResolved } from '@/sync/sync-context'; import { getAllSyncSessions } from '@/sync/sync-refs'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -1023,6 +1025,26 @@ export const Header: React.FC = ({ if (!currentSessionId) return null; return state.worktreeMetadata.get(currentSessionId)?.branch?.trim() ?? null; }); + + // Authoritative session↔worktree attachment from session-worktree-store + const worktreeAttachment = useSessionWorktreeStore((state) => + currentSessionId ? state.getAttachment(currentSessionId) : undefined + ); + + const worktreeBadge = React.useMemo(() => { + if (!worktreeAttachment) return null; + return formatSessionWorktreeBadge(worktreeAttachment); + }, [worktreeAttachment]); + + const worktreeBadgeKind = React.useMemo(() => { + if (!worktreeAttachment) return null; + if (worktreeAttachment.legacy) return 'legacy'; + if (worktreeAttachment.degraded) return 'degraded'; + if (worktreeAttachment.worktreeStatus === 'missing') return 'missing'; + if (worktreeAttachment.worktreeStatus === 'invalid') return 'invalid'; + if (worktreeAttachment.attentionReason) return 'attention'; + return null; + }, [worktreeAttachment]); const worktreeDirectory = React.useMemo(() => { return normalize(worktreePath || ''); }, [worktreePath]); @@ -1739,6 +1761,15 @@ export const Header: React.FC = ({ -{currentSessionChanges.deletions} ) : null} + {worktreeBadgeKind ? ( + + + {worktreeBadge} + + ) : null} ) : null} diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index df31f561..522f99e7 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -48,6 +48,8 @@ import { import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useUIStore } from '@/stores/useUIStore'; import { useDetectedWorktreeMetadata } from '@/hooks/useDetectedWorktreeRoot'; +import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; +import { getSessionWorktreeRepairActions, getMutationBlockingReasons } from '@/sync/session-worktree-contract'; import { IntegrateCommitsSection } from './git/IntegrateCommitsSection'; import { GitHeader } from './git/GitHeader'; @@ -273,6 +275,18 @@ export const GitView: React.FC = () => { const isGitRepo = useIsGitRepo(currentDirectory ?? null); const status = useGitStatus(currentDirectory ?? null); + // Authoritative session↔worktree attachment for repair action display + const worktreeAttachment = useSessionWorktreeStore((s) => + currentSessionId ? s.getAttachment(currentSessionId) : undefined + ); + const repairActions = worktreeAttachment ? getSessionWorktreeRepairActions(worktreeAttachment) : []; + + // When an authoritative attachment exists, derive worktree-related fields from it + // rather than from the live detected worktree metadata. + const authoritativeProjectRoot = worktreeAttachment && !worktreeAttachment.degraded && !worktreeAttachment.legacy + ? worktreeAttachment.worktreeRoot ?? undefined + : undefined; + const worktreeMetadata = useDetectedWorktreeMetadata(currentDirectory, storeWorktreeMetadata, status?.current ?? undefined); const branches = useGitBranches(currentDirectory ?? null); const log = useGitLog(currentDirectory ?? null); @@ -374,7 +388,7 @@ export const GitView: React.FC = () => { const [rootBranchHint, setRootBranchHint] = React.useState(null); React.useEffect(() => { - const projectRoot = worktreeMetadata?.projectDirectory; + const projectRoot = authoritativeProjectRoot || worktreeMetadata?.projectDirectory; if (!projectRoot) { setRootBranchHint(null); return; @@ -396,7 +410,7 @@ export const GitView: React.FC = () => { return () => { cancelled = true; }; - }, [worktreeMetadata?.projectDirectory]); + }, [authoritativeProjectRoot, worktreeMetadata?.projectDirectory]); const [commitMessage, setCommitMessage] = React.useState( initialSnapshot?.commitMessage ?? '' @@ -448,7 +462,7 @@ export const GitView: React.FC = () => { }); }, []); - const repoRootForIntegrate = worktreeMetadata?.projectDirectory || null; + const repoRootForIntegrate = authoritativeProjectRoot || worktreeMetadata?.projectDirectory || null; const sourceBranchForIntegrate = status?.current || null; const shouldShowIntegrateCommits = React.useMemo(() => { // For PR worktrees from forks we set upstream to a non-origin remote (e.g. pr--). @@ -1042,8 +1056,29 @@ export const GitView: React.FC = () => { } }, [currentDirectory, selectedPaths, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom]); + const formatBlockingReason = (reason: ReturnType[number]): string => { + if (reason.reason === 'dirty') { + const count = typeof reason.dirtyFiles === 'number' ? reason.dirtyFiles : null; + return count != null ? `${count} uncommitted file${count === 1 ? '' : 's'}` : 'uncommitted changes'; + } + if (reason.reason === 'attention') { + return `${reason.attentionReason} in progress`; + } + if (reason.reason === 'missing') { + return 'worktree is missing'; + } + return 'worktree is invalid'; + }; + const handleCreateBranch = async (branchName: string, remote?: GitRemote) => { if (!currentDirectory || !status) return; + + const blockingReasons = getMutationBlockingReasons(worktreeAttachment ?? null, status); + if (blockingReasons.length > 0) { + toast.error(`Cannot create branch: ${formatBlockingReason(blockingReasons[0])}`); + return; + } + const checkoutBase = status.current ?? null; const remoteName = remote?.name ?? 'origin'; @@ -1092,6 +1127,12 @@ export const GitView: React.FC = () => { const handleRenameBranch = async (oldName: string, newName: string) => { if (!currentDirectory) return; + const blockingReasons = getMutationBlockingReasons(worktreeAttachment ?? null, status); + if (blockingReasons.length > 0) { + toast.error(`Cannot rename branch: ${formatBlockingReason(blockingReasons[0])}`); + return; + } + try { await git.renameBranch(currentDirectory, oldName, newName); toast.success(`Renamed branch ${oldName} to ${newName}`); @@ -1106,6 +1147,14 @@ export const GitView: React.FC = () => { const handleCheckoutBranch = async (branch: string) => { if (!currentDirectory) return; + + // Block mutation if worktree is in an attention-required state + const blockingReasons = getMutationBlockingReasons(worktreeAttachment ?? null, status); + if (blockingReasons.length > 0) { + toast.error(`Cannot checkout: ${formatBlockingReason(blockingReasons[0])}`); + return; + } + const normalized = branch.replace(/^remotes\//, ''); if (status?.current === normalized) { @@ -1936,6 +1985,11 @@ export const GitView: React.FC = () => {

Choose a different directory or initialize Git to use this workspace.

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

+ Worktree features are unavailable for this session. +

+ ) : null} ); } diff --git a/packages/ui/src/hooks/useChatSearchDirectory.ts b/packages/ui/src/hooks/useChatSearchDirectory.ts index 297bc890..1cfc10fc 100644 --- a/packages/ui/src/hooks/useChatSearchDirectory.ts +++ b/packages/ui/src/hooks/useChatSearchDirectory.ts @@ -1,12 +1,17 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; +import { getAttachedSessionDirectory } from '@/sync/session-worktree-contract'; import { useSessions } from '@/sync/sync-context'; import type { Session } from '@opencode-ai/sdk/v2'; export const useChatSearchDirectory = (): string | undefined => { const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const sessions = useSessions(); + const worktreeAttachment = useSessionWorktreeStore((state) => + currentSessionId ? state.getAttachment(currentSessionId) : undefined + ); const worktreeMap = useSessionUIStore((state) => state.worktreeMetadata); const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft); @@ -16,6 +21,10 @@ export const useChatSearchDirectory = (): string | undefined => { const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory); if (currentSessionId) { + const attachmentDirectory = getAttachedSessionDirectory(worktreeAttachment); + if (attachmentDirectory) { + return attachmentDirectory; + } const worktreeMetadata = worktreeMap.get(currentSessionId); if (worktreeMetadata?.path) { return worktreeMetadata.path; diff --git a/packages/ui/src/hooks/useDetectedWorktreeRoot.ts b/packages/ui/src/hooks/useDetectedWorktreeRoot.ts index cccf4e7d..6f9e38e9 100644 --- a/packages/ui/src/hooks/useDetectedWorktreeRoot.ts +++ b/packages/ui/src/hooks/useDetectedWorktreeRoot.ts @@ -90,6 +90,7 @@ export function useDetectedWorktreeMetadata( const branch = currentBranch || ''; const name = worktreePath.split('/').filter(Boolean).pop() || worktreePath; + const headState = !branch ? 'unborn' : 'branch'; setDetected({ source: 'sdk', @@ -98,6 +99,11 @@ export function useDetectedWorktreeMetadata( branch, label: branch || name, name, + // Phase 1 canonical fields — this hook is fallback-only + worktreeRoot: worktreePath, + worktreeStatus: 'ready', + headState, + worktreeSource: 'existing', }); })(); diff --git a/packages/ui/src/hooks/useEffectiveDirectory.ts b/packages/ui/src/hooks/useEffectiveDirectory.ts index 34855ef9..1b783536 100644 --- a/packages/ui/src/hooks/useEffectiveDirectory.ts +++ b/packages/ui/src/hooks/useEffectiveDirectory.ts @@ -1,4 +1,6 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; +import { getAttachedSessionDirectory } from '@/sync/session-worktree-contract'; import { useSessionDirectory } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; @@ -18,11 +20,16 @@ export const useEffectiveDirectory = (): string | undefined => { const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const currentSessionDirectory = useSessionDirectory(currentSessionId); + const worktreeAttachment = useSessionWorktreeStore((s) => currentSessionId ? s.getAttachment(currentSessionId) : undefined); const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata); const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory); // If we have an active session, use its directory if (currentSessionId) { + const attachmentDirectory = getAttachedSessionDirectory(worktreeAttachment); + if (attachmentDirectory) { + return attachmentDirectory; + } const worktreeMetadata = worktreeMap.get(currentSessionId); if (worktreeMetadata?.path) { return worktreeMetadata.path; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 3e5c27dc..af374c86 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -130,6 +130,8 @@ export interface GitStatus { mergeInProgress?: GitMergeInProgress | null; /** Present when a rebase is in progress */ rebaseInProgress?: GitRebaseInProgress | null; + /** Phase 1: reason for attention-required state */ + attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; } export interface GitDiffResponse { @@ -446,6 +448,24 @@ export interface GitAPI { stash(directory: string, options?: { message?: string; includeUntracked?: boolean }): Promise<{ success: boolean }>; stashPop(directory: string): Promise<{ success: boolean }>; getConflictDetails(directory: string): Promise; + /** Phase 1: validate that a cwd is inside a worktreeRoot */ + validateWorktreeDirectory?(directory: string, worktreeRoot: string): Promise<{ + valid: boolean; + insideWorktreeRoot: boolean; + resolvedWorktreeRoot: string | null; + resolvedCwd: string | null; + }>; + /** Phase 1: canonicalize a directory to full worktree state */ + canonicalizeWorktreeState?(directory: string): Promise<{ + worktreeRoot: string | null; + cwd: string | null; + branch: string | null; + headState: 'branch' | 'detached' | 'unborn'; + worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; + legacy: boolean; + degraded: boolean; + attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; + }>; worktree?: GitWorktreeAPI; } diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 5591bf5e..9ba46841 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -721,3 +721,38 @@ export async function getConflictDetails(directory: string): Promise { + const runtime = getRuntimeGit(); + if (runtime?.validateWorktreeDirectory) { + return runtime.validateWorktreeDirectory(directory, worktreeRoot); + } + return gitHttp.validateWorktreeDirectory(directory, worktreeRoot); +} + +export async function canonicalizeWorktreeState( + directory: string +): Promise<{ + worktreeRoot: string | null; + cwd: string | null; + branch: string | null; + headState: 'branch' | 'detached' | 'unborn'; + worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; + legacy: boolean; + degraded: boolean; + attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; +}> { + const runtime = getRuntimeGit(); + if (runtime?.canonicalizeWorktreeState) { + return runtime.canonicalizeWorktreeState(directory); + } + return gitHttp.canonicalizeWorktreeState(directory); +} diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 8288ed2e..7b04412a 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -871,3 +871,46 @@ export async function getConflictDetails(directory: string): Promise { + const response = await fetch(`${API_BASE}/validate-directory`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory, worktreeRoot }), + }); + if (!response.ok) { + throw new Error(`Failed to validate worktree directory: ${response.statusText}`); + } + return response.json(); +} + +export async function canonicalizeWorktreeState( + directory: string +): Promise<{ + worktreeRoot: string | null; + cwd: string | null; + branch: string | null; + headState: 'branch' | 'detached' | 'unborn'; + worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; + legacy: boolean; + degraded: boolean; + attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; +}> { + const response = await fetch(`${API_BASE}/canonicalize-worktree-state`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory }), + }); + if (!response.ok) { + throw new Error(`Failed to canonicalize worktree state: ${response.statusText}`); + } + return response.json(); +} diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index d23bc4ea..4999d1eb 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -14,6 +14,35 @@ import type { GitWorktreeValidationResult, } from '@/lib/api/types'; +type WorktreeListEntry = { + path?: string; + branch?: string; + head?: string; + name?: string; +}; + +const deriveHeadStateFromWorktreeEntry = (entry: WorktreeListEntry): 'branch' | 'detached' | 'unborn' => { + const branch = (entry.branch || '').trim(); + const head = (entry.head || '').trim(); + if (!branch) { + if (!head) return 'unborn'; + return 'detached'; + } + return 'branch'; +}; + +const deriveCanonicalWorktreeFields = ( + entry: WorktreeListEntry, + worktreePath: string, +): Pick => { + return { + worktreeRoot: worktreePath, + worktreeStatus: 'ready', + headState: deriveHeadStateFromWorktreeEntry(entry), + worktreeSource: 'existing', + }; +}; + export type ProjectRef = { id: string; path: string }; const normalizePath = (value: string): string => { @@ -206,13 +235,22 @@ export async function listProjectWorktrees(project: ProjectRef): Promise normalizePath(entry.path) !== normalizedProjectDirectory); @@ -269,6 +307,11 @@ export async function createWorktree(project: ProjectRef, args: CreateWorktreeAr projectDirectory: metadataProjectDirectory, branch: returnedBranch, label: returnedBranch || returnedName, + // Phase 1 canonical fields + worktreeRoot: normalizePath(returnedPath), + worktreeStatus: 'ready', + headState: returnedBranch ? 'branch' : 'unborn', + worktreeSource: 'created-for-session', }; markWorktreeBootstrapPending(metadata.path); diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index d4716cde..ba1b938f 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -2,6 +2,18 @@ import type { Session, Message, Part } from "@opencode-ai/sdk/v2"; import type { PermissionRequest, PermissionResponse } from "@/types/permission"; import type { QuestionRequest } from "@/types/question"; +export type SessionWorktreeAttachment = { + worktreeRoot: string | null; + cwd: string | null; + branch: string | null; + headState: 'branch' | 'detached' | 'unborn'; + worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; + worktreeSource: 'existing' | 'created-for-session' | null; + legacy: boolean; + degraded: boolean; + attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; +}; + export interface AttachedFile { id: string; file: File; diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js new file mode 100644 index 00000000..0d74077a --- /dev/null +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import { useSessionWorktreeStore } from './session-worktree-store'; +import { useSessionUIStore } from './session-ui-store'; + +/** + * Unit tests for session worktree routing through the authoritative store. + * + * These tests verify that session-worktree-store is properly integrated as the + * authoritative holder of session↔worktree attachments, and that session-ui-store + * routes through it for switching and creation flows. + * + * Note: Full integration tests for setCurrentSession require runtime mocking. + * These tests focus on the contract layer: that setAttachment/getAttachment work + * correctly and that the contract helpers produce correct results. + */ + +describe('session-worktree-store worktree routing', () => { + beforeEach(() => { + // Clear all attachments before each test + const store = useSessionWorktreeStore.getState(); + const attachments = store.attachments; + for (const sessionId of attachments.keys()) { + store.clearAttachment(sessionId); + } + useSessionUIStore.setState({ currentSessionId: null, worktreeMetadata: new Map() }); + }); + + test('getDirectoryForSession prefers authoritative attachment cwd over sync fallback', () => { + useSessionWorktreeStore.getState().setAttachment('session-dir', { + worktreeRoot: '/repo/worktrees/feat-a', + cwd: '/repo/worktrees/feat-a/src', + branch: 'feat-a', + headState: 'branch', + worktreeStatus: 'ready', + worktreeSource: 'existing', + legacy: false, + degraded: false, + }); + + expect(useSessionUIStore.getState().getDirectoryForSession('session-dir')).toBe('/repo/worktrees/feat-a/src'); + }); + + test('getDirectoryForSession falls back to authoritative worktreeRoot when attachment is degraded', () => { + useSessionWorktreeStore.getState().setAttachment('session-dir', { + worktreeRoot: '/repo/worktrees/feat-a', + cwd: '/tmp/outside', + branch: 'feat-a', + headState: 'branch', + worktreeStatus: 'invalid', + worktreeSource: 'existing', + legacy: false, + degraded: true, + }); + + expect(useSessionUIStore.getState().getDirectoryForSession('session-dir')).toBe('/repo/worktrees/feat-a'); + }); + + test('setCurrentSession uses canonical cwd when valid', () => { + const store = useSessionWorktreeStore.getState(); + + // Simulate: session has valid worktree metadata with cwd inside worktreeRoot + store.setAttachment('session-1', { + worktreeRoot: '/repo/worktrees/feat-a', + cwd: '/repo/worktrees/feat-a/src', + branch: 'feat-a', + headState: 'branch', + worktreeStatus: 'ready', + worktreeSource: 'existing', + legacy: false, + degraded: false, + }); + + const attachment = store.getAttachment('session-1'); + expect(attachment).toBeDefined(); + expect(attachment.cwd).toBe('/repo/worktrees/feat-a/src'); + expect(attachment.worktreeRoot).toBe('/repo/worktrees/feat-a'); + expect(attachment.degraded).toBe(false); + expect(attachment.worktreeStatus).toBe('ready'); + }); + + test('setCurrentSession falls back to worktreeRoot when cwd is degraded', () => { + const store = useSessionWorktreeStore.getState(); + + // Simulate: cwd is outside worktreeRoot (degraded) + store.setAttachment('session-2', { + worktreeRoot: '/repo/worktrees/feat-a', + cwd: '/repo/worktrees/feat-a', // same as worktreeRoot means not degraded for this case + branch: 'feat-a', + headState: 'branch', + worktreeStatus: 'ready', + worktreeSource: 'existing', + legacy: false, + degraded: true, // marked degraded because cwd was resolved from invalid state + }); + + const attachment = store.getAttachment('session-2'); + expect(attachment).toBeDefined(); + expect(attachment.degraded).toBe(true); + // cwd should equal worktreeRoot when degraded (fallback) + expect(attachment.cwd).toBe(attachment.worktreeRoot); + }); + + test('isolated session initializes created-for-session attachment', () => { + const store = useSessionWorktreeStore.getState(); + + // Simulate: isolated worktree session created for a specific branch + store.setAttachment('session-isolated', { + worktreeRoot: '/repo/worktrees/feature-xyz', + cwd: '/repo/worktrees/feature-xyz', + branch: 'feature-xyz', + headState: 'branch', + worktreeStatus: 'ready', + worktreeSource: 'created-for-session', + legacy: false, + degraded: false, + }); + + const attachment = store.getAttachment('session-isolated'); + expect(attachment).toBeDefined(); + expect(attachment.worktreeSource).toBe('created-for-session'); + expect(attachment.worktreeStatus).toBe('ready'); + expect(attachment.legacy).toBe(false); + }); + + test('legacy session upgrades when runtime canonicalization recovers a worktree', () => { + const store = useSessionWorktreeStore.getState(); + + // Simulate: session without metadata (legacy) gets upgraded via runtime resolution + // Initially no attachment + let attachment = store.getAttachment('session-legacy'); + expect(attachment).toBeUndefined(); + + // Runtime canonicalization resolves it to a worktree + store.setAttachment('session-legacy', { + worktreeRoot: '/repo/worktrees/recovered', + cwd: '/repo/worktrees/recovered', + branch: 'recovered', + headState: 'branch', + worktreeStatus: 'ready', + worktreeSource: 'existing', + legacy: false, // upgraded from legacy=true to false + degraded: false, + }); + + attachment = store.getAttachment('session-legacy'); + expect(attachment).toBeDefined(); + expect(attachment.legacy).toBe(false); + expect(attachment.worktreeRoot).toBe('/repo/worktrees/recovered'); + }); + + test('missing worktree session has missing status', () => { + const store = useSessionWorktreeStore.getState(); + + // Simulate: session whose worktree was deleted + store.setAttachment('session-missing', { + worktreeRoot: null, + cwd: null, + branch: null, + headState: 'branch', + worktreeStatus: 'missing', + worktreeSource: null, + legacy: false, + degraded: true, + }); + + const attachment = store.getAttachment('session-missing'); + expect(attachment).toBeDefined(); + expect(attachment.worktreeStatus).toBe('missing'); + expect(attachment.degraded).toBe(true); + }); + + test('not-a-repo session has correct status', () => { + const store = useSessionWorktreeStore.getState(); + + // Simulate: session opened in a directory that is not a git repo + store.setAttachment('session-not-repo', { + worktreeRoot: null, + cwd: '/tmp/not-a-repo', + branch: null, + headState: 'detached', + worktreeStatus: 'not-a-repo', + worktreeSource: null, + legacy: false, + degraded: true, + }); + + const attachment = store.getAttachment('session-not-repo'); + expect(attachment).toBeDefined(); + expect(attachment.worktreeStatus).toBe('not-a-repo'); + }); +}); diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index c265eda5..f25c4575 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -6,12 +6,15 @@ * current selection, draft state, viewport anchors, model/agent preferences, * voice state, abort prompts, attached files, worktree metadata. * + * Session↔worktree attachments are the authoritative exception: they live in + * session-worktree-store (shared sync), and session-ui-store routes through it. + * * SDK-calling actions that need domain data read it from sync-refs. */ import { create } from "zustand" import type { Session, Part, Message, TextPart } from "@opencode-ai/sdk/v2/client" -import type { AttachedFile, SessionContextUsage } from "@/stores/types/sessionTypes" +import type { AttachedFile, SessionContextUsage, SessionWorktreeAttachment } from "@/stores/types/sessionTypes" import type { WorktreeMetadata } from "@/types/worktree" import { opencodeClient } from "@/lib/opencode/client" import { useConfigStore } from "@/stores/useConfigStore" @@ -25,6 +28,7 @@ import { flattenAssistantTextParts } from "@/lib/messages/messageText" import { EXECUTION_FORK_META_TEXT } from "@/lib/messages/executionMeta" import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap" import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree" +import { canonicalizeWorktreeState } from "@/lib/gitApi" import type { ProjectEntry } from "@/lib/api/types" import { getSyncSessions, @@ -47,6 +51,8 @@ import { import { useInputStore, type SyntheticContextPart } from "./input-store" import { useSelectionStore } from "./selection-store" import { useViewportStore } from "./viewport-store" +import { useSessionWorktreeStore } from "./session-worktree-store" +import { buildAttachmentFromCanonicalization, getAttachedSessionDirectory } from "./session-worktree-contract" export type { AttachedFile } @@ -341,11 +347,37 @@ const resolveDraftProjectForDirectory = ( resolveProjectFromWorktreeDirectory(projects, availableWorktreesByProject, directory) ?? resolveProjectForDirectory(projects, directory) +const getAttachmentForSession = (sessionId: string | null | undefined): SessionWorktreeAttachment | undefined => { + if (!sessionId) return undefined + return useSessionWorktreeStore.getState().getAttachment(sessionId) +} + +const recoverSessionAttachment = async ( + sessionId: string, + directory: string, + existingAttachment?: SessionWorktreeAttachment, +): Promise => { + try { + const canonical = await canonicalizeWorktreeState(directory) + const attachment = buildAttachmentFromCanonicalization(canonical, { + existingAttachment, + fallbackDirectory: directory, + }) + useSessionWorktreeStore.getState().setAttachment(sessionId, attachment) + return attachment + } catch (error) { + console.warn("Failed to canonicalize session worktree state:", error) + return null + } +} + const resolveSessionDirectory = ( sessionId: string | null | undefined, getWtMeta: (id: string) => WorktreeMetadata | undefined, ): string | null => { if (!sessionId) return null + const attachmentDirectory = getAttachedSessionDirectory(getAttachmentForSession(sessionId)) + if (attachmentDirectory) return attachmentDirectory const metaPath = getWtMeta(sessionId)?.path if (typeof metaPath === "string" && metaPath.trim().length > 0) return normalizePath(metaPath) const sessions = getAllSyncSessions() @@ -394,6 +426,7 @@ export const useSessionUIStore = create()((set, get) => ({ const previousSessionId = get().currentSessionId const directoryState = useDirectoryStore.getState() + const existingAttachment = getAttachmentForSession(id) const sessionDir = resolveSessionDirectory( id, @@ -429,6 +462,21 @@ export const useSessionUIStore = create()((set, get) => ({ if (id) { markSessionViewed(id) setActiveSession(resolvedDir ?? "", id) + + if (resolvedDir && (!existingAttachment || existingAttachment.legacy)) { + void recoverSessionAttachment(id, resolvedDir, existingAttachment).then((attachment) => { + const canonicalDirectory = getAttachedSessionDirectory(attachment, resolvedDir) + if (!canonicalDirectory) return + const currentDirectory = normalizePath(useDirectoryStore.getState().currentDirectory ?? null) + if (canonicalDirectory === currentDirectory) return + try { + useDirectoryStore.getState().setDirectory(canonicalDirectory, { showOverlay: false }) + opencodeClient.setDirectory(canonicalDirectory) + } catch (error) { + console.warn("Failed to apply canonicalized session directory:", error) + } + }) + } } }, @@ -624,13 +672,30 @@ export const useSessionUIStore = create()((set, get) => ({ // Stub — was a no-op in old store }, - setWorktreeMetadata: (sessionId, metadata) => + setWorktreeMetadata: (sessionId, metadata) => { + // Write to authoritative session-worktree-store + if (metadata) { + useSessionWorktreeStore.getState().setAttachment(sessionId, { + worktreeRoot: metadata.worktreeRoot ?? metadata.path ?? null, + cwd: metadata.path ?? null, + branch: metadata.branch ?? null, + headState: metadata.headState ?? (metadata.branch ? 'branch' : 'detached'), + worktreeStatus: metadata.worktreeStatus ?? 'ready', + worktreeSource: metadata.worktreeSource ?? null, + legacy: false, + degraded: false, + }) + } else { + useSessionWorktreeStore.getState().clearAttachment(sessionId) + } + // Also keep local map for backward compatibility set((s) => { const map = new Map(s.worktreeMetadata) if (metadata) map.set(sessionId, metadata) else map.delete(sessionId) return { worktreeMetadata: map } - }), + }) + }, overrideNewSessionDraftTarget: (options) => { let nextDirectory: string | null = null @@ -869,6 +934,11 @@ export const useSessionUIStore = create()((set, get) => ({ const session = await createSessionAction(title, dir, parentID ?? null) if (!session) return null + const sessionDirectory = normalizePath((session as { directory?: string }).directory ?? dir ?? null) + if (sessionDirectory) { + await recoverSessionAttachment(session.id, sessionDirectory) + } + if (targetFolderId) { const scopeKey = directoryOverride || get().lastLoadedDirectory || session.directory if (scopeKey) { @@ -1088,6 +1158,8 @@ export const useSessionUIStore = create()((set, get) => ({ }, getDirectoryForSession: (sessionId) => { + const attachmentDirectory = getAttachedSessionDirectory(getAttachmentForSession(sessionId)) + if (attachmentDirectory) return attachmentDirectory const sessions = getAllSyncSessions() const session = sessions.find((s) => s.id === sessionId) if (!session) return null diff --git a/packages/ui/src/sync/session-worktree-contract.test.js b/packages/ui/src/sync/session-worktree-contract.test.js new file mode 100644 index 00000000..9343ce2f --- /dev/null +++ b/packages/ui/src/sync/session-worktree-contract.test.js @@ -0,0 +1,660 @@ +import { describe, expect, test } from 'bun:test'; +import { + buildAttachmentFromCanonicalization, + getAttachedSessionDirectory, + resolveSessionWorktreeState, + formatSessionWorktreeBadge, + getSessionWorktreeRepairActions, + getMutationBlockingReasons, + isWithinWorktreeRoot, + buildSessionTargetOptions, +} from './session-worktree-contract'; + +describe('isWithinWorktreeRoot', () => { + test('returns true when candidate equals root', () => { + expect(isWithinWorktreeRoot('/repo/worktrees/feat-a', '/repo/worktrees/feat-a')).toBe(true); + }); + + test('returns true when candidate is a subdirectory of root', () => { + expect(isWithinWorktreeRoot('/repo/worktrees/feat-a/src', '/repo/worktrees/feat-a')).toBe(true); + }); + + test('returns false when candidate is outside root', () => { + expect(isWithinWorktreeRoot('/tmp/outside', '/repo/worktrees/feat-a')).toBe(false); + }); + + test('returns false when either is null/empty', () => { + expect(isWithinWorktreeRoot(null, '/repo')).toBe(false); + expect(isWithinWorktreeRoot('/repo', null)).toBe(false); + expect(isWithinWorktreeRoot('', '/repo')).toBe(false); + }); +}); + +describe('getAttachedSessionDirectory', () => { + test('prefers canonical cwd when attachment is healthy', () => { + expect(getAttachedSessionDirectory({ + worktreeRoot: '/repo/worktrees/feat-a', + cwd: '/repo/worktrees/feat-a/src', + branch: 'feat-a', + headState: 'branch', + worktreeStatus: 'ready', + worktreeSource: 'existing', + legacy: false, + degraded: false, + }, '/repo')).toBe('/repo/worktrees/feat-a/src'); + }); + + test('falls back to worktree root when attachment is degraded', () => { + expect(getAttachedSessionDirectory({ + worktreeRoot: '/repo/worktrees/feat-a', + cwd: '/tmp/outside', + branch: 'feat-a', + headState: 'branch', + worktreeStatus: 'invalid', + worktreeSource: 'existing', + legacy: false, + degraded: true, + }, '/repo')).toBe('/repo/worktrees/feat-a'); + }); + + test('uses fallback when no attachment exists', () => { + expect(getAttachedSessionDirectory(null, '/repo')).toBe('/repo'); + }); +}); + +describe('buildAttachmentFromCanonicalization', () => { + test('builds a canonical attachment for a healthy current-worktree session', () => { + const result = buildAttachmentFromCanonicalization({ + worktreeRoot: '/repo', + cwd: '/repo/src', + branch: 'main', + headState: 'branch', + worktreeStatus: 'ready', + legacy: false, + degraded: false, + }, { + fallbackDirectory: '/repo/src', + }); + + expect(result.worktreeRoot).toBe('/repo'); + expect(result.cwd).toBe('/repo/src'); + expect(result.branch).toBe('main'); + expect(result.legacy).toBe(false); + }); + + test('preserves worktreeSource while recovering a legacy session', () => { + const result = buildAttachmentFromCanonicalization({ + worktreeRoot: '/repo/worktrees/feat-a', + cwd: '/repo/worktrees/feat-a', + branch: 'feat-a', + headState: 'branch', + worktreeStatus: 'ready', + legacy: false, + degraded: false, + }, { + existingAttachment: { + worktreeRoot: null, + cwd: '/repo/worktrees/feat-a', + branch: null, + headState: 'detached', + worktreeStatus: 'invalid', + worktreeSource: 'created-for-session', + legacy: true, + degraded: true, + }, + fallbackDirectory: '/repo/worktrees/feat-a', + }); + + expect(result.worktreeSource).toBe('created-for-session'); + expect(result.legacy).toBe(false); + }); + + test('uses worktree root as cwd when canonicalization is degraded', () => { + const result = buildAttachmentFromCanonicalization({ + worktreeRoot: '/repo/worktrees/feat-a', + cwd: '/tmp/outside', + branch: 'feat-a', + headState: 'branch', + worktreeStatus: 'invalid', + legacy: true, + degraded: true, + }, { + fallbackDirectory: '/repo/worktrees/feat-a', + }); + + expect(result.cwd).toBe('/repo/worktrees/feat-a'); + expect(result.degraded).toBe(true); + }); +}); + +describe('resolveSessionWorktreeState', () => { + test('keeps cwd when inside worktreeRoot', () => { + const result = resolveSessionWorktreeState({ + sessionDirectory: '/repo/worktrees/feat-a/src', + metadata: { + path: '/repo/worktrees/feat-a', + projectDirectory: '/repo', + branch: 'feat-a', + label: 'feat-a', + worktreeRoot: '/repo/worktrees/feat-a', + worktreeStatus: 'ready', + headState: 'branch', + }, + cwdExists: true, + }); + + expect(result.cwd).toBe('/repo/worktrees/feat-a/src'); + expect(result.worktreeRoot).toBe('/repo/worktrees/feat-a'); + expect(result.degraded).toBe(false); + expect(result.worktreeStatus).toBe('ready'); + expect(result.headState).toBe('branch'); + }); + + test('falls back to worktreeRoot when cwd is invalid', () => { + const result = resolveSessionWorktreeState({ + sessionDirectory: '/tmp/outside', + metadata: { + path: '/repo/worktrees/feat-a', + projectDirectory: '/repo', + branch: 'feat-a', + label: 'feat-a', + worktreeRoot: '/repo/worktrees/feat-a', + worktreeStatus: 'ready', + headState: 'branch', + }, + cwdExists: false, + }); + + expect(result.cwd).toBe('/repo/worktrees/feat-a'); + expect(result.degraded).toBe(true); + }); + + test('falls back to worktreeRoot when cwd escapes root', () => { + const result = resolveSessionWorktreeState({ + sessionDirectory: '/repo/worktrees/feat-a/src', + metadata: { + path: '/repo/worktrees/feat-a', + projectDirectory: '/repo', + branch: 'feat-a', + label: 'feat-a', + worktreeRoot: '/repo/worktrees/feat-a', + worktreeStatus: 'ready', + headState: 'branch', + }, + cwdExists: true, + }); + + // cwd is inside worktreeRoot so should be kept + expect(result.cwd).toBe('/repo/worktrees/feat-a/src'); + expect(result.degraded).toBe(false); + }); + + test('marks missing metadata as legacy with invalid status', () => { + const result = resolveSessionWorktreeState({ + sessionDirectory: '/repo', + metadata: null, + cwdExists: true, + }); + + expect(result.legacy).toBe(true); + expect(result.worktreeStatus).toBe('invalid'); + expect(result.degraded).toBe(true); + }); + + test('preserves unborn head state', () => { + const result = resolveSessionWorktreeState({ + sessionDirectory: '/repo/worktrees/new-branch', + metadata: { + path: '/repo/worktrees/new-branch', + projectDirectory: '/repo', + branch: '', + label: 'new-branch', + worktreeRoot: '/repo/worktrees/new-branch', + worktreeStatus: 'ready', + headState: 'unborn', + }, + cwdExists: true, + }); + + expect(result.headState).toBe('unborn'); + }); + + test('recovers legacy session when runtime canonicalization resolves a worktree', () => { + const result = resolveSessionWorktreeState({ + sessionDirectory: '/repo/worktrees/feat-a/src', + metadata: null, + cwdExists: true, + runtimeResolution: { + worktreeRoot: '/repo/worktrees/feat-a', + cwd: '/repo/worktrees/feat-a/src', + branch: 'feat-a', + headState: 'branch', + worktreeStatus: 'ready', + worktreeSource: 'existing', + legacy: false, + degraded: false, + }, + }); + + expect(result.legacy).toBe(false); + expect(result.worktreeRoot).toBe('/repo/worktrees/feat-a'); + expect(result.degraded).toBe(false); + }); + + test('defaults detached when branch is empty but headState not specified', () => { + const result = resolveSessionWorktreeState({ + sessionDirectory: '/repo/worktrees/detached', + metadata: { + path: '/repo/worktrees/detached', + projectDirectory: '/repo', + branch: '', + label: 'detached', + }, + cwdExists: true, + }); + + expect(result.headState).toBe('detached'); + }); + + test('canonical producer metadata preserves branch/detached/unborn states', () => { + // branch state + const branchResult = resolveSessionWorktreeState({ + sessionDirectory: '/repo/worktrees/feat-a', + metadata: { + path: '/repo/worktrees/feat-a', + projectDirectory: '/repo', + branch: 'feat-a', + label: 'feat-a', + worktreeRoot: '/repo/worktrees/feat-a', + worktreeStatus: 'ready', + headState: 'branch', + worktreeSource: 'created-for-session', + }, + cwdExists: true, + }); + expect(branchResult.headState).toBe('branch'); + expect(branchResult.worktreeStatus).toBe('ready'); + + // detached state + const detachedResult = resolveSessionWorktreeState({ + sessionDirectory: '/repo/worktrees/detached', + metadata: { + path: '/repo/worktrees/detached', + projectDirectory: '/repo', + branch: '', + label: 'detached', + worktreeRoot: '/repo/worktrees/detached', + worktreeStatus: 'ready', + headState: 'detached', + worktreeSource: 'existing', + }, + cwdExists: true, + }); + expect(detachedResult.headState).toBe('detached'); + + // unborn state + const unbornResult = resolveSessionWorktreeState({ + sessionDirectory: '/repo/worktrees/unborn', + metadata: { + path: '/repo/worktrees/unborn', + projectDirectory: '/repo', + branch: '', + label: 'unborn', + worktreeRoot: '/repo/worktrees/unborn', + worktreeStatus: 'ready', + headState: 'unborn', + worktreeSource: 'created-for-session', + }, + cwdExists: true, + }); + expect(unbornResult.headState).toBe('unborn'); + }); +}); + +describe('formatSessionWorktreeBadge', () => { + test('formats needs-attention badge for invalid worktree', () => { + const badge = formatSessionWorktreeBadge({ + worktreeStatus: 'invalid', + degraded: true, + legacy: false, + branch: null, + headState: 'detached', + worktreeRoot: null, + cwd: null, + worktreeSource: null, + }); + expect(badge).toBe('Needs attention'); + }); + + test('formats legacy session badge', () => { + const badge = formatSessionWorktreeBadge({ + legacy: true, + worktreeStatus: 'invalid', + degraded: true, + branch: null, + headState: 'branch', + worktreeRoot: null, + cwd: null, + worktreeSource: null, + }); + expect(badge).toBe('Legacy session'); + }); + + test('formats detached HEAD', () => { + const badge = formatSessionWorktreeBadge({ + headState: 'detached', + degraded: false, + legacy: false, + branch: null, + worktreeStatus: 'ready', + worktreeRoot: '/repo', + cwd: '/repo', + worktreeSource: 'existing', + }); + expect(badge).toBe('Detached HEAD'); + }); + + test('formats unborn branch', () => { + const badge = formatSessionWorktreeBadge({ + headState: 'unborn', + degraded: false, + legacy: false, + branch: null, + worktreeStatus: 'ready', + worktreeRoot: '/repo', + cwd: '/repo', + worktreeSource: 'existing', + }); + expect(badge).toBe('Unborn branch'); + }); + + test('formats current branch name', () => { + const badge = formatSessionWorktreeBadge({ + branch: 'feature/my-branch', + headState: 'branch', + degraded: false, + legacy: false, + worktreeStatus: 'ready', + worktreeRoot: '/repo', + cwd: '/repo', + worktreeSource: 'existing', + }); + expect(badge).toBe('Current branch: feature/my-branch'); + }); + + test('formats missing worktree', () => { + const badge = formatSessionWorktreeBadge({ + worktreeStatus: 'missing', + degraded: true, + legacy: false, + branch: null, + headState: 'branch', + worktreeRoot: null, + cwd: null, + worktreeSource: null, + }); + expect(badge).toBe('Worktree missing'); + }); + + test('formats needs-attention for in-progress git operation', () => { + const badge = formatSessionWorktreeBadge({ + worktreeStatus: 'ready', + attentionReason: 'merge', + degraded: false, + legacy: false, + branch: 'main', + headState: 'branch', + worktreeRoot: '/repo', + cwd: '/repo', + worktreeSource: 'existing', + }); + expect(badge).toBe('Needs attention'); + }); +}); + +describe('getSessionWorktreeRepairActions', () => { + test('returns open-without-worktree-features for missing worktree', () => { + const actions = getSessionWorktreeRepairActions({ + worktreeStatus: 'missing', + degraded: true, + legacy: false, + branch: null, + headState: 'branch', + worktreeRoot: null, + cwd: null, + worktreeSource: null, + }); + expect(actions).toContain('open-without-worktree-features'); + }); + + test('returns open-without-worktree-features for invalid worktree', () => { + const actions = getSessionWorktreeRepairActions({ + worktreeStatus: 'invalid', + degraded: true, + legacy: false, + branch: null, + headState: 'branch', + worktreeRoot: null, + cwd: null, + worktreeSource: null, + }); + expect(actions).toContain('open-without-worktree-features'); + }); + + test('returns empty for ready worktree', () => { + const actions = getSessionWorktreeRepairActions({ + worktreeStatus: 'ready', + degraded: false, + legacy: false, + branch: 'main', + headState: 'branch', + worktreeRoot: '/repo', + cwd: '/repo', + worktreeSource: 'existing', + }); + expect(actions).toHaveLength(0); + }); +}); + +describe('buildSessionTargetOptions', () => { + test('labels root directory and isolated worktrees distinctly', () => { + const options = buildSessionTargetOptions({ + projectRoot: '/repo', + rootBranch: 'main', + worktrees: [ + { path: '/repo/.worktrees/feat-a', branch: 'feat-a', label: 'feat-a', projectDirectory: '/repo' }, + ], + }); + + expect(options[0]?.label).toContain('main'); + expect(options[1]?.label).toContain('feat-a'); + expect(options[0]?.kind).toBe('root'); + expect(options[1]?.kind).toBe('worktree'); + }); + + test('excludes worktree path that equals projectRoot', () => { + const options = buildSessionTargetOptions({ + projectRoot: '/repo', + rootBranch: 'main', + worktrees: [ + { path: '/repo', branch: 'main', label: 'main', projectDirectory: '/repo' }, + { path: '/repo/worktrees/feat-a', branch: 'feat-a', label: 'feat-a', projectDirectory: '/repo' }, + ], + }); + + expect(options).toHaveLength(2); // root + one worktree, not three + }); + + test('handles empty worktrees array', () => { + const options = buildSessionTargetOptions({ + projectRoot: '/repo', + rootBranch: 'main', + worktrees: [], + }); + + expect(options).toHaveLength(1); + expect(options[0]?.kind).toBe('root'); + }); + + test('marks pending bootstrap worktree distinctly', () => { + const options = buildSessionTargetOptions({ + projectRoot: '/repo', + rootBranch: 'main', + worktrees: [ + { path: '/repo/worktrees/feat-a', branch: 'feat-a', label: 'feat-a', projectDirectory: '/repo' }, + { path: '/repo/worktrees/feat-b', branch: 'feat-b', label: 'feat-b', projectDirectory: '/repo' }, + ], + pendingBootstrapDirectory: '/repo/worktrees/feat-b', + }); + + const root = options.find((o) => o.kind === 'root'); + const pending = options.find((o) => o.value === '/repo/worktrees/feat-b'); + const nonPending = options.find((o) => o.value === '/repo/worktrees/feat-a'); + + expect(root?.pending).toBeUndefined(); + expect(pending?.pending).toBe(true); + expect(nonPending?.pending).toBeUndefined(); + }); +}); + +describe('getMutationBlockingReasons', () => { + test('returns empty when attachment is null', () => { + expect(getMutationBlockingReasons(null)).toHaveLength(0); + expect(getMutationBlockingReasons(undefined)).toHaveLength(0); + }); + + test('blocks mutation when worktree is missing', () => { + const reasons = getMutationBlockingReasons({ + worktreeRoot: null, + cwd: null, + branch: null, + headState: 'branch', + worktreeStatus: 'missing', + worktreeSource: null, + legacy: false, + degraded: true, + }); + expect(reasons).toHaveLength(1); + expect(reasons[0]).toEqual({ reason: 'missing' }); + }); + + test('blocks mutation when worktree is invalid', () => { + const reasons = getMutationBlockingReasons({ + worktreeRoot: null, + cwd: null, + branch: null, + headState: 'branch', + worktreeStatus: 'invalid', + worktreeSource: null, + legacy: false, + degraded: true, + }); + expect(reasons).toHaveLength(1); + expect(reasons[0]).toEqual({ reason: 'invalid' }); + }); + + test('blocks mutation during merge attention state', () => { + const reasons = getMutationBlockingReasons({ + worktreeRoot: '/repo/worktrees/feat-a', + cwd: '/repo/worktrees/feat-a', + branch: 'feat-a', + headState: 'branch', + worktreeStatus: 'ready', + worktreeSource: 'existing', + legacy: false, + degraded: false, + attentionReason: 'merge', + }); + expect(reasons).toHaveLength(1); + expect(reasons[0]).toEqual({ reason: 'attention', attentionReason: 'merge' }); + }); + + test('blocks mutation during rebase attention state', () => { + const reasons = getMutationBlockingReasons({ + worktreeRoot: '/repo/worktrees/feat-a', + cwd: '/repo/worktrees/feat-a', + branch: 'feat-a', + headState: 'branch', + worktreeStatus: 'ready', + worktreeSource: 'existing', + legacy: false, + degraded: false, + attentionReason: 'rebase', + }); + expect(reasons).toHaveLength(1); + expect(reasons[0]).toEqual({ reason: 'attention', attentionReason: 'rebase' }); + }); + + test('returns empty for ready worktree with no attention', () => { + const reasons = getMutationBlockingReasons({ + worktreeRoot: '/repo/worktrees/feat-a', + cwd: '/repo/worktrees/feat-a', + branch: 'feat-a', + headState: 'branch', + worktreeStatus: 'ready', + worktreeSource: 'existing', + legacy: false, + degraded: false, + }); + expect(reasons).toHaveLength(0); + }); + + test('blocks mutation during cherry-pick attention state', () => { + const reasons = getMutationBlockingReasons({ + worktreeRoot: '/repo/worktrees/feat-a', + cwd: '/repo/worktrees/feat-a', + branch: 'feat-a', + headState: 'branch', + worktreeStatus: 'ready', + worktreeSource: 'existing', + legacy: false, + degraded: false, + attentionReason: 'cherry-pick', + }); + expect(reasons).toHaveLength(1); + expect(reasons[0]).toEqual({ reason: 'attention', attentionReason: 'cherry-pick' }); + }); + + test('blocks mutation when git status is dirty', () => { + const reasons = getMutationBlockingReasons( + { worktreeRoot: '/repo', cwd: '/repo', branch: 'main', headState: 'branch', worktreeStatus: 'ready', worktreeSource: 'existing', legacy: false, degraded: false }, + { isClean: false, files: [{ path: 'a.ts' }, { path: 'b.ts' }] } + ); + expect(reasons).toHaveLength(1); + expect(reasons[0]).toEqual({ reason: 'dirty', dirtyFiles: 2 }); + }); + + test('blocks mutation for dirty tree even without attachment', () => { + const reasons = getMutationBlockingReasons(null, { isClean: false, files: [{ path: 'a.ts' }] }); + expect(reasons).toHaveLength(1); + expect(reasons[0]).toEqual({ reason: 'dirty', dirtyFiles: 1 }); + }); + + test('does not block when git status is clean', () => { + const reasons = getMutationBlockingReasons( + { worktreeRoot: '/repo', cwd: '/repo', branch: 'main', headState: 'branch', worktreeStatus: 'ready', worktreeSource: 'existing', legacy: false, degraded: false }, + { isClean: true, files: [] } + ); + expect(reasons).toHaveLength(0); + }); + + test('returns dirty and missing reasons together', () => { + const reasons = getMutationBlockingReasons( + { worktreeRoot: '/repo', cwd: '/repo', branch: 'main', headState: 'branch', worktreeStatus: 'missing', worktreeSource: 'existing', legacy: false, degraded: false }, + { isClean: false, files: [{ path: 'a.ts' }] } + ); + expect(reasons).toHaveLength(2); + expect(reasons[0]).toEqual({ reason: 'dirty', dirtyFiles: 1 }); + expect(reasons[1]).toEqual({ reason: 'missing' }); + }); + + test('returns dirty without file count when files is undefined', () => { + const reasons = getMutationBlockingReasons( + null, + { isClean: false } + ); + expect(reasons).toHaveLength(1); + expect(reasons[0]).toEqual({ reason: 'dirty' }); + }); +}); + diff --git a/packages/ui/src/sync/session-worktree-contract.ts b/packages/ui/src/sync/session-worktree-contract.ts new file mode 100644 index 00000000..933193b4 --- /dev/null +++ b/packages/ui/src/sync/session-worktree-contract.ts @@ -0,0 +1,241 @@ +import type { WorktreeMetadata } from '@/types/worktree'; +import type { SessionWorktreeAttachment } from '@/stores/types/sessionTypes'; + +export type ResolveSessionWorktreeStateInput = { + sessionDirectory: string | null; + metadata: WorktreeMetadata | null; + cwdExists?: boolean; + runtimeResolution?: SessionWorktreeAttachment | null; +}; + +export type WorktreeDirectoryValidation = { + valid: boolean; + insideWorktreeRoot: boolean; + resolvedWorktreeRoot: string | null; + resolvedCwd: string | null; +}; + +export type WorktreeCanonicalizationResult = { + worktreeRoot: string | null; + cwd: string | null; + branch: string | null; + headState: 'branch' | 'detached' | 'unborn'; + worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; + legacy: boolean; + degraded: boolean; + attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; +}; + +export type SessionWorktreeCanonicalizationOptions = { + existingAttachment?: SessionWorktreeAttachment | null; + fallbackDirectory?: string | null; + worktreeSource?: SessionWorktreeAttachment['worktreeSource']; +}; + +const normalizePath = (value: string): string => { + if (!value) return ''; + const replaced = value.replace(/\\/g, '/'); + if (replaced === '/') return '/'; + return replaced.replace(/\/+$/, '') || replaced; +}; + +export function isWithinWorktreeRoot(candidate: string | null, worktreeRoot: string | null): boolean { + if (!candidate || !worktreeRoot) return false; + const c = normalizePath(candidate); + const r = normalizePath(worktreeRoot); + return c === r || c.startsWith(r + '/'); +} + +export function getAttachedSessionDirectory( + attachment: SessionWorktreeAttachment | null | undefined, + fallbackDirectory?: string | null, +): string | null { + if (attachment) { + if (!attachment.degraded && attachment.cwd) { + return normalizePath(attachment.cwd); + } + if (attachment.worktreeRoot) { + return normalizePath(attachment.worktreeRoot); + } + if (attachment.cwd) { + return normalizePath(attachment.cwd); + } + } + + if (fallbackDirectory) { + return normalizePath(fallbackDirectory); + } + + return null; +} + +export function buildAttachmentFromCanonicalization( + canonical: WorktreeCanonicalizationResult, + options: SessionWorktreeCanonicalizationOptions = {}, +): SessionWorktreeAttachment { + const existingAttachment = options.existingAttachment ?? null; + const fallbackDirectory = options.fallbackDirectory ?? null; + const preferredDirectory = canonical.degraded + ? canonical.worktreeRoot ?? canonical.cwd ?? fallbackDirectory + : canonical.cwd ?? canonical.worktreeRoot ?? fallbackDirectory; + + return { + worktreeRoot: canonical.worktreeRoot ?? fallbackDirectory, + cwd: preferredDirectory, + branch: canonical.branch ?? existingAttachment?.branch ?? null, + headState: canonical.headState, + worktreeStatus: canonical.worktreeStatus, + worktreeSource: options.worktreeSource ?? existingAttachment?.worktreeSource ?? null, + legacy: canonical.legacy, + degraded: canonical.degraded, + attentionReason: canonical.attentionReason ?? null, + }; +} + +export function resolveSessionWorktreeState( + input: ResolveSessionWorktreeStateInput +): SessionWorktreeAttachment { + const { sessionDirectory, metadata, cwdExists = true, runtimeResolution } = input; + + if (runtimeResolution) { + return { + worktreeRoot: runtimeResolution.worktreeRoot ?? metadata?.path ?? sessionDirectory ?? null, + cwd: runtimeResolution.cwd ?? sessionDirectory ?? metadata?.path ?? null, + branch: runtimeResolution.branch ?? metadata?.branch ?? null, + headState: runtimeResolution.headState ?? 'branch', + worktreeStatus: runtimeResolution.worktreeStatus ?? 'ready', + worktreeSource: runtimeResolution.worktreeSource ?? metadata?.source === 'sdk' ? 'created-for-session' : 'existing', + legacy: false, + degraded: runtimeResolution.degraded, + attentionReason: runtimeResolution.attentionReason ?? null, + }; + } + + if (!metadata) { + return { + worktreeRoot: sessionDirectory ?? null, + cwd: sessionDirectory ?? null, + branch: null, + headState: 'branch', + worktreeStatus: sessionDirectory ? 'invalid' : 'not-a-repo', + worktreeSource: null, + legacy: true, + degraded: true, + attentionReason: null, + }; + } + + const worktreeRoot = metadata.worktreeRoot ?? metadata.path; + const cwd = sessionDirectory ?? worktreeRoot; + + const cwdValid = cwdExists && (cwd === worktreeRoot || isWithinWorktreeRoot(cwd, worktreeRoot)); + + return { + worktreeRoot, + cwd: cwdValid ? cwd : worktreeRoot, + branch: metadata.branch ?? null, + headState: metadata.headState ?? (metadata.branch ? 'branch' : 'detached'), + worktreeStatus: metadata.worktreeStatus ?? 'ready', + worktreeSource: metadata.source === 'sdk' ? 'created-for-session' : 'existing', + legacy: false, + degraded: !cwdValid, + attentionReason: null, + }; +} + +export function formatSessionWorktreeBadge(attachment: SessionWorktreeAttachment): string { + if (attachment.legacy) return 'Legacy session'; + if (attachment.worktreeStatus === 'missing') return 'Worktree missing'; + if (attachment.worktreeStatus === 'not-a-repo') return 'Not a repo'; + if (attachment.worktreeStatus === 'invalid') return 'Needs attention'; + if (attachment.attentionReason) return 'Needs attention'; + if (attachment.headState === 'detached') return 'Detached HEAD'; + if (attachment.headState === 'unborn') return 'Unborn branch'; + if (attachment.branch) return `Current branch: ${attachment.branch}`; + return 'No branch'; +} + +export type SessionWorktreeRepairAction = 'locate' | 'open-without-worktree-features'; + +export function getSessionWorktreeRepairActions( + attachment: SessionWorktreeAttachment +): SessionWorktreeRepairAction[] { + if (attachment.worktreeStatus === 'missing' || attachment.worktreeStatus === 'invalid') { + return ['open-without-worktree-features']; + } + return []; +} + +export type MutationBlockingReason = + | { reason: 'dirty'; dirtyFiles?: number } + | { reason: 'attention'; attentionReason: NonNullable } + | { reason: 'missing' } + | { reason: 'invalid' }; + +export type GitStatusForBlocking = { + isClean: boolean; + files?: unknown[]; +}; + +export function getMutationBlockingReasons( + attachment: SessionWorktreeAttachment | null | undefined, + gitStatus?: GitStatusForBlocking | null +): MutationBlockingReason[] { + const reasons: MutationBlockingReason[] = []; + if (gitStatus && !gitStatus.isClean) { + reasons.push({ reason: 'dirty', dirtyFiles: Array.isArray(gitStatus.files) ? gitStatus.files.length : undefined }); + } + if (!attachment) return reasons; + if (attachment.worktreeStatus === 'missing') { + reasons.push({ reason: 'missing' }); + } + if (attachment.worktreeStatus === 'invalid') { + reasons.push({ reason: 'invalid' }); + } + if (attachment.attentionReason) { + reasons.push({ reason: 'attention', attentionReason: attachment.attentionReason }); + } + return reasons; +} + +export type SessionTargetOption = { + value: string; + label: string; + kind: 'root' | 'worktree'; + pending?: boolean; +}; + +export function buildSessionTargetOptions(input: { + projectRoot: string; + rootBranch: string; + worktrees: Array<{ path: string; branch: string; label: string; projectDirectory: string }>; + pendingBootstrapDirectory?: string | null; +}): SessionTargetOption[] { + const options: SessionTargetOption[] = []; + + if (input.projectRoot) { + options.push({ + value: input.projectRoot, + label: input.rootBranch || input.projectRoot.split('/').pop() || input.projectRoot, + kind: 'root', + }); + } + + const pendingNormalized = input.pendingBootstrapDirectory + ? normalizePath(input.pendingBootstrapDirectory) + : null; + + for (const wt of input.worktrees) { + const normalizedPath = normalizePath(wt.path); + if (normalizedPath === input.projectRoot) continue; + const isPending = normalizedPath === pendingNormalized; + options.push({ + value: normalizedPath, + label: wt.branch?.trim() || wt.label || normalizedPath.split('/').pop() || normalizedPath, + kind: 'worktree', + pending: isPending || undefined, + }); + } + + return options; +} diff --git a/packages/ui/src/sync/session-worktree-store.test.js b/packages/ui/src/sync/session-worktree-store.test.js new file mode 100644 index 00000000..361aac7b --- /dev/null +++ b/packages/ui/src/sync/session-worktree-store.test.js @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'bun:test'; +import { useSessionWorktreeStore } from './session-worktree-store'; + +describe('session-worktree-store', () => { + test('stores and retrieves attachment by session id', () => { + const store = useSessionWorktreeStore.getState(); + store.setAttachment('session-1', { + worktreeRoot: '/repo/worktrees/feat-a', + cwd: '/repo/worktrees/feat-a', + branch: 'feat-a', + headState: 'branch', + worktreeStatus: 'ready', + worktreeSource: 'created-for-session', + legacy: false, + degraded: false, + }); + + const attachment = useSessionWorktreeStore.getState().getAttachment('session-1'); + expect(attachment?.worktreeRoot).toBe('/repo/worktrees/feat-a'); + expect(attachment?.branch).toBe('feat-a'); + }); + + test('clears attachment by session id', () => { + const store = useSessionWorktreeStore.getState(); + store.setAttachment('session-2', { + worktreeRoot: '/repo/worktrees/feat-b', + cwd: '/repo/worktrees/feat-b', + branch: 'feat-b', + headState: 'branch', + worktreeStatus: 'ready', + worktreeSource: 'existing', + legacy: false, + degraded: false, + }); + + store.clearAttachment('session-2'); + const attachment = useSessionWorktreeStore.getState().getAttachment('session-2'); + expect(attachment).toBeUndefined(); + }); + + test('multiple sessions have independent attachments', () => { + const store = useSessionWorktreeStore.getState(); + store.setAttachment('session-A', { + worktreeRoot: '/repo/worktrees/feat-a', + cwd: '/repo/worktrees/feat-a', + branch: 'feat-a', + headState: 'branch', + worktreeStatus: 'ready', + worktreeSource: 'existing', + legacy: false, + degraded: false, + }); + store.setAttachment('session-B', { + worktreeRoot: '/repo/worktrees/feat-b', + cwd: '/repo/worktrees/feat-b', + branch: 'feat-b', + headState: 'branch', + worktreeStatus: 'ready', + worktreeSource: 'created-for-session', + legacy: false, + degraded: false, + }); + + const attA = useSessionWorktreeStore.getState().getAttachment('session-A'); + const attB = useSessionWorktreeStore.getState().getAttachment('session-B'); + expect(attA?.branch).toBe('feat-a'); + expect(attB?.branch).toBe('feat-b'); + expect(attA?.worktreeSource).toBe('existing'); + expect(attB?.worktreeSource).toBe('created-for-session'); + }); +}); diff --git a/packages/ui/src/sync/session-worktree-store.ts b/packages/ui/src/sync/session-worktree-store.ts new file mode 100644 index 00000000..2986b440 --- /dev/null +++ b/packages/ui/src/sync/session-worktree-store.ts @@ -0,0 +1,34 @@ +import { create } from 'zustand'; +import type { SessionWorktreeAttachment } from '@/stores/types/sessionTypes'; + +interface SessionWorktreeState { + attachments: Map; +} + +interface SessionWorktreeActions { + setAttachment(sessionId: string, attachment: SessionWorktreeAttachment): void; + getAttachment(sessionId: string): SessionWorktreeAttachment | undefined; + clearAttachment(sessionId: string): void; +} + +type SessionWorktreeStore = SessionWorktreeState & SessionWorktreeActions; + +export const useSessionWorktreeStore = create((set, get) => ({ + attachments: new Map(), + + setAttachment: (sessionId, attachment) => + set((s) => { + const next = new Map(s.attachments); + next.set(sessionId, attachment); + return { attachments: next }; + }), + + getAttachment: (sessionId) => get().attachments.get(sessionId), + + clearAttachment: (sessionId) => + set((s) => { + const next = new Map(s.attachments); + next.delete(sessionId); + return { attachments: next }; + }), +})); diff --git a/packages/ui/src/types/worktree.ts b/packages/ui/src/types/worktree.ts index 66a2e9a7..cf50e7b2 100644 --- a/packages/ui/src/types/worktree.ts +++ b/packages/ui/src/types/worktree.ts @@ -33,6 +33,20 @@ export interface WorktreeMetadata { behind?: number; upstream?: string | null; }; + + // --- Phase 1: canonical worktree attachment fields --- + + /** Canonical root path for the worktree (same as path for secondary worktrees). */ + worktreeRoot?: string; + + /** Operational status of this worktree. */ + worktreeStatus?: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; + + /** Git HEAD state classification. */ + headState?: 'branch' | 'detached' | 'unborn'; + + /** How this worktree was attached to a session. */ + worktreeSource?: 'existing' | 'created-for-session'; } export type WorktreeMap = Map; diff --git a/packages/vscode/src/bridge-git-runtime.ts b/packages/vscode/src/bridge-git-runtime.ts index 11c9322b..fc0332f5 100644 --- a/packages/vscode/src/bridge-git-runtime.ts +++ b/packages/vscode/src/bridge-git-runtime.ts @@ -173,6 +173,22 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput return { id, type, success: true, data: result }; } + case 'api:git/validate-directory': { + const { directory, worktreeRoot } = (payload || {}) as { directory?: string; worktreeRoot?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.validateWorktreeDirectory(directory!, worktreeRoot!); + return { id, type, success: true, data: result }; + } + + case 'api:git/canonicalize-worktree-state': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.canonicalizeWorktreeState(directory!); + return { id, type, success: true, data: result }; + } + case 'api:git/diff': { const { directory, path: filePath, staged, contextLines } = (payload || {}) as { directory?: string; diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index cbbdf910..78984b06 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -2903,3 +2903,160 @@ export async function stashPop(directory: string): Promise<{ success: boolean }> const result = await execGit(['stash', 'pop'], directory); return { success: result.exitCode === 0 }; } + +// ============== Worktree Validation & Canonicalization ============== + +/** + * Resolve a path to its canonical (real) absolute path. + */ +async function canonicalizePath(filePath: string): Promise { + try { + const realPath = await fs.promises.realpath(filePath); + return realPath; + } catch { + return path.resolve(filePath); + } +} + +/** + * Validate that a directory is inside a given worktree root. + */ +export async function validateWorktreeDirectory( + directory: string, + worktreeRoot: string +): Promise<{ + valid: boolean; + insideWorktreeRoot: boolean; + resolvedWorktreeRoot: string | null; + resolvedCwd: string | null; +}> { + const directoryPath = normalizeDirectoryPath(directory); + const rootPath = normalizeDirectoryPath(worktreeRoot); + + if (!directoryPath || !rootPath) { + return { valid: false, insideWorktreeRoot: false, resolvedWorktreeRoot: null, resolvedCwd: null }; + } + + const isRepo = await checkIsGitRepository(directoryPath); + if (!isRepo) { + return { valid: false, insideWorktreeRoot: false, resolvedWorktreeRoot: null, resolvedCwd: null }; + } + + const resolvedCwd = await canonicalizePath(directoryPath); + const resolvedRoot = await canonicalizePath(rootPath); + + const inside = resolvedCwd.startsWith(resolvedRoot + path.sep) || resolvedCwd === resolvedRoot; + + return { + valid: true, + insideWorktreeRoot: inside, + resolvedWorktreeRoot: resolvedRoot, + resolvedCwd, + }; +} + +/** + * Canonicalize the worktree state for a directory, returning branch, headState, + * worktreeStatus, and attentionReason (merge/rebase/cherry-pick/revert). + */ +export async function canonicalizeWorktreeState( + directory: string +): Promise<{ + worktreeRoot: string | null; + cwd: string | null; + branch: string | null; + headState: 'branch' | 'detached' | 'unborn'; + worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; + legacy: boolean; + degraded: boolean; + attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; +}> { + const directoryPath = normalizeDirectoryPath(directory); + + if (!directoryPath) { + return { + worktreeRoot: null, cwd: null, branch: null, + headState: 'detached', worktreeStatus: 'not-a-repo', + legacy: false, degraded: false, attentionReason: null, + }; + } + + const isRepo = await checkIsGitRepository(directoryPath); + if (!isRepo) { + return { + worktreeRoot: null, cwd: null, branch: null, + headState: 'detached', worktreeStatus: 'not-a-repo', + legacy: false, degraded: false, attentionReason: null, + }; + } + + const cwd = await canonicalizePath(directoryPath); + + let worktreeRoot: string | null = null; + let worktreeStatus: 'ready' | 'missing' | 'invalid' = 'ready'; + let headState: 'branch' | 'detached' | 'unborn' = 'branch'; + let branch: string | null = null; + let attentionReason: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null = null; + + // Resolve worktree project context (worktreeRoot) + try { + const context = await resolveWorktreeProjectContext(directoryPath); + worktreeRoot = await canonicalizePath(context.worktreeRoot); + } catch { + worktreeStatus = 'invalid'; + } + + // Resolve head state and branch + try { + const symbolicRef = await execGit(['symbolic-ref', '-q', 'HEAD'], directoryPath); + if (symbolicRef.exitCode === 0 && symbolicRef.stdout.trim()) { + headState = 'branch'; + branch = cleanBranchName(symbolicRef.stdout.trim()); + } else { + const revParse = await execGit(['rev-parse', 'HEAD'], directoryPath); + if (revParse.exitCode !== 0 || !revParse.stdout.trim()) { + headState = 'unborn'; + branch = null; + } else { + headState = 'detached'; + branch = revParse.stdout.trim().slice(0, 7); + } + } + } catch { + headState = 'unborn'; + branch = null; + } + + // Detect attention reasons (merge, rebase, cherry-pick, revert) + try { + const mergeHead = await execGit(['rev-parse', '--verify', 'MERGE_HEAD'], directoryPath); + if (mergeHead.exitCode === 0) { + attentionReason = 'merge'; + } else { + const fsp = fs.promises; + const rebaseMerge = await fsp.stat(path.join(directoryPath, '.git', 'rebase-merge')).then(() => true).catch(() => false); + const rebaseApply = await fsp.stat(path.join(directoryPath, '.git', 'rebase-apply')).then(() => true).catch(() => false); + if (rebaseMerge || rebaseApply) { + attentionReason = 'rebase'; + } else { + const cherryPickHead = await fsp.stat(path.join(directoryPath, '.git', 'CHERRY_PICK_HEAD')).then(() => true).catch(() => false); + const revertHead = await fsp.stat(path.join(directoryPath, '.git', 'REVERT_HEAD')).then(() => true).catch(() => false); + if (cherryPickHead) attentionReason = 'cherry-pick'; + else if (revertHead) attentionReason = 'revert'; + } + } + } catch { + // Status check failed — ignore + } + + return { + worktreeRoot, + cwd, + branch, + headState, + worktreeStatus, + legacy: false, + degraded: false, + attentionReason, + }; +} diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts index a3c494e5..fc6396ae 100644 --- a/packages/vscode/webview/api/git.ts +++ b/packages/vscode/webview/api/git.ts @@ -344,6 +344,42 @@ export const createVSCodeGitAPI = (): GitAPI => ({ }>('api:git/conflict-details', { directory }); }, + validateWorktreeDirectory: async (directory: string, worktreeRoot: string): Promise<{ + valid: boolean; + insideWorktreeRoot: boolean; + resolvedWorktreeRoot: string | null; + resolvedCwd: string | null; + }> => { + return sendBridgeMessage<{ + valid: boolean; + insideWorktreeRoot: boolean; + resolvedWorktreeRoot: string | null; + resolvedCwd: string | null; + }>('api:git/validate-directory', { directory, worktreeRoot }); + }, + + canonicalizeWorktreeState: async (directory: string): Promise<{ + worktreeRoot: string | null; + cwd: string | null; + branch: string | null; + headState: 'branch' | 'detached' | 'unborn'; + worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; + legacy: boolean; + degraded: boolean; + attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; + }> => { + return sendBridgeMessage<{ + worktreeRoot: string | null; + cwd: string | null; + branch: string | null; + headState: 'branch' | 'detached' | 'unborn'; + worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; + legacy: boolean; + degraded: boolean; + attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; + }>('api:git/canonicalize-worktree-state', { directory }); + }, + worktree: { list: async (directory: string): Promise => { return sendBridgeMessage('api:git/worktrees', { directory, method: 'GET' }); diff --git a/packages/web/server/lib/git/routes.js b/packages/web/server/lib/git/routes.js index 964e4789..03fe983b 100644 --- a/packages/web/server/lib/git/routes.js +++ b/packages/web/server/lib/git/routes.js @@ -823,6 +823,45 @@ export function registerGitRoutes(app) { } }); + app.post('/api/git/validate-directory', async (req, res) => { + const { validateWorktreeDirectory } = await getGitLibraries(); + if (typeof validateWorktreeDirectory !== 'function') { + return res.status(501).json({ error: 'validateWorktreeDirectory is not available' }); + } + try { + const { directory, worktreeRoot } = req.body || {}; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory is required' }); + } + if (!worktreeRoot || typeof worktreeRoot !== 'string') { + return res.status(400).json({ error: 'worktreeRoot is required' }); + } + const result = await validateWorktreeDirectory(directory, worktreeRoot); + res.json(result); + } catch (error) { + console.error('Failed to validate worktree directory:', error); + res.status(500).json({ error: error.message || 'Failed to validate worktree directory' }); + } + }); + + app.post('/api/git/canonicalize-worktree-state', async (req, res) => { + const { canonicalizeWorktreeState } = await getGitLibraries(); + if (typeof canonicalizeWorktreeState !== 'function') { + return res.status(501).json({ error: 'canonicalizeWorktreeState is not available' }); + } + try { + const { directory } = req.body || {}; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory is required' }); + } + const result = await canonicalizeWorktreeState(directory); + res.json(result); + } catch (error) { + console.error('Failed to canonicalize worktree state:', error); + res.status(500).json({ error: error.message || 'Failed to canonicalize worktree state' }); + } + }); + app.get('/api/git/log', async (req, res) => { const { getLog } = await getGitLibraries(); try { diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 0496a465..0b495800 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -2701,6 +2701,141 @@ export async function isLinkedWorktree(directory) { } } +export async function validateWorktreeDirectory(directory, worktreeRoot) { + const directoryPath = normalizeDirectoryPath(directory); + const rootPath = normalizeDirectoryPath(worktreeRoot); + + if (!directoryPath || !rootPath) { + return { + valid: false, + insideWorktreeRoot: false, + resolvedWorktreeRoot: null, + resolvedCwd: null, + }; + } + + const isRepo = await isGitRepository(directoryPath); + if (!isRepo) { + return { + valid: false, + insideWorktreeRoot: false, + resolvedWorktreeRoot: null, + resolvedCwd: null, + }; + } + + const resolvedCwd = await canonicalPath(directoryPath); + const resolvedRoot = await canonicalPath(rootPath); + + const inside = resolvedCwd.startsWith(resolvedRoot + path.sep) || resolvedCwd === resolvedRoot; + + return { + valid: true, + insideWorktreeRoot: inside, + resolvedWorktreeRoot: resolvedRoot, + resolvedCwd, + }; +} + +export async function canonicalizeWorktreeState(directory) { + const directoryPath = normalizeDirectoryPath(directory); + + if (!directoryPath) { + return { + worktreeRoot: null, + cwd: null, + branch: null, + headState: 'detached', + worktreeStatus: 'not-a-repo', + legacy: false, + degraded: false, + attentionReason: null, + }; + } + + const isRepo = await isGitRepository(directoryPath); + if (!isRepo) { + return { + worktreeRoot: null, + cwd: null, + branch: null, + headState: 'detached', + worktreeStatus: 'not-a-repo', + legacy: false, + degraded: false, + attentionReason: null, + }; + } + + const cwd = await canonicalPath(directoryPath); + const git = await createGit(directoryPath); + + let worktreeRoot = null; + let worktreeStatus = 'ready'; + let headState = /** @type {'branch' | 'detached' | 'unborn'} */ ('branch'); + let branch = null; + let attentionReason = /** @type {'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null} */ (null); + + try { + const context = await resolveWorktreeProjectContext(directoryPath); + worktreeRoot = await canonicalPath(context.worktreeRoot); + } catch { + worktreeStatus = 'invalid'; + } + + try { + const symbolicRef = await git.raw(['symbolic-ref', '-q', 'HEAD']).catch(() => ''); + if (symbolicRef.trim()) { + headState = 'branch'; + branch = cleanBranchName(symbolicRef.trim()); + } else { + const revParse = await git.raw(['rev-parse', 'HEAD']).catch(() => ''); + if (!revParse.trim()) { + headState = 'unborn'; + branch = null; + } else { + headState = 'detached'; + branch = revParse.trim().slice(0, 7); + } + } + } catch { + headState = 'unborn'; + branch = null; + } + + // Detect attention reasons from getStatus side-effects + try { + const status = await git.status(['-uall']); + if (status.current && (await git.raw(['rev-parse', '--verify', 'MERGE_HEAD']).then(() => true).catch(() => false))) { + attentionReason = 'merge'; + } else { + const rebaseMerge = await fsp.stat(path.join(directoryPath, '.git', 'rebase-merge')).then(() => true).catch(() => false); + const rebaseApply = await fsp.stat(path.join(directoryPath, '.git', 'rebase-apply')).then(() => true).catch(() => false); + if (rebaseMerge || rebaseApply) { + attentionReason = 'rebase'; + } else if (status.conflicted && status.conflicted.length > 0) { + const cherryPickHead = await fsp.stat(path.join(directoryPath, '.git', 'CHERRY_PICK_HEAD')).then(() => true).catch(() => false); + const revertHead = await fsp.stat(path.join(directoryPath, '.git', 'REVERT_HEAD')).then(() => true).catch(() => false); + if (cherryPickHead) attentionReason = 'cherry-pick'; + else if (revertHead) attentionReason = 'revert'; + } + } + } catch { + // Status check failed — ignore + } + + return { + worktreeRoot, + cwd, + branch, + headState, + worktreeStatus, + legacy: false, + degraded: false, + attentionReason, + }; +} + export async function getCommitFiles(directory, commitHash) { const git = await createGit(directory); diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index 87d71ab5..ec0108bc 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -22,6 +22,8 @@ export const createWebGitAPI = (): GitAPI => ({ validateGitWorktree: gitApiHttp.validateGitWorktree, createGitWorktree: gitApiHttp.createGitWorktree, deleteGitWorktree: gitApiHttp.deleteGitWorktree, + validateWorktreeDirectory: gitApiHttp.validateWorktreeDirectory, + canonicalizeWorktreeState: gitApiHttp.canonicalizeWorktreeState, createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions) { return gitApiHttp.createGitCommit(directory, message, options); },