Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ fe2c4c8 (claude [session_id] 2025-12-02 19:25:13 -0500 142) let fro

### Supported Agents

<img src="assets/docs/badges/claude_code.svg" alt="Claude Code" height="30" /> <img src="assets/docs/badges/codex-black.svg" alt="Codex" height="30" /> <img src="assets/docs/badges/cursor.svg" alt="Cursor" height="30" /> <img src="assets/docs/badges/opencode.svg" alt="OpenCode" height="30" /> <img src="assets/docs/badges/windsurf.svg" alt="Windsurf" height="30" /> <img src="assets/docs/badges/amp.svg" alt="Amp" height="30" /> <img src="assets/docs/badges/gemini.svg" alt="Gemini" height="30" /> <img src="assets/docs/badges/copilot.svg" alt="GitHub Copilot" height="30" /> <img src="assets/docs/badges/continue.svg" alt="Continue" height="30" /> <img src="assets/docs/badges/droid.svg" alt="Droid" height="30" /> <img src="assets/docs/badges/junie_white.svg" alt="Junie" height="30" /> <img src="assets/docs/badges/rovodev.svg" alt="Rovo Dev" height="30" />
<img src="assets/docs/badges/claude_code.svg" alt="Claude Code" height="30" /> <img src="assets/docs/badges/codex-black.svg" alt="Codex" height="30" /> <img src="assets/docs/badges/cursor.svg" alt="Cursor" height="30" /> <img src="assets/docs/badges/opencode.svg" alt="OpenCode" height="30" /> <img src="assets/docs/badges/windsurf.svg" alt="Windsurf" height="30" /> <img src="assets/docs/badges/amp.svg" alt="Amp" height="30" /> <img src="assets/docs/badges/gemini.svg" alt="Gemini" height="30" /> <img src="assets/docs/badges/copilot.svg" alt="GitHub Copilot" height="30" /> <img src="assets/docs/badges/continue.svg" alt="Continue" height="30" /> <img src="assets/docs/badges/droid.svg" alt="Droid" height="30" /> <img src="assets/docs/badges/junie_white.svg" alt="Junie" height="30" /> <img src="assets/docs/badges/rovodev.svg" alt="Rovo Dev" height="30" /> <img src="assets/docs/badges/firebender.svg" alt="Firebender" height="30" />

> [+ Add support for another agent](https://usegitai.com/docs/cli/add-your-agent)

Expand Down
22 changes: 22 additions & 0 deletions assets/docs/badges/firebender.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
212 changes: 212 additions & 0 deletions src/commands/checkpoint_agent/agent_presets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::{
error::GitAiError,
git::repository::find_repository_for_file,
observability::log_error,
utils::normalize_to_posix,
};
use chrono::{TimeZone, Utc};
use dirs;
Expand Down Expand Up @@ -3648,3 +3649,214 @@ impl AgentCheckpointPreset for AiTabPreset {
})
}
}

// Firebender to checkpoint preset
pub struct FirebenderPreset;

#[derive(Debug, Deserialize)]
struct FirebenderHookInput {
hook_event_name: String,
model: String,
repo_working_dir: Option<String>,
workspace_roots: Option<Vec<String>>,
tool_name: Option<String>,
tool_input: Option<serde_json::Value>,
completion_id: Option<String>,
dirty_files: Option<HashMap<String, String>>,
}

