Skip to main content

git_paw/mcp/query/
git.rs

1//! Git-context reads — thin wrappers over `git` invoked against the resolved
2//! repository root. These always work (the repo always exists), so there is
3//! no degradation path here.
4
5use std::path::Path;
6use std::process::Command;
7
8use rmcp::schemars;
9use serde::Serialize;
10
11/// One local branch.
12#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
13pub struct Branch {
14    /// Branch name.
15    pub name: String,
16    /// Head commit SHA.
17    pub head: String,
18    /// Whether this is the currently checked-out branch (in the main worktree).
19    pub current: bool,
20    /// Whether the branch is checked out in a linked worktree (git-paw managed
21    /// agent branches live in linked worktrees).
22    pub worktree: bool,
23}
24
25/// One commit.
26#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
27pub struct Commit {
28    /// Full commit SHA.
29    pub sha: String,
30    /// Author name.
31    pub author: String,
32    /// Author date, ISO-8601.
33    pub timestamp: String,
34    /// Subject line.
35    pub subject: String,
36}
37
38/// Diff summary for a branch against its base.
39#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
40pub struct Diff {
41    /// Base branch the diff was taken against.
42    pub base: String,
43    /// Branch the diff describes.
44    pub branch: String,
45    /// Unified diff text.
46    pub diff: String,
47    /// Number of files changed.
48    pub files_changed: usize,
49    /// Lines added.
50    pub insertions: u64,
51    /// Lines deleted.
52    pub deletions: u64,
53}
54
55fn git(repo_root: &Path, args: &[&str]) -> Option<String> {
56    let out = Command::new("git")
57        .current_dir(repo_root)
58        .args(args)
59        .output()
60        .ok()?;
61    if !out.status.success() {
62        return None;
63    }
64    Some(String::from_utf8_lossy(&out.stdout).into_owned())
65}
66
67/// Lists local branches with head SHA, current flag, and worktree flag.
68#[must_use]
69pub fn branches(repo_root: &Path) -> Vec<Branch> {
70    // Branches checked out in *linked* worktrees (git-paw managed). The first
71    // `git worktree list --porcelain` block is the primary worktree (the repo
72    // root itself) — exclude it so `current` and `worktree` stay distinct.
73    let main_canon = repo_root.canonicalize().ok();
74    let mut worktree_branches: std::collections::HashSet<String> = std::collections::HashSet::new();
75    if let Some(raw) = git(repo_root, &["worktree", "list", "--porcelain"]) {
76        for block in raw.split("\n\n") {
77            let mut path: Option<std::path::PathBuf> = None;
78            let mut branch: Option<String> = None;
79            for line in block.lines() {
80                if let Some(p) = line.strip_prefix("worktree ") {
81                    path = Some(std::path::PathBuf::from(p));
82                } else if let Some(b) = line.strip_prefix("branch refs/heads/") {
83                    branch = Some(b.to_string());
84                }
85            }
86            if let (Some(p), Some(b)) = (path, branch) {
87                let is_main = p.canonicalize().ok() == main_canon;
88                if !is_main {
89                    worktree_branches.insert(b);
90                }
91            }
92        }
93    }
94
95    let Some(raw) = git(
96        repo_root,
97        &[
98            "for-each-ref",
99            "--format=%(HEAD)%00%(refname:short)%00%(objectname)",
100            "refs/heads",
101        ],
102    ) else {
103        return Vec::new();
104    };
105
106    raw.lines()
107        .filter_map(|line| {
108            let mut parts = line.split('\u{0}');
109            let head_marker = parts.next()?;
110            let name = parts.next()?.to_string();
111            let head = parts.next().unwrap_or("").to_string();
112            Some(Branch {
113                current: head_marker.trim() == "*",
114                worktree: worktree_branches.contains(&name),
115                name,
116                head,
117            })
118        })
119        .collect()
120}
121
122/// Returns up to `limit` recent commits on `branch`, newest first.
123#[must_use]
124pub fn recent_commits(repo_root: &Path, branch: &str, limit: usize) -> Vec<Commit> {
125    let limit_arg = format!("-{}", limit.max(1));
126    let Some(raw) = git(
127        repo_root,
128        &[
129            "log",
130            &limit_arg,
131            "--format=%H%x1f%an%x1f%aI%x1f%s",
132            branch,
133            "--",
134        ],
135    ) else {
136        return Vec::new();
137    };
138
139    raw.lines()
140        .filter_map(|line| {
141            let mut parts = line.split('\u{1f}');
142            Some(Commit {
143                sha: parts.next()?.to_string(),
144                author: parts.next().unwrap_or("").to_string(),
145                timestamp: parts.next().unwrap_or("").to_string(),
146                subject: parts.next().unwrap_or("").to_string(),
147            })
148        })
149        .collect()
150}
151
152/// Returns the diff of `branch` against `base` (default: the repo's default
153/// branch, falling back to `main`), with a changed-files summary.
154#[must_use]
155pub fn diff(repo_root: &Path, branch: &str, base: Option<&str>) -> Diff {
156    let base = base
157        .map(str::to_string)
158        .or_else(|| crate::git::default_branch(repo_root).ok())
159        .unwrap_or_else(|| "main".to_string());
160
161    let range = format!("{base}...{branch}");
162    let diff = git(repo_root, &["diff", &range]).unwrap_or_default();
163
164    // numstat: "<added>\t<deleted>\t<path>" per file (added/deleted "-" for binary).
165    let mut files_changed = 0usize;
166    let mut insertions = 0u64;
167    let mut deletions = 0u64;
168    if let Some(raw) = git(repo_root, &["diff", "--numstat", &range]) {
169        for line in raw.lines() {
170            let mut parts = line.split('\t');
171            let add = parts.next().unwrap_or("0");
172            let del = parts.next().unwrap_or("0");
173            files_changed += 1;
174            insertions += add.parse::<u64>().unwrap_or(0);
175            deletions += del.parse::<u64>().unwrap_or(0);
176        }
177    }
178
179    Diff {
180        base,
181        branch: branch.to_string(),
182        diff,
183        files_changed,
184        insertions,
185        deletions,
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use std::process::Command;
193
194    fn init_repo() -> tempfile::TempDir {
195        let tmp = tempfile::tempdir().unwrap();
196        let dir = tmp.path();
197        for args in [
198            vec!["init", "-q", "-b", "main"],
199            vec!["config", "user.email", "t@example.com"],
200            vec!["config", "user.name", "Test"],
201        ] {
202            assert!(
203                Command::new("git")
204                    .current_dir(dir)
205                    .args(&args)
206                    .status()
207                    .unwrap()
208                    .success()
209            );
210        }
211        std::fs::write(dir.join("a.txt"), "one\n").unwrap();
212        for args in [vec!["add", "."], vec!["commit", "-q", "-m", "first"]] {
213            assert!(
214                Command::new("git")
215                    .current_dir(dir)
216                    .args(&args)
217                    .status()
218                    .unwrap()
219                    .success()
220            );
221        }
222        tmp
223    }
224
225    #[test]
226    fn branches_lists_current_branch() {
227        let tmp = init_repo();
228        let bs = branches(tmp.path());
229        assert_eq!(bs.len(), 1);
230        assert_eq!(bs[0].name, "main");
231        assert!(bs[0].current);
232        assert!(!bs[0].worktree);
233        assert!(!bs[0].head.is_empty());
234    }
235
236    #[test]
237    fn recent_commits_returns_first_commit() {
238        let tmp = init_repo();
239        let cs = recent_commits(tmp.path(), "main", 10);
240        assert_eq!(cs.len(), 1);
241        assert_eq!(cs[0].subject, "first");
242        assert_eq!(cs[0].author, "Test");
243    }
244
245    #[test]
246    fn diff_against_base_summarizes_changes() {
247        let tmp = init_repo();
248        let dir = tmp.path();
249        assert!(
250            Command::new("git")
251                .current_dir(dir)
252                .args(["checkout", "-q", "-b", "feat/x"])
253                .status()
254                .unwrap()
255                .success()
256        );
257        std::fs::write(dir.join("a.txt"), "one\ntwo\n").unwrap();
258        for args in [vec!["add", "."], vec!["commit", "-q", "-m", "second"]] {
259            assert!(
260                Command::new("git")
261                    .current_dir(dir)
262                    .args(&args)
263                    .status()
264                    .unwrap()
265                    .success()
266            );
267        }
268        let d = diff(dir, "feat/x", Some("main"));
269        assert_eq!(d.base, "main");
270        assert_eq!(d.files_changed, 1);
271        assert_eq!(d.insertions, 1);
272        assert!(d.diff.contains("two"));
273    }
274}