This commit is contained in:
Aidan Cunniffe 2026-04-17 08:26:01 +00:00 committed by GitHub
commit 6f426d864a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 834 additions and 0 deletions

View file

@ -14,6 +14,8 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats"
import type { ToolUse } from "../../shared/tools"
import { gitAiBeforeEdit, gitAiAfterEdit } 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 gitAiBeforeEdit(task.cwd, [relPath])
await task.diffViewProvider.saveDirectly(
relPath,
diffResult.content,
@ -183,8 +186,11 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> {
diagnosticsEnabled,
writeDelayMs,
)
await gitAiAfterEdit(task.cwd, task, [relPath])
} else {
// Original behavior with diff view
await gitAiBeforeEdit(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 gitAiAfterEdit(task.cwd, task, [relPath])
}
// Track file edit operation

View file

@ -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 { gitAiBeforeEdit, gitAiAfterEdit } 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 gitAiBeforeEdit(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 gitAiAfterEdit(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 gitAiBeforeEdit(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 gitAiAfterEdit(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 gitAiBeforeEdit(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 gitAiAfterEdit(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 gitAiAfterEdit(task.cwd, task, [relPath])
await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource)
}

View file

@ -13,6 +13,8 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats"
import type { ToolUse } from "../../shared/tools"
import { gitAiBeforeEdit, gitAiAfterEdit } 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 gitAiBeforeEdit(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 gitAiAfterEdit(task.cwd, task, [relPath])
// Track file edit operation
if (relPath) {

View file

@ -13,6 +13,8 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats"
import type { ToolUse } from "../../shared/tools"
import { gitAiBeforeEdit, gitAiAfterEdit } 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 gitAiBeforeEdit(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 gitAiAfterEdit(task.cwd, task, [relPath])
// Track file edit operation
if (relPath) {

View file

@ -13,6 +13,8 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats"
import type { ToolUse } from "../../shared/tools"
import { gitAiBeforeEdit, gitAiAfterEdit } 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 gitAiBeforeEdit(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 gitAiAfterEdit(task.cwd, task, [relPath])
// Track file edit operation
if (relPath) {

View file

@ -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 { gitAiBeforeEdit, gitAiAfterEdit } 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 didGitAiBeforeEdit = false
override resetPartialState(): void {
super.resetPartialState()
this.didGitAiBeforeEdit = false
}
async execute(params: WriteToFileParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
const { pushToolResult, handleError, askApproval } = callbacks
@ -133,8 +141,14 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
return
}
await gitAiBeforeEdit(task.cwd, [relPath])
await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs)
await gitAiAfterEdit(task.cwd, task, [relPath])
} else {
if (!this.didGitAiBeforeEdit) {
await gitAiBeforeEdit(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 gitAiAfterEdit(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.didGitAiBeforeEdit) {
await gitAiBeforeEdit(task.cwd, [relPath!])
this.didGitAiBeforeEdit = true
}
await task.diffViewProvider.open(relPath!)
}

View file

@ -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 {
gitAiBeforeEdit,
gitAiAfterEdit,
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?: (...args: any[]) => void) => {
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?: (...args: any[]) => void) => {
const cb = callback || _opts
if (typeof cb === "function") {
cb(new Error(message), "", "")
}
},
)
}
function createMockTask(overrides: Record<string, any> = {}) {
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("gitAiBeforeEdit", () => {
it("no-ops when git-ai is not installed", async () => {
mockExecFailure("not found")
await gitAiBeforeEdit("/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?: (...args: any[]) => void) => {
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 gitAiBeforeEdit("/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?: (...args: any[]) => void) => {
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 = gitAiBeforeEdit("/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("gitAiAfterEdit", () => {
it("no-ops when git-ai is not installed", async () => {
mockExecFailure("not found")
const task = createMockTask()
await gitAiAfterEdit("/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?: (...args: any[]) => void) => {
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 = gitAiAfterEdit("/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("gitAiBeforeEdit never throws", async () => {
let callCount = 0
;(childProcess.exec as any).mockImplementation(
(_cmd: string, _opts: any, callback?: (...args: any[]) => void) => {
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 = gitAiBeforeEdit("/test/workspace", ["file.ts"])
// Simulate failure exit
setTimeout(() => proc.emit("close", 1), 10)
// Should not throw
await expect(promise).resolves.toBeUndefined()
})
it("gitAiAfterEdit never throws", async () => {
let callCount = 0
;(childProcess.exec as any).mockImplementation(
(_cmd: string, _opts: any, callback?: (...args: any[]) => void) => {
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(
gitAiAfterEdit("/test/workspace", task, ["file.ts"]),
).resolves.toBeUndefined()
})
})
})

View file

@ -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: "<environment_details>\n# VSCode Visible Files\nfirst-20-primes.txt\n\n# Current Time\n...\n</environment_details>",
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: "<user_message>\nfix the bug\n</user_message>" },
{ type: "text", text: "<environment_details>\n# VSCode Visible Files\n...</environment_details>" },
],
ts: 1700000000000,
},
]
const result = buildTranscript(history)
expect(result).toEqual([
{
type: "user",
text: "<user_message>\nfix the bug\n</user_message>",
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: "<environment_details>\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([])
})
})

View file

@ -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 gitAiAfterEdit.
* 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<boolean> {
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<string, string | null>()
/**
* 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<string | null> {
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<void> {
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 gitAiBeforeEdit(cwd: string, filepaths: string[]): Promise<void> {
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] gitAiBeforeEdit 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 gitAiAfterEdit(cwd: string, task: GitAiTaskContext, filepaths: string[]): Promise<void> {
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] gitAiAfterEdit failed:", error)
}
}
/**
* Reset cached state. Exported for testing.
*/
export function resetGitAiCache(): void {
gitAiAvailableCache = null
repoRootCache.clear()
}

View file

@ -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<string, unknown>
timestamp?: string
}
/**
* Roo Code injects <environment_details> 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("<environment_details>")
}
/**
* 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<string, unknown>,
timestamp,
})
}
// Skip tool_result, image, and other block types
}
}
return messages
}