agent-ecosystem/src/main/services/parsing/GitIdentityResolver.ts

774 lines
26 KiB
TypeScript

/**
* GitIdentityResolver service - Resolves git repository identity from project paths.
*
* Responsibilities:
* - Detect if a path is inside a git worktree vs main repository
* - Extract the main repository path from worktree's .git file
* - Get git remote URL for repository identity
* - Build consistent repository identity across all worktrees
*
* Git worktree detection:
* - Main repo: .git is a directory
* - Worktree: .git is a file containing "gitdir: /path/to/main/.git/worktrees/<name>"
*
* All filesystem operations use fs.promises to avoid blocking the main process event loop.
* Results are cached with a short TTL to avoid redundant reads during batch operations.
*/
import {
AUTO_CLAUDE_DIR,
CCSWITCH_DIR,
CLAUDE_CODE_DIR,
CLAUDE_WORKTREES_DIR,
CONDUCTOR_DIR,
CURSOR_DIR,
TASKS_DIR,
TWENTYFIRST_DIR,
VIBE_KANBAN_DIR,
WORKSPACES_DIR,
WORKTREES_DIR,
} from '@main/constants/worktreePatterns';
import { type RepositoryIdentity, type WorktreeSource } from '@main/types';
import { createLogger } from '@shared/utils/logger';
import * as crypto from 'crypto';
import * as fsp from 'fs/promises';
import * as path from 'path';
const logger = createLogger('Service:GitIdentityResolver');
interface CacheEntry<T> {
value: T;
expiry: number;
}
/** Check if a path exists on the filesystem (async). */
async function fileExists(filePath: string): Promise<boolean> {
try {
await fsp.access(filePath);
return true;
} catch {
return false;
}
}
function splitPathSegments(value: string): string[] {
return value.split(/[/\\]+/).filter(Boolean);
}
class GitIdentityResolver {
private identityCache = new Map<string, CacheEntry<RepositoryIdentity | null>>();
private branchCache = new Map<string, CacheEntry<string | null>>();
private static readonly CACHE_TTL_MS = 60_000;
/**
* Resolve repository identity from a project path.
*
* Algorithm:
* 1. Check if path/.git exists on filesystem
* 2. If .git is a file (worktree), read gitdir to find main repo
* 3. If .git is a directory (main repo), use it directly
* 4. Extract remote URL from .git/config
* 5. Build RepositoryIdentity with consistent ID
* 6. FALLBACK: If path doesn't exist, use heuristics based on path patterns
*
* @param projectPath - The filesystem path to check
* @returns RepositoryIdentity or null if not a git repo
*/
async resolveIdentity(projectPath: string): Promise<RepositoryIdentity | null> {
const cached = this.identityCache.get(projectPath);
if (cached && cached.expiry > Date.now()) {
return cached.value;
}
const result = await this.resolveIdentityUncached(projectPath);
this.identityCache.set(projectPath, {
value: result,
expiry: Date.now() + GitIdentityResolver.CACHE_TTL_MS,
});
return result;
}
private async resolveIdentityUncached(projectPath: string): Promise<RepositoryIdentity | null> {
try {
const gitPath = path.join(projectPath, '.git');
let stats: Awaited<ReturnType<typeof fsp.stat>>;
try {
stats = await fsp.stat(gitPath);
} catch {
// .git doesn't exist — fallback to path heuristics
return this.resolveIdentityFromPath(projectPath);
}
let mainGitDir: string;
if (stats.isFile()) {
// This is a worktree - parse the .git file to find main repo
const gitFileContent = (await fsp.readFile(gitPath, 'utf-8')).trim();
const gitDirMatch = /^gitdir:\s*(\S[^\r\n]*)$/m.exec(gitFileContent);
if (!gitDirMatch) {
logger.warn(`Invalid .git file format at ${gitPath}`);
return this.resolveIdentityFromPath(projectPath);
}
let worktreeGitDir = gitDirMatch[1].trim();
// Handle relative paths in gitdir (resolve relative to the .git file location)
if (!path.isAbsolute(worktreeGitDir)) {
worktreeGitDir = path.resolve(projectPath, worktreeGitDir);
}
mainGitDir = this.extractMainGitDir(worktreeGitDir);
} else if (stats.isDirectory()) {
mainGitDir = gitPath;
} else {
return this.resolveIdentityFromPath(projectPath);
}
// Normalize the path to handle symlinks (e.g., /tmp -> /private/var/folders)
// This ensures all worktrees of the same repo get the same ID
try {
mainGitDir = await fsp.realpath(mainGitDir);
} catch {
// If realpath fails (e.g., path doesn't exist), use as-is
}
// Extract remote URL from config
const remoteUrl = await this.getRemoteUrl(mainGitDir);
// Generate consistent repository ID based on the CANONICAL main git directory
const repoId = this.generateRepoId(remoteUrl, mainGitDir);
// Extract repository name from path or remote URL
const repoName = this.extractRepoName(remoteUrl, mainGitDir);
return {
id: repoId,
remoteUrl: remoteUrl ?? undefined,
mainGitDir,
name: repoName,
};
} catch (error) {
logger.error(`Error resolving git identity for ${projectPath}:`, error);
// Try fallback even on error
return this.resolveIdentityFromPath(projectPath);
}
}
/**
* Fallback: Resolve repository identity from path patterns when filesystem is unavailable.
* Uses heuristics to detect common worktree path patterns.
*
* Patterns supported:
* - /.cursor/worktrees/{repo}/{worktree-name}
* - /vibe-kanban/worktrees/{issue-branch}/{repo}
* - /T/vibe-kanban/worktrees/{issue-branch}/{repo}
* - Regular paths: use last component as repo name
*/
private resolveIdentityFromPath(projectPath: string): RepositoryIdentity | null {
const repoName = this.extractRepoNameFromPath(projectPath);
if (!repoName) {
return null;
}
// Generate ID from full path (since no remote URL, avoids colliding same-named repos)
const repoId = this.generateRepoId(null, projectPath);
return {
id: repoId,
remoteUrl: undefined,
mainGitDir: repoName, // Use repo name as placeholder
name: repoName,
};
}
/**
* Extract repository name from path using heuristics.
* Works for both existing and deleted worktrees based on path patterns.
*
* Patterns:
* - /.cursor/worktrees/{repo}/{worktree} → repo
* - /vibe-kanban/worktrees/{issue-branch}/{repo} → repo (last component)
* - /conductor/workspaces/{repo}/{subpath} → repo
* - /.auto-claude/worktrees/tasks/{task-id} → parent repo (2 levels up from .auto-claude)
* - /.21st/worktrees/{id}/{name} → parent repo
* - /.claude-worktrees/{repo}/{name} → repo
* - /.ccswitch/worktrees/{repo}/{name} → repo
* - Default: last path component
*/
private extractRepoNameFromPath(projectPath: string): string | null {
const parts = splitPathSegments(projectPath);
if (parts.length === 0) {
return null;
}
// Pattern 1: /.cursor/worktrees/{repo}/{worktree-name}
const cursorWorktreeIdx = parts.indexOf(CURSOR_DIR);
if (cursorWorktreeIdx >= 0 && parts[cursorWorktreeIdx + 1] === WORKTREES_DIR) {
if (parts[cursorWorktreeIdx + 2]) {
return parts[cursorWorktreeIdx + 2];
}
}
// Pattern 2: /vibe-kanban/worktrees/{issue-branch}/{repo}
const vibeKanbanIdx = parts.indexOf(VIBE_KANBAN_DIR);
if (vibeKanbanIdx >= 0 && parts[vibeKanbanIdx + 1] === WORKTREES_DIR) {
// The repo name is the LAST component (after issue-branch)
return parts[parts.length - 1];
}
// Pattern 3: /conductor/workspaces/{repo}/{subpath}
const conductorIdx = parts.indexOf(CONDUCTOR_DIR);
if (conductorIdx >= 0 && parts[conductorIdx + 1] === WORKSPACES_DIR) {
if (parts[conductorIdx + 2]) {
return parts[conductorIdx + 2];
}
}
// Pattern 4: /.auto-claude/worktrees/tasks/{task-id}
// Repo is typically the directory containing .auto-claude
const autoClaudeIdx = parts.indexOf(AUTO_CLAUDE_DIR);
if (autoClaudeIdx > 0 && parts[autoClaudeIdx + 1] === WORKTREES_DIR) {
return parts[autoClaudeIdx - 1]; // Parent directory is the repo
}
// Pattern 5: /.21st/worktrees/{id}/{name}
const twentyFirstIdx = parts.indexOf(TWENTYFIRST_DIR);
if (twentyFirstIdx > 0 && parts[twentyFirstIdx + 1] === WORKTREES_DIR) {
return parts[twentyFirstIdx - 1]; // Parent directory is the repo
}
// Pattern 6: /.claude-worktrees/{repo}/{name}
const claudeWorktreesIdx = parts.indexOf(CLAUDE_WORKTREES_DIR);
if (claudeWorktreesIdx >= 0 && parts[claudeWorktreesIdx + 1]) {
return parts[claudeWorktreesIdx + 1];
}
// Pattern 6b: /.claude/worktrees/{name} (Claude Code CLI)
const claudeCodeDirIdx = parts.indexOf(CLAUDE_CODE_DIR);
if (claudeCodeDirIdx >= 0 && parts[claudeCodeDirIdx + 1] === WORKTREES_DIR) {
// Repo is the directory containing .claude
if (claudeCodeDirIdx > 0) {
return parts[claudeCodeDirIdx - 1];
}
}
// Pattern 7: /.ccswitch/worktrees/{repo}/{name}
const ccswitchIdx = parts.indexOf(CCSWITCH_DIR);
if (ccswitchIdx >= 0 && parts[ccswitchIdx + 1] === WORKTREES_DIR) {
if (parts[ccswitchIdx + 2]) {
return parts[ccswitchIdx + 2];
}
}
// Default: use the last component
return parts[parts.length - 1];
}
/**
* Determine if a path is a worktree (vs main repo).
* Worktrees have a .git file, main repos have a .git directory.
* Uses path heuristics if filesystem is not available (for deleted worktrees).
*/
async isWorktree(projectPath: string): Promise<boolean> {
// First, try path-based heuristics (works for deleted worktrees)
const parts = splitPathSegments(projectPath);
// Check for known worktree patterns - these are ALWAYS worktrees
if (parts.includes(CURSOR_DIR) && parts.includes(WORKTREES_DIR)) {
return true;
}
if (parts.includes(VIBE_KANBAN_DIR) && parts.includes(WORKTREES_DIR)) {
return true;
}
if (parts.includes(AUTO_CLAUDE_DIR) && parts.includes(WORKTREES_DIR)) {
return true;
}
if (parts.includes(TWENTYFIRST_DIR) && parts.includes(WORKTREES_DIR)) {
return true;
}
if (parts.includes(CLAUDE_WORKTREES_DIR)) {
return true;
}
if (parts.includes(CCSWITCH_DIR) && parts.includes(WORKTREES_DIR)) {
return true;
}
// Pattern: .claude/worktrees/{name} (Claude Code CLI worktrees)
const claudeCodeIdx = parts.indexOf(CLAUDE_CODE_DIR);
if (claudeCodeIdx >= 0 && parts[claudeCodeIdx + 1] === WORKTREES_DIR) {
return true;
}
if (parts.includes(CONDUCTOR_DIR) && parts.includes(WORKSPACES_DIR)) {
// Subpaths in conductor/workspaces are worktrees
const conductorIdx = parts.indexOf(CONDUCTOR_DIR);
if (conductorIdx >= 0 && parts.length > conductorIdx + 3) {
return true; // Has subpath after workspaces/{repo}
}
}
// Fallback: check filesystem if available
try {
const gitPath = path.join(projectPath, '.git');
const stats = await fsp.stat(gitPath);
return stats.isFile();
} catch {
// Ignore errors - filesystem might not be available
}
return false;
}
/**
* Extract the main .git directory path from a worktree's gitdir.
*
* @param worktreeGitDir - Path like "/path/to/main/.git/worktrees/<name>"
* @returns Path to main .git directory like "/path/to/main/.git"
*/
private extractMainGitDir(worktreeGitDir: string): string {
// worktreeGitDir is typically: /path/to/main/.git/worktrees/<worktree-name>
// We need to go up two levels to get to .git
const parts = splitPathSegments(worktreeGitDir);
const worktreesIndex = parts.lastIndexOf(WORKTREES_DIR);
if (worktreesIndex > 0) {
// Return everything up to and including .git
return parts.slice(0, worktreesIndex).join(path.sep);
}
// Fallback: try to find .git in the path
const gitIndex = worktreeGitDir.lastIndexOf('.git');
if (gitIndex > 0) {
return worktreeGitDir.substring(0, gitIndex + 4); // +4 for ".git"
}
// Last resort: return as-is
return worktreeGitDir;
}
/**
* Get git remote URL from a repository's config file.
*
* @param gitDir - Path to the .git directory
* @returns Remote URL or null if not found
*/
private async getRemoteUrl(gitDir: string): Promise<string | null> {
try {
const configPath = path.join(gitDir, 'config');
let configContent: string;
try {
configContent = await fsp.readFile(configPath, 'utf-8');
} catch {
return null;
}
// Parse git config to find [remote "origin"] section
const lines = configContent.split(/\r?\n/);
let inOriginRemote = false;
for (const line of lines) {
const trimmed = line.trim();
// Check for remote "origin" section
if (/^\[remote\s+"origin"\]$/.exec(trimmed)) {
inOriginRemote = true;
continue;
}
// Check for new section (exit origin remote)
if (trimmed.startsWith('[') && inOriginRemote) {
break;
}
// Look for url = ... in origin remote section
if (inOriginRemote && trimmed.startsWith('url')) {
const urlMatch = /^url\s*=\s*(\S[^\r\n]*)$/.exec(trimmed);
if (urlMatch) {
return urlMatch[1].trim();
}
}
}
return null;
} catch (error) {
logger.error(`Error reading git config at ${gitDir}:`, error);
return null;
}
}
/**
* Generate consistent repository ID.
* Uses the LOCAL DIRECTORY NAME as the primary identifier to ensure consistent grouping
* across filesystem-based and path-based resolution.
*
* IMPORTANT: We prioritize local directory name over remote URL repo name because:
* 1. Path-based resolution (for deleted worktrees) can only use directory names
* 2. Users may clone repos with different local names than remote names
* 3. We need consistent grouping regardless of whether filesystem exists
*
* @param _remoteUrl - Git remote URL (unused, kept for API compatibility)
* @param mainGitDirOrName - Path to main .git directory, or repo name for path-based resolution
* @returns Consistent hash-based ID
*/
private generateRepoId(remoteUrl: string | null, mainGitDirOrName: string): string {
// When a remote URL is available, use directory name for grouping
// (worktrees of the same repo have same dir name but different paths)
// When NO remote URL, use the full path to avoid colliding repos with same name
let identity: string;
if (mainGitDirOrName.includes(path.sep) || mainGitDirOrName.includes('/')) {
if (remoteUrl) {
// Has remote → use dir name (allows worktree grouping)
const parentDir = path.dirname(mainGitDirOrName);
identity = path.basename(parentDir);
} else {
// No remote → use full path to distinguish same-named repos.
// For filesystem-based calls, mainGitDirOrName is like /path/.git → strip .git
// For fallback calls, mainGitDirOrName is the project path itself
identity = mainGitDirOrName.endsWith('.git')
? path.dirname(mainGitDirOrName)
: mainGitDirOrName;
}
} else {
// It's already just a name (from path-based resolution fallback)
identity = mainGitDirOrName;
}
// Normalize and generate hash
const normalized = identity.toLowerCase().trim();
// Generate SHA-256 hash and take first 12 characters
const hash = crypto.createHash('sha256').update(normalized).digest('hex');
return hash.substring(0, 12);
}
/**
* Extract repository name from git directory path.
* Always uses the LOCAL directory name for consistency with path-based resolution.
*
* @param _remoteUrl - Git remote URL (unused, kept for API compatibility)
* @param mainGitDir - Path to main .git directory
* @returns Repository name for display
*/
private extractRepoName(_remoteUrl: string | null, mainGitDir: string): string {
// Always use local directory name for consistency
// /Users/username/projectname/.git -> projectname
// /Users/username/projectname/.git -> projectname
const parentDir = path.dirname(mainGitDir);
return path.basename(parentDir);
}
/**
* Get the git branch for a worktree.
*
* @param projectPath - The filesystem path to check
* @returns Branch name or null
*/
async getBranch(
projectPath: string,
options?: {
forceRefresh?: boolean;
}
): Promise<string | null> {
const forceRefresh = options?.forceRefresh === true;
const cached = this.branchCache.get(projectPath);
if (!forceRefresh && cached && cached.expiry > Date.now()) {
return cached.value;
}
const result = await this.getBranchUncached(projectPath);
this.branchCache.set(projectPath, {
value: result,
expiry: Date.now() + GitIdentityResolver.CACHE_TTL_MS,
});
return result;
}
private async getBranchUncached(projectPath: string): Promise<string | null> {
try {
const gitPath = path.join(projectPath, '.git');
let stats: Awaited<ReturnType<typeof fsp.stat>>;
try {
stats = await fsp.stat(gitPath);
} catch {
return null;
}
let headPath: string;
if (stats.isFile()) {
// Worktree - read .git file to find the HEAD location
const gitFileContent = (await fsp.readFile(gitPath, 'utf-8')).trim();
const gitDirMatch = /^gitdir:\s*(\S[^\r\n]*)$/.exec(gitFileContent);
if (!gitDirMatch) {
return null;
}
headPath = path.join(gitDirMatch[1], 'HEAD');
} else {
// Main repo
headPath = path.join(gitPath, 'HEAD');
}
let headContent: string;
try {
headContent = (await fsp.readFile(headPath, 'utf-8')).trim();
} catch {
return null;
}
// Check if HEAD is a symbolic ref (branch)
const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(headContent);
if (refMatch) {
return refMatch[1];
}
// HEAD is detached (commit hash)
return 'detached HEAD';
} catch (error) {
logger.error(`Error reading git branch for ${projectPath}:`, error);
return null;
}
}
/**
* Detect the worktree source based on path patterns.
* This method works purely on path patterns and does NOT require filesystem access,
* ensuring detection works even for deleted worktrees.
*
* Supported patterns:
* - vibe-kanban: /tmp/vibe-kanban/worktrees/{issue-branch}/{repo}
* - conductor: /Users/.../conductor/workspaces/{repo}/{workspace}
* - auto-claude: /Users/.../.auto-claude/worktrees/tasks/{task-id}
* - 21st: /Users/.../.21st/worktrees/{id}/{name}
* - claude-desktop: /Users/.../.claude-worktrees/{repo}/{name}
* - ccswitch: /Users/.../.ccswitch/worktrees/{repo}/{name}
* - git: Standard git worktree (fallback if none of the above match)
* - unknown: Non-git or undetectable
*
* @param projectPath - The filesystem path to check
* @returns WorktreeSource identifier
*/
async detectWorktreeSource(projectPath: string): Promise<WorktreeSource> {
const parts = splitPathSegments(projectPath);
// Pattern: vibe-kanban
// /tmp/vibe-kanban/worktrees/{issue-branch}/{repo}
// /private/var/folders/.../vibe-kanban/worktrees/{issue-branch}/{repo}
if (parts.includes(VIBE_KANBAN_DIR) && parts.includes(WORKTREES_DIR)) {
return 'vibe-kanban';
}
// Pattern: conductor
// /Users/.../conductor/workspaces/{repo}/{workspace}
if (parts.includes(CONDUCTOR_DIR) && parts.includes(WORKSPACES_DIR)) {
return 'conductor';
}
// Pattern: auto-claude
// /Users/.../.auto-claude/worktrees/tasks/{task-id}
if (parts.includes(AUTO_CLAUDE_DIR) && parts.includes(WORKTREES_DIR)) {
return 'auto-claude';
}
// Pattern: 21st (1code)
// /Users/.../.21st/worktrees/{id}/{name}
if (parts.includes(TWENTYFIRST_DIR) && parts.includes(WORKTREES_DIR)) {
return '21st';
}
// Pattern: claude-desktop
// /Users/.../.claude-worktrees/{repo}/{name}
if (parts.includes(CLAUDE_WORKTREES_DIR)) {
return 'claude-desktop';
}
// Pattern: ccswitch
// /Users/.../.ccswitch/worktrees/{repo}/{name}
if (parts.includes(CCSWITCH_DIR) && parts.includes(WORKTREES_DIR)) {
return 'ccswitch';
}
// Pattern: claude-code (Claude Code CLI)
// /Users/.../.claude/worktrees/{name}
{
const claudeCodeIdx = parts.indexOf(CLAUDE_CODE_DIR);
if (claudeCodeIdx >= 0 && parts[claudeCodeIdx + 1] === WORKTREES_DIR) {
return 'claude-code';
}
}
// 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
if (await fileExists(path.join(projectPath, '.git'))) {
return 'git';
}
// Default to 'git' for paths that don't match known patterns
// This is a reasonable default since most worktrees come from git
return 'git';
}
/**
* Get the display name for a worktree based on its source.
* Extracts the meaningful identifier from the path based on the pattern.
*
* @param projectPath - The filesystem path
* @param source - The detected worktree source
* @param branch - The git branch (if available)
* @param isMainWorktree - Whether this is the main worktree
* @returns Display name for the worktree
*/
async getWorktreeDisplayName(
projectPath: string,
source: WorktreeSource,
branch: string | null,
isMainWorktree: boolean
): Promise<string> {
const parts = splitPathSegments(projectPath);
switch (source) {
case 'vibe-kanban': {
// Pattern: vibe-kanban/worktrees/{issue-branch}/{repo}
// Display: {issue-branch} (e.g., "92a6-kanban-extension")
const worktreesIdx = parts.indexOf(WORKTREES_DIR);
if (worktreesIdx >= 0 && parts[worktreesIdx + 1]) {
return parts[worktreesIdx + 1];
}
break;
}
case 'conductor': {
// Pattern: conductor/workspaces/{repo}/{workspace}
// Display: {workspace} (e.g., "san-francisco")
const workspacesIdx = parts.indexOf(WORKSPACES_DIR);
if (workspacesIdx >= 0 && parts[workspacesIdx + 2]) {
return parts[workspacesIdx + 2];
}
break;
}
case 'auto-claude': {
// Pattern: .auto-claude/worktrees/tasks/{task-id}
// Display: {task-id} (e.g., "002-hjell")
const tasksIdx = parts.indexOf(TASKS_DIR);
if (tasksIdx >= 0 && parts[tasksIdx + 1]) {
return parts[tasksIdx + 1];
}
// Fallback: last component
return parts[parts.length - 1];
}
case '21st': {
// Pattern: .21st/worktrees/{id}/{name with [bracket-id]}
// e.g., "mkp2f9a3y7x1s2nr 3b06478 [colonial-swordfish-fcad5f]"
// Display: Extract from brackets (e.g., "colonial-swordfish-fcad5f")
const lastPart = parts[parts.length - 1];
// Extract content from square brackets using indexOf to avoid regex backtracking
const bracketStart = lastPart.indexOf('[');
const bracketEnd = lastPart.indexOf(']', bracketStart);
if (bracketStart !== -1 && bracketEnd !== -1 && bracketEnd > bracketStart + 1) {
return lastPart.slice(bracketStart + 1, bracketEnd);
}
// Fallback: use the last part as-is
return lastPart;
}
case 'claude-desktop': {
// Pattern: .claude-worktrees/{repo}/{name}
// Display: {name} (e.g., "keen-sinoussi")
const claudeWorktreesIdx = parts.indexOf(CLAUDE_WORKTREES_DIR);
if (claudeWorktreesIdx >= 0 && parts[claudeWorktreesIdx + 2]) {
return parts[claudeWorktreesIdx + 2];
}
break;
}
case 'ccswitch': {
// Pattern: .ccswitch/worktrees/{repo}/{name}
// Display: {name} (e.g., "just-explain-my-repo-briefly")
const ccswitchWorktreesIdx = parts.indexOf(CCSWITCH_DIR);
if (ccswitchWorktreesIdx >= 0) {
const worktreesIdx = parts.indexOf(WORKTREES_DIR, ccswitchWorktreesIdx);
if (worktreesIdx >= 0 && parts[worktreesIdx + 2]) {
return parts[worktreesIdx + 2];
}
}
break;
}
case 'claude-code': {
// Pattern: .claude/worktrees/{name}
// Display: {name} (e.g., "editor-feature")
const claudeCodeDirIdx = parts.indexOf(CLAUDE_CODE_DIR);
if (claudeCodeDirIdx >= 0) {
const worktreesIdx = parts.indexOf(WORKTREES_DIR, claudeCodeDirIdx);
if (worktreesIdx >= 0 && parts[worktreesIdx + 1]) {
return parts[worktreesIdx + 1];
}
}
break;
}
case 'git':
// Standard git worktree - use branch or path-based name
if (isMainWorktree) {
return branch ?? 'main';
}
// For non-main git worktrees, try to get the worktree name from .git file
return (await this.getGitWorktreeName(projectPath)) ?? branch ?? parts[parts.length - 1];
case 'unknown':
default:
// Non-git project - use last path component
return parts[parts.length - 1] ?? 'unknown';
}
// Fallback for any case that didn't return
return branch ?? parts[parts.length - 1] ?? 'unknown';
}
/**
* Get the worktree name from git's internal tracking.
* Reads .git file to find the worktree name in .git/worktrees/{name}
*
* @param projectPath - The filesystem path
* @returns Worktree name or null
*/
private async getGitWorktreeName(projectPath: string): Promise<string | null> {
try {
const gitPath = path.join(projectPath, '.git');
let stats: Awaited<ReturnType<typeof fsp.stat>>;
try {
stats = await fsp.stat(gitPath);
} catch {
return null;
}
if (!stats.isFile()) return null;
const content = await fsp.readFile(gitPath, 'utf-8');
const match = /gitdir:\s*(\S[^\r\n]*)/.exec(content);
if (!match) return null;
// gitdir: /main/.git/worktrees/my-worktree-name
const gitdirParts = splitPathSegments(match[1].trim());
const worktreesIdx = gitdirParts.lastIndexOf(WORKTREES_DIR);
if (worktreesIdx >= 0 && gitdirParts[worktreesIdx + 1]) {
return gitdirParts[worktreesIdx + 1];
}
return null;
} catch {
return null;
}
}
}
// Export singleton instance
export const gitIdentityResolver = new GitIdentityResolver();