1use super::run_git;
2use super::super::Tool;
3use async_trait::async_trait;
4use serde_json::{json, Value};
5use std::path::PathBuf;
6
7pub struct GitBranchTool {
9 workspace_root: PathBuf,
10}
11
12impl GitBranchTool {
13 pub fn new(workspace_root: PathBuf) -> Self {
14 Self { workspace_root }
15 }
16}
17
18#[async_trait]
19impl Tool for GitBranchTool {
20 fn name(&self) -> &str {
21 "git_branch"
22 }
23
24 fn description(&self) -> &str {
25 "List branches or get current branch name. Shows local and optionally remote branches."
26 }
27
28 fn parameters_schema(&self) -> Value {
29 json!({
30 "type": "object",
31 "properties": {
32 "all": {
33 "type": "boolean",
34 "description": "Show both local and remote branches (default: false)"
35 }
36 },
37 "required": []
38 })
39 }
40
41 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
42 use thulp_core::{Parameter, ParameterType};
43 thulp_core::ToolDefinition::builder("git_branch")
44 .description(self.description())
45 .parameter(Parameter::builder("all").param_type(ParameterType::Boolean).required(false)
46 .description("Show both local and remote branches (default: false)").build())
47 .build()
48 }
49
50 async fn execute(&self, args: Value) -> crate::Result<Value> {
51 let all = args["all"].as_bool().unwrap_or(false);
52
53 let (_, current, _) = run_git(&self.workspace_root, &["branch", "--show-current"]).await?;
55 let current_branch = current.trim().to_string();
56
57 let mut git_args = vec!["branch", "--format=%(refname:short)"];
59 if all {
60 git_args.push("-a");
61 }
62
63 let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
64
65 if !success {
66 return Err(crate::PawanError::Git(format!(
67 "git branch failed: {}",
68 stderr
69 )));
70 }
71
72 let branches: Vec<&str> = stdout
73 .lines()
74 .map(|l| l.trim())
75 .filter(|l| !l.is_empty())
76 .collect();
77
78 Ok(json!({
79 "current": current_branch,
80 "branches": branches,
81 "count": branches.len(),
82 "success": true
83 }))
84 }
85}
86
87pub struct GitCheckoutTool {
89 workspace_root: PathBuf,
90}
91
92impl GitCheckoutTool {
93 pub fn new(workspace_root: PathBuf) -> Self {
94 Self { workspace_root }
95 }
96}
97
98#[async_trait]
99impl Tool for GitCheckoutTool {
100 fn name(&self) -> &str {
101 "git_checkout"
102 }
103
104 fn description(&self) -> &str {
105 "Switch branches or restore working tree files. Can create new branches with create=true."
106 }
107
108 fn parameters_schema(&self) -> Value {
109 json!({
110 "type": "object",
111 "properties": {
112 "target": {
113 "type": "string",
114 "description": "Branch name, commit, or file path to checkout"
115 },
116 "create": {
117 "type": "boolean",
118 "description": "Create a new branch (git checkout -b)"
119 },
120 "files": {
121 "type": "array",
122 "items": { "type": "string" },
123 "description": "Specific files to restore (git checkout -- <files>)"
124 }
125 },
126 "required": ["target"]
127 })
128 }
129
130 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
131 use thulp_core::{Parameter, ParameterType};
132 thulp_core::ToolDefinition::builder("git_checkout")
133 .description(self.description())
134 .parameter(Parameter::builder("target").param_type(ParameterType::String).required(true)
135 .description("Branch name, commit, or file path to checkout").build())
136 .parameter(Parameter::builder("create").param_type(ParameterType::Boolean).required(false)
137 .description("Create a new branch (git checkout -b)").build())
138 .parameter(Parameter::builder("files").param_type(ParameterType::Array).required(false)
139 .description("Specific files to restore (git checkout -- <files>)").build())
140 .build()
141 }
142
143 async fn execute(&self, args: Value) -> crate::Result<Value> {
144 let target = args["target"]
145 .as_str()
146 .ok_or_else(|| crate::PawanError::Tool("target is required".into()))?;
147 let create = args["create"].as_bool().unwrap_or(false);
148 let files: Vec<&str> = args["files"]
149 .as_array()
150 .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
151 .unwrap_or_default();
152
153 let mut git_args: Vec<&str> = vec!["checkout"];
154
155 if create {
156 git_args.push("-b");
157 }
158
159 git_args.push(target);
160
161 if !files.is_empty() {
162 git_args.push("--");
163 git_args.extend(files.iter());
164 }
165
166 let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
167
168 if !success {
169 return Err(crate::PawanError::Git(format!(
170 "git checkout failed: {}",
171 stderr
172 )));
173 }
174
175 Ok(json!({
176 "success": true,
177 "target": target,
178 "created": create,
179 "output": format!("{}{}", stdout, stderr).trim().to_string()
180 }))
181 }
182}
183
184pub struct GitStashTool {
186 workspace_root: PathBuf,
187}
188
189impl GitStashTool {
190 pub fn new(workspace_root: PathBuf) -> Self {
191 Self { workspace_root }
192 }
193}
194
195#[async_trait]
196impl Tool for GitStashTool {
197 fn name(&self) -> &str {
198 "git_stash"
199 }
200
201 fn description(&self) -> &str {
202 "Stash or restore uncommitted changes. Actions: push (default), pop, list, drop."
203 }
204
205 fn parameters_schema(&self) -> Value {
206 json!({
207 "type": "object",
208 "properties": {
209 "action": {
210 "type": "string",
211 "enum": ["push", "pop", "list", "drop", "show"],
212 "description": "Stash action (default: push)"
213 },
214 "message": {
215 "type": "string",
216 "description": "Message for stash push"
217 },
218 "index": {
219 "type": "integer",
220 "description": "Stash index for pop/drop/show (default: 0)"
221 }
222 },
223 "required": []
224 })
225 }
226
227 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
228 use thulp_core::{Parameter, ParameterType};
229 thulp_core::ToolDefinition::builder("git_stash")
230 .description(self.description())
231 .parameter(Parameter::builder("action").param_type(ParameterType::String).required(false)
232 .description("Stash action (default: push)").build())
233 .parameter(Parameter::builder("message").param_type(ParameterType::String).required(false)
234 .description("Message for stash push").build())
235 .parameter(Parameter::builder("index").param_type(ParameterType::Integer).required(false)
236 .description("Stash index for pop/drop/show (default: 0)").build())
237 .build()
238 }
239
240 async fn execute(&self, args: Value) -> crate::Result<Value> {
241 let action = args["action"].as_str().unwrap_or("push");
242 let message = args["message"].as_str();
243 let index = args["index"].as_u64().unwrap_or(0);
244
245 let git_args: Vec<String> = match action {
246 "push" => {
247 let mut a = vec!["stash".to_string(), "push".to_string()];
248 if let Some(msg) = message {
249 a.push("-m".to_string());
250 a.push(msg.to_string());
251 }
252 a
253 }
254 "pop" => vec!["stash".to_string(), "pop".to_string(), format!("stash@{{{}}}", index)],
255 "list" => vec!["stash".to_string(), "list".to_string()],
256 "drop" => vec!["stash".to_string(), "drop".to_string(), format!("stash@{{{}}}", index)],
257 "show" => vec!["stash".to_string(), "show".to_string(), "-p".to_string(), format!("stash@{{{}}}", index)],
258 _ => return Err(crate::PawanError::Tool(format!("Unknown stash action: {}", action))),
259 };
260
261 let git_args_ref: Vec<&str> = git_args.iter().map(|s| s.as_str()).collect();
262 let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args_ref).await?;
263
264 if !success {
265 return Err(crate::PawanError::Git(format!(
266 "git stash {} failed: {}",
267 action, stderr
268 )));
269 }
270
271 Ok(json!({
272 "success": true,
273 "action": action,
274 "output": stdout.trim().to_string()
275 }))
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use crate::tools::git::staging::{GitAddTool, GitCommitTool};
283 use serde_json::json;
284 use tempfile::TempDir;
285 use tokio::process::Command;
286
287 async fn setup_git_repo() -> TempDir {
288 let temp_dir = TempDir::new().unwrap();
289
290 Command::new("git")
291 .args(["init"])
292 .current_dir(temp_dir.path())
293 .output()
294 .await
295 .unwrap();
296
297 Command::new("git")
298 .args(["config", "user.email", "test@test.com"])
299 .current_dir(temp_dir.path())
300 .output()
301 .await
302 .unwrap();
303
304 Command::new("git")
305 .args(["config", "user.name", "Test User"])
306 .current_dir(temp_dir.path())
307 .output()
308 .await
309 .unwrap();
310
311 temp_dir
312 }
313
314 #[tokio::test]
315 async fn test_git_branch_list() {
316 let temp_dir = setup_git_repo().await;
317 std::fs::write(temp_dir.path().join("f.txt"), "init").unwrap();
318 Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
319 Command::new("git").args(["commit", "-m", "init"]).current_dir(temp_dir.path()).output().await.unwrap();
320
321 let tool = GitBranchTool::new(temp_dir.path().into());
322 let result = tool.execute(json!({})).await.unwrap();
323 assert!(result["success"].as_bool().unwrap());
324 let branches = result["branches"].as_array().unwrap();
325 assert!(!branches.is_empty(), "Should have at least one branch");
326 assert!(result["current"].as_str().is_some());
327 }
328
329 #[tokio::test]
330 async fn test_git_checkout_create_branch() {
331 let temp_dir = setup_git_repo().await;
332 std::fs::write(temp_dir.path().join("f.txt"), "init").unwrap();
333 Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
334 Command::new("git").args(["commit", "-m", "init"]).current_dir(temp_dir.path()).output().await.unwrap();
335
336 let tool = GitCheckoutTool::new(temp_dir.path().into());
337 let result = tool.execute(json!({"target": "feature-test", "create": true})).await.unwrap();
338 assert!(result["success"].as_bool().unwrap());
339
340 let branch_tool = GitBranchTool::new(temp_dir.path().into());
342 let branches = branch_tool.execute(json!({})).await.unwrap();
343 assert_eq!(branches["current"].as_str().unwrap(), "feature-test");
344 }
345
346 #[tokio::test]
347 async fn test_git_stash_on_clean_repo() {
348 let temp_dir = setup_git_repo().await;
349 let tool = GitStashTool::new(temp_dir.path().into());
350 let result = tool.execute(json!({"action": "list"})).await.unwrap();
352 assert!(result["success"].as_bool().unwrap());
353 }
354
355 #[tokio::test]
356 async fn test_git_stash_on_dirty_repo_saves_changes() {
357 let temp_dir = setup_git_repo().await;
358 std::fs::write(temp_dir.path().join("base.txt"), "v1").unwrap();
360 GitAddTool::new(temp_dir.path().to_path_buf())
361 .execute(json!({ "files": ["base.txt"] }))
362 .await
363 .unwrap();
364 GitCommitTool::new(temp_dir.path().to_path_buf())
365 .execute(json!({ "message": "base" }))
366 .await
367 .unwrap();
368
369 std::fs::write(temp_dir.path().join("base.txt"), "v2-dirty").unwrap();
371
372 let stash_tool = GitStashTool::new(temp_dir.path().to_path_buf());
373 let result = stash_tool
374 .execute(json!({ "action": "push", "message": "test stash" }))
375 .await
376 .unwrap();
377 assert!(result["success"].as_bool().unwrap());
378
379 let content = std::fs::read_to_string(temp_dir.path().join("base.txt")).unwrap();
381 assert_eq!(content, "v1", "stash push should revert working tree");
382 }
383
384 #[tokio::test]
385 async fn test_git_checkout_nonexistent_branch_without_create_errors() {
386 let temp_dir = setup_git_repo().await;
389 std::fs::write(temp_dir.path().join("init.txt"), "init").unwrap();
390 Command::new("git")
391 .args(["add", "."])
392 .current_dir(temp_dir.path())
393 .output()
394 .await
395 .unwrap();
396 Command::new("git")
397 .args(["commit", "-m", "init"])
398 .current_dir(temp_dir.path())
399 .output()
400 .await
401 .unwrap();
402
403 let tool = GitCheckoutTool::new(temp_dir.path().to_path_buf());
404 let result = tool
405 .execute(json!({
406 "target": "nonexistent-branch-xyz-abc-9999",
407 "create": false
408 }))
409 .await;
410 assert!(
411 result.is_err(),
412 "checkout to nonexistent branch without create must error"
413 );
414 }
415}