choreo-daemon 0.1.0

Agentic coding assistant — daemon, TUI, and bridges
use crate::tools::{ToolError, truncate_tool_output};
use gix::ObjectId;
use schemars::JsonSchema;
use serde::Deserialize;
use std::io;

use super::{collect_cached_diff_lines, load_mutable_index, open_repo, path_from_bytes};

#[derive(Debug, Deserialize, JsonSchema)]
pub struct GitCommitArgs {
    pub repo_path: Option<String>,
    pub message: String,
    pub allow_empty: Option<bool>,
}

pub fn execute_git_commit_tool(
    args: &GitCommitArgs,
    working_dir: Option<&std::path::Path>,
) -> Result<String, ToolError> {
    let output = git_commit_impl(
        args.repo_path.as_deref(),
        &args.message,
        args.allow_empty.unwrap_or(false),
        working_dir,
    )?;
    Ok(truncate_tool_output(&output))
}

fn git_commit_impl(
    repo_path: Option<&str>,
    message: &str,
    allow_empty: bool,
    working_dir: Option<&std::path::Path>,
) -> Result<String, ToolError> {
    let repo = open_repo(repo_path, working_dir)?;
    let message = message.trim();
    if message.is_empty() {
        return Err(ToolError::Other(
            "commit message must not be empty".to_string(),
        ));
    }

    let index = load_mutable_index(&repo)?;
    ensure_index_has_no_conflicts(&index)?;

    if !allow_empty && collect_cached_diff_lines(&repo, &[] as &[String])?.is_empty() {
        return Err(ToolError::Other("no staged changes to commit".to_string()));
    }
    let tree_id = write_tree_from_index(&repo, &index)?;
    let parents = current_head_parents(&repo)?;

    repo.commit("HEAD", message, tree_id, parents)
        .map_err(io::Error::other)?;

    super::log::git_log_impl(repo_path, 1, working_dir)
}

fn ensure_index_has_no_conflicts(index: &gix::index::File) -> Result<(), ToolError> {
    if let Some(path) = index
        .entries()
        .iter()
        .find(|entry| entry.stage() != gix::index::entry::Stage::Unconflicted)
        .map(|entry| path_from_bytes(entry.path(index).as_ref()))
    {
        Err(ToolError::Other(format!(
            "cannot commit with unresolved index conflicts at {path}"
        )))
    } else {
        Ok(())
    }
}

fn write_tree_from_index(
    repo: &gix::Repository,
    index: &gix::index::File,
) -> Result<ObjectId, ToolError> {
    let mut editor = repo.empty_tree().edit().map_err(io::Error::other)?;
    for entry in index.entries() {
        if entry.stage() != gix::index::entry::Stage::Unconflicted {
            return Err(ToolError::Other(format!(
                "cannot write tree with conflicted index entry at {}",
                path_from_bytes(entry.path(index).as_ref())
            )));
        }
        let kind = entry.mode.to_tree_entry_mode().ok_or_else(|| {
            ToolError::Other(format!(
                "unsupported index entry mode {} at {}",
                entry.mode.bits(),
                path_from_bytes(entry.path(index).as_ref())
            ))
        })?;
        editor
            .upsert(entry.path(index).to_owned(), kind.into(), entry.id)
            .map_err(io::Error::other)?;
    }
    editor
        .write()
        .map(|id| id.detach())
        .map_err(io::Error::other)
        .map_err(ToolError::from)
}

fn current_head_parents(repo: &gix::Repository) -> Result<Vec<ObjectId>, ToolError> {
    match repo.head_id() {
        Ok(head) => Ok(vec![head.detach()]),
        Err(_) => Ok(Vec::new()),
    }
}

pub fn describe_git_commit_invocation(args: &GitCommitArgs) -> String {
    let mut parts = vec![format!("Committing: {}.", args.message)];
    if args.allow_empty.unwrap_or(false) {
        parts.push(" Allow empty commit.".to_string());
    }
    parts.concat()
}

pub(crate) struct GitCommit;

define_tool!(
    GitCommit,
    "git_commit",
    "Create a Git commit from the current index.",
    GitCommitArgs,
    execute_git_commit_tool,
    "git",
    describe_git_commit_invocation
);