git.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. import * as vscode from "vscode"
  2. import * as path from "path"
  3. import { promises as fs } from "fs"
  4. import { exec } from "child_process"
  5. import { promisify } from "util"
  6. import type { GitRepositoryInfo, GitCommit } from "@roo-code/types"
  7. import { truncateOutput } from "../integrations/misc/extract-text"
  8. const execAsync = promisify(exec)
  9. const GIT_OUTPUT_LINE_LIMIT = 500
  10. /**
  11. * Extracts git repository information from the workspace's .git directory
  12. * @param workspaceRoot The root path of the workspace
  13. * @returns Git repository information or empty object if not a git repository
  14. */
  15. export async function getGitRepositoryInfo(workspaceRoot: string): Promise<GitRepositoryInfo> {
  16. try {
  17. const gitDir = path.join(workspaceRoot, ".git")
  18. // Check if .git directory exists
  19. try {
  20. await fs.access(gitDir)
  21. } catch {
  22. // Not a git repository
  23. return {}
  24. }
  25. const gitInfo: GitRepositoryInfo = {}
  26. // Try to read git config file
  27. try {
  28. const configPath = path.join(gitDir, "config")
  29. const configContent = await fs.readFile(configPath, "utf8")
  30. // Very simple approach - just find any URL line
  31. const urlMatch = configContent.match(/url\s*=\s*(.+?)(?:\r?\n|$)/m)
  32. if (urlMatch && urlMatch[1]) {
  33. const url = urlMatch[1].trim()
  34. // Sanitize the URL and convert to HTTPS format for telemetry
  35. gitInfo.repositoryUrl = convertGitUrlToHttps(sanitizeGitUrl(url))
  36. const repositoryName = extractRepositoryName(url)
  37. if (repositoryName) {
  38. gitInfo.repositoryName = repositoryName
  39. }
  40. }
  41. // Extract default branch (if available)
  42. const branchMatch = configContent.match(/\[branch "([^"]+)"\]/i)
  43. if (branchMatch && branchMatch[1]) {
  44. gitInfo.defaultBranch = branchMatch[1]
  45. }
  46. } catch (error) {
  47. // Ignore config reading errors
  48. }
  49. // Try to read HEAD file to get current branch
  50. if (!gitInfo.defaultBranch) {
  51. try {
  52. const headPath = path.join(gitDir, "HEAD")
  53. const headContent = await fs.readFile(headPath, "utf8")
  54. const branchMatch = headContent.match(/ref: refs\/heads\/(.+)/)
  55. if (branchMatch && branchMatch[1]) {
  56. gitInfo.defaultBranch = branchMatch[1].trim()
  57. }
  58. } catch (error) {
  59. // Ignore HEAD reading errors
  60. }
  61. }
  62. return gitInfo
  63. } catch (error) {
  64. // Return empty object on any error
  65. return {}
  66. }
  67. }
  68. /**
  69. * Converts a git URL to HTTPS format
  70. * @param url The git URL to convert
  71. * @returns The URL in HTTPS format, or the original URL if conversion is not possible
  72. */
  73. export function convertGitUrlToHttps(url: string): string {
  74. try {
  75. // Already HTTPS, just return it
  76. if (url.startsWith("https://")) {
  77. return url
  78. }
  79. // Handle SSH format: [email protected]:user/repo.git -> https://github.com/user/repo.git
  80. if (url.startsWith("git@")) {
  81. const match = url.match(/git@([^:]+):(.+)/)
  82. if (match && match.length === 3) {
  83. const [, host, path] = match
  84. return `https://${host}/${path}`
  85. }
  86. }
  87. // Handle SSH with protocol: ssh://[email protected]/user/repo.git -> https://github.com/user/repo.git
  88. if (url.startsWith("ssh://")) {
  89. const match = url.match(/ssh:\/\/(?:git@)?([^\/]+)\/(.+)/)
  90. if (match && match.length === 3) {
  91. const [, host, path] = match
  92. return `https://${host}/${path}`
  93. }
  94. }
  95. // Return original URL if we can't convert it
  96. return url
  97. } catch {
  98. // If parsing fails, return original
  99. return url
  100. }
  101. }
  102. /**
  103. * Sanitizes a git URL to remove sensitive information like tokens
  104. * @param url The original git URL
  105. * @returns Sanitized URL
  106. */
  107. export function sanitizeGitUrl(url: string): string {
  108. try {
  109. // Remove credentials from HTTPS URLs
  110. if (url.startsWith("https://")) {
  111. const urlObj = new URL(url)
  112. // Remove username and password
  113. urlObj.username = ""
  114. urlObj.password = ""
  115. return urlObj.toString()
  116. }
  117. // For SSH URLs, return as-is (they don't contain sensitive tokens)
  118. if (url.startsWith("git@") || url.startsWith("ssh://")) {
  119. return url
  120. }
  121. // For other formats, return as-is but remove any potential tokens
  122. return url.replace(/:[a-f0-9]{40,}@/gi, "@")
  123. } catch {
  124. // If URL parsing fails, return original (might be SSH format)
  125. return url
  126. }
  127. }
  128. /**
  129. * Extracts repository name from a git URL
  130. * @param url The git URL
  131. * @returns Repository name or undefined
  132. */
  133. export function extractRepositoryName(url: string): string {
  134. try {
  135. // Handle different URL formats
  136. const patterns = [
  137. // HTTPS: https://github.com/user/repo.git -> user/repo
  138. /https:\/\/[^\/]+\/([^\/]+\/[^\/]+?)(?:\.git)?$/,
  139. // SSH: [email protected]:user/repo.git -> user/repo
  140. /git@[^:]+:([^\/]+\/[^\/]+?)(?:\.git)?$/,
  141. // SSH with user: ssh://[email protected]/user/repo.git -> user/repo
  142. /ssh:\/\/[^\/]+\/([^\/]+\/[^\/]+?)(?:\.git)?$/,
  143. ]
  144. for (const pattern of patterns) {
  145. const match = url.match(pattern)
  146. if (match && match[1]) {
  147. return match[1].replace(/\.git$/, "")
  148. }
  149. }
  150. return ""
  151. } catch {
  152. return ""
  153. }
  154. }
  155. /**
  156. * Gets git repository information for the current VSCode workspace
  157. * @returns Git repository information or empty object if not available
  158. */
  159. export async function getWorkspaceGitInfo(): Promise<GitRepositoryInfo> {
  160. const workspaceFolders = vscode.workspace.workspaceFolders
  161. if (!workspaceFolders || workspaceFolders.length === 0) {
  162. return {}
  163. }
  164. // Use the first workspace folder.
  165. const workspaceRoot = workspaceFolders[0].uri.fsPath
  166. return getGitRepositoryInfo(workspaceRoot)
  167. }
  168. async function checkGitRepo(cwd: string): Promise<boolean> {
  169. try {
  170. await execAsync("git rev-parse --git-dir", { cwd })
  171. return true
  172. } catch (error) {
  173. return false
  174. }
  175. }
  176. /**
  177. * Checks if Git is installed on the system by attempting to run git --version
  178. * @returns {Promise<boolean>} True if Git is installed and accessible, false otherwise
  179. * @example
  180. * const isGitInstalled = await checkGitInstalled();
  181. * if (!isGitInstalled) {
  182. * console.log("Git is not installed");
  183. * }
  184. */
  185. export async function checkGitInstalled(): Promise<boolean> {
  186. try {
  187. await execAsync("git --version")
  188. return true
  189. } catch (error) {
  190. return false
  191. }
  192. }
  193. export async function searchCommits(query: string, cwd: string): Promise<GitCommit[]> {
  194. try {
  195. const isInstalled = await checkGitInstalled()
  196. if (!isInstalled) {
  197. console.error("Git is not installed")
  198. return []
  199. }
  200. const isRepo = await checkGitRepo(cwd)
  201. if (!isRepo) {
  202. console.error("Not a git repository")
  203. return []
  204. }
  205. // Search commits by hash or message, limiting to 10 results
  206. const { stdout } = await execAsync(
  207. `git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short ` + `--grep="${query}" --regexp-ignore-case`,
  208. { cwd },
  209. )
  210. let output = stdout
  211. if (!output.trim() && /^[a-f0-9]+$/i.test(query)) {
  212. // If no results from grep search and query looks like a hash, try searching by hash
  213. const { stdout: hashStdout } = await execAsync(
  214. `git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short ` + `--author-date-order ${query}`,
  215. { cwd },
  216. ).catch(() => ({ stdout: "" }))
  217. if (!hashStdout.trim()) {
  218. return []
  219. }
  220. output = hashStdout
  221. }
  222. const commits: GitCommit[] = []
  223. const lines = output
  224. .trim()
  225. .split("\n")
  226. .filter((line) => line !== "--")
  227. for (let i = 0; i < lines.length; i += 5) {
  228. commits.push({
  229. hash: lines[i],
  230. shortHash: lines[i + 1],
  231. subject: lines[i + 2],
  232. author: lines[i + 3],
  233. date: lines[i + 4],
  234. })
  235. }
  236. return commits
  237. } catch (error) {
  238. console.error("Error searching commits:", error)
  239. return []
  240. }
  241. }
  242. export async function getCommitInfo(hash: string, cwd: string): Promise<string> {
  243. try {
  244. const isInstalled = await checkGitInstalled()
  245. if (!isInstalled) {
  246. return "Git is not installed"
  247. }
  248. const isRepo = await checkGitRepo(cwd)
  249. if (!isRepo) {
  250. return "Not a git repository"
  251. }
  252. // Get commit info, stats, and diff separately
  253. const { stdout: info } = await execAsync(`git show --format="%H%n%h%n%s%n%an%n%ad%n%b" --no-patch ${hash}`, {
  254. cwd,
  255. })
  256. const [fullHash, shortHash, subject, author, date, body] = info.trim().split("\n")
  257. const { stdout: stats } = await execAsync(`git show --stat --format="" ${hash}`, { cwd })
  258. const { stdout: diff } = await execAsync(`git show --format="" ${hash}`, { cwd })
  259. const summary = [
  260. `Commit: ${shortHash} (${fullHash})`,
  261. `Author: ${author}`,
  262. `Date: ${date}`,
  263. `\nMessage: ${subject}`,
  264. body ? `\nDescription:\n${body}` : "",
  265. "\nFiles Changed:",
  266. stats.trim(),
  267. "\nFull Changes:",
  268. ].join("\n")
  269. const output = summary + "\n\n" + diff.trim()
  270. return truncateOutput(output, GIT_OUTPUT_LINE_LIMIT)
  271. } catch (error) {
  272. console.error("Error getting commit info:", error)
  273. return `Failed to get commit info: ${error instanceof Error ? error.message : String(error)}`
  274. }
  275. }
  276. export async function getWorkingState(cwd: string): Promise<string> {
  277. try {
  278. const isInstalled = await checkGitInstalled()
  279. if (!isInstalled) {
  280. return "Git is not installed"
  281. }
  282. const isRepo = await checkGitRepo(cwd)
  283. if (!isRepo) {
  284. return "Not a git repository"
  285. }
  286. // Get status of working directory
  287. const { stdout: status } = await execAsync("git status --short", { cwd })
  288. if (!status.trim()) {
  289. return "No changes in working directory"
  290. }
  291. // Get all changes (both staged and unstaged) compared to HEAD
  292. const { stdout: diff } = await execAsync("git diff HEAD", { cwd })
  293. const lineLimit = GIT_OUTPUT_LINE_LIMIT
  294. const output = `Working directory changes:\n\n${status}\n\n${diff}`.trim()
  295. return truncateOutput(output, lineLimit)
  296. } catch (error) {
  297. console.error("Error getting working state:", error)
  298. return `Failed to get working state: ${error instanceof Error ? error.message : String(error)}`
  299. }
  300. }
  301. /**
  302. * Gets git status output with configurable file limit
  303. * @param cwd The working directory to check git status in
  304. * @param maxFiles Maximum number of file entries to include (0 = disabled)
  305. * @returns Git status string or null if not a git repository
  306. */
  307. export async function getGitStatus(cwd: string, maxFiles: number = 20): Promise<string | null> {
  308. try {
  309. const isInstalled = await checkGitInstalled()
  310. if (!isInstalled) {
  311. return null
  312. }
  313. const isRepo = await checkGitRepo(cwd)
  314. if (!isRepo) {
  315. return null
  316. }
  317. // Use porcelain v1 format with branch info
  318. const { stdout } = await execAsync("git status --porcelain=v1 --branch", { cwd })
  319. if (!stdout.trim()) {
  320. return null
  321. }
  322. const lines = stdout.trim().split("\n")
  323. // First line is always branch info (e.g., "## main...origin/main")
  324. const branchLine = lines[0]
  325. const fileLines = lines.slice(1)
  326. // Build output with branch info and limited file entries
  327. const output: string[] = [branchLine]
  328. if (maxFiles > 0 && fileLines.length > 0) {
  329. const filesToShow = fileLines.slice(0, maxFiles)
  330. output.push(...filesToShow)
  331. // Add truncation notice if needed
  332. if (fileLines.length > maxFiles) {
  333. output.push(`... ${fileLines.length - maxFiles} more files`)
  334. }
  335. }
  336. return output.join("\n")
  337. } catch (error) {
  338. console.error("Error getting git status:", error)
  339. return null
  340. }
  341. }