mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Desktop repository launches now defer isolated worktree creation until the first user turn so the CLI owns worktree setup, cwd initialization, and session metadata. The chat UI surfaces the pre-startup Git phase so users see when a session is creating a worktree or switching a branch before model output begins. Constraint: Desktop must preserve the selected source checkout until a user actually sends a message Constraint: CLI setup is the canonical owner for worktree creation and cwd initialization Rejected: Create the worktree eagerly in the desktop session picker | it diverges from CLI session startup and creates worktrees before a conversation exists Confidence: high Scope-risk: moderate Directive: Keep repository session startup routed through CLI worktree flags; do not reintroduce eager desktop worktree creation without testing transcript cwd and cleanup behavior Tested: bun test src/server/__tests__/sessions.test.ts src/server/__tests__/conversation-service.test.ts Tested: bun test src/server/__tests__/conversations.test.ts Tested: bun test src/server/__tests__/conversations.test.ts --test-name-pattern "worktree startup status" Tested: cd desktop && bun run test -- src/pages/EmptySession.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: git diff --check Tested: CLI init-only native worktree smoke from feature/rail Tested: agent-browser UI flow with MiniMax-M2.7-highspeed in a /tmp repository
611 lines
17 KiB
TypeScript
611 lines
17 KiB
TypeScript
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
|
|
worktreeSlug?: 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,
|
|
worktreeSlug: slug,
|
|
},
|
|
}
|
|
}
|
|
|
|
function planIsolatedWorktree(
|
|
context: RepositoryContextResult,
|
|
branch: ResolvedBranch,
|
|
sessionId: string,
|
|
): 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)
|
|
|
|
return {
|
|
workDir: context.workDir,
|
|
repository: {
|
|
requestedWorkDir: context.workDir,
|
|
repoRoot: context.repoRoot,
|
|
branch: branch.name,
|
|
worktree: true,
|
|
baseRef: branch.baseRef,
|
|
worktreePath,
|
|
worktreeBranch: branchName,
|
|
worktreeSlug: slug,
|
|
},
|
|
}
|
|
}
|
|
|
|
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 (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)
|
|
}
|
|
|
|
export async function resolveSessionWorkspaceLaunch(
|
|
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
|
|
? planIsolatedWorktree(context, branch, sessionId)
|
|
: {
|
|
workDir: context.workDir,
|
|
repository: {
|
|
requestedWorkDir: context.workDir,
|
|
repoRoot: context.repoRoot,
|
|
branch: branch.name,
|
|
worktree: false,
|
|
baseRef: branch.baseRef,
|
|
},
|
|
}
|
|
}
|