Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 38 additions & 31 deletions agent-support/opencode/git-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ import { dirname } from "path"
// Absolute path to git-ai binary, replaced at install time by `git-ai install-hooks`
const GIT_AI_BIN = "__GIT_AI_BINARY_PATH__"

// Tools that modify files and should be tracked
const FILE_EDIT_TOOLS = ["edit", "write"]
// Bash/shell tool names that need special checkpoint handling
const BASH_TOOLS = ["bash", "shell"]

export const GitAiPlugin: Plugin = async (ctx) => {
const { $ } = ctx
Expand All @@ -44,7 +44,7 @@ export const GitAiPlugin: Plugin = async (ctx) => {

// Track pending edits by callID so we can reference them in the after hook
// Stores { filePath, repoDir, sessionID } for each pending edit
const pendingEdits = new Map<string, { filePath: string; repoDir: string; sessionID: string }>()
const pendingEdits = new Map<string, { filePath: string; repoDir: string; sessionID: string; bashCommand?: string }>()

// Helper to find git repo root from a file path
const findGitRepo = async (filePath: string): Promise<string | null> => {
Expand All @@ -61,74 +61,81 @@ export const GitAiPlugin: Plugin = async (ctx) => {

return {
"tool.execute.before": async (input, output) => {
// Only intercept file editing tools
if (!FILE_EDIT_TOOLS.includes(input.tool)) {
return
}

// Extract file path from tool arguments (args are in output, not input)
const filePath = output.args?.filePath as string | undefined
if (!filePath) {
return

// For bash/shell tools, extract the command for blacklist evaluation
const isBashTool = BASH_TOOLS.includes(input.tool)
const bashCommand = isBashTool
? (output.args?.command as string | undefined) ?? (output.args?.input as string | undefined)
: undefined

// Determine the working directory
let repoDir: string | null = null
if (filePath) {
repoDir = await findGitRepo(filePath)
} else if (output.args?.cwd) {
repoDir = await findGitRepo(output.args.cwd as string)
} else {
// Try process cwd as fallback
try {
const result = await $`git rev-parse --show-toplevel`.quiet()
repoDir = result.stdout.toString().trim() || null
} catch {
// Not in a git repo
}
}

// Find the git repo for this file
const repoDir = await findGitRepo(filePath)
if (!repoDir) {
// File is not in a git repo, skip silently
return
}

// Store filePath, repoDir, and sessionID for the after hook
pendingEdits.set(input.callID, { filePath, repoDir, sessionID: input.sessionID })
// Store info for the after hook
pendingEdits.set(input.callID, { filePath: filePath ?? "", repoDir, sessionID: input.sessionID, bashCommand })

try {
// Create human checkpoint before AI edit
// This marks any changes since the last checkpoint as human-authored
const hookInput = JSON.stringify({
hook_event_name: "PreToolUse",
tool_name: input.tool,
session_id: input.sessionID,
cwd: repoDir,
tool_input: { filePath },
tool_input: {
...(filePath ? { filePath } : {}),
...(bashCommand ? { command: bashCommand } : {}),
},
})

await $`echo ${hookInput} | ${GIT_AI_BIN} checkpoint opencode --hook-input stdin`.quiet()
} catch (error) {
// Log to stderr for debugging, but don't throw - git-ai errors shouldn't break the agent
console.error("[git-ai] Failed to create human checkpoint:", String(error))
}
},

"tool.execute.after": async (input, _output) => {
// Only intercept file editing tools
if (!FILE_EDIT_TOOLS.includes(input.tool)) {
return
}

// Get the filePath and repoDir we stored in the before hook
// Get the info we stored in the before hook
const editInfo = pendingEdits.get(input.callID)
pendingEdits.delete(input.callID)

if (!editInfo) {
return
}

const { filePath, repoDir, sessionID } = editInfo
const { filePath, repoDir, sessionID, bashCommand } = editInfo

try {
// Create AI checkpoint after edit
// This marks the changes made by this tool call as AI-authored
// Transcript is fetched from OpenCode's local storage by the preset
const hookInput = JSON.stringify({
hook_event_name: "PostToolUse",
tool_name: input.tool,
session_id: sessionID,
cwd: repoDir,
tool_input: { filePath },
tool_input: {
...(filePath ? { filePath } : {}),
...(bashCommand ? { command: bashCommand } : {}),
},
})

await $`echo ${hookInput} | ${GIT_AI_BIN} checkpoint opencode --hook-input stdin`.quiet()
} catch (error) {
// Log to stderr for debugging, but don't throw - git-ai errors shouldn't break the agent
console.error("[git-ai] Failed to create AI checkpoint:", String(error))
}
},
Expand Down
Loading
Loading