impl FirebenderPreset {
fn push_unique_path(paths: &mut Vec<String>, candidate: &str) {
let trimmed = candidate.trim();
if !trimmed.is_empty() && !paths.iter().any(|path| path == trimmed) {
paths.push(trimmed.to_string());
}
}

fn normalize_hook_path(raw_path: &str, cwd: &str) -> Option<String> {
let trimmed = raw_path.trim();
if trimmed.is_empty() {
return None;
}

let normalized_path = normalize_to_posix(trimmed);
let normalized_cwd = normalize_to_posix(cwd.trim())
.trim_end_matches('/')
.to_string();

if normalized_cwd.is_empty() {
return Some(normalized_path);
}

let relative = if normalized_path == normalized_cwd {
String::new()
Comment on lines +3691 to +3692
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 normalize_hook_path returns Some("") (empty string path) when file path equals repo working dir

When normalized_path == normalized_cwd (i.e., the tool reports the repo directory itself as a file path), normalize_hook_path returns Some(String::new()) at line 3691-3692. This empty string passes through the filter_map at agent_presets.rs:3813 and ends up in edited_filepaths or will_edit_filepaths. Downstream consumers of these path lists would encounter an empty string as a file path, which is semantically invalid. The function should return None for this case, similar to how it handles empty trimmed input at line 3678-3680.

Suggested change
let relative = if normalized_path == normalized_cwd {
String::new()
let relative = if normalized_path == normalized_cwd {
return None;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

} else if let Some(stripped) = normalized_path.strip_prefix(&(normalized_cwd.clone() + "/"))
{
stripped.to_string()
} else {
normalized_path
};

Some(relative)
}

fn extract_patch_paths(patch: &str) -> Vec<String> {
let mut paths = Vec::new();

for line in patch.lines() {
for prefix in [
"*** Add File: ",
"*** Update File: ",
"*** Delete File: ",
"*** Move to: ",
] {
if let Some(path) = line.strip_prefix(prefix) {
Self::push_unique_path(&mut paths, path);
}
}
}

paths
}

// Firebender emits multiple real tool_input shapes across editing flows.
// Normalize direct file fields, structured patch payloads, and raw apply-patch
// text into a single edited-file list for checkpointing.
fn extract_file_paths(tool_input: &serde_json::Value) -> Option<Vec<String>> {
let mut paths = Vec::new();

match tool_input {
serde_json::Value::Object(_) => {
for key in [
"file_path",
"target_file",
"relative_workspace_path",
"path",
] {
if let Some(path) = tool_input.get(key).and_then(|v| v.as_str()) {
Self::push_unique_path(&mut paths, path);
}
}

if let Some(patch) = tool_input.get("patch").and_then(|v| v.as_str()) {
for path in Self::extract_patch_paths(patch) {
Self::push_unique_path(&mut paths, &path);
}
}
}
serde_json::Value::String(raw_patch) => {
for path in Self::extract_patch_paths(raw_patch) {
Self::push_unique_path(&mut paths, &path);
}
}
_ => {}
}

if paths.is_empty() { None } else { Some(paths) }
}
}

impl AgentCheckpointPreset for FirebenderPreset {
fn run(&self, flags: AgentCheckpointFlags) -> Result<AgentRunResult, GitAiError> {
let hook_input_json = flags.hook_input.ok_or_else(|| {
GitAiError::PresetError("hook_input is required for firebender preset".to_string())
})?;

let hook_input: FirebenderHookInput = serde_json::from_str(&hook_input_json)
.map_err(|e| GitAiError::PresetError(format!("Invalid JSON in hook_input: {}", e)))?;

let FirebenderHookInput {
hook_event_name,
model,
repo_working_dir,
workspace_roots,
tool_name,
tool_input,
completion_id,
dirty_files,
} = hook_input;

if hook_event_name == "beforeSubmitPrompt" || hook_event_name == "afterFileEdit" {
std::process::exit(0);
}
Comment on lines +3779 to +3781
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 std::process::exit(0) in trait method terminates process without returning, bypassing all cleanup

FirebenderPreset::run() calls std::process::exit(0) at line 3780 when hook_event_name is "beforeSubmitPrompt" or "afterFileEdit", instead of returning an Err(...). While this pattern was copied from CursorPreset (agent_presets.rs:1404-1407) which has a legacy migration justification ("Legacy hooks no longer installed; exit silently for existing users who haven't reinstalled"), Firebender is a brand-new agent with no legacy hooks to worry about. Calling process::exit(0) inside a -> Result<AgentRunResult, GitAiError> trait method violates the method's return contract, prevents these code paths from being tested (any test hitting this path would kill the entire test process), and bypasses any cleanup or resource finalization in the caller. This should return an Err(GitAiError::PresetError(...)) instead — the handler at src/commands/git_ai_handlers.rs:560-564 already calls std::process::exit(0) on preset errors.

Suggested change
if hook_event_name == "beforeSubmitPrompt" || hook_event_name == "afterFileEdit" {
std::process::exit(0);
}
if hook_event_name == "beforeSubmitPrompt" || hook_event_name == "afterFileEdit" {
return Err(GitAiError::PresetError(format!(
"Skipping Firebender hook for non-checkpointed event '{}'.",
hook_event_name
)));
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


if hook_event_name != "preToolUse" && hook_event_name != "postToolUse" {
return Err(GitAiError::PresetError(format!(
"Invalid hook_event_name: {}. Expected 'preToolUse' or 'postToolUse'",
hook_event_name
)));
}

let tool_name = tool_name.unwrap_or_default();
// Firebender hooks emit canonical hook tool names rather than raw function names.
// For example, `apply_patch` and `local_search_replace` both come through as `Edit`.
if !matches!(
tool_name.as_str(),
"Write" | "Edit" | "Delete" | "RenameSymbol" | "DeleteSymbol"
) {
return Err(GitAiError::PresetError(format!(
"Skipping Firebender hook for non-edit tool_name '{}'.",
tool_name
)));
}

let repo_working_dir = repo_working_dir
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| workspace_roots.and_then(|roots| roots.into_iter().next()));

let tool_input = tool_input.unwrap_or(serde_json::Value::Null);
let file_paths = Self::extract_file_paths(&tool_input).map(|paths| {
if let Some(cwd) = repo_working_dir.as_deref() {
paths
.into_iter()
.filter_map(|path| Self::normalize_hook_path(&path, cwd))
.collect::<Vec<String>>()
} else {
paths
}
});

let model = {
let m = model.trim().to_string();
if m.is_empty() {
"unknown".to_string()
} else {
m
}
};

let agent_id = AgentId {
tool: "firebender".to_string(),
id: format!(
"firebender-{}",
completion_id.unwrap_or_else(|| Utc::now().timestamp_millis().to_string())
),
model,
};

if hook_event_name == "preToolUse" {
return Ok(AgentRunResult {
agent_id,
agent_metadata: None,
checkpoint_kind: CheckpointKind::Human,
transcript: None,
repo_working_dir,
edited_filepaths: None,
will_edit_filepaths: file_paths.clone(),
dirty_files,
});
}

Ok(AgentRunResult {
agent_id,
agent_metadata: None,
checkpoint_kind: CheckpointKind::AiAgent,
transcript: None,
repo_working_dir,
edited_filepaths: file_paths,
will_edit_filepaths: None,
dirty_files,
})
}
}
22 changes: 19 additions & 3 deletions src/commands/git_ai_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ use crate::authorship::working_log::{AgentId, CheckpointKind};
use crate::commands;
use crate::commands::checkpoint_agent::agent_presets::{
AgentCheckpointFlags, AgentCheckpointPreset, AgentRunResult, AiTabPreset, ClaudePreset,
CodexPreset, ContinueCliPreset, CursorPreset, DroidPreset, GeminiPreset, GithubCopilotPreset,
WindsurfPreset,
CodexPreset, ContinueCliPreset, CursorPreset, DroidPreset, FirebenderPreset, GeminiPreset,
GithubCopilotPreset, WindsurfPreset,
};
use crate::commands::checkpoint_agent::agent_v1_preset::AgentV1Preset;
use crate::commands::checkpoint_agent::amp_preset::AmpPreset;
Expand Down Expand Up @@ -257,7 +257,7 @@ fn print_help() {
eprintln!("Commands:");
eprintln!(" checkpoint Checkpoint working changes and attribute author");
eprintln!(
" Presets: claude, codex, continue-cli, cursor, gemini, github-copilot, amp, windsurf, opencode, ai_tab, mock_ai"
" Presets: claude, codex, continue-cli, cursor, gemini, github-copilot, amp, windsurf, opencode, ai_tab, firebender, mock_ai"
);
eprintln!(
" --hook-input <json|stdin> JSON payload required by presets, or 'stdin' to read from stdin"
Expand Down Expand Up @@ -547,6 +547,22 @@ fn handle_checkpoint(args: &[String]) {
}
}
}
"firebender" => {
match FirebenderPreset.run(AgentCheckpointFlags {
hook_input: hook_input.clone(),
}) {
Ok(agent_run) => {
if agent_run.repo_working_dir.is_some() {
repository_working_dir = agent_run.repo_working_dir.clone().unwrap();
}
agent_run_result = Some(agent_run);
}
Err(e) => {
eprintln!("Firebender preset error: {}", e);
std::process::exit(0);
}
}
}
"agent-v1" => {
match AgentV1Preset.run(AgentCheckpointFlags {
hook_input: hook_input.clone(),
Expand Down
Loading
Loading