mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
83c19ce2c8
commit
aaed043cb0
7 changed files with 646 additions and 16 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
149
src/utils/git.ts
149
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: 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 })
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue