Browse Source

Git repo cloud telemetry (#5119)

This PR adds filtering of git repository properties from telemetry data and includes git info in telemetry properties, with comprehensive tests.

Behavior:
PostHogTelemetryClient now filters out repositoryUrl, repositoryName, and defaultBranch from telemetry events.
ClineProvider includes git repository information in telemetry properties, filtered by clients.
Functions:
Added isPropertyCapturable() in BaseTelemetryClient to allow property filtering.
Implemented getGitRepositoryInfo() and getWorkspaceGitInfo() in git.ts to extract git info.
Tests:
Added tests for isPropertyCapturable() in PostHogTelemetryClient.test.ts.
Added tests for getGitRepositoryInfo() and getWorkspaceGitInfo() in git.spec.ts.
Chris Estreich 6 months ago
parent
commit
aaed043cb0

+ 13 - 2
packages/telemetry/src/BaseTelemetryClient.ts

@@ -25,13 +25,21 @@ export abstract class BaseTelemetryClient implements TelemetryClient {
 			: !this.subscription.events.includes(eventName)
 	}
 
+	/**
+	 * Determines if a specific property should be included in telemetry events
+	 * Override in subclasses to filter specific properties
+	 */
+	protected isPropertyCapturable(_propertyName: string): boolean {
+		return true
+	}
+
 	protected async getEventProperties(event: TelemetryEvent): Promise<TelemetryEvent["properties"]> {
 		let providerProperties: TelemetryEvent["properties"] = {}
 		const provider = this.providerRef?.deref()
 
 		if (provider) {
 			try {
-				// Get the telemetry properties directly from the provider.
+				// Get properties from the provider
 				providerProperties = await provider.getTelemetryProperties()
 			} catch (error) {
 				// Log error but continue with capturing the event.
@@ -43,7 +51,10 @@ export abstract class BaseTelemetryClient implements TelemetryClient {
 
 		// Merge provider properties with event-specific properties.
 		// Event properties take precedence in case of conflicts.
-		return { ...providerProperties, ...(event.properties || {}) }
+		const mergedProperties = { ...providerProperties, ...(event.properties || {}) }
+
+		// Filter out properties that shouldn't be captured by this client
+		return Object.fromEntries(Object.entries(mergedProperties).filter(([key]) => this.isPropertyCapturable(key)))
 	}
 
 	public abstract capture(event: TelemetryEvent): Promise<void>

+ 15 - 0
packages/telemetry/src/PostHogTelemetryClient.ts

@@ -13,6 +13,8 @@ import { BaseTelemetryClient } from "./BaseTelemetryClient"
 export class PostHogTelemetryClient extends BaseTelemetryClient {
 	private client: PostHog
 	private distinctId: string = vscode.env.machineId
+	// Git repository properties that should be filtered out
+	private readonly gitPropertyNames = ["repositoryUrl", "repositoryName", "defaultBranch"]
 
 	constructor(debug = false) {
 		super(
@@ -26,6 +28,19 @@ export class PostHogTelemetryClient extends BaseTelemetryClient {
 		this.client = new PostHog(process.env.POSTHOG_API_KEY || "", { host: "https://us.i.posthog.com" })
 	}
 
+	/**
+	 * Filter out git repository properties for PostHog telemetry
+	 * @param propertyName The property name to check
+	 * @returns Whether the property should be included in telemetry events
+	 */
+	protected override isPropertyCapturable(propertyName: string): boolean {
+		// Filter out git repository properties
+		if (this.gitPropertyNames.includes(propertyName)) {
+			return false
+		}
+		return true
+	}
+
 	public override async capture(event: TelemetryEvent): Promise<void> {
 		if (!this.isTelemetryEnabled() || !this.isEventCapturable(event.event)) {
 			if (this.debug) {

+ 113 - 0
packages/telemetry/src/__tests__/PostHogTelemetryClient.test.ts

@@ -70,6 +70,29 @@ describe("PostHogTelemetryClient", () => {
 		})
 	})
 
+	describe("isPropertyCapturable", () => {
+		it("should filter out git repository properties", () => {
+			const client = new PostHogTelemetryClient()
+
+			const isPropertyCapturable = getPrivateProperty<(propertyName: string) => boolean>(
+				client,
+				"isPropertyCapturable",
+			).bind(client)
+
+			// Git properties should be filtered out
+			expect(isPropertyCapturable("repositoryUrl")).toBe(false)
+			expect(isPropertyCapturable("repositoryName")).toBe(false)
+			expect(isPropertyCapturable("defaultBranch")).toBe(false)
+
+			// Other properties should be included
+			expect(isPropertyCapturable("appVersion")).toBe(true)
+			expect(isPropertyCapturable("vscodeVersion")).toBe(true)
+			expect(isPropertyCapturable("platform")).toBe(true)
+			expect(isPropertyCapturable("mode")).toBe(true)
+			expect(isPropertyCapturable("customProperty")).toBe(true)
+		})
+	})
+
 	describe("getEventProperties", () => {
 		it("should merge provider properties with event properties", async () => {
 			const client = new PostHogTelemetryClient()
@@ -112,6 +135,54 @@ describe("PostHogTelemetryClient", () => {
 			expect(mockProvider.getTelemetryProperties).toHaveBeenCalledTimes(1)
 		})
 
+		it("should filter out git repository properties", async () => {
+			const client = new PostHogTelemetryClient()
+
+			const mockProvider: TelemetryPropertiesProvider = {
+				getTelemetryProperties: vi.fn().mockResolvedValue({
+					appVersion: "1.0.0",
+					vscodeVersion: "1.60.0",
+					platform: "darwin",
+					editorName: "vscode",
+					language: "en",
+					mode: "code",
+					// Git properties that should be filtered out
+					repositoryUrl: "https://github.com/example/repo",
+					repositoryName: "example/repo",
+					defaultBranch: "main",
+				}),
+			}
+
+			client.setProvider(mockProvider)
+
+			const getEventProperties = getPrivateProperty<
+				(event: { event: TelemetryEventName; properties?: Record<string, any> }) => Promise<Record<string, any>>
+			>(client, "getEventProperties").bind(client)
+
+			const result = await getEventProperties({
+				event: TelemetryEventName.TASK_CREATED,
+				properties: {
+					customProp: "value",
+				},
+			})
+
+			// Git properties should be filtered out
+			expect(result).not.toHaveProperty("repositoryUrl")
+			expect(result).not.toHaveProperty("repositoryName")
+			expect(result).not.toHaveProperty("defaultBranch")
+
+			// Other properties should be included
+			expect(result).toEqual({
+				appVersion: "1.0.0",
+				vscodeVersion: "1.60.0",
+				platform: "darwin",
+				editorName: "vscode",
+				language: "en",
+				mode: "code",
+				customProp: "value",
+			})
+		})
+
 		it("should handle errors from provider gracefully", async () => {
 			const client = new PostHogTelemetryClient()
 
@@ -211,6 +282,48 @@ describe("PostHogTelemetryClient", () => {
 				}),
 			})
 		})
+
+		it("should filter out git repository properties when capturing events", async () => {
+			const client = new PostHogTelemetryClient()
+			client.updateTelemetryState(true)
+
+			const mockProvider: TelemetryPropertiesProvider = {
+				getTelemetryProperties: vi.fn().mockResolvedValue({
+					appVersion: "1.0.0",
+					vscodeVersion: "1.60.0",
+					platform: "darwin",
+					editorName: "vscode",
+					language: "en",
+					mode: "code",
+					// Git properties that should be filtered out
+					repositoryUrl: "https://github.com/example/repo",
+					repositoryName: "example/repo",
+					defaultBranch: "main",
+				}),
+			}
+
+			client.setProvider(mockProvider)
+
+			await client.capture({
+				event: TelemetryEventName.TASK_CREATED,
+				properties: { test: "value" },
+			})
+
+			expect(mockPostHogClient.capture).toHaveBeenCalledWith({
+				distinctId: "test-machine-id",
+				event: TelemetryEventName.TASK_CREATED,
+				properties: expect.objectContaining({
+					appVersion: "1.0.0",
+					test: "value",
+				}),
+			})
+
+			// Verify git properties are not included
+			const captureCall = mockPostHogClient.capture.mock.calls[0][0]
+			expect(captureCall.properties).not.toHaveProperty("repositoryUrl")
+			expect(captureCall.properties).not.toHaveProperty("repositoryName")
+			expect(captureCall.properties).not.toHaveProperty("defaultBranch")
+		})
 	})
 
 	describe("updateTelemetryState", () => {

+ 3 - 9
packages/types/src/telemetry.ts

@@ -101,12 +101,7 @@ export const telemetryPropertiesSchema = z.object({
 	...gitPropertiesSchema.shape,
 })
 
-export const cloudTelemetryPropertiesSchema = z.object({
-	...telemetryPropertiesSchema.shape,
-})
-
 export type TelemetryProperties = z.infer<typeof telemetryPropertiesSchema>
-export type CloudTelemetryProperties = z.infer<typeof cloudTelemetryPropertiesSchema>
 export type GitProperties = z.infer<typeof gitPropertiesSchema>
 
 /**
@@ -161,12 +156,12 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [
 			TelemetryEventName.MODE_SETTINGS_CHANGED,
 			TelemetryEventName.CUSTOM_MODE_CREATED,
 		]),
-		properties: cloudTelemetryPropertiesSchema,
+		properties: telemetryPropertiesSchema,
 	}),
 	z.object({
 		type: z.literal(TelemetryEventName.TASK_MESSAGE),
 		properties: z.object({
-			...cloudTelemetryPropertiesSchema.shape,
+			...telemetryPropertiesSchema.shape,
 			taskId: z.string(),
 			message: clineMessageSchema,
 		}),
@@ -174,7 +169,7 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [
 	z.object({
 		type: z.literal(TelemetryEventName.LLM_COMPLETION),
 		properties: z.object({
-			...cloudTelemetryPropertiesSchema.shape,
+			...telemetryPropertiesSchema.shape,
 			inputTokens: z.number(),
 			outputTokens: z.number(),
 			cacheReadTokens: z.number().optional(),
@@ -200,7 +195,6 @@ export type TelemetryEventSubscription =
 
 export interface TelemetryPropertiesProvider {
 	getTelemetryProperties(): Promise<TelemetryProperties>
-	getCloudTelemetryProperties?(): Promise<CloudTelemetryProperties>
 }
 
 /**

+ 7 - 1
src/core/webview/ClineProvider.ts

@@ -68,6 +68,7 @@ import { webviewMessageHandler } from "./webviewMessageHandler"
 import { WebviewMessage } from "../../shared/WebviewMessage"
 import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels"
 import { ProfileValidator } from "../../shared/ProfileValidator"
+import { getWorkspaceGitInfo } from "../../utils/git"
 
 /**
  * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -1785,7 +1786,7 @@ export class ClineProvider
 	/**
 	 * Returns properties to be included in every telemetry event
 	 * This method is called by the telemetry service to get context information
-	 * like the current mode, API provider, etc.
+	 * like the current mode, API provider, git repository information, etc.
 	 */
 	public async getTelemetryProperties(): Promise<TelemetryProperties> {
 		const { mode, apiConfiguration, language } = await this.getState()
@@ -1805,6 +1806,10 @@ export class ClineProvider
 			this.log(`[getTelemetryProperties] Failed to get cloud auth state: ${error}`)
 		}
 
+		// Get git repository information
+		const gitInfo = await getWorkspaceGitInfo()
+
+		// Return all properties including git info - clients will filter as needed
 		return {
 			appName: packageJSON?.name ?? Package.name,
 			appVersion: packageJSON?.version ?? Package.version,
@@ -1818,6 +1823,7 @@ export class ClineProvider
 			diffStrategy: task?.diffStrategy?.getName(),
 			isSubtask: task ? !!task.parentTask : undefined,
 			cloudIsAuthenticated,
+			...gitInfo,
 		}
 	}
 }

+ 347 - 5
src/utils/__tests__/git.spec.ts

@@ -1,6 +1,19 @@
 import { ExecException } from "child_process"
-
-import { searchCommits, getCommitInfo, getWorkingState } from "../git"
+import * as vscode from "vscode"
+import * as fs from "fs"
+import * as path from "path"
+
+import {
+	searchCommits,
+	getCommitInfo,
+	getWorkingState,
+	getGitRepositoryInfo,
+	sanitizeGitUrl,
+	extractRepositoryName,
+	getWorkspaceGitInfo,
+	GitRepositoryInfo,
+} from "../git"
+import { truncateOutput } from "../../integrations/misc/extract-text"
 
 type ExecFunction = (
 	command: string,
@@ -15,6 +28,24 @@ vitest.mock("child_process", () => ({
 	exec: vitest.fn(),
 }))
 
+// Mock fs.promises
+vitest.mock("fs", () => ({
+	promises: {
+		access: vitest.fn(),
+		readFile: vitest.fn(),
+	},
+}))
+
+// Create a mock for vscode
+const mockWorkspaceFolders = vitest.fn()
+vitest.mock("vscode", () => ({
+	workspace: {
+		get workspaceFolders() {
+			return mockWorkspaceFolders()
+		},
+	},
+}))
+
 // Mock util.promisify to return our own mock function
 vitest.mock("util", () => ({
 	promisify: vitest.fn((fn: ExecFunction): PromisifiedExec => {
@@ -169,7 +200,6 @@ describe("git utils", () => {
 					if (command === cmd) {
 						callback(null, response)
 						return {} as any
-						return {} as any
 					}
 				}
 				callback(new Error("Unexpected command"))
@@ -217,7 +247,6 @@ describe("git utils", () => {
 					if (command.startsWith(cmd)) {
 						callback(null, response)
 						return {} as any
-						return {} as any
 					}
 				}
 				callback(new Error("Unexpected command"))
@@ -229,6 +258,7 @@ describe("git utils", () => {
 			expect(result).toContain("Author: John Doe")
 			expect(result).toContain("Files Changed:")
 			expect(result).toContain("Full Changes:")
+			expect(vitest.mocked(truncateOutput)).toHaveBeenCalled()
 		})
 
 		it("should return error message when git is not installed", async () => {
@@ -297,6 +327,7 @@ describe("git utils", () => {
 			expect(result).toContain("Working directory changes:")
 			expect(result).toContain("src/file1.ts")
 			expect(result).toContain("src/file2.ts")
+			expect(vitest.mocked(truncateOutput)).toHaveBeenCalled()
 		})
 
 		it("should return message when working directory is clean", async () => {
@@ -311,7 +342,6 @@ describe("git utils", () => {
 					if (command === cmd) {
 						callback(null, response)
 						return {} as any
-						return {} as any
 					}
 				}
 				callback(new Error("Unexpected command"))
@@ -361,3 +391,315 @@ describe("git utils", () => {
 		})
 	})
 })
+
+describe("getGitRepositoryInfo", () => {
+	const workspaceRoot = "/test/workspace"
+	const gitDir = path.join(workspaceRoot, ".git")
+	const configPath = path.join(gitDir, "config")
+	const headPath = path.join(gitDir, "HEAD")
+
+	beforeEach(() => {
+		vitest.clearAllMocks()
+	})
+
+	it("should return empty object when not a git repository", async () => {
+		// Mock fs.access to throw error (directory doesn't exist)
+		vitest.mocked(fs.promises.access).mockRejectedValueOnce(new Error("ENOENT"))
+
+		const result = await getGitRepositoryInfo(workspaceRoot)
+
+		expect(result).toEqual({})
+		expect(fs.promises.access).toHaveBeenCalledWith(gitDir)
+	})
+
+	it("should extract repository info from git config", async () => {
+		// Clear previous mocks
+		vitest.clearAllMocks()
+
+		// Create a spy to track the implementation
+		const gitSpy = vitest.spyOn(fs.promises, "readFile")
+
+		// Mock successful access to .git directory
+		vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
+
+		// Mock git config file content
+		const mockConfig = `
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = false
+ logallrefupdates = true
+ ignorecase = true
+ precomposeunicode = true
+[remote "origin"]
+ url = https://github.com/RooCodeInc/Roo-Code.git
+ fetch = +refs/heads/*:refs/remotes/origin/*
+[branch "main"]
+ remote = origin
+ merge = refs/heads/main
+`
+		// Mock HEAD file content
+		const mockHead = "ref: refs/heads/main"
+
+		// Setup the readFile mock to return different values based on the path
+		gitSpy.mockImplementation((path: any, encoding: any) => {
+			if (path === configPath) {
+				return Promise.resolve(mockConfig)
+			} else if (path === headPath) {
+				return Promise.resolve(mockHead)
+			}
+			return Promise.reject(new Error(`Unexpected path: ${path}`))
+		})
+
+		const result = await getGitRepositoryInfo(workspaceRoot)
+
+		expect(result).toEqual({
+			repositoryUrl: "https://github.com/RooCodeInc/Roo-Code.git",
+			repositoryName: "RooCodeInc/Roo-Code",
+			defaultBranch: "main",
+		})
+
+		// Verify config file was read
+		expect(gitSpy).toHaveBeenCalledWith(configPath, "utf8")
+
+		// The implementation might not always read the HEAD file if it already found the branch in config
+		// So we don't assert that it was called
+	})
+
+	it("should handle missing repository URL in config", async () => {
+		// Clear previous mocks
+		vitest.clearAllMocks()
+
+		// Create a spy to track the implementation
+		const gitSpy = vitest.spyOn(fs.promises, "readFile")
+
+		// Mock successful access to .git directory
+		vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
+
+		// Mock git config file without URL
+		const mockConfig = `
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = false
+`
+		// Mock HEAD file content
+		const mockHead = "ref: refs/heads/main"
+
+		// Setup the readFile mock to return different values based on the path
+		gitSpy.mockImplementation((path: any, encoding: any) => {
+			if (path === configPath) {
+				return Promise.resolve(mockConfig)
+			} else if (path === headPath) {
+				return Promise.resolve(mockHead)
+			}
+			return Promise.reject(new Error(`Unexpected path: ${path}`))
+		})
+
+		const result = await getGitRepositoryInfo(workspaceRoot)
+
+		expect(result).toEqual({
+			defaultBranch: "main",
+		})
+	})
+
+	it("should handle errors when reading git config", async () => {
+		// Clear previous mocks
+		vitest.clearAllMocks()
+
+		// Create a spy to track the implementation
+		const gitSpy = vitest.spyOn(fs.promises, "readFile")
+
+		// Mock successful access to .git directory
+		vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
+
+		// Setup the readFile mock to return different values based on the path
+		gitSpy.mockImplementation((path: any, encoding: any) => {
+			if (path === configPath) {
+				return Promise.reject(new Error("Failed to read config"))
+			} else if (path === headPath) {
+				return Promise.resolve("ref: refs/heads/main")
+			}
+			return Promise.reject(new Error(`Unexpected path: ${path}`))
+		})
+
+		const result = await getGitRepositoryInfo(workspaceRoot)
+
+		expect(result).toEqual({
+			defaultBranch: "main",
+		})
+	})
+
+	it("should handle errors when reading HEAD file", async () => {
+		// Clear previous mocks
+		vitest.clearAllMocks()
+
+		// Create a spy to track the implementation
+		const gitSpy = vitest.spyOn(fs.promises, "readFile")
+
+		// Mock successful access to .git directory
+		vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
+
+		// Setup the readFile mock to return different values based on the path
+		gitSpy.mockImplementation((path: any, encoding: any) => {
+			if (path === configPath) {
+				return Promise.resolve(`
+[remote "origin"]
+ url = https://github.com/RooCodeInc/Roo-Code.git
+`)
+			} else if (path === headPath) {
+				return Promise.reject(new Error("Failed to read HEAD"))
+			}
+			return Promise.reject(new Error(`Unexpected path: ${path}`))
+		})
+
+		const result = await getGitRepositoryInfo(workspaceRoot)
+
+		expect(result).toEqual({
+			repositoryUrl: "https://github.com/RooCodeInc/Roo-Code.git",
+			repositoryName: "RooCodeInc/Roo-Code",
+		})
+	})
+})
+
+describe("sanitizeGitUrl", () => {
+	it("should sanitize HTTPS URLs with credentials", () => {
+		const url = "https://username:[email protected]/RooCodeInc/Roo-Code.git"
+		const sanitized = sanitizeGitUrl(url)
+
+		expect(sanitized).toBe("https://github.com/RooCodeInc/Roo-Code.git")
+	})
+
+	it("should leave SSH URLs unchanged", () => {
+		const url = "[email protected]:RooCodeInc/Roo-Code.git"
+		const sanitized = sanitizeGitUrl(url)
+
+		expect(sanitized).toBe("[email protected]:RooCodeInc/Roo-Code.git")
+	})
+
+	it("should leave SSH URLs with ssh:// prefix unchanged", () => {
+		const url = "ssh://[email protected]/RooCodeInc/Roo-Code.git"
+		const sanitized = sanitizeGitUrl(url)
+
+		expect(sanitized).toBe("ssh://[email protected]/RooCodeInc/Roo-Code.git")
+	})
+
+	it("should remove tokens from other URL formats", () => {
+		const url = "https://oauth2:[email protected]/RooCodeInc/Roo-Code.git"
+		const sanitized = sanitizeGitUrl(url)
+
+		expect(sanitized).toBe("https://github.com/RooCodeInc/Roo-Code.git")
+	})
+
+	it("should handle invalid URLs gracefully", () => {
+		const url = "not-a-valid-url"
+		const sanitized = sanitizeGitUrl(url)
+
+		expect(sanitized).toBe("not-a-valid-url")
+	})
+})
+
+describe("extractRepositoryName", () => {
+	it("should extract repository name from HTTPS URL", () => {
+		const url = "https://github.com/RooCodeInc/Roo-Code.git"
+		const repoName = extractRepositoryName(url)
+
+		expect(repoName).toBe("RooCodeInc/Roo-Code")
+	})
+
+	it("should extract repository name from HTTPS URL without .git suffix", () => {
+		const url = "https://github.com/RooCodeInc/Roo-Code"
+		const repoName = extractRepositoryName(url)
+
+		expect(repoName).toBe("RooCodeInc/Roo-Code")
+	})
+
+	it("should extract repository name from SSH URL", () => {
+		const url = "[email protected]:RooCodeInc/Roo-Code.git"
+		const repoName = extractRepositoryName(url)
+
+		expect(repoName).toBe("RooCodeInc/Roo-Code")
+	})
+
+	it("should extract repository name from SSH URL with ssh:// prefix", () => {
+		const url = "ssh://[email protected]/RooCodeInc/Roo-Code.git"
+		const repoName = extractRepositoryName(url)
+
+		expect(repoName).toBe("RooCodeInc/Roo-Code")
+	})
+
+	it("should return empty string for unrecognized URL formats", () => {
+		const url = "not-a-valid-git-url"
+		const repoName = extractRepositoryName(url)
+
+		expect(repoName).toBe("")
+	})
+
+	it("should handle URLs with credentials", () => {
+		const url = "https://username:[email protected]/RooCodeInc/Roo-Code.git"
+		const repoName = extractRepositoryName(url)
+
+		expect(repoName).toBe("RooCodeInc/Roo-Code")
+	})
+})
+
+describe("getWorkspaceGitInfo", () => {
+	const workspaceRoot = "/test/workspace"
+
+	beforeEach(() => {
+		vitest.clearAllMocks()
+	})
+
+	it("should return empty object when no workspace folders", async () => {
+		// Mock workspace with no folders
+		mockWorkspaceFolders.mockReturnValue(undefined)
+
+		const result = await getWorkspaceGitInfo()
+
+		expect(result).toEqual({})
+	})
+
+	it("should return git info for the first workspace folder", async () => {
+		// Clear previous mocks
+		vitest.clearAllMocks()
+
+		// Mock workspace with one folder
+		mockWorkspaceFolders.mockReturnValue([{ uri: { fsPath: workspaceRoot }, name: "workspace", index: 0 }])
+
+		// Create a spy to track the implementation
+		const gitSpy = vitest.spyOn(fs.promises, "access")
+		const readFileSpy = vitest.spyOn(fs.promises, "readFile")
+
+		// Mock successful access to .git directory
+		gitSpy.mockResolvedValue(undefined)
+
+		// Mock git config file content
+		const mockConfig = `
+[remote "origin"]
+ url = https://github.com/RooCodeInc/Roo-Code.git
+[branch "main"]
+ remote = origin
+ merge = refs/heads/main
+`
+
+		// Setup the readFile mock to return config content
+		readFileSpy.mockImplementation((path: any, encoding: any) => {
+			if (path.includes("config")) {
+				return Promise.resolve(mockConfig)
+			}
+			return Promise.reject(new Error(`Unexpected path: ${path}`))
+		})
+
+		const result = await getWorkspaceGitInfo()
+
+		expect(result).toEqual({
+			repositoryUrl: "https://github.com/RooCodeInc/Roo-Code.git",
+			repositoryName: "RooCodeInc/Roo-Code",
+			defaultBranch: "main",
+		})
+
+		// Verify the fs operations were called with the correct workspace path
+		expect(gitSpy).toHaveBeenCalled()
+		expect(readFileSpy).toHaveBeenCalled()
+	})
+})

+ 149 - 0
src/utils/git.ts

@@ -1,3 +1,6 @@
+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"
@@ -5,6 +8,12 @@ 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
@@ -13,6 +22,146 @@ export interface GitCommit {
 	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()
+				gitInfo.repositoryUrl = 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 {}
+	}
+}
+
+/**
+ * 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 })