diff --git a/packages/telemetry/src/BaseTelemetryClient.ts b/packages/telemetry/src/BaseTelemetryClient.ts index ab8ab56f59..2eb308b414 100644 --- a/packages/telemetry/src/BaseTelemetryClient.ts +++ b/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 { 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 diff --git a/packages/telemetry/src/PostHogTelemetryClient.ts b/packages/telemetry/src/PostHogTelemetryClient.ts index 243176ed45..f1c46577df 100644 --- a/packages/telemetry/src/PostHogTelemetryClient.ts +++ b/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 { if (!this.isTelemetryEnabled() || !this.isEventCapturable(event.event)) { if (this.debug) { diff --git a/packages/telemetry/src/__tests__/PostHogTelemetryClient.test.ts b/packages/telemetry/src/__tests__/PostHogTelemetryClient.test.ts index c94dbdb734..282d1d6c6a 100644 --- a/packages/telemetry/src/__tests__/PostHogTelemetryClient.test.ts +++ b/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 }) => Promise> + >(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", () => { diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index 26f5ab894a..0f9ddfa122 100644 --- a/packages/types/src/telemetry.ts +++ b/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 -export type CloudTelemetryProperties = z.infer export type GitProperties = z.infer /** @@ -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 - getCloudTelemetryProperties?(): Promise } /** diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2581c49052..51cb9a275b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/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 { 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, } } } diff --git a/src/utils/__tests__/git.spec.ts b/src/utils/__tests__/git.spec.ts index 754d041e29..3dc0f6fe24 100644 --- a/src/utils/__tests__/git.spec.ts +++ b/src/utils/__tests__/git.spec.ts @@ -1,6 +1,19 @@ import { ExecException } from "child_process" +import * as vscode from "vscode" +import * as fs from "fs" +import * as path from "path" -import { searchCommits, getCommitInfo, getWorkingState } from "../git" +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:password@github.com/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 = "git@github.com:RooCodeInc/Roo-Code.git" + const sanitized = sanitizeGitUrl(url) + + expect(sanitized).toBe("git@github.com:RooCodeInc/Roo-Code.git") + }) + + it("should leave SSH URLs with ssh:// prefix unchanged", () => { + const url = "ssh://git@github.com/RooCodeInc/Roo-Code.git" + const sanitized = sanitizeGitUrl(url) + + expect(sanitized).toBe("ssh://git@github.com/RooCodeInc/Roo-Code.git") + }) + + it("should remove tokens from other URL formats", () => { + const url = "https://oauth2:ghp_abcdef1234567890abcdef1234567890abcdef@github.com/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 = "git@github.com: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://git@github.com/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:password@github.com/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() + }) +}) diff --git a/src/utils/git.ts b/src/utils/git.ts index 640af7fd29..7ecf33172b 100644 --- a/src/utils/git.ts +++ b/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 { + 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: 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 { + 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 { try { await execAsync("git rev-parse --git-dir", { cwd })