Skip to main content

koda_core/
git.rs

1//! Git integration for context injection.
2//!
3//! Provides a compact snapshot of the current git state for injection into
4//! the system prompt. This gives the model awareness of:
5//!
6//! - **Current branch** — so it knows where it's working
7//! - **Staged changes** — diff stat showing what's ready to commit
8//! - **Unstaged changes** — diff stat showing what's modified but not staged
9//! - **Recent commits** — last N commit subjects for historical context
10//!
11//! ## What this module does NOT do
12//!
13//! - **File-level undo** — handled by [`crate::undo`] (in-memory snapshots)
14//! - **Git operations** — commits, pushes, etc. are done via the Bash tool
15//! - **Worktree management** — handled by [`crate::worktree`]
16//!
17//! ## Output format
18//!
19//! ```text
20//! [Git: branch=main
21//!  Staged: 2 files changed, 15 insertions(+), 3 deletions(-)
22//!  Unstaged: 1 file changed, 4 insertions(+)
23//!  Recent: fix: align arrows | docs: enrich modules | feat: add AskUser]
24//! ```
25//!
26//! Diff stats are truncated at 2KB to avoid bloating the system prompt.
27
28use std::path::Path;
29use std::process::Command;
30
31// ── Context injection (#263) ────────────────────────────────────
32
33/// Maximum characters for the diff stat section.
34const MAX_DIFF_STAT_CHARS: usize = 2_000;
35/// Maximum recent commits to include.
36const MAX_RECENT_COMMITS: usize = 5;
37
38/// Compact git context for injection into the system prompt.
39///
40/// Returns `None` if not in a git repo. Includes:
41/// - Current branch name
42/// - Staged diff stat (truncated)
43/// - Unstaged diff stat (truncated)
44/// - Last N commit subjects
45pub fn git_context(project_root: &Path) -> Option<String> {
46    let branch = git_cmd(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])?;
47
48    let mut parts = vec![format!("[Git: branch={branch}")];
49
50    // Staged changes (stat only — token-efficient)
51    if let Some(staged) = git_cmd(project_root, &["diff", "--cached", "--stat"])
52        && !staged.trim().is_empty()
53    {
54        let truncated = truncate_str(&staged, MAX_DIFF_STAT_CHARS);
55        parts.push(format!("staged:\n{truncated}"));
56    }
57
58    // Unstaged changes (stat only)
59    if let Some(unstaged) = git_cmd(project_root, &["diff", "--stat"])
60        && !unstaged.trim().is_empty()
61    {
62        let truncated = truncate_str(&unstaged, MAX_DIFF_STAT_CHARS);
63        parts.push(format!("unstaged:\n{truncated}"));
64    }
65
66    // Untracked file count
67    if let Some(untracked) = git_cmd(
68        project_root,
69        &["ls-files", "--others", "--exclude-standard"],
70    ) {
71        let count = untracked.lines().count();
72        if count > 0 {
73            parts.push(format!("{count} untracked file(s)"));
74        }
75    }
76
77    // Recent commits
78    if let Some(log) = git_cmd(
79        project_root,
80        &[
81            "log",
82            "--oneline",
83            &format!("-{MAX_RECENT_COMMITS}"),
84            "--no-decorate",
85        ],
86    ) && !log.trim().is_empty()
87    {
88        parts.push(format!("recent commits:\n{log}"));
89    }
90
91    parts.push("]".to_string());
92    Some(parts.join(", "))
93}
94
95// ── Helpers ─────────────────────────────────────────────────────
96
97/// Run a git command and return stdout if successful.
98fn git_cmd(cwd: &Path, args: &[&str]) -> Option<String> {
99    Command::new("git")
100        .args(args)
101        .current_dir(cwd)
102        .output()
103        .ok()
104        .filter(|o| o.status.success())
105        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
106}
107
108/// Truncate a string to max chars at a line boundary.
109fn truncate_str(s: &str, max: usize) -> String {
110    if s.len() <= max {
111        return s.to_string();
112    }
113    // Find last newline before max
114    let end = s[..max].rfind('\n').unwrap_or(max);
115    let truncated = &s[..end];
116    let remaining = s[end..].lines().count();
117    format!("{truncated}\n  ... ({remaining} more lines)")
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn test_git_context_in_repo() {
126        // We're running tests inside the koda repo, so this should work
127        let ctx = git_context(Path::new("."));
128        assert!(ctx.is_some());
129        let ctx = ctx.unwrap();
130        assert!(ctx.contains("[Git: branch="));
131        assert!(ctx.contains("recent commits:"));
132    }
133
134    #[test]
135    fn test_git_context_not_a_repo() {
136        let tmp = tempfile::tempdir().unwrap();
137        let ctx = git_context(tmp.path());
138        assert!(ctx.is_none());
139    }
140
141    #[test]
142    fn test_truncate_str_short() {
143        assert_eq!(truncate_str("hello", 100), "hello");
144    }
145
146    #[test]
147    fn test_truncate_str_long() {
148        let lines: Vec<String> = (0..50).map(|i| format!("line {i}")).collect();
149        let input = lines.join("\n");
150        let truncated = truncate_str(&input, 50);
151        assert!(truncated.len() <= 80); // 50 + "... (N more lines)"
152        assert!(truncated.contains("more lines"));
153    }
154}