diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index cfed5ae9..83ec1bb1 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -269,6 +269,26 @@ function makeSessionMetaEntry(workDir: string): Record { } } +function makeWorktreeStateEntry( + sessionId: string, + worktreePath: string, + overrides: Record = {}, +): Record { + return { + type: 'worktree-state', + sessionId, + worktreeSession: { + originalCwd: '/tmp/source', + worktreePath, + worktreeName: 'desktop-main-12345678', + worktreeBranch: 'worktree-desktop-main-12345678', + originalBranch: 'main', + sessionId, + ...overrides, + }, + } +} + async function writeFileHistoryBackup( sessionId: string, backupFileName: string, @@ -891,6 +911,27 @@ describe('SessionService', () => { expect(workDir).toBe('/tmp/latest-worktree') }) + it('should recover CLI worktree state from transcript metadata', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + await writeSessionFile('-tmp-project--claude-worktrees-desktop-main-12345678', sessionId, [ + makeSnapshotEntry(), + makeSessionMetaEntry('/tmp/project/.claude/worktrees/desktop-main-12345678'), + makeWorktreeStateEntry(sessionId, '/tmp/project/.claude/worktrees/desktop-main-12345678', { + originalCwd: '/tmp/project', + }), + makeUserEntry('Hello from CLI worktree'), + ]) + + const launchInfo = await service.getSessionLaunchInfo(sessionId) + expect(launchInfo?.worktreeSession).toMatchObject({ + originalCwd: '/tmp/project', + worktreePath: '/tmp/project/.claude/worktrees/desktop-main-12345678', + worktreeName: 'desktop-main-12345678', + worktreeBranch: 'worktree-desktop-main-12345678', + originalBranch: 'main', + }) + }) + it('should preserve repository metadata when replacing placeholder transcripts', async () => { const workDir = await createCleanGitRepo(tmpDir) const { sessionId, workDir: sessionWorkDir } = await service.createSession( @@ -1594,6 +1635,48 @@ describe('Sessions API', () => { } }) + it('GET /api/sessions/:id/git-info should use CLI worktree-state after reload', async () => { + const workDir = await createCleanGitRepo(tmpDir) + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const activeWorktree = path.join(workDir, '.claude', 'worktrees', 'desktop-main-12345678') + git(workDir, 'worktree', 'add', '-b', 'worktree-desktop-main-12345678', activeWorktree, 'main') + await writeSessionFile(sanitizePath(activeWorktree), sessionId, [ + makeSnapshotEntry(), + makeSessionMetaEntry(activeWorktree), + makeWorktreeStateEntry(sessionId, activeWorktree, { + originalCwd: await fs.realpath(workDir), + }), + makeUserEntry('Hello from persisted worktree state'), + ]) + + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/git-info`) + expect(res.status).toBe(200) + + const body = (await res.json()) as { + branch: string | null + repoName: string | null + workDir: string + worktree: { + enabled: boolean + path: string | null + plannedPath: string | null + sourceWorkDir: string | null + slug: string | null + branch: string | null + } | null + } + expect(body.branch).toBe('main') + expect(body.workDir).toBe(activeWorktree) + expect(body.worktree).toEqual({ + enabled: true, + path: activeWorktree, + plannedPath: activeWorktree, + sourceWorkDir: await fs.realpath(workDir), + slug: 'desktop-main-12345678', + branch: 'worktree-desktop-main-12345678', + }) + }) + it('DELETE /api/sessions/:id should delete the session', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-api-test', sessionId, [makeSnapshotEntry()]) diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index b0ee0add..8eb82fd7 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -540,15 +540,16 @@ async function getGitInfo(sessionId: string): Promise { } const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null) const repository = launchInfo?.repository - const sessionBranch = repository?.branch || null - const worktree = repository?.worktree + const worktreeSession = launchInfo?.worktreeSession + const sessionBranch = repository?.branch || worktreeSession?.originalBranch || null + const worktree = repository?.worktree || worktreeSession ? { enabled: true, - path: workDir, - plannedPath: repository.worktreePath || null, - sourceWorkDir: repository.requestedWorkDir || repository.repoRoot || null, - slug: repository.worktreeSlug || null, - branch: repository.worktreeBranch || null, + path: worktreeSession?.worktreePath || workDir, + plannedPath: repository?.worktreePath || worktreeSession?.worktreePath || null, + sourceWorkDir: repository?.requestedWorkDir || repository?.repoRoot || worktreeSession?.originalCwd || null, + slug: repository?.worktreeSlug || worktreeSession?.worktreeName || null, + branch: repository?.worktreeBranch || worktreeSession?.worktreeBranch || null, } : null diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 19052a1a..e889958a 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -49,6 +49,7 @@ export type SessionLaunchInfo = { projectDir: string workDir: string repository?: PreparedSessionWorkspace['repository'] + worktreeSession?: PersistedWorktreeSession | null transcriptMessageCount: number customTitle: string | null } @@ -178,10 +179,23 @@ type RawEntry = { timestamp?: string } customTitle?: string + worktreeSession?: PersistedWorktreeSession | null title?: string [key: string]: unknown } +type PersistedWorktreeSession = { + originalCwd: string + worktreePath: string + worktreeName: string + worktreeBranch?: string + originalBranch?: string + originalHeadCommit?: string + sessionId: string + tmuxSessionName?: string + hookBased?: boolean +} + type ContentBlock = Record const USER_INTERRUPTION_TEXTS = new Set([ @@ -282,6 +296,25 @@ export class SessionService { return undefined } + private resolveWorktreeSessionFromEntries(entries: RawEntry[]): PersistedWorktreeSession | null | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i] + if (entry?.type !== 'worktree-state') continue + + const worktreeSession = entry.worktreeSession + if (worktreeSession === null) return null + if ( + worktreeSession && + typeof worktreeSession === 'object' && + typeof worktreeSession.worktreePath === 'string' && + typeof worktreeSession.worktreeName === 'string' + ) { + return worktreeSession + } + } + return undefined + } + private countTranscriptMessages(entries: RawEntry[]): number { return entries.filter((entry) => !entry.isMeta && @@ -1386,6 +1419,7 @@ export class SessionService { const entries = await this.readJsonlFile(found.filePath) const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || process.cwd() const repository = this.resolveRepositoryFromEntries(entries) + const worktreeSession = this.resolveWorktreeSessionFromEntries(entries) let customTitle: string | null = null for (const entry of entries) { @@ -1400,6 +1434,7 @@ export class SessionService { projectDir: found.projectDir, workDir, repository, + worktreeSession, transcriptMessageCount, customTitle, }