1use std::path::{Path, PathBuf};
4use std::process::Command;
5
6#[derive(Debug, Clone)]
7pub struct BranchInfo {
8 pub name: String,
9 pub current: bool,
10 pub remote: bool,
11 pub upstream: Option<String>,
13}
14
15#[derive(Debug, Clone)]
16pub struct DiffLine {
17 pub kind: DiffLineKind,
18 pub text: String,
20 pub old_no: Option<u32>,
22 pub new_no: Option<u32>,
24}
25
26impl DiffLine {
27 pub fn new(kind: DiffLineKind, text: impl Into<String>) -> Self {
28 Self {
29 kind,
30 text: text.into(),
31 old_no: None,
32 new_no: None,
33 }
34 }
35
36 pub fn content(&self) -> &str {
38 match self.kind {
39 DiffLineKind::Add | DiffLineKind::Del | DiffLineKind::Context => {
40 self.text.get(1..).unwrap_or(self.text.as_str())
41 }
42 _ => self.text.as_str(),
43 }
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum DiffLineKind {
49 Header,
50 Hunk,
51 Add,
52 Del,
53 Context,
54 Meta,
55}
56
57pub fn find_git_root(hint: Option<&Path>) -> Option<PathBuf> {
58 let start = hint
59 .and_then(|p| {
60 if p.is_file() {
61 p.parent().map(|x| x.to_path_buf())
62 } else {
63 Some(p.to_path_buf())
64 }
65 })
66 .or_else(|| std::env::current_dir().ok())?;
67
68 let mut cur = start;
69 for _ in 0..24 {
70 if cur.join(".git").exists() {
71 return Some(cur);
72 }
73 if !cur.pop() {
74 break;
75 }
76 }
77 None
78}
79
80pub fn run_git(root: &Path, args: &[&str]) -> Result<String, String> {
81 let output = Command::new("git")
82 .args(args)
83 .current_dir(root)
84 .output()
85 .map_err(|e| format!("git failed to start: {e}"))?;
86 if !output.status.success() {
87 let err = String::from_utf8_lossy(&output.stderr);
88 let err = err.trim();
89 if err.is_empty() {
90 return Err(format!("git {} failed", args.first().unwrap_or(&"")));
91 }
92 return Err(err.lines().next().unwrap_or("git error").to_string());
93 }
94 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
95}
96
97pub fn run_git_ok(root: &Path, args: &[&str]) -> Result<String, String> {
99 run_git(root, args)
100}
101
102pub fn list_branches(root: &Path) -> Result<Vec<BranchInfo>, String> {
103 let local = run_git(
105 root,
106 &[
107 "for-each-ref",
108 "--format=%(refname:short)%00%(upstream:short)%00%(HEAD)",
109 "refs/heads",
110 ],
111 )?;
112 let mut out = Vec::new();
113 for line in local.lines() {
114 if line.is_empty() {
115 continue;
116 }
117 let parts: Vec<&str> = line.split('\0').collect();
118 let name = parts.first().copied().unwrap_or("").to_string();
119 if name.is_empty() {
120 continue;
121 }
122 let upstream = parts
123 .get(1)
124 .map(|s| s.trim())
125 .filter(|s| !s.is_empty())
126 .map(|s| s.to_string());
127 let current = parts.get(2).map(|s| *s == "*").unwrap_or(false);
128 out.push(BranchInfo {
129 name,
130 current,
131 remote: false,
132 upstream,
133 });
134 }
135
136 if let Ok(remote) = run_git(
138 root,
139 &[
140 "for-each-ref",
141 "--format=%(refname:short)",
142 "refs/remotes",
143 ],
144 ) {
145 for line in remote.lines() {
146 let name = line.trim();
147 if name.is_empty() || name.ends_with("/HEAD") {
148 continue;
149 }
150 if out.iter().any(|b| b.name == name) {
152 continue;
153 }
154 out.push(BranchInfo {
155 name: name.to_string(),
156 current: false,
157 remote: true,
158 upstream: None,
159 });
160 }
161 }
162
163 out.sort_by(|a, b| {
165 b.current
166 .cmp(&a.current)
167 .then_with(|| a.remote.cmp(&b.remote))
168 .then_with(|| a.name.cmp(&b.name))
169 });
170 Ok(out)
171}
172
173pub fn checkout_branch(root: &Path, name: &str) -> Result<String, String> {
174 if name.contains('/') && name.starts_with("origin/") {
176 let local = name.trim_start_matches("origin/");
177 match run_git(root, &["checkout", "-B", local, "--track", name]) {
179 Ok(o) => return Ok(o.lines().next().unwrap_or("Checked out").to_string()),
180 Err(_) => {
181 return run_git(root, &["checkout", local])
183 .map(|o| o.lines().next().unwrap_or("Checked out").to_string());
184 }
185 }
186 }
187 run_git(root, &["checkout", name])
188 .map(|o| o.lines().next().unwrap_or("Checked out").to_string())
189}
190
191pub fn create_branch(root: &Path, name: &str) -> Result<String, String> {
192 run_git(root, &["checkout", "-b", name])
193 .map(|o| o.lines().next().unwrap_or("Created branch").to_string())
194}
195
196pub fn delete_branch(root: &Path, name: &str, force: bool) -> Result<String, String> {
197 let flag = if force { "-D" } else { "-d" };
198 run_git(root, &["branch", flag, name])
199 .map(|_| format!("Deleted branch {name}"))
200}
201
202pub fn stage_all(root: &Path) -> Result<String, String> {
203 run_git(root, &["add", "-A"]).map(|_| "Staged all changes".into())
204}
205
206pub fn unstage_all(root: &Path) -> Result<String, String> {
207 run_git(root, &["restore", "--staged", "."]).map(|_| "Unstaged all".into())
208}
209
210pub fn discard_file(root: &Path, path: &str) -> Result<String, String> {
211 let status = run_git(root, &["status", "--porcelain", "--", path])?;
213 let line = status.lines().next().unwrap_or("");
214 if line.starts_with("??") {
215 let p = root.join(path);
216 if p.is_file() {
217 std::fs::remove_file(&p).map_err(|e| e.to_string())?;
218 } else if p.is_dir() {
219 std::fs::remove_dir_all(&p).map_err(|e| e.to_string())?;
220 }
221 return Ok(format!("Removed untracked {path}"));
222 }
223 let _ = run_git(root, &["restore", "--staged", "--", path]);
225 run_git(root, &["restore", "--", path]).map(|_| format!("Discarded {path}"))
226}
227
228pub fn cherry_pick(root: &Path, hash: &str) -> Result<String, String> {
229 run_git(root, &["cherry-pick", hash]).map(|o| {
230 o.lines()
231 .next()
232 .unwrap_or("Cherry-picked")
233 .to_string()
234 })
235}
236
237pub fn revert_commit(root: &Path, hash: &str) -> Result<String, String> {
238 run_git(root, &["revert", "--no-edit", hash]).map(|o| {
239 o.lines()
240 .next()
241 .unwrap_or("Reverted")
242 .to_string()
243 })
244}
245
246pub fn pull_rebase(root: &Path) -> Result<String, String> {
247 run_git(root, &["pull", "--rebase"]).map(|o| {
248 let t = o.trim();
249 if t.is_empty() {
250 "Pulled (rebase)".into()
251 } else {
252 t.lines().next().unwrap_or("Pulled (rebase)").to_string()
253 }
254 })
255}
256
257pub fn stash_list(root: &Path) -> Result<Vec<String>, String> {
258 let out = run_git(root, &["stash", "list"])?;
259 Ok(out
260 .lines()
261 .filter(|l| !l.is_empty())
262 .map(|s| s.to_string())
263 .collect())
264}
265
266pub fn remotes(root: &Path) -> Result<Vec<(String, String)>, String> {
267 let out = run_git(root, &["remote", "-v"])?;
268 let mut v = Vec::new();
269 for line in out.lines() {
270 let mut parts = line.split_whitespace();
271 let name = parts.next().unwrap_or("").to_string();
272 let url = parts.next().unwrap_or("").to_string();
273 let kind = parts.next().unwrap_or("");
274 if kind.contains("fetch") && !name.is_empty() {
275 v.push((name, url));
276 }
277 }
278 Ok(v)
279}
280
281pub fn log_file(root: &Path, path: &str, limit: usize) -> Result<Vec<CommitSummary>, String> {
282 let n = limit.clamp(5, 100).to_string();
283 let out = run_git(
284 root,
285 &[
286 "log",
287 "-n",
288 &n,
289 "--pretty=format:%H%x00%h%x00%s%x00%an%x00%ae%x00%ar%x00%P",
290 "--",
291 path,
292 ],
293 )?;
294 let mut commits = Vec::new();
295 for line in out.lines() {
296 if line.is_empty() {
297 continue;
298 }
299 let p: Vec<&str> = line.split('\0').collect();
300 if p.len() < 6 {
301 continue;
302 }
303 commits.push(CommitSummary {
304 hash: p[0].to_string(),
305 short: p[1].to_string(),
306 subject: p[2].to_string(),
307 author: p[3].to_string(),
308 email: p[4].to_string(),
309 when: p[5].to_string(),
310 parents: p
311 .get(6)
312 .unwrap_or(&"")
313 .split_whitespace()
314 .filter(|s| !s.is_empty())
315 .map(|s| s.to_string())
316 .collect(),
317 });
318 }
319 Ok(commits)
320}
321
322pub fn fetch(root: &Path) -> Result<String, String> {
323 run_git(root, &["fetch", "--all", "--prune"])
324 .map(|_| "Fetched".into())
325}
326
327pub fn pull(root: &Path) -> Result<String, String> {
328 run_git(root, &["pull", "--ff-only"])
329 .or_else(|_| run_git(root, &["pull"]))
330 .map(|o| {
331 let t = o.trim();
332 if t.is_empty() {
333 "Pulled".into()
334 } else {
335 t.lines().next().unwrap_or("Pulled").to_string()
336 }
337 })
338}
339
340pub fn push(root: &Path) -> Result<String, String> {
341 run_git(root, &["push"]).map(|o| {
342 let t = o.trim();
343 if t.is_empty() {
344 "Pushed".into()
346 } else {
347 t.lines().next().unwrap_or("Pushed").to_string()
348 }
349 }).or_else(|e| {
350 if e.contains("no upstream") || e.contains("has no upstream") {
352 run_git(root, &["push", "-u", "origin", "HEAD"]).map(|_| "Pushed (set upstream)".into())
353 } else {
354 let output = Command::new("git")
356 .args(["push"])
357 .current_dir(root)
358 .output()
359 .map_err(|err| format!("git push: {err}"))?;
360 if output.status.success() {
361 Ok("Pushed".into())
362 } else {
363 let err = String::from_utf8_lossy(&output.stderr);
364 Err(err.lines().next().unwrap_or("push failed").to_string())
365 }
366 }
367 })
368}
369
370pub fn file_diff(root: &Path, path: &str, staged: bool) -> Result<Vec<DiffLine>, String> {
371 let args: Vec<&str> = if staged {
372 vec!["diff", "--no-color", "--cached", "--", path]
373 } else {
374 vec!["diff", "--no-color", "HEAD", "--", path]
375 };
376 let out = match run_git(root, &args) {
378 Ok(o) if !o.trim().is_empty() => o,
379 _ => {
380 let o2 = run_git(root, &["diff", "--no-color", "--", path]).unwrap_or_default();
382 if o2.trim().is_empty() {
383 let full = root.join(path);
385 if full.is_file() {
386 let content = std::fs::read_to_string(&full).unwrap_or_default();
387 let mut lines = vec![DiffLine::new(
388 DiffLineKind::Header,
389 format!("diff -- untracked a/{path} b/{path}"),
390 )];
391 let mut n = 1u32;
392 for l in content.lines() {
393 lines.push(DiffLine {
394 kind: DiffLineKind::Add,
395 text: format!("+{l}"),
396 old_no: None,
397 new_no: Some(n),
398 });
399 n += 1;
400 }
401 if lines.len() == 1 {
402 lines.push(DiffLine::new(DiffLineKind::Meta, "(empty file)"));
403 }
404 return Ok(lines);
405 }
406 return Ok(vec![DiffLine::new(DiffLineKind::Meta, "No diff")]);
407 }
408 o2
409 }
410 };
411 Ok(parse_diff(&out))
412}
413
414pub fn parse_diff(text: &str) -> Vec<DiffLine> {
416 let mut out = Vec::new();
417 let mut old_ln: u32 = 0;
418 let mut new_ln: u32 = 0;
419 for line in text.lines() {
420 if line.starts_with("diff ") || line.starts_with("index ") {
421 out.push(DiffLine::new(DiffLineKind::Header, line));
422 } else if line.starts_with("@@") {
423 if let Some((o, n)) = parse_hunk_starts(line) {
425 old_ln = o;
426 new_ln = n;
427 }
428 out.push(DiffLine::new(DiffLineKind::Hunk, line));
429 } else if line.starts_with('+') && !line.starts_with("+++") {
430 out.push(DiffLine {
431 kind: DiffLineKind::Add,
432 text: line.to_string(),
433 old_no: None,
434 new_no: Some(new_ln),
435 });
436 new_ln = new_ln.saturating_add(1);
437 } else if line.starts_with('-') && !line.starts_with("---") {
438 out.push(DiffLine {
439 kind: DiffLineKind::Del,
440 text: line.to_string(),
441 old_no: Some(old_ln),
442 new_no: None,
443 });
444 old_ln = old_ln.saturating_add(1);
445 } else if line.starts_with("+++") || line.starts_with("---") {
446 out.push(DiffLine::new(DiffLineKind::Meta, line));
447 } else if line.starts_with(' ') || (line.is_empty() && (old_ln > 0 || new_ln > 0)) {
448 let old_no = if old_ln > 0 { Some(old_ln) } else { None };
450 let new_no = if new_ln > 0 { Some(new_ln) } else { None };
451 if old_ln > 0 {
452 old_ln = old_ln.saturating_add(1);
453 }
454 if new_ln > 0 {
455 new_ln = new_ln.saturating_add(1);
456 }
457 out.push(DiffLine {
458 kind: DiffLineKind::Context,
459 text: if line.is_empty() {
460 " ".into()
461 } else {
462 line.to_string()
463 },
464 old_no,
465 new_no,
466 });
467 } else {
468 out.push(DiffLine::new(DiffLineKind::Meta, line));
469 }
470 }
471 if out.is_empty() {
472 out.push(DiffLine::new(DiffLineKind::Meta, "No changes"));
473 }
474 out
475}
476
477fn parse_hunk_starts(hunk: &str) -> Option<(u32, u32)> {
479 let mut old = None;
481 let mut new = None;
482 for part in hunk.split_whitespace() {
483 if let Some(rest) = part.strip_prefix('-') {
484 let num = rest.split(',').next()?.parse::<u32>().ok()?;
485 old = Some(num.max(1));
486 } else if let Some(rest) = part.strip_prefix('+') {
487 if rest.starts_with('+') {
488 continue; }
490 let num = rest.split(',').next()?.parse::<u32>().ok()?;
491 new = Some(num.max(1));
492 }
493 }
494 Some((old?, new?))
495}
496
497pub fn stash_push(root: &Path) -> Result<String, String> {
498 run_git(root, &["stash", "push", "-u"]).map(|o| {
499 o.lines()
500 .next()
501 .unwrap_or("Stashed")
502 .to_string()
503 })
504}
505
506pub fn stash_pop(root: &Path) -> Result<String, String> {
507 run_git(root, &["stash", "pop"]).map(|o| {
508 o.lines()
509 .next()
510 .unwrap_or("Stash applied")
511 .to_string()
512 })
513}
514
515pub fn stash_apply(root: &Path, index: usize) -> Result<String, String> {
516 let refname = format!("stash@{{{index}}}");
517 run_git(root, &["stash", "apply", &refname]).map(|o| {
518 o.lines()
519 .next()
520 .unwrap_or("Stash applied")
521 .to_string()
522 })
523}
524
525pub fn stash_drop(root: &Path, index: usize) -> Result<String, String> {
526 let refname = format!("stash@{{{index}}}");
527 run_git(root, &["stash", "drop", &refname]).map(|o| {
528 o.lines()
529 .next()
530 .unwrap_or("Stash dropped")
531 .to_string()
532 })
533}
534
535pub fn stash_show(root: &Path, index: usize) -> Result<String, String> {
536 let refname = format!("stash@{{{index}}}");
537 run_git(root, &["stash", "show", "-p", "--stat", &refname])
538}
539
540pub fn current_branch(root: &Path) -> String {
541 run_git(root, &["branch", "--show-current"])
542 .map(|s| s.trim().to_string())
543 .unwrap_or_default()
544}
545
546#[derive(Debug, Clone)]
549pub struct CommitSummary {
550 pub hash: String,
551 pub short: String,
552 pub subject: String,
553 pub author: String,
554 pub email: String,
555 pub when: String,
556 pub parents: Vec<String>,
557}
558
559#[derive(Debug, Clone)]
560pub struct CommitFileChange {
561 pub path: String,
562 pub status: char,
564 pub insertions: u32,
565 pub deletions: u32,
566}
567
568#[derive(Debug, Clone)]
569pub struct CommitDetail {
570 pub hash: String,
571 pub short: String,
572 pub subject: String,
573 pub body: String,
575 pub author: String,
576 pub email: String,
577 pub date: String,
578 pub files: Vec<CommitFileChange>,
579 pub insertions: u32,
580 pub deletions: u32,
581}
582
583pub fn list_commits(root: &Path, limit: usize, all: bool) -> Result<Vec<CommitSummary>, String> {
585 let n = limit.clamp(20, 2000).to_string();
586 let mut args = vec![
587 "log",
588 "-n",
589 n.as_str(),
590 "--pretty=format:%H%x00%h%x00%s%x00%an%x00%ae%x00%ar%x00%P",
591 ];
592 if all {
593 args.insert(1, "--all");
594 }
595 let out = run_git(root, &args)?;
596 let mut commits = Vec::new();
597 for line in out.lines() {
598 if line.is_empty() {
599 continue;
600 }
601 let p: Vec<&str> = line.split('\0').collect();
602 if p.len() < 6 {
603 continue;
604 }
605 let parents = p
606 .get(6)
607 .unwrap_or(&"")
608 .split_whitespace()
609 .filter(|s| !s.is_empty())
610 .map(|s| s.to_string())
611 .collect();
612 commits.push(CommitSummary {
613 hash: p[0].to_string(),
614 short: p[1].to_string(),
615 subject: p[2].to_string(),
616 author: p[3].to_string(),
617 email: p[4].to_string(),
618 when: p[5].to_string(),
619 parents,
620 });
621 }
622 Ok(commits)
623}
624
625pub fn commit_detail(root: &Path, hash: &str) -> Result<CommitDetail, String> {
627 let meta = run_git(
629 root,
630 &[
631 "show",
632 "-s",
633 "--format=%H%x00%h%x00%s%x00%b%x00%an%x00%ae%x00%aI",
634 hash,
635 ],
636 )?;
637 let head = run_git(
643 root,
644 &[
645 "show",
646 "-s",
647 "--format=%H%x00%h%x00%s%x00%an%x00%ae%x00%aI%x00%P",
648 hash,
649 ],
650 )?;
651 let line = head.lines().next().unwrap_or("");
652 let p: Vec<&str> = line.split('\0').collect();
653 let full = p.first().unwrap_or(&hash).to_string();
654 let short = p.get(1).unwrap_or(&"").to_string();
655 let subject = p.get(2).unwrap_or(&"").to_string();
656 let author = p.get(3).unwrap_or(&"").to_string();
657 let email = p.get(4).unwrap_or(&"").to_string();
658 let date = p.get(5).unwrap_or(&"").to_string();
659
660 let body = run_git(root, &["log", "-1", "--format=%b", hash])
661 .map(|s| s.trim_end().to_string())
662 .unwrap_or_default();
663 let _ = meta;
664
665 let ns = run_git(root, &["show", "--name-status", "--format=", hash]).unwrap_or_default();
667 let num = run_git(root, &["show", "--numstat", "--format=", hash]).unwrap_or_default();
669
670 let mut stats: std::collections::HashMap<String, (u32, u32)> = std::collections::HashMap::new();
671 for line in num.lines() {
672 let line = line.trim();
673 if line.is_empty() {
674 continue;
675 }
676 let parts: Vec<&str> = line.split('\t').collect();
677 if parts.len() < 3 {
678 continue;
679 }
680 let ins: u32 = parts[0].parse().unwrap_or(0);
681 let del: u32 = parts[1].parse().unwrap_or(0);
682 let path = parts[2].to_string();
683 let path = if let Some((_, n)) = path.split_once(" => ") {
685 n.to_string()
686 } else {
687 path
688 };
689 stats.insert(path, (ins, del));
690 }
691
692 let mut files = Vec::new();
693 let mut total_ins = 0u32;
694 let mut total_del = 0u32;
695 for line in ns.lines() {
696 let line = line.trim();
697 if line.is_empty() {
698 continue;
699 }
700 let mut parts = line.splitn(2, char::is_whitespace);
701 let st = parts.next().unwrap_or("M");
702 let path_raw = parts.next().unwrap_or("").trim();
703 if path_raw.is_empty() {
704 continue;
705 }
706 let status = st.chars().next().unwrap_or('M');
707 let path = if let Some((_, n)) = path_raw.split_once(" => ") {
708 n.to_string()
709 } else if let Some((_, n)) = path_raw.split_once('\t') {
710 n.to_string()
712 } else {
713 path_raw.to_string()
714 };
715 let path = {
717 let bits: Vec<&str> = line.split('\t').collect();
718 if bits.len() >= 3 {
719 bits[2].to_string()
720 } else if bits.len() == 2 {
721 bits[1].to_string()
722 } else {
723 path
724 }
725 };
726 let (ins, del) = stats.get(&path).copied().unwrap_or((0, 0));
727 total_ins += ins;
728 total_del += del;
729 files.push(CommitFileChange {
730 path,
731 status,
732 insertions: ins,
733 deletions: del,
734 });
735 }
736
737 Ok(CommitDetail {
738 hash: full,
739 short,
740 subject,
741 body,
742 author,
743 email,
744 date,
745 files,
746 insertions: total_ins,
747 deletions: total_del,
748 })
749}
750
751pub fn commit_file_diff(root: &Path, hash: &str, path: &str) -> Result<Vec<DiffLine>, String> {
753 let out = run_git(
754 root,
755 &["show", "--no-color", "--format=", hash, "--", path],
756 )?;
757 if out.trim().is_empty() {
758 return Ok(vec![DiffLine::new(
759 DiffLineKind::Meta,
760 "No diff for this file",
761 )]);
762 }
763 Ok(parse_diff(&out))
764}
765
766#[cfg(test)]
767mod tests {
768 use super::*;
769
770 #[test]
771 fn parse_diff_kinds() {
772 let d = parse_diff(
773 "diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new\n context\n",
774 );
775 assert!(d.iter().any(|l| l.kind == DiffLineKind::Add));
776 assert!(d.iter().any(|l| l.kind == DiffLineKind::Del));
777 assert!(d.iter().any(|l| l.kind == DiffLineKind::Hunk));
778 }
779
780 #[test]
781 fn parse_diff_line_numbers() {
782 let d = parse_diff(
783 "@@ -10,3 +20,4 @@ fn foo\n context a\n-removed\n+added1\n+added2\n context b\n",
784 );
785 let del = d.iter().find(|l| l.kind == DiffLineKind::Del).unwrap();
786 assert_eq!(del.old_no, Some(11));
787 assert_eq!(del.new_no, None);
788 let adds: Vec<_> = d.iter().filter(|l| l.kind == DiffLineKind::Add).collect();
789 assert_eq!(adds[0].new_no, Some(21));
790 assert_eq!(adds[1].new_no, Some(22));
791 let ctx: Vec<_> = d
792 .iter()
793 .filter(|l| l.kind == DiffLineKind::Context)
794 .collect();
795 assert_eq!(ctx[0].old_no, Some(10));
796 assert_eq!(ctx[0].new_no, Some(20));
797 }
798}