Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 60 additions & 42 deletions src/main/services/parsing/GitIdentityResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -49,19 +79,10 @@ class GitIdentityResolver {
*/
async resolveIdentity(projectPath: string): Promise<RepositoryIdentity | null> {
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;

Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -426,15 +444,10 @@ class GitIdentityResolver {
*/
async getBranch(projectPath: string): Promise<string | null> {
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()) {
Expand All @@ -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');
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -666,21 +681,24 @@ class GitIdentityResolver {
*/
private async getGitWorktreeName(projectPath: string): Promise<string | null> {
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];
Expand Down
89 changes: 89 additions & 0 deletions test/main/services/discovery/WorktreeGrouper.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
51 changes: 51 additions & 0 deletions test/main/services/parsing/GitIdentityResolver.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
57 changes: 57 additions & 0 deletions test/main/services/parsing/GitIdentityResolver.upwards.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading