mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-19 00:01:19 +00:00
Merge 5c031ad9e2 into c4547d25c5
This commit is contained in:
commit
34910c78e3
10 changed files with 834 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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!)
|
||||
}
|
||||
|
||||
|
|
|
|||
294
src/services/git-ai/__tests__/index.spec.ts
Normal file
294
src/services/git-ai/__tests__/index.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
262
src/services/git-ai/__tests__/transcript.spec.ts
Normal file
262
src/services/git-ai/__tests__/transcript.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
152
src/services/git-ai/index.ts
Normal file
152
src/services/git-ai/index.ts
Normal 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()
|
||||
}
|
||||
74
src/services/git-ai/transcript.ts
Normal file
74
src/services/git-ai/transcript.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue