import * as vscode from "vscode" import * as path from "path" import { promises as fs } from "fs" import { exec } from "child_process" import { promisify } from "util" import type { GitRepositoryInfo, GitCommit } from "@roo-code/types" import { truncateOutput } from "../integrations/misc/extract-text" const execAsync = promisify(exec) const GIT_OUTPUT_LINE_LIMIT = 500 /** * Extracts git repository information from the workspace's .git directory * @param workspaceRoot The root path of the workspace * @returns Git repository information or empty object if not a git repository */ export async function getGitRepositoryInfo(workspaceRoot: string): Promise { try { const gitDir = path.join(workspaceRoot, ".git") // Check if .git directory exists try { await fs.access(gitDir) } catch { // Not a git repository return {} } const gitInfo: GitRepositoryInfo = {} // Try to read git config file try { const configPath = path.join(gitDir, "config") const configContent = await fs.readFile(configPath, "utf8") // Very simple approach - just find any URL line const urlMatch = configContent.match(/url\s*=\s*(.+?)(?:\r?\n|$)/m) if (urlMatch && urlMatch[1]) { const url = urlMatch[1].trim() // Sanitize the URL and convert to HTTPS format for telemetry gitInfo.repositoryUrl = convertGitUrlToHttps(sanitizeGitUrl(url)) const repositoryName = extractRepositoryName(url) if (repositoryName) { gitInfo.repositoryName = repositoryName } } // Extract default branch (if available) const branchMatch = configContent.match(/\[branch "([^"]+)"\]/i) if (branchMatch && branchMatch[1]) { gitInfo.defaultBranch = branchMatch[1] } } catch (error) { // Ignore config reading errors } // Try to read HEAD file to get current branch if (!gitInfo.defaultBranch) { try { const headPath = path.join(gitDir, "HEAD") const headContent = await fs.readFile(headPath, "utf8") const branchMatch = headContent.match(/ref: refs\/heads\/(.+)/) if (branchMatch && branchMatch[1]) { gitInfo.defaultBranch = branchMatch[1].trim() } } catch (error) { // Ignore HEAD reading errors } } return gitInfo } catch (error) { // Return empty object on any error return {} } } /** * Converts a git URL to HTTPS format * @param url The git URL to convert * @returns The URL in HTTPS format, or the original URL if conversion is not possible */ export function convertGitUrlToHttps(url: string): string { try { // Already HTTPS, just return it if (url.startsWith("https://")) { return url } // Handle SSH format: git@github.com:user/repo.git -> https://github.com/user/repo.git if (url.startsWith("git@")) { const match = url.match(/git@([^:]+):(.+)/) if (match && match.length === 3) { const [, host, path] = match return `https://${host}/${path}` } } // Handle SSH with protocol: ssh://git@github.com/user/repo.git -> https://github.com/user/repo.git if (url.startsWith("ssh://")) { const match = url.match(/ssh:\/\/(?:git@)?([^\/]+)\/(.+)/) if (match && match.length === 3) { const [, host, path] = match return `https://${host}/${path}` } } // Return original URL if we can't convert it return url } catch { // If parsing fails, return original return url } } /** * Sanitizes a git URL to remove sensitive information like tokens * @param url The original git URL * @returns Sanitized URL */ export function sanitizeGitUrl(url: string): string { try { // Remove credentials from HTTPS URLs if (url.startsWith("https://")) { const urlObj = new URL(url) // Remove username and password urlObj.username = "" urlObj.password = "" return urlObj.toString() } // For SSH URLs, return as-is (they don't contain sensitive tokens) if (url.startsWith("git@") || url.startsWith("ssh://")) { return url } // For other formats, return as-is but remove any potential tokens return url.replace(/:[a-f0-9]{40,}@/gi, "@") } catch { // If URL parsing fails, return original (might be SSH format) return url } } /** * Extracts repository name from a git URL * @param url The git URL * @returns Repository name or undefined */ export function extractRepositoryName(url: string): string { try { // Handle different URL formats const patterns = [ // HTTPS: https://github.com/user/repo.git -> user/repo /https:\/\/[^\/]+\/([^\/]+\/[^\/]+?)(?:\.git)?$/, // SSH: git@github.com:user/repo.git -> user/repo /git@[^:]+:([^\/]+\/[^\/]+?)(?:\.git)?$/, // SSH with user: ssh://git@github.com/user/repo.git -> user/repo /ssh:\/\/[^\/]+\/([^\/]+\/[^\/]+?)(?:\.git)?$/, ] for (const pattern of patterns) { const match = url.match(pattern) if (match && match[1]) { return match[1].replace(/\.git$/, "") } } return "" } catch { return "" } } /** * Gets git repository information for the current VSCode workspace * @returns Git repository information or empty object if not available */ export async function getWorkspaceGitInfo(): Promise { const workspaceFolders = vscode.workspace.workspaceFolders if (!workspaceFolders || workspaceFolders.length === 0) { return {} } // Use the first workspace folder. const workspaceRoot = workspaceFolders[0].uri.fsPath return getGitRepositoryInfo(workspaceRoot) } async function checkGitRepo(cwd: string): Promise { try { await execAsync("git rev-parse --git-dir", { cwd }) return true } catch (error) { return false } } /** * Checks if Git is installed on the system by attempting to run git --version * @returns {Promise} True if Git is installed and accessible, false otherwise * @example * const isGitInstalled = await checkGitInstalled(); * if (!isGitInstalled) { * console.log("Git is not installed"); * } */ export async function checkGitInstalled(): Promise { try { await execAsync("git --version") return true } catch (error) { return false } } export async function searchCommits(query: string, cwd: string): Promise { try { const isInstalled = await checkGitInstalled() if (!isInstalled) { console.error("Git is not installed") return [] } const isRepo = await checkGitRepo(cwd) if (!isRepo) { console.error("Not a git repository") return [] } // Search commits by hash or message, limiting to 10 results const { stdout } = await execAsync( `git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short ` + `--grep="${query}" --regexp-ignore-case`, { cwd }, ) let output = stdout if (!output.trim() && /^[a-f0-9]+$/i.test(query)) { // If no results from grep search and query looks like a hash, try searching by hash const { stdout: hashStdout } = await execAsync( `git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short ` + `--author-date-order ${query}`, { cwd }, ).catch(() => ({ stdout: "" })) if (!hashStdout.trim()) { return [] } output = hashStdout } const commits: GitCommit[] = [] const lines = output .trim() .split("\n") .filter((line) => line !== "--") for (let i = 0; i < lines.length; i += 5) { commits.push({ hash: lines[i], shortHash: lines[i + 1], subject: lines[i + 2], author: lines[i + 3], date: lines[i + 4], }) } return commits } catch (error) { console.error("Error searching commits:", error) return [] } } export async function getCommitInfo(hash: string, cwd: string): Promise { try { const isInstalled = await checkGitInstalled() if (!isInstalled) { return "Git is not installed" } const isRepo = await checkGitRepo(cwd) if (!isRepo) { return "Not a git repository" } // Get commit info, stats, and diff separately const { stdout: info } = await execAsync(`git show --format="%H%n%h%n%s%n%an%n%ad%n%b" --no-patch ${hash}`, { cwd, }) const [fullHash, shortHash, subject, author, date, body] = info.trim().split("\n") const { stdout: stats } = await execAsync(`git show --stat --format="" ${hash}`, { cwd }) const { stdout: diff } = await execAsync(`git show --format="" ${hash}`, { cwd }) const summary = [ `Commit: ${shortHash} (${fullHash})`, `Author: ${author}`, `Date: ${date}`, `\nMessage: ${subject}`, body ? `\nDescription:\n${body}` : "", "\nFiles Changed:", stats.trim(), "\nFull Changes:", ].join("\n") const output = summary + "\n\n" + diff.trim() return truncateOutput(output, GIT_OUTPUT_LINE_LIMIT) } catch (error) { console.error("Error getting commit info:", error) return `Failed to get commit info: ${error instanceof Error ? error.message : String(error)}` } } export async function getWorkingState(cwd: string): Promise { try { const isInstalled = await checkGitInstalled() if (!isInstalled) { return "Git is not installed" } const isRepo = await checkGitRepo(cwd) if (!isRepo) { return "Not a git repository" } // Get status of working directory const { stdout: status } = await execAsync("git status --short", { cwd }) if (!status.trim()) { return "No changes in working directory" } // Get all changes (both staged and unstaged) compared to HEAD const { stdout: diff } = await execAsync("git diff HEAD", { cwd }) const lineLimit = GIT_OUTPUT_LINE_LIMIT const output = `Working directory changes:\n\n${status}\n\n${diff}`.trim() return truncateOutput(output, lineLimit) } catch (error) { console.error("Error getting working state:", error) return `Failed to get working state: ${error instanceof Error ? error.message : String(error)}` } } /** * Gets git status output with configurable file limit * @param cwd The working directory to check git status in * @param maxFiles Maximum number of file entries to include (0 = disabled) * @returns Git status string or null if not a git repository */ export async function getGitStatus(cwd: string, maxFiles: number = 20): Promise { try { const isInstalled = await checkGitInstalled() if (!isInstalled) { return null } const isRepo = await checkGitRepo(cwd) if (!isRepo) { return null } // Use porcelain v1 format with branch info const { stdout } = await execAsync("git status --porcelain=v1 --branch", { cwd }) if (!stdout.trim()) { return null } const lines = stdout.trim().split("\n") // First line is always branch info (e.g., "## main...origin/main") const branchLine = lines[0] const fileLines = lines.slice(1) // Build output with branch info and limited file entries const output: string[] = [branchLine] if (maxFiles > 0 && fileLines.length > 0) { const filesToShow = fileLines.slice(0, maxFiles) output.push(...filesToShow) // Add truncation notice if needed if (fileLines.length > maxFiles) { output.push(`... ${fileLines.length - maxFiles} more files`) } } return output.join("\n") } catch (error) { console.error("Error getting git status:", error) return null } }