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::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
const GIT_TIMEOUT_MS: u64 = 120_000;
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);
}
Err("path is required".to_string())
}
static TOPLEVEL_CACHE: OnceLock<Mutex<HashMap<PathBuf, PathBuf>>> = OnceLock::new();
fn toplevel_cache() -> &'static Mutex<HashMap<PathBuf, PathBuf>> {
TOPLEVEL_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn open_repo(args: &Value) -> Result<PathBuf, String> {
let path = resolve_repo_path(args)?;
let cached = toplevel_cache().lock().unwrap().get(&path).cloned();
if let Some(cached) = cached {
if cached.join(".git").exists() {
return Ok(cached);
}
toplevel_cache().lock().unwrap().remove(&path);
}
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()));
}
let worktree = PathBuf::from(worktree);
toplevel_cache()
.lock()
.unwrap()
.insert(path, worktree.clone());
Ok(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_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 a Conventional Commit header like 'fix: summary'",
);
}
let author = author_identity(args);
let mut command: Vec<String> = identity_flags(&worktree, author.as_ref());
command.push("commit".to_string());
if let Some((name, email)) = &author {
command.push("--author".to_string());
command.push(format!("{name} <{email}>"));
}
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 a Conventional Commit header like 'fix: summary'",
);
}
let mut command: Vec<String> = identity_flags(&worktree, author.as_ref());
command.push("commit".to_string());
command.push("--amend".to_string());
if let Some((name, email)) = &author {
command.push("--author".to_string());
command.push(format!("{name} <{email}>"));
}
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 source = args.get("source").and_then(Value::as_str);
let target = args.get("target").and_then(Value::as_str);
for value in [source, target].into_iter().flatten() {
if value.starts_with('-') {
return RawResult::error(format!("revision must not start with '-': {value}"));
}
}
let worktree = match open_repo(args) {
Ok(worktree) => worktree,
Err(error) => return RawResult::error(error),
};
let check = bool_field(args, "check", false);
let mut command: Vec<String> = vec!["diff".to_string()];
if check {
command.push("--check".to_string());
}
else 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 {
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);
}
if check {
return run_diff_check(&worktree, &command);
}
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() }))
}
fn run_diff_check(worktree: &Path, command: &[String]) -> RawResult {
let result = run_external(
ExternalTool::Git,
command,
Some(worktree),
Some(GIT_TIMEOUT_MS),
);
let output = match result {
Ok(output) => output,
Err(error) => return RawResult::error(error),
};
let path_label = worktree.display().to_string();
match output.status_code {
Some(0) => RawResult::structured(
"No whitespace errors or conflict markers".to_string(),
json!({ "path": path_label, "clean": true }),
),
Some(code) if code > 0 && code < 128 => RawResult::structured(
output.stdout.trim_end().to_string(),
json!({ "path": path_label, "clean": false }),
),
other => {
let detail = if output.stderr.trim().is_empty() {
output.stdout.trim().to_string()
}
else {
output.stderr.trim().to_string()
};
RawResult::error(format!("git failed (code {other:?}): {detail}"))
}
}
}
pub fn handle_git_show(args: &Value) -> RawResult {
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 stat_flag = bool_field(args, "stat", false);
for object in objects.iter_mut() {
if !object.trim().contains(char::is_whitespace) {
continue;
}
let mut parts = object.split_whitespace();
let head = parts.next().unwrap_or("").to_string();
for extra in parts {
if extra == "--stat" {
stat_flag = true;
}
else {
return RawResult::error(format!(
"unsupported token '{extra}' in object '{object}'; pass options via schema fields (stat, format, filePath)"
));
}
}
*object = head;
}
for object in &objects {
if object.starts_with('-') {
return RawResult::error(format!("object must not start with '-': {object}"));
}
}
let worktree = match open_repo(args) {
Ok(worktree) => worktree,
Err(error) => return RawResult::error(error),
};
let mut command = git_args(&["show"]);
if stat_flag {
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) if !object.contains(':') => command.push(format!("{object}:{file}")),
_ => 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, 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((name.to_string(), email.to_string()))
}
fn identity_flags(worktree: &Path, author: Option<&(String, String)>) -> Vec<String> {
if let Some((name, email)) = author {
return vec![
"-c".to_string(),
format!("user.name={name}"),
"-c".to_string(),
format!("user.email={email}"),
];
}
let (name, email) = config_identity(worktree);
let mut flags = Vec::new();
if name.is_none() {
flags.push("-c".to_string());
flags.push("user.name=rust-fs-mcp".to_string());
}
if email.is_none() {
flags.push("-c".to_string());
flags.push("user.email=rust-fs-mcp@example.invalid".to_string());
}
flags
}
fn config_identity(worktree: &Path) -> (Option<String>, Option<String>) {
let Ok(output) = run_git(worktree, &git_args(&["config", "--get-regexp", "^user\\."])) else {
return (None, None);
};
let mut name = None;
let mut email = None;
for line in output.lines() {
if let Some(value) = line.strip_prefix("user.name ") {
name = Some(value.trim().to_string());
}
else if let Some(value) = line.strip_prefix("user.email ") {
email = Some(value.trim().to_string());
}
}
(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(char::is_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("fix: 섹타 단말기 매입취소 기본값 차단"));
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),
Some(("Jane".to_string(), "jane@example.com".to_string()))
);
assert_eq!(author_identity(&json!({})), None);
}
fn temp_repo(prefix: &str) -> std::path::PathBuf {
let dir = std::env::current_dir()
.unwrap()
.join("target")
.join(format!(
"{prefix}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
run_git(&dir, &git_args(&["init"])).unwrap();
dir
}
#[test]
fn commit_identity_prefers_config_then_author_arg() {
let dir = temp_repo("rust-fs-mcp-ident");
run_git(&dir, &git_args(&["config", "user.name", "Matrix Tester"])).unwrap();
run_git(
&dir,
&git_args(&["config", "user.email", "matrix@example.com"]),
)
.unwrap();
std::fs::write(dir.join("a.txt"), "x").unwrap();
let commit = handle_git_commit(&json!({
"path": dir.display().to_string(),
"message": "test: config identity",
"filesToStage": [dir.join("a.txt").display().to_string()]
}));
assert!(!commit.is_error, "{commit:?}");
let line = run_git(
&dir,
&git_args(&["show", "-s", "--format=%an|%cn|%ae|%ce", "HEAD"]),
)
.unwrap();
assert_eq!(
line.trim(),
"Matrix Tester|Matrix Tester|matrix@example.com|matrix@example.com"
);
std::fs::write(dir.join("a.txt"), "y").unwrap();
let authored = handle_git_commit(&json!({
"path": dir.display().to_string(),
"message": "test: author arg identity",
"filesToStage": [dir.join("a.txt").display().to_string()],
"author": { "name": "Jane", "email": "jane@example.com" }
}));
assert!(!authored.is_error, "{authored:?}");
let line = run_git(&dir, &git_args(&["show", "-s", "--format=%an|%cn", "HEAD"])).unwrap();
assert_eq!(line.trim(), "Jane|Jane");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn open_repo_caches_and_invalidates_toplevel() {
let dir = temp_repo("rust-fs-mcp-tlcache");
let args = json!({ "path": dir.display().to_string() });
let first = open_repo(&args).unwrap();
let second = open_repo(&args).unwrap();
assert_eq!(first, second);
let moved = dir.join(".git-moved");
std::fs::rename(dir.join(".git"), &moved).unwrap();
let reresolved = open_repo(&args).unwrap();
assert_ne!(reresolved, first);
std::fs::rename(&moved, dir.join(".git")).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[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())
);
}
#[test]
fn git_diff_rejects_option_like_revision() {
let result = handle_git_diff(&json!({ "source": "--output=escape" }));
assert!(result.is_error, "{result:?}");
let text = result.content[0]["text"].as_str().unwrap_or("");
assert!(text.contains("must not start with '-'"), "{text}");
}
#[test]
fn git_status_requires_path() {
let result = handle_git_status(&json!({}));
assert!(result.is_error, "{result:?}");
assert_eq!(result.content[0]["text"], "Error: path is required");
}
#[test]
fn git_show_absorbs_stat_token_from_object() {
let dir = temp_repo("rust-fs-mcp-show-stat");
let commit = handle_git_commit(&json!({
"path": dir.display().to_string(),
"message": "test: create show fixture",
"allowEmpty": true
}));
assert!(!commit.is_error, "{commit:?}");
let result = handle_git_show(&json!({ "path": dir.display().to_string(), "object": "HEAD --stat" }));
assert!(!result.is_error, "{result:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn git_show_rejects_unknown_token_in_object() {
let result = handle_git_show(&json!({ "object": "HEAD --patch" }));
assert!(result.is_error, "{result:?}");
}
#[test]
fn git_show_rejects_option_like_object() {
let result = handle_git_show(&json!({ "object": "--output=escape" }));
assert!(result.is_error, "{result:?}");
let text = result.content[0]["text"].as_str().unwrap_or("");
assert!(text.contains("must not start with '-'"), "{text}");
}
}