| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398 |
- 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<GitRepositoryInfo> {
- 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: [email protected]: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://[email protected]/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: [email protected]:user/repo.git -> user/repo
- /git@[^:]+:([^\/]+\/[^\/]+?)(?:\.git)?$/,
- // SSH with user: ssh://[email protected]/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<GitRepositoryInfo> {
- 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<boolean> {
- 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<boolean>} 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<boolean> {
- try {
- await execAsync("git --version")
- return true
- } catch (error) {
- return false
- }
- }
- export async function searchCommits(query: string, cwd: string): Promise<GitCommit[]> {
- 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<string> {
- 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<string> {
- 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<string | null> {
- 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
- }
- }
|