674 lines · 23.1 KB · TypeScript
RawDownload
/** * 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>" */ import {  AUTO_CLAUDE_DIR,  CCSWITCH_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 fs from 'fs';import * as path from 'path'; const logger = createLogger('Service:GitIdentityResolver'); class GitIdentityResolver {  /**   * 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> {    try {      const gitPath = path.join(projectPath, '.git');       // First, try filesystem-based resolution      if (fs.existsSync(gitPath)) {        const stats = fs.statSync(gitPath);         let mainGitDir: string;         if (stats.isFile()) {          // This is a worktree - parse the .git file to find main repo          const gitFileContent = fs.readFileSync(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 = fs.realpathSync(mainGitDir);        } catch {          // If realpath fails (e.g., path doesn't exist), use as-is        }         // Extract remote URL from config        const remoteUrl = 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,        };      }       // Fallback: path doesn't exist, use heuristic resolution      return this.resolveIdentityFromPath(projectPath);    } 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 = projectPath.split(path.sep).filter(Boolean);     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 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).   */  isWorktree(projectPath: string): boolean {    // First, try path-based heuristics (works for deleted worktrees)    const parts = projectPath.split(path.sep).filter(Boolean);     // 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;    }    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');      if (fs.existsSync(gitPath)) {        const stats = fs.statSync(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 = worktreeGitDir.split(path.sep);    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 getRemoteUrl(gitDir: string): string | null {    try {      const configPath = path.join(gitDir, 'config');      if (!fs.existsSync(configPath)) {        return null;      }       const configContent = fs.readFileSync(configPath, 'utf-8');       // 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): Promise<string | null> {    try {      const gitPath = path.join(projectPath, '.git');       if (!fs.existsSync(gitPath)) {        return null;      }       const stats = fs.statSync(gitPath);      let headPath: string;       if (stats.isFile()) {        // Worktree - read .git file to find the HEAD location        const gitFileContent = fs.readFileSync(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');      }       if (!fs.existsSync(headPath)) {        return null;      }       const headContent = fs.readFileSync(headPath, 'utf-8').trim();       // 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   */  detectWorktreeSource(projectPath: string): WorktreeSource {    const parts = projectPath.split(path.sep).filter(Boolean);     // 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';    } 

Lines 1–500 of 674.