1use async_trait::async_trait;
23use clap::{CommandFactory, Parser};
24
25use kaish_types::Value;
26use kaish_types::{ExecResult, OutputData};
27use kaish_tool_api::{schema_from_clap, GlobalFlags, Tool, ToolArgs, ToolCtx, ToolSchema};
28use crate::git_vfs::GitVfs;
29
30pub struct Git;
32
33#[derive(Parser, Debug)]
41#[command(name = "git", about = "Version control operations")]
42struct GitArgs {
43 #[arg(short = 's', long = "short")]
45 short: bool,
46
47 #[arg(long = "porcelain")]
49 porcelain: bool,
50
51 #[arg(long = "oneline")]
53 oneline: bool,
54
55 #[arg(short = 'f', long = "force")]
57 force: bool,
58
59 #[arg(short = 'm', long = "message")]
61 message: Option<String>,
62
63 #[arg(short = 'n', long = "count")]
65 count: Option<String>,
66
67 #[arg(short = 'c')]
69 c: Option<String>,
70
71 #[arg(short = 'b')]
73 b: Option<String>,
74
75 #[arg(long = "author")]
77 author: Option<String>,
78
79 #[arg(long = "reason")]
81 reason: Option<String>,
82
83 #[command(flatten)]
84 global: GlobalFlags,
85
86 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
88 subcommand: Vec<String>,
89}
90
91#[async_trait]
92impl Tool for Git {
93 fn name(&self) -> &str {
94 "git"
95 }
96
97 fn schema(&self) -> ToolSchema {
98 schema_from_clap(
99 &GitArgs::command(),
100 "git",
101 "Version control operations",
102 [
103 ("Show status", "git status"),
104 ("Stage and commit", "git add file.rs; git commit -m 'fix bug'"),
105 ("Recent log", "git log -n 5 --oneline"),
106 ],
107 )
108 }
109
110 async fn execute(&self, mut args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult {
111 args.flagify_bool_named();
112
113 let parsed = match GitArgs::try_parse_from(
114 std::iter::once("git".to_string()).chain(args.to_argv()),
115 ) {
116 Ok(p) => p,
117 Err(e) => return ExecResult::failure(2, format!("git: {e}")),
118 };
119 parsed.global.apply(ctx);
120
121 let subcommand = match args.get_string("subcommand", 0) {
123 Some(s) => s,
124 None => return ExecResult::failure(1, "git: missing subcommand"),
125 };
126
127 let rest_args: Vec<String> = args
129 .positional
130 .iter()
131 .skip(1)
132 .filter_map(|v| match v {
133 Value::String(s) => Some(s.clone()),
134 Value::Int(i) => Some(i.to_string()),
135 _ => None,
136 })
137 .collect();
138
139 match subcommand.as_str() {
141 "init" => git_init(&rest_args, ctx).await,
142 "clone" => git_clone(&rest_args, ctx).await,
143 "status" => git_status(&args, ctx).await,
144 "add" => git_add(&rest_args, ctx).await,
145 "commit" => git_commit(&args, ctx).await,
146 "log" => git_log(&args, ctx).await,
147 "diff" => git_diff(ctx).await,
148 "branch" => git_branch(&args, ctx).await,
149 "checkout" => git_checkout(&rest_args, ctx).await,
150 "worktree" => git_worktree(&args, &rest_args, ctx).await,
151 _ => ExecResult::failure(1, format!("git: unknown subcommand '{}'", subcommand)),
152 }
153 }
154}
155
156async fn git_init(args: &[String], ctx: &dyn ToolCtx) -> ExecResult {
158 let vfs_path = if args.is_empty() {
159 ctx.cwd().to_path_buf()
160 } else {
161 ctx.resolve_path(&args[0])
162 };
163
164 let real_path = match ctx.backend().resolve_real_path(&vfs_path) {
166 Some(p) => p,
167 None => {
168 return ExecResult::failure(
169 1,
170 format!("git init: {} is not on a real filesystem", vfs_path.display()),
171 )
172 }
173 };
174
175 match GitVfs::init(&real_path) {
176 Ok(_) => ExecResult::with_output(OutputData::text(format!(
177 "Initialized empty Git repository in {}",
178 vfs_path.display()
179 ))),
180 Err(e) => ExecResult::failure(1, format!("git init: {}", e)),
181 }
182}
183
184async fn git_clone(args: &[String], ctx: &dyn ToolCtx) -> ExecResult {
186 if args.is_empty() {
187 return ExecResult::failure(1, "git clone: missing repository URL");
188 }
189
190 let url = &args[0];
191 let vfs_dest = if args.len() > 1 {
192 ctx.resolve_path(&args[1])
193 } else {
194 let name = url
196 .rsplit('/')
197 .next()
198 .unwrap_or("repo")
199 .strip_suffix(".git")
200 .unwrap_or(url.rsplit('/').next().unwrap_or("repo"));
201 ctx.resolve_path(name)
202 };
203
204 let parent = vfs_dest.parent().unwrap_or(&vfs_dest);
207 let real_parent = match ctx.backend().resolve_real_path(parent) {
208 Some(p) => p,
209 None => {
210 return ExecResult::failure(
211 1,
212 format!("git clone: {} is not on a real filesystem", parent.display()),
213 )
214 }
215 };
216 let real_dest = if let Some(name) = vfs_dest.file_name() {
217 real_parent.join(name)
218 } else {
219 real_parent
220 };
221
222 match GitVfs::clone(url, &real_dest) {
223 Ok(_) => ExecResult::with_output(OutputData::text(format!("Cloning into '{}'...\ndone.", vfs_dest.display()))),
224 Err(e) => ExecResult::failure(1, format!("git clone: {}", e)),
225 }
226}
227
228async fn git_status(args: &ToolArgs, ctx: &dyn ToolCtx) -> ExecResult {
230 let git = match open_repo(ctx) {
231 Ok(g) => g,
232 Err(e) => return e,
233 };
234
235 let short = args.has_flag("s") || args.has_flag("short");
236 let porcelain = args.has_flag("porcelain");
237
238 match git.status() {
239 Ok(statuses) => {
240 if statuses.is_empty() {
241 if porcelain {
242 return ExecResult::success("");
243 }
244 return ExecResult::with_output(OutputData::text("nothing to commit, working tree clean"));
245 }
246
247 let mut output = String::new();
248
249 if porcelain || short {
250 for file in &statuses {
252 output.push_str(file.status_char());
253 output.push(' ');
254 output.push_str(&file.path);
255 output.push('\n');
256 }
257 } else {
258 let mut staged = Vec::new();
260 let mut modified = Vec::new();
261 let mut untracked = Vec::new();
262
263 for file in &statuses {
264 if file.status.is_index_new()
265 || file.status.is_index_modified()
266 || file.status.is_index_deleted()
267 {
268 staged.push(&file.path);
269 }
270 if file.status.is_wt_modified() || file.status.is_wt_deleted() {
271 modified.push(&file.path);
272 }
273 if file.status.is_wt_new() && !file.status.is_index_new() {
274 untracked.push(&file.path);
275 }
276 }
277
278 if let Ok(Some(branch)) = git.current_branch() {
279 output.push_str(&format!("On branch {}\n\n", branch));
280 }
281
282 if !staged.is_empty() {
283 output.push_str("Changes to be committed:\n");
284 for path in staged {
285 output.push_str(&format!(" {}\n", path));
286 }
287 output.push('\n');
288 }
289
290 if !modified.is_empty() {
291 output.push_str("Changes not staged for commit:\n");
292 for path in modified {
293 output.push_str(&format!(" modified: {}\n", path));
294 }
295 output.push('\n');
296 }
297
298 if !untracked.is_empty() {
299 output.push_str("Untracked files:\n");
300 for path in untracked {
301 output.push_str(&format!(" {}\n", path));
302 }
303 }
304 }
305
306 ExecResult::with_output(OutputData::text(output.trim_end()))
307 }
308 Err(e) => ExecResult::failure(1, format!("git status: {}", e)),
309 }
310}
311
312async fn git_add(args: &[String], ctx: &dyn ToolCtx) -> ExecResult {
314 if args.is_empty() {
315 return ExecResult::failure(1, "git add: missing pathspec");
316 }
317
318 let git = match open_repo(ctx) {
319 Ok(g) => g,
320 Err(e) => return e,
321 };
322
323 let pathspecs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
325
326 match git.add(&pathspecs) {
327 Ok(()) => ExecResult::success(""),
328 Err(e) => ExecResult::failure(1, format!("git add: {}", e)),
329 }
330}
331
332async fn git_commit(args: &ToolArgs, ctx: &dyn ToolCtx) -> ExecResult {
334 let git = match open_repo(ctx) {
335 Ok(g) => g,
336 Err(e) => return e,
337 };
338
339 let message = args
341 .get_string("m", usize::MAX)
342 .or_else(|| args.get_string("message", usize::MAX));
343
344 let message = match message {
345 Some(m) => m,
346 None => return ExecResult::failure(1, "git commit: missing commit message (-m)"),
347 };
348
349 let author = args.get_string("author", usize::MAX);
351
352 match git.commit(&message, author.as_deref()) {
353 Ok(oid) => {
354 let short = &oid.to_string()[..7];
355 ExecResult::with_output(OutputData::text(format!("[{short}] {message}")))
356 }
357 Err(e) => ExecResult::failure(1, format!("git commit: {}", e)),
358 }
359}
360
361async fn git_log(args: &ToolArgs, ctx: &dyn ToolCtx) -> ExecResult {
363 let git = match open_repo(ctx) {
364 Ok(g) => g,
365 Err(e) => return e,
366 };
367
368 let count = args
370 .get("count", usize::MAX)
371 .and_then(|v| match v {
372 Value::Int(i) => Some(*i as usize),
373 Value::String(s) => s.parse().ok(),
374 _ => None,
375 })
376 .unwrap_or(10);
377
378 let oneline = args.has_flag("oneline");
379
380 match git.log(count) {
381 Ok(entries) => {
382 if entries.is_empty() {
383 return ExecResult::with_output(OutputData::text("No commits yet"));
384 }
385
386 let mut output = String::new();
387
388 for entry in entries {
389 if oneline {
390 output.push_str(&format!("{} {}\n", entry.short_id, first_line(&entry.message)));
391 } else {
392 output.push_str(&format!("commit {}\n", entry.oid));
393 output.push_str(&format!("Author: {} <{}>\n", entry.author, entry.email));
394 output.push_str(&format!(
395 "Date: {}\n",
396 format_timestamp(entry.time)
397 ));
398 output.push_str(&format!("\n {}\n\n", entry.message.trim()));
399 }
400 }
401
402 ExecResult::with_output(OutputData::text(output.trim_end()))
403 }
404 Err(e) => ExecResult::failure(1, format!("git log: {}", e)),
405 }
406}
407
408async fn git_diff(ctx: &dyn ToolCtx) -> ExecResult {
410 let git = match open_repo(ctx) {
411 Ok(g) => g,
412 Err(e) => return e,
413 };
414
415 match git.diff() {
416 Ok(diff) => ExecResult::with_output(OutputData::text(diff.trim_end())),
417 Err(e) => ExecResult::failure(1, format!("git diff: {}", e)),
418 }
419}
420
421async fn git_branch(args: &ToolArgs, ctx: &dyn ToolCtx) -> ExecResult {
423 let git = match open_repo(ctx) {
424 Ok(g) => g,
425 Err(e) => return e,
426 };
427
428 let create = args.get_string("c", usize::MAX).or_else(|| args.get_string("b", usize::MAX));
430
431 if let Some(name) = create {
432 match git.create_branch(&name) {
434 Ok(()) => {
435 if args.has_flag("b") {
437 match git.checkout(&name) {
438 Ok(()) => {
439 return ExecResult::with_output(OutputData::text(format!("Switched to a new branch '{}'", name)))
440 }
441 Err(e) => return ExecResult::failure(1, format!("git checkout: {}", e)),
442 }
443 }
444 ExecResult::with_output(OutputData::text(format!("Branch '{}' created", name)))
445 }
446 Err(e) => ExecResult::failure(1, format!("git branch: {}", e)),
447 }
448 } else {
449 match git.branches() {
451 Ok(branches) => {
452 let current = git.current_branch().ok().flatten();
453 let mut output = String::new();
454
455 for branch in branches {
456 let marker = if current.as_ref() == Some(&branch) {
457 "* "
458 } else {
459 " "
460 };
461 output.push_str(&format!("{}{}\n", marker, branch));
462 }
463
464 ExecResult::with_output(OutputData::text(output.trim_end()))
465 }
466 Err(e) => ExecResult::failure(1, format!("git branch: {}", e)),
467 }
468 }
469}
470
471async fn git_checkout(args: &[String], ctx: &dyn ToolCtx) -> ExecResult {
473 if args.is_empty() {
474 return ExecResult::failure(1, "git checkout: missing branch or commit");
475 }
476
477 let git = match open_repo(ctx) {
478 Ok(g) => g,
479 Err(e) => return e,
480 };
481
482 let target = &args[0];
483
484 match git.checkout(target) {
485 Ok(()) => ExecResult::with_output(OutputData::text(format!("Switched to '{}'", target))),
486 Err(e) => ExecResult::failure(1, format!("git checkout: {}", e)),
487 }
488}
489
490async fn git_worktree(args: &ToolArgs, rest_args: &[String], ctx: &dyn ToolCtx) -> ExecResult {
492 if rest_args.is_empty() {
493 return ExecResult::failure(1, "git worktree: missing subcommand (list, add, remove, lock, unlock, prune)");
494 }
495
496 let git = match open_repo(ctx) {
497 Ok(g) => g,
498 Err(e) => return e,
499 };
500
501 let subcmd = &rest_args[0];
502 let subargs = &rest_args[1..];
503
504 match subcmd.as_str() {
505 "list" => worktree_list(&git),
506 "add" => worktree_add(&git, subargs, ctx),
507 "remove" => worktree_remove(&git, subargs, args),
508 "lock" => worktree_lock(&git, subargs, args),
509 "unlock" => worktree_unlock(&git, subargs),
510 "prune" => worktree_prune(&git),
511 _ => ExecResult::failure(1, format!("git worktree: unknown subcommand '{}'", subcmd)),
512 }
513}
514
515fn worktree_list(git: &GitVfs) -> ExecResult {
517 match git.worktrees() {
518 Ok(worktrees) => {
519 let mut output = String::new();
520 for wt in worktrees {
521 let name_display = wt.name.as_deref().unwrap_or("(main)");
523 let head_display = wt.head.as_deref().unwrap_or("(detached)");
524 let lock_marker = if wt.locked { " [locked]" } else { "" };
525
526 output.push_str(&format!(
527 "{:<40} {:<12} [{}]{}\n",
528 wt.path.display(),
529 head_display,
530 name_display,
531 lock_marker
532 ));
533 }
534 ExecResult::with_output(OutputData::text(output.trim_end()))
535 }
536 Err(e) => ExecResult::failure(1, format!("git worktree list: {}", e)),
537 }
538}
539
540fn worktree_add(git: &GitVfs, args: &[String], ctx: &dyn ToolCtx) -> ExecResult {
542 if args.is_empty() {
543 return ExecResult::failure(1, "git worktree add: missing path");
544 }
545
546 let path_arg = &args[0];
547 let branch = args.get(1).map(|s| s.as_str());
548
549 let vfs_path = ctx.resolve_path(path_arg);
551
552 let real_path = match ctx.backend().resolve_real_path(&vfs_path) {
554 Some(p) => p,
555 None => {
556 let parent = vfs_path.parent().unwrap_or(&vfs_path);
558 match ctx.backend().resolve_real_path(parent) {
559 Some(p) => {
560 if let Some(name) = vfs_path.file_name() {
561 p.join(name)
562 } else {
563 p
564 }
565 }
566 None => {
567 return ExecResult::failure(
568 1,
569 format!("git worktree add: {} is not on a real filesystem", vfs_path.display()),
570 )
571 }
572 }
573 }
574 };
575
576 let name = real_path
578 .file_name()
579 .and_then(|n| n.to_str())
580 .unwrap_or("worktree");
581
582 match git.worktree_add(name, &real_path, branch) {
583 Ok(info) => {
584 let branch_msg = info.head.as_deref().unwrap_or(name);
585 ExecResult::with_output(OutputData::text(format!(
586 "Preparing worktree (new branch '{}')\nHEAD is now at {}",
587 branch_msg,
588 info.path.display()
589 )))
590 }
591 Err(e) => ExecResult::failure(1, format!("git worktree add: {}", e)),
592 }
593}
594
595fn worktree_remove(git: &GitVfs, args: &[String], tool_args: &ToolArgs) -> ExecResult {
597 if args.is_empty() {
598 return ExecResult::failure(1, "git worktree remove: missing worktree name");
599 }
600
601 let name = &args[0];
602 let force = tool_args.has_flag("f") || tool_args.has_flag("force");
603
604 match git.worktree_remove(name, force) {
605 Ok(()) => ExecResult::with_output(OutputData::text(format!("Removed worktree '{}'", name))),
606 Err(e) => ExecResult::failure(1, format!("git worktree remove: {}", e)),
607 }
608}
609
610fn worktree_lock(git: &GitVfs, args: &[String], tool_args: &ToolArgs) -> ExecResult {
612 if args.is_empty() {
613 return ExecResult::failure(1, "git worktree lock: missing worktree name");
614 }
615
616 let name = &args[0];
617 let reason = tool_args.get_string("reason", usize::MAX);
618
619 match git.worktree_lock(name, reason.as_deref()) {
620 Ok(()) => ExecResult::with_output(OutputData::text(format!("Locked worktree '{}'", name))),
621 Err(e) => ExecResult::failure(1, format!("git worktree lock: {}", e)),
622 }
623}
624
625fn worktree_unlock(git: &GitVfs, args: &[String]) -> ExecResult {
627 if args.is_empty() {
628 return ExecResult::failure(1, "git worktree unlock: missing worktree name");
629 }
630
631 let name = &args[0];
632
633 match git.worktree_unlock(name) {
634 Ok(()) => ExecResult::with_output(OutputData::text(format!("Unlocked worktree '{}'", name))),
635 Err(e) => ExecResult::failure(1, format!("git worktree unlock: {}", e)),
636 }
637}
638
639fn worktree_prune(git: &GitVfs) -> ExecResult {
641 match git.worktree_prune() {
642 Ok(count) => {
643 if count == 0 {
644 ExecResult::with_output(OutputData::text("Nothing to prune"))
645 } else {
646 ExecResult::with_output(OutputData::text(format!("Pruned {} stale worktree(s)", count)))
647 }
648 }
649 Err(e) => ExecResult::failure(1, format!("git worktree prune: {}", e)),
650 }
651}
652
653#[allow(clippy::result_large_err)]
655fn open_repo(ctx: &dyn ToolCtx) -> Result<GitVfs, ExecResult> {
656 let real_path = ctx.backend().resolve_real_path(ctx.cwd()).ok_or_else(|| {
658 ExecResult::failure(
659 128,
660 format!(
661 "fatal: not a git repository: {} is not on a real filesystem",
662 ctx.cwd().display()
663 ),
664 )
665 })?;
666
667 GitVfs::open(&real_path).map_err(|e| {
668 ExecResult::failure(
669 128,
670 format!("fatal: not a git repository: {}", e),
671 )
672 })
673}
674
675fn first_line(s: &str) -> &str {
677 s.lines().next().unwrap_or(s)
678}
679
680fn format_timestamp(secs: i64) -> String {
682 use chrono::{DateTime, Utc};
683 let dt = DateTime::from_timestamp(secs, 0).unwrap_or(DateTime::<Utc>::UNIX_EPOCH);
684 dt.format("%a %b %d %H:%M:%S %Y %z").to_string()
685}