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