use std::process::{Command, Output, Stdio};
use std::sync::Mutex;
use std::time::Duration;
use crate::hooks::Hook;
use crate::hooks::context::{PostToolUseContext, RunEndContext, RunStartContext};
const GIT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, thiserror::Error)]
pub enum GitExecutorError {
#[error("Failed to execute git: {0}")]
ExecutionFailed(String),
#[error("Git error: {0}")]
GitError(String),
#[error("No changes to commit")]
NoChanges,
#[error("Invalid repository state: {0}")]
InvalidState(String),
#[error("Git command timed out after {0:?}")]
Timeout(Duration),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AutoCommitResult {
Committed {
sha: String,
},
NoChanges,
Skipped {
reason: String,
},
Failed {
error: String,
},
}
pub struct GitExecutor;
impl GitExecutor {
fn run_git(args: &[&str]) -> Result<(Vec<u8>, Vec<u8>), GitExecutorError> {
let child = Command::new("git")
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| GitExecutorError::ExecutionFailed(e.to_string()))?;
let pid = child.id();
let (tx, rx) = std::sync::mpsc::channel::<Result<Output, std::io::Error>>();
std::thread::spawn(move || {
let result = child.wait_with_output();
drop(tx.send(result));
});
match rx.recv_timeout(GIT_TIMEOUT) {
Ok(Ok(output)) => {
if !output.status.success() {
return Err(GitExecutorError::GitError(
String::from_utf8_lossy(&output.stderr).to_string(),
));
}
Ok((output.stdout, output.stderr))
}
Ok(Err(e)) => Err(GitExecutorError::ExecutionFailed(e.to_string())),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
let _ = Command::new("kill")
.args(["-9", &pid.to_string()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.ok();
let _ = rx.recv().ok();
Err(GitExecutorError::Timeout(GIT_TIMEOUT))
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(
GitExecutorError::ExecutionFailed("git worker thread panicked".to_string()),
),
}
}
pub fn has_changes() -> Result<bool, GitExecutorError> {
let (stdout, _stderr) = Self::run_git(&["status", "--porcelain"])?;
let status = String::from_utf8_lossy(&stdout);
Ok(!status.trim().is_empty())
}
pub fn stage_files(files: &[String]) -> Result<(), GitExecutorError> {
if files.is_empty() {
return Err(GitExecutorError::GitError(
"refusing to stage with empty file list (would run `git add -A` \
and commit unrelated changes); populate `AutoCommitConfig::files` \
or record modifications via the hook"
.to_string(),
));
}
for file in files {
Self::run_git(&["add", "--", file])?;
}
Ok(())
}
pub fn commit(message: &str, amend: bool) -> Result<String, GitExecutorError> {
let mut args: Vec<&str> = vec!["commit"];
if amend {
args.push("--amend");
}
args.push("-m");
args.push(message);
let result = Self::run_git(&args);
match result {
Ok(_) => Self::get_head_sha(),
Err(e) => {
let err_str = e.to_string();
if err_str.contains("nothing to commit") {
return Err(GitExecutorError::NoChanges);
}
Err(e)
}
}
}
pub fn push(branch: Option<&str>) -> Result<(), GitExecutorError> {
let current_branch = Self::current_branch()?;
let branch = branch.unwrap_or(¤t_branch);
Self::run_git(&["push", "origin", branch])?;
Ok(())
}
pub fn current_branch() -> Result<String, GitExecutorError> {
let (stdout, _stderr) = Self::run_git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
Ok(String::from_utf8_lossy(&stdout).trim().to_string())
}
pub fn get_head_sha() -> Result<String, GitExecutorError> {
let (stdout, _stderr) = Self::run_git(&["rev-parse", "HEAD"])?;
Ok(String::from_utf8_lossy(&stdout).trim().to_string())
}
#[must_use]
pub fn auto_commit_with_files(
config: &AutoCommitConfig,
session_files: Option<&[String]>,
tool_name: Option<&str>,
session_id: Option<&str>,
) -> AutoCommitResult {
if !config.enabled {
return AutoCommitResult::Skipped {
reason: "Auto-commit disabled".to_string(),
};
}
match Self::has_changes() {
Ok(true) => {}
Ok(false) => {
if config.skip_if_clean {
return AutoCommitResult::NoChanges;
}
return AutoCommitResult::Skipped {
reason: "No changes detected".to_string(),
};
}
Err(e) => {
return AutoCommitResult::Failed {
error: e.to_string(),
};
}
}
let files = session_files.unwrap_or(&config.files);
if let Err(e) = Self::stage_files(files) {
return AutoCommitResult::Failed {
error: e.to_string(),
};
}
let message =
Self::expand_message_template(&config.message_template, tool_name, session_id);
match Self::commit(&message, config.commit_mode.is_amend()) {
Ok(sha) => {
if config.auto_push
&& let Err(e) = Self::push(config.push_branch.as_deref())
{
return AutoCommitResult::Failed {
error: format!("Commit succeeded but push failed: {e}"),
};
}
AutoCommitResult::Committed { sha }
}
Err(GitExecutorError::NoChanges) => AutoCommitResult::NoChanges,
Err(e) => AutoCommitResult::Failed {
error: e.to_string(),
},
}
}
#[must_use]
pub fn auto_commit(config: &AutoCommitConfig) -> AutoCommitResult {
Self::auto_commit_with_files(config, None, None, None)
}
fn expand_message_template(
template: &str,
tool_name: Option<&str>,
session_id: Option<&str>,
) -> String {
let tool = tool_name.unwrap_or("");
let session = session_id.unwrap_or("");
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(pos) = rest.find("{{") {
out.push_str(&rest[..pos]);
let after = &rest[pos..];
if let Some(tail) = after.strip_prefix("{{tool}}") {
out.push_str(tool);
rest = tail;
} else if let Some(tail) = after.strip_prefix("{{session}}") {
out.push_str(session);
rest = tail;
} else {
out.push_str("{{");
rest = &after[2..];
}
}
out.push_str(rest);
out
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CommitMode {
#[default]
Create,
Amend,
}
impl CommitMode {
#[must_use]
pub fn is_amend(self) -> bool {
self == Self::Amend
}
}
#[derive(Debug, Clone)]
pub struct AutoCommitConfig {
pub enabled: bool,
pub message_template: String,
pub auto_push: bool,
pub push_branch: Option<String>,
pub skip_if_clean: bool,
pub commit_mode: CommitMode,
pub commit_on_tools: Vec<String>,
pub files: Vec<String>,
}
impl Default for AutoCommitConfig {
fn default() -> Self {
Self {
enabled: true,
message_template: "chore(agent): auto-commit".to_string(),
auto_push: false,
push_branch: None,
skip_if_clean: true,
commit_mode: CommitMode::default(),
commit_on_tools: vec!["Write".to_string(), "Edit".to_string()],
files: vec![],
}
}
}
pub struct AutoCommitConfigBuilder {
config: AutoCommitConfig,
}
impl AutoCommitConfigBuilder {
#[must_use]
pub fn new() -> Self {
Self {
config: AutoCommitConfig::default(),
}
}
#[must_use]
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.config.enabled = enabled;
self
}
#[must_use]
pub fn with_message_template(mut self, template: impl Into<String>) -> Self {
self.config.message_template = template.into();
self
}
#[must_use]
pub fn with_auto_push(mut self, value: bool) -> Self {
self.config.auto_push = value;
self
}
#[must_use]
pub fn with_files(mut self, files: Vec<String>) -> Self {
self.config.files = files;
self
}
#[must_use]
pub fn build(self) -> AutoCommitConfig {
self.config
}
}
impl Default for AutoCommitConfigBuilder {
fn default() -> Self {
Self::new()
}
}
pub struct AutoCommitHook {
config: AutoCommitConfig,
modified_files: Mutex<Vec<String>>,
last_tool: Mutex<Option<String>>,
}
impl AutoCommitHook {
#[must_use]
pub fn new() -> Self {
Self {
config: AutoCommitConfig::default(),
modified_files: Mutex::new(Vec::new()),
last_tool: Mutex::new(None),
}
}
#[must_use]
pub fn with_config(mut self, config: AutoCommitConfig) -> Self {
self.config = config;
self
}
fn track_modification(&self, file: &str) {
if let Ok(mut files) = self.modified_files.lock()
&& !files.contains(&file.to_string())
{
files.push(file.to_string());
}
}
fn record_triggering_tool(&self, tool_name: &str) {
if let Ok(mut tool) = self.last_tool.lock() {
*tool = Some(tool_name.to_string());
}
}
fn clear_modifications(&self) {
if let Ok(mut files) = self.modified_files.lock() {
files.clear();
}
if let Ok(mut tool) = self.last_tool.lock() {
*tool = None;
}
}
}
impl Default for AutoCommitHook {
fn default() -> Self {
Self::new()
}
}
impl Hook for AutoCommitHook {
fn name(&self) -> &'static str {
"auto_commit"
}
fn on_post_tool_use(&self, ctx: &PostToolUseContext) {
if !self.config.enabled {
return;
}
if self.config.commit_on_tools.contains(&ctx.tool_name)
&& let Some(file_path) = ctx.input.get("file_path").and_then(|v| v.as_str())
{
self.track_modification(file_path);
self.record_triggering_tool(&ctx.tool_name);
}
}
fn on_run_start(&self, _ctx: &RunStartContext) {
self.clear_modifications();
}
fn on_run_end(&self, ctx: &RunEndContext) {
let files = self.modified_files.lock().ok().filter(|f| !f.is_empty());
let tool = self.last_tool.lock().ok().and_then(|t| t.clone());
let session = ctx.session_id.to_string();
let result = GitExecutor::auto_commit_with_files(
&self.config,
files.as_deref().map(|v| v as &[String]),
tool.as_deref(),
Some(session.as_str()),
);
if let AutoCommitResult::Failed { error } = result {
tracing::warn!(%error, "auto-commit failed");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_builder() {
let config = AutoCommitConfigBuilder::new()
.with_enabled(true)
.with_message_template("feat: {{tool}}")
.with_auto_push(true)
.build();
assert!(config.enabled);
assert_eq!(config.message_template, "feat: {{tool}}");
assert!(config.auto_push);
}
#[test]
fn config_default() {
let config = AutoCommitConfig::default();
assert!(config.enabled);
assert!(!config.auto_push);
assert!(config.skip_if_clean);
assert_eq!(config.commit_on_tools.len(), 2);
}
#[test]
fn hook_creation() {
let hook = AutoCommitHook::new();
assert_eq!(hook.name(), "auto_commit");
}
#[test]
fn hook_with_disabled_config() {
let config = AutoCommitConfig {
enabled: false,
..AutoCommitConfig::default()
};
let hook = AutoCommitHook::new().with_config(config);
assert_eq!(hook.name(), "auto_commit");
}
#[test]
fn track_modifications() {
let hook = AutoCommitHook::new();
hook.track_modification("src/main.rs");
hook.track_modification("src/lib.rs");
hook.track_modification("src/main.rs");
let files = hook.modified_files.lock().unwrap();
assert_eq!(files.len(), 2);
assert!(files.contains(&"src/main.rs".to_string()));
assert!(files.contains(&"src/lib.rs".to_string()));
}
#[test]
fn clear_modifications() {
let hook = AutoCommitHook::new();
hook.track_modification("src/main.rs");
hook.clear_modifications();
let files = hook.modified_files.lock().unwrap();
assert!(files.is_empty());
}
#[test]
fn auto_commit_result_equality() {
let r1 = AutoCommitResult::Committed {
sha: "abc123".to_string(),
};
let r2 = AutoCommitResult::Committed {
sha: "abc123".to_string(),
};
assert_eq!(r1, r2);
let r3 = AutoCommitResult::NoChanges;
assert_ne!(r1, r3);
}
#[test]
fn auto_commit_disabled_returns_skipped() {
let config = AutoCommitConfig {
enabled: false,
..AutoCommitConfig::default()
};
let result = GitExecutor::auto_commit(&config);
assert!(matches!(result, AutoCommitResult::Skipped { .. }));
}
#[test]
fn commit_mode_default_is_create() {
assert_eq!(CommitMode::default(), CommitMode::Create);
}
#[test]
fn commit_mode_is_amend() {
assert!(CommitMode::Amend.is_amend());
}
#[test]
fn commit_mode_create_is_not_amend() {
assert!(!CommitMode::Create.is_amend());
}
#[test]
fn config_default_commit_mode() {
let config = AutoCommitConfig::default();
assert_eq!(config.commit_mode, CommitMode::Create);
}
#[test]
fn hook_tracks_tracked_tools() {
let hook = AutoCommitHook::new();
let ctx = PostToolUseContext {
tool_name: "Write".to_string(),
input: serde_json::json!({"file_path": "test.rs"}),
output: "ok".to_string(),
is_error: false,
duration_ms: 10,
session_id: uuid::Uuid::nil(),
turn_number: 0,
};
hook.on_post_tool_use(&ctx);
let files = hook.modified_files.lock().unwrap();
assert!(files.contains(&"test.rs".to_string()));
}
#[test]
fn hook_ignores_untracked_tools() {
let hook = AutoCommitHook::new();
let ctx = PostToolUseContext {
tool_name: "Read".to_string(),
input: serde_json::json!({"file_path": "test.rs"}),
output: "ok".to_string(),
is_error: false,
duration_ms: 10,
session_id: uuid::Uuid::nil(),
turn_number: 0,
};
hook.on_post_tool_use(&ctx);
let files = hook.modified_files.lock().unwrap();
assert!(files.is_empty());
}
#[test]
fn hook_clear_resets_tracking() {
let hook = AutoCommitHook::new();
let ctx = PostToolUseContext {
tool_name: "Write".to_string(),
input: serde_json::json!({"file_path": "test.rs"}),
output: "ok".to_string(),
is_error: false,
duration_ms: 10,
session_id: uuid::Uuid::nil(),
turn_number: 0,
};
hook.on_post_tool_use(&ctx);
assert!(!hook.modified_files.lock().unwrap().is_empty());
hook.clear_modifications();
assert!(hook.modified_files.lock().unwrap().is_empty());
}
struct RepoGuard(std::path::PathBuf);
impl Drop for RepoGuard {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).ok();
}
}
static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn stage_files_empty_list_does_not_stage_unrelated_changes() {
use std::fs;
use std::process::Command;
if Command::new("git")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_err()
{
eprintln!("skipping: git not on PATH");
return;
}
let mut repo_dir = std::env::temp_dir();
repo_dir.push(format!(
"loopctl-stage-files-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
));
fs::create_dir_all(&repo_dir).expect("created temp repo dir");
let guard = RepoGuard(repo_dir.clone());
for (label, args) in [
("init", vec!["init"]),
("config user.name", vec!["config", "user.name", "test"]),
("config user.email", vec!["config", "user.email", "t@t"]),
] {
let status = Command::new("git")
.args(&args)
.current_dir(&repo_dir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap_or_else(|e| panic!("git {label} spawn failed: {e}"));
assert!(status.success(), "git {label} failed");
}
let sentinel = repo_dir.join("unrelated.txt");
fs::write(&sentinel, "v1\n").expect("wrote sentinel v1");
for (label, args) in [
("add", vec!["add", "unrelated.txt"]),
("commit", vec!["commit", "-m", "init"]),
] {
let status = Command::new("git")
.args(&args)
.current_dir(&repo_dir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap_or_else(|e| panic!("git {label} spawn failed: {e}"));
assert!(status.success(), "git {label} failed");
}
fs::write(&sentinel, "v2-uncommitted\n").expect("wrote sentinel v2");
let _cwd_guard = CWD_LOCK.lock().unwrap();
let prev_dir = std::env::current_dir().expect("cwd readable");
std::env::set_current_dir(&repo_dir).expect("cd into repo");
let result = GitExecutor::stage_files(&[]);
std::env::set_current_dir(&prev_dir).expect("restored cwd");
assert!(
result.is_err(),
"stage_files(&[]) must refuse to run, got {result:?}"
);
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(&repo_dir)
.output()
.unwrap_or_else(|e| panic!("git status spawn failed: {e}"));
let status_text = String::from_utf8_lossy(&output.stdout);
assert!(
status_text.contains(" M unrelated.txt"),
"unrelated.txt must remain unstaged after stage_files(&[]), \
but git status was:\n{status_text}\n\
(this means stage_files ran `git add -A` — the empty-list \
footgun is still present)"
);
drop(guard);
}
fn repo_with_uncommitted_sentinel(tag: &str) -> (std::path::PathBuf, RepoGuard) {
use std::fs;
use std::process::Command;
let mut repo_dir = std::env::temp_dir();
repo_dir.push(format!(
"loopctl-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
));
fs::create_dir_all(&repo_dir).expect("created temp repo dir");
let guard = RepoGuard(repo_dir.clone());
fs::write(repo_dir.join("tool_output.txt"), "v1\n").expect("wrote sentinel v1");
for (label, args) in [
("init", vec!["init"]),
("config user.name", vec!["config", "user.name", "test"]),
("config user.email", vec!["config", "user.email", "t@t"]),
("add", vec!["add", "tool_output.txt"]),
("commit", vec!["commit", "-m", "init"]),
] {
let status = Command::new("git")
.args(&args)
.current_dir(&repo_dir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap_or_else(|e| panic!("git {label} spawn failed: {e}"));
assert!(status.success(), "git {label} failed");
}
fs::write(repo_dir.join("tool_output.txt"), "v2-uncommitted\n").expect("wrote sentinel v2");
(repo_dir, guard)
}
#[test]
fn message_template_placeholders_are_expanded() {
use std::process::Command;
use std::process::Stdio;
if Command::new("git")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_err()
{
eprintln!("skipping: git not on PATH");
return;
}
let (repo_dir, guard) = repo_with_uncommitted_sentinel("template-test");
let config = AutoCommitConfig {
enabled: true,
message_template: "feat: {{tool}} ({{session}}) [{{branch}}]".to_string(),
..AutoCommitConfig::default()
};
let session_id = "11111111-2222-3333-4444-555555555555";
let _cwd_guard = CWD_LOCK.lock().unwrap();
let prev_dir = std::env::current_dir().expect("cwd readable");
std::env::set_current_dir(&repo_dir).expect("cd into repo");
let result = GitExecutor::auto_commit_with_files(
&config,
Some(&["tool_output.txt".to_string()]),
Some("Write"),
Some(session_id),
);
std::env::set_current_dir(&prev_dir).expect("restored cwd");
assert!(
matches!(result, AutoCommitResult::Committed { .. }),
"auto-commit must succeed, got {result:?}"
);
let output = Command::new("git")
.args(["log", "--format=%s", "-1"])
.current_dir(&repo_dir)
.output()
.unwrap_or_else(|e| panic!("git log spawn failed: {e}"));
let subject = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
subject,
format!("feat: Write ({session_id}) [{{{{branch}}}}]"),
"doc: {{tool}} and {{session}} are expanded at run end into the triggering \
tool name and the session identifier; any other braced text passes through \
verbatim"
);
drop(guard);
}
#[test]
fn placeholder_values_are_not_rescanned() {
let expanded = GitExecutor::expand_message_template(
"{{tool}} / {{session}} [{{branch}}]",
Some("{{session}}"),
Some("1111"),
);
assert_eq!(
expanded, "{{session}} / 1111 [{{branch}}]",
"doc: inserted values are not re-scanned — a value containing \
another token's text is committed literally"
);
let unsupplied =
GitExecutor::expand_message_template("feat: {{tool}} ({{session}})", None, None);
assert_eq!(
unsupplied, "feat: ()",
"doc: a placeholder whose value was not supplied expands to an \
empty string"
);
}
#[test]
fn run_end_commits_the_tracked_tools_name_and_session() {
use std::process::Command;
use std::process::Stdio;
if Command::new("git")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_err()
{
eprintln!("skipping: git not on PATH");
return;
}
let (repo_dir, guard) = repo_with_uncommitted_sentinel("hook-wiring-test");
let hook = AutoCommitHook::new().with_config(AutoCommitConfig {
enabled: true,
message_template: "chore: {{tool}} {{session}}".to_string(),
..AutoCommitConfig::default()
});
let session_id = uuid::Uuid::nil();
let post_ctx = |tool: &str| PostToolUseContext {
tool_name: tool.to_string(),
input: serde_json::json!({"file_path": "tool_output.txt"}),
output: "ok".to_string(),
is_error: false,
duration_ms: 5,
session_id,
turn_number: 0,
};
hook.on_post_tool_use(&post_ctx("Write"));
hook.on_post_tool_use(&post_ctx("Edit"));
let _cwd_guard = CWD_LOCK.lock().unwrap();
let prev_dir = std::env::current_dir().expect("cwd readable");
std::env::set_current_dir(&repo_dir).expect("cd into repo");
hook.on_run_end(&RunEndContext {
session_id,
reason: crate::hooks::context::RunEndReason::Complete,
total_turns: 1,
total_tokens: 0,
duration_secs: 1,
});
std::env::set_current_dir(&prev_dir).expect("restored cwd");
let output = Command::new("git")
.args(["log", "--format=%s", "-1"])
.current_dir(&repo_dir)
.output()
.unwrap_or_else(|e| panic!("git log spawn failed: {e}"));
let subject = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
subject, "chore: Edit 00000000-0000-0000-0000-000000000000",
"the run-end commit expands {{tool}} with the most recently run \
tracked tool and {{session}} with the run's session id"
);
drop(guard);
}
}