From 284638487ed73181e9f7f0a03d802c4c74a8e6ac Mon Sep 17 00:00:00 2001 From: Aidan Cunniffe Date: Sat, 21 Mar 2026 12:24:52 -0400 Subject: [PATCH 1/3] git-ai integration --- src/core/tools/ApplyDiffTool.ts | 7 + src/core/tools/ApplyPatchTool.ts | 11 + src/core/tools/EditFileTool.ts | 5 + src/core/tools/EditTool.ts | 5 + src/core/tools/SearchReplaceTool.ts | 5 + src/core/tools/WriteToFileTool.ts | 19 ++ src/services/git-ai/__tests__/index.spec.ts | 294 ++++++++++++++++++ .../git-ai/__tests__/transcript.spec.ts | 262 ++++++++++++++++ src/services/git-ai/index.ts | 152 +++++++++ src/services/git-ai/transcript.ts | 74 +++++ 10 files changed, 834 insertions(+) create mode 100644 src/services/git-ai/__tests__/index.spec.ts create mode 100644 src/services/git-ai/__tests__/transcript.spec.ts create mode 100644 src/services/git-ai/index.ts create mode 100644 src/services/git-ai/transcript.ts diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 3b664b3bd22..fe11aa806db 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -14,6 +14,8 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import type { ToolUse } from "../../shared/tools" +import { checkpointBeforeEdit, checkpointAfterEdit } from "../../services/git-ai" + import { BaseTool, ToolCallbacks } from "./BaseTool" interface ApplyDiffParams { @@ -176,6 +178,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { // Save directly without showing diff view or opening the file task.diffViewProvider.editType = "modify" task.diffViewProvider.originalContent = originalContent + await checkpointBeforeEdit(task.cwd, [relPath]) await task.diffViewProvider.saveDirectly( relPath, diffResult.content, @@ -183,8 +186,11 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { diagnosticsEnabled, writeDelayMs, ) + await checkpointAfterEdit(task.cwd, task, [relPath]) } else { // Original behavior with diff view + await checkpointBeforeEdit(task.cwd, [relPath]) + // Show diff view before asking for approval task.diffViewProvider.editType = "modify" await task.diffViewProvider.open(relPath) @@ -222,6 +228,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { // Call saveChanges to update the DiffViewProvider properties await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await checkpointAfterEdit(task.cwd, task, [relPath]) } // Track file edit operation diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 3f3295404ba..c847bd6ae23 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -11,6 +11,8 @@ import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath } from "../../utils/fs" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" +import { checkpointBeforeEdit, checkpointAfterEdit } from "../../services/git-ai" + import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { parsePatch, ParseError, processAllHunks } from "./apply-patch" @@ -195,6 +197,8 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { diffStats, } satisfies ClineSayTool) + await checkpointBeforeEdit(task.cwd, [relPath]) + // Show diff view if focus disruption prevention is disabled if (!isPreventFocusDisruptionEnabled) { await task.diffViewProvider.open(relPath) @@ -219,6 +223,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } else { await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } + await checkpointAfterEdit(task.cwd, task, [relPath]) // Track file edit operation await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) @@ -273,6 +278,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } // Delete the file + await checkpointBeforeEdit(task.cwd, [relPath]) try { await fs.unlink(absolutePath) } catch (error) { @@ -281,6 +287,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { pushToolResult(formatResponse.toolError(errorMessage)) return } + await checkpointAfterEdit(task.cwd, task, [relPath]) task.didEditFile = true pushToolResult(`Successfully deleted ${relPath}`) @@ -352,6 +359,8 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { diffStats, } satisfies ClineSayTool) + await checkpointBeforeEdit(task.cwd, [relPath]) + // Show diff view if focus disruption prevention is disabled if (!isPreventFocusDisruptionEnabled) { await task.diffViewProvider.open(relPath) @@ -429,6 +438,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } catch (error) { console.error(`Failed to delete original file after move: ${error}`) } + await checkpointAfterEdit(task.cwd, task, [relPath, change.movePath]) await task.fileContextTracker.trackFileContext(change.movePath, "roo_edited" as RecordSource) } else { @@ -438,6 +448,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } else { await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } + await checkpointAfterEdit(task.cwd, task, [relPath]) await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) } diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index 2495a372bc5..fbf946119d8 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -13,6 +13,8 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import type { ToolUse } from "../../shared/tools" +import { checkpointBeforeEdit, checkpointAfterEdit } from "../../services/git-ai" + import { BaseTool, ToolCallbacks } from "./BaseTool" interface EditFileParams { @@ -415,6 +417,8 @@ export class EditFileTool extends BaseTool<"edit_file"> { diffStats, } satisfies ClineSayTool) + await checkpointBeforeEdit(task.cwd, [relPath]) + // Show diff view if focus disruption prevention is disabled if (!isPreventFocusDisruptionEnabled) { await task.diffViewProvider.open(relPath) @@ -448,6 +452,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { // Call saveChanges to update the DiffViewProvider properties await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } + await checkpointAfterEdit(task.cwd, task, [relPath]) // Track file edit operation if (relPath) { diff --git a/src/core/tools/EditTool.ts b/src/core/tools/EditTool.ts index 79338c17a66..c91ed727d91 100644 --- a/src/core/tools/EditTool.ts +++ b/src/core/tools/EditTool.ts @@ -13,6 +13,8 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import type { ToolUse } from "../../shared/tools" +import { checkpointBeforeEdit, checkpointAfterEdit } from "../../services/git-ai" + import { BaseTool, ToolCallbacks } from "./BaseTool" interface EditParams { @@ -190,6 +192,8 @@ export class EditTool extends BaseTool<"edit"> { diffStats, } satisfies ClineSayTool) + await checkpointBeforeEdit(task.cwd, [relPath]) + // Show diff view if focus disruption prevention is disabled if (!isPreventFocusDisruptionEnabled) { await task.diffViewProvider.open(relPath) @@ -217,6 +221,7 @@ export class EditTool extends BaseTool<"edit"> { // Call saveChanges to update the DiffViewProvider properties await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } + await checkpointAfterEdit(task.cwd, task, [relPath]) // Track file edit operation if (relPath) { diff --git a/src/core/tools/SearchReplaceTool.ts b/src/core/tools/SearchReplaceTool.ts index 2d8817364ff..212f46a2e0f 100644 --- a/src/core/tools/SearchReplaceTool.ts +++ b/src/core/tools/SearchReplaceTool.ts @@ -13,6 +13,8 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import type { ToolUse } from "../../shared/tools" +import { checkpointBeforeEdit, checkpointAfterEdit } from "../../services/git-ai" + import { BaseTool, ToolCallbacks } from "./BaseTool" interface SearchReplaceParams { @@ -186,6 +188,8 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { diffStats, } satisfies ClineSayTool) + await checkpointBeforeEdit(task.cwd, [relPath]) + // Show diff view if focus disruption prevention is disabled if (!isPreventFocusDisruptionEnabled) { await task.diffViewProvider.open(relPath) @@ -213,6 +217,7 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { // Call saveChanges to update the DiffViewProvider properties await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } + await checkpointAfterEdit(task.cwd, task, [relPath]) // Track file edit operation if (relPath) { diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index c8455ef3d97..9d32b7c5093 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -16,6 +16,8 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import type { ToolUse } from "../../shared/tools" +import { checkpointBeforeEdit, checkpointAfterEdit } from "../../services/git-ai" + import { BaseTool, ToolCallbacks } from "./BaseTool" interface WriteToFileParams { @@ -25,6 +27,12 @@ interface WriteToFileParams { export class WriteToFileTool extends BaseTool<"write_to_file"> { readonly name = "write_to_file" as const + private didCheckpointBeforeEdit = false + + override resetPartialState(): void { + super.resetPartialState() + this.didCheckpointBeforeEdit = false + } async execute(params: WriteToFileParams, task: Task, callbacks: ToolCallbacks): Promise { const { pushToolResult, handleError, askApproval } = callbacks @@ -133,8 +141,14 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return } + await checkpointBeforeEdit(task.cwd, [relPath]) await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + await checkpointAfterEdit(task.cwd, task, [relPath]) } else { + if (!this.didCheckpointBeforeEdit) { + await checkpointBeforeEdit(task.cwd, [relPath]) + } + if (!task.diffViewProvider.isEditing) { const partialMessage = JSON.stringify(sharedMessageProps) await task.ask("tool", partialMessage, true).catch(() => {}) @@ -167,6 +181,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { } await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await checkpointAfterEdit(task.cwd, task, [relPath]) } if (relPath) { @@ -246,6 +261,10 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { if (newContent) { if (!task.diffViewProvider.isEditing) { + if (!this.didCheckpointBeforeEdit) { + await checkpointBeforeEdit(task.cwd, [relPath!]) + this.didCheckpointBeforeEdit = true + } await task.diffViewProvider.open(relPath!) } diff --git a/src/services/git-ai/__tests__/index.spec.ts b/src/services/git-ai/__tests__/index.spec.ts new file mode 100644 index 00000000000..c7e8044b94c --- /dev/null +++ b/src/services/git-ai/__tests__/index.spec.ts @@ -0,0 +1,294 @@ +// npx vitest run src/services/git-ai/__tests__/index.spec.ts + +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest" +import * as childProcess from "child_process" +import { EventEmitter } from "events" +import { Writable } from "stream" + +import { + checkpointBeforeEdit, + checkpointAfterEdit, + isGitAiAvailable, + resetGitAiCache, +} from "../index" + +// Mock child_process +vi.mock("child_process", () => ({ + exec: vi.fn(), + spawn: vi.fn(), +})) + +// Mock util.promisify to return a function that calls exec +vi.mock("util", () => ({ + promisify: (fn: any) => { + return (...args: any[]) => + new Promise((resolve, reject) => { + fn(...args, (error: any, stdout: any, stderr: any) => { + if (error) { + reject(error) + } else { + resolve({ stdout, stderr }) + } + }) + }) + }, +})) + +function createMockProcess() { + const proc = new EventEmitter() as any + const stdinData: Buffer[] = [] + proc.stdin = new Writable({ + write(chunk: Buffer, _encoding: string, callback: () => void) { + stdinData.push(chunk) + callback() + }, + }) + proc.stdout = null + proc.stderr = null + return { proc, stdinData } +} + +function mockExecSuccess(stdout: string) { + ;(childProcess.exec as any).mockImplementation( + (_cmd: string, _opts: any, callback?: Function) => { + const cb = callback || _opts + if (typeof cb === "function") { + cb(null, stdout, "") + } + }, + ) +} + +function mockExecFailure(message = "not found") { + ;(childProcess.exec as any).mockImplementation( + (_cmd: string, _opts: any, callback?: Function) => { + const cb = callback || _opts + if (typeof cb === "function") { + cb(new Error(message), "", "") + } + }, + ) +} + +function createMockTask(overrides: Record = {}) { + return { + taskId: "test-task-123", + cwd: "/test/workspace", + apiConversationHistory: [ + { role: "user", content: "Fix the bug", ts: 1700000000000 }, + { role: "assistant", content: "Done.", ts: 1700000001000 }, + ], + api: { + getModel: () => ({ id: "claude-sonnet-4-20250514" }), + }, + ...overrides, + } as any +} + +describe("git-ai service", () => { + beforeEach(() => { + resetGitAiCache() + vi.clearAllMocks() + }) + + describe("isGitAiAvailable", () => { + it("returns true when git-ai is found", async () => { + mockExecSuccess("/usr/local/bin/git-ai\n") + + const result = await isGitAiAvailable() + + expect(result).toBe(true) + }) + + it("returns false when git-ai is not found", async () => { + mockExecFailure("not found") + + const result = await isGitAiAvailable() + + expect(result).toBe(false) + }) + + it("caches the result across calls", async () => { + mockExecSuccess("/usr/local/bin/git-ai\n") + + await isGitAiAvailable() + await isGitAiAvailable() + + // exec should only be called once for the `which` check + expect(childProcess.exec).toHaveBeenCalledTimes(1) + }) + }) + + describe("checkpointBeforeEdit", () => { + it("no-ops when git-ai is not installed", async () => { + mockExecFailure("not found") + + await checkpointBeforeEdit("/test/workspace", ["src/file.ts"]) + + expect(childProcess.spawn).not.toHaveBeenCalled() + }) + + it("no-ops when cwd is not a git repo", async () => { + // First call: which git-ai succeeds + // Second call: git rev-parse fails + let callCount = 0 + ;(childProcess.exec as any).mockImplementation( + (_cmd: string, _opts: any, callback?: Function) => { + const cb = callback || _opts + callCount++ + if (callCount === 1) { + // which git-ai + cb(null, "/usr/local/bin/git-ai\n", "") + } else { + // git rev-parse --show-toplevel + cb(new Error("not a git repo"), "", "") + } + }, + ) + + await checkpointBeforeEdit("/not/a/repo", ["src/file.ts"]) + + expect(childProcess.spawn).not.toHaveBeenCalled() + }) + + it("sends correct pre-edit payload via stdin", async () => { + // which git-ai succeeds, git rev-parse succeeds + let callCount = 0 + ;(childProcess.exec as any).mockImplementation( + (_cmd: string, _opts: any, callback?: Function) => { + const cb = callback || _opts + callCount++ + if (callCount <= 1) { + cb(null, "/usr/local/bin/git-ai\n", "") + } else { + cb(null, "/test/repo\n", "") + } + }, + ) + + const { proc, stdinData } = createMockProcess() + ;(childProcess.spawn as any).mockReturnValue(proc) + + const promise = checkpointBeforeEdit("/test/workspace", ["src/file.ts"]) + + // Simulate successful exit + setTimeout(() => proc.emit("close", 0), 10) + + await promise + + expect(childProcess.spawn).toHaveBeenCalledWith( + "git-ai", + ["checkpoint", "agent-v1", "--hook-input", "stdin"], + expect.objectContaining({ cwd: "/test/repo" }), + ) + + const payload = JSON.parse(Buffer.concat(stdinData).toString()) + expect(payload).toEqual({ + type: "human", + repo_working_dir: "/test/repo", + will_edit_filepaths: ["src/file.ts"], + }) + }) + }) + + describe("checkpointAfterEdit", () => { + it("no-ops when git-ai is not installed", async () => { + mockExecFailure("not found") + + const task = createMockTask() + await checkpointAfterEdit("/test/workspace", task, ["src/file.ts"]) + + expect(childProcess.spawn).not.toHaveBeenCalled() + }) + + it("sends correct post-edit payload via stdin", async () => { + let callCount = 0 + ;(childProcess.exec as any).mockImplementation( + (_cmd: string, _opts: any, callback?: Function) => { + const cb = callback || _opts + callCount++ + if (callCount <= 1) { + cb(null, "/usr/local/bin/git-ai\n", "") + } else { + cb(null, "/test/repo\n", "") + } + }, + ) + + const { proc, stdinData } = createMockProcess() + ;(childProcess.spawn as any).mockReturnValue(proc) + + const task = createMockTask() + const promise = checkpointAfterEdit("/test/workspace", task, ["src/file.ts"]) + + setTimeout(() => proc.emit("close", 0), 10) + + await promise + + const payload = JSON.parse(Buffer.concat(stdinData).toString()) + expect(payload.type).toBe("ai_agent") + expect(payload.repo_working_dir).toBe("/test/repo") + expect(payload.agent_name).toBe("roo-code") + expect(payload.model).toBe("claude-sonnet-4-20250514") + expect(payload.conversation_id).toBe("test-task-123") + expect(payload.edited_filepaths).toEqual(["src/file.ts"]) + expect(payload.transcript.messages).toHaveLength(2) + expect(payload.transcript.messages[0].type).toBe("user") + expect(payload.transcript.messages[1].type).toBe("assistant") + }) + }) + + describe("error handling", () => { + it("checkpointBeforeEdit never throws", async () => { + let callCount = 0 + ;(childProcess.exec as any).mockImplementation( + (_cmd: string, _opts: any, callback?: Function) => { + const cb = callback || _opts + callCount++ + if (callCount <= 1) { + cb(null, "/usr/local/bin/git-ai\n", "") + } else { + cb(null, "/test/repo\n", "") + } + }, + ) + + const { proc } = createMockProcess() + ;(childProcess.spawn as any).mockReturnValue(proc) + + const promise = checkpointBeforeEdit("/test/workspace", ["file.ts"]) + + // Simulate failure exit + setTimeout(() => proc.emit("close", 1), 10) + + // Should not throw + await expect(promise).resolves.toBeUndefined() + }) + + it("checkpointAfterEdit never throws", async () => { + let callCount = 0 + ;(childProcess.exec as any).mockImplementation( + (_cmd: string, _opts: any, callback?: Function) => { + const cb = callback || _opts + callCount++ + if (callCount <= 1) { + cb(null, "/usr/local/bin/git-ai\n", "") + } else { + cb(null, "/test/repo\n", "") + } + }, + ) + + ;(childProcess.spawn as any).mockImplementation(() => { + throw new Error("spawn failed") + }) + + const task = createMockTask() + + // Should not throw + await expect( + checkpointAfterEdit("/test/workspace", task, ["file.ts"]), + ).resolves.toBeUndefined() + }) + }) +}) diff --git a/src/services/git-ai/__tests__/transcript.spec.ts b/src/services/git-ai/__tests__/transcript.spec.ts new file mode 100644 index 00000000000..21f72f4b51f --- /dev/null +++ b/src/services/git-ai/__tests__/transcript.spec.ts @@ -0,0 +1,262 @@ +// npx vitest run src/services/git-ai/__tests__/transcript.spec.ts + +import type { ApiMessage } from "../../../core/task-persistence/apiMessages" +import { buildTranscript } from "../transcript" + +describe("buildTranscript", () => { + it("converts a simple string user message", () => { + const history: ApiMessage[] = [ + { role: "user", content: "Hello world", ts: 1700000000000 }, + ] + + const result = buildTranscript(history) + + expect(result).toEqual([ + { + type: "user", + text: "Hello world", + timestamp: new Date(1700000000000).toISOString(), + }, + ]) + }) + + it("converts a simple string assistant message", () => { + const history: ApiMessage[] = [ + { role: "assistant", content: "Sure, I can help", ts: 1700000001000 }, + ] + + const result = buildTranscript(history) + + expect(result).toEqual([ + { + type: "assistant", + text: "Sure, I can help", + timestamp: new Date(1700000001000).toISOString(), + }, + ]) + }) + + it("converts array-content assistant messages with text and tool_use blocks", () => { + const history: ApiMessage[] = [ + { + role: "assistant", + content: [ + { type: "text", text: "Let me edit that file." }, + { + type: "tool_use", + id: "tool_1", + name: "edit_file", + input: { file_path: "src/index.ts", old_string: "foo", new_string: "bar" }, + }, + ], + ts: 1700000002000, + }, + ] + + const result = buildTranscript(history) + + expect(result).toEqual([ + { + type: "assistant", + text: "Let me edit that file.", + timestamp: new Date(1700000002000).toISOString(), + }, + { + type: "tool_use", + name: "edit_file", + input: { file_path: "src/index.ts", old_string: "foo", new_string: "bar" }, + timestamp: new Date(1700000002000).toISOString(), + }, + ]) + }) + + it("excludes tool_result blocks from user messages", () => { + const history: ApiMessage[] = [ + { + role: "user", + content: [ + { type: "text", text: "Please fix this" }, + { + type: "tool_result", + tool_use_id: "tool_1", + content: "File edited successfully", + }, + ], + ts: 1700000003000, + }, + ] + + const result = buildTranscript(history) + + // Only the text block should be included, tool_result excluded + expect(result).toEqual([ + { + type: "user", + text: "Please fix this", + timestamp: new Date(1700000003000).toISOString(), + }, + ]) + }) + + it("handles messages without timestamps", () => { + const history: ApiMessage[] = [ + { role: "user", content: "No timestamp" }, + ] + + const result = buildTranscript(history) + + expect(result).toEqual([ + { + type: "user", + text: "No timestamp", + timestamp: undefined, + }, + ]) + }) + + it("truncates to last 50 messages", () => { + const history: ApiMessage[] = Array.from({ length: 60 }, (_, i) => ({ + role: "user" as const, + content: `Message ${i}`, + ts: 1700000000000 + i * 1000, + })) + + const result = buildTranscript(history) + + // Should only process the last 50 ApiMessages + expect(result.length).toBe(50) + expect(result[0].text).toBe("Message 10") + expect(result[49].text).toBe("Message 59") + }) + + it("handles a mixed conversation with multiple message types", () => { + const history: ApiMessage[] = [ + { role: "user", content: "Fix the bug", ts: 1700000000000 }, + { + role: "assistant", + content: [ + { type: "text", text: "I'll look into it." }, + { + type: "tool_use", + id: "tool_1", + name: "read_file", + input: { path: "src/bug.ts" }, + }, + ], + ts: 1700000001000, + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool_1", + content: "file contents here", + }, + ], + ts: 1700000002000, + }, + { + role: "assistant", + content: "I found the issue and fixed it.", + ts: 1700000003000, + }, + ] + + const result = buildTranscript(history) + + expect(result).toEqual([ + { type: "user", text: "Fix the bug", timestamp: new Date(1700000000000).toISOString() }, + { type: "assistant", text: "I'll look into it.", timestamp: new Date(1700000001000).toISOString() }, + { + type: "tool_use", + name: "read_file", + input: { path: "src/bug.ts" }, + timestamp: new Date(1700000001000).toISOString(), + }, + // tool_result message is excluded entirely + { + type: "assistant", + text: "I found the issue and fixed it.", + timestamp: new Date(1700000003000).toISOString(), + }, + ]) + }) + + it("filters out environment_details string messages", () => { + const history: ApiMessage[] = [ + { role: "user", content: "Fix the bug", ts: 1700000000000 }, + { + role: "user", + content: "\n# VSCode Visible Files\nfirst-20-primes.txt\n\n# Current Time\n...\n", + ts: 1700000001000, + }, + ] + + const result = buildTranscript(history) + + expect(result).toEqual([ + { type: "user", text: "Fix the bug", timestamp: new Date(1700000000000).toISOString() }, + ]) + }) + + it("filters out environment_details text blocks within array content", () => { + const history: ApiMessage[] = [ + { + role: "user", + content: [ + { type: "text", text: "\nfix the bug\n" }, + { type: "text", text: "\n# VSCode Visible Files\n..." }, + ], + ts: 1700000000000, + }, + ] + + const result = buildTranscript(history) + + expect(result).toEqual([ + { + type: "user", + text: "\nfix the bug\n", + timestamp: new Date(1700000000000).toISOString(), + }, + ]) + }) + + it("keeps non-environment_details user messages like read_file results", () => { + const history: ApiMessage[] = [ + { + role: "user", + content: "[read_file for 'src/index.ts']\nFile: src/index.ts\n 1 | console.log('hello')", + ts: 1700000000000, + }, + ] + + const result = buildTranscript(history) + + expect(result).toHaveLength(1) + expect(result[0].text).toContain("[read_file for") + }) + + it("does not filter environment_details-like text from assistant messages", () => { + const history: ApiMessage[] = [ + { role: "assistant", content: "\nsome response", ts: 1700000000000 }, + ] + + const result = buildTranscript(history) + + expect(result).toHaveLength(1) + expect(result[0].type).toBe("assistant") + }) + + it("skips messages with non-string, non-array content", () => { + const history: ApiMessage[] = [ + { role: "user", content: undefined as any }, + { role: "assistant", content: 42 as any }, + ] + + const result = buildTranscript(history) + + expect(result).toEqual([]) + }) +}) diff --git a/src/services/git-ai/index.ts b/src/services/git-ai/index.ts new file mode 100644 index 00000000000..e3d70d2f69e --- /dev/null +++ b/src/services/git-ai/index.ts @@ -0,0 +1,152 @@ +import { spawn, exec } from "child_process" +import { promisify } from "util" + +import type { ApiMessage } from "../../core/task-persistence/apiMessages" + +import { buildTranscript } from "./transcript" + +const execAsync = promisify(exec) + +/** + * Narrow interface for the Task data needed by checkpointAfterEdit. + * Avoids coupling the git-ai service to the full Task class. + */ +export interface GitAiTaskContext { + taskId: string + cwd: string + apiConversationHistory: ApiMessage[] + api: { getModel(): { id: string } } +} + +// Cached result of checking if git-ai CLI is installed. +let gitAiAvailableCache: boolean | null = null + +/** + * Check if the git-ai CLI is available on the system. + * Result is cached for the lifetime of the process. + */ +export async function isGitAiAvailable(): Promise { + if (gitAiAvailableCache !== null) { + return gitAiAvailableCache + } + try { + const cmd = process.platform === "win32" ? "where" : "which" + await execAsync(`${cmd} git-ai`) + gitAiAvailableCache = true + } catch { + gitAiAvailableCache = false + } + return gitAiAvailableCache +} + +// Cached git repo roots keyed by working directory. Null values indicate non-git directories. +const repoRootCache = new Map() + +/** + * Get the git repository root for a given working directory. + * Result is cached per cwd (including negative results for non-git directories). + */ +async function getGitRepoRoot(cwd: string): Promise { + if (repoRootCache.has(cwd)) { + return repoRootCache.get(cwd)! + } + try { + const { stdout } = await execAsync("git rev-parse --show-toplevel", { cwd }) + const root = stdout.trim() + repoRootCache.set(cwd, root) + return root + } catch { + repoRootCache.set(cwd, null) + return null + } +} + +/** + * Spawn a process and pipe input to its stdin. + */ +function execWithStdin(command: string, args: string[], input: string, cwd: string): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { cwd, stdio: ["pipe", "ignore", "ignore"] }) + proc.stdin.write(input) + proc.stdin.end() + proc.on("close", (code) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`git-ai exited with code ${code}`)) + } + }) + proc.on("error", reject) + }) +} + +/** + * Call git-ai checkpoint before a file edit. + * Marks any uncommitted changes as human-generated. + * + * Must be awaited before the file save to correctly attribute prior changes. + * Never throws — failures are logged and silently ignored. + */ +export async function checkpointBeforeEdit(cwd: string, filepaths: string[]): Promise { + try { + if (!(await isGitAiAvailable())) { + return + } + const repoRoot = await getGitRepoRoot(cwd) + if (!repoRoot) { + return + } + + const payload = JSON.stringify({ + type: "human", + repo_working_dir: repoRoot, + will_edit_filepaths: filepaths, + }) + + await execWithStdin("git-ai", ["checkpoint", "agent-v1", "--hook-input", "stdin"], payload, repoRoot) + } catch (error) { + console.error("[git-ai] checkpointBeforeEdit failed:", error) + } +} + +/** + * Call git-ai checkpoint after a file edit. + * Marks the new changes as AI-generated with full session context. + * + * Never throws — failures are logged and silently ignored. + */ +export async function checkpointAfterEdit(cwd: string, task: GitAiTaskContext, filepaths: string[]): Promise { + try { + if (!(await isGitAiAvailable())) { + return + } + const repoRoot = await getGitRepoRoot(cwd) + if (!repoRoot) { + return + } + + const payload = JSON.stringify({ + type: "ai_agent", + repo_working_dir: repoRoot, + transcript: { + messages: buildTranscript(task.apiConversationHistory), + }, + agent_name: "roo-code", + model: task.api.getModel().id.split("/").pop() ?? task.api.getModel().id, + conversation_id: task.taskId, + edited_filepaths: filepaths, + }) + + await execWithStdin("git-ai", ["checkpoint", "agent-v1", "--hook-input", "stdin"], payload, repoRoot) + } catch (error) { + console.error("[git-ai] checkpointAfterEdit failed:", error) + } +} + +/** + * Reset cached state. Exported for testing. + */ +export function resetGitAiCache(): void { + gitAiAvailableCache = null + repoRootCache.clear() +} diff --git a/src/services/git-ai/transcript.ts b/src/services/git-ai/transcript.ts new file mode 100644 index 00000000000..6558fc25a9d --- /dev/null +++ b/src/services/git-ai/transcript.ts @@ -0,0 +1,74 @@ +import type { ApiMessage } from "../../core/task-persistence/apiMessages" + +export interface GitAiMessage { + type: "user" | "assistant" | "tool_use" + text?: string + name?: string + input?: Record + timestamp?: string +} + +/** + * Roo Code injects blocks into user messages containing + * workspace file listings, VS Code state, timestamps, etc. These are noisy + * and not useful for git-ai attribution. + */ +function isEnvironmentDetails(text: string): boolean { + return text.trimStart().startsWith("") +} + +/** + * Convert ApiMessage[] to git-ai transcript format. + * Per the git-ai spec, only user, assistant, and tool_use messages are included. + * tool_result blocks are excluded due to size, staleness, and security concerns. + * Auto-injected environment_details blocks are also filtered out. + * + * Truncates to the last 50 messages to keep the payload reasonable. + */ +export function buildTranscript(history: ApiMessage[]): GitAiMessage[] { + const messages: GitAiMessage[] = [] + const recentHistory = history.slice(-50) + + for (const msg of recentHistory) { + const timestamp = msg.ts ? new Date(msg.ts).toISOString() : undefined + + if (typeof msg.content === "string") { + if (msg.role === "user" && isEnvironmentDetails(msg.content)) { + continue + } + messages.push({ + type: msg.role === "user" ? "user" : "assistant", + text: msg.content, + timestamp, + }) + continue + } + + if (!Array.isArray(msg.content)) { + continue + } + + for (const block of msg.content) { + if (block.type === "text") { + if (msg.role === "user" && isEnvironmentDetails(block.text)) { + continue + } + messages.push({ + type: msg.role === "user" ? "user" : "assistant", + text: block.text, + timestamp, + }) + } else if (block.type === "tool_use") { + messages.push({ + type: "tool_use", + name: block.name, + input: block.input as Record, + timestamp, + }) + } + // Skip tool_result, image, and other block types + } + } + + return messages +} From eb25ab638940a3959c3e4aa05af34d63c007fb4e Mon Sep 17 00:00:00 2001 From: Aidan Cunniffe Date: Sat, 21 Mar 2026 12:29:06 -0400 Subject: [PATCH 2/3] rename checkpoint to gitAIBefore/AfterEdit --- src/core/tools/ApplyDiffTool.ts | 10 ++++---- src/core/tools/ApplyPatchTool.ts | 16 ++++++------- src/core/tools/EditFileTool.ts | 6 ++--- src/core/tools/EditTool.ts | 6 ++--- src/core/tools/SearchReplaceTool.ts | 6 ++--- src/core/tools/WriteToFileTool.ts | 22 ++++++++--------- src/services/git-ai/__tests__/index.spec.ts | 26 ++++++++++----------- src/services/git-ai/index.ts | 10 ++++---- 8 files changed, 51 insertions(+), 51 deletions(-) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index fe11aa806db..f519524b54f 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -14,7 +14,7 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import type { ToolUse } from "../../shared/tools" -import { checkpointBeforeEdit, checkpointAfterEdit } from "../../services/git-ai" +import { gitAiBeforeEdit, gitAiAfterEdit } from "../../services/git-ai" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -178,7 +178,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { // Save directly without showing diff view or opening the file task.diffViewProvider.editType = "modify" task.diffViewProvider.originalContent = originalContent - await checkpointBeforeEdit(task.cwd, [relPath]) + await gitAiBeforeEdit(task.cwd, [relPath]) await task.diffViewProvider.saveDirectly( relPath, diffResult.content, @@ -186,10 +186,10 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { diagnosticsEnabled, writeDelayMs, ) - await checkpointAfterEdit(task.cwd, task, [relPath]) + await gitAiAfterEdit(task.cwd, task, [relPath]) } else { // Original behavior with diff view - await checkpointBeforeEdit(task.cwd, [relPath]) + await gitAiBeforeEdit(task.cwd, [relPath]) // Show diff view before asking for approval task.diffViewProvider.editType = "modify" @@ -228,7 +228,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { // Call saveChanges to update the DiffViewProvider properties await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) - await checkpointAfterEdit(task.cwd, task, [relPath]) + await gitAiAfterEdit(task.cwd, task, [relPath]) } // Track file edit operation diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index c847bd6ae23..3589df36b22 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -11,7 +11,7 @@ import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath } from "../../utils/fs" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" -import { checkpointBeforeEdit, checkpointAfterEdit } from "../../services/git-ai" +import { gitAiBeforeEdit, gitAiAfterEdit } from "../../services/git-ai" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" @@ -197,7 +197,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { diffStats, } satisfies ClineSayTool) - await checkpointBeforeEdit(task.cwd, [relPath]) + await gitAiBeforeEdit(task.cwd, [relPath]) // Show diff view if focus disruption prevention is disabled if (!isPreventFocusDisruptionEnabled) { @@ -223,7 +223,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } else { await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } - await checkpointAfterEdit(task.cwd, task, [relPath]) + await gitAiAfterEdit(task.cwd, task, [relPath]) // Track file edit operation await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) @@ -278,7 +278,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } // Delete the file - await checkpointBeforeEdit(task.cwd, [relPath]) + await gitAiBeforeEdit(task.cwd, [relPath]) try { await fs.unlink(absolutePath) } catch (error) { @@ -287,7 +287,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { pushToolResult(formatResponse.toolError(errorMessage)) return } - await checkpointAfterEdit(task.cwd, task, [relPath]) + await gitAiAfterEdit(task.cwd, task, [relPath]) task.didEditFile = true pushToolResult(`Successfully deleted ${relPath}`) @@ -359,7 +359,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { diffStats, } satisfies ClineSayTool) - await checkpointBeforeEdit(task.cwd, [relPath]) + await gitAiBeforeEdit(task.cwd, [relPath]) // Show diff view if focus disruption prevention is disabled if (!isPreventFocusDisruptionEnabled) { @@ -438,7 +438,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } catch (error) { console.error(`Failed to delete original file after move: ${error}`) } - await checkpointAfterEdit(task.cwd, task, [relPath, change.movePath]) + await gitAiAfterEdit(task.cwd, task, [relPath, change.movePath]) await task.fileContextTracker.trackFileContext(change.movePath, "roo_edited" as RecordSource) } else { @@ -448,7 +448,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } else { await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } - await checkpointAfterEdit(task.cwd, task, [relPath]) + await gitAiAfterEdit(task.cwd, task, [relPath]) await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) } diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index fbf946119d8..52dcea32781 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -13,7 +13,7 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import type { ToolUse } from "../../shared/tools" -import { checkpointBeforeEdit, checkpointAfterEdit } from "../../services/git-ai" +import { gitAiBeforeEdit, gitAiAfterEdit } from "../../services/git-ai" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -417,7 +417,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { diffStats, } satisfies ClineSayTool) - await checkpointBeforeEdit(task.cwd, [relPath]) + await gitAiBeforeEdit(task.cwd, [relPath]) // Show diff view if focus disruption prevention is disabled if (!isPreventFocusDisruptionEnabled) { @@ -452,7 +452,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { // Call saveChanges to update the DiffViewProvider properties await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } - await checkpointAfterEdit(task.cwd, task, [relPath]) + await gitAiAfterEdit(task.cwd, task, [relPath]) // Track file edit operation if (relPath) { diff --git a/src/core/tools/EditTool.ts b/src/core/tools/EditTool.ts index c91ed727d91..f6b65fb5f9e 100644 --- a/src/core/tools/EditTool.ts +++ b/src/core/tools/EditTool.ts @@ -13,7 +13,7 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import type { ToolUse } from "../../shared/tools" -import { checkpointBeforeEdit, checkpointAfterEdit } from "../../services/git-ai" +import { gitAiBeforeEdit, gitAiAfterEdit } from "../../services/git-ai" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -192,7 +192,7 @@ export class EditTool extends BaseTool<"edit"> { diffStats, } satisfies ClineSayTool) - await checkpointBeforeEdit(task.cwd, [relPath]) + await gitAiBeforeEdit(task.cwd, [relPath]) // Show diff view if focus disruption prevention is disabled if (!isPreventFocusDisruptionEnabled) { @@ -221,7 +221,7 @@ export class EditTool extends BaseTool<"edit"> { // Call saveChanges to update the DiffViewProvider properties await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } - await checkpointAfterEdit(task.cwd, task, [relPath]) + await gitAiAfterEdit(task.cwd, task, [relPath]) // Track file edit operation if (relPath) { diff --git a/src/core/tools/SearchReplaceTool.ts b/src/core/tools/SearchReplaceTool.ts index 212f46a2e0f..221021279bc 100644 --- a/src/core/tools/SearchReplaceTool.ts +++ b/src/core/tools/SearchReplaceTool.ts @@ -13,7 +13,7 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import type { ToolUse } from "../../shared/tools" -import { checkpointBeforeEdit, checkpointAfterEdit } from "../../services/git-ai" +import { gitAiBeforeEdit, gitAiAfterEdit } from "../../services/git-ai" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -188,7 +188,7 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { diffStats, } satisfies ClineSayTool) - await checkpointBeforeEdit(task.cwd, [relPath]) + await gitAiBeforeEdit(task.cwd, [relPath]) // Show diff view if focus disruption prevention is disabled if (!isPreventFocusDisruptionEnabled) { @@ -217,7 +217,7 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { // Call saveChanges to update the DiffViewProvider properties await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } - await checkpointAfterEdit(task.cwd, task, [relPath]) + await gitAiAfterEdit(task.cwd, task, [relPath]) // Track file edit operation if (relPath) { diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 9d32b7c5093..f00379883af 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -16,7 +16,7 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import type { ToolUse } from "../../shared/tools" -import { checkpointBeforeEdit, checkpointAfterEdit } from "../../services/git-ai" +import { gitAiBeforeEdit, gitAiAfterEdit } from "../../services/git-ai" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -27,11 +27,11 @@ interface WriteToFileParams { export class WriteToFileTool extends BaseTool<"write_to_file"> { readonly name = "write_to_file" as const - private didCheckpointBeforeEdit = false + private didGitAiBeforeEdit = false override resetPartialState(): void { super.resetPartialState() - this.didCheckpointBeforeEdit = false + this.didGitAiBeforeEdit = false } async execute(params: WriteToFileParams, task: Task, callbacks: ToolCallbacks): Promise { @@ -141,12 +141,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return } - await checkpointBeforeEdit(task.cwd, [relPath]) + await gitAiBeforeEdit(task.cwd, [relPath]) await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) - await checkpointAfterEdit(task.cwd, task, [relPath]) + await gitAiAfterEdit(task.cwd, task, [relPath]) } else { - if (!this.didCheckpointBeforeEdit) { - await checkpointBeforeEdit(task.cwd, [relPath]) + if (!this.didGitAiBeforeEdit) { + await gitAiBeforeEdit(task.cwd, [relPath]) } if (!task.diffViewProvider.isEditing) { @@ -181,7 +181,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { } await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) - await checkpointAfterEdit(task.cwd, task, [relPath]) + await gitAiAfterEdit(task.cwd, task, [relPath]) } if (relPath) { @@ -261,9 +261,9 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { if (newContent) { if (!task.diffViewProvider.isEditing) { - if (!this.didCheckpointBeforeEdit) { - await checkpointBeforeEdit(task.cwd, [relPath!]) - this.didCheckpointBeforeEdit = true + if (!this.didGitAiBeforeEdit) { + await gitAiBeforeEdit(task.cwd, [relPath!]) + this.didGitAiBeforeEdit = true } await task.diffViewProvider.open(relPath!) } diff --git a/src/services/git-ai/__tests__/index.spec.ts b/src/services/git-ai/__tests__/index.spec.ts index c7e8044b94c..e3a27eac317 100644 --- a/src/services/git-ai/__tests__/index.spec.ts +++ b/src/services/git-ai/__tests__/index.spec.ts @@ -6,8 +6,8 @@ import { EventEmitter } from "events" import { Writable } from "stream" import { - checkpointBeforeEdit, - checkpointAfterEdit, + gitAiBeforeEdit, + gitAiAfterEdit, isGitAiAvailable, resetGitAiCache, } from "../index" @@ -119,11 +119,11 @@ describe("git-ai service", () => { }) }) - describe("checkpointBeforeEdit", () => { + describe("gitAiBeforeEdit", () => { it("no-ops when git-ai is not installed", async () => { mockExecFailure("not found") - await checkpointBeforeEdit("/test/workspace", ["src/file.ts"]) + await gitAiBeforeEdit("/test/workspace", ["src/file.ts"]) expect(childProcess.spawn).not.toHaveBeenCalled() }) @@ -146,7 +146,7 @@ describe("git-ai service", () => { }, ) - await checkpointBeforeEdit("/not/a/repo", ["src/file.ts"]) + await gitAiBeforeEdit("/not/a/repo", ["src/file.ts"]) expect(childProcess.spawn).not.toHaveBeenCalled() }) @@ -169,7 +169,7 @@ describe("git-ai service", () => { const { proc, stdinData } = createMockProcess() ;(childProcess.spawn as any).mockReturnValue(proc) - const promise = checkpointBeforeEdit("/test/workspace", ["src/file.ts"]) + const promise = gitAiBeforeEdit("/test/workspace", ["src/file.ts"]) // Simulate successful exit setTimeout(() => proc.emit("close", 0), 10) @@ -191,12 +191,12 @@ describe("git-ai service", () => { }) }) - describe("checkpointAfterEdit", () => { + describe("gitAiAfterEdit", () => { it("no-ops when git-ai is not installed", async () => { mockExecFailure("not found") const task = createMockTask() - await checkpointAfterEdit("/test/workspace", task, ["src/file.ts"]) + await gitAiAfterEdit("/test/workspace", task, ["src/file.ts"]) expect(childProcess.spawn).not.toHaveBeenCalled() }) @@ -219,7 +219,7 @@ describe("git-ai service", () => { ;(childProcess.spawn as any).mockReturnValue(proc) const task = createMockTask() - const promise = checkpointAfterEdit("/test/workspace", task, ["src/file.ts"]) + const promise = gitAiAfterEdit("/test/workspace", task, ["src/file.ts"]) setTimeout(() => proc.emit("close", 0), 10) @@ -239,7 +239,7 @@ describe("git-ai service", () => { }) describe("error handling", () => { - it("checkpointBeforeEdit never throws", async () => { + it("gitAiBeforeEdit never throws", async () => { let callCount = 0 ;(childProcess.exec as any).mockImplementation( (_cmd: string, _opts: any, callback?: Function) => { @@ -256,7 +256,7 @@ describe("git-ai service", () => { const { proc } = createMockProcess() ;(childProcess.spawn as any).mockReturnValue(proc) - const promise = checkpointBeforeEdit("/test/workspace", ["file.ts"]) + const promise = gitAiBeforeEdit("/test/workspace", ["file.ts"]) // Simulate failure exit setTimeout(() => proc.emit("close", 1), 10) @@ -265,7 +265,7 @@ describe("git-ai service", () => { await expect(promise).resolves.toBeUndefined() }) - it("checkpointAfterEdit never throws", async () => { + it("gitAiAfterEdit never throws", async () => { let callCount = 0 ;(childProcess.exec as any).mockImplementation( (_cmd: string, _opts: any, callback?: Function) => { @@ -287,7 +287,7 @@ describe("git-ai service", () => { // Should not throw await expect( - checkpointAfterEdit("/test/workspace", task, ["file.ts"]), + gitAiAfterEdit("/test/workspace", task, ["file.ts"]), ).resolves.toBeUndefined() }) }) diff --git a/src/services/git-ai/index.ts b/src/services/git-ai/index.ts index e3d70d2f69e..11708f3d35e 100644 --- a/src/services/git-ai/index.ts +++ b/src/services/git-ai/index.ts @@ -8,7 +8,7 @@ import { buildTranscript } from "./transcript" const execAsync = promisify(exec) /** - * Narrow interface for the Task data needed by checkpointAfterEdit. + * Narrow interface for the Task data needed by gitAiAfterEdit. * Avoids coupling the git-ai service to the full Task class. */ export interface GitAiTaskContext { @@ -87,7 +87,7 @@ function execWithStdin(command: string, args: string[], input: string, cwd: stri * Must be awaited before the file save to correctly attribute prior changes. * Never throws — failures are logged and silently ignored. */ -export async function checkpointBeforeEdit(cwd: string, filepaths: string[]): Promise { +export async function gitAiBeforeEdit(cwd: string, filepaths: string[]): Promise { try { if (!(await isGitAiAvailable())) { return @@ -105,7 +105,7 @@ export async function checkpointBeforeEdit(cwd: string, filepaths: string[]): Pr await execWithStdin("git-ai", ["checkpoint", "agent-v1", "--hook-input", "stdin"], payload, repoRoot) } catch (error) { - console.error("[git-ai] checkpointBeforeEdit failed:", error) + console.error("[git-ai] gitAiBeforeEdit failed:", error) } } @@ -115,7 +115,7 @@ export async function checkpointBeforeEdit(cwd: string, filepaths: string[]): Pr * * Never throws — failures are logged and silently ignored. */ -export async function checkpointAfterEdit(cwd: string, task: GitAiTaskContext, filepaths: string[]): Promise { +export async function gitAiAfterEdit(cwd: string, task: GitAiTaskContext, filepaths: string[]): Promise { try { if (!(await isGitAiAvailable())) { return @@ -139,7 +139,7 @@ export async function checkpointAfterEdit(cwd: string, task: GitAiTaskContext, f await execWithStdin("git-ai", ["checkpoint", "agent-v1", "--hook-input", "stdin"], payload, repoRoot) } catch (error) { - console.error("[git-ai] checkpointAfterEdit failed:", error) + console.error("[git-ai] gitAiAfterEdit failed:", error) } } From 5c031ad9e2a41d7e3e51f402c6c83dc05cb6d46e Mon Sep 17 00:00:00 2001 From: Aidan Cunniffe Date: Sat, 21 Mar 2026 13:00:05 -0400 Subject: [PATCH 3/3] fix lints --- src/services/git-ai/__tests__/index.spec.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/services/git-ai/__tests__/index.spec.ts b/src/services/git-ai/__tests__/index.spec.ts index e3a27eac317..8e2e896ab6d 100644 --- a/src/services/git-ai/__tests__/index.spec.ts +++ b/src/services/git-ai/__tests__/index.spec.ts @@ -50,7 +50,7 @@ function createMockProcess() { function mockExecSuccess(stdout: string) { ;(childProcess.exec as any).mockImplementation( - (_cmd: string, _opts: any, callback?: Function) => { + (_cmd: string, _opts: any, callback?: (...args: any[]) => void) => { const cb = callback || _opts if (typeof cb === "function") { cb(null, stdout, "") @@ -61,7 +61,7 @@ function mockExecSuccess(stdout: string) { function mockExecFailure(message = "not found") { ;(childProcess.exec as any).mockImplementation( - (_cmd: string, _opts: any, callback?: Function) => { + (_cmd: string, _opts: any, callback?: (...args: any[]) => void) => { const cb = callback || _opts if (typeof cb === "function") { cb(new Error(message), "", "") @@ -133,7 +133,7 @@ describe("git-ai service", () => { // Second call: git rev-parse fails let callCount = 0 ;(childProcess.exec as any).mockImplementation( - (_cmd: string, _opts: any, callback?: Function) => { + (_cmd: string, _opts: any, callback?: (...args: any[]) => void) => { const cb = callback || _opts callCount++ if (callCount === 1) { @@ -155,7 +155,7 @@ describe("git-ai service", () => { // which git-ai succeeds, git rev-parse succeeds let callCount = 0 ;(childProcess.exec as any).mockImplementation( - (_cmd: string, _opts: any, callback?: Function) => { + (_cmd: string, _opts: any, callback?: (...args: any[]) => void) => { const cb = callback || _opts callCount++ if (callCount <= 1) { @@ -204,7 +204,7 @@ describe("git-ai service", () => { it("sends correct post-edit payload via stdin", async () => { let callCount = 0 ;(childProcess.exec as any).mockImplementation( - (_cmd: string, _opts: any, callback?: Function) => { + (_cmd: string, _opts: any, callback?: (...args: any[]) => void) => { const cb = callback || _opts callCount++ if (callCount <= 1) { @@ -242,7 +242,7 @@ describe("git-ai service", () => { it("gitAiBeforeEdit never throws", async () => { let callCount = 0 ;(childProcess.exec as any).mockImplementation( - (_cmd: string, _opts: any, callback?: Function) => { + (_cmd: string, _opts: any, callback?: (...args: any[]) => void) => { const cb = callback || _opts callCount++ if (callCount <= 1) { @@ -268,7 +268,7 @@ describe("git-ai service", () => { it("gitAiAfterEdit never throws", async () => { let callCount = 0 ;(childProcess.exec as any).mockImplementation( - (_cmd: string, _opts: any, callback?: Function) => { + (_cmd: string, _opts: any, callback?: (...args: any[]) => void) => { const cb = callback || _opts callCount++ if (callCount <= 1) {