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.
This commit is contained in:
Chris Estreich 2025-06-25 17:01:32 -07:00 committed by GitHub
parent 83c19ce2c8
commit aaed043cb0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 646 additions and 16 deletions

View file

@ -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>

View file

@ -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) {

View file

@ -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", () => {

View file

@ -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>
}
/**

View file

@ -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,
}
}
}

View file

@ -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()
})
})

View file

@ -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: 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 })