1use serde_json::{Value, json};
24use std::process::Command;
25
26const MAX_TOTAL_PATCH_BYTES: usize = 10_000_000;
27const FULL_CONTEXT: &str = "2147483647";
30
31fn run_git(dir: &str, args: &[&str]) -> Option<(String, i32)> {
32 let out = Command::new("git")
33 .current_dir(dir)
34 .args(args)
35 .output()
36 .ok()?;
37 let text = String::from_utf8_lossy(&out.stdout).into_owned();
38 Some((text, out.status.code().unwrap_or(-1)))
39}
40
41fn status_entries(dir: &str) -> Vec<(String, String)> {
44 let Some((text, _)) = run_git(
45 dir,
46 &[
47 "status",
48 "--porcelain=v1",
49 "--untracked-files=all",
50 "--no-renames",
51 "-z",
52 "--",
53 ".",
54 ],
55 ) else {
56 return vec![];
57 };
58 let mut out = Vec::new();
59 for item in text.split('\0').filter(|s| !s.is_empty()) {
60 if item.len() < 3 {
61 continue;
62 }
63 let code = item[..2].to_string();
64 let file = item[3..].to_string();
65 out.push((file, code));
66 }
67 out
68}
69
70fn numstat(dir: &str, ref_: &str) -> std::collections::HashMap<String, (i64, i64)> {
72 let Some((text, _)) = run_git(
73 dir,
74 &[
75 "diff",
76 "--no-ext-diff",
77 "--no-renames",
78 "--numstat",
79 "-z",
80 ref_,
81 "--",
82 ".",
83 ],
84 ) else {
85 return std::collections::HashMap::new();
86 };
87 let mut map = std::collections::HashMap::new();
88 for item in text.split('\0').filter(|s| !s.is_empty()) {
91 let mut it = item.splitn(3, '\t');
92 let (Some(a), Some(d), Some(f)) = (it.next(), it.next(), it.next()) else {
93 continue;
94 };
95 let add = a.parse::<i64>().unwrap_or(0);
96 let del = d.parse::<i64>().unwrap_or(0);
97 map.insert(f.to_string(), (add, del));
98 }
99 map
100}
101
102fn kind(code: &str) -> &'static str {
103 if code.starts_with('A') || code == "??" {
105 "added"
106 } else if code.starts_with('D') {
107 "deleted"
108 } else {
109 "modified"
110 }
111}
112
113const MAX_APPLY_PATCH_BYTES: usize = 64 * 1024 * 1024;
117
118pub fn apply(dir: &str, patch: &str) -> Option<bool> {
121 if patch.len() > MAX_APPLY_PATCH_BYTES {
122 return Some(false);
123 }
124 if !is_git(dir) {
125 return None;
126 }
127 let mut child = Command::new("git")
128 .current_dir(dir)
129 .args(["apply", "-"])
130 .stdin(std::process::Stdio::piped())
131 .stdout(std::process::Stdio::piped())
132 .stderr(std::process::Stdio::piped())
133 .spawn()
134 .ok()?;
135 if let Some(mut si) = child.stdin.take() {
136 use std::io::Write;
137 let _ = si.write_all(patch.as_bytes());
138 }
139 let out = child.wait_with_output().ok()?;
140 Some(out.status.success())
141}
142
143fn has_head(dir: &str) -> bool {
144 run_git(dir, &["rev-parse", "--verify", "--quiet", "HEAD"])
145 .map(|(_, c)| c == 0)
146 .unwrap_or(false)
147}
148
149pub fn toplevel(dir: &std::path::Path) -> Option<String> {
152 let d = dir.to_str()?;
153 let (t, c) = run_git(d, &["rev-parse", "--show-toplevel"])?;
154 (c == 0 && !t.trim().is_empty()).then(|| t.trim().to_string())
155}
156
157pub fn info(dir: &str) -> Value {
158 let branch = run_git(dir, &["rev-parse", "--abbrev-ref", "HEAD"])
159 .filter(|(t, c)| *c == 0 && !t.trim().is_empty())
160 .map(|(t, _)| t.trim().to_string());
161 let default_branch = run_git(dir, &["symbolic-ref", "refs/remotes/origin/HEAD"])
162 .filter(|(t, c)| *c == 0 && !t.trim().is_empty())
163 .map(|(t, _)| {
164 t.trim()
165 .trim_start_matches("refs/remotes/origin/")
166 .to_string()
167 });
168 json!({"branch": branch, "default_branch": default_branch})
169}
170
171fn is_git(dir: &str) -> bool {
172 run_git(dir, &["rev-parse", "--is-inside-work-tree"])
173 .map(|(t, c)| c == 0 && t.trim() == "true")
174 .unwrap_or(false)
175}
176
177pub fn status(dir: &str) -> Value {
178 if !is_git(dir) {
179 return Value::Array(vec![]);
180 }
181 let ref_ = if has_head(dir) { "HEAD" } else { "" };
182 let stats = if ref_.is_empty() {
183 std::collections::HashMap::new()
184 } else {
185 numstat(dir, ref_)
186 };
187 let mut items: Vec<(String, String)> = status_entries(dir);
188 items.sort_by(|a, b| a.0.cmp(&b.0));
189 let arr: Vec<Value> = items
190 .into_iter()
191 .map(|(file, code)| {
192 let (a, d) = stats.get(&file).copied().unwrap_or_else(|| {
193 if code == "??" {
194 stat_untracked(dir, &file)
195 } else {
196 (0, 0)
197 }
198 });
199 json!({"file": file, "additions": a, "deletions": d, "status": kind(&code)})
200 })
201 .collect();
202 Value::Array(arr)
203}
204
205fn stat_untracked(dir: &str, file: &str) -> (i64, i64) {
207 let Some((text, _)) = run_git(
208 dir,
209 &["diff", "--no-index", "--numstat", "--", "/dev/null", file],
210 ) else {
211 return (0, 0);
212 };
213 let line = text.lines().next().unwrap_or("");
214 let mut it = line.splitn(3, '\t');
215 let (Some(a), Some(d)) = (it.next(), it.next()) else {
216 return (0, 0);
217 };
218 (a.parse::<i64>().unwrap_or(0), d.parse::<i64>().unwrap_or(0))
219}
220
221fn tracked_patches(
223 dir: &str,
224 ref_: &str,
225 context: &str,
226) -> (std::collections::HashMap<String, String>, bool) {
227 let Some((text, _)) = run_git(
228 dir,
229 &[
230 "diff",
231 "--patch",
232 "--no-ext-diff",
233 "--no-renames",
234 &format!("--unified={context}"),
235 ref_,
236 "--",
237 ".",
238 ],
239 ) else {
240 return (std::collections::HashMap::new(), false);
241 };
242 let mut map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
244 let mut cur_file: Option<String> = None;
245 let mut cur = String::new();
246 for line in text.split_inclusive('\n') {
247 if line.starts_with("diff --git ") {
248 if let Some(f) = cur_file.take() {
249 map.entry(f).or_default().push_str(&cur);
250 cur.clear();
251 }
252 cur_file = parse_git_header(line);
253 }
254 cur.push_str(line);
255 }
256 if let Some(f) = cur_file {
257 map.entry(f).or_default().push_str(&cur);
258 }
259 (map, false)
261}
262
263fn parse_git_header(line: &str) -> Option<String> {
265 let rest = line.trim_end().strip_prefix("diff --git ")?;
266 let idx = rest.find(" b/")?;
268 Some(rest[idx + 3..].to_string())
269}
270
271pub fn diff_raw(dir: &str) -> String {
272 if !is_git(dir) {
273 return String::new();
274 }
275 let ref_ = if has_head(dir) { "HEAD" } else { "" };
276 let mut parts: Vec<String> = Vec::new();
277 if !ref_.is_empty()
278 && let Some((text, _)) = run_git(
279 dir,
280 &[
281 "diff",
282 "--patch",
283 "--no-ext-diff",
284 "--no-renames",
285 &format!("--unified={FULL_CONTEXT}"),
286 ref_,
287 "--",
288 ".",
289 ],
290 )
291 {
292 parts.push(text);
293 }
294 for (file, code) in status_entries(dir) {
295 if code == "??"
296 && let Some((text, _)) = run_git(
297 dir,
298 &[
299 "diff",
300 "--no-index",
301 "--patch",
302 "--no-ext-diff",
303 "--no-renames",
304 &format!("--unified={FULL_CONTEXT}"),
305 "--",
306 "/dev/null",
307 &file,
308 ],
309 )
310 {
311 parts.push(text);
312 }
313 }
314 parts
315 .into_iter()
316 .filter(|s| !s.is_empty())
317 .collect::<Vec<_>>()
318 .join("\n")
319}
320
321pub fn diff_raw_bounded(dir: &str) -> (String, bool) {
327 let full = diff_raw(dir);
328 if full.len() <= MAX_TOTAL_PATCH_BYTES {
329 return (full, false);
330 }
331 let mut cut = MAX_TOTAL_PATCH_BYTES;
332 while cut > 0 && !full.is_char_boundary(cut) {
333 cut -= 1;
334 }
335 (full[..cut].to_string(), true)
336}
337
338pub fn diff(dir: &str, mode: &str, context: Option<u32>) -> Value {
340 if !is_git(dir) {
341 return Value::Array(vec![]);
342 }
343 let ref_ = match mode {
344 "git" => {
345 if has_head(dir) {
346 "HEAD".to_string()
347 } else {
348 String::new()
349 }
350 }
351 "branch" => {
352 let Some((def, c)) = run_git(dir, &["symbolic-ref", "refs/remotes/origin/HEAD"]) else {
353 return Value::Array(vec![]);
354 };
355 if c != 0 || def.trim().is_empty() {
356 return Value::Array(vec![]);
357 }
358 let name = def
359 .trim()
360 .trim_start_matches("refs/remotes/origin/")
361 .to_string();
362 match run_git(dir, &["merge-base", "HEAD", &name]) {
363 Some((mb, 0)) if !mb.trim().is_empty() => mb.trim().to_string(),
364 _ => return Value::Array(vec![]),
365 }
366 }
367 _ => return json!({"__error": "mode"}),
368 };
369
370 let ctx = context
371 .map(|c| c.to_string())
372 .unwrap_or_else(|| FULL_CONTEXT.into());
373 let stats = if ref_.is_empty() {
374 std::collections::HashMap::new()
375 } else {
376 numstat(dir, &ref_)
377 };
378 let (tracked, _trunc) = if ref_.is_empty() {
379 (std::collections::HashMap::new(), false)
380 } else {
381 tracked_patches(dir, &ref_, &ctx)
382 };
383
384 let mut items: Vec<(String, String)> = status_entries(dir);
386 items.sort_by(|a, b| a.0.cmp(&b.0));
387
388 let mut out: Vec<Value> = Vec::new();
389 let mut total = 0usize;
390 for (file, code) in items {
391 let (a, d) = stats.get(&file).copied().unwrap_or_else(|| {
392 if code == "??" {
393 stat_untracked(dir, &file)
394 } else {
395 (0, 0)
396 }
397 });
398 let patch = if total >= MAX_TOTAL_PATCH_BYTES {
399 String::new()
400 } else if let Some(p) = tracked.get(&file) {
401 p.clone()
402 } else if code == "??" {
403 run_git(
404 dir,
405 &[
406 "diff",
407 "--no-index",
408 "--patch",
409 "--no-ext-diff",
410 "--no-renames",
411 &format!("--unified={ctx}"),
412 "--",
413 "/dev/null",
414 &file,
415 ],
416 )
417 .map(|(t, _)| t)
418 .unwrap_or_default()
419 } else {
420 String::new()
421 };
422 total += patch.len();
423 out.push(json!({
424 "file": file,
425 "patch": patch,
426 "additions": a,
427 "deletions": d,
428 "status": kind(&code),
429 }));
430 }
431 Value::Array(out)
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn kind_mapping() {
440 assert_eq!(kind("A "), "added");
441 assert_eq!(kind("D "), "deleted");
442 assert_eq!(kind("M "), "modified");
443 assert_eq!(kind("??"), "added");
444 }
445
446 #[test]
447 fn header_parse() {
448 assert_eq!(
449 parse_git_header("diff --git a/src/x.rs b/src/x.rs\n"),
450 Some("src/x.rs".into())
451 );
452 assert_eq!(parse_git_header("not a header"), None);
453 }
454}