use anyhow::{Result, bail};
use serde_json::{Map, Value, json};
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use std::sync::{Mutex, OnceLock};
static BUT_AVAILABLE: OnceLock<bool> = OnceLock::new();
static MAIN_REPO_PROJECT_CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
thread_local! {
static AUTO_NO_PROJECT_NOTICE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
static ALWAYS_NO_PROJECT_NOTICE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
static AUTO_SETUP_HINT_NOTICE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
fn set_once(notice: &'static std::thread::LocalKey<std::cell::Cell<bool>>) -> bool {
notice.with(|cell| {
if cell.get() {
false
} else {
cell.set(true);
true
}
})
}
const CLAUDE_TOOL_MATCHER: &str = "Edit|MultiEdit|Write";
const CLAUDE_SETTINGS_PATH: &str = ".claude/settings.local.json";
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct TaskWorktreeIntegrationPlan {
pub install_claude_hooks: bool,
pub on_done_command: Option<String>,
pub emit_setup_hint: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Mode {
#[default]
Off,
Auto,
Always,
}
impl Mode {
pub fn from_str(s: &str) -> Result<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"off" => Ok(Self::Off),
"auto" => Ok(Self::Auto),
"always" => Ok(Self::Always),
other => bail!("unknown gitbutler mode '{other}'"),
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Auto => "auto",
Self::Always => "always",
}
}
}
pub fn but_available() -> bool {
#[cfg(test)]
{
return detect_but_available();
}
#[cfg(not(test))]
*BUT_AVAILABLE.get_or_init(detect_but_available)
}
pub fn is_active(mode: Mode) -> bool {
match mode {
Mode::Off => false,
Mode::Auto => but_available(),
Mode::Always => true,
}
}
pub fn ensure_setup(repo_dir: &Path) -> Result<()> {
let output = Command::new("but").arg("setup").current_dir(repo_dir).output()?;
if output.status.success() || setup_already_done(&output) {
return Ok(());
}
bail!("{}", command_failure_message("but setup", &output));
}
pub fn apply_branch(repo_dir: &Path, branch: &str) -> Result<()> {
if !but_available() {
bail!("GitButler CLI not found. Install: https://gitbutler.com");
}
let output = Command::new("but")
.arg("apply")
.arg(branch)
.current_dir(repo_dir)
.output()?;
if output.status.success() {
return Ok(());
}
bail!("{}", command_failure_message(&format!("but apply {branch}"), &output));
}
pub fn agent_uses_claude_hooks(agent_kind: &str) -> bool {
matches!(agent_kind.to_ascii_lowercase().as_str(), "claude" | "claude-code")
}
pub fn install_claude_hooks(worktree: &Path) -> Result<()> {
let settings_path = worktree.join(CLAUDE_SETTINGS_PATH);
let mut root = read_settings_json(&settings_path)?;
let hooks = ensure_object_field(&mut root, "hooks")?;
upsert_hook(hooks, "PreToolUse", Some(CLAUDE_TOOL_MATCHER), "but claude pre-tool");
upsert_hook(hooks, "PostToolUse", Some(CLAUDE_TOOL_MATCHER), "but claude post-tool");
upsert_hook(hooks, "Stop", None, "but claude stop");
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(settings_path, serde_json::to_vec_pretty(&root)?)?;
Ok(())
}
pub fn on_done_command(worktree: &Path) -> String {
format!(
"but -C {} commit -i || true",
shell_quote(&worktree.to_string_lossy())
)
}
pub(crate) fn task_worktree_integration_plan(
repo_dir: &Path,
worktree: &Path,
mode: Mode,
agent_kind: &str,
) -> TaskWorktreeIntegrationPlan {
if !is_active(mode) {
return TaskWorktreeIntegrationPlan::default();
}
if !main_repo_has_project(repo_dir) {
match mode {
Mode::Auto => {
if set_once(&AUTO_NO_PROJECT_NOTICE) {
aid_warn!(
"[aid] gitbutler = auto but main repo has no GitButler project — skipping per-task GitButler hooks"
);
}
return TaskWorktreeIntegrationPlan {
emit_setup_hint: set_once(&AUTO_SETUP_HINT_NOTICE),
..Default::default()
};
}
Mode::Always => {
if set_once(&ALWAYS_NO_PROJECT_NOTICE) {
aid_warn!(
"[aid] gitbutler = always but main repo has no GitButler project — skipping per-task GitButler hooks"
);
}
return TaskWorktreeIntegrationPlan::default();
}
Mode::Off => return TaskWorktreeIntegrationPlan::default(),
}
}
if agent_uses_claude_hooks(agent_kind) {
return TaskWorktreeIntegrationPlan { install_claude_hooks: true, ..Default::default() };
}
TaskWorktreeIntegrationPlan {
on_done_command: Some(on_done_command(worktree)),
..Default::default()
}
}
pub(crate) fn main_repo_has_project(repo_dir: &Path) -> bool {
#[cfg(test)]
if let Ok(value) = std::env::var("AID_GITBUTLER_TEST_PROJECT_PRESENT") {
return matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes");
}
if !but_available() {
return false;
}
let key = repo_dir.canonicalize().unwrap_or_else(|_| repo_dir.to_path_buf()).to_string_lossy().to_string();
let cache = MAIN_REPO_PROJECT_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(status) = cache.lock().ok().and_then(|cache| cache.get(&key).copied()) {
return status;
}
let status = Command::new("but")
.args(["status", "--json"])
.current_dir(repo_dir)
.output()
.map(|output| output.status.success())
.unwrap_or(false);
if let Ok(mut cache) = cache.lock() {
cache.insert(key, status);
}
status
}
fn detect_but_available() -> bool {
#[cfg(test)]
{
return std::env::var("AID_GITBUTLER_TEST_PRESENT")
.map(|value| matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false);
}
#[cfg(not(test))]
Command::new("but")
.arg("--version")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn setup_already_done(output: &std::process::Output) -> bool {
let message = command_failure_message("but setup", output).to_ascii_lowercase();
message.contains("already set up") || message.contains("already setup")
}
fn command_failure_message(command: &str, output: &std::process::Output) -> String {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
match (stdout.is_empty(), stderr.is_empty()) {
(true, true) => format!("{command} failed with status {}", output.status),
(false, true) => format!("{command} failed: {stdout}"),
(true, false) => format!("{command} failed: {stderr}"),
(false, false) => format!("{command} failed: {stderr} ({stdout})"),
}
}
fn read_settings_json(path: &Path) -> Result<Value> {
if !path.exists() {
return Ok(Value::Object(Map::new()));
}
let value = serde_json::from_slice::<Value>(&std::fs::read(path)?)?;
if value.is_object() {
Ok(value)
} else {
bail!("{} must contain a JSON object", path.display());
}
}
fn ensure_object_field<'a>(root: &'a mut Value, field: &str) -> Result<&'a mut Map<String, Value>> {
let Some(root_object) = root.as_object_mut() else {
bail!("settings root must be a JSON object");
};
let entry = root_object
.entry(field.to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !entry.is_object() {
*entry = Value::Object(Map::new());
}
match entry.as_object_mut() {
Some(object) => Ok(object),
None => bail!("settings field '{field}' must be a JSON object"),
}
}
fn upsert_hook(
hooks: &mut Map<String, Value>,
event_name: &str,
matcher: Option<&str>,
command: &str,
) {
let entry = hooks
.entry(event_name.to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if !entry.is_array() {
*entry = Value::Array(Vec::new());
}
if let Some(items) = entry.as_array_mut() {
let index = items.iter().position(|value| matcher_matches(value, matcher));
let hook_value = build_hook_value(matcher, command);
if let Some(index) = index {
items[index] = hook_value;
} else {
items.push(hook_value);
}
}
}
fn matcher_matches(value: &Value, matcher: Option<&str>) -> bool {
let current = value.get("matcher").and_then(Value::as_str).unwrap_or("");
match matcher {
Some(expected) => current == expected,
None => current.is_empty(),
}
}
fn build_hook_value(matcher: Option<&str>, command: &str) -> Value {
match matcher {
Some(matcher) => json!({
"matcher": matcher,
"hooks": [{"type": "command", "command": command}],
}),
None => json!({
"hooks": [{"type": "command", "command": command}],
}),
}
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
#[cfg(test)]
mod tests;