mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
5f0ddaecb2
commit
f46b0c80cd
@ -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()])
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user