fix: restore worktree badges from CLI transcript state

Desktop sessions already persist repository launch metadata, but CLI worktree sessions also write native worktree-state entries to the JSONL transcript for resume. Relying only on desktop session-meta leaves reopened or older transcripts vulnerable to losing the footer worktree identity when in-memory state is gone.

This teaches SessionService to recover the latest CLI worktree-state entry and lets git-info use it as a fallback for worktree badge fields. Desktop repository metadata still wins when present because it carries the selected business branch, while the CLI state keeps reopened sessions stable.

Constraint: CLI records worktree state as transcript metadata, not only as cwd.

Rejected: Infer worktree identity only from .claude/worktrees path | path heuristics miss hook-based worktrees and do not carry original cwd or slug.

Confidence: high

Scope-risk: narrow

Directive: Prefer explicit transcript metadata over path guessing for worktree session identity.

Tested: bun test src/server/__tests__/sessions.test.ts --test-name-pattern CLI worktree state

Tested: bun test src/server/__tests__/sessions.test.ts --test-name-pattern worktree-state|worktree identity|git-info

Tested: cd desktop && bun run lint

Tested: git diff --check
This commit is contained in:
程序员阿江(Relakkes) 2026-05-08 18:29:17 +08:00
parent 5f0ddaecb2
commit f46b0c80cd
3 changed files with 126 additions and 7 deletions

View File

@ -269,6 +269,26 @@ function makeSessionMetaEntry(workDir: string): Record<string, unknown> {
}
}
function makeWorktreeStateEntry(
sessionId: string,
worktreePath: string,
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
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()])

View File

@ -540,15 +540,16 @@ async function getGitInfo(sessionId: string): Promise<Response> {
}
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

View File

@ -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<string, unknown>
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,
}