Skip to main content

kaish_tools_git/
git_tool.rs

1//! git — Version control operations via git2-rs.
2//!
3//! # Examples
4//!
5//! ```kaish
6//! git init                        # Initialize a new repository
7//! git clone https://url.git dir   # Clone a repository
8//! git status                      # Show working tree status
9//! git add src/*.rs                # Stage files
10//! git commit -m "message"         # Create a commit
11//! git log -n 5                    # Show recent commits
12//! git diff                        # Show changes
13//! git branch                      # List branches
14//! git branch -c feature           # Create a new branch
15//! git checkout main               # Switch branches
16//! git worktree list               # List worktrees
17//! git worktree add ../wt feature  # Create worktree for branch
18//! git worktree remove wt-name     # Remove a worktree
19//! git worktree prune              # Clean stale worktrees
20//! ```
21
22use 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
30/// Git tool: version control operations via git2-rs.
31pub struct Git;
32
33/// clap-derived argv layer for git. See docs/clap-migration.md.
34///
35/// git multiplexes subcommands; rather than enumerate every subflag, the
36/// struct declares the flags / named values our handlers consult (-s, -m,
37/// -c, -b, -n, -f, --oneline, --short, --porcelain, --author, --reason)
38/// plus a trailing positional sink. Subcommand-specific argv is read off
39/// `args.positional` like before.
40#[derive(Parser, Debug)]
41#[command(name = "git", about = "Version control operations")]
42struct GitArgs {
43    /// Short status format (-s).
44    #[arg(short = 's', long = "short")]
45    short: bool,
46
47    /// Porcelain status format.
48    #[arg(long = "porcelain")]
49    porcelain: bool,
50
51    /// One-line log format.
52    #[arg(long = "oneline")]
53    oneline: bool,
54
55    /// Force flag (-f) for worktree remove etc.
56    #[arg(short = 'f', long = "force")]
57    force: bool,
58
59    /// Commit message.
60    #[arg(short = 'm', long = "message")]
61    message: Option<String>,
62
63    /// Number of entries / count.
64    #[arg(short = 'n', long = "count")]
65    count: Option<String>,
66
67    /// Branch name to create (-c).
68    #[arg(short = 'c')]
69    c: Option<String>,
70
71    /// Branch name to create and checkout (-b).
72    #[arg(short = 'b')]
73    b: Option<String>,
74
75    /// Author for commit.
76    #[arg(long = "author")]
77    author: Option<String>,
78
79    /// Reason for worktree lock.
80    #[arg(long = "reason")]
81    reason: Option<String>,
82
83    #[command(flatten)]
84    global: GlobalFlags,
85
86    /// Subcommand (`status`, `commit`, `branch`, …) followed by its arguments.
87    #[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        // Get subcommand
122        let subcommand = match args.get_string("subcommand", 0) {
123            Some(s) => s,
124            None => return ExecResult::failure(1, "git: missing subcommand"),
125        };
126
127        // Collect remaining positional args
128        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        // Route to subcommand handler
140        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
156/// Initialize a new git repository.
157async 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    // Resolve VFS path to real filesystem path
165    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
184/// Clone a repository.
185async 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        // Extract repo name from URL
195        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    // Resolve VFS path to real filesystem path
205    // For clone, the destination doesn't exist yet, so resolve the parent
206    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
228/// Show repository status.
229async 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                // Short format: XY filename
251                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                // Long format
259                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
312/// Add files to the staging area.
313async 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    // Convert args to references
324    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
332/// Create a commit.
333async 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    // Get commit message
340    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    // Get author if specified
350    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
361/// Show commit log.
362async 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    // Get count (-n / count param)
369    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
408/// Show diff.
409async 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
421/// Branch operations.
422async 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    // Check for -c (create) or -b (create and checkout)
429    let create = args.get_string("c", usize::MAX).or_else(|| args.get_string("b", usize::MAX));
430
431    if let Some(name) = create {
432        // Create a new branch
433        match git.create_branch(&name) {
434            Ok(()) => {
435                // If -b, also checkout
436                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        // List branches
450        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
471/// Checkout a branch or commit.
472async 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
490/// Worktree operations.
491async 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
515/// List all worktrees.
516fn worktree_list(git: &GitVfs) -> ExecResult {
517    match git.worktrees() {
518        Ok(worktrees) => {
519            let mut output = String::new();
520            for wt in worktrees {
521                // Format: path  commit  [branch]
522                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
540/// Add a new worktree.
541fn 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    // Resolve the path relative to cwd
550    let vfs_path = ctx.resolve_path(path_arg);
551
552    // Get real filesystem path
553    let real_path = match ctx.backend().resolve_real_path(&vfs_path) {
554        Some(p) => p,
555        None => {
556            // If VFS path doesn't resolve, try resolving the parent
557            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    // Derive worktree name from path
577    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
595/// Remove a worktree.
596fn 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
610/// Lock a worktree.
611fn 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
625/// Unlock a worktree.
626fn 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
639/// Prune stale worktrees.
640fn 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/// Open the git repository in the current working directory.
654#[allow(clippy::result_large_err)]
655fn open_repo(ctx: &dyn ToolCtx) -> Result<GitVfs, ExecResult> {
656    // Resolve VFS path to real filesystem path
657    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
675/// Get the first line of a string.
676fn first_line(s: &str) -> &str {
677    s.lines().next().unwrap_or(s)
678}
679
680/// Format a Unix timestamp as a human-readable date.
681fn 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}