/ utils / githubRepoPathMapping.ts
githubRepoPathMapping.ts
  1  import { realpath } from 'fs/promises'
  2  import { getOriginalCwd } from '../bootstrap/state.js'
  3  import { getGlobalConfig, saveGlobalConfig } from './config.js'
  4  import { logForDebugging } from './debug.js'
  5  import {
  6    detectCurrentRepository,
  7    parseGitHubRepository,
  8  } from './detectRepository.js'
  9  import { pathExists } from './file.js'
 10  import { getRemoteUrlForDir } from './git/gitFilesystem.js'
 11  import { findGitRoot } from './git.js'
 12  
 13  /**
 14   * Updates the GitHub repository path mapping in global config.
 15   * Called at startup (fire-and-forget) to track known local paths for repos.
 16   * This is non-blocking and errors are logged silently.
 17   *
 18   * Stores the git root (not cwd) so the mapping always points to the
 19   * repository root regardless of which subdirectory the user launched from.
 20   * If the path is already tracked, it is promoted to the front of the list
 21   * so the most recently used clone appears first.
 22   */
 23  export async function updateGithubRepoPathMapping(): Promise<void> {
 24    try {
 25      const repo = await detectCurrentRepository()
 26      if (!repo) {
 27        logForDebugging(
 28          'Not in a GitHub repository, skipping path mapping update',
 29        )
 30        return
 31      }
 32  
 33      // Use the git root as the canonical path for this repo clone.
 34      // This ensures we always store the repo root, not an arbitrary subdirectory.
 35      const cwd = getOriginalCwd()
 36      const gitRoot = findGitRoot(cwd)
 37      const basePath = gitRoot ?? cwd
 38  
 39      // Resolve symlinks for canonical storage
 40      let currentPath: string
 41      try {
 42        currentPath = (await realpath(basePath)).normalize('NFC')
 43      } catch {
 44        currentPath = basePath
 45      }
 46  
 47      // Normalize repo key to lowercase for case-insensitive matching
 48      const repoKey = repo.toLowerCase()
 49  
 50      const config = getGlobalConfig()
 51      const existingPaths = config.githubRepoPaths?.[repoKey] ?? []
 52  
 53      if (existingPaths[0] === currentPath) {
 54        // Already at the front — nothing to do
 55        logForDebugging(`Path ${currentPath} already tracked for repo ${repoKey}`)
 56        return
 57      }
 58  
 59      // Remove if present elsewhere (to promote to front), then prepend
 60      const withoutCurrent = existingPaths.filter(p => p !== currentPath)
 61      const updatedPaths = [currentPath, ...withoutCurrent]
 62  
 63      saveGlobalConfig(current => ({
 64        ...current,
 65        githubRepoPaths: {
 66          ...current.githubRepoPaths,
 67          [repoKey]: updatedPaths,
 68        },
 69      }))
 70  
 71      logForDebugging(`Added ${currentPath} to tracked paths for repo ${repoKey}`)
 72    } catch (error) {
 73      logForDebugging(`Error updating repo path mapping: ${error}`)
 74      // Silently fail - this is non-blocking startup work
 75    }
 76  }
 77  
 78  /**
 79   * Gets known local paths for a given GitHub repository.
 80   * @param repo The repository in "owner/repo" format
 81   * @returns Array of known absolute paths, or empty array if none
 82   */
 83  export function getKnownPathsForRepo(repo: string): string[] {
 84    const config = getGlobalConfig()
 85    const repoKey = repo.toLowerCase()
 86    return config.githubRepoPaths?.[repoKey] ?? []
 87  }
 88  
 89  /**
 90   * Filters paths to only those that exist on the filesystem.
 91   * @param paths Array of absolute paths to check
 92   * @returns Array of paths that exist
 93   */
 94  export async function filterExistingPaths(paths: string[]): Promise<string[]> {
 95    const results = await Promise.all(paths.map(pathExists))
 96    return paths.filter((_, i) => results[i])
 97  }
 98  
 99  /**
100   * Validates that a path contains the expected GitHub repository.
101   * @param path Absolute path to check
102   * @param expectedRepo Expected repository in "owner/repo" format
103   * @returns true if the path contains the expected repo, false otherwise
104   */
105  export async function validateRepoAtPath(
106    path: string,
107    expectedRepo: string,
108  ): Promise<boolean> {
109    try {
110      const remoteUrl = await getRemoteUrlForDir(path)
111      if (!remoteUrl) {
112        return false
113      }
114  
115      const actualRepo = parseGitHubRepository(remoteUrl)
116      if (!actualRepo) {
117        return false
118      }
119  
120      // Case-insensitive comparison
121      return actualRepo.toLowerCase() === expectedRepo.toLowerCase()
122    } catch {
123      return false
124    }
125  }
126  
127  /**
128   * Removes a path from the tracked paths for a given repository.
129   * Used when a path is found to be invalid during selection.
130   * @param repo The repository in "owner/repo" format
131   * @param pathToRemove The path to remove from tracking
132   */
133  export function removePathFromRepo(repo: string, pathToRemove: string): void {
134    const config = getGlobalConfig()
135    const repoKey = repo.toLowerCase()
136    const existingPaths = config.githubRepoPaths?.[repoKey] ?? []
137  
138    const updatedPaths = existingPaths.filter(path => path !== pathToRemove)
139  
140    if (updatedPaths.length === existingPaths.length) {
141      // Path wasn't in the list, nothing to do
142      return
143    }
144  
145    const updatedMapping = { ...config.githubRepoPaths }
146  
147    if (updatedPaths.length === 0) {
148      // Remove the repo key entirely if no paths remain
149      delete updatedMapping[repoKey]
150    } else {
151      updatedMapping[repoKey] = updatedPaths
152    }
153  
154    saveGlobalConfig(current => ({
155      ...current,
156      githubRepoPaths: updatedMapping,
157    }))
158  
159    logForDebugging(
160      `Removed ${pathToRemove} from tracked paths for repo ${repoKey}`,
161    )
162  }