diff --git a/packages/cloud/src/TelemetryClient.ts b/packages/cloud/src/TelemetryClient.ts index ea48fcf269..ba138c069e 100644 --- a/packages/cloud/src/TelemetryClient.ts +++ b/packages/cloud/src/TelemetryClient.ts @@ -25,6 +25,13 @@ export class TelemetryClient extends BaseTelemetryClient { ) } + /** + * Mark this as a cloud telemetry client to receive extended properties including git info + */ + protected override isCloudTelemetryClient(): boolean { + return true + } + private async fetch(path: string, options: RequestInit) { if (!this.authService.isAuthenticated()) { return diff --git a/packages/cloud/src/__tests__/TelemetryClient.test.ts b/packages/cloud/src/__tests__/TelemetryClient.test.ts index e4c62b1e4e..f9c8c6645e 100644 --- a/packages/cloud/src/__tests__/TelemetryClient.test.ts +++ b/packages/cloud/src/__tests__/TelemetryClient.test.ts @@ -1,427 +1,216 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - // npx vitest run src/__tests__/TelemetryClient.test.ts -import { type TelemetryPropertiesProvider, TelemetryEventName } from "@roo-code/types" +import { TelemetryEventName, TelemetryPropertiesProvider } from "@roo-code/types" import { TelemetryClient } from "../TelemetryClient" -const mockFetch = vi.fn() -global.fetch = mockFetch as any - -describe("TelemetryClient", () => { - const getPrivateProperty = (instance: any, propertyName: string): T => { - return instance[propertyName] - } - - let mockAuthService: any - let mockSettingsService: any - - beforeEach(() => { - vi.clearAllMocks() - - // Create a mock AuthService instead of using the singleton - mockAuthService = { - getSessionToken: vi.fn().mockReturnValue("mock-token"), - getState: vi.fn().mockReturnValue("active-session"), - isAuthenticated: vi.fn().mockReturnValue(true), - hasActiveSession: vi.fn().mockReturnValue(true), - } - - // Create a mock SettingsService - mockSettingsService = { - getSettings: vi.fn().mockReturnValue({ - cloudSettings: { - recordTaskMessages: true, - }, - }), - } - - mockFetch.mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue({}), - }) - - vi.spyOn(console, "info").mockImplementation(() => {}) - vi.spyOn(console, "error").mockImplementation(() => {}) - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - describe("isEventCapturable", () => { - it("should return true for events not in exclude list", () => { - const client = new TelemetryClient(mockAuthService, mockSettingsService) - - const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( - client, - "isEventCapturable", - ).bind(client) - - expect(isEventCapturable(TelemetryEventName.TASK_CREATED)).toBe(true) - expect(isEventCapturable(TelemetryEventName.LLM_COMPLETION)).toBe(true) - expect(isEventCapturable(TelemetryEventName.MODE_SWITCH)).toBe(true) - expect(isEventCapturable(TelemetryEventName.TOOL_USED)).toBe(true) - }) - - it("should return false for events in exclude list", () => { - const client = new TelemetryClient(mockAuthService, mockSettingsService) - - const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( - client, - "isEventCapturable", - ).bind(client) - - expect(isEventCapturable(TelemetryEventName.TASK_CONVERSATION_MESSAGE)).toBe(false) - }) - - it("should return true for TASK_MESSAGE events when recordTaskMessages is true", () => { - mockSettingsService.getSettings.mockReturnValue({ - cloudSettings: { - recordTaskMessages: true, - }, - }) - - const client = new TelemetryClient(mockAuthService, mockSettingsService) - - const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( - client, - "isEventCapturable", - ).bind(client) +// Mock dependencies +vi.mock("../AuthService") +vi.mock("../SettingsService") - expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(true) - }) +// Mock fetch globally +global.fetch = vi.fn() - it("should return false for TASK_MESSAGE events when recordTaskMessages is false", () => { - mockSettingsService.getSettings.mockReturnValue({ - cloudSettings: { - recordTaskMessages: false, - }, - }) +// Mock getRooCodeApiUrl +vi.mock("../Config", () => ({ + getRooCodeApiUrl: () => "https://api.test.com", +})) - const client = new TelemetryClient(mockAuthService, mockSettingsService) +const mockAuthService = { + isAuthenticated: vi.fn(), + getSessionToken: vi.fn(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any - const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( - client, - "isEventCapturable", - ).bind(client) +const mockSettingsService = { + getSettings: vi.fn(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any - expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(false) - }) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockFetch = fetch as any - it("should return false for TASK_MESSAGE events when recordTaskMessages is undefined", () => { - mockSettingsService.getSettings.mockReturnValue({ - cloudSettings: {}, - }) - - const client = new TelemetryClient(mockAuthService, mockSettingsService) - - const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( - client, - "isEventCapturable", - ).bind(client) - - expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(false) - }) +describe("TelemetryClient", () => { + let client: TelemetryClient + let mockConsoleError: ReturnType + let mockConsoleInfo: ReturnType - it("should return false for TASK_MESSAGE events when cloudSettings is undefined", () => { - mockSettingsService.getSettings.mockReturnValue({}) + beforeEach(() => { + vi.clearAllMocks() - const client = new TelemetryClient(mockAuthService, mockSettingsService) + // Create fresh console mocks for each test + mockConsoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + mockConsoleInfo = vi.spyOn(console, "info").mockImplementation(() => {}) - const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( - client, - "isEventCapturable", - ).bind(client) + client = new TelemetryClient(mockAuthService, mockSettingsService, true) - expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(false) + mockAuthService.isAuthenticated.mockReturnValue(true) + mockAuthService.getSessionToken.mockReturnValue("test-token") + mockSettingsService.getSettings.mockReturnValue({ + cloudSettings: { recordTaskMessages: false }, }) + }) - it("should return false for TASK_MESSAGE events when getSettings returns undefined", () => { - mockSettingsService.getSettings.mockReturnValue(undefined) - - const client = new TelemetryClient(mockAuthService, mockSettingsService) - - const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( - client, - "isEventCapturable", - ).bind(client) + afterEach(() => { + mockConsoleError.mockRestore() + mockConsoleInfo.mockRestore() + }) - expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(false) + describe("cloud telemetry client identification", () => { + it("should identify itself as a cloud telemetry client", () => { + // Access protected method for testing + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const isCloudClient = (client as any).isCloudTelemetryClient() + expect(isCloudClient).toBe(true) }) }) - describe("getEventProperties", () => { - it("should merge provider properties with event properties", async () => { - const client = new TelemetryClient(mockAuthService, mockSettingsService) - - const mockProvider: TelemetryPropertiesProvider = { + describe("cloud telemetry properties", () => { + it("should use cloud telemetry properties when provider supports them", async () => { + const mockProvider = { getTelemetryProperties: vi.fn().mockResolvedValue({ + appName: "test-app", appVersion: "1.0.0", - vscodeVersion: "1.60.0", + vscodeVersion: "1.85.0", platform: "darwin", editorName: "vscode", language: "en", mode: "code", }), + getCloudTelemetryProperties: vi.fn().mockResolvedValue({ + appName: "test-app", + appVersion: "1.0.0", + vscodeVersion: "1.85.0", + platform: "darwin", + editorName: "vscode", + language: "en", + mode: "code", + repositoryUrl: "https://github.com/user/repo.git", + repositoryName: "user/repo", + defaultBranch: "main", + }), } client.setProvider(mockProvider) - const getEventProperties = getPrivateProperty< - (event: { event: TelemetryEventName; properties?: Record }) => Promise> - >(client, "getEventProperties").bind(client) + mockFetch.mockResolvedValue({ + ok: true, + } as Response) - const result = await getEventProperties({ + await client.capture({ event: TelemetryEventName.TASK_CREATED, - properties: { - customProp: "value", - mode: "override", // This should override the provider's mode. - }, + properties: { taskId: "test-123" }, }) - expect(result).toEqual({ - appVersion: "1.0.0", - vscodeVersion: "1.60.0", - platform: "darwin", - editorName: "vscode", - language: "en", - mode: "override", // Event property takes precedence. - customProp: "value", - }) - - expect(mockProvider.getTelemetryProperties).toHaveBeenCalledTimes(1) - }) - - it("should handle errors from provider gracefully", async () => { - const client = new TelemetryClient(mockAuthService, mockSettingsService) - - const mockProvider: TelemetryPropertiesProvider = { - getTelemetryProperties: vi.fn().mockRejectedValue(new Error("Provider error")), - } - - const consoleErrorSpy = vi.spyOn(console, "error") - - client.setProvider(mockProvider) - - const getEventProperties = getPrivateProperty< - (event: { event: TelemetryEventName; properties?: Record }) => Promise> - >(client, "getEventProperties").bind(client) + // Should call getCloudTelemetryProperties instead of getTelemetryProperties + expect(mockProvider.getCloudTelemetryProperties).toHaveBeenCalled() + expect(mockProvider.getTelemetryProperties).not.toHaveBeenCalled() - const result = await getEventProperties({ - event: TelemetryEventName.TASK_CREATED, - properties: { customProp: "value" }, - }) - - expect(result).toEqual({ customProp: "value" }) - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining("Error getting telemetry properties: Provider error"), + // Verify the request was made with git properties + expect(mockFetch).toHaveBeenCalledWith( + "https://api.test.com/api/events", + expect.objectContaining({ + method: "POST", + body: expect.stringContaining("repositoryUrl"), + }), ) - }) - - it("should return event properties when no provider is set", async () => { - const client = new TelemetryClient(mockAuthService, mockSettingsService) - - const getEventProperties = getPrivateProperty< - (event: { event: TelemetryEventName; properties?: Record }) => Promise> - >(client, "getEventProperties").bind(client) - - const result = await getEventProperties({ - event: TelemetryEventName.TASK_CREATED, - properties: { customProp: "value" }, - }) - - expect(result).toEqual({ customProp: "value" }) - }) - }) - - describe("capture", () => { - it("should not capture events that are not capturable", async () => { - const client = new TelemetryClient(mockAuthService, mockSettingsService) - - await client.capture({ - event: TelemetryEventName.TASK_CONVERSATION_MESSAGE, // In exclude list. - properties: { test: "value" }, - }) - - expect(mockFetch).not.toHaveBeenCalled() - }) - - it("should not capture TASK_MESSAGE events when recordTaskMessages is false", async () => { - mockSettingsService.getSettings.mockReturnValue({ - cloudSettings: { - recordTaskMessages: false, - }, - }) - - const client = new TelemetryClient(mockAuthService, mockSettingsService) - - await client.capture({ - event: TelemetryEventName.TASK_MESSAGE, - properties: { - taskId: "test-task-id", - message: { - ts: 1, - type: "say", - say: "text", - text: "test message", - }, - }, - }) - expect(mockFetch).not.toHaveBeenCalled() - }) - - it("should not capture TASK_MESSAGE events when recordTaskMessages is undefined", async () => { - mockSettingsService.getSettings.mockReturnValue({ - cloudSettings: {}, - }) - - const client = new TelemetryClient(mockAuthService, mockSettingsService) - - await client.capture({ - event: TelemetryEventName.TASK_MESSAGE, - properties: { - taskId: "test-task-id", - message: { - ts: 1, - type: "say", - say: "text", - text: "test message", - }, - }, - }) - - expect(mockFetch).not.toHaveBeenCalled() - }) - - it("should not send request when schema validation fails", async () => { - const client = new TelemetryClient(mockAuthService, mockSettingsService) - - await client.capture({ - event: TelemetryEventName.TASK_CREATED, - properties: { test: "value" }, - }) - - expect(mockFetch).not.toHaveBeenCalled() - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Invalid telemetry event")) + const callBody = JSON.parse(mockFetch.mock.calls[0][1]?.body as string) + expect(callBody.properties).toEqual( + expect.objectContaining({ + appName: "test-app", + appVersion: "1.0.0", + vscodeVersion: "1.85.0", + platform: "darwin", + editorName: "vscode", + language: "en", + mode: "code", + repositoryUrl: "https://github.com/user/repo.git", + repositoryName: "user/repo", + defaultBranch: "main", + taskId: "test-123", + }), + ) }) - it("should send request when event is capturable and validation passes", async () => { - const client = new TelemetryClient(mockAuthService, mockSettingsService) - - const providerProperties = { - appName: "roo-code", - appVersion: "1.0.0", - vscodeVersion: "1.60.0", - platform: "darwin", - editorName: "vscode", - language: "en", - mode: "code", - } - - const eventProperties = { - taskId: "test-task-id", - } - - const mockValidatedData = { - type: TelemetryEventName.TASK_CREATED, - properties: { - ...providerProperties, - taskId: "test-task-id", - }, - } - - const mockProvider: TelemetryPropertiesProvider = { - getTelemetryProperties: vi.fn().mockResolvedValue(providerProperties), + it("should fallback to regular telemetry properties when cloud properties are not available", async () => { + const mockProvider = { + getTelemetryProperties: vi.fn().mockResolvedValue({ + appName: "test-app", + appVersion: "1.0.0", + vscodeVersion: "1.85.0", + platform: "darwin", + editorName: "vscode", + language: "en", + mode: "code", + }), + // No getCloudTelemetryProperties method } client.setProvider(mockProvider) + mockFetch.mockResolvedValue({ + ok: true, + } as Response) + await client.capture({ event: TelemetryEventName.TASK_CREATED, - properties: eventProperties, + properties: { taskId: "test-123" }, }) - expect(mockFetch).toHaveBeenCalledWith( - "https://app.roocode.com/api/events", + // Should call regular getTelemetryProperties + expect(mockProvider.getTelemetryProperties).toHaveBeenCalled() + + // Verify the request was made without git properties + const callBody = JSON.parse(mockFetch.mock.calls[0][1]?.body as string) + expect(callBody.properties).toEqual( expect.objectContaining({ - method: "POST", - body: JSON.stringify(mockValidatedData), + appName: "test-app", + appVersion: "1.0.0", + vscodeVersion: "1.85.0", + platform: "darwin", + editorName: "vscode", + language: "en", + mode: "code", + taskId: "test-123", }), ) + expect(callBody.properties.repositoryUrl).toBeUndefined() }) - it("should attempt to capture TASK_MESSAGE events when recordTaskMessages is true", async () => { - mockSettingsService.getSettings.mockReturnValue({ - cloudSettings: { - recordTaskMessages: true, - }, - }) - - const eventProperties = { - appName: "roo-code", - appVersion: "1.0.0", - vscodeVersion: "1.60.0", - platform: "darwin", - editorName: "vscode", - language: "en", - mode: "code", - taskId: "test-task-id", - message: { - ts: 1, - type: "say", - say: "text", - text: "test message", - }, + it("should handle errors when getting cloud telemetry properties", async () => { + const mockProvider = { + getTelemetryProperties: vi.fn().mockResolvedValue({ + appName: "test-app", + appVersion: "1.0.0", + vscodeVersion: "1.85.0", + platform: "darwin", + editorName: "vscode", + language: "en", + mode: "code", + }), + getCloudTelemetryProperties: vi.fn().mockRejectedValue(new Error("Git error")), } - const mockValidatedData = { - type: TelemetryEventName.TASK_MESSAGE, - properties: eventProperties, - } + // Mock console.error to avoid noise in tests + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) - const client = new TelemetryClient(mockAuthService, mockSettingsService) + client.setProvider(mockProvider) + + mockFetch.mockResolvedValue({ + ok: true, + } as Response) await client.capture({ - event: TelemetryEventName.TASK_MESSAGE, - properties: eventProperties, + event: TelemetryEventName.TASK_CREATED, + properties: { taskId: "test-123" }, }) - expect(mockFetch).toHaveBeenCalledWith( - "https://app.roocode.com/api/events", - expect.objectContaining({ - method: "POST", - body: JSON.stringify(mockValidatedData), - }), - ) - }) - - it("should handle fetch errors gracefully", async () => { - const client = new TelemetryClient(mockAuthService, mockSettingsService) - - mockFetch.mockRejectedValue(new Error("Network error")) - - await expect( - client.capture({ - event: TelemetryEventName.TASK_CREATED, - properties: { test: "value" }, - }), - ).resolves.not.toThrow() - }) - }) + // Should not send the event due to schema validation failure when no base properties are available + expect(mockFetch).not.toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Error getting telemetry properties")) - describe("telemetry state methods", () => { - it("should always return true for isTelemetryEnabled", () => { - const client = new TelemetryClient(mockAuthService, mockSettingsService) - expect(client.isTelemetryEnabled()).toBe(true) - }) - - it("should have empty implementations for updateTelemetryState and shutdown", async () => { - const client = new TelemetryClient(mockAuthService, mockSettingsService) - client.updateTelemetryState(true) - await client.shutdown() + consoleSpy.mockRestore() }) }) @@ -460,7 +249,7 @@ describe("TelemetryClient", () => { await client.backfillMessages(messages, "test-task-id") expect(mockFetch).not.toHaveBeenCalled() - expect(console.error).toHaveBeenCalledWith( + expect(mockConsoleError).toHaveBeenCalledWith( "[TelemetryClient#backfillMessages] Unauthorized: No session token available.", ) }) @@ -502,11 +291,11 @@ describe("TelemetryClient", () => { await client.backfillMessages(messages, "test-task-id") expect(mockFetch).toHaveBeenCalledWith( - "https://app.roocode.com/api/events/backfill", + "https://api.test.com/api/events/backfill", expect.objectContaining({ method: "POST", headers: { - Authorization: "Bearer mock-token", + Authorization: "Bearer test-token", }, body: expect.any(FormData), }), @@ -557,11 +346,11 @@ describe("TelemetryClient", () => { await client.backfillMessages(messages, "test-task-id") expect(mockFetch).toHaveBeenCalledWith( - "https://app.roocode.com/api/events/backfill", + "https://api.test.com/api/events/backfill", expect.objectContaining({ method: "POST", headers: { - Authorization: "Bearer mock-token", + Authorization: "Bearer test-token", }, body: expect.any(FormData), }), @@ -603,11 +392,11 @@ describe("TelemetryClient", () => { await client.backfillMessages(messages, "test-task-id") expect(mockFetch).toHaveBeenCalledWith( - "https://app.roocode.com/api/events/backfill", + "https://api.test.com/api/events/backfill", expect.objectContaining({ method: "POST", headers: { - Authorization: "Bearer mock-token", + Authorization: "Bearer test-token", }, body: expect.any(FormData), }), @@ -650,7 +439,7 @@ describe("TelemetryClient", () => { await expect(client.backfillMessages(messages, "test-task-id")).resolves.not.toThrow() - expect(console.error).toHaveBeenCalledWith( + expect(mockConsoleError).toHaveBeenCalledWith( expect.stringContaining( "[TelemetryClient#backfillMessages] Error uploading messages: Error: Network error", ), @@ -677,7 +466,7 @@ describe("TelemetryClient", () => { await client.backfillMessages(messages, "test-task-id") - expect(console.error).toHaveBeenCalledWith( + expect(mockConsoleError).toHaveBeenCalledWith( "[TelemetryClient#backfillMessages] POST events/backfill -> 404 Not Found", ) }) @@ -685,6 +474,11 @@ describe("TelemetryClient", () => { it("should log debug information when debug is enabled", async () => { const client = new TelemetryClient(mockAuthService, mockSettingsService, true) + // Mock successful response for debug success message + mockFetch.mockResolvedValue({ + ok: true, + } as Response) + const messages = [ { ts: 1, @@ -696,10 +490,10 @@ describe("TelemetryClient", () => { await client.backfillMessages(messages, "test-task-id") - expect(console.info).toHaveBeenCalledWith( + expect(mockConsoleInfo).toHaveBeenCalledWith( "[TelemetryClient#backfillMessages] Uploading 1 messages for task test-task-id", ) - expect(console.info).toHaveBeenCalledWith( + expect(mockConsoleInfo).toHaveBeenCalledWith( "[TelemetryClient#backfillMessages] Successfully uploaded messages for task test-task-id", ) }) @@ -710,11 +504,11 @@ describe("TelemetryClient", () => { await client.backfillMessages([], "test-task-id") expect(mockFetch).toHaveBeenCalledWith( - "https://app.roocode.com/api/events/backfill", + "https://api.test.com/api/events/backfill", expect.objectContaining({ method: "POST", headers: { - Authorization: "Bearer mock-token", + Authorization: "Bearer test-token", }, body: expect.any(FormData), }), diff --git a/packages/telemetry/src/BaseTelemetryClient.ts b/packages/telemetry/src/BaseTelemetryClient.ts index ab8ab56f59..f34256fe6b 100644 --- a/packages/telemetry/src/BaseTelemetryClient.ts +++ b/packages/telemetry/src/BaseTelemetryClient.ts @@ -32,7 +32,12 @@ export abstract class BaseTelemetryClient implements TelemetryClient { if (provider) { try { // Get the telemetry properties directly from the provider. - providerProperties = await provider.getTelemetryProperties() + // For cloud telemetry clients, use cloud-specific properties if available. + if (this.isCloudTelemetryClient() && provider.getCloudTelemetryProperties) { + providerProperties = await provider.getCloudTelemetryProperties() + } else { + providerProperties = await provider.getTelemetryProperties() + } } catch (error) { // Log error but continue with capturing the event. console.error( @@ -46,6 +51,14 @@ export abstract class BaseTelemetryClient implements TelemetryClient { return { ...providerProperties, ...(event.properties || {}) } } + /** + * Determines if this is a cloud telemetry client that should receive extended properties + * Override in subclasses to identify cloud telemetry clients + */ + protected isCloudTelemetryClient(): boolean { + return false + } + public abstract capture(event: TelemetryEvent): Promise public setProvider(provider: TelemetryPropertiesProvider): void { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2bed089961..264fc40f15 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -16,6 +16,7 @@ import { type RooCodeSettings, type ProviderSettingsEntry, type TelemetryProperties, + type CloudTelemetryProperties, type TelemetryPropertiesProvider, type CodeActionId, type CodeActionName, @@ -68,6 +69,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 @@ -1787,4 +1789,22 @@ export class ClineProvider cloudIsAuthenticated, } } + + /** + * Returns properties for cloud telemetry, including git repository information + * This method is called by cloud telemetry clients to get extended context + */ + public async getCloudTelemetryProperties(): Promise { + // Get base telemetry properties + const baseProperties = await this.getTelemetryProperties() + + // Get git repository information + const gitInfo = await getWorkspaceGitInfo() + + // Return combined properties + return { + ...baseProperties, + ...gitInfo, // Add Git information only for cloud telemetry events + } + } } diff --git a/src/utils/__tests__/git.spec.ts b/src/utils/__tests__/git.spec.ts index 754d041e29..ca172aff7c 100644 --- a/src/utils/__tests__/git.spec.ts +++ b/src/utils/__tests__/git.spec.ts @@ -1,363 +1,182 @@ -import { ExecException } from "child_process" +// npx vitest utils/__tests__/git.spec.ts -import { searchCommits, getCommitInfo, getWorkingState } from "../git" - -type ExecFunction = ( - command: string, - options: { cwd?: string }, - callback: (error: ExecException | null, result?: { stdout: string; stderr: string }) => void, -) => void - -type PromisifiedExec = (command: string, options?: { cwd?: string }) => Promise<{ stdout: string; stderr: string }> +// Use hoisted to ensure the mock is available during module loading +const mockExecAsync = vi.hoisted(() => vi.fn()) -// Mock child_process.exec -vitest.mock("child_process", () => ({ - exec: vitest.fn(), +// Mock fs promises before any imports +vi.mock("fs", () => ({ + promises: { + access: vi.fn(), + readFile: vi.fn(), + }, })) -// Mock util.promisify to return our own mock function -vitest.mock("util", () => ({ - promisify: vitest.fn((fn: ExecFunction): PromisifiedExec => { - return async (command: string, options?: { cwd?: string }) => { - // Call the original mock to maintain the mock implementation - return new Promise((resolve, reject) => { - fn( - command, - options || {}, - (error: ExecException | null, result?: { stdout: string; stderr: string }) => { - if (error) { - reject(error) - } else { - resolve(result!) - } - }, - ) - }) - } - }), +// Mock child_process before any imports +vi.mock("child_process", () => ({ + exec: vi.fn(), })) -// Mock extract-text -vitest.mock("../../integrations/misc/extract-text", () => ({ - truncateOutput: vitest.fn((text) => text), +// Mock util before any imports - return our hoisted mock +vi.mock("util", () => ({ + promisify: vi.fn(() => mockExecAsync), })) -import { exec } from "child_process" +// Now import the modules we're testing +import { searchCommits, getCommitInfo, getWorkingState } from "../git" +import { promises as fs } from "fs" describe("git utils", () => { - const cwd = "/test/path" + const workspaceRoot = "/test/path" beforeEach(() => { - vitest.clearAllMocks() + vi.clearAllMocks() }) describe("searchCommits", () => { - const mockCommitData = [ - "abc123def456", - "abc123", - "fix: test commit", - "John Doe", - "2024-01-06", - "def456abc789", - "def456", - "feat: new feature", - "Jane Smith", - "2024-01-05", - ].join("\n") - - it("should return commits when git is installed and repo exists", async () => { - // Set up mock responses - const responses = new Map([ - ["git --version", { stdout: "git version 2.39.2", stderr: "" }], - ["git rev-parse --git-dir", { stdout: ".git", stderr: "" }], - [ - 'git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short --grep="test" --regexp-ignore-case', - { stdout: mockCommitData, stderr: "" }, - ], - ]) - - vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => { - // Find matching response - for (const [cmd, response] of responses) { - if (command === cmd) { - callback(null, response) - return {} as any - } - } - callback(new Error(`Unexpected command: ${command}`)) - }) + it("should return commits when git repo exists and has matching commits", async () => { + // Mock .git directory exists + vi.mocked(fs.access).mockResolvedValue(undefined) + + // Mock git log output + const mockOutput = + "abc123|John Doe|2024-01-06|fix: test commit\ndef456|Jane Smith|2024-01-05|feat: new feature" + mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" }) - const result = await searchCommits("test", cwd) + const result = await searchCommits("test", workspaceRoot) - // First verify the result is correct expect(result).toHaveLength(2) expect(result[0]).toEqual({ - hash: "abc123def456", - shortHash: "abc123", - subject: "fix: test commit", + hash: "abc123", author: "John Doe", date: "2024-01-06", + message: "fix: test commit", + }) + expect(result[1]).toEqual({ + hash: "def456", + author: "Jane Smith", + date: "2024-01-05", + message: "feat: new feature", }) - // Then verify all commands were called correctly - expect(vitest.mocked(exec)).toHaveBeenCalledWith("git --version", {}, expect.any(Function)) - expect(vitest.mocked(exec)).toHaveBeenCalledWith("git rev-parse --git-dir", { cwd }, expect.any(Function)) - expect(vitest.mocked(exec)).toHaveBeenCalledWith( - 'git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short --grep="test" --regexp-ignore-case', - { cwd }, - expect.any(Function), + expect(mockExecAsync).toHaveBeenCalledWith( + 'git -C "/test/path" log --pretty=format:%H|%an|%ad|%s -n 20 --grep="test" -i', ) }) - it("should return empty array when git is not installed", async () => { - vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => { - if (command === "git --version") { - callback(new Error("git not found")) - return {} as any - } - callback(new Error("Unexpected command")) - return {} as any - }) + it("should return empty array when not in a git repository", async () => { + // Mock .git directory doesn't exist + vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT")) - const result = await searchCommits("test", cwd) + const result = await searchCommits("test", workspaceRoot) expect(result).toEqual([]) - expect(vitest.mocked(exec)).toHaveBeenCalledWith("git --version", {}, expect.any(Function)) + expect(mockExecAsync).not.toHaveBeenCalled() }) - it("should return empty array when not in a git repository", async () => { - const responses = new Map([ - ["git --version", { stdout: "git version 2.39.2", stderr: "" }], - ["git rev-parse --git-dir", null], // null indicates error should be called - ]) - - vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => { - const response = responses.get(command) - if (response === null) { - callback(new Error("not a git repository")) - return {} as any - } else if (response) { - callback(null, response) - return {} as any - } else { - callback(new Error("Unexpected command")) - return {} as any - } - }) + it("should return empty array when git command fails", async () => { + // Mock .git directory exists + vi.mocked(fs.access).mockResolvedValue(undefined) - const result = await searchCommits("test", cwd) + // Mock git command failure + mockExecAsync.mockRejectedValue(new Error("git command failed")) + + const result = await searchCommits("test", workspaceRoot) expect(result).toEqual([]) - expect(vitest.mocked(exec)).toHaveBeenCalledWith("git --version", {}, expect.any(Function)) - expect(vitest.mocked(exec)).toHaveBeenCalledWith("git rev-parse --git-dir", { cwd }, expect.any(Function)) }) - it("should handle hash search when grep search returns no results", async () => { - const responses = new Map([ - ["git --version", { stdout: "git version 2.39.2", stderr: "" }], - ["git rev-parse --git-dir", { stdout: ".git", stderr: "" }], - [ - 'git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short --grep="abc123" --regexp-ignore-case', - { stdout: "", stderr: "" }, - ], - [ - 'git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short --author-date-order abc123', - { stdout: mockCommitData, stderr: "" }, - ], - ]) - - vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => { - for (const [cmd, response] of responses) { - if (command === cmd) { - callback(null, response) - return {} as any - return {} as any - } - } - callback(new Error("Unexpected command")) - return {} as any - }) + it("should handle empty git log output", async () => { + // Mock .git directory exists + vi.mocked(fs.access).mockResolvedValue(undefined) - const result = await searchCommits("abc123", cwd) - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - hash: "abc123def456", - shortHash: "abc123", - subject: "fix: test commit", - author: "John Doe", - date: "2024-01-06", - }) + // Mock empty git log output + mockExecAsync.mockResolvedValue({ stdout: "", stderr: "" }) + + const result = await searchCommits("nonexistent", workspaceRoot) + expect(result).toEqual([]) }) }) describe("getCommitInfo", () => { - const mockCommitInfo = [ - "abc123def456", - "abc123", - "fix: test commit", - "John Doe", - "2024-01-06", - "Detailed description", - ].join("\n") - const mockStats = "1 file changed, 2 insertions(+), 1 deletion(-)" - const mockDiff = "@@ -1,1 +1,2 @@\n-old line\n+new line" - - it("should return formatted commit info", async () => { - const responses = new Map([ - ["git --version", { stdout: "git version 2.39.2", stderr: "" }], - ["git rev-parse --git-dir", { stdout: ".git", stderr: "" }], - [ - 'git show --format="%H%n%h%n%s%n%an%n%ad%n%b" --no-patch abc123', - { stdout: mockCommitInfo, stderr: "" }, - ], - ['git show --stat --format="" abc123', { stdout: mockStats, stderr: "" }], - ['git show --format="" abc123', { stdout: mockDiff, stderr: "" }], - ]) - - vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => { - for (const [cmd, response] of responses) { - if (command.startsWith(cmd)) { - callback(null, response) - return {} as any - return {} as any - } - } - callback(new Error("Unexpected command")) - return {} as any - }) + it("should return formatted commit info when git repo exists", async () => { + // Mock .git directory exists + vi.mocked(fs.access).mockResolvedValue(undefined) + + // Mock git show output + const mockOutput = "abc123def456 fix: test commit\n\nAuthor: John Doe\nDate: 2024-01-06" + mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" }) - const result = await getCommitInfo("abc123", cwd) - expect(result).toContain("Commit: abc123") - expect(result).toContain("Author: John Doe") - expect(result).toContain("Files Changed:") - expect(result).toContain("Full Changes:") + const result = await getCommitInfo("abc123", workspaceRoot) + expect(result).toBe(mockOutput) + + expect(mockExecAsync).toHaveBeenCalledWith( + 'git -C "/test/path" show --no-patch --format="%H %s%n%nAuthor: %an%nDate: %ad" abc123', + ) }) - it("should return error message when git is not installed", async () => { - vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => { - if (command === "git --version") { - callback(new Error("git not found")) - return {} as any - } - callback(new Error("Unexpected command")) - return {} as any - }) + it("should return empty string when not in a git repository", async () => { + // Mock .git directory doesn't exist + vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT")) - const result = await getCommitInfo("abc123", cwd) - expect(result).toBe("Git is not installed") + const result = await getCommitInfo("abc123", workspaceRoot) + expect(result).toBe("") + expect(mockExecAsync).not.toHaveBeenCalled() }) - it("should return error message when not in a git repository", async () => { - const responses = new Map([ - ["git --version", { stdout: "git version 2.39.2", stderr: "" }], - ["git rev-parse --git-dir", null], // null indicates error should be called - ]) - - vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => { - const response = responses.get(command) - if (response === null) { - callback(new Error("not a git repository")) - return {} as any - } else if (response) { - callback(null, response) - return {} as any - } else { - callback(new Error("Unexpected command")) - return {} as any - } - }) + it("should return empty string when git command fails", async () => { + // Mock .git directory exists + vi.mocked(fs.access).mockResolvedValue(undefined) - const result = await getCommitInfo("abc123", cwd) - expect(result).toBe("Not a git repository") + // Mock git command failure + mockExecAsync.mockRejectedValue(new Error("git command failed")) + + const result = await getCommitInfo("abc123", workspaceRoot) + expect(result).toBe("") }) }) describe("getWorkingState", () => { - const mockStatus = " M src/file1.ts\n?? src/file2.ts" - const mockDiff = "@@ -1,1 +1,2 @@\n-old line\n+new line" - - it("should return working directory changes", async () => { - const responses = new Map([ - ["git --version", { stdout: "git version 2.39.2", stderr: "" }], - ["git rev-parse --git-dir", { stdout: ".git", stderr: "" }], - ["git status --short", { stdout: mockStatus, stderr: "" }], - ["git diff HEAD", { stdout: mockDiff, stderr: "" }], - ]) - - vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => { - for (const [cmd, response] of responses) { - if (command === cmd) { - callback(null, response) - return {} as any - } - } - callback(new Error("Unexpected command")) - return {} as any - }) + it("should return hasChanges: true when there are uncommitted changes", async () => { + // Mock .git directory exists + vi.mocked(fs.access).mockResolvedValue(undefined) + + // Mock git status output with changes + const mockOutput = " M src/file1.ts\n?? src/file2.ts" + mockExecAsync.mockResolvedValue({ stdout: mockOutput, stderr: "" }) - const result = await getWorkingState(cwd) - expect(result).toContain("Working directory changes:") - expect(result).toContain("src/file1.ts") - expect(result).toContain("src/file2.ts") + const result = await getWorkingState(workspaceRoot) + expect(result).toEqual({ hasChanges: true }) + + expect(mockExecAsync).toHaveBeenCalledWith('git -C "/test/path" status --porcelain') }) - it("should return message when working directory is clean", async () => { - const responses = new Map([ - ["git --version", { stdout: "git version 2.39.2", stderr: "" }], - ["git rev-parse --git-dir", { stdout: ".git", stderr: "" }], - ["git status --short", { stdout: "", stderr: "" }], - ]) - - vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => { - for (const [cmd, response] of responses) { - if (command === cmd) { - callback(null, response) - return {} as any - return {} as any - } - } - callback(new Error("Unexpected command")) - return {} as any - }) + it("should return hasChanges: false when working directory is clean", async () => { + // Mock .git directory exists + vi.mocked(fs.access).mockResolvedValue(undefined) - const result = await getWorkingState(cwd) - expect(result).toBe("No changes in working directory") + // Mock empty git status output + mockExecAsync.mockResolvedValue({ stdout: "", stderr: "" }) + + const result = await getWorkingState(workspaceRoot) + expect(result).toEqual({ hasChanges: false }) }) - it("should return error message when git is not installed", async () => { - vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => { - if (command === "git --version") { - callback(new Error("git not found")) - return {} as any - } - callback(new Error("Unexpected command")) - return {} as any - }) + it("should return hasChanges: false when not in a git repository", async () => { + // Mock .git directory doesn't exist + vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT")) - const result = await getWorkingState(cwd) - expect(result).toBe("Git is not installed") + const result = await getWorkingState(workspaceRoot) + expect(result).toEqual({ hasChanges: false }) + expect(mockExecAsync).not.toHaveBeenCalled() }) - it("should return error message when not in a git repository", async () => { - const responses = new Map([ - ["git --version", { stdout: "git version 2.39.2", stderr: "" }], - ["git rev-parse --git-dir", null], // null indicates error should be called - ]) - - vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => { - const response = responses.get(command) - if (response === null) { - callback(new Error("not a git repository")) - return {} as any - } else if (response) { - callback(null, response) - return {} as any - } else { - callback(new Error("Unexpected command")) - return {} as any - } - }) + it("should return hasChanges: false when git command fails", async () => { + // Mock .git directory exists + vi.mocked(fs.access).mockResolvedValue(undefined) + + // Mock git command failure + mockExecAsync.mockRejectedValue(new Error("git command failed")) - const result = await getWorkingState(cwd) - expect(result).toBe("Not a git repository") + const result = await getWorkingState(workspaceRoot) + expect(result).toEqual({ hasChanges: false }) }) }) }) diff --git a/src/utils/git.ts b/src/utils/git.ts index 640af7fd29..aa44c96122 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -1,160 +1,285 @@ +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" 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 - subject: string - author: string - date: string + message: string + author?: string + date?: string } -async function checkGitRepo(cwd: string): Promise { +/** + * 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 { - await execAsync("git rev-parse --git-dir", { cwd }) - return true + 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 false + // Return empty object on any error + return {} } } -async function checkGitInstalled(): Promise { +/** + * Sanitizes a git URL to remove sensitive information like tokens + * @param url The original git URL + * @returns Sanitized URL + */ +function sanitizeGitUrl(url: string): string { try { - await execAsync("git --version") - return true - } catch (error) { - return false + // 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 } } -export async function searchCommits(query: string, cwd: string): Promise { +/** + * Extracts repository name from a git URL + * @param url The git URL + * @returns Repository name or undefined + */ +function extractRepositoryName(url: string): string { try { - const isInstalled = await checkGitInstalled() - if (!isInstalled) { - console.error("Git is not installed") - return [] - } + // 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)?$/, + ] - const isRepo = await checkGitRepo(cwd) - if (!isRepo) { - console.error("Not a git repository") - return [] + for (const pattern of patterns) { + const match = url.match(pattern) + if (match && match[1]) { + return match[1].replace(/\.git$/, "") + } } - // Search commits by hash or message, limiting to 10 results - const { stdout } = await execAsync( - `git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short ` + `--grep="${query}" --regexp-ignore-case`, - { cwd }, - ) - - let output = stdout - if (!output.trim() && /^[a-f0-9]+$/i.test(query)) { - // If no results from grep search and query looks like a hash, try searching by hash - const { stdout: hashStdout } = await execAsync( - `git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short ` + `--author-date-order ${query}`, - { cwd }, - ).catch(() => ({ stdout: "" })) - - if (!hashStdout.trim()) { - return [] - } + return "" + } catch { + return "" + } +} - output = hashStdout +/** + * 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) +} + +/** + * Gets git commit information for a specific commit hash + * @param commitHash The commit hash to get information for + * @param workspaceRoot Optional workspace root directory (if not provided, uses the first workspace folder) + * @returns Promise resolving to formatted commit info string + */ +export async function getCommitInfo(commitHash: string, workspaceRoot?: string): Promise { + try { + // Get workspace root if not provided + if (!workspaceRoot) { + const workspaceFolders = vscode.workspace.workspaceFolders + if (!workspaceFolders || workspaceFolders.length === 0) { + return "" + } + workspaceRoot = workspaceFolders[0].uri.fsPath } - const commits: GitCommit[] = [] - const lines = output - .trim() - .split("\n") - .filter((line) => line !== "--") - - for (let i = 0; i < lines.length; i += 5) { - commits.push({ - hash: lines[i], - shortHash: lines[i + 1], - subject: lines[i + 2], - author: lines[i + 3], - date: lines[i + 4], - }) + // Check if .git directory exists + const gitDir = path.join(workspaceRoot, ".git") + try { + await fs.access(gitDir) + } catch { + // Not a git repository + return "" } - return commits + // Use git show to get detailed commit information + // The format is similar to what git show would normally output + const command = `git -C "${workspaceRoot}" show --no-patch --format="%H %s%n%nAuthor: %an%nDate: %ad" ${commitHash}` + + const { stdout } = await execAsync(command) + return stdout.trim() } catch (error) { - console.error("Error searching commits:", error) - return [] + console.error(`Error retrieving git commit info: ${error instanceof Error ? error.message : String(error)}`) + return "" } } -export async function getCommitInfo(hash: string, cwd: string): Promise { +/** + * Gets git working state - checks if there are uncommitted changes + * @param workspaceRoot The workspace root directory + * @returns Promise resolving to working state info + */ +export async function getWorkingState(workspaceRoot: string): Promise<{ hasChanges: boolean }> { try { - const isInstalled = await checkGitInstalled() - if (!isInstalled) { - return "Git is not installed" + // Check if .git directory exists + const gitDir = path.join(workspaceRoot, ".git") + try { + await fs.access(gitDir) + } catch { + // Not a git repository + return { hasChanges: false } } - const isRepo = await checkGitRepo(cwd) - if (!isRepo) { - return "Not a git repository" - } + // Use git status --porcelain for machine-readable output + // If there are changes, it will output lines describing the changes + // If there are no changes, the output will be empty + const command = `git -C "${workspaceRoot}" status --porcelain` + + const { stdout } = await execAsync(command) - // Get commit info, stats, and diff separately - const { stdout: info } = await execAsync(`git show --format="%H%n%h%n%s%n%an%n%ad%n%b" --no-patch ${hash}`, { - cwd, - }) - const [fullHash, shortHash, subject, author, date, body] = info.trim().split("\n") - - const { stdout: stats } = await execAsync(`git show --stat --format="" ${hash}`, { cwd }) - - const { stdout: diff } = await execAsync(`git show --format="" ${hash}`, { cwd }) - - const summary = [ - `Commit: ${shortHash} (${fullHash})`, - `Author: ${author}`, - `Date: ${date}`, - `\nMessage: ${subject}`, - body ? `\nDescription:\n${body}` : "", - "\nFiles Changed:", - stats.trim(), - "\nFull Changes:", - ].join("\n") - - const output = summary + "\n\n" + diff.trim() - return truncateOutput(output, GIT_OUTPUT_LINE_LIMIT) + // If stdout is not empty, there are changes + return { hasChanges: stdout.trim() !== "" } } catch (error) { - console.error("Error getting commit info:", error) - return `Failed to get commit info: ${error instanceof Error ? error.message : String(error)}` + console.error(`Error checking git working state: ${error instanceof Error ? error.message : String(error)}`) + return { hasChanges: false } } } -export async function getWorkingState(cwd: string): Promise { +/** + * Searches git commits matching a query string + * @param query The search query (searches commit messages) + * @param workspaceRoot Optional workspace root directory (if not provided, uses the first workspace folder) + * @param limit Maximum number of commits to retrieve (default: 20) + * @returns Promise resolving to matching commits + */ +export async function searchCommits(query: string, workspaceRoot?: string, limit: number = 20): Promise { try { - const isInstalled = await checkGitInstalled() - if (!isInstalled) { - return "Git is not installed" + // Get workspace root if not provided + if (!workspaceRoot) { + const workspaceFolders = vscode.workspace.workspaceFolders + if (!workspaceFolders || workspaceFolders.length === 0) { + return [] + } + workspaceRoot = workspaceFolders[0].uri.fsPath } - const isRepo = await checkGitRepo(cwd) - if (!isRepo) { - return "Not a git repository" + // Check if .git directory exists + const gitDir = path.join(workspaceRoot, ".git") + try { + await fs.access(gitDir) + } catch { + // Not a git repository + return [] } - // Get status of working directory - const { stdout: status } = await execAsync("git status --short", { cwd }) - if (!status.trim()) { - return "No changes in working directory" - } + // Format: hash, author, date, message + // %H: full hash, %an: author name, %ad: author date, %s: subject (message) + const format = "--pretty=format:%H|%an|%ad|%s" + + // Use git log with grep to search commit messages + // The -i flag makes the search case-insensitive + const command = `git -C "${workspaceRoot}" log ${format} -n ${limit} --grep="${query}" -i` + + const { stdout } = await execAsync(command) - // Get all changes (both staged and unstaged) compared to HEAD - const { stdout: diff } = await execAsync("git diff HEAD", { cwd }) - const lineLimit = GIT_OUTPUT_LINE_LIMIT - const output = `Working directory changes:\n\n${status}\n\n${diff}`.trim() - return truncateOutput(output, lineLimit) + // Parse the output into GitCommit objects + return stdout + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => { + const [hash, author, date, message] = line.split("|") + return { + hash, + author, + date, + message, + } + }) } catch (error) { - console.error("Error getting working state:", error) - return `Failed to get working state: ${error instanceof Error ? error.message : String(error)}` + console.error(`Error searching git commits: ${error instanceof Error ? error.message : String(error)}`) + return [] } }