use crate::core::args_ref::read_text_slice;
use crate::core::config::ensure_path_allowed;
use crate::core::external::{ExternalTool, run_external};
use crate::core::response::RawResult;
use serde_json::{Value, json};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
static GIT_CWD: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
const GIT_TIMEOUT_MS: u64 = 120_000;
fn git_cwd() -> &'static Mutex<Option<PathBuf>> {
GIT_CWD.get_or_init(|| Mutex::new(None))
}
fn run_git(cwd: &Path, args: &[String]) -> Result<String, String> {
let output = run_external(ExternalTool::Git, args, Some(cwd), Some(GIT_TIMEOUT_MS))?;
if output.status_code == Some(0) {
return Ok(output.stdout);
}
let detail = if output.stderr.trim().is_empty() {
output.stdout.trim().to_string()
} else {
output.stderr.trim().to_string()
};
Err(format!(
"git failed (code {:?}): {detail}",
output.status_code
))
}
fn git_args(parts: &[&str]) -> Vec<String> {
parts.iter().map(|part| part.to_string()).collect()
}
fn resolve_repo_path(args: &Value) -> Result<PathBuf, String> {
if let Some(path) = args.get("path").and_then(Value::as_str) {
let resolved = ensure_path_allowed(path)?;
if resolved.is_file() {
return Ok(resolved.parent().map(Path::to_path_buf).unwrap_or(resolved));
}
return Ok(resolved);
}
git_cwd()
.lock()
.unwrap()
.clone()
.ok_or_else(|| "path is required (no git cwd set)".to_string())
}
fn open_repo(args: &Value) -> Result<PathBuf, String> {
let path = resolve_repo_path(args)?;
let toplevel = run_git(&path, &git_args(&["rev-parse", "--show-toplevel"]))
.map_err(|_| format!("Not a git repository: {}", path.display()))?;
let worktree = toplevel.trim();
if worktree.is_empty() {
return Err(format!("Not a git repository: {}", path.display()));
}
Ok(PathBuf::from(worktree))
}
fn status_text(worktree: &Path, include_untracked: bool) -> Result<String, String> {
let mut parts = vec!["status", "--porcelain", "--branch"];
if !include_untracked {
parts.push("--untracked-files=no");
}
let output = run_git(worktree, &git_args(&parts))?;
Ok(output.trim_end().to_string())
}
pub fn handle_git_set_workdir(args: &Value) -> RawResult {
let Some(path) = args.get("path").and_then(Value::as_str) else {
return RawResult::error("path must be a string");
};
let path = match ensure_path_allowed(path) {
Ok(path) => path,
Err(error) => return RawResult::error(error),
};
let has_git = path.join(".git").exists();
if bool_field(args, "initializeIfNotPresent", false) && !has_git {
if !path.exists()
&& let Err(error) = fs::create_dir_all(&path)
{
return RawResult::error(format!("Failed to create {}: {error}", path.display()));
}
if let Err(error) = run_git(&path, &git_args(&["init"])) {
return RawResult::error(error);
}
}
let toplevel = run_git(&path, &git_args(&["rev-parse", "--show-toplevel"]));
let worktree = match toplevel {
Ok(value) if !value.trim().is_empty() => PathBuf::from(value.trim()),
_ => {
if bool_field(args, "validateGitRepo", true) {
return RawResult::error(format!("Not a git repository: {}", path.display()));
}
*git_cwd().lock().unwrap() = Some(path.clone());
return RawResult::structured(
format!("Git workdir set to {}", path.display()),
json!({ "path": path.display().to_string(), "validated": false }),
);
}
};
*git_cwd().lock().unwrap() = Some(worktree.clone());
let git_dir = run_git(&worktree, &git_args(&["rev-parse", "--absolute-git-dir"]))
.map(|value| value.trim().to_string())
.unwrap_or_default();
let status = status_text(&worktree, true).unwrap_or_default();
RawResult::structured(
format!("Git workdir set to {}", worktree.display()),
json!({
"path": worktree.display().to_string(),
"gitDir": git_dir,
"status": status
}),
)
}
pub fn handle_git_status(args: &Value) -> RawResult {
let worktree = match open_repo(args) {
Ok(worktree) => worktree,
Err(error) => return RawResult::error(error),
};
match status_text(&worktree, bool_field(args, "includeUntracked", true)) {
Ok(status) => {
RawResult::structured(status, json!({ "path": worktree.display().to_string() }))
}
Err(error) => RawResult::error(error),
}
}
pub fn handle_git_add(args: &Value) -> RawResult {
let worktree = match open_repo(args) {
Ok(worktree) => worktree,
Err(error) => return RawResult::error(error),
};
let mut paths = string_array(args, "paths").unwrap_or_default();
if paths.is_empty()
&& let Some(single) = args.get("path").and_then(Value::as_str)
{
paths.push(single.to_string());
}
let all = bool_field(args, "all", false);
let update = bool_field(args, "update", false);
if paths.is_empty() && !all && !update {
return RawResult::error("paths or path is required unless all or update is set");
}
let mut command = git_args(&["add"]);
if all {
command.push("--all".to_string());
}
if update {
command.push("--update".to_string());
}
if bool_field(args, "force", false) {
command.push("--force".to_string());
}
command.push("--".to_string());
command.extend(paths.iter().cloned());
if let Err(error) = run_git(&worktree, &command) {
return RawResult::error(error);
}
let summary = if paths.is_empty() {
"Updated index".to_string()
} else {
format!("Updated index with {} paths", paths.len())
};
RawResult::structured(
summary,
json!({
"path": worktree.display().to_string(),
"entries": paths.len()
}),
)
}
pub fn handle_git_commit(args: &Value) -> RawResult {
let worktree = match open_repo(args) {
Ok(worktree) => worktree,
Err(error) => return RawResult::error(error),
};
if let Some(files) = string_array(args, "filesToStage")
&& !files.is_empty()
{
let mut add = git_args(&["add", "--"]);
add.extend(files.iter().cloned());
if let Err(error) = run_git(&worktree, &add) {
return RawResult::error(error);
}
}
let message = match commit_message(args) {
Ok(message) => message,
Err(error) => return RawResult::error(error),
};
if !looks_conventional(&message) {
return RawResult::error(
"Commit message must start with an English Conventional Commit header",
);
}
let mut command: Vec<String> = vec![
"-c".to_string(),
"user.name=rust-fs-mcp".to_string(),
"-c".to_string(),
"user.email=rust-fs-mcp@example.invalid".to_string(),
"commit".to_string(),
];
if let Some(author) = author_identity(args) {
command.push("--author".to_string());
command.push(author);
}
command.push("-m".to_string());
command.push(message.clone());
if bool_field(args, "amend", false) {
command.push("--amend".to_string());
}
if bool_field(args, "allowEmpty", false) {
command.push("--allow-empty".to_string());
}
if bool_field(args, "noVerify", false) {
command.push("--no-verify".to_string());
}
if let Err(error) = run_git(&worktree, &command) {
return RawResult::error(error);
}
let oid = run_git(&worktree, &git_args(&["rev-parse", "HEAD"]))
.map(|value| value.trim().to_string())
.unwrap_or_default();
RawResult::structured(
format!("[{oid}] {}", first_line(&message)),
json!({
"path": worktree.display().to_string(),
"oid": oid
}),
)
}
pub fn handle_git_amend(args: &Value) -> RawResult {
let worktree = match open_repo(args) {
Ok(worktree) => worktree,
Err(error) => return RawResult::error(error),
};
if run_git(&worktree, &git_args(&["rev-parse", "--verify", "HEAD"])).is_err() {
return RawResult::error("Cannot amend: repository has no commits yet");
}
let author = author_identity(args);
let reset_author = bool_field(args, "resetAuthor", false);
if author.is_some() && reset_author {
return RawResult::error("author and resetAuthor cannot be combined");
}
if let Some(files) = string_array(args, "filesToStage")
&& !files.is_empty()
{
let mut add = git_args(&["add", "--"]);
add.extend(files.iter().cloned());
if let Err(error) = run_git(&worktree, &add) {
return RawResult::error(error);
}
}
let new_message = match optional_commit_message(args) {
Ok(message) => message,
Err(error) => return RawResult::error(error),
};
if let Some(message) = &new_message
&& !looks_conventional(message)
{
return RawResult::error(
"Commit message must start with an English Conventional Commit header",
);
}
let mut command: Vec<String> = vec![
"-c".to_string(),
"user.name=rust-fs-mcp".to_string(),
"-c".to_string(),
"user.email=rust-fs-mcp@example.invalid".to_string(),
"commit".to_string(),
"--amend".to_string(),
];
if let Some(author) = author {
command.push("--author".to_string());
command.push(author);
}
if reset_author {
command.push("--reset-author".to_string());
}
match &new_message {
Some(message) => {
command.push("-m".to_string());
command.push(message.clone());
}
None => command.push("--no-edit".to_string()),
}
if bool_field(args, "allowEmpty", false) {
command.push("--allow-empty".to_string());
}
if bool_field(args, "noVerify", false) {
command.push("--no-verify".to_string());
}
if let Err(error) = run_git(&worktree, &command) {
return RawResult::error(error);
}
let oid = run_git(&worktree, &git_args(&["rev-parse", "HEAD"]))
.map(|value| value.trim().to_string())
.unwrap_or_default();
let subject = match &new_message {
Some(message) => first_line(message).to_string(),
None => run_git(&worktree, &git_args(&["show", "-s", "--format=%s", "HEAD"]))
.map(|value| value.trim().to_string())
.unwrap_or_default(),
};
RawResult::structured(
format!("[{oid}] {subject}"),
json!({
"path": worktree.display().to_string(),
"oid": oid
}),
)
}
pub fn handle_git_diff(args: &Value) -> RawResult {
let worktree = match open_repo(args) {
Ok(worktree) => worktree,
Err(error) => return RawResult::error(error),
};
let mut command: Vec<String> = vec!["diff".to_string()];
if bool_field(args, "nameOnly", false) {
command.push("--name-only".to_string());
} else if bool_field(args, "stat", false) {
command.push("--stat".to_string());
} else if let Some(context) = args.get("contextLines").and_then(Value::as_u64) {
command.push(format!("--unified={context}"));
}
if bool_field(args, "staged", false) {
command.push("--staged".to_string());
} else {
let source = args.get("source").and_then(Value::as_str);
let target = args.get("target").and_then(Value::as_str);
match (source, target) {
(Some(source), Some(target)) => {
command.push(source.to_string());
command.push(target.to_string());
}
(Some(value), None) | (None, Some(value)) => {
command.push(value.to_string());
}
(None, None) => {}
}
}
if let Some(paths) = string_array(args, "paths")
&& !paths.is_empty()
{
command.push("--".to_string());
command.extend(paths);
}
let output = match run_git(&worktree, &command) {
Ok(output) => output.trim_end().to_string(),
Err(error) => return RawResult::error(error),
};
RawResult::structured(output, json!({ "path": worktree.display().to_string() }))
}
pub fn handle_git_show(args: &Value) -> RawResult {
let worktree = match open_repo(args) {
Ok(worktree) => worktree,
Err(error) => return RawResult::error(error),
};
let mut objects = string_array(args, "objects").unwrap_or_default();
let from_single = objects.is_empty();
if from_single && let Some(object) = args.get("object").and_then(Value::as_str) {
objects.push(object.to_string());
}
if objects.is_empty() {
return RawResult::error("object or objects is required");
}
let mut command = git_args(&["show"]);
if bool_field(args, "stat", false) {
command.push("--stat".to_string());
}
if args.get("format").and_then(Value::as_str) == Some("raw") {
command.push("--format=raw".to_string());
}
let file_path = args.get("filePath").and_then(Value::as_str);
for object in &objects {
match file_path {
Some(file) => command.push(format!("{object}:{file}")),
None => command.push(object.clone()),
}
}
let output = match run_git(&worktree, &command) {
Ok(output) => output.trim_end().to_string(),
Err(error) => return RawResult::error(error),
};
let path_label = worktree.display().to_string();
let structured = if from_single {
json!({ "path": path_label, "object": objects[0] })
} else {
json!({ "path": path_label, "objects": objects })
};
RawResult::structured(output, structured)
}
fn commit_message(args: &Value) -> Result<String, String> {
if let Some(path) = args.get("messagePath").and_then(Value::as_str) {
let path = ensure_path_allowed(path)?;
let offset = args
.get("messageOffset")
.and_then(Value::as_u64)
.unwrap_or(0) as usize;
let length = args
.get("messageLength")
.and_then(Value::as_u64)
.map(|value| value as usize);
return read_text_slice(path, offset, length);
}
args.get("message")
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| "message or messagePath is required".to_string())
}
fn optional_commit_message(args: &Value) -> Result<Option<String>, String> {
if args.get("message").and_then(Value::as_str).is_some()
|| args.get("messagePath").and_then(Value::as_str).is_some()
{
return commit_message(args).map(Some);
}
Ok(None)
}
fn author_identity(args: &Value) -> Option<String> {
let author = args.get("author").and_then(Value::as_object)?;
let name = author
.get("name")
.and_then(Value::as_str)
.unwrap_or("rust-fs-mcp");
let email = author
.get("email")
.and_then(Value::as_str)
.unwrap_or("rust-fs-mcp@example.invalid");
Some(format!("{name} <{email}>"))
}
fn looks_conventional(message: &str) -> bool {
let Some(header) = message.lines().next() else {
return false;
};
let Some((kind, summary)) = header.split_once(": ") else {
return false;
};
let valid_type = kind
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch == '-' || ch == '(' || ch == ')');
valid_type && summary.chars().any(|ch| ch.is_ascii_alphabetic())
}
fn first_line(value: &str) -> &str {
value.lines().next().unwrap_or("")
}
fn bool_field(value: &Value, key: &str, default: bool) -> bool {
value.get(key).and_then(Value::as_bool).unwrap_or(default)
}
fn string_array(args: &Value, key: &str) -> Option<Vec<String>> {
args.get(key).and_then(Value::as_array).map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn conventional_header_detected() {
assert!(looks_conventional("feat: add thing"));
assert!(looks_conventional("fix(core): correct path"));
assert!(!looks_conventional("no type here"));
assert!(!looks_conventional("WIP"));
}
#[test]
fn author_identity_formats_name_and_email() {
let args = json!({ "author": { "name": "Jane", "email": "jane@example.com" } });
assert_eq!(
author_identity(&args).as_deref(),
Some("Jane <jane@example.com>")
);
assert_eq!(author_identity(&json!({})), None);
}
#[test]
fn optional_commit_message_absent_yields_none() {
assert_eq!(optional_commit_message(&json!({})).unwrap(), None);
assert_eq!(
optional_commit_message(&json!({ "message": "feat: add x" })).unwrap(),
Some("feat: add x".to_string())
);
}
}