use super::super::Tool;
use super::run_git;
use async_trait::async_trait;
use serde_json::{json, Value};
use std::path::PathBuf;
pub struct GitBranchTool {
workspace_root: PathBuf,
}
impl GitBranchTool {
pub fn new(workspace_root: PathBuf) -> Self {
Self { workspace_root }
}
}
#[async_trait]
impl Tool for GitBranchTool {
fn name(&self) -> &str {
"git_branch"
}
fn description(&self) -> &str {
"List branches or get current branch name. Shows local and optionally remote branches."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"all": {
"type": "boolean",
"description": "Show both local and remote branches (default: false)"
}
},
"required": []
})
}
fn thulp_definition(&self) -> thulp_core::ToolDefinition {
use thulp_core::{Parameter, ParameterType};
thulp_core::ToolDefinition::builder("git_branch")
.description(self.description())
.parameter(
Parameter::builder("all")
.param_type(ParameterType::Boolean)
.required(false)
.description("Show both local and remote branches (default: false)")
.build(),
)
.build()
}
async fn execute(&self, args: Value) -> crate::Result<Value> {
let all = args["all"].as_bool().unwrap_or(false);
let (_, current, _) = run_git(&self.workspace_root, &["branch", "--show-current"]).await?;
let current_branch = current.trim().to_string();
let mut git_args = vec!["branch", "--format=%(refname:short)"];
if all {
git_args.push("-a");
}
let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
if !success {
return Err(crate::PawanError::Git(format!(
"git branch failed: {}",
stderr
)));
}
let branches: Vec<&str> = stdout
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect();
Ok(json!({
"current": current_branch,
"branches": branches,
"count": branches.len(),
"success": true
}))
}
}
pub struct GitCheckoutTool {
workspace_root: PathBuf,
}
impl GitCheckoutTool {
pub fn new(workspace_root: PathBuf) -> Self {
Self { workspace_root }
}
}
#[async_trait]
impl Tool for GitCheckoutTool {
fn name(&self) -> &str {
"git_checkout"
}
fn description(&self) -> &str {
"Switch branches or restore working tree files. Can create new branches with create=true."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"target": {
"type": "string",
"description": "Branch name, commit, or file path to checkout"
},
"create": {
"type": "boolean",
"description": "Create a new branch (git checkout -b)"
},
"files": {
"type": "array",
"items": { "type": "string" },
"description": "Specific files to restore (git checkout -- <files>)"
}
},
"required": ["target"]
})
}
fn thulp_definition(&self) -> thulp_core::ToolDefinition {
use thulp_core::{Parameter, ParameterType};
thulp_core::ToolDefinition::builder("git_checkout")
.description(self.description())
.parameter(
Parameter::builder("target")
.param_type(ParameterType::String)
.required(true)
.description("Branch name, commit, or file path to checkout")
.build(),
)
.parameter(
Parameter::builder("create")
.param_type(ParameterType::Boolean)
.required(false)
.description("Create a new branch (git checkout -b)")
.build(),
)
.parameter(
Parameter::builder("files")
.param_type(ParameterType::Array)
.required(false)
.description("Specific files to restore (git checkout -- <files>)")
.build(),
)
.build()
}
async fn execute(&self, args: Value) -> crate::Result<Value> {
let target = args["target"]
.as_str()
.ok_or_else(|| crate::PawanError::Tool("target is required".into()))?;
let create = args["create"].as_bool().unwrap_or(false);
let files: Vec<&str> = args["files"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
let mut git_args: Vec<&str> = vec!["checkout"];
if create {
git_args.push("-b");
}
git_args.push(target);
if !files.is_empty() {
git_args.push("--");
git_args.extend(files.iter());
}
let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
if !success {
return Err(crate::PawanError::Git(format!(
"git checkout failed: {}",
stderr
)));
}
Ok(json!({
"success": true,
"target": target,
"created": create,
"output": format!("{}{}", stdout, stderr).trim().to_string()
}))
}
}
pub struct GitStashTool {
workspace_root: PathBuf,
}
impl GitStashTool {
pub fn new(workspace_root: PathBuf) -> Self {
Self { workspace_root }
}
}
#[async_trait]
impl Tool for GitStashTool {
fn name(&self) -> &str {
"git_stash"
}
fn description(&self) -> &str {
"Stash or restore uncommitted changes. Actions: push (default), pop, list, drop."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["push", "pop", "list", "drop", "show"],
"description": "Stash action (default: push)"
},
"message": {
"type": "string",
"description": "Message for stash push"
},
"index": {
"type": "integer",
"description": "Stash index for pop/drop/show (default: 0)"
}
},
"required": []
})
}
fn thulp_definition(&self) -> thulp_core::ToolDefinition {
use thulp_core::{Parameter, ParameterType};
thulp_core::ToolDefinition::builder("git_stash")
.description(self.description())
.parameter(
Parameter::builder("action")
.param_type(ParameterType::String)
.required(false)
.description("Stash action (default: push)")
.build(),
)
.parameter(
Parameter::builder("message")
.param_type(ParameterType::String)
.required(false)
.description("Message for stash push")
.build(),
)
.parameter(
Parameter::builder("index")
.param_type(ParameterType::Integer)
.required(false)
.description("Stash index for pop/drop/show (default: 0)")
.build(),
)
.build()
}
async fn execute(&self, args: Value) -> crate::Result<Value> {
let action = args["action"].as_str().unwrap_or("push");
let message = args["message"].as_str();
let index = args["index"].as_u64().unwrap_or(0);
let git_args: Vec<String> = match action {
"push" => {
let mut a = vec!["stash".to_string(), "push".to_string()];
if let Some(msg) = message {
a.push("-m".to_string());
a.push(msg.to_string());
}
a
}
"pop" => vec![
"stash".to_string(),
"pop".to_string(),
format!("stash@{{{}}}", index),
],
"list" => vec!["stash".to_string(), "list".to_string()],
"drop" => vec![
"stash".to_string(),
"drop".to_string(),
format!("stash@{{{}}}", index),
],
"show" => vec![
"stash".to_string(),
"show".to_string(),
"-p".to_string(),
format!("stash@{{{}}}", index),
],
_ => {
return Err(crate::PawanError::Tool(format!(
"Unknown stash action: {}",
action
)))
}
};
let git_args_ref: Vec<&str> = git_args.iter().map(|s| s.as_str()).collect();
let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args_ref).await?;
if !success {
return Err(crate::PawanError::Git(format!(
"git stash {} failed: {}",
action, stderr
)));
}
Ok(json!({
"success": true,
"action": action,
"output": stdout.trim().to_string()
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::git::staging::{GitAddTool, GitCommitTool};
use serde_json::json;
use tempfile::TempDir;
use tokio::process::Command;
async fn setup_git_repo() -> TempDir {
let temp_dir = TempDir::new().unwrap();
Command::new("git")
.args(["init"])
.current_dir(temp_dir.path())
.output()
.await
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(temp_dir.path())
.output()
.await
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await
.unwrap();
temp_dir
}
#[tokio::test]
async fn test_git_branch_list() {
let temp_dir = setup_git_repo().await;
std::fs::write(temp_dir.path().join("f.txt"), "init").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await
.unwrap();
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(temp_dir.path())
.output()
.await
.unwrap();
let tool = GitBranchTool::new(temp_dir.path().into());
let result = tool.execute(json!({})).await.unwrap();
assert!(result["success"].as_bool().unwrap());
let branches = result["branches"].as_array().unwrap();
assert!(!branches.is_empty(), "Should have at least one branch");
assert!(result["current"].as_str().is_some());
}
#[tokio::test]
async fn test_git_checkout_create_branch() {
let temp_dir = setup_git_repo().await;
std::fs::write(temp_dir.path().join("f.txt"), "init").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await
.unwrap();
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(temp_dir.path())
.output()
.await
.unwrap();
let tool = GitCheckoutTool::new(temp_dir.path().into());
let result = tool
.execute(json!({"target": "feature-test", "create": true}))
.await
.unwrap();
assert!(result["success"].as_bool().unwrap());
let branch_tool = GitBranchTool::new(temp_dir.path().into());
let branches = branch_tool.execute(json!({})).await.unwrap();
assert_eq!(branches["current"].as_str().unwrap(), "feature-test");
}
#[tokio::test]
async fn test_git_stash_on_clean_repo() {
let temp_dir = setup_git_repo().await;
let tool = GitStashTool::new(temp_dir.path().into());
let result = tool.execute(json!({"action": "list"})).await.unwrap();
assert!(result["success"].as_bool().unwrap());
}
#[tokio::test]
async fn test_git_stash_on_dirty_repo_saves_changes() {
let temp_dir = setup_git_repo().await;
std::fs::write(temp_dir.path().join("base.txt"), "v1").unwrap();
GitAddTool::new(temp_dir.path().to_path_buf())
.execute(json!({ "files": ["base.txt"] }))
.await
.unwrap();
GitCommitTool::new(temp_dir.path().to_path_buf())
.execute(json!({ "message": "base" }))
.await
.unwrap();
std::fs::write(temp_dir.path().join("base.txt"), "v2-dirty").unwrap();
let stash_tool = GitStashTool::new(temp_dir.path().to_path_buf());
let result = stash_tool
.execute(json!({ "action": "push", "message": "test stash" }))
.await
.unwrap();
assert!(result["success"].as_bool().unwrap());
let content = std::fs::read_to_string(temp_dir.path().join("base.txt")).unwrap();
assert_eq!(content, "v1", "stash push should revert working tree");
}
#[tokio::test]
async fn test_git_checkout_nonexistent_branch_without_create_errors() {
let temp_dir = setup_git_repo().await;
std::fs::write(temp_dir.path().join("init.txt"), "init").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await
.unwrap();
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(temp_dir.path())
.output()
.await
.unwrap();
let tool = GitCheckoutTool::new(temp_dir.path().to_path_buf());
let result = tool
.execute(json!({
"target": "nonexistent-branch-xyz-abc-9999",
"create": false
}))
.await;
assert!(
result.is_err(),
"checkout to nonexistent branch without create must error"
);
}
}