|
| 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 { truncateOutput } from "../integrations/misc/extract-text" |
| 7 | + |
| 8 | +const execAsync = promisify(exec) |
| 9 | +const SVN_OUTPUT_LINE_LIMIT = 500 |
| 10 | + |
| 11 | +export interface SvnRepositoryInfo { |
| 12 | + repositoryUrl?: string |
| 13 | + repositoryName?: string |
| 14 | + workingCopyRoot?: string |
| 15 | +} |
| 16 | + |
| 17 | +export interface SvnCommit { |
| 18 | + revision: string |
| 19 | + author: string |
| 20 | + date: string |
| 21 | + message: string |
| 22 | +} |
| 23 | + |
| 24 | +/** |
| 25 | + * Extracts SVN repository information from the workspace's .svn directory |
| 26 | + * @param workspaceRoot The root path of the workspace |
| 27 | + * @returns SVN repository information or empty object if not an SVN repository |
| 28 | + */ |
| 29 | +export async function getSvnRepositoryInfo(workspaceRoot: string): Promise<SvnRepositoryInfo> { |
| 30 | + try { |
| 31 | + const svnDir = path.join(workspaceRoot, ".svn") |
| 32 | + |
| 33 | + // Check if .svn directory exists |
| 34 | + try { |
| 35 | + await fs.access(svnDir) |
| 36 | + } catch { |
| 37 | + // Not an SVN repository |
| 38 | + return {} |
| 39 | + } |
| 40 | + |
| 41 | + const svnInfo: SvnRepositoryInfo = {} |
| 42 | + |
| 43 | + // Try to get SVN info using svn info command |
| 44 | + try { |
| 45 | + const { stdout } = await execAsync("svn info", { cwd: workspaceRoot }) |
| 46 | + |
| 47 | + // Parse SVN info output |
| 48 | + const urlMatch = stdout.match(/^URL:\s*(.+)$/m) |
| 49 | + if (urlMatch && urlMatch[1]) { |
| 50 | + const url = urlMatch[1].trim() |
| 51 | + svnInfo.repositoryUrl = url |
| 52 | + svnInfo.repositoryName = extractSvnRepositoryName(url) |
| 53 | + } |
| 54 | + |
| 55 | + const rootMatch = stdout.match(/^Working Copy Root Path:\s*(.+)$/m) |
| 56 | + if (rootMatch && rootMatch[1]) { |
| 57 | + svnInfo.workingCopyRoot = rootMatch[1].trim() |
| 58 | + } |
| 59 | + } catch (error) { |
| 60 | + // Ignore SVN info errors |
| 61 | + } |
| 62 | + |
| 63 | + return svnInfo |
| 64 | + } catch (error) { |
| 65 | + // Return empty object on any error |
| 66 | + return {} |
| 67 | + } |
| 68 | +} |
| 69 | + |
| 70 | +/** |
| 71 | + * Extracts repository name from an SVN URL |
| 72 | + * @param url The SVN URL |
| 73 | + * @returns Repository name or undefined |
| 74 | + */ |
| 75 | +export function extractSvnRepositoryName(url: string): string { |
| 76 | + try { |
| 77 | + // Handle different SVN URL formats |
| 78 | + const patterns = [ |
| 79 | + // Standard SVN: https://svn.example.com/repo/trunk -> repo |
| 80 | + /\/([^\/]+)\/(?:trunk|branches|tags)(?:\/.*)?$/, |
| 81 | + // Simple repo: https://svn.example.com/repo -> repo |
| 82 | + /\/([^\/]+)\/?$/, |
| 83 | + ] |
| 84 | + |
| 85 | + for (const pattern of patterns) { |
| 86 | + const match = url.match(pattern) |
| 87 | + if (match && match[1]) { |
| 88 | + return match[1] |
| 89 | + } |
| 90 | + } |
| 91 | + |
| 92 | + // Fallback: use the last part of the URL |
| 93 | + const parts = url.split("/").filter(Boolean) |
| 94 | + return parts[parts.length - 1] || "" |
| 95 | + } catch { |
| 96 | + return "" |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +/** |
| 101 | + * Gets SVN repository information for the current VSCode workspace |
| 102 | + * @returns SVN repository information or empty object if not available |
| 103 | + */ |
| 104 | +export async function getWorkspaceSvnInfo(): Promise<SvnRepositoryInfo> { |
| 105 | + const workspaceFolders = vscode.workspace.workspaceFolders |
| 106 | + if (!workspaceFolders || workspaceFolders.length === 0) { |
| 107 | + return {} |
| 108 | + } |
| 109 | + |
| 110 | + // Use the first workspace folder |
| 111 | + const workspaceRoot = workspaceFolders[0].uri.fsPath |
| 112 | + return getSvnRepositoryInfo(workspaceRoot) |
| 113 | +} |
| 114 | + |
| 115 | +/** |
| 116 | + * Checks if the given directory is an SVN repository |
| 117 | + * @param cwd The directory to check |
| 118 | + * @returns True if it's an SVN repository, false otherwise |
| 119 | + */ |
| 120 | +export async function checkSvnRepo(cwd: string): Promise<boolean> { |
| 121 | + try { |
| 122 | + const svnDir = path.join(cwd, ".svn") |
| 123 | + await fs.access(svnDir) |
| 124 | + return true |
| 125 | + } catch { |
| 126 | + return false |
| 127 | + } |
| 128 | +} |
| 129 | + |
| 130 | +/** |
| 131 | + * Checks if SVN is installed and available |
| 132 | + * @returns True if SVN is available, false otherwise |
| 133 | + */ |
| 134 | +export async function checkSvnInstalled(): Promise<boolean> { |
| 135 | + try { |
| 136 | + await execAsync("svn --version") |
| 137 | + return true |
| 138 | + } catch { |
| 139 | + return false |
| 140 | + } |
| 141 | +} |
| 142 | + |
| 143 | +/** |
| 144 | + * Searches for SVN commits by revision number or message content |
| 145 | + * @param query The search query (revision number or message text) |
| 146 | + * @param cwd The working directory |
| 147 | + * @returns Array of matching SVN commits |
| 148 | + */ |
| 149 | +export async function searchSvnCommits(query: string, cwd: string): Promise<SvnCommit[]> { |
| 150 | + try { |
| 151 | + // Check if SVN is available |
| 152 | + if (!(await checkSvnInstalled()) || !(await checkSvnRepo(cwd))) { |
| 153 | + return [] |
| 154 | + } |
| 155 | + |
| 156 | + const commits: SvnCommit[] = [] |
| 157 | + |
| 158 | + // If query looks like a revision number, search for that specific revision |
| 159 | + if (/^\d+$/.test(query)) { |
| 160 | + try { |
| 161 | + const { stdout } = await execAsync(`svn log -r ${query} --xml`, { cwd }) |
| 162 | + const revisionCommits = parseSvnLogXml(stdout) |
| 163 | + commits.push(...revisionCommits) |
| 164 | + } catch { |
| 165 | + // Revision might not exist, continue with general search |
| 166 | + } |
| 167 | + } |
| 168 | + |
| 169 | + // Search in commit messages (get recent commits and filter) |
| 170 | + try { |
| 171 | + const { stdout } = await execAsync("svn log -l 100 --xml", { cwd }) |
| 172 | + const allCommits = parseSvnLogXml(stdout) |
| 173 | + |
| 174 | + // Filter commits by message content |
| 175 | + const messageMatches = allCommits.filter( |
| 176 | + (commit) => commit.message.toLowerCase().includes(query.toLowerCase()) || commit.revision === query, |
| 177 | + ) |
| 178 | + |
| 179 | + // Add unique commits (avoid duplicates from revision search) |
| 180 | + messageMatches.forEach((commit) => { |
| 181 | + if (!commits.some((c) => c.revision === commit.revision)) { |
| 182 | + commits.push(commit) |
| 183 | + } |
| 184 | + }) |
| 185 | + } catch { |
| 186 | + // Ignore errors in message search |
| 187 | + } |
| 188 | + |
| 189 | + return commits.slice(0, 20) // Limit results |
| 190 | + } catch (error) { |
| 191 | + console.error("Error searching SVN commits:", error) |
| 192 | + return [] |
| 193 | + } |
| 194 | +} |
| 195 | + |
| 196 | +/** |
| 197 | + * Parses SVN log XML output into commit objects |
| 198 | + * @param xmlOutput The XML output from svn log --xml |
| 199 | + * @returns Array of SVN commits |
| 200 | + */ |
| 201 | +function parseSvnLogXml(xmlOutput: string): SvnCommit[] { |
| 202 | + const commits: SvnCommit[] = [] |
| 203 | + |
| 204 | + try { |
| 205 | + // Simple XML parsing for SVN log entries |
| 206 | + const entryRegex = /<logentry[^>]*revision="(\d+)"[^>]*>([\s\S]*?)<\/logentry>/g |
| 207 | + const authorRegex = /<author>([\s\S]*?)<\/author>/ |
| 208 | + const dateRegex = /<date>([\s\S]*?)<\/date>/ |
| 209 | + const msgRegex = /<msg>([\s\S]*?)<\/msg>/ |
| 210 | + |
| 211 | + let match |
| 212 | + while ((match = entryRegex.exec(xmlOutput)) !== null) { |
| 213 | + const [, revision, entryContent] = match |
| 214 | + |
| 215 | + const authorMatch = authorRegex.exec(entryContent) |
| 216 | + const dateMatch = dateRegex.exec(entryContent) |
| 217 | + const msgMatch = msgRegex.exec(entryContent) |
| 218 | + |
| 219 | + commits.push({ |
| 220 | + revision, |
| 221 | + author: authorMatch ? authorMatch[1].trim() : "Unknown", |
| 222 | + date: dateMatch ? new Date(dateMatch[1]).toISOString() : "", |
| 223 | + message: msgMatch ? msgMatch[1].trim() : "", |
| 224 | + }) |
| 225 | + } |
| 226 | + } catch (error) { |
| 227 | + console.error("Error parsing SVN log XML:", error) |
| 228 | + } |
| 229 | + |
| 230 | + return commits |
| 231 | +} |
| 232 | + |
| 233 | +/** |
| 234 | + * Gets detailed information about a specific SVN revision |
| 235 | + * @param revision The revision number |
| 236 | + * @param cwd The working directory |
| 237 | + * @returns Detailed commit information including diff |
| 238 | + */ |
| 239 | +export async function getSvnCommitInfo( |
| 240 | + revision: string, |
| 241 | + cwd: string, |
| 242 | +): Promise<{ |
| 243 | + commit: SvnCommit | null |
| 244 | + diff: string |
| 245 | + stats: string |
| 246 | +}> { |
| 247 | + try { |
| 248 | + if (!(await checkSvnInstalled()) || !(await checkSvnRepo(cwd))) { |
| 249 | + return { commit: null, diff: "", stats: "" } |
| 250 | + } |
| 251 | + |
| 252 | + // Get commit info |
| 253 | + const { stdout: logOutput } = await execAsync(`svn log -r ${revision} --xml`, { cwd }) |
| 254 | + const commits = parseSvnLogXml(logOutput) |
| 255 | + const commit = commits[0] || null |
| 256 | + |
| 257 | + // Get diff |
| 258 | + let diff = "" |
| 259 | + try { |
| 260 | + const { stdout: diffOutput } = await execAsync(`svn diff -c ${revision}`, { cwd }) |
| 261 | + diff = truncateOutput(diffOutput, SVN_OUTPUT_LINE_LIMIT) |
| 262 | + } catch { |
| 263 | + // Diff might not be available |
| 264 | + } |
| 265 | + |
| 266 | + // Get stats (changed files) |
| 267 | + let stats = "" |
| 268 | + try { |
| 269 | + const { stdout: changedOutput } = await execAsync(`svn log -r ${revision} -v`, { cwd }) |
| 270 | + const changedMatch = changedOutput.match(/Changed paths:([\s\S]*?)(?=\n\n|\n-{72}|\n$)/) |
| 271 | + if (changedMatch) { |
| 272 | + stats = changedMatch[1].trim() |
| 273 | + } |
| 274 | + } catch { |
| 275 | + // Stats might not be available |
| 276 | + } |
| 277 | + |
| 278 | + return { commit, diff, stats } |
| 279 | + } catch (error) { |
| 280 | + console.error("Error getting SVN commit info:", error) |
| 281 | + return { commit: null, diff: "", stats: "" } |
| 282 | + } |
| 283 | +} |
| 284 | + |
| 285 | +/** |
| 286 | + * Gets the current working state of the SVN repository |
| 287 | + * @param cwd The working directory |
| 288 | + * @returns Object containing status and diff information |
| 289 | + */ |
| 290 | +export async function getSvnWorkingState(cwd: string): Promise<{ |
| 291 | + status: string |
| 292 | + diff: string |
| 293 | +}> { |
| 294 | + try { |
| 295 | + if (!(await checkSvnInstalled()) || !(await checkSvnRepo(cwd))) { |
| 296 | + return { status: "", diff: "" } |
| 297 | + } |
| 298 | + |
| 299 | + // Get status |
| 300 | + let status = "" |
| 301 | + try { |
| 302 | + const { stdout: statusOutput } = await execAsync("svn status", { cwd }) |
| 303 | + status = truncateOutput(statusOutput, SVN_OUTPUT_LINE_LIMIT) |
| 304 | + } catch { |
| 305 | + // Status might not be available |
| 306 | + } |
| 307 | + |
| 308 | + // Get diff of working changes |
| 309 | + let diff = "" |
| 310 | + try { |
| 311 | + const { stdout: diffOutput } = await execAsync("svn diff", { cwd }) |
| 312 | + diff = truncateOutput(diffOutput, SVN_OUTPUT_LINE_LIMIT) |
| 313 | + } catch { |
| 314 | + // Diff might not be available |
| 315 | + } |
| 316 | + |
| 317 | + return { status, diff } |
| 318 | + } catch (error) { |
| 319 | + console.error("Error getting SVN working state:", error) |
| 320 | + return { status: "", diff: "" } |
| 321 | + } |
| 322 | +} |
0 commit comments