diff --git a/src/main/lib/repo.test.ts b/src/main/lib/repo.test.ts new file mode 100644 index 0000000..b945a8c --- /dev/null +++ b/src/main/lib/repo.test.ts @@ -0,0 +1,301 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { detectRepo } from './repo' + +let base: string +let main: string + +function dir(...parts: string[]): string { + const path = join(base, ...parts) + mkdirSync(path, { recursive: true }) + return path +} + +/** 메인 저장소의 워크트리를 만든다. .git 파일이 메인의 .git/worktrees/<이름> 을 가리킨다 */ +function worktree(name: string, ...parts: string[]): string { + const path = dir(...parts) + mkdirSync(join(main, '.git', 'worktrees', name), { recursive: true }) + writeFileSync(join(path, '.git'), `gitdir: ${join(main, '.git', 'worktrees', name)}\n`) + return path +} + +const savedHome = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE } + +beforeEach(() => { + // macOS 의 tmpdir 은 심볼릭 링크라, 판정 결과(링크를 푼 경로)와 비교하려면 여기서도 풀어 둔다 + base = realpathSync(mkdtempSync(join(tmpdir(), 'wake-repo-'))) + main = dir('main') + mkdirSync(join(main, '.git')) + // 개발 머신의 홈 디렉터리(예: dotfiles 저장소)가 판정에 끼어들지 않게 홈을 임시 폴더로 옮긴다 + process.env.HOME = dir('home') + process.env.USERPROFILE = process.env.HOME +}) + +afterEach(() => { + // undefined 를 대입하면 문자열 'undefined' 가 들어가므로, 없던 키는 지워서 되돌린다 + for (const [key, value] of Object.entries(savedHome)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + rmSync(base, { recursive: true, force: true }) +}) + +describe('detectRepo', () => { + it('저장소 루트는 자기 경로를 rootPath 로 갖고 sub 가 없다', async () => { + expect(await detectRepo('-main', main)).toEqual({ rootPath: main, sub: null }) + }) + + it('.orca 워크트리는 .git 파일이 가리키는 메인 저장소로 묶인다', async () => { + const wt = worktree('sander', 'main', '.orca', 'worktrees', 'main', 'sander') + + expect(await detectRepo('-main--orca-worktrees-main-sander', wt)).toEqual({ + rootPath: main, + rootDirName: '-main', + sub: { kind: 'worktree', name: 'sander' } + }) + }) + + it('저장소 하위 폴더는 subdir 로 묶인다', async () => { + const docs = dir('main', 'docs') + + expect(await detectRepo('-main-docs', docs)).toEqual({ + rootPath: main, + rootDirName: null, + sub: { kind: 'subdir', name: 'docs' } + }) + }) + + it('.claude 워크트리는 이름 한 단계를 그대로 쓴다', async () => { + const wt = worktree('mossy-flask', 'main', '.claude', 'worktrees', 'mossy-flask') + + expect((await detectRepo('-main--claude-worktrees-mossy-flask', wt)).sub).toEqual({ + kind: 'worktree', + name: 'mossy-flask' + }) + }) + + it('저장소 밖에 만든 워크트리는 폴더명을 이름으로 쓴다', async () => { + const wt = worktree('feature-x', 'elsewhere', 'feature-x') + + expect(await detectRepo('-elsewhere-feature-x', wt)).toEqual({ + rootPath: main, + rootDirName: null, + sub: { kind: 'worktree', name: 'feature-x' } + }) + }) + + it('워크트리 안의 하위 폴더는 워크트리명 뒤에 상대 경로를 붙인다', async () => { + worktree('sander', 'main', '.orca', 'worktrees', 'main', 'sander') + const src = dir('main', '.orca', 'worktrees', 'main', 'sander', 'src') + + expect((await detectRepo('-main--orca-worktrees-main-sander-src', src)).sub).toEqual({ + kind: 'worktree', + name: 'sander/src' + }) + }) + + it('.git 파일의 gitdir 가 상대 경로여도 메인 저장소를 찾는다', async () => { + const wt = dir('main', '.claude', 'worktrees', 'rel') + mkdirSync(join(main, '.git', 'worktrees', 'rel'), { recursive: true }) + writeFileSync(join(wt, '.git'), 'gitdir: ../../../.git/worktrees/rel\n') + + expect(await detectRepo('-main--claude-worktrees-rel', wt)).toEqual({ + rootPath: main, + rootDirName: '-main', + sub: { kind: 'worktree', name: 'rel' } + }) + }) + + it('서브모듈처럼 worktrees 를 가리키지 않는 .git 파일은 독립 저장소로 본다', async () => { + const sub = dir('main', 'vendor', 'lib') + writeFileSync(join(sub, '.git'), 'gitdir: ../../.git/modules/vendor/lib\n') + + expect(await detectRepo('-main-vendor-lib', sub)).toEqual({ rootPath: sub, sub: null }) + }) + + it('지워진 워크트리는 남은 상위 저장소와 경로 패턴으로 묶는다', async () => { + const gone = join(main, '.orca', 'worktrees', 'main', 'gone') + + expect(await detectRepo('-main--orca-worktrees-main-gone', gone)).toEqual({ + rootPath: main, + rootDirName: '-main', + sub: { kind: 'worktree', name: 'gone' } + }) + }) + + it('지워진 하위 폴더도 남은 상위 저장소의 subdir 로 묶는다', async () => { + const gone = join(main, 'packages', 'gone') + + expect((await detectRepo('-main-packages-gone', gone)).sub).toEqual({ + kind: 'subdir', + name: 'packages/gone' + }) + }) + + it('지워진 도구 워크트리는 형제 워크트리가 살아 있어도 같은 저장소의 워크트리다', async () => { + worktree('alive', 'main', '.orca', 'worktrees', 'main', 'alive') + const gone = join(main, '.orca', 'worktrees', 'main', 'gone') + + expect((await detectRepo('-main--orca-worktrees-main-gone', gone)).sub).toEqual({ + kind: 'worktree', + name: 'gone' + }) + }) + + it('지워진 경로의 형제가 git 저장소면 워크스페이스로 보고 별개 저장소로 둔다', async () => { + // main 이 저장소들을 모아 둔 워크스페이스: repositories/alive 는 살아 있는 별개 저장소 + mkdirSync(join(dir('main', 'repositories', 'alive'), '.git')) + const gone = join(main, 'repositories', 'gone') + + expect(await detectRepo('-main-repositories-gone', gone)).toEqual({ rootPath: gone, sub: null }) + }) + + it('지워진 형제 저장소의 하위 폴더는 그 저장소의 subdir 로 본다', async () => { + mkdirSync(join(dir('main', 'repositories', 'alive'), '.git')) + const goneSrc = join(main, 'repositories', 'gone', 'src') + + expect(await detectRepo('-main-repositories-gone-src', goneSrc)).toEqual({ + rootPath: join(main, 'repositories', 'gone'), + rootDirName: null, + sub: { kind: 'subdir', name: 'src' } + }) + }) + + it('서브모듈이 있는 저장소의 지워진 하위 폴더는 여전히 subdir 다', async () => { + // 서브모듈의 .git 은 파일이라 워크스페이스의 근거가 되지 않는다 + writeFileSync( + join(dir('main', 'vendor', 'lib'), '.git'), + 'gitdir: ../../.git/modules/vendor/lib\n' + ) + const gone = join(main, 'vendor', 'gone') + + expect((await detectRepo('-main-vendor-gone', gone)).sub).toEqual({ + kind: 'subdir', + name: 'vendor/gone' + }) + }) + + it('홈 디렉터리나 그 위의 .git 은 저장소 경계로 보지 않는다', async () => { + // 홈을 통째로 저장소로 둔(dotfiles) 사용자의 모든 폴더가 한 그룹으로 빨려 들어가면 안 된다 + const home = process.env.HOME as string + mkdirSync(join(home, '.git')) + const notes = dir('home', 'Documents', 'notes') + + expect(await detectRepo('-home-Documents-notes', notes)).toEqual({ rootPath: notes, sub: null }) + }) + + it('홈 아래의 저장소는 그대로 저장소다', async () => { + const home = process.env.HOME as string + mkdirSync(join(home, '.git')) + const repo = dir('home', 'Dev', 'repo') + mkdirSync(join(repo, '.git')) + const src = dir('home', 'Dev', 'repo', 'src') + + expect(await detectRepo('-home-Dev-repo-src', src)).toEqual({ + rootPath: repo, + rootDirName: null, + sub: { kind: 'subdir', name: 'src' } + }) + }) + + it('읽을 수 없는 .git 파일도 저장소 경계라 상위로 올라가지 않는다', async () => { + const broken = dir('main', 'broken') + writeFileSync(join(broken, '.git'), 'garbage\n') + + expect(await detectRepo('-main-broken', broken)).toEqual({ rootPath: broken, sub: null }) + }) + + it('bare 저장소의 워크트리도 메인으로 묶인다', async () => { + const bare = dir('bare.git') + mkdirSync(join(bare, 'worktrees', 'feature'), { recursive: true }) + const wt = dir('checkouts', 'feature') + writeFileSync(join(wt, '.git'), `gitdir: ${join(bare, 'worktrees', 'feature')}\n`) + + expect(await detectRepo('-checkouts-feature', wt)).toEqual({ + rootPath: bare, + rootDirName: null, + sub: { kind: 'worktree', name: 'feature' } + }) + }) + + it('심볼릭 링크로 들어온 경로도 링크를 푼 저장소 경로로 묶인다', async () => { + // 루트 세션은 링크 경로, 워크트리의 .git 파일은 git 이 쓴 실제 경로를 가져 문자열이 갈린다 + symlinkSync(main, join(base, 'linked')) + const viaLink = join(base, 'linked') + const wt = worktree('sander', 'main', '.orca', 'worktrees', 'main', 'sander') + + expect(await detectRepo('-linked', viaLink)).toEqual({ rootPath: main, sub: null }) + expect( + ( + await detectRepo( + '-linked--orca-worktrees-main-sander', + join(viaLink, '.orca', 'worktrees', 'main', 'sander') + ) + ).rootPath + ).toBe(main) + expect((await detectRepo('-main--orca-worktrees-main-sander', wt)).rootPath).toBe(main) + }) + + it('점 디렉터리 밑이 아닌 worktrees 는 워크트리가 아니라 하위 폴더다', async () => { + const plain = dir('main', 'worktrees', 'a') + + expect((await detectRepo('-main-worktrees-a', plain)).sub).toEqual({ + kind: 'subdir', + name: 'worktrees/a' + }) + }) + + it('중간 세그먼트가 저장소명과 다르면 벗기지 않는다', async () => { + const gone = join(main, '.orca', 'worktrees', 'other', 'thing') + + expect((await detectRepo('-main--orca-worktrees-other-thing', gone)).sub).toEqual({ + kind: 'worktree', + name: 'other/thing' + }) + }) + + it('저장소가 통째로 지워졌으면 경로 패턴만으로 워크트리를 가른다', async () => { + const gone = join(base, 'removed', '.orca', 'worktrees', 'removed', 'wt') + + expect(await detectRepo('-removed--orca-worktrees-removed-wt', gone)).toEqual({ + rootPath: join(base, 'removed'), + rootDirName: '-removed', + sub: { kind: 'worktree', name: 'wt' } + }) + }) + + it('git 저장소도 워크트리 패턴도 없으면 독립 프로젝트다', async () => { + const plain = dir('notes') + + expect(await detectRepo('-notes', plain)).toEqual({ rootPath: plain, sub: null }) + }) + + it('realPath 가 없으면 디렉터리명 마커로 워크트리를 가른다', async () => { + expect(await detectRepo('-Users-me-Dev-wake--claude-worktrees-mossy-flask', null)).toEqual({ + rootPath: null, + rootDirName: '-Users-me-Dev-wake', + sub: { kind: 'worktree', name: 'mossy-flask' } + }) + }) + + it('도구명에 하이픈이 있어도 디렉터리명 폴백에서 감지한다', async () => { + // 구분자와 도구명 속 하이픈이 같은 문자라, 마커를 최소 매치로 잡아야 경계가 맞는다 + expect((await detectRepo('-Users-me-Dev-wake--my-tool-worktrees-branch-a', null)).sub).toEqual({ + kind: 'worktree', + name: 'branch-a' + }) + }) + + it('디렉터리명 폴백에서는 repo명을 추측해 벗기지 않는다', async () => { + // 하이픈 인코딩이라 경계를 알 수 없다. 잘못 벗기면 이름이 깨진다. + const found = await detectRepo('-Users-me-Dev-wake--orca-worktrees-wake-sargassum', null) + + expect(found.sub?.name).toBe('wake-sargassum') + }) + + it('realPath 도 마커도 없으면 경로를 모르는 독립 프로젝트다', async () => { + expect(await detectRepo('-Users-me-Dev-wake', null)).toEqual({ rootPath: null, sub: null }) + }) +}) diff --git a/src/main/lib/repo.ts b/src/main/lib/repo.ts new file mode 100644 index 0000000..452b3e2 --- /dev/null +++ b/src/main/lib/repo.ts @@ -0,0 +1,217 @@ +import { readdir, readFile, realpath, stat } from 'fs/promises' +import { homedir } from 'os' +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path' +import type { RepoInfo, RepoSub } from '../../shared/types' + +// 도구가 만든 워크트리는 /.<도구>/worktrees/… 아래에 놓인다. +// 도구명을 하드코딩하지 않고 패턴으로 잡아 새 도구도 그대로 지원한다. +// 구분자로 '\'도 받는 것은 Windows 에서 기록된 cwd 때문이다. +const PATH_MARKER = /[\\/]\.[^\\/]+[\\/]worktrees[\\/]/ +// 프로젝트 디렉터리명은 경로의 영숫자 아닌 글자가 전부 '-'로 바뀐 것이라 '/.orca/worktrees/'가 +// '--orca-worktrees-'로 나타난다. 도구명에도 하이픈이 들어갈 수 있어(.my-tool) 최소 매치로 +// 잡는다 — 구분자와 도구명 속 하이픈이 같은 문자라 경계를 길이로는 가를 수 없다. +const DIR_MARKER = /--.+?-worktrees-/ +// 워크트리의 .git 파일은 메인 저장소의 .git/worktrees/<이름> 을 가리킨다. +// bare 저장소면 <이름>.git/worktrees/<이름> 이라 '.git' 으로 끝나는 세그먼트를 다 받는다. +const WORKTREE_GITDIR = /^(.+\.git)[\\/]worktrees[\\/][^\\/]+[\\/]?$/ + +interface Located { + rootPath: string + sub: RepoSub | null +} + +/** 링크를 푼 경로와, 그중 실제로 남아 있는 가장 깊은 상위 폴더·지워진 나머지 세그먼트 */ +interface Anchored { + path: string + existing: string + missing: string[] +} + +type GitEntry = { kind: 'root' } | { kind: 'link'; gitdir: string } + +function segments(path: string): string[] { + return path.split(sep).filter(Boolean) +} + +function isDotDir(segment: string): boolean { + return segment.length > 1 && segment.startsWith('.') && segment !== '..' +} + +// 지워진 워크트리·폴더는 흔하고 정상이라 ENOENT 는 조용히 넘긴다. 그 밖의 실패(권한 등)는 +// 저장소 경계를 잘못 잡게 하므로 남긴다. +function ignoreMissing(what: string, path: string): (error: NodeJS.ErrnoException) => null { + return (error) => { + if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') { + console.error(`[repo] ${what} 실패`, path, error) + } + return null + } +} + +/** + * 심볼릭 링크를 푼다. 지워진 경로는 남아 있는 상위 폴더까지만 풀고 나머지는 그대로 붙인다. + * 같은 저장소를 링크 경로와 실제 경로로 따로 만나도 한 문자열이 되게 하려는 것이다. + */ +async function anchor(path: string): Promise { + const missing: string[] = [] + let existing = path + for (;;) { + const resolved = await realpath(existing).catch(ignoreMissing('realpath', existing)) + if (resolved) { + return { + path: missing.length ? join(resolved, ...missing) : resolved, + existing: resolved, + missing + } + } + const parent = dirname(existing) + if (parent === existing) return { path, existing: path, missing: [] } + missing.unshift(basename(existing)) + existing = parent + } +} + +/** + * 루트 기준 상대 경로가 도구 워크트리 자리(.<도구>/worktrees/…)면 워크트리 이름을 돌려준다. + * .claude 는 이름 한 단계(<이름>), .orca 는 repo명을 한 번 더 낀 두 단계(/<이름>)를 쓴다. + */ +function toolWorktreeName(rootPath: string, rel: string[]): string | null { + if (rel.length < 3 || !isDotDir(rel[0]) || rel[1] !== 'worktrees') return null + const rest = rel.slice(2) + // 첫 세그먼트가 부모 repo명이면 중복이라 벗긴다 (.orca 형태) + if (rest.length > 1 && rest[0] === basename(rootPath)) rest.shift() + return rest.join('/') +} + +/** 루트 안의 상대 경로를 워크트리·하위 폴더·루트 자신 중 하나로 가른다 */ +function classify(rootPath: string, inside: string[]): RepoSub | null { + const worktree = toolWorktreeName(rootPath, inside) + if (worktree) return { kind: 'worktree', name: worktree } + if (inside.length === 0) return null + return { kind: 'subdir', name: inside.join('/') } +} + +async function hasGitDir(dir: string): Promise { + const path = join(dir, '.git') + const info = await stat(path).catch(ignoreMissing('stat', path)) + return info?.isDirectory() ?? false +} + +async function readGitEntry(dir: string): Promise { + const path = join(dir, '.git') + const info = await stat(path).catch(ignoreMissing('stat', path)) + if (!info) return null + if (info.isDirectory()) return { kind: 'root' } + const text = await readFile(path, 'utf8').catch(ignoreMissing('read', path)) + const match = text === null ? null : /^gitdir:\s*(.+)$/m.exec(text) + if (!match) { + // .git 이 있는데 읽지 못하면 내용은 몰라도 경계는 여기다. 지나쳐 올라가면 엉뚱한 상위 저장소에 붙는다 + if (text !== null) console.error('[repo] .git 파일을 해석할 수 없다', path) + return { kind: 'root' } + } + return { kind: 'link', gitdir: resolve(dir, match[1].trim()) } +} + +/** 홈 디렉터리 자신이나 그 위(/, /Users …)에 있는 .git 은 저장소 경계로 보지 않는다 */ +function isAtOrAboveHome(dir: string): boolean { + const rel = relative(dir, homedir()) + return !rel.startsWith('..') && !isAbsolute(rel) +} + +// 경로에서 위로 올라가며 가장 가까운 .git 을 찾는다. 지워진 경로는 stat 이 실패해 그냥 지나친다. +// 홈을 통째로 저장소로 둔(dotfiles) 사용자의 모든 폴더가 한 그룹으로 빨려 들어가면 안 되므로 +// 홈과 그 위는 보지 않는다. +async function findGitEntry(path: string): Promise<{ dir: string; entry: GitEntry } | null> { + let dir = path + for (;;) { + if (isAtOrAboveHome(dir)) return null + const entry = await readGitEntry(dir) + if (entry) return { dir, entry } + const parent = dirname(dir) + if (parent === dir) return null + dir = parent + } +} + +/** + * 지워진 경로가 저장소 안의 폴더였는지, 저장소들을 모아 둔 워크스페이스 안의 별개 저장소였는지는 + * 파일시스템만으로 가를 수 없다. 남아 있는 상위 폴더 안에서 지워진 항목의 형제 중 하나라도 + * .git 디렉터리를 가진 저장소면 워크스페이스로 보고, 지워진 첫 세그먼트를 별개 저장소의 루트로 + * 삼는다. 서브모듈·워크트리의 .git 은 파일이라 근거로 치지 않는다. 살아 있는 경로는 null. + */ +async function presumedSiblingRepo({ existing, missing }: Anchored): Promise { + if (missing.length === 0) return null + const siblings = + (await readdir(existing, { withFileTypes: true }).catch(ignoreMissing('readdir', existing))) ?? + [] + for (const sibling of siblings) { + if (!sibling.isDirectory() || sibling.name.startsWith('.')) continue + if (await hasGitDir(join(existing, sibling.name))) return join(existing, missing[0]) + } + return null +} + +/** + * 파일시스템의 .git 으로 실제 저장소 경계를 가른다. 워크트리의 .git 파일은 메인 저장소를 + * 가리키므로 도구가 어디에 워크트리를 만들었든 메인으로 묶이고, 저장소 안 하위 폴더에서 + * 시작한 세션도 같은 저장소로 묶인다. 저장소 안에 만든 워크트리는 지워져도 남아 있는 + * 상위 저장소에서 잡힌다. + */ +async function locateByGit(anchored: Anchored): Promise { + const { path } = anchored + const found = await findGitEntry(path) + if (!found) return null + const { dir, entry } = found + const inside = segments(relative(dir, path)) + if (entry.kind === 'link') { + const match = WORKTREE_GITDIR.exec(entry.gitdir) + if (match) { + const rootPath = (await anchor(match[1].replace(/[\\/]\.git$/, ''))).path + const name = toolWorktreeName(rootPath, segments(relative(rootPath, dir))) ?? basename(dir) + return { rootPath, sub: { kind: 'worktree', name: [name, ...inside].join('/') } } + } + // worktrees 를 가리키지 않는 .git 파일(서브모듈 등)은 그 자체가 저장소다 + } + const sub = classify(dir, inside) + // 도구 워크트리 자리는 이름만으로 확실하다. 그 밖의 지워진 경로만 워크스페이스인지 따져 본다 + if (sub?.kind === 'subdir') { + const sibling = await presumedSiblingRepo(anchored) + if (sibling) + return { rootPath: sibling, sub: classify(sibling, segments(relative(sibling, path))) } + } + return { rootPath: dir, sub } +} + +/** 저장소가 통째로 지워져 .git 이 없으면 경로 문자열의 워크트리 패턴만으로 가른다 */ +function locateByPattern(path: string): Located | null { + const match = PATH_MARKER.exec(path) + if (!match) return null + const rootPath = path.slice(0, match.index) + const name = toolWorktreeName(rootPath, segments(path.slice(match.index))) + return name ? { rootPath, sub: { kind: 'worktree', name } } : null +} + +/** + * 프로젝트가 속한 git 저장소를 찾는다. + * + * realPath(cwd)가 있으면 파일시스템을 보고, 없을 때만 디렉터리명으로 폴백한다. 디렉터리명은 + * '/'와 '-'가 뭉개져 있어 repo명 경계를 알 수 없으므로, 폴백에서는 추측해서 벗기지 않고 + * 마커 뒤 전체를 이름으로 쓴다. + */ +export async function detectRepo(dirName: string, realPath: string | null): Promise { + const dirMatch = DIR_MARKER.exec(dirName) + const markedRootDirName = dirMatch ? dirName.slice(0, dirMatch.index) || null : null + + if (realPath) { + const anchored = await anchor(realPath) + const located = (await locateByGit(anchored)) ?? locateByPattern(anchored.path) + if (!located) return { rootPath: anchored.path, sub: null } + if (!located.sub) return { rootPath: located.rootPath, sub: null } + return { rootPath: located.rootPath, rootDirName: markedRootDirName, sub: located.sub } + } + + const name = dirMatch ? dirName.slice(dirMatch.index + dirMatch[0].length) : '' + if (name) + return { rootPath: null, rootDirName: markedRootDirName, sub: { kind: 'worktree', name } } + return { rootPath: null, sub: null } +} diff --git a/src/main/lib/scanner.test.ts b/src/main/lib/scanner.test.ts index 2b90543..254bc1d 100644 --- a/src/main/lib/scanner.test.ts +++ b/src/main/lib/scanner.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -105,3 +105,90 @@ describe('listProjects', () => { expect(found.userSessionCount).toBe(0) }) }) + +/** 임의의 엔트리 목록을 세션 파일로 쓴다. 대화 한 턴은 뒤에 붙여 실제 세션으로 잡히게 한다 */ +function writeEntries(dir: string, id: string, entries: Record[]): void { + const lines = [ + ...entries, + { + type: 'assistant', + timestamp: '2026-08-08T00:00:01.000Z', + message: { id: `msg-${id}`, role: 'assistant', content: [{ type: 'text', text: '네' }] } + } + ] + writeFileSync(join(dir, `${id}.jsonl`), lines.map((line) => JSON.stringify(line)).join('\n')) +} + +describe('listProjects realPath', () => { + const prompt = { role: 'user', content: '안녕' } + // Claude Code 가 cwd 로 프로젝트 디렉터리명을 만드는 규칙. 임시 폴더 안의 경로를 써야 + // 저장소 판정이 개발 머신의 실제 파일시스템에 좌우되지 않는다 + const dirNameOf = (cwd: string): string => cwd.replace(/[^A-Za-z0-9]/g, '-') + + it('디렉터리명과 맞아떨어지는 cwd 를 첫 cwd 보다 우선한다', async () => { + const cwd = join(root, 'main') + const dir = project(dirNameOf(cwd)) + writeEntries(dir, 'aaa', [ + { type: 'user', cwd: join(cwd, '.claude', 'worktrees', 'wt'), message: prompt }, + { type: 'user', cwd, message: prompt } + ]) + + const [found] = await listProjects() + + expect(found.realPath).toBe(cwd) + }) + + it('worktree-state 엔트리의 worktreeSession.originalCwd 도 후보로 본다', async () => { + // EnterWorktree 를 쓴 세션은 첫 cwd 부터 워크트리라 originalCwd 가 유일한 단서다 + const cwd = join(root, 'main') + const worktreePath = join(cwd, '.claude', 'worktrees', 'wt') + const dir = project(dirNameOf(cwd)) + writeEntries(dir, 'aaa', [ + { type: 'worktree-state', worktreeSession: { originalCwd: cwd, worktreePath } }, + { type: 'user', cwd: worktreePath, message: prompt } + ]) + + const [found] = await listProjects() + + expect(found.realPath).toBe(cwd) + }) + + it('같은 엔트리에 cwd 와 originalCwd 가 다 있으면 디렉터리명과 맞는 쪽을 쓴다', async () => { + const cwd = join(root, 'main') + const worktreePath = join(cwd, '.claude', 'worktrees', 'wt') + const dir = project(dirNameOf(cwd)) + writeEntries(dir, 'aaa', [ + { + type: 'worktree-state', + cwd: worktreePath, + worktreeSession: { originalCwd: cwd, worktreePath } + }, + { type: 'user', cwd: worktreePath, message: prompt } + ]) + + const [found] = await listProjects() + + expect(found.realPath).toBe(cwd) + }) + + it('맞아떨어지는 cwd 가 없으면 첫 cwd 를 쓴다', async () => { + const dir = project(dirNameOf(join(root, 'main'))) + const elsewhere = join(root, 'elsewhere') + writeEntries(dir, 'aaa', [{ type: 'user', cwd: elsewhere, message: prompt }]) + + const [found] = await listProjects() + + expect(found.realPath).toBe(elsewhere) + }) + + it('realPath 로 저장소 정보를 채운다', async () => { + const cwd = join(root, 'main') + const dir = project(dirNameOf(cwd)) + writeEntries(dir, 'aaa', [{ type: 'user', cwd, message: prompt }]) + + const [found] = await listProjects() + + // 저장소 경로는 심볼릭 링크를 푼 값이다 + expect(found.repo).toEqual({ rootPath: join(realpathSync(root), 'main'), sub: null }) + }) +}) diff --git a/src/main/lib/scanner.ts b/src/main/lib/scanner.ts index 4ae6b7a..2b0ad70 100644 --- a/src/main/lib/scanner.ts +++ b/src/main/lib/scanner.ts @@ -5,27 +5,54 @@ import type { ProjectInfo, SessionMeta, SessionOrigin } from '../../shared/types import { forEachJsonlLine, readHead } from './jsonl' import { getMessage, isRealUserPrompt, summarize } from './entries' import { originFromEntrypoint } from './sessionOrigin' -import { detectWorktree } from './worktree' +import { detectRepo } from './repo' export function projectsRoot(): string { // CHV_DATA_DIR: 개발·데모용 데이터 디렉토리 오버라이드 return process.env.CHV_DATA_DIR ?? join(homedir(), '.claude', 'projects') } -async function detectRealPath(sessionFiles: string[]): Promise { +/** Claude Code 는 cwd 의 영숫자 아닌 글자를 전부 '-'로 바꿔 프로젝트 디렉터리명을 만든다 */ +function projectDirName(cwd: string): string { + return cwd.replace(/[^A-Za-z0-9]/g, '-') +} + +function stringField(entry: Record, key: string): string | null { + const value = entry[key] + return typeof value === 'string' && value ? value : null +} + +// 프로젝트 디렉터리명은 세션을 시작한 cwd 로 정해지지만, 파일 속 cwd 는 그 뒤에 바뀔 수 있다. +// EnterWorktree 로 워크트리에 들어간 세션은 첫 엔트리부터 워크트리 경로를 싣고, 시작 cwd 는 +// worktreeSession.originalCwd(worktree-state 엔트리)에만 남는다. 그래서 어느 엔트리든 두 필드를 +// 다 후보로 보고 디렉터리명과 맞아떨어지는 쪽을 우선하며, 하나도 없을 때만 처음 만난 후보로 물러선다. +async function detectRealPath(dirName: string, sessionFiles: string[]): Promise { + let first: string | null = null for (const file of sessionFiles) { const head = await readHead(file, 32 * 1024).catch(() => '') for (const line of head.split('\n')) { - if (!line.includes('"cwd"')) continue + if (!line.includes('"cwd"') && !line.includes('"originalCwd"')) continue + let entry: Record try { - const entry = JSON.parse(line) - if (typeof entry.cwd === 'string' && entry.cwd) return entry.cwd + entry = JSON.parse(line) } catch { continue } + const worktreeSession = entry.worktreeSession + const candidates = [ + stringField(entry, 'cwd'), + typeof worktreeSession === 'object' && worktreeSession !== null + ? stringField(worktreeSession as Record, 'originalCwd') + : null + ] + for (const candidate of candidates) { + if (!candidate) continue + if (projectDirName(candidate) === dirName) return candidate + first ??= candidate + } } } - return null + return first } interface SessionHead { @@ -82,7 +109,10 @@ export async function listProjects(): Promise { if (sessionFiles.length === 0) continue sessionFiles.sort((a, b) => b.mtimeMs - a.mtimeMs) - const realPath = await detectRealPath(sessionFiles.slice(0, 3).map((f) => f.path)) + const realPath = await detectRealPath( + dirent.name, + sessionFiles.slice(0, 3).map((f) => f.path) + ) projects.push({ id: dirent.name, dirName: dirent.name, @@ -94,7 +124,7 @@ export async function listProjects(): Promise { sessionCount: sessionFiles.length, userSessionCount: sessionFiles.filter((f) => f.origin === 'user').length, lastActiveAt: sessionFiles[0].mtimeMs, - worktree: detectWorktree(dirent.name, realPath) + repo: await detectRepo(dirent.name, realPath) }) } diff --git a/src/main/lib/worktree.test.ts b/src/main/lib/worktree.test.ts deleted file mode 100644 index 4fdc2f0..0000000 --- a/src/main/lib/worktree.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { detectWorktree } from './worktree' - -describe('detectWorktree', () => { - it('.claude 워크트리는 이름 한 단계를 그대로 쓴다', () => { - expect( - detectWorktree( - '-Users-me-Dev-wake--claude-worktrees-mossy-flask', - '/Users/me/Dev/wake/.claude/worktrees/mossy-flask' - ) - ).toEqual({ - rootPath: '/Users/me/Dev/wake', - rootDirName: '-Users-me-Dev-wake', - name: 'mossy-flask' - }) - }) - - it('.orca 워크트리는 부모 repo명 세그먼트를 벗겨낸다', () => { - expect( - detectWorktree( - '-Users-me-Dev-wake--orca-worktrees-wake-sargassum', - '/Users/me/Dev/wake/.orca/worktrees/wake/sargassum' - ) - ).toEqual({ - rootPath: '/Users/me/Dev/wake', - rootDirName: '-Users-me-Dev-wake', - name: 'sargassum' - }) - }) - - it('하이픈이 든 repo명도 경로에서는 정확히 갈린다', () => { - const found = detectWorktree( - '-Users-me-repos-fond-monorepo--orca-worktrees-fond-monorepo-kingfish', - '/Users/me/repos/fond-monorepo/.orca/worktrees/fond-monorepo/kingfish' - ) - expect(found?.name).toBe('kingfish') - expect(found?.rootPath).toBe('/Users/me/repos/fond-monorepo') - }) - - it('중간 세그먼트가 부모 repo명과 다르면 벗기지 않는다', () => { - const found = detectWorktree( - '-Users-me-Dev-wake--orca-worktrees-other-thing', - '/Users/me/Dev/wake/.orca/worktrees/other/thing' - ) - expect(found?.name).toBe('other/thing') - }) - - it('처음 보는 도구의 worktrees 도 감지한다', () => { - const found = detectWorktree( - '-Users-me-Dev-wake--newtool-worktrees-branch-a', - '/Users/me/Dev/wake/.newtool/worktrees/branch-a' - ) - expect(found?.name).toBe('branch-a') - expect(found?.rootPath).toBe('/Users/me/Dev/wake') - }) - - it('도구명에 하이픈이 있어도 폴백에서 감지한다', () => { - // 구분자와 도구명 속 하이픈이 같은 문자라, 마커를 최소 매치로 잡아야 경계가 맞는다 - expect(detectWorktree('-Users-me-Dev-wake--my-tool-worktrees-branch-a', null)).toEqual({ - rootPath: null, - rootDirName: '-Users-me-Dev-wake', - name: 'branch-a' - }) - }) - - it('realPath 가 없으면 디렉터리명으로 폴백한다', () => { - expect(detectWorktree('-Users-me-Dev-wake--claude-worktrees-mossy-flask', null)).toEqual({ - rootPath: null, - rootDirName: '-Users-me-Dev-wake', - name: 'mossy-flask' - }) - }) - - it('폴백에서는 repo명을 추측해 벗기지 않는다', () => { - // 하이픈 인코딩이라 경계를 알 수 없다. 잘못 벗기면 이름이 깨진다. - expect(detectWorktree('-Users-me-Dev-wake--orca-worktrees-wake-sargassum', null)?.name).toBe( - 'wake-sargassum' - ) - }) - - it('워크트리가 아니면 null 을 준다', () => { - expect(detectWorktree('-Users-me-Dev-wake', '/Users/me/Dev/wake')).toBeNull() - }) - - it('worktrees 가 점 디렉터리 밑이 아니면 워크트리가 아니다', () => { - expect( - detectWorktree('-Users-me-Dev-wake-worktrees-a', '/Users/me/Dev/wake/worktrees/a') - ).toBeNull() - }) -}) diff --git a/src/main/lib/worktree.ts b/src/main/lib/worktree.ts deleted file mode 100644 index 0b1cfbe..0000000 --- a/src/main/lib/worktree.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { basename } from 'path' -import type { WorktreeInfo } from '../../shared/types' - -// 도구가 만든 워크트리는 /.<도구>/worktrees/… 아래에 놓인다. -// .claude 는 이름 한 단계(<이름>), .orca 는 repo명을 한 번 더 낀 두 단계(/<이름>)를 쓴다. -// 도구명을 하드코딩하지 않고 패턴으로 잡아 새 도구도 그대로 지원한다. -const PATH_MARKER = /\/\.[^/]+\/worktrees\// -// 프로젝트 디렉터리명은 경로의 '/'와 '.'을 '-'로 바꾼 것이라 '/.orca/worktrees/'가 -// '--orca-worktrees-'로 나타난다. 도구명에도 하이픈이 들어갈 수 있어(.my-tool) 최소 매치로 -// 잡는다 — 구분자와 도구명 속 하이픈이 같은 문자라 경계를 길이로는 가를 수 없다. -const DIR_MARKER = /--.+?-worktrees-/ - -/** - * 워크트리 세션인지 판별하고 부모 repo를 찾는다. - * - * realPath(cwd)가 있으면 경로를 그대로 가르므로 하이픈이 든 repo명도 정확하다. - * 없을 때만 디렉터리명으로 폴백하는데, 그쪽은 '/'와 '-'가 뭉개져 있어 repo명 경계를 - * 알 수 없다. 그래서 폴백에서는 추측해서 벗기지 않고 마커 뒤 전체를 이름으로 쓴다. - */ -export function detectWorktree(dirName: string, realPath: string | null): WorktreeInfo | null { - let rootPath: string | null = null - let name: string | null = null - - if (realPath) { - const match = PATH_MARKER.exec(realPath) - if (match) { - rootPath = realPath.slice(0, match.index) - const segments = realPath - .slice(match.index + match[0].length) - .split('/') - .filter(Boolean) - // 첫 세그먼트가 부모 repo명이면 중복이라 벗긴다 (.orca 형태) - if (segments.length > 1 && segments[0] === basename(rootPath)) segments.shift() - name = segments.join('/') || null - } - } - - const dirMatch = DIR_MARKER.exec(dirName) - const rootDirName = dirMatch ? dirName.slice(0, dirMatch.index) : '' - if (name === null && dirMatch) { - name = dirName.slice(dirMatch.index + dirMatch[0].length) || null - } - - if (name === null) return null - return { rootPath, rootDirName, name } -} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index dda4827..a1fe68c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -24,7 +24,7 @@ type UpdateBannerState = | { mode: 'ready'; version: string } import { makeTranslate, resolveLanguage } from './i18n' import { DEFAULT_SETTINGS, PrefsContext, type Prefs } from './prefs' -import { buildGroups, SYNTHETIC_PREFIX } from './lib/groups' +import { buildGroups, type SubGroup } from './lib/groups' import { shortcut } from './lib/platform' import { Sidebar } from './components/Sidebar' import { ConversationView } from './components/ConversationView' @@ -105,27 +105,45 @@ export default function App(): ReactElement { window.setTimeout(() => setToast(null), 2600) }, []) - const loadSessions = useCallback(async (projectId: string) => { - if (projectId.startsWith(SYNTHETIC_PREFIX)) return - if (loadedProjects.current.has(projectId)) return - loadedProjects.current.add(projectId) - const metas = await window.api.listSessions(projectId) - setSessions((prev) => ({ ...prev, [projectId]: metas })) - // 시작 시 카운트는 파일 수 휴리스틱이라 실제 목록으로 보정한다. - // 상태에서 지우지는 않는다 — 빈 그룹을 숨기는 일은 buildGroups가 맡고, - // 그래야 표시 토글을 되돌렸을 때 프로젝트가 다시 나타난다. - setProjects((prev) => - prev.map((p) => - p.id === projectId - ? { - ...p, - sessionCount: metas.length, - userSessionCount: metas.filter((m) => m.origin === 'user').length - } - : p + // 언어가 바뀌어도 loadSessions 가 새로 만들어지지 않게 한다. 시작 효과가 이 함수에 묶여 있어 + // 함수가 바뀌면 프로젝트를 처음부터 다시 읽는다 + const translateRef = useRef(t) + useEffect(() => { + translateRef.current = t + }, [t]) + + const loadSessions = useCallback( + async (projectId: string) => { + if (loadedProjects.current.has(projectId)) return + loadedProjects.current.add(projectId) + let metas: SessionMeta[] + try { + metas = await window.api.listSessions(projectId) + } catch (error) { + // 그냥 놓치면 스피너가 영원히 돌고 다시 시도할 길도 없다. 표시를 지워 다음 펼침에서 재시도하게 한다 + loadedProjects.current.delete(projectId) + console.error('[sidebar] 세션 목록 조회 실패', projectId, error) + showToast(translateRef.current('sidebar.loadFailed')) + return + } + setSessions((prev) => ({ ...prev, [projectId]: metas })) + // 시작 시 카운트는 파일 수 휴리스틱이라 실제 목록으로 보정한다. + // 상태에서 지우지는 않는다 — 빈 그룹을 숨기는 일은 buildGroups가 맡고, + // 그래야 표시 토글을 되돌렸을 때 프로젝트가 다시 나타난다. + setProjects((prev) => + prev.map((p) => + p.id === projectId + ? { + ...p, + sessionCount: metas.length, + userSessionCount: metas.filter((m) => m.origin === 'user').length + } + : p + ) ) - ) - }, []) + }, + [showToast] + ) useEffect(() => { // electron-updater 이벤트(다운로드 진행·완료)가 배너 상태를 구동한다 @@ -149,15 +167,19 @@ export default function App(): ReactElement { } }) } - window.api.listProjects().then((list) => { - setProjects(list) - // 최상위는 그룹 단위이므로 첫 그룹의 루트를 펼친다 - const first = buildGroups(list, info.settings.showAgentSessions)[0] - if (first) { - setExpanded(new Set([first.root.id])) - loadSessions(first.root.id) - } - }) + window.api + .listProjects() + .then((list) => { + setProjects(list) + // 최상위는 그룹 단위이므로 첫 그룹을 펼치고 그 루트 프로젝트들의 세션을 읽는다 + const first = buildGroups(list, info.settings.showAgentSessions)[0] + if (first) { + setExpanded(new Set([first.id])) + first.roots.forEach((root) => loadSessions(root.id)) + } + }) + // 조용히 죽으면 "세션 없음" 안내가 떠서 읽기 실패와 구분이 안 된다 + .catch((error) => console.error('[startup] 프로젝트 목록 조회 실패', error)) }) return unsubscribe }, [loadSessions]) @@ -242,19 +264,20 @@ export default function App(): ReactElement { return () => window.removeEventListener('keydown', onKeyDown) }, [openContentSearch]) - const toggleProject = useCallback( - (projectId: string) => { + // key 는 그룹 id 이거나 하위 항목 id 다. 한 항목에 프로젝트가 여럿일 수 있어 읽을 대상을 따로 받는다 + const toggleExpanded = useCallback( + (key: string, projectIds: string[]) => { + const opening = !expanded.has(key) setExpanded((prev) => { const next = new Set(prev) - if (next.has(projectId)) next.delete(projectId) - else { - next.add(projectId) - loadSessions(projectId) - } + if (opening) next.add(key) + else next.delete(key) return next }) + // 상태 갱신 함수 안에서 부르면 React 가 갱신 함수를 두 번 돌릴 때 요청도 두 번 나간다 + if (opening) projectIds.forEach((projectId) => loadSessions(projectId)) }, - [loadSessions] + [expanded, loadSessions] ) const selectSession = useCallback( @@ -308,12 +331,13 @@ export default function App(): ReactElement { (projectId: string) => { const project = projects.find((candidate) => candidate.id === projectId) if (!project) return projectId + const sub = project.repo.sub const group = groups.find((candidate) => - candidate.worktrees.some((worktree) => worktree.id === projectId) + candidate.subs.some((slot) => slot.projects.some((p) => p.id === projectId)) ) - // 워크트리 세션은 어느 저장소의 어느 워크트리인지 같이 보여야 구분이 된다 - if (group) return `${group.root.name} ⎇ ${project.worktree?.name ?? project.name}` - return project.name + // 워크트리·하위 폴더 세션은 어느 저장소의 어디인지 같이 보여야 구분이 된다 + if (!sub || !group) return project.name + return sub.kind === 'worktree' ? `${group.name} ⎇ ${sub.name}` : `${group.name}/${sub.name}` }, [groups, projects] ) @@ -340,22 +364,26 @@ export default function App(): ReactElement { showToast(t('search.missing')) return } - // 워크트리 세션은 그룹 루트까지 펼쳐야 사이드바에서 선택 상태가 보인다 + // 검색으로 연 세션은 그 그룹까지 펼쳐야 사이드바에서 선택 상태가 보인다. 하위 항목이면 그 항목도 펼친다 + const inSlot = (slot: SubGroup): boolean => + slot.projects.some((project) => project.id === hit.projectId) const group = groups.find( (candidate) => - candidate.root.id === hit.projectId || - candidate.worktrees.some((worktree) => worktree.id === hit.projectId) + candidate.roots.some((root) => root.id === hit.projectId) || candidate.subs.some(inSlot) ) + const slot = group?.subs.find(inSlot) setExpanded((previous) => { const next = new Set(previous) - if (group) next.add(group.root.id) - next.add(hit.projectId) + if (group) next.add(group.id) + if (slot) next.add(slot.id) return next }) + // 한 항목에 프로젝트가 여럿이면 나머지 프로젝트의 세션도 읽어야 목록이 온전하다 + for (const project of slot?.projects ?? group?.roots ?? []) loadSessions(project.id) setSearchOpen(false) await selectSession(session, ref || null) }, - [groups, selectSession, sessions, showToast, t] + [groups, loadSessions, selectSession, sessions, showToast, t] ) const runAction = useCallback( @@ -424,7 +452,7 @@ export default function App(): ReactElement { query={query} searchRef={searchRef} onQueryChange={setQuery} - onToggleProject={toggleProject} + onToggle={toggleExpanded} onSelectSession={selectSession} onSessionMenu={openSessionMenu} onResizeStart={startSidebarResize} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 3bbe435..08335ca 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -18,7 +18,8 @@ interface Props { query: string searchRef: RefObject onQueryChange: (query: string) => void - onToggleProject: (projectId: string) => void + /** 펼침 키와, 펼칠 때 세션을 읽어야 하는 프로젝트 id 목록 */ + onToggle: (key: string, projectIds: string[]) => void onSelectSession: (session: SessionMeta) => void onSessionMenu: (session: SessionMeta) => void onResizeStart: (event: ReactMouseEvent) => void @@ -49,17 +50,25 @@ function matches(session: SessionMeta, query: string): boolean { ) } -/** 폴더 이름이 걸리면 그 안의 세션은 제목과 무관하게 전부 보여준다 */ -function folderMatches(project: ProjectInfo, query: string): boolean { - const lowered = query.toLowerCase() - return ( - project.name.toLowerCase().includes(lowered) || - (project.worktree?.name.toLowerCase().includes(lowered) ?? false) - ) +/** 폴더 이름이 걸리면 그 안의 세션은 제목 필터만 건너뛴다. 자동 세션 필터는 그대로 받는다 */ +function folderMatches(name: string, query: string): boolean { + return name.toLowerCase().includes(query.toLowerCase()) +} + +// 한 항목에 프로젝트가 여럿이면 세션 목록을 합친다. 하나면 listSessions 가 이미 최근 순이라 +// 그대로 넘기고, 둘 이상이면 flat 으로 새 배열을 만들어 정렬한다 — 상태에 든 배열을 제자리 +// 정렬하면 안 된다. 하나라도 안 왔으면 로딩 중으로 둔다. 온 것만 보여주면 빠진 프로젝트를 +// 알아챌 길이 없다. +function mergeSessions(lists: (SessionMeta[] | undefined)[]): SessionMeta[] | undefined { + if (lists.some((list) => list === undefined)) return undefined + const loaded = lists as SessionMeta[][] + if (loaded.length === 1) return loaded[0] + return loaded.flat().sort((a, b) => b.updatedAt - a.updatedAt) } // 목록에 실제로 그릴 세션을 고른다. undefined는 "아직 로딩 중"이라 그대로 흘려보내지만, -// 검색 중에는 기존 동작대로 빈 배열로 바꿔 "결과 없음" 판정에 걸리게 한다. +// 검색 중에는 빈 배열로 바꿔 "결과 없음" 판정에 걸리게 한다 — 아직 읽지 않은 프로젝트가 +// 검색 결과에 빈 그룹으로 남지 않게 하려는 것이다. // 자동 생성 세션 필터는 검색 여부와 무관하게 늘 적용하고, 제목 필터만 folderMatched일 때 건너뛴다. function visibleSessions( list: SessionMeta[] | undefined, @@ -120,7 +129,7 @@ export function Sidebar({ query, searchRef, onQueryChange, - onToggleProject, + onToggle, onSelectSession, onSessionMenu, onResizeStart @@ -147,46 +156,49 @@ export function Sidebar({