mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-20 13:53:32 +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(
|
async function writeFileHistoryBackup(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
backupFileName: string,
|
backupFileName: string,
|
||||||
@ -891,6 +911,27 @@ describe('SessionService', () => {
|
|||||||
expect(workDir).toBe('/tmp/latest-worktree')
|
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 () => {
|
it('should preserve repository metadata when replacing placeholder transcripts', async () => {
|
||||||
const workDir = await createCleanGitRepo(tmpDir)
|
const workDir = await createCleanGitRepo(tmpDir)
|
||||||
const { sessionId, workDir: sessionWorkDir } = await service.createSession(
|
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 () => {
|
it('DELETE /api/sessions/:id should delete the session', async () => {
|
||||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||||
await writeSessionFile('-tmp-api-test', sessionId, [makeSnapshotEntry()])
|
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 launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null)
|
||||||
const repository = launchInfo?.repository
|
const repository = launchInfo?.repository
|
||||||
const sessionBranch = repository?.branch || null
|
const worktreeSession = launchInfo?.worktreeSession
|
||||||
const worktree = repository?.worktree
|
const sessionBranch = repository?.branch || worktreeSession?.originalBranch || null
|
||||||
|
const worktree = repository?.worktree || worktreeSession
|
||||||
? {
|
? {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
path: workDir,
|
path: worktreeSession?.worktreePath || workDir,
|
||||||
plannedPath: repository.worktreePath || null,
|
plannedPath: repository?.worktreePath || worktreeSession?.worktreePath || null,
|
||||||
sourceWorkDir: repository.requestedWorkDir || repository.repoRoot || null,
|
sourceWorkDir: repository?.requestedWorkDir || repository?.repoRoot || worktreeSession?.originalCwd || null,
|
||||||
slug: repository.worktreeSlug || null,
|
slug: repository?.worktreeSlug || worktreeSession?.worktreeName || null,
|
||||||
branch: repository.worktreeBranch || null,
|
branch: repository?.worktreeBranch || worktreeSession?.worktreeBranch || null,
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
|||||||
@ -49,6 +49,7 @@ export type SessionLaunchInfo = {
|
|||||||
projectDir: string
|
projectDir: string
|
||||||
workDir: string
|
workDir: string
|
||||||
repository?: PreparedSessionWorkspace['repository']
|
repository?: PreparedSessionWorkspace['repository']
|
||||||
|
worktreeSession?: PersistedWorktreeSession | null
|
||||||
transcriptMessageCount: number
|
transcriptMessageCount: number
|
||||||
customTitle: string | null
|
customTitle: string | null
|
||||||
}
|
}
|
||||||
@ -178,10 +179,23 @@ type RawEntry = {
|
|||||||
timestamp?: string
|
timestamp?: string
|
||||||
}
|
}
|
||||||
customTitle?: string
|
customTitle?: string
|
||||||
|
worktreeSession?: PersistedWorktreeSession | null
|
||||||
title?: string
|
title?: string
|
||||||
[key: string]: unknown
|
[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>
|
type ContentBlock = Record<string, unknown>
|
||||||
|
|
||||||
const USER_INTERRUPTION_TEXTS = new Set([
|
const USER_INTERRUPTION_TEXTS = new Set([
|
||||||
@ -282,6 +296,25 @@ export class SessionService {
|
|||||||
return undefined
|
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 {
|
private countTranscriptMessages(entries: RawEntry[]): number {
|
||||||
return entries.filter((entry) =>
|
return entries.filter((entry) =>
|
||||||
!entry.isMeta &&
|
!entry.isMeta &&
|
||||||
@ -1386,6 +1419,7 @@ export class SessionService {
|
|||||||
const entries = await this.readJsonlFile(found.filePath)
|
const entries = await this.readJsonlFile(found.filePath)
|
||||||
const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || process.cwd()
|
const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || process.cwd()
|
||||||
const repository = this.resolveRepositoryFromEntries(entries)
|
const repository = this.resolveRepositoryFromEntries(entries)
|
||||||
|
const worktreeSession = this.resolveWorktreeSessionFromEntries(entries)
|
||||||
let customTitle: string | null = null
|
let customTitle: string | null = null
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@ -1400,6 +1434,7 @@ export class SessionService {
|
|||||||
projectDir: found.projectDir,
|
projectDir: found.projectDir,
|
||||||
workDir,
|
workDir,
|
||||||
repository,
|
repository,
|
||||||
|
worktreeSession,
|
||||||
transcriptMessageCount,
|
transcriptMessageCount,
|
||||||
customTitle,
|
customTitle,
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user