mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
408 lines
11 KiB
TypeScript
408 lines
11 KiB
TypeScript
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 { truncateOutput } from "../integrations/misc/extract-text"
|
|
|
|
const execAsync = promisify(exec)
|
|
const GIT_OUTPUT_LINE_LIMIT = 500
|
|
|
|
export interface GitRepositoryInfo {
|
|
repositoryUrl?: string
|
|
repositoryName?: string
|
|
defaultBranch?: string
|
|
}
|
|
|
|
export interface GitCommit {
|
|
hash: string
|
|
shortHash: string
|
|
subject: string
|
|
author: string
|
|
date: string
|
|
}
|
|
|
|
/**
|
|
* 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: 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<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
|
|
}
|
|
}
|