From 6c20a4d4047433c4be4c17be9033cd59cbb95261 Mon Sep 17 00:00:00 2001 From: Cesar Augusto Fonseca Date: Fri, 20 Feb 2026 11:28:15 -0300 Subject: [PATCH] fix: prevent false cwd split that hides all sessions Sessions without the cwd field (older JSONL format) were creating a separate subproject group, even when all sessions with cwd shared the same value. The orphan subproject got a relative fallback path that failed git identity resolution, causing zero sessions to load on select. Now only counts distinct real cwds when deciding whether to split, treating cwd-less sessions as belonging to the same project. --- src/main/services/discovery/ProjectScanner.ts | 7 +- .../discovery/ProjectScanner.cwdSplit.test.ts | 105 ++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 test/main/services/discovery/ProjectScanner.cwdSplit.test.ts diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 90884236..31e00af9 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -267,8 +267,11 @@ export class ProjectScanner { cwdGroups.set(key, group); } - // If only 1 unique cwd, return single project (current behavior) - if (cwdGroups.size <= 1) { + // If only 1 unique real cwd, return single project (current behavior) + // Sessions without cwd (older format) are implicitly from the same project, + // so we only count distinct real cwds to decide whether to split. + const realCwdKeys = [...cwdGroups.keys()].filter((k) => !k.startsWith('__decoded__')); + if (realCwdKeys.length <= 1) { const allSessionIds = sessionInfos.map((s) => s.sessionId); let mostRecentSession: number | undefined; let createdAt = Date.now(); diff --git a/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts new file mode 100644 index 00000000..5a851846 --- /dev/null +++ b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts @@ -0,0 +1,105 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { ProjectScanner } from '../../../../src/main/services/discovery/ProjectScanner'; +import { subprojectRegistry } from '../../../../src/main/services/discovery/SubprojectRegistry'; + +function createSessionLine(opts: { cwd?: string; type?: string }): string { + return JSON.stringify({ + uuid: 'test-uuid', + type: opts.type ?? 'user', + ...(opts.cwd ? { cwd: opts.cwd } : {}), + message: { role: 'user', content: 'hello' }, + timestamp: new Date().toISOString(), + }); +} + +describe('ProjectScanner cwd split logic', () => { + const tempDirs: string[] = []; + + afterEach(async () => { + subprojectRegistry.clear(); + await new Promise((resolve) => setTimeout(resolve, 50)); + for (const dir of tempDirs) { + try { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } catch { + // Ignore cleanup failures + } + } + tempDirs.length = 0; + }); + + it('does not split when sessions have a single cwd mixed with sessions without cwd', async () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-')); + tempDirs.push(projectsDir); + + // Create a project directory with encoded name + const encodedName = '-Users-test-myproject'; + const projectDir = path.join(projectsDir, encodedName); + fs.mkdirSync(projectDir); + + // Session WITH cwd + fs.writeFileSync( + path.join(projectDir, 'session-with-cwd.jsonl'), + createSessionLine({ cwd: '/Users/test/myproject' }) + '\n' + ); + + // Session WITHOUT cwd (older format) + fs.writeFileSync( + path.join(projectDir, 'session-no-cwd.jsonl'), + createSessionLine({ type: 'system' }) + + '\n' + + createSessionLine({ type: 'user' }) + + '\n' + ); + + const scanner = new ProjectScanner(projectsDir); + const projects = await scanner.scan(); + + // Should produce 1 project, not 2 subprojects + const myProjects = projects.filter((p) => p.id.includes('myproject')); + expect(myProjects).toHaveLength(1); + + // Should use the plain encoded name, not a composite ID + expect(myProjects[0].id).toBe(encodedName); + + // Should include both sessions + expect(myProjects[0].sessions).toHaveLength(2); + }); + + it('splits when sessions have multiple distinct cwds', async () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-')); + tempDirs.push(projectsDir); + + const encodedName = '-Users-test-myproject'; + const projectDir = path.join(projectsDir, encodedName); + fs.mkdirSync(projectDir); + + // Session with cwd A + fs.writeFileSync( + path.join(projectDir, 'session-a.jsonl'), + createSessionLine({ cwd: '/Users/test/myproject' }) + '\n' + ); + + // Session with cwd B (different) + fs.writeFileSync( + path.join(projectDir, 'session-b.jsonl'), + createSessionLine({ cwd: '/Users/test/other-project' }) + '\n' + ); + + const scanner = new ProjectScanner(projectsDir); + const projects = await scanner.scan(); + + // Should produce 2 subprojects with composite IDs + const myProjects = projects.filter((p) => p.id.includes('myproject')); + expect(myProjects).toHaveLength(2); + + // Both should be composite IDs + for (const proj of myProjects) { + expect(proj.id).toContain('::'); + } + }); +});