diff --git a/src/main/services/parsing/GitIdentityResolver.ts b/src/main/services/parsing/GitIdentityResolver.ts index 19ba482b..8880bfb1 100644 --- a/src/main/services/parsing/GitIdentityResolver.ts +++ b/src/main/services/parsing/GitIdentityResolver.ts @@ -33,6 +33,36 @@ import * as path from 'path'; const logger = createLogger('Service:GitIdentityResolver'); class GitIdentityResolver { + /** + * Helper to find the nearest .git file or directory by searching upwards. + */ + private async findGitPath(projectPath: string): Promise<{ repoRoot: string; gitPath: string; stats: fs.Stats } | null> { + try { + let currentPath = path.resolve(projectPath); + + while (currentPath) { + const gitPath = path.join(currentPath, '.git'); + try { + const stats = await fs.promises.stat(gitPath); + if (stats.isFile() || stats.isDirectory()) { + return { repoRoot: currentPath, gitPath, stats }; + } + } catch { + // Ignore and continue upward + } + + const parentDir = path.dirname(currentPath); + if (parentDir === currentPath) { + break; + } + currentPath = parentDir; + } + } catch { + // Ignore resolving errors + } + return null; + } + /** * Resolve repository identity from a project path. * @@ -49,19 +79,10 @@ class GitIdentityResolver { */ async resolveIdentity(projectPath: string): Promise { try { - const gitPath = path.join(projectPath, '.git'); - - // First, try filesystem-based resolution - let gitPathExists = false; - try { - await fs.promises.access(gitPath); - gitPathExists = true; - } catch { - // Path doesn't exist - } + const gitInfo = await this.findGitPath(projectPath); - if (gitPathExists) { - const stats = await fs.promises.stat(gitPath); + if (gitInfo) { + const { repoRoot: currentPath, gitPath, stats } = gitInfo; let mainGitDir: string; @@ -79,7 +100,7 @@ class GitIdentityResolver { // Handle relative paths in gitdir (resolve relative to the .git file location) if (!path.isAbsolute(worktreeGitDir)) { - worktreeGitDir = path.resolve(projectPath, worktreeGitDir); + worktreeGitDir = path.resolve(currentPath, worktreeGitDir); } mainGitDir = this.extractMainGitDir(worktreeGitDir); @@ -264,12 +285,9 @@ class GitIdentityResolver { // Fallback: check filesystem if available try { - const gitPath = path.join(projectPath, '.git'); - try { - const stats = await fs.promises.stat(gitPath); - return stats.isFile(); - } catch { - // Path doesn't exist + const gitInfo = await this.findGitPath(projectPath); + if (gitInfo) { + return gitInfo.stats.isFile(); } } catch { // Ignore errors - filesystem might not be available @@ -426,15 +444,10 @@ class GitIdentityResolver { */ async getBranch(projectPath: string): Promise { try { - const gitPath = path.join(projectPath, '.git'); + const gitInfo = await this.findGitPath(projectPath); + if (!gitInfo) return null; - try { - await fs.promises.access(gitPath); - } catch { - return null; - } - - const stats = await fs.promises.stat(gitPath); + const { repoRoot, gitPath, stats } = gitInfo; let headPath: string; if (stats.isFile()) { @@ -446,7 +459,12 @@ class GitIdentityResolver { return null; } - headPath = path.join(gitDirMatch[1], 'HEAD'); + let worktreeGitDir = gitDirMatch[1].trim(); + if (!path.isAbsolute(worktreeGitDir)) { + worktreeGitDir = path.resolve(repoRoot, worktreeGitDir); + } + + headPath = path.join(worktreeGitDir, 'HEAD'); } else { // Main repo headPath = path.join(gitPath, 'HEAD'); @@ -535,12 +553,9 @@ class GitIdentityResolver { // Check if it's a standard git repo (only if filesystem exists) // For deleted repos, we'll return 'git' as fallback since we can't verify try { - const gitPath = path.join(projectPath, '.git'); - try { - await fs.promises.access(gitPath); + const gitInfo = await this.findGitPath(projectPath); + if (gitInfo) { return 'git'; - } catch { - // Path doesn't exist } } catch { // Ignore errors - filesystem might not be available @@ -666,21 +681,24 @@ class GitIdentityResolver { */ private async getGitWorktreeName(projectPath: string): Promise { try { - const gitPath = path.join(projectPath, '.git'); - let stats: fs.Stats; - try { - stats = await fs.promises.stat(gitPath); - } catch { - return null; - } - if (!stats.isFile()) return null; + const gitInfo = await this.findGitPath(projectPath); + + if (!gitInfo) return null; + if (!gitInfo.stats.isFile()) return null; + + const { repoRoot, gitPath } = gitInfo; const content = await fs.promises.readFile(gitPath, 'utf-8'); const match = /gitdir:\s*(\S[^\r\n]*)/.exec(content); if (!match) return null; + let worktreeGitDir = match[1].trim(); + if (!path.isAbsolute(worktreeGitDir)) { + worktreeGitDir = path.resolve(repoRoot, worktreeGitDir); + } + // gitdir: /main/.git/worktrees/my-worktree-name - const gitdirParts = match[1].trim().split(path.sep); + const gitdirParts = worktreeGitDir.split(path.sep); const worktreesIdx = gitdirParts.lastIndexOf(WORKTREES_DIR); if (worktreesIdx >= 0 && gitdirParts[worktreesIdx + 1]) { return gitdirParts[worktreesIdx + 1]; diff --git a/test/main/services/discovery/WorktreeGrouper.test.ts b/test/main/services/discovery/WorktreeGrouper.test.ts new file mode 100644 index 00000000..53b5b72c --- /dev/null +++ b/test/main/services/discovery/WorktreeGrouper.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { WorktreeGrouper } from '@main/services/discovery/WorktreeGrouper'; +import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider'; +import { Project } from '@main/types'; +import { gitIdentityResolver } from '@main/services/parsing/GitIdentityResolver'; + +describe('WorktreeGrouper', () => { + let tmpDir: string; + let mainRepoDir: string; + let worktreeDir: string; + let projectsDir: string; + let grouper: WorktreeGrouper; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'worktree-grouper-test-')); + mainRepoDir = path.join(tmpDir, 'main-repo'); + worktreeDir = path.join(tmpDir, 'my-worktree'); + projectsDir = path.join(tmpDir, 'projects'); + + fs.mkdirSync(mainRepoDir); + fs.mkdirSync(path.join(mainRepoDir, '.git')); + fs.mkdirSync(path.join(mainRepoDir, '.git', 'worktrees')); + fs.mkdirSync(path.join(mainRepoDir, '.git', 'worktrees', 'my-worktree')); + + fs.writeFileSync( + path.join(mainRepoDir, '.git', 'config'), + '[remote "origin"]\n\turl = git@github.com:matt1398/claude-devtools.git\n' + ); + + fs.mkdirSync(worktreeDir); + fs.writeFileSync( + path.join(worktreeDir, '.git'), + `gitdir: ${path.join(mainRepoDir, '.git', 'worktrees', 'my-worktree')}\n` + ); + + fs.mkdirSync(projectsDir); + + grouper = new WorktreeGrouper(projectsDir, new LocalFileSystemProvider()); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('groups main repo and worktree together', async () => { + // Mock the SubprojectRegistry so it returns no session filter, which allows all sessions + vi.mock('@main/services/discovery/SubprojectRegistry', () => ({ + subprojectRegistry: { + getSessionFilter: () => null, + }, + })); + + // Mock SessionContentFilter to always return true (not noise) + vi.mock('@main/services/discovery/SessionContentFilter', () => ({ + SessionContentFilter: { + hasNonNoiseMessages: vi.fn().mockResolvedValue(true), + }, + })); + + const projects: Project[] = [ + { + id: 'main-repo-id', + path: mainRepoDir, + name: 'main-repo', + sessions: ['session1'], + createdAt: 1000, + mostRecentSession: 2000, + }, + { + id: 'worktree-id', + path: worktreeDir, + name: 'my-worktree', + sessions: ['session2'], + createdAt: 1500, + mostRecentSession: 2500, + }, + ]; + + const groups = await grouper.groupByRepository(projects); + + expect(groups).toHaveLength(1); + expect(groups[0].worktrees).toHaveLength(2); + expect(groups[0].worktrees.find(w => w.isMainWorktree)?.path).toBe(mainRepoDir); + expect(groups[0].worktrees.find(w => !w.isMainWorktree)?.path).toBe(worktreeDir); + }); +}); diff --git a/test/main/services/parsing/GitIdentityResolver.test.ts b/test/main/services/parsing/GitIdentityResolver.test.ts new file mode 100644 index 00000000..24369b67 --- /dev/null +++ b/test/main/services/parsing/GitIdentityResolver.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { gitIdentityResolver } from '@main/services/parsing/GitIdentityResolver'; + +describe('GitIdentityResolver', () => { + let tmpDir: string; + let mainRepoDir: string; + let worktreeDir: string; + + beforeEach(async () => { + tmpDir = await fs.promises.realpath(fs.mkdtempSync(path.join(os.tmpdir(), 'git-identity-test-'))); + mainRepoDir = path.join(tmpDir, 'main-repo'); + worktreeDir = path.join(tmpDir, 'my-worktree'); + + fs.mkdirSync(mainRepoDir); + fs.mkdirSync(path.join(mainRepoDir, '.git')); + fs.mkdirSync(path.join(mainRepoDir, '.git', 'worktrees')); + fs.mkdirSync(path.join(mainRepoDir, '.git', 'worktrees', 'my-worktree')); + + fs.writeFileSync( + path.join(mainRepoDir, '.git', 'config'), + '[remote "origin"]\n\turl = git@github.com:matt1398/claude-devtools.git\n' + ); + + fs.mkdirSync(worktreeDir); + fs.writeFileSync( + path.join(worktreeDir, '.git'), + `gitdir: ${path.join(mainRepoDir, '.git', 'worktrees', 'my-worktree')}\n` + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves identity for main repo', async () => { + const identity = await gitIdentityResolver.resolveIdentity(mainRepoDir); + expect(identity).toBeDefined(); + expect(identity?.mainGitDir).toBe(await fs.promises.realpath(path.join(mainRepoDir, '.git'))); + expect(identity?.name).toBe('main-repo'); + }); + + it('resolves identity for worktree', async () => { + const identity = await gitIdentityResolver.resolveIdentity(worktreeDir); + expect(identity).toBeDefined(); + expect(identity?.mainGitDir).toBe(await fs.promises.realpath(path.join(mainRepoDir, '.git'))); + expect(identity?.name).toBe('main-repo'); + }); +}); diff --git a/test/main/services/parsing/GitIdentityResolver.upwards.test.ts b/test/main/services/parsing/GitIdentityResolver.upwards.test.ts new file mode 100644 index 00000000..ec289601 --- /dev/null +++ b/test/main/services/parsing/GitIdentityResolver.upwards.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { gitIdentityResolver } from '@main/services/parsing/GitIdentityResolver'; + +describe('GitIdentityResolver - Upwards Search', () => { + let tmpDir: string; + let mainRepoDir: string; + let worktreeDir: string; + + beforeEach(async () => { + tmpDir = await fs.promises.realpath(fs.mkdtempSync(path.join(os.tmpdir(), 'git-upwards-test-'))); + mainRepoDir = path.join(tmpDir, 'main-repo'); + worktreeDir = path.join(tmpDir, 'my-worktree'); + + fs.mkdirSync(mainRepoDir); + fs.mkdirSync(path.join(mainRepoDir, '.git')); + fs.mkdirSync(path.join(mainRepoDir, '.git', 'worktrees')); + fs.mkdirSync(path.join(mainRepoDir, '.git', 'worktrees', 'my-worktree')); + + fs.writeFileSync( + path.join(mainRepoDir, '.git', 'config'), + '[remote "origin"]\n\turl = git@github.com:matt1398/claude-devtools.git\n' + ); + + fs.mkdirSync(worktreeDir); + fs.writeFileSync( + path.join(worktreeDir, '.git'), + `gitdir: ../main-repo/.git/worktrees/my-worktree\n` + ); + + // Create subdirectories + fs.mkdirSync(path.join(mainRepoDir, 'src')); + fs.mkdirSync(path.join(worktreeDir, 'src')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves identity when path is a subdirectory of main repo', async () => { + const identity = await gitIdentityResolver.resolveIdentity(path.join(mainRepoDir, 'src')); + expect(identity).toBeDefined(); + expect(identity?.mainGitDir).toBe(await fs.promises.realpath(path.join(mainRepoDir, '.git'))); + expect(identity?.remoteUrl).toBe('git@github.com:matt1398/claude-devtools.git'); + expect(await gitIdentityResolver.isWorktree(path.join(mainRepoDir, 'src'))).toBe(false); + }); + + it('resolves identity when path is a subdirectory of a worktree (with relative gitdir)', async () => { + const identity = await gitIdentityResolver.resolveIdentity(path.join(worktreeDir, 'src')); + expect(identity).toBeDefined(); + expect(identity?.mainGitDir).toBe(await fs.promises.realpath(path.join(mainRepoDir, '.git'))); + expect(identity?.remoteUrl).toBe('git@github.com:matt1398/claude-devtools.git'); + expect(await gitIdentityResolver.isWorktree(path.join(worktreeDir, 'src'))).toBe(true); + }); +});