mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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:
commit
d1bf65d107
@ -7,7 +7,41 @@ type MessagesResponse = {
|
|||||||
messages: MessageEntry[]
|
messages: MessageEntry[]
|
||||||
taskNotifications?: AgentTaskNotification[]
|
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 = {
|
export type SessionRewindResponse = {
|
||||||
target: {
|
target: {
|
||||||
targetUserMessageId: string
|
targetUserMessageId: string
|
||||||
@ -249,8 +283,11 @@ export const sessionsApi = {
|
|||||||
return api.get<MessagesResponse>(`/api/sessions/${sessionId}/messages`)
|
return api.get<MessagesResponse>(`/api/sessions/${sessionId}/messages`)
|
||||||
},
|
},
|
||||||
|
|
||||||
create(workDir?: string) {
|
create(input?: string | CreateSessionRequest) {
|
||||||
return api.post<CreateSessionResponse>('/api/sessions', workDir ? { workDir } : {})
|
const body = typeof input === 'string'
|
||||||
|
? (input ? { workDir: input } : {})
|
||||||
|
: (input ?? {})
|
||||||
|
return api.post<CreateSessionResponse>('/api/sessions', body)
|
||||||
},
|
},
|
||||||
|
|
||||||
delete(sessionId: string) {
|
delete(sessionId: string) {
|
||||||
@ -266,6 +303,11 @@ export const sessionsApi = {
|
|||||||
return api.get<{ projects: RecentProject[] }>(`/api/sessions/recent-projects${query}`)
|
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) {
|
getGitInfo(sessionId: string) {
|
||||||
return api.get<{ branch: string | null; repoName: string | null; workDir: string; changedFiles: number }>(`/api/sessions/${sessionId}/git-info`)
|
return api.get<{ branch: string | null; repoName: string | null; workDir: string; changedFiles: number }>(`/api/sessions/${sessionId}/git-info`)
|
||||||
},
|
},
|
||||||
|
|||||||
31
desktop/src/components/shared/DirectoryPicker.test.tsx
Normal file
31
desktop/src/components/shared/DirectoryPicker.test.tsx
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -15,11 +15,19 @@ type DirEntry = { name: string; path: string; isDirectory: boolean }
|
|||||||
let cachedProjects: RecentProject[] | null = null
|
let cachedProjects: RecentProject[] | null = null
|
||||||
let cacheTimestamp = 0
|
let cacheTimestamp = 0
|
||||||
const CACHE_TTL = 30_000 // 30s
|
const CACHE_TTL = 30_000 // 30s
|
||||||
|
const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/'
|
||||||
|
|
||||||
function isTauriRuntime() {
|
function isTauriRuntime() {
|
||||||
return typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
|
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) {
|
export function DirectoryPicker({ value, onChange }: Props) {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
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="material-symbols-outlined text-[14px] text-[var(--color-text-secondary)]">folder</span>
|
||||||
)}
|
)}
|
||||||
<span className="font-medium text-[var(--color-text-primary)]">
|
<span className="font-medium text-[var(--color-text-primary)]">
|
||||||
{selectedProject?.repoName || selectedProject?.projectName || value.split('/').pop()}
|
{selectedProject?.repoName || selectedProject?.projectName || projectNameFromPath(value)}
|
||||||
</span>
|
</span>
|
||||||
{selectedProject?.branch && (
|
{selectedProject?.branch && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
402
desktop/src/components/shared/RepositoryLaunchControls.tsx
Normal file
402
desktop/src/components/shared/RepositoryLaunchControls.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -673,6 +673,31 @@ export const en = {
|
|||||||
'empty.addFiles': 'Add files or photos',
|
'empty.addFiles': 'Add files or photos',
|
||||||
'empty.slashCommands': 'Slash commands',
|
'empty.slashCommands': 'Slash commands',
|
||||||
'empty.failedToCreate': 'Failed to create session',
|
'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 Input ──────────────────────────────────────
|
||||||
'chat.placeholder': 'Ask Claude to edit, debug or explain...',
|
'chat.placeholder': 'Ask Claude to edit, debug or explain...',
|
||||||
|
|||||||
@ -675,6 +675,31 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'empty.addFiles': '添加文件或图片',
|
'empty.addFiles': '添加文件或图片',
|
||||||
'empty.slashCommands': '斜杠命令',
|
'empty.slashCommands': '斜杠命令',
|
||||||
'empty.failedToCreate': '创建会话失败',
|
'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 Input ──────────────────────────────────────
|
||||||
'chat.placeholder': '让 Claude 编辑、调试或解释代码...',
|
'chat.placeholder': '让 Claude 编辑、调试或解释代码...',
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import '@testing-library/jest-dom'
|
|||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
createSession: vi.fn(),
|
createSession: vi.fn(),
|
||||||
listSessions: vi.fn(),
|
listSessions: vi.fn(),
|
||||||
|
getRepositoryContext: vi.fn(),
|
||||||
getMessages: vi.fn(),
|
getMessages: vi.fn(),
|
||||||
getSlashCommands: vi.fn(),
|
getSlashCommands: vi.fn(),
|
||||||
listSkills: vi.fn(),
|
listSkills: vi.fn(),
|
||||||
@ -21,6 +22,7 @@ vi.mock('../api/sessions', () => ({
|
|||||||
sessionsApi: {
|
sessionsApi: {
|
||||||
create: mocks.createSession,
|
create: mocks.createSession,
|
||||||
list: mocks.listSessions,
|
list: mocks.listSessions,
|
||||||
|
getRepositoryContext: mocks.getRepositoryContext,
|
||||||
getMessages: mocks.getMessages,
|
getMessages: mocks.getMessages,
|
||||||
getSlashCommands: mocks.getSlashCommands,
|
getSlashCommands: mocks.getSlashCommands,
|
||||||
},
|
},
|
||||||
@ -66,12 +68,40 @@ vi.mock('../components/controls/ModelSelector', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
import { EmptySession } from './EmptySession'
|
import { EmptySession } from './EmptySession'
|
||||||
|
import { ApiError } from '../api/client'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
import { useSessionRuntimeStore } from '../stores/sessionRuntimeStore'
|
import { useSessionRuntimeStore } from '../stores/sessionRuntimeStore'
|
||||||
import { useSessionStore } from '../stores/sessionStore'
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
import { useSettingsStore } from '../stores/settingsStore'
|
import { useSettingsStore } from '../stores/settingsStore'
|
||||||
import { useTabStore } from '../stores/tabStore'
|
import { useTabStore } from '../stores/tabStore'
|
||||||
import { useUIStore } from '../stores/uiStore'
|
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', () => {
|
describe('EmptySession', () => {
|
||||||
const initialSessionState = useSessionStore.getInitialState()
|
const initialSessionState = useSessionStore.getInitialState()
|
||||||
@ -90,6 +120,7 @@ describe('EmptySession', () => {
|
|||||||
useUIStore.setState(initialUiState, true)
|
useUIStore.setState(initialUiState, true)
|
||||||
|
|
||||||
mocks.createSession.mockResolvedValue({ sessionId: 'draft-session' })
|
mocks.createSession.mockResolvedValue({ sessionId: 'draft-session' })
|
||||||
|
mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext())
|
||||||
mocks.listSessions.mockResolvedValue({
|
mocks.listSessions.mockResolvedValue({
|
||||||
sessions: [{
|
sessions: [{
|
||||||
id: 'draft-session',
|
id: 'draft-session',
|
||||||
@ -118,7 +149,7 @@ describe('EmptySession', () => {
|
|||||||
useUIStore.setState(initialUiState, true)
|
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 />)
|
render(<EmptySession />)
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole('textbox'), {
|
fireEvent.change(screen.getByRole('textbox'), {
|
||||||
@ -126,8 +157,19 @@ describe('EmptySession', () => {
|
|||||||
})
|
})
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Pick project' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Pick project' }))
|
||||||
|
|
||||||
|
expect(mocks.createSession).not.toHaveBeenCalled()
|
||||||
|
|
||||||
await waitFor(() => {
|
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')
|
expect(useTabStore.getState().activeTabId).toBe('draft-session')
|
||||||
@ -138,7 +180,121 @@ describe('EmptySession', () => {
|
|||||||
id: 'draft-session',
|
id: 'draft-session',
|
||||||
workDir: '/workspace/project',
|
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')
|
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 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { ApiError } from '../api/client'
|
||||||
import { skillsApi } from '../api/skills'
|
import { skillsApi } from '../api/skills'
|
||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
import { useSessionStore } from '../stores/sessionStore'
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
@ -9,7 +10,7 @@ import { useSettingsStore } from '../stores/settingsStore'
|
|||||||
import { useUIStore } from '../stores/uiStore'
|
import { useUIStore } from '../stores/uiStore'
|
||||||
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
|
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
|
||||||
import { OFFICIAL_DEFAULT_MODEL_ID } from '../constants/modelCatalog'
|
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 { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
|
||||||
import { ModelSelector } from '../components/controls/ModelSelector'
|
import { ModelSelector } from '../components/controls/ModelSelector'
|
||||||
import { AttachmentGallery } from '../components/chat/AttachmentGallery'
|
import { AttachmentGallery } from '../components/chat/AttachmentGallery'
|
||||||
@ -24,7 +25,7 @@ import {
|
|||||||
replaceSlashCommand,
|
replaceSlashCommand,
|
||||||
resolveSlashUiAction,
|
resolveSlashUiAction,
|
||||||
} from '../components/chat/composerUtils'
|
} from '../components/chat/composerUtils'
|
||||||
import type { AttachmentRef, UIAttachment } from '../types/chat'
|
import type { AttachmentRef } from '../types/chat'
|
||||||
import type { SlashCommandOption } from '../components/chat/composerUtils'
|
import type { SlashCommandOption } from '../components/chat/composerUtils'
|
||||||
|
|
||||||
type Attachment = {
|
type Attachment = {
|
||||||
@ -37,12 +38,52 @@ type Attachment = {
|
|||||||
data?: string
|
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() {
|
export function EmptySession() {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [isCreatingSession, setIsCreatingSession] = useState(false)
|
|
||||||
const [workDir, setWorkDir] = useState('')
|
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 [attachments, setAttachments] = useState<Attachment[]>([])
|
||||||
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
|
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
|
||||||
const [slashMenuOpen, setSlashMenuOpen] = 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)
|
setWorkDir(newWorkDir)
|
||||||
if (!newWorkDir || isCreatingSession || isSubmitting) return
|
setSelectedBranch(null)
|
||||||
|
setUseWorktree(false)
|
||||||
setIsCreatingSession(true)
|
setRepositoryLaunchReady(!newWorkDir)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredCommands = useMemo(() => {
|
const filteredCommands = useMemo(() => {
|
||||||
@ -248,6 +254,11 @@ export function EmptySession() {
|
|||||||
if (!normalized) return null
|
if (!normalized) return null
|
||||||
return filteredCommands.find((command) => command.name.toLowerCase() === normalized) ?? null
|
return filteredCommands.find((command) => command.name.toLowerCase() === normalized) ?? null
|
||||||
}, [filteredCommands, slashFilter])
|
}, [filteredCommands, slashFilter])
|
||||||
|
const canSubmit = (
|
||||||
|
input.trim().length > 0 ||
|
||||||
|
attachments.length > 0 ||
|
||||||
|
!!workDir
|
||||||
|
) && !isSubmitting && repositoryLaunchReady
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSlashSelectedIndex(0)
|
setSlashSelectedIndex(0)
|
||||||
@ -262,7 +273,7 @@ export function EmptySession() {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
if ((!text && attachments.length === 0) || isSubmitting) return
|
if (!canSubmit) return
|
||||||
|
|
||||||
const slashUiAction = text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null
|
const slashUiAction = text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null
|
||||||
if (slashUiAction?.type === 'panel') {
|
if (slashUiAction?.type === 'panel') {
|
||||||
@ -287,7 +298,12 @@ export function EmptySession() {
|
|||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const draftSelection = await resolveDraftRuntimeSelection()
|
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().setSelection(sessionId, draftSelection)
|
||||||
useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY)
|
useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY)
|
||||||
setActiveView('code')
|
setActiveView('code')
|
||||||
@ -300,13 +316,15 @@ export function EmptySession() {
|
|||||||
data: attachment.data,
|
data: attachment.data,
|
||||||
mimeType: attachment.mimeType,
|
mimeType: attachment.mimeType,
|
||||||
}))
|
}))
|
||||||
sendMessage(sessionId, text, attachmentPayload)
|
if (text || attachmentPayload.length > 0) {
|
||||||
|
sendMessage(sessionId, text, attachmentPayload)
|
||||||
|
}
|
||||||
setInput('')
|
setInput('')
|
||||||
setAttachments([])
|
setAttachments([])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast({
|
addToast({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: error instanceof Error ? error.message : t('empty.failedToCreate'),
|
message: resolveCreateSessionErrorMessage(error, t),
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
@ -681,10 +699,10 @@ export function EmptySession() {
|
|||||||
fallbackModelLabel={draftModelLabel}
|
fallbackModelLabel={draftModelLabel}
|
||||||
draft
|
draft
|
||||||
/>
|
/>
|
||||||
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting || isCreatingSession} />
|
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting} />
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
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"
|
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')}
|
{t('common.run')}
|
||||||
@ -694,9 +712,16 @@ export function EmptySession() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<RepositoryLaunchControls
|
||||||
<DirectoryPicker value={workDir} onChange={(path) => void openDraftSessionForWorkDir(path)} />
|
workDir={workDir}
|
||||||
</div>
|
onWorkDirChange={handleWorkDirChange}
|
||||||
|
branch={selectedBranch}
|
||||||
|
onBranchChange={setSelectedBranch}
|
||||||
|
useWorktree={useWorktree}
|
||||||
|
onUseWorktreeChange={setUseWorktree}
|
||||||
|
onLaunchReadyChange={setRepositoryLaunchReady}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -58,6 +58,42 @@ describe('sessionStore', () => {
|
|||||||
workDir: 'D:/workspace/code/myself_code/cc-haha',
|
workDir: 'D:/workspace/code/myself_code/cc-haha',
|
||||||
workDirExists: true,
|
workDirExists: true,
|
||||||
})
|
})
|
||||||
|
expect(createMock).toHaveBeenCalledWith({
|
||||||
|
workDir: 'D:/workspace/code/myself_code/cc-haha',
|
||||||
|
})
|
||||||
expect(listMock).toHaveBeenCalledOnce()
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { sessionsApi } from '../api/sessions'
|
import { sessionsApi, type CreateSessionRepositoryOptions } from '../api/sessions'
|
||||||
import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
||||||
import type { SessionListItem } from '../types/session'
|
import type { SessionListItem } from '../types/session'
|
||||||
|
|
||||||
|
type CreateSessionOptions = {
|
||||||
|
repository?: CreateSessionRepositoryOptions
|
||||||
|
}
|
||||||
|
|
||||||
type SessionStore = {
|
type SessionStore = {
|
||||||
sessions: SessionListItem[]
|
sessions: SessionListItem[]
|
||||||
activeSessionId: string | null
|
activeSessionId: string | null
|
||||||
@ -12,7 +16,7 @@ type SessionStore = {
|
|||||||
availableProjects: string[]
|
availableProjects: string[]
|
||||||
|
|
||||||
fetchSessions: (project?: string) => Promise<void>
|
fetchSessions: (project?: string) => Promise<void>
|
||||||
createSession: (workDir?: string) => Promise<string>
|
createSession: (workDir?: string, options?: CreateSessionOptions) => Promise<string>
|
||||||
deleteSession: (id: string) => Promise<void>
|
deleteSession: (id: string) => Promise<void>
|
||||||
renameSession: (id: string, title: string) => Promise<void>
|
renameSession: (id: string, title: string) => Promise<void>
|
||||||
updateSessionTitle: (id: string, title: string) => void
|
updateSessionTitle: (id: string, title: string) => void
|
||||||
@ -48,8 +52,11 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
createSession: async (workDir?: string) => {
|
createSession: async (workDir?: string, options?: CreateSessionOptions) => {
|
||||||
const { sessionId: id } = await sessionsApi.create(workDir || undefined)
|
const { sessionId: id, workDir: resolvedWorkDir } = await sessionsApi.create({
|
||||||
|
...(workDir ? { workDir } : {}),
|
||||||
|
...(options?.repository ? { repository: options.repository } : {}),
|
||||||
|
})
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const optimisticSession: SessionListItem = {
|
const optimisticSession: SessionListItem = {
|
||||||
id,
|
id,
|
||||||
@ -58,7 +65,7 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
|||||||
modifiedAt: now,
|
modifiedAt: now,
|
||||||
messageCount: 0,
|
messageCount: 0,
|
||||||
projectPath: '',
|
projectPath: '',
|
||||||
workDir: workDir ?? null,
|
workDir: resolvedWorkDir ?? workDir ?? null,
|
||||||
workDirExists: true,
|
workDirExists: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import * as path from 'node:path'
|
|||||||
import * as os from 'node:os'
|
import * as os from 'node:os'
|
||||||
import { SessionService } from '../services/sessionService.js'
|
import { SessionService } from '../services/sessionService.js'
|
||||||
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 { conversationService } from '../services/conversationService.js'
|
||||||
import { clearCommandsCache } from '../../commands.js'
|
import { clearCommandsCache } from '../../commands.js'
|
||||||
import { sanitizePath } from '../../utils/sessionStoragePortable.js'
|
import { sanitizePath } from '../../utils/sessionStoragePortable.js'
|
||||||
@ -106,6 +107,29 @@ async function createWorkspaceApiGitRepo(baseDir: string): Promise<string> {
|
|||||||
return workDir
|
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
|
// Sample entries matching real CLI format
|
||||||
function makeSnapshotEntry(): Record<string, unknown> {
|
function makeSnapshotEntry(): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
@ -861,6 +885,132 @@ describe('SessionService', () => {
|
|||||||
expect(entry.type).toBe('file-history-snapshot')
|
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 () => {
|
it('should create a Windows-safe project directory name', async () => {
|
||||||
if (process.platform !== 'win32') return
|
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 () => {
|
it('GET /api/sessions/:id should return session detail', async () => {
|
||||||
// Create a session file
|
// Create a session file
|
||||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||||
|
|||||||
@ -21,6 +21,10 @@ import { getSlashCommands } from '../ws/handler.js'
|
|||||||
import { getCommandName } from '../../commands.js'
|
import { getCommandName } from '../../commands.js'
|
||||||
import { getSkillDirCommands } from '../../skills/loadSkillsDir.js'
|
import { getSkillDirCommands } from '../../skills/loadSkillsDir.js'
|
||||||
import { WorkspaceService } from '../services/workspaceService.js'
|
import { WorkspaceService } from '../services/workspaceService.js'
|
||||||
|
import {
|
||||||
|
getRepositoryContext,
|
||||||
|
type CreateSessionRepositoryOptions,
|
||||||
|
} from '../services/repositoryLaunchService.js'
|
||||||
import {
|
import {
|
||||||
executeSessionRewind,
|
executeSessionRewind,
|
||||||
getSessionTurnCheckpointDiff,
|
getSessionTurnCheckpointDiff,
|
||||||
@ -70,6 +74,11 @@ export async function handleSessionsApi(
|
|||||||
return await getRecentProjects(url)
|
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
|
// Sub-resource routes: /api/sessions/:id/messages
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@ -244,9 +253,9 @@ async function handleSessionWorkspaceRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createSession(req: Request): Promise<Response> {
|
async function createSession(req: Request): Promise<Response> {
|
||||||
let body: { workDir?: string }
|
let body: { workDir?: string; repository?: CreateSessionRepositoryOptions }
|
||||||
try {
|
try {
|
||||||
body = (await req.json()) as { workDir?: string }
|
body = (await req.json()) as { workDir?: string; repository?: CreateSessionRepositoryOptions }
|
||||||
} catch {
|
} catch {
|
||||||
throw ApiError.badRequest('Invalid JSON body')
|
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')
|
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 })
|
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> {
|
async function requireSessionWorkspace(sessionId: string): Promise<string> {
|
||||||
const workDir =
|
const workDir =
|
||||||
conversationService.getSessionWorkDir(sessionId) ||
|
conversationService.getSessionWorkDir(sessionId) ||
|
||||||
@ -640,6 +671,18 @@ type RecentProjectEntry = {
|
|||||||
// In-memory cache for recent projects (TTL: 30s)
|
// In-memory cache for recent projects (TTL: 30s)
|
||||||
let recentProjectsCache: { projects: RecentProjectEntry[]; timestamp: number } | null = null
|
let recentProjectsCache: { projects: RecentProjectEntry[]; timestamp: number } | null = null
|
||||||
const RECENT_PROJECTS_CACHE_TTL = 30_000
|
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> {
|
async function getRecentProjects(url: URL): Promise<Response> {
|
||||||
const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '10', 10) || 10, 1), 500)
|
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 entries = Array.from(realPathMap.entries())
|
||||||
const projects = await Promise.all(
|
const projects = await Promise.all(
|
||||||
entries.map(async ([realPath, info]) => {
|
entries.map(async ([realPath, info]) => {
|
||||||
const projectName = realPath.split('/').filter(Boolean).pop() || info.projectPath
|
const projectName = projectNameForRecentPath(realPath, info.projectPath)
|
||||||
|
|
||||||
let isGit = false
|
let isGit = false
|
||||||
let repoName: string | null = null
|
let repoName: string | null = null
|
||||||
@ -712,7 +755,7 @@ async function getRecentProjects(url: URL): Promise<Response> {
|
|||||||
} catch { return null }
|
} catch { return null }
|
||||||
})(),
|
})(),
|
||||||
])
|
])
|
||||||
branch = branchResult
|
branch = isDesktopWorktreeBranchName(branchResult) ? null : branchResult
|
||||||
repoName = remoteResult
|
repoName = remoteResult
|
||||||
}
|
}
|
||||||
} catch { /* not a git repo or dir doesn't exist */ }
|
} catch { /* not a git repo or dir doesn't exist */ }
|
||||||
|
|||||||
531
src/server/services/repositoryLaunchService.ts
Normal file
531
src/server/services/repositoryLaunchService.ts
Normal 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)
|
||||||
|
}
|
||||||
@ -19,6 +19,10 @@ import {
|
|||||||
getModelMaxOutputTokens,
|
getModelMaxOutputTokens,
|
||||||
} from '../../utils/context.js'
|
} from '../../utils/context.js'
|
||||||
import { getCanonicalName } from '../../utils/model/model.js'
|
import { getCanonicalName } from '../../utils/model/model.js'
|
||||||
|
import {
|
||||||
|
prepareSessionWorkspace,
|
||||||
|
type CreateSessionRepositoryOptions,
|
||||||
|
} from './repositoryLaunchService.js'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
@ -1197,33 +1201,32 @@ export class SessionService {
|
|||||||
/**
|
/**
|
||||||
* Create a new session file for the given working directory.
|
* 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
|
// Default to user home directory when no workDir specified
|
||||||
const resolvedWorkDir = workDir || os.homedir()
|
const resolvedWorkDir = workDir || os.homedir()
|
||||||
|
const sessionId = crypto.randomUUID()
|
||||||
|
|
||||||
// Resolve to absolute path. NOTE: path.resolve() uses process.cwd() to
|
// Resolve to absolute path. NOTE: path.resolve() uses process.cwd() to
|
||||||
// expand relative paths — in bundled sidecar mode the server's cwd is
|
// expand relative paths — in bundled sidecar mode the server's cwd is
|
||||||
// typically '/'. Callers (IM adapters) already send absolute realPath,
|
// typically '/'. Callers (IM adapters) already send absolute realPath,
|
||||||
// but we log here so cwd regressions are caught early.
|
// but we log here so cwd regressions are caught early.
|
||||||
const resolvedPath = path.resolve(resolvedWorkDir)
|
const preparedWorkspace = await prepareSessionWorkspace(
|
||||||
let absWorkDir: string
|
resolvedWorkDir,
|
||||||
try {
|
repositoryOptions,
|
||||||
absWorkDir = await fs.realpath(resolvedPath)
|
sessionId,
|
||||||
} catch {
|
)
|
||||||
throw ApiError.badRequest(`Working directory does not exist: ${resolvedPath}`)
|
const absWorkDir = preparedWorkspace.workDir
|
||||||
}
|
|
||||||
console.log(
|
console.log(
|
||||||
`[SessionService] createSession: requested workDir=${JSON.stringify(
|
`[SessionService] createSession: requested workDir=${JSON.stringify(
|
||||||
workDir,
|
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 sanitized = this.sanitizePath(absWorkDir)
|
||||||
const dirPath = path.join(this.getProjectsDir(), sanitized)
|
const dirPath = path.join(this.getProjectsDir(), sanitized)
|
||||||
|
|
||||||
@ -1250,12 +1253,13 @@ export class SessionService {
|
|||||||
type: 'session-meta',
|
type: 'session-meta',
|
||||||
isMeta: true,
|
isMeta: true,
|
||||||
workDir: absWorkDir,
|
workDir: absWorkDir,
|
||||||
|
repository: preparedWorkspace.repository,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.writeFile(filePath, JSON.stringify(initialEntry) + '\n' + JSON.stringify(metaEntry) + '\n', 'utf-8')
|
await fs.writeFile(filePath, JSON.stringify(initialEntry) + '\n' + JSON.stringify(metaEntry) + '\n', 'utf-8')
|
||||||
|
|
||||||
return { sessionId }
|
return { sessionId, workDir: absWorkDir }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
stat,
|
stat,
|
||||||
symlink,
|
symlink,
|
||||||
utimes,
|
utimes,
|
||||||
|
writeFile,
|
||||||
} from 'fs/promises'
|
} from 'fs/promises'
|
||||||
import ignore from 'ignore'
|
import ignore from 'ignore'
|
||||||
import { basename, dirname, join } from 'path'
|
import { basename, dirname, join } from 'path'
|
||||||
@ -205,6 +206,35 @@ function worktreesDir(repoRoot: string): string {
|
|||||||
return join(repoRoot, '.claude', 'worktrees')
|
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
|
// Flatten nested slugs (`user/feature` → `user+feature`) for both the branch
|
||||||
// name and the directory path. Nesting in either location is unsafe:
|
// name and the directory path. Nesting in either location is unsafe:
|
||||||
// - git refs: `worktree-user` (file) vs `worktree-user/feature` (needs dir)
|
// - 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
|
// New worktree: fetch base branch then add
|
||||||
|
await ensureWorktreesDirExcluded(repoRoot)
|
||||||
await mkdir(worktreesDir(repoRoot), { recursive: true })
|
await mkdir(worktreesDir(repoRoot), { recursive: true })
|
||||||
|
|
||||||
const fetchEnv = { ...process.env, ...GIT_NO_PROMPT_ENV }
|
const fetchEnv = { ...process.env, ...GIT_NO_PROMPT_ENV }
|
||||||
@ -507,7 +538,7 @@ export async function copyWorktreeIncludeFiles(
|
|||||||
* Post-creation setup for a newly created worktree.
|
* Post-creation setup for a newly created worktree.
|
||||||
* Propagates settings.local.json, configures git hooks, and symlinks directories.
|
* Propagates settings.local.json, configures git hooks, and symlinks directories.
|
||||||
*/
|
*/
|
||||||
async function performPostCreationSetup(
|
export async function performPostCreationSetup(
|
||||||
repoRoot: string,
|
repoRoot: string,
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user