Merge desktop repository launch safety into main

This brings the desktop branch/worktree launch flow into the local main checkout after validation in the feature worktree. The merge keeps the feature commit intact while preserving the newer main history.

Constraint: Local main already contains newer notification routing work
Confidence: high
Scope-risk: moderate
Tested: ALLOW_CLI_CORE_CHANGE=1 bun run quality:pr in the feature worktree before merge
Tested: /tmp business-flow script with dirty checkout, checked-out branch, isolated worktree, missing branch, non-git, missing directory, and real LLM session
Tested: agent-browser desktop UI flow across dirty, checked-out, non-git, branch search, recent-project, and Run scenarios
This commit is contained in:
程序员阿江(Relakkes) 2026-05-06 14:52:21 +08:00
commit d1bf65d107
15 changed files with 1650 additions and 85 deletions

View File

@ -7,7 +7,41 @@ type MessagesResponse = {
messages: MessageEntry[]
taskNotifications?: AgentTaskNotification[]
}
type CreateSessionResponse = { sessionId: string }
type CreateSessionResponse = { sessionId: string; workDir?: string }
export type CreateSessionRepositoryOptions = {
branch?: string | null
worktree?: boolean
}
export type CreateSessionRequest = {
workDir?: string
repository?: CreateSessionRepositoryOptions
}
export type RepositoryBranchInfo = {
name: string
current: boolean
local: boolean
remote: boolean
remoteRef?: string
checkedOut: boolean
worktreePath?: string
}
export type RepositoryWorktreeInfo = {
path: string
branch: string | null
current: boolean
}
export type RepositoryContextResult = {
state: 'ok' | 'not_git_repo' | 'missing_workdir' | 'error'
workDir: string
repoRoot: string | null
repoName: string | null
currentBranch: string | null
defaultBranch: string | null
dirty: boolean
branches: RepositoryBranchInfo[]
worktrees: RepositoryWorktreeInfo[]
error?: string
}
export type SessionRewindResponse = {
target: {
targetUserMessageId: string
@ -249,8 +283,11 @@ export const sessionsApi = {
return api.get<MessagesResponse>(`/api/sessions/${sessionId}/messages`)
},
create(workDir?: string) {
return api.post<CreateSessionResponse>('/api/sessions', workDir ? { workDir } : {})
create(input?: string | CreateSessionRequest) {
const body = typeof input === 'string'
? (input ? { workDir: input } : {})
: (input ?? {})
return api.post<CreateSessionResponse>('/api/sessions', body)
},
delete(sessionId: string) {
@ -266,6 +303,11 @@ export const sessionsApi = {
return api.get<{ projects: RecentProject[] }>(`/api/sessions/recent-projects${query}`)
},
getRepositoryContext(workDir: string) {
const query = new URLSearchParams({ workDir })
return api.get<RepositoryContextResult>(`/api/sessions/repository-context?${query.toString()}`)
},
getGitInfo(sessionId: string) {
return api.get<{ branch: string | null; repoName: string | null; workDir: string; changedFiles: number }>(`/api/sessions/${sessionId}/git-info`)
},

View File

@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
vi.mock('../../api/sessions', () => ({
sessionsApi: {
getRecentProjects: vi.fn(),
},
}))
vi.mock('../../api/filesystem', () => ({
filesystemApi: {
browse: vi.fn(),
},
}))
import { DirectoryPicker } from './DirectoryPicker'
describe('DirectoryPicker', () => {
it('uses the source repository name as the fallback label for desktop worktree paths', () => {
render(
<DirectoryPicker
value="/workspace/checkout/.claude/worktrees/desktop-feature-rail-12345678"
onChange={vi.fn()}
/>,
)
expect(screen.getByRole('button')).toHaveTextContent('checkout')
expect(screen.getByRole('button')).not.toHaveTextContent('desktop-feature-rail-12345678')
})
})

View File

@ -15,11 +15,19 @@ type DirEntry = { name: string; path: string; isDirectory: boolean }
let cachedProjects: RecentProject[] | null = null
let cacheTimestamp = 0
const CACHE_TTL = 30_000 // 30s
const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/'
function isTauriRuntime() {
return typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
}
function projectNameFromPath(filePath: string) {
const displayRoot = filePath.includes(DESKTOP_WORKTREE_MARKER)
? filePath.slice(0, filePath.indexOf(DESKTOP_WORKTREE_MARKER))
: filePath
return displayRoot.split('/').filter(Boolean).pop() || filePath
}
export function DirectoryPicker({ value, onChange }: Props) {
const t = useTranslation()
const [isOpen, setIsOpen] = useState(false)
@ -154,7 +162,7 @@ export function DirectoryPicker({ value, onChange }: Props) {
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-secondary)]">folder</span>
)}
<span className="font-medium text-[var(--color-text-primary)]">
{selectedProject?.repoName || selectedProject?.projectName || value.split('/').pop()}
{selectedProject?.repoName || selectedProject?.projectName || projectNameFromPath(value)}
</span>
{selectedProject?.branch && (
<>

View File

@ -0,0 +1,402 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import {
AlertCircle,
Check,
ChevronDown,
GitBranch,
GitFork,
Loader2,
Search,
} from 'lucide-react'
import {
sessionsApi,
type RepositoryBranchInfo,
type RepositoryContextResult,
} from '../../api/sessions'
import { useTranslation } from '../../i18n'
import { DirectoryPicker } from './DirectoryPicker'
type Props = {
workDir: string
onWorkDirChange: (path: string) => void
branch: string | null
onBranchChange: (branch: string | null) => void
useWorktree: boolean
onUseWorktreeChange: (enabled: boolean) => void
onLaunchReadyChange?: (ready: boolean) => void
disabled?: boolean
}
const BRANCH_MENU_HEIGHT = 360
const BRANCH_MENU_WIDTH = 390
const VIEWPORT_GUTTER = 12
function stateMessage(context: RepositoryContextResult | null, error: string | null) {
if (error) return error
if (!context) return null
if (context.state === 'not_git_repo') return 'not_git'
if (context.state === 'missing_workdir') return 'missing'
if (context.state === 'error') return context.error || 'error'
return null
}
export function RepositoryLaunchControls({
workDir,
onWorkDirChange,
branch,
onBranchChange,
useWorktree,
onUseWorktreeChange,
onLaunchReadyChange,
disabled = false,
}: Props) {
const t = useTranslation()
const [context, setContext] = useState<RepositoryContextResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [branchMenuOpen, setBranchMenuOpen] = useState(false)
const [branchFilter, setBranchFilter] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const [menuPos, setMenuPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null)
const rootRef = useRef<HTMLDivElement>(null)
const branchButtonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
const searchInputId = useId()
const listboxId = useId()
const updateMenuPos = useCallback(() => {
if (!branchButtonRef.current) return
const rect = branchButtonRef.current.getBoundingClientRect()
const spaceAbove = rect.top
const spaceBelow = window.innerHeight - rect.bottom
const direction = spaceBelow >= BRANCH_MENU_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up'
const maxLeft = Math.max(VIEWPORT_GUTTER, window.innerWidth - BRANCH_MENU_WIDTH - VIEWPORT_GUTTER)
setMenuPos({
top: direction === 'down' ? rect.bottom + 6 : rect.top - 6,
left: Math.min(Math.max(rect.left, VIEWPORT_GUTTER), maxLeft),
direction,
})
}, [])
useEffect(() => {
if (!workDir) {
setContext(null)
setError(null)
setLoading(false)
onBranchChange(null)
return
}
let cancelled = false
setLoading(true)
setError(null)
sessionsApi.getRepositoryContext(workDir)
.then((result) => {
if (cancelled) return
setContext(result)
})
.catch((err) => {
if (cancelled) return
setContext(null)
setError(err instanceof Error ? err.message : String(err))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [workDir, onBranchChange])
useEffect(() => {
if (context?.state !== 'ok') {
if (context && branch !== null) onBranchChange(null)
return
}
const branchExists = branch && context.branches.some((candidate) => candidate.name === branch)
if (branchExists) return
const fallbackBranch = [
context.currentBranch,
context.defaultBranch,
context.branches[0]?.name,
].find((name) => name && context.branches.some((candidate) => candidate.name === name))
onBranchChange(fallbackBranch || null)
}, [branch, context, onBranchChange])
useEffect(() => {
if (!branchMenuOpen) return
const handleClick = (event: MouseEvent) => {
const target = event.target as Node
if (rootRef.current?.contains(target)) return
if (menuRef.current?.contains(target)) return
setBranchMenuOpen(false)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
event.preventDefault()
setBranchMenuOpen(false)
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleKeyDown)
}
}, [branchMenuOpen])
useEffect(() => {
if (!branchMenuOpen) return
updateMenuPos()
window.addEventListener('scroll', updateMenuPos, true)
window.addEventListener('resize', updateMenuPos)
requestAnimationFrame(() => searchRef.current?.focus())
return () => {
window.removeEventListener('scroll', updateMenuPos, true)
window.removeEventListener('resize', updateMenuPos)
}
}, [branchMenuOpen, updateMenuPos])
useEffect(() => {
setSelectedIndex(0)
}, [branchFilter])
useEffect(() => {
const activeItem = branchMenuOpen ? itemRefs.current[selectedIndex] : null
activeItem?.scrollIntoView({ block: 'nearest' })
}, [branchMenuOpen, selectedIndex])
const selectedBranch = useMemo(() => {
if (context?.state !== 'ok') return null
return context.branches.find((candidate) => candidate.name === branch) ?? null
}, [branch, context])
const filteredBranches = useMemo(() => {
if (context?.state !== 'ok') return []
const query = branchFilter.trim().toLowerCase()
if (!query) return context.branches
return context.branches.filter((candidate) => (
candidate.name.toLowerCase().includes(query) ||
candidate.remoteRef?.toLowerCase().includes(query) ||
candidate.worktreePath?.toLowerCase().includes(query)
))
}, [branchFilter, context])
const warningMessage = useMemo(() => {
if (context?.state !== 'ok' || !selectedBranch || useWorktree) return null
if (selectedBranch.name !== context.currentBranch && context.dirty) {
return t('repoLaunch.dirtyWarning')
}
if (selectedBranch.name !== context.currentBranch && selectedBranch.checkedOut) {
return t('repoLaunch.checkedOutWarning')
}
return null
}, [context, selectedBranch, t, useWorktree])
const selectBranch = (candidate: RepositoryBranchInfo) => {
onBranchChange(candidate.name)
setBranchMenuOpen(false)
setBranchFilter('')
}
const handleBranchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
setSelectedIndex((prev) => Math.min(prev + 1, Math.max(filteredBranches.length - 1, 0)))
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setSelectedIndex((prev) => Math.max(prev - 1, 0))
return
}
if (event.key === 'Enter') {
event.preventDefault()
const candidate = filteredBranches[selectedIndex]
if (candidate) selectBranch(candidate)
return
}
if (event.key === 'Escape') {
event.preventDefault()
setBranchMenuOpen(false)
}
}
const message = stateMessage(context, error)
const isGitReady = context?.state === 'ok'
const isLaunchReady = !workDir || (
!loading &&
(!!context || !!error) &&
(
context?.state !== 'ok' ||
context.branches.length === 0 ||
!!selectedBranch
)
)
useEffect(() => {
onLaunchReadyChange?.(isLaunchReady)
}, [isLaunchReady, onLaunchReadyChange])
return (
<div ref={rootRef} className="flex min-w-0 flex-col gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<DirectoryPicker value={workDir} onChange={onWorkDirChange} />
{loading && workDir && (
<div className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)]">
<Loader2 size={14} className="shrink-0 animate-spin" />
<span>{t('common.loading')}</span>
</div>
)}
{isGitReady && (
<>
<button
ref={branchButtonRef}
type="button"
disabled={disabled || loading || context.branches.length === 0}
aria-haspopup="listbox"
aria-expanded={branchMenuOpen}
aria-label={t('repoLaunch.selectBranch')}
onClick={() => {
setBranchMenuOpen((prev) => !prev)
setBranchFilter('')
}}
className="inline-flex max-w-full items-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50"
>
<GitBranch size={15} className="shrink-0" />
<span className="truncate font-medium text-[var(--color-text-primary)]">
{selectedBranch?.name || t('repoLaunch.noBranch')}
</span>
<ChevronDown size={14} className="shrink-0 text-[var(--color-text-tertiary)]" />
</button>
<button
type="button"
disabled={disabled}
aria-pressed={useWorktree}
aria-label={t('repoLaunch.toggleWorktree')}
onClick={() => onUseWorktreeChange(!useWorktree)}
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50 ${
useWorktree
? 'border-[var(--color-brand)]/40 bg-[var(--color-primary-fixed)] text-[var(--color-text-primary)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<GitFork size={15} className="shrink-0" />
<span className="font-medium">
{useWorktree ? t('repoLaunch.worktreeIsolated') : t('repoLaunch.worktreeCurrent')}
</span>
</button>
</>
)}
</div>
{message && workDir && (
<div className="flex items-center gap-2 px-1 text-[11px] text-[var(--color-text-tertiary)]">
<AlertCircle size={13} className="shrink-0" />
<span>
{message === 'not_git'
? t('repoLaunch.notGit')
: message === 'missing'
? t('repoLaunch.missingWorkdir')
: message}
</span>
</div>
)}
{warningMessage && (
<div className="flex items-center gap-2 px-1 text-[11px] text-[var(--color-warning)]">
<AlertCircle size={13} className="shrink-0" />
<span>{warningMessage}</span>
</div>
)}
{branchMenuOpen && menuPos && createPortal(
<div
ref={menuRef}
className="w-[390px] overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
style={{
position: 'fixed',
left: menuPos.left,
...(menuPos.direction === 'down'
? { top: menuPos.top }
: { bottom: window.innerHeight - menuPos.top }),
zIndex: 9999,
}}
>
<div className="border-b border-[var(--color-border)] p-3">
<label htmlFor={searchInputId} className="mb-2 block text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('repoLaunch.selectBranch')}
</label>
<div className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
<Search size={15} className="shrink-0 text-[var(--color-text-tertiary)]" />
<input
id={searchInputId}
ref={searchRef}
value={branchFilter}
onChange={(event) => setBranchFilter(event.target.value)}
onKeyDown={handleBranchKeyDown}
aria-controls={listboxId}
aria-activedescendant={filteredBranches[selectedIndex] ? `${listboxId}-option-${selectedIndex}` : undefined}
placeholder={t('repoLaunch.searchBranch')}
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
</div>
</div>
<div id={listboxId} role="listbox" aria-label={t('repoLaunch.selectBranch')} className="max-h-[280px] overflow-y-auto py-1">
{filteredBranches.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-[var(--color-text-tertiary)]">
{t('repoLaunch.noBranchMatch')}
</div>
) : filteredBranches.map((candidate, index) => {
const isSelected = candidate.name === selectedBranch?.name
return (
<button
key={candidate.name}
id={`${listboxId}-option-${index}`}
ref={(el) => { itemRefs.current[index] = el }}
type="button"
role="option"
aria-selected={isSelected}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => selectBranch(candidate)}
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 ${
index === selectedIndex || isSelected ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className={`h-8 w-1 rounded-full ${isSelected ? 'bg-[var(--color-brand)]' : 'bg-transparent'}`} />
<GitBranch size={17} className="shrink-0 text-[var(--color-text-secondary)]" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-[var(--color-text-primary)]">
{candidate.name}
</span>
<span className="block truncate text-[11px] text-[var(--color-text-tertiary)]">
{candidate.current
? t('repoLaunch.currentBranch')
: candidate.checkedOut
? t('repoLaunch.checkedOut')
: candidate.remote && !candidate.local
? candidate.remoteRef || t('repoLaunch.remoteBranch')
: t('repoLaunch.localBranch')}
</span>
</span>
{isSelected && <Check size={17} className="shrink-0 text-[var(--color-brand)]" />}
</button>
)
})}
</div>
</div>,
document.body,
)}
</div>
)
}

View File

@ -673,6 +673,31 @@ export const en = {
'empty.addFiles': 'Add files or photos',
'empty.slashCommands': 'Slash commands',
'empty.failedToCreate': 'Failed to create session',
'empty.createError.workdirMissing': 'The project folder is missing or is not a directory. Pick an existing project and try again.',
'empty.createError.notGit': 'This project is not a Git repository, so branch launch is unavailable. Start in the current folder or pick a Git project.',
'empty.createError.branchNotFound': 'The selected branch no longer exists. Refresh the project context and choose another branch.',
'empty.createError.dirtyWorktree': 'Current project has uncommitted changes. Direct branch switching was blocked; enable "Isolated worktree" or commit/stash your changes first.',
'empty.createError.branchCheckedOut': 'That branch is already checked out in another worktree. Enable "Isolated worktree" or choose a different branch.',
'empty.createError.worktreeCreateFailed': 'Could not create the isolated worktree. Check that Git can write to this repository, then try again. Detail: {detail}',
'empty.createError.switchFailed': 'Could not switch the current project branch. Check the Git error, keep your changes safe, then try again. Detail: {detail}',
'empty.createError.contextFailed': 'Could not inspect this Git project. Check the repository state and try again.',
// ─── Repository Launch Controls ──────────────────────────────────────
'repoLaunch.selectBranch': 'Select branch',
'repoLaunch.searchBranch': 'Search branches',
'repoLaunch.noBranch': 'No branch',
'repoLaunch.noBranchMatch': 'No matching branches',
'repoLaunch.currentBranch': 'Current branch',
'repoLaunch.localBranch': 'Local branch',
'repoLaunch.remoteBranch': 'Remote branch',
'repoLaunch.checkedOut': 'Checked out in another worktree',
'repoLaunch.worktreeCurrent': 'Current worktree',
'repoLaunch.worktreeIsolated': 'Isolated worktree',
'repoLaunch.toggleWorktree': 'Toggle worktree isolation',
'repoLaunch.notGit': 'Current project is not a Git repository.',
'repoLaunch.missingWorkdir': 'Working directory is missing.',
'repoLaunch.dirtyWarning': 'Uncommitted changes detected. Direct switching will be blocked; enable isolated worktree to continue without touching this folder.',
'repoLaunch.checkedOutWarning': 'This branch is already checked out elsewhere. Enable isolated worktree or choose another branch.',
// ─── Chat Input ──────────────────────────────────────
'chat.placeholder': 'Ask Claude to edit, debug or explain...',

View File

@ -675,6 +675,31 @@ export const zh: Record<TranslationKey, string> = {
'empty.addFiles': '添加文件或图片',
'empty.slashCommands': '斜杠命令',
'empty.failedToCreate': '创建会话失败',
'empty.createError.workdirMissing': '项目目录不存在或不是文件夹。请选择一个存在的项目后重试。',
'empty.createError.notGit': '当前项目不是 Git 仓库,无法选择分支启动。可以直接在当前目录开始,或改选一个 Git 项目。',
'empty.createError.branchNotFound': '选中的分支已经不存在。请刷新项目上下文后重新选择分支。',
'empty.createError.dirtyWorktree': '当前项目有未提交改动,已阻止直接切换分支。请开启“独立工作树”,或先提交/暂存这些改动。',
'empty.createError.branchCheckedOut': '该分支已在其他工作树中检出。请开启“独立工作树”,或选择其他分支。',
'empty.createError.worktreeCreateFailed': '无法创建独立工作树。请确认 Git 可以写入此仓库后重试。详情:{detail}',
'empty.createError.switchFailed': '无法切换当前项目分支。请先确认 Git 错误并保护好本地改动后重试。详情:{detail}',
'empty.createError.contextFailed': '无法检查这个 Git 项目。请确认仓库状态后重试。',
// ─── Repository Launch Controls ──────────────────────────────────────
'repoLaunch.selectBranch': '选择分支',
'repoLaunch.searchBranch': '搜索分支',
'repoLaunch.noBranch': '无分支',
'repoLaunch.noBranchMatch': '没有匹配的分支',
'repoLaunch.currentBranch': '当前分支',
'repoLaunch.localBranch': '本地分支',
'repoLaunch.remoteBranch': '远程分支',
'repoLaunch.checkedOut': '已在其他工作树中检出',
'repoLaunch.worktreeCurrent': '当前工作树',
'repoLaunch.worktreeIsolated': '独立工作树',
'repoLaunch.toggleWorktree': '切换工作树隔离',
'repoLaunch.notGit': '当前项目不是 Git 仓库。',
'repoLaunch.missingWorkdir': '工作目录不存在。',
'repoLaunch.dirtyWarning': '检测到未提交变更,直接切换会被阻止。开启独立工作树即可继续,且不会改动当前目录。',
'repoLaunch.checkedOutWarning': '这个分支已在其他工作树中检出。请开启独立工作树或选择其他分支。',
// ─── Chat Input ──────────────────────────────────────
'chat.placeholder': '让 Claude 编辑、调试或解释代码...',

View File

@ -5,6 +5,7 @@ import '@testing-library/jest-dom'
const mocks = vi.hoisted(() => ({
createSession: vi.fn(),
listSessions: vi.fn(),
getRepositoryContext: vi.fn(),
getMessages: vi.fn(),
getSlashCommands: vi.fn(),
listSkills: vi.fn(),
@ -21,6 +22,7 @@ vi.mock('../api/sessions', () => ({
sessionsApi: {
create: mocks.createSession,
list: mocks.listSessions,
getRepositoryContext: mocks.getRepositoryContext,
getMessages: mocks.getMessages,
getSlashCommands: mocks.getSlashCommands,
},
@ -66,12 +68,40 @@ vi.mock('../components/controls/ModelSelector', () => ({
}))
import { EmptySession } from './EmptySession'
import { ApiError } from '../api/client'
import { useChatStore } from '../stores/chatStore'
import { useSessionRuntimeStore } from '../stores/sessionRuntimeStore'
import { useSessionStore } from '../stores/sessionStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useTabStore } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore'
import type { RepositoryContextResult } from '../api/sessions'
function okRepositoryContext(overrides: Partial<RepositoryContextResult> = {}): RepositoryContextResult {
return {
state: 'ok',
workDir: '/workspace/project',
repoRoot: '/workspace/project',
repoName: 'project',
currentBranch: 'main',
defaultBranch: 'main',
dirty: false,
branches: [{
name: 'main',
current: true,
local: true,
remote: false,
checkedOut: true,
worktreePath: '/workspace/project',
}],
worktrees: [{
path: '/workspace/project',
branch: 'main',
current: true,
}],
...overrides,
}
}
describe('EmptySession', () => {
const initialSessionState = useSessionStore.getInitialState()
@ -90,6 +120,7 @@ describe('EmptySession', () => {
useUIStore.setState(initialUiState, true)
mocks.createSession.mockResolvedValue({ sessionId: 'draft-session' })
mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext())
mocks.listSessions.mockResolvedValue({
sessions: [{
id: 'draft-session',
@ -118,7 +149,7 @@ describe('EmptySession', () => {
useUIStore.setState(initialUiState, true)
})
it('creates an empty session as soon as a project is selected', async () => {
it('creates a session with the selected project and branch when submitted', async () => {
render(<EmptySession />)
fireEvent.change(screen.getByRole('textbox'), {
@ -126,8 +157,19 @@ describe('EmptySession', () => {
})
fireEvent.click(screen.getByRole('button', { name: 'Pick project' }))
expect(mocks.createSession).not.toHaveBeenCalled()
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith('/workspace/project')
expect(screen.getByText('main')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({
workDir: '/workspace/project',
repository: { branch: 'main', worktree: false },
})
})
expect(useTabStore.getState().activeTabId).toBe('draft-session')
@ -138,7 +180,121 @@ describe('EmptySession', () => {
id: 'draft-session',
workDir: '/workspace/project',
})
expect(useChatStore.getState().sessions['draft-session']?.composerPrefill?.text).toBe('draft question')
const messages = useChatStore.getState().sessions['draft-session']?.messages ?? []
expect(messages[messages.length - 1]).toMatchObject({
type: 'user_text',
content: 'draft question',
})
expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
type: 'user_message',
content: 'draft question',
attachments: [],
})
expect(mocks.wsConnect).toHaveBeenCalledWith('draft-session')
})
it('shows an actionable repository error when direct branch switching is blocked', async () => {
mocks.createSession.mockRejectedValueOnce(new ApiError(400, {
error: 'REPOSITORY_DIRTY_WORKTREE',
message: 'Working tree has uncommitted changes.',
}))
render(<EmptySession />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'draft question', selectionStart: 14 },
})
fireEvent.click(screen.getByRole('button', { name: 'Pick project' }))
await waitFor(() => {
expect(screen.getByText('main')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
const toasts = useUIStore.getState().toasts
expect(toasts[toasts.length - 1]?.message).toBe(
'Current project has uncommitted changes. Direct branch switching was blocked; enable "Isolated worktree" or commit/stash your changes first.',
)
})
expect(useTabStore.getState().activeTabId).toBeNull()
})
it('keeps Run disabled until repository context resolves for a selected project', async () => {
let resolveContext: (context: RepositoryContextResult) => void = () => {}
mocks.getRepositoryContext.mockImplementationOnce(() => new Promise<RepositoryContextResult>((resolve) => {
resolveContext = resolve
}))
render(<EmptySession />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'draft question', selectionStart: 14 },
})
fireEvent.click(screen.getByRole('button', { name: 'Pick project' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /Run/i })).toBeDisabled()
})
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
expect(mocks.createSession).not.toHaveBeenCalled()
resolveContext(okRepositoryContext())
await waitFor(() => {
expect(screen.getByRole('button', { name: /Run/i })).not.toBeDisabled()
})
})
it('falls back to a visible branch when the current branch is an internal desktop worktree branch', async () => {
mocks.getRepositoryContext.mockResolvedValueOnce(okRepositoryContext({
currentBranch: 'worktree-desktop-feature-a-12345678',
defaultBranch: 'main',
branches: [
{
name: 'main',
current: false,
local: true,
remote: false,
checkedOut: false,
},
{
name: 'feature/a',
current: false,
local: true,
remote: false,
checkedOut: false,
},
],
worktrees: [{
path: '/workspace/project/.claude/worktrees/desktop-feature-a-12345678',
branch: 'worktree-desktop-feature-a-12345678',
current: true,
}],
}))
render(<EmptySession />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'draft question', selectionStart: 14 },
})
fireEvent.click(screen.getByRole('button', { name: 'Pick project' }))
await waitFor(() => {
expect(screen.getByText('main')).toBeInTheDocument()
})
expect(screen.queryByText('worktree-desktop-feature-a-12345678')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({
workDir: '/workspace/project',
repository: { branch: 'main', worktree: false },
})
})
})
})

View File

@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ApiError } from '../api/client'
import { skillsApi } from '../api/skills'
import { useTranslation } from '../i18n'
import { useSessionStore } from '../stores/sessionStore'
@ -9,7 +10,7 @@ import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
import { OFFICIAL_DEFAULT_MODEL_ID } from '../constants/modelCatalog'
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
import { RepositoryLaunchControls } from '../components/shared/RepositoryLaunchControls'
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
import { ModelSelector } from '../components/controls/ModelSelector'
import { AttachmentGallery } from '../components/chat/AttachmentGallery'
@ -24,7 +25,7 @@ import {
replaceSlashCommand,
resolveSlashUiAction,
} from '../components/chat/composerUtils'
import type { AttachmentRef, UIAttachment } from '../types/chat'
import type { AttachmentRef } from '../types/chat'
import type { SlashCommandOption } from '../components/chat/composerUtils'
type Attachment = {
@ -37,12 +38,52 @@ type Attachment = {
data?: string
}
type Translate = ReturnType<typeof useTranslation>
function getApiErrorCode(error: unknown): string | null {
if (!(error instanceof ApiError)) return null
const body = error.body
if (!body || typeof body !== 'object' || !('error' in body)) return null
return typeof body.error === 'string' ? body.error : null
}
function resolveCreateSessionErrorMessage(error: unknown, t: Translate): string {
const code = getApiErrorCode(error)
switch (code) {
case 'WORKDIR_MISSING':
case 'WORKDIR_NOT_DIRECTORY':
return t('empty.createError.workdirMissing')
case 'REPOSITORY_NOT_GIT':
return t('empty.createError.notGit')
case 'REPOSITORY_BRANCH_NOT_FOUND':
return t('empty.createError.branchNotFound')
case 'REPOSITORY_DIRTY_WORKTREE':
return t('empty.createError.dirtyWorktree')
case 'REPOSITORY_BRANCH_CHECKED_OUT':
return t('empty.createError.branchCheckedOut')
case 'REPOSITORY_WORKTREE_CREATE_FAILED':
return t('empty.createError.worktreeCreateFailed', {
detail: error instanceof Error ? error.message : t('empty.failedToCreate'),
})
case 'REPOSITORY_SWITCH_FAILED':
return t('empty.createError.switchFailed', {
detail: error instanceof Error ? error.message : t('empty.failedToCreate'),
})
case 'REPOSITORY_CONTEXT_ERROR':
return t('empty.createError.contextFailed')
default:
return error instanceof Error ? error.message : t('empty.failedToCreate')
}
}
export function EmptySession() {
const t = useTranslation()
const [input, setInput] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isCreatingSession, setIsCreatingSession] = useState(false)
const [workDir, setWorkDir] = useState('')
const [selectedBranch, setSelectedBranch] = useState<string | null>(null)
const [useWorktree, setUseWorktree] = useState(false)
const [repositoryLaunchReady, setRepositoryLaunchReady] = useState(true)
const [attachments, setAttachments] = useState<Attachment[]>([])
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
@ -191,46 +232,11 @@ export function EmptySession() {
)
}
const openDraftSessionForWorkDir = async (newWorkDir: string) => {
const handleWorkDirChange = (newWorkDir: string) => {
setWorkDir(newWorkDir)
if (!newWorkDir || isCreatingSession || isSubmitting) return
setIsCreatingSession(true)
try {
const draftSelection = await resolveDraftRuntimeSelection()
const sessionId = await createSession(newWorkDir)
useSessionRuntimeStore.getState().setSelection(sessionId, draftSelection)
useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY)
setActiveView('code')
useTabStore.getState().openTab(sessionId, 'New Session')
connectToSession(sessionId)
const draftAttachments: UIAttachment[] = attachments.map((attachment) => ({
type: attachment.type,
name: attachment.name,
data: attachment.data,
mimeType: attachment.mimeType,
}))
if (input.trim() || draftAttachments.length > 0) {
useChatStore.getState().queueComposerPrefill(sessionId, {
text: input,
attachments: draftAttachments,
})
}
setInput('')
setAttachments([])
setFileSearchOpen(false)
setSlashMenuOpen(false)
setPlusMenuOpen(false)
setLocalSlashPanel(null)
} catch (error) {
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('empty.failedToCreate'),
})
} finally {
setIsCreatingSession(false)
}
setSelectedBranch(null)
setUseWorktree(false)
setRepositoryLaunchReady(!newWorkDir)
}
const filteredCommands = useMemo(() => {
@ -248,6 +254,11 @@ export function EmptySession() {
if (!normalized) return null
return filteredCommands.find((command) => command.name.toLowerCase() === normalized) ?? null
}, [filteredCommands, slashFilter])
const canSubmit = (
input.trim().length > 0 ||
attachments.length > 0 ||
!!workDir
) && !isSubmitting && repositoryLaunchReady
useEffect(() => {
setSlashSelectedIndex(0)
@ -262,7 +273,7 @@ export function EmptySession() {
const handleSubmit = async () => {
const text = input.trim()
if ((!text && attachments.length === 0) || isSubmitting) return
if (!canSubmit) return
const slashUiAction = text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null
if (slashUiAction?.type === 'panel') {
@ -287,7 +298,12 @@ export function EmptySession() {
setIsSubmitting(true)
try {
const draftSelection = await resolveDraftRuntimeSelection()
const sessionId = await createSession(workDir || undefined)
const sessionId = await createSession(
workDir || undefined,
selectedBranch
? { repository: { branch: selectedBranch, worktree: useWorktree } }
: undefined,
)
useSessionRuntimeStore.getState().setSelection(sessionId, draftSelection)
useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY)
setActiveView('code')
@ -300,13 +316,15 @@ export function EmptySession() {
data: attachment.data,
mimeType: attachment.mimeType,
}))
sendMessage(sessionId, text, attachmentPayload)
if (text || attachmentPayload.length > 0) {
sendMessage(sessionId, text, attachmentPayload)
}
setInput('')
setAttachments([])
} catch (error) {
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('empty.failedToCreate'),
message: resolveCreateSessionErrorMessage(error, t),
})
} finally {
setIsSubmitting(false)
@ -681,10 +699,10 @@ export function EmptySession() {
fallbackModelLabel={draftModelLabel}
draft
/>
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting || isCreatingSession} />
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting} />
<button
onClick={handleSubmit}
disabled={(!input.trim() && attachments.length === 0) || isSubmitting || isCreatingSession}
disabled={!canSubmit}
className="flex w-[112px] items-center justify-center gap-1 rounded-lg bg-[image:var(--gradient-btn-primary)] px-3 py-1.5 text-xs font-semibold text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-all hover:brightness-105 disabled:opacity-30"
>
{t('common.run')}
@ -694,9 +712,16 @@ export function EmptySession() {
</div>
</div>
<div>
<DirectoryPicker value={workDir} onChange={(path) => void openDraftSessionForWorkDir(path)} />
</div>
<RepositoryLaunchControls
workDir={workDir}
onWorkDirChange={handleWorkDirChange}
branch={selectedBranch}
onBranchChange={setSelectedBranch}
useWorktree={useWorktree}
onUseWorktreeChange={setUseWorktree}
onLaunchReadyChange={setRepositoryLaunchReady}
disabled={isSubmitting}
/>
</div>
</div>

View File

@ -58,6 +58,42 @@ describe('sessionStore', () => {
workDir: 'D:/workspace/code/myself_code/cc-haha',
workDirExists: true,
})
expect(createMock).toHaveBeenCalledWith({
workDir: 'D:/workspace/code/myself_code/cc-haha',
})
expect(listMock).toHaveBeenCalledOnce()
})
it('forwards direct branch switch repository options when creating a session', async () => {
createMock.mockResolvedValue({ sessionId: 'session-branch-switch', workDir: '/workspace/repo' })
listMock.mockResolvedValue({ sessions: [], total: 0 })
await useSessionStore.getState().createSession('/workspace/repo', {
repository: { branch: 'feature/rail', worktree: false },
})
expect(createMock).toHaveBeenCalledWith({
workDir: '/workspace/repo',
repository: { branch: 'feature/rail', worktree: false },
})
})
it('forwards isolated worktree repository options when creating a session', async () => {
createMock.mockResolvedValue({
sessionId: 'session-worktree-launch',
workDir: '/workspace/repo/.claude/worktrees/desktop-feature-rail-12345678',
})
listMock.mockImplementation(() => new Promise(() => {}))
await useSessionStore.getState().createSession('/workspace/repo', {
repository: { branch: 'feature/rail', worktree: true },
})
expect(createMock).toHaveBeenCalledWith({
workDir: '/workspace/repo',
repository: { branch: 'feature/rail', worktree: true },
})
expect(useSessionStore.getState().sessions[0]?.workDir)
.toBe('/workspace/repo/.claude/worktrees/desktop-feature-rail-12345678')
})
})

View File

@ -1,8 +1,12 @@
import { create } from 'zustand'
import { sessionsApi } from '../api/sessions'
import { sessionsApi, type CreateSessionRepositoryOptions } from '../api/sessions'
import { useSessionRuntimeStore } from './sessionRuntimeStore'
import type { SessionListItem } from '../types/session'
type CreateSessionOptions = {
repository?: CreateSessionRepositoryOptions
}
type SessionStore = {
sessions: SessionListItem[]
activeSessionId: string | null
@ -12,7 +16,7 @@ type SessionStore = {
availableProjects: string[]
fetchSessions: (project?: string) => Promise<void>
createSession: (workDir?: string) => Promise<string>
createSession: (workDir?: string, options?: CreateSessionOptions) => Promise<string>
deleteSession: (id: string) => Promise<void>
renameSession: (id: string, title: string) => Promise<void>
updateSessionTitle: (id: string, title: string) => void
@ -48,8 +52,11 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
}
},
createSession: async (workDir?: string) => {
const { sessionId: id } = await sessionsApi.create(workDir || undefined)
createSession: async (workDir?: string, options?: CreateSessionOptions) => {
const { sessionId: id, workDir: resolvedWorkDir } = await sessionsApi.create({
...(workDir ? { workDir } : {}),
...(options?.repository ? { repository: options.repository } : {}),
})
const now = new Date().toISOString()
const optimisticSession: SessionListItem = {
id,
@ -58,7 +65,7 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
modifiedAt: now,
messageCount: 0,
projectPath: '',
workDir: workDir ?? null,
workDir: resolvedWorkDir ?? workDir ?? null,
workDirExists: true,
}

View File

@ -9,6 +9,7 @@ import * as path from 'node:path'
import * as os from 'node:os'
import { SessionService } from '../services/sessionService.js'
import { sessionService } from '../services/sessionService.js'
import { getRepositoryContext } from '../services/repositoryLaunchService.js'
import { conversationService } from '../services/conversationService.js'
import { clearCommandsCache } from '../../commands.js'
import { sanitizePath } from '../../utils/sessionStoragePortable.js'
@ -106,6 +107,29 @@ async function createWorkspaceApiGitRepo(baseDir: string): Promise<string> {
return workDir
}
async function createCleanGitRepo(baseDir: string): Promise<string> {
const workDir = path.join(
baseDir,
`repo-${Date.now()}-${Math.random().toString(36).slice(2)}`,
)
await fs.mkdir(workDir, { recursive: true })
git(workDir, 'init')
git(workDir, 'config', 'user.email', 'sessions-api@example.com')
git(workDir, 'config', 'user.name', 'Sessions API')
git(workDir, 'checkout', '-b', 'main')
await fs.writeFile(path.join(workDir, 'README.md'), 'main\n')
git(workDir, 'add', 'README.md')
git(workDir, 'commit', '-m', 'initial')
git(workDir, 'checkout', '-b', 'feature/rail')
await fs.writeFile(path.join(workDir, 'feature.txt'), 'feature\n')
git(workDir, 'add', 'feature.txt')
git(workDir, 'commit', '-m', 'feature')
git(workDir, 'checkout', 'main')
return workDir
}
// Sample entries matching real CLI format
function makeSnapshotEntry(): Record<string, unknown> {
return {
@ -861,6 +885,132 @@ describe('SessionService', () => {
expect(entry.type).toBe('file-history-snapshot')
})
it('should create a session in an isolated worktree from the selected branch', async () => {
const workDir = await createCleanGitRepo(tmpDir)
const { sessionId, workDir: sessionWorkDir } = await service.createSession(
workDir,
{ branch: 'feature/rail', worktree: true },
)
expect(sessionWorkDir).toContain(path.join('.claude', 'worktrees', 'desktop-feature-rail-'))
expect(git(sessionWorkDir, 'branch', '--show-current')).toStartWith('worktree-desktop-feature-rail-')
expect(await fs.readFile(path.join(sessionWorkDir, 'feature.txt'), 'utf-8')).toBe('feature\n')
expect(git(workDir, 'status', '--porcelain')).toBe('')
expect(await fs.readFile(path.join(workDir, '.git', 'info', 'exclude'), 'utf-8'))
.toContain('.claude/worktrees/')
const sanitized = sanitizePath(await fs.realpath(sessionWorkDir))
const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`)
const lines = (await fs.readFile(filePath, 'utf-8')).trim().split('\n')
const metadata = JSON.parse(lines[1]!)
expect(metadata.workDir).toBe(sessionWorkDir)
expect(metadata.repository).toMatchObject({
requestedWorkDir: await fs.realpath(workDir),
branch: 'feature/rail',
worktree: true,
worktreePath: sessionWorkDir,
})
const context = await getRepositoryContext(workDir)
expect(context.state).toBe('ok')
expect(context.branches.map((branch) => branch.name)).not.toContain(
path.basename(sessionWorkDir).replace(/^desktop-/, 'worktree-desktop-'),
)
expect(context.branches.some((branch) => branch.name.startsWith('worktree-desktop-'))).toBe(false)
})
it('should switch a clean checkout to the selected branch when worktree isolation is disabled', async () => {
const workDir = await createCleanGitRepo(tmpDir)
const { sessionId, workDir: sessionWorkDir } = await service.createSession(
workDir,
{ branch: 'feature/rail', worktree: false },
)
expect(sessionWorkDir).toBe(await fs.realpath(workDir))
expect(git(workDir, 'branch', '--show-current')).toBe('feature/rail\n')
expect(await fs.readFile(path.join(workDir, 'feature.txt'), 'utf-8')).toBe('feature\n')
const sanitized = sanitizePath(await fs.realpath(workDir))
const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`)
const lines = (await fs.readFile(filePath, 'utf-8')).trim().split('\n')
const metadata = JSON.parse(lines[1]!)
expect(metadata.workDir).toBe(await fs.realpath(workDir))
expect(metadata.repository).toMatchObject({
requestedWorkDir: await fs.realpath(workDir),
branch: 'feature/rail',
worktree: false,
baseRef: 'feature/rail',
})
})
it('should not select hidden desktop worktree branches when no branch is requested', async () => {
const workDir = await createCleanGitRepo(tmpDir)
const { workDir: firstWorktreeDir } = await service.createSession(
workDir,
{ branch: 'feature/rail', worktree: true },
)
const { workDir: nestedWorktreeDir } = await service.createSession(
firstWorktreeDir,
{ worktree: true },
)
expect(nestedWorktreeDir).toContain(path.join('.claude', 'worktrees', 'desktop-'))
expect(git(nestedWorktreeDir, 'branch', '--show-current')).toStartWith('worktree-desktop-')
const context = await getRepositoryContext(firstWorktreeDir)
expect(context.state).toBe('ok')
expect(context.currentBranch).toStartWith('worktree-desktop-')
expect(context.branches.some((branch) => branch.name === context.currentBranch)).toBe(false)
expect(context.branches.some((branch) => branch.name.startsWith('worktree-desktop-'))).toBe(false)
})
it('should reject direct branch launch from a dirty working tree with a stable error code', async () => {
const workDir = await createCleanGitRepo(tmpDir)
await fs.writeFile(path.join(workDir, 'README.md'), 'main\nlocal-pricing-edit\n')
await expect(service.createSession(
workDir,
{ branch: 'feature/rail', worktree: false },
)).rejects.toMatchObject({ code: 'REPOSITORY_DIRTY_WORKTREE' })
expect(git(workDir, 'branch', '--show-current')).toBe('main\n')
expect(await fs.readFile(path.join(workDir, 'README.md'), 'utf-8'))
.toContain('local-pricing-edit')
})
it('should reject direct branch launch when the branch is checked out elsewhere', async () => {
const workDir = await createCleanGitRepo(tmpDir)
const existingWorktree = path.join(tmpDir, `existing-feature-rail-${Date.now()}`)
git(workDir, 'worktree', 'add', existingWorktree, 'feature/rail')
await expect(service.createSession(
workDir,
{ branch: 'feature/rail', worktree: false },
)).rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_CHECKED_OUT' })
expect(git(workDir, 'branch', '--show-current')).toBe('main\n')
})
it('should reject branch launch outside Git repositories with a stable error code', async () => {
const workDir = path.join(tmpDir, `not-git-${Date.now()}`)
await fs.mkdir(workDir, { recursive: true })
await expect(service.createSession(
workDir,
{ branch: 'main', worktree: false },
)).rejects.toMatchObject({ code: 'REPOSITORY_NOT_GIT' })
})
it('should reject missing selected branches with a stable error code', async () => {
const workDir = await createCleanGitRepo(tmpDir)
await expect(service.createSession(
workDir,
{ branch: 'missing/branch', worktree: true },
)).rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_NOT_FOUND' })
})
it('should create a Windows-safe project directory name', async () => {
if (process.platform !== 'win32') return
@ -1097,6 +1247,55 @@ describe('Sessions API', () => {
)
})
it('GET /api/sessions/repository-context should return branch launch metadata', async () => {
const workDir = await createCleanGitRepo(tmpDir)
const res = await fetch(
`${baseUrl}/api/sessions/repository-context?workDir=${encodeURIComponent(workDir)}`,
)
expect(res.status).toBe(200)
const body = (await res.json()) as {
state: string
repoName: string
currentBranch: string
branches: Array<{ name: string; current: boolean; local: boolean }>
worktrees: Array<{ path: string; branch: string | null; current: boolean }>
}
expect(body.state).toBe('ok')
expect(body.repoName).toBe(path.basename(workDir))
expect(body.currentBranch).toBe('main')
expect(body.branches.some((branch) => branch.name === 'main' && branch.current)).toBe(true)
expect(body.branches.some((branch) => branch.name === 'feature/rail' && branch.local)).toBe(true)
const realWorkDir = await fs.realpath(workDir)
expect(body.worktrees.some((worktree) => worktree.path === realWorkDir && worktree.current)).toBe(true)
})
it('GET /api/sessions/recent-projects should hide internal desktop worktree branch metadata', async () => {
const workDir = await createCleanGitRepo(tmpDir)
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workDir,
repository: { branch: 'feature/rail', worktree: true },
}),
})
expect(createRes.status).toBe(201)
const created = (await createRes.json()) as { workDir: string }
const recentRes = await fetch(`${baseUrl}/api/sessions/recent-projects?limit=20`)
expect(recentRes.status).toBe(200)
const body = (await recentRes.json()) as {
projects: Array<{ realPath: string; projectName: string; branch: string | null }>
}
const project = body.projects.find((candidate) => candidate.realPath === created.workDir)
expect(project).toBeDefined()
expect(project?.projectName).toBe(path.basename(workDir))
expect(project?.branch).toBeNull()
expect(project?.realPath).toContain('/.claude/worktrees/')
})
it('GET /api/sessions/:id should return session detail', async () => {
// Create a session file
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'

View File

@ -21,6 +21,10 @@ import { getSlashCommands } from '../ws/handler.js'
import { getCommandName } from '../../commands.js'
import { getSkillDirCommands } from '../../skills/loadSkillsDir.js'
import { WorkspaceService } from '../services/workspaceService.js'
import {
getRepositoryContext,
type CreateSessionRepositoryOptions,
} from '../services/repositoryLaunchService.js'
import {
executeSessionRewind,
getSessionTurnCheckpointDiff,
@ -70,6 +74,11 @@ export async function handleSessionsApi(
return await getRecentProjects(url)
}
// Special collection route: /api/sessions/repository-context
if (sessionId === 'repository-context' && req.method === 'GET') {
return await getSessionRepositoryContext(url)
}
// -----------------------------------------------------------------------
// Sub-resource routes: /api/sessions/:id/messages
// -----------------------------------------------------------------------
@ -244,9 +253,9 @@ async function handleSessionWorkspaceRoute(
}
async function createSession(req: Request): Promise<Response> {
let body: { workDir?: string }
let body: { workDir?: string; repository?: CreateSessionRepositoryOptions }
try {
body = (await req.json()) as { workDir?: string }
body = (await req.json()) as { workDir?: string; repository?: CreateSessionRepositoryOptions }
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
@ -255,10 +264,32 @@ async function createSession(req: Request): Promise<Response> {
throw ApiError.badRequest('workDir must be a string')
}
const result = await sessionService.createSession(body.workDir)
if (body.repository !== undefined) {
if (!body.repository || typeof body.repository !== 'object' || Array.isArray(body.repository)) {
throw ApiError.badRequest('repository must be an object')
}
if (body.repository.branch !== undefined && body.repository.branch !== null && typeof body.repository.branch !== 'string') {
throw ApiError.badRequest('repository.branch must be a string')
}
if (body.repository.worktree !== undefined && typeof body.repository.worktree !== 'boolean') {
throw ApiError.badRequest('repository.worktree must be a boolean')
}
}
const result = await sessionService.createSession(body.workDir, body.repository)
recentProjectsCache = null
return Response.json(result, { status: 201 })
}
async function getSessionRepositoryContext(url: URL): Promise<Response> {
const workDir = url.searchParams.get('workDir')
if (!workDir) {
throw ApiError.badRequest('workDir query parameter is required')
}
return Response.json(await getRepositoryContext(workDir))
}
async function requireSessionWorkspace(sessionId: string): Promise<string> {
const workDir =
conversationService.getSessionWorkDir(sessionId) ||
@ -640,6 +671,18 @@ type RecentProjectEntry = {
// In-memory cache for recent projects (TTL: 30s)
let recentProjectsCache: { projects: RecentProjectEntry[]; timestamp: number } | null = null
const RECENT_PROJECTS_CACHE_TTL = 30_000
const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/'
function projectNameForRecentPath(realPath: string, fallback: string): string {
const displayRoot = realPath.includes(DESKTOP_WORKTREE_MARKER)
? realPath.slice(0, realPath.indexOf(DESKTOP_WORKTREE_MARKER))
: realPath
return displayRoot.split('/').filter(Boolean).pop() || fallback
}
function isDesktopWorktreeBranchName(branch: string | null): boolean {
return !!branch && branch.startsWith('worktree-desktop-')
}
async function getRecentProjects(url: URL): Promise<Response> {
const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '10', 10) || 10, 1), 500)
@ -680,7 +723,7 @@ async function getRecentProjects(url: URL): Promise<Response> {
const entries = Array.from(realPathMap.entries())
const projects = await Promise.all(
entries.map(async ([realPath, info]) => {
const projectName = realPath.split('/').filter(Boolean).pop() || info.projectPath
const projectName = projectNameForRecentPath(realPath, info.projectPath)
let isGit = false
let repoName: string | null = null
@ -712,7 +755,7 @@ async function getRecentProjects(url: URL): Promise<Response> {
} catch { return null }
})(),
])
branch = branchResult
branch = isDesktopWorktreeBranchName(branchResult) ? null : branchResult
repoName = remoteResult
}
} catch { /* not a git repo or dir doesn't exist */ }

View File

@ -0,0 +1,531 @@
import { execFile as execFileCallback } from 'node:child_process'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { promisify } from 'node:util'
import { ApiError } from '../middleware/errorHandler.js'
import { findCanonicalGitRoot, findGitRoot } from '../../utils/git.js'
import {
ensureWorktreesDirExcluded,
performPostCreationSetup,
validateWorktreeSlug,
worktreeBranchName,
} from '../../utils/worktree.js'
const execFile = promisify(execFileCallback)
const GIT_TIMEOUT_MS = 10_000
const WORKTREE_TIMEOUT_MS = 60_000
const MAX_GIT_BUFFER_BYTES = 2_000_000
const GIT_NO_PROMPT_ENV = {
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: '',
}
const REPOSITORY_ERROR = {
workdirMissing: 'WORKDIR_MISSING',
workdirNotDirectory: 'WORKDIR_NOT_DIRECTORY',
notGit: 'REPOSITORY_NOT_GIT',
contextFailed: 'REPOSITORY_CONTEXT_ERROR',
branchNotFound: 'REPOSITORY_BRANCH_NOT_FOUND',
dirtyWorktree: 'REPOSITORY_DIRTY_WORKTREE',
branchCheckedOut: 'REPOSITORY_BRANCH_CHECKED_OUT',
worktreeCreateFailed: 'REPOSITORY_WORKTREE_CREATE_FAILED',
switchFailed: 'REPOSITORY_SWITCH_FAILED',
} as const
type RepositoryErrorCode = typeof REPOSITORY_ERROR[keyof typeof REPOSITORY_ERROR]
export type RepositoryBranchInfo = {
name: string
current: boolean
local: boolean
remote: boolean
remoteRef?: string
checkedOut: boolean
worktreePath?: string
}
export type RepositoryWorktreeInfo = {
path: string
branch: string | null
current: boolean
}
export type RepositoryContextResult = {
state: 'ok' | 'not_git_repo' | 'missing_workdir' | 'error'
workDir: string
repoRoot: string | null
repoName: string | null
currentBranch: string | null
defaultBranch: string | null
dirty: boolean
branches: RepositoryBranchInfo[]
worktrees: RepositoryWorktreeInfo[]
error?: string
}
export type CreateSessionRepositoryOptions = {
branch?: string | null
worktree?: boolean
}
export type PreparedSessionWorkspace = {
workDir: string
repository?: {
requestedWorkDir: string
repoRoot: string
branch: string
worktree: boolean
baseRef: string
worktreePath?: string
worktreeBranch?: string
}
}
type GitResult = {
stdout: string
stderr: string
code: number
}
type GitWorktreeRecord = {
path: string
branch: string | null
}
type ResolvedBranch = RepositoryBranchInfo & {
baseRef: string
}
function repositoryBadRequest(code: RepositoryErrorCode, message: string): ApiError {
return new ApiError(400, message, code)
}
async function runGit(
cwd: string,
args: string[],
timeout = GIT_TIMEOUT_MS,
): Promise<GitResult> {
try {
const { stdout, stderr } = await execFile('git', args, {
cwd,
timeout,
maxBuffer: MAX_GIT_BUFFER_BYTES,
env: { ...process.env, ...GIT_NO_PROMPT_ENV },
})
return {
stdout: String(stdout ?? ''),
stderr: String(stderr ?? ''),
code: 0,
}
} catch (error) {
const err = error as {
stdout?: string | Buffer
stderr?: string | Buffer
code?: unknown
message?: string
}
return {
stdout: String(err.stdout ?? ''),
stderr: String(err.stderr ?? err.message ?? ''),
code: typeof err.code === 'number' ? err.code : 1,
}
}
}
async function resolveDirectory(workDir: string): Promise<string> {
const resolved = path.resolve(workDir)
let realPath: string
try {
realPath = await fs.realpath(resolved)
} catch {
throw repositoryBadRequest(
REPOSITORY_ERROR.workdirMissing,
`Working directory does not exist: ${resolved}`,
)
}
const stat = await fs.stat(realPath)
if (!stat.isDirectory()) {
throw repositoryBadRequest(
REPOSITORY_ERROR.workdirNotDirectory,
`Working directory is not a directory: ${realPath}`,
)
}
return realPath
}
function normalizeRemoteBranch(ref: string): { name: string; remoteRef: string } | null {
if (!ref || ref.endsWith('/HEAD')) return null
const slash = ref.indexOf('/')
if (slash < 1) return null
const remote = ref.slice(0, slash)
const name = ref.slice(slash + 1)
if (!name) return null
return {
name: remote === 'origin' ? name : ref,
remoteRef: ref,
}
}
function parseWorktreeList(stdout: string): GitWorktreeRecord[] {
const records: GitWorktreeRecord[] = []
let current: GitWorktreeRecord | null = null
for (const line of stdout.split('\n')) {
if (line.startsWith('worktree ')) {
if (current) records.push(current)
current = { path: line.slice('worktree '.length).normalize('NFC'), branch: null }
continue
}
if (current && line.startsWith('branch ')) {
const ref = line.slice('branch '.length)
current.branch = ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref
}
}
if (current) records.push(current)
return records
}
function branchSort(a: RepositoryBranchInfo, b: RepositoryBranchInfo): number {
if (a.current !== b.current) return a.current ? -1 : 1
if (a.local !== b.local) return a.local ? -1 : 1
return a.name.localeCompare(b.name)
}
function isDesktopWorktreeBranch(name: string): boolean {
return name.startsWith('worktree-desktop-')
}
async function listBranches(repoRoot: string, currentBranch: string | null, worktrees: GitWorktreeRecord[]): Promise<RepositoryBranchInfo[]> {
const branches = new Map<string, RepositoryBranchInfo>()
const checkedOutByBranch = new Map<string, string>()
for (const worktree of worktrees) {
if (worktree.branch) checkedOutByBranch.set(worktree.branch, worktree.path)
}
const localResult = await runGit(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'])
if (localResult.code === 0) {
for (const name of localResult.stdout.split('\n').map((line) => line.trim()).filter(Boolean)) {
const worktreePath = checkedOutByBranch.get(name)
branches.set(name, {
name,
current: name === currentBranch,
local: true,
remote: false,
checkedOut: !!worktreePath,
worktreePath,
})
}
}
const remoteResult = await runGit(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/remotes'])
if (remoteResult.code === 0) {
for (const ref of remoteResult.stdout.split('\n').map((line) => line.trim()).filter(Boolean)) {
const parsed = normalizeRemoteBranch(ref)
if (!parsed) continue
const existing = branches.get(parsed.name)
if (existing) {
branches.set(parsed.name, {
...existing,
remote: true,
remoteRef: parsed.remoteRef,
})
} else {
branches.set(parsed.name, {
name: parsed.name,
current: parsed.name === currentBranch,
local: false,
remote: true,
remoteRef: parsed.remoteRef,
checkedOut: false,
})
}
}
}
if (currentBranch && !branches.has(currentBranch)) {
const worktreePath = checkedOutByBranch.get(currentBranch)
branches.set(currentBranch, {
name: currentBranch,
current: true,
local: true,
remote: false,
checkedOut: !!worktreePath,
worktreePath,
})
}
return [...branches.values()]
.filter((branch) => !isDesktopWorktreeBranch(branch.name))
.sort(branchSort)
}
async function getDefaultBranch(repoRoot: string): Promise<string | null> {
const originHead = await runGit(repoRoot, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
if (originHead.code === 0) {
const value = originHead.stdout.trim()
if (value.startsWith('origin/')) return value.slice('origin/'.length)
if (value) return value
}
const current = await runGit(repoRoot, ['branch', '--show-current'])
const currentBranch = current.stdout.trim()
return currentBranch || null
}
export async function getRepositoryContext(workDir: string): Promise<RepositoryContextResult> {
let absWorkDir: string
try {
absWorkDir = await resolveDirectory(workDir)
} catch (error) {
return {
state: 'missing_workdir',
workDir: path.resolve(workDir),
repoRoot: null,
repoName: null,
currentBranch: null,
defaultBranch: null,
dirty: false,
branches: [],
worktrees: [],
error: error instanceof Error ? error.message : String(error),
}
}
const gitRoot = findGitRoot(absWorkDir)
if (!gitRoot) {
return {
state: 'not_git_repo',
workDir: absWorkDir,
repoRoot: null,
repoName: null,
currentBranch: null,
defaultBranch: null,
dirty: false,
branches: [],
worktrees: [],
}
}
try {
const repoRoot = findCanonicalGitRoot(gitRoot) ?? gitRoot
const [branchResult, defaultBranch, statusResult, worktreeResult] = await Promise.all([
runGit(gitRoot, ['branch', '--show-current']),
getDefaultBranch(gitRoot),
runGit(gitRoot, ['--no-optional-locks', 'status', '--porcelain']),
runGit(repoRoot, ['worktree', 'list', '--porcelain']),
])
const currentBranch = branchResult.stdout.trim() || null
const worktreeRecords = worktreeResult.code === 0 ? parseWorktreeList(worktreeResult.stdout) : []
const worktrees = worktreeRecords.map((worktree) => ({
path: worktree.path,
branch: worktree.branch,
current: absWorkDir === worktree.path || absWorkDir.startsWith(`${worktree.path}${path.sep}`),
}))
return {
state: 'ok',
workDir: absWorkDir,
repoRoot,
repoName: path.basename(repoRoot),
currentBranch,
defaultBranch,
dirty: statusResult.code === 0 && statusResult.stdout.trim().length > 0,
branches: await listBranches(repoRoot, currentBranch, worktreeRecords),
worktrees,
}
} catch (error) {
return {
state: 'error',
workDir: absWorkDir,
repoRoot: gitRoot,
repoName: path.basename(gitRoot),
currentBranch: null,
defaultBranch: null,
dirty: false,
branches: [],
worktrees: [],
error: error instanceof Error ? error.message : String(error),
}
}
}
function resolveBranch(context: RepositoryContextResult, requestedBranch?: string | null): ResolvedBranch | null {
if (context.state !== 'ok') return null
const selectedName = requestedBranch || [
context.currentBranch,
context.defaultBranch,
context.branches[0]?.name,
].find((name) => name && context.branches.some((candidate) => candidate.name === name))
if (!selectedName) return null
const branch = context.branches.find((candidate) => candidate.name === selectedName)
if (!branch) return null
return {
...branch,
baseRef: branch.local ? branch.name : branch.remoteRef ?? branch.name,
}
}
function safeWorktreeSlug(branchName: string, sessionId: string): string {
const safeBranch = branchName
.replace(/[^a-zA-Z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 38) || 'branch'
const slug = `desktop-${safeBranch}-${sessionId.slice(0, 8)}`
validateWorktreeSlug(slug)
return slug
}
async function createDesktopWorktree(
context: RepositoryContextResult,
branch: ResolvedBranch,
sessionId: string,
): Promise<PreparedSessionWorkspace> {
if (context.state !== 'ok' || !context.repoRoot) {
throw repositoryBadRequest(
REPOSITORY_ERROR.notGit,
'Cannot create a worktree outside a Git repository',
)
}
const slug = safeWorktreeSlug(branch.name, sessionId)
const worktreePath = path.join(context.repoRoot, '.claude', 'worktrees', slug)
const branchName = worktreeBranchName(slug)
await ensureWorktreesDirExcluded(context.repoRoot)
await fs.mkdir(path.dirname(worktreePath), { recursive: true })
const result = await runGit(
context.repoRoot,
['worktree', 'add', '-b', branchName, worktreePath, branch.baseRef],
WORKTREE_TIMEOUT_MS,
)
if (result.code !== 0) {
throw repositoryBadRequest(
REPOSITORY_ERROR.worktreeCreateFailed,
`Failed to create worktree: ${result.stderr.trim() || result.stdout.trim() || 'git worktree add failed'}`,
)
}
await performPostCreationSetup(context.repoRoot, worktreePath)
return {
workDir: worktreePath,
repository: {
requestedWorkDir: context.workDir,
repoRoot: context.repoRoot,
branch: branch.name,
worktree: true,
baseRef: branch.baseRef,
worktreePath,
worktreeBranch: branchName,
},
}
}
async function switchExistingCheckout(
context: RepositoryContextResult,
branch: ResolvedBranch,
): Promise<PreparedSessionWorkspace> {
if (context.state !== 'ok' || !context.repoRoot) {
throw repositoryBadRequest(
REPOSITORY_ERROR.notGit,
'Cannot switch branches outside a Git repository',
)
}
if (branch.name === context.currentBranch) {
return {
workDir: context.workDir,
repository: {
requestedWorkDir: context.workDir,
repoRoot: context.repoRoot,
branch: branch.name,
worktree: false,
baseRef: branch.baseRef,
},
}
}
if (context.dirty) {
throw repositoryBadRequest(
REPOSITORY_ERROR.dirtyWorktree,
'Working tree has uncommitted changes. Enable worktree isolation before starting from another branch.',
)
}
if (branch.checkedOut) {
throw repositoryBadRequest(
REPOSITORY_ERROR.branchCheckedOut,
`Branch "${branch.name}" is already checked out in another worktree.`,
)
}
const args = branch.local
? ['switch', branch.name]
: ['switch', '--track', '-c', branch.name, branch.baseRef]
const result = await runGit(context.workDir, args)
if (result.code !== 0) {
throw repositoryBadRequest(
REPOSITORY_ERROR.switchFailed,
`Failed to switch branch: ${result.stderr.trim() || result.stdout.trim() || 'git switch failed'}`,
)
}
return {
workDir: context.workDir,
repository: {
requestedWorkDir: context.workDir,
repoRoot: context.repoRoot,
branch: branch.name,
worktree: false,
baseRef: branch.baseRef,
},
}
}
export async function prepareSessionWorkspace(
workDir: string,
options: CreateSessionRepositoryOptions | undefined,
sessionId: string,
): Promise<PreparedSessionWorkspace> {
const absWorkDir = await resolveDirectory(workDir)
if (!options?.branch && !options?.worktree) {
return { workDir: absWorkDir }
}
const context = await getRepositoryContext(absWorkDir)
if (context.state !== 'ok') {
if (context.state === 'not_git_repo') {
throw repositoryBadRequest(
REPOSITORY_ERROR.notGit,
'Selected directory is not a Git repository',
)
}
if (context.state === 'missing_workdir') {
throw repositoryBadRequest(
REPOSITORY_ERROR.workdirMissing,
context.error || 'Working directory does not exist',
)
}
throw repositoryBadRequest(
REPOSITORY_ERROR.contextFailed,
context.error || 'Failed to inspect Git repository',
)
}
const branch = resolveBranch(context, options.branch)
if (!branch) {
throw repositoryBadRequest(
REPOSITORY_ERROR.branchNotFound,
`Branch not found: ${options.branch || 'default branch'}`,
)
}
return options.worktree
? createDesktopWorktree(context, branch, sessionId)
: switchExistingCheckout(context, branch)
}

View File

@ -19,6 +19,10 @@ import {
getModelMaxOutputTokens,
} from '../../utils/context.js'
import { getCanonicalName } from '../../utils/model/model.js'
import {
prepareSessionWorkspace,
type CreateSessionRepositoryOptions,
} from './repositoryLaunchService.js'
// ============================================================================
// Types
@ -1197,33 +1201,32 @@ export class SessionService {
/**
* Create a new session file for the given working directory.
*/
async createSession(workDir?: string): Promise<{ sessionId: string }> {
async createSession(
workDir?: string,
repositoryOptions?: CreateSessionRepositoryOptions,
): Promise<{ sessionId: string; workDir: string }> {
// Default to user home directory when no workDir specified
const resolvedWorkDir = workDir || os.homedir()
const sessionId = crypto.randomUUID()
// Resolve to absolute path. NOTE: path.resolve() uses process.cwd() to
// expand relative paths — in bundled sidecar mode the server's cwd is
// typically '/'. Callers (IM adapters) already send absolute realPath,
// but we log here so cwd regressions are caught early.
const resolvedPath = path.resolve(resolvedWorkDir)
let absWorkDir: string
try {
absWorkDir = await fs.realpath(resolvedPath)
} catch {
throw ApiError.badRequest(`Working directory does not exist: ${resolvedPath}`)
}
const preparedWorkspace = await prepareSessionWorkspace(
resolvedWorkDir,
repositoryOptions,
sessionId,
)
const absWorkDir = preparedWorkspace.workDir
console.log(
`[SessionService] createSession: requested workDir=${JSON.stringify(
workDir,
)}, resolved=${absWorkDir} (process.cwd()=${process.cwd()})`,
)}, resolved=${absWorkDir}, repository=${JSON.stringify(
preparedWorkspace.repository ?? null,
)} (process.cwd()=${process.cwd()})`,
)
let stat
stat = await fs.stat(absWorkDir)
if (!stat.isDirectory()) {
throw ApiError.badRequest(`Working directory is not a directory: ${absWorkDir}`)
}
const sessionId = crypto.randomUUID()
const sanitized = this.sanitizePath(absWorkDir)
const dirPath = path.join(this.getProjectsDir(), sanitized)
@ -1250,12 +1253,13 @@ export class SessionService {
type: 'session-meta',
isMeta: true,
workDir: absWorkDir,
repository: preparedWorkspace.repository,
timestamp: now,
}
await fs.writeFile(filePath, JSON.stringify(initialEntry) + '\n' + JSON.stringify(metaEntry) + '\n', 'utf-8')
return { sessionId }
return { sessionId, workDir: absWorkDir }
}
/**

View File

@ -9,6 +9,7 @@ import {
stat,
symlink,
utimes,
writeFile,
} from 'fs/promises'
import ignore from 'ignore'
import { basename, dirname, join } from 'path'
@ -205,6 +206,35 @@ function worktreesDir(repoRoot: string): string {
return join(repoRoot, '.claude', 'worktrees')
}
export async function ensureWorktreesDirExcluded(repoRoot: string): Promise<void> {
const gitDir = await resolveGitDir(repoRoot)
const commonDir = gitDir ? ((await getCommonDir(gitDir)) ?? gitDir) : null
if (!commonDir) return
const excludePath = join(commonDir, 'info', 'exclude')
const pattern = '.claude/worktrees/'
let existing = ''
try {
existing = await readFile(excludePath, 'utf-8')
} catch {
// Missing exclude file is normal in freshly initialized repositories.
}
const alreadyExcluded = existing
.split(/\r?\n/)
.map(line => line.trim())
.some(line => line === pattern || line === `/${pattern}`)
if (alreadyExcluded) return
await mkdir(dirname(excludePath), { recursive: true })
const prefix = existing.length === 0 || existing.endsWith('\n') ? existing : `${existing}\n`
await writeFile(
excludePath,
`${prefix}# Claude worktree sessions\n${pattern}\n`,
'utf-8',
)
}
// Flatten nested slugs (`user/feature` → `user+feature`) for both the branch
// name and the directory path. Nesting in either location is unsafe:
// - git refs: `worktree-user` (file) vs `worktree-user/feature` (needs dir)
@ -255,6 +285,7 @@ async function getOrCreateWorktree(
}
// New worktree: fetch base branch then add
await ensureWorktreesDirExcluded(repoRoot)
await mkdir(worktreesDir(repoRoot), { recursive: true })
const fetchEnv = { ...process.env, ...GIT_NO_PROMPT_ENV }
@ -507,7 +538,7 @@ export async function copyWorktreeIncludeFiles(
* Post-creation setup for a newly created worktree.
* Propagates settings.local.json, configures git hooks, and symlinks directories.
*/
async function performPostCreationSetup(
export async function performPostCreationSetup(
repoRoot: string,
worktreePath: string,
): Promise<void> {