1use std::path::Path;
6use std::process::Command;
7
8use rmcp::schemars;
9use serde::Serialize;
10
11#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
13pub struct Branch {
14 pub name: String,
16 pub head: String,
18 pub current: bool,
20 pub worktree: bool,
23}
24
25#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
27pub struct Commit {
28 pub sha: String,
30 pub author: String,
32 pub timestamp: String,
34 pub subject: String,
36}
37
38#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
40pub struct Diff {
41 pub base: String,
43 pub branch: String,
45 pub diff: String,
47 pub files_changed: usize,
49 pub insertions: u64,
51 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#[must_use]
69pub fn branches(repo_root: &Path) -> Vec<Branch> {
70 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#[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#[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 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}