1use std::path::Path;
11
12use serde::{Deserialize, Serialize};
13use tokio::process::Command;
14
15use crate::error::GitError;
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23#[serde(rename_all = "lowercase")]
24pub enum ChangeType {
25 Modified,
27 Added,
29 Deleted,
31 Renamed,
33 Copied,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct FileStatus {
41 pub path: String,
43 pub change_type: ChangeType,
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub old_path: Option<String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase")]
53pub struct StatusReport {
54 pub branch: String,
56 pub ahead: u32,
58 pub behind: u32,
60 pub staged: Vec<FileStatus>,
62 pub unstaged: Vec<FileStatus>,
64 pub untracked: Vec<String>,
66 pub is_clean: bool,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct CommitEntry {
74 pub hash: String,
76 pub short_hash: String,
78 pub message: String,
80 pub author: String,
82 pub date: String,
84 pub date_relative: String,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct BranchInfo {
92 pub current: String,
94 pub local: Vec<String>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct CommitResult {
102 pub hash: String,
104 pub short_hash: String,
106 pub summary: String,
108}
109
110async fn git_run(args: &[&str], repo: &Path) -> Result<String, GitError> {
117 let output = Command::new("git")
118 .args(args)
119 .current_dir(repo)
120 .output()
121 .await
122 .map_err(|e| {
123 if e.kind() == std::io::ErrorKind::NotFound {
124 GitError::BackendNotAvailable("git binary not found in PATH".into())
125 } else {
126 GitError::Io(e)
127 }
128 })?;
129
130 if output.status.success() {
131 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
132 } else {
133 Err(GitError::BackendFailed {
134 exit_code: output.status.code(),
135 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
136 })
137 }
138}
139
140async fn git_run_tolerant(args: &[&str], repo: &Path) -> Result<String, GitError> {
143 let output = Command::new("git")
144 .args(args)
145 .current_dir(repo)
146 .output()
147 .await
148 .map_err(|e| {
149 if e.kind() == std::io::ErrorKind::NotFound {
150 GitError::BackendNotAvailable("git binary not found in PATH".into())
151 } else {
152 GitError::Io(e)
153 }
154 })?;
155
156 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
157}
158
159fn validate_path(path: &str) -> Result<(), GitError> {
162 if path.starts_with('/') || path.contains("..") {
163 return Err(GitError::PathTraversal(path.to_string()));
164 }
165 Ok(())
166}
167
168fn parse_xy(x: char, y: char) -> (Option<ChangeType>, Option<ChangeType>) {
170 let map_char = |c: char| match c {
171 'M' => Some(ChangeType::Modified),
172 'A' => Some(ChangeType::Added),
173 'D' => Some(ChangeType::Deleted),
174 'R' => Some(ChangeType::Renamed),
175 'C' => Some(ChangeType::Copied),
176 _ => None,
177 };
178 (map_char(x), map_char(y))
179}
180
181pub async fn git_status(repo: &Path) -> Result<StatusReport, GitError> {
187 let raw = git_run(&["status", "--porcelain=v1", "-b"], repo).await?;
188 parse_status_output(&raw)
189}
190
191pub fn parse_status_output(raw: &str) -> Result<StatusReport, GitError> {
193 let mut lines = raw.lines();
194
195 let branch_line = lines.next().unwrap_or("");
197 let branch_line = branch_line.trim_start_matches("## ");
199
200 let mut ahead: u32 = 0;
201 let mut behind: u32 = 0;
202
203 let branch: String;
204 if branch_line.starts_with("No commits yet on ") {
205 branch = branch_line
206 .trim_start_matches("No commits yet on ")
207 .to_string();
208 } else {
209 let (branch_part, tracking_part) = if let Some(idx) = branch_line.find("...") {
212 (&branch_line[..idx], Some(&branch_line[idx + 3..]))
213 } else {
214 (branch_line, None)
215 };
216 branch = branch_part.to_string();
217
218 if let Some(tracking) = tracking_part {
219 if let Some(bracket_start) = tracking.find('[') {
221 let inside = &tracking[bracket_start + 1..];
222 let inside = inside.trim_end_matches(']');
223 for part in inside.split(',') {
224 let part = part.trim();
225 if let Some(n) = part.strip_prefix("ahead ") {
226 ahead = n.trim().parse().unwrap_or(0);
227 } else if let Some(n) = part.strip_prefix("behind ") {
228 behind = n.trim().parse().unwrap_or(0);
229 }
230 }
231 }
232 }
233 }
234
235 let mut staged: Vec<FileStatus> = Vec::new();
237 let mut unstaged: Vec<FileStatus> = Vec::new();
238 let mut untracked: Vec<String> = Vec::new();
239
240 for line in lines {
241 if line.len() < 4 {
242 continue;
243 }
244 let x = line.chars().next().unwrap_or(' ');
245 let y = line.chars().nth(1).unwrap_or(' ');
246 let rest = &line[3..]; if x == '?' && y == '?' {
249 untracked.push(rest.to_string());
250 continue;
251 }
252
253 let (staged_change, unstaged_change) = parse_xy(x, y);
254
255 let (path, old_path) =
261 if (x == 'R' || x == 'C' || y == 'R' || y == 'C') && rest.contains(" -> ") {
262 let mut parts = rest.splitn(2, " -> ");
263 let dest = parts.next().unwrap_or(rest).to_string();
264 let orig = parts.next().map(str::to_string);
265 (dest, orig)
266 } else {
267 (rest.to_string(), None)
268 };
269
270 if let Some(ct) = staged_change {
271 staged.push(FileStatus {
272 path: path.clone(),
273 change_type: ct,
274 old_path: old_path.clone(),
275 });
276 }
277 if let Some(ct) = unstaged_change {
278 unstaged.push(FileStatus {
279 path: path.clone(),
280 change_type: ct,
281 old_path,
282 });
283 }
284 }
285
286 let is_clean = staged.is_empty() && unstaged.is_empty() && untracked.is_empty();
287
288 Ok(StatusReport {
289 branch,
290 ahead,
291 behind,
292 staged,
293 unstaged,
294 untracked,
295 is_clean,
296 })
297}
298
299pub async fn git_log(repo: &Path, limit: u32) -> Result<Vec<CommitEntry>, GitError> {
301 let cap = limit.min(100);
302 let cap_str = cap.to_string();
303 let format = "%H\x1F%h\x1F%s\x1F%an\x1F%aI\x1F%ar";
304 let raw = match git_run(
305 &["log", &format!("--format={format}"), "-n", &cap_str],
306 repo,
307 )
308 .await
309 {
310 Ok(v) => v,
311 Err(GitError::BackendFailed { ref stderr, .. })
312 if stderr.contains("does not have any commits")
313 || stderr.contains("bad default revision")
314 || stderr.contains("fatal: your current branch") =>
315 {
316 return Ok(Vec::new());
317 }
318 Err(e) => return Err(e),
319 };
320
321 if raw.trim().is_empty() {
322 return Ok(Vec::new());
323 }
324
325 let mut entries = Vec::new();
326 for line in raw.lines() {
327 let parts: Vec<&str> = line.splitn(6, '\x1F').collect();
328 if parts.len() < 6 {
329 continue;
330 }
331 entries.push(CommitEntry {
332 hash: parts[0].to_string(),
333 short_hash: parts[1].to_string(),
334 message: parts[2].to_string(),
335 author: parts[3].to_string(),
336 date: parts[4].to_string(),
337 date_relative: parts[5].to_string(),
338 });
339 }
340 Ok(entries)
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize)]
346#[serde(rename_all = "camelCase")]
347pub struct ResolvedCommit {
348 pub hash: String,
350 pub author_email: String,
353 pub author_name: String,
355 pub subject: String,
357 pub committed_at: u64,
359 pub parent: Option<String>,
362 pub files: Vec<String>,
364}
365
366pub async fn resolve_commit(repo: &Path, sha: &str) -> Result<ResolvedCommit, GitError> {
375 if sha.is_empty() || sha.len() > 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
378 return Err(GitError::PathTraversal(format!("invalid commit id: {sha}")));
379 }
380
381 let fmt = "%H\x1F%ae\x1F%an\x1F%s\x1F%at\x1F%P";
384 let meta = match git_run(
385 &["show", "--no-patch", &format!("--format={fmt}"), sha],
386 repo,
387 )
388 .await
389 {
390 Ok(v) => v,
391 Err(GitError::BackendFailed { ref stderr, .. })
392 if stderr.contains("unknown revision")
393 || stderr.contains("bad revision")
394 || stderr.contains("bad object")
395 || stderr.contains("ambiguous argument")
396 || stderr.contains("does not have any commits") =>
397 {
398 return Err(GitError::NotARepository(format!("unknown commit {sha}")));
399 }
400 Err(e) => return Err(e),
401 };
402 let line = meta.lines().next().unwrap_or("");
403 let parts: Vec<&str> = line.splitn(6, '\x1F').collect();
404 if parts.len() < 5 {
405 return Err(GitError::MalformedCgi(format!(
406 "git show metadata for {sha}"
407 )));
408 }
409 let committed_at = parts[4].trim().parse::<u64>().unwrap_or(0);
410 let parent = parts
411 .get(5)
412 .map(|s| s.trim())
413 .filter(|s| !s.is_empty())
414 .and_then(|s| s.split_whitespace().next())
416 .map(str::to_string);
417
418 let files_raw = git_run(&["show", "--name-only", "--format=", sha], repo)
420 .await
421 .unwrap_or_default();
422 let files: Vec<String> = files_raw
423 .lines()
424 .map(str::trim)
425 .filter(|l| !l.is_empty())
426 .map(str::to_string)
427 .collect();
428
429 Ok(ResolvedCommit {
430 hash: parts[0].to_string(),
431 author_email: parts[1].to_string(),
432 author_name: parts[2].to_string(),
433 subject: parts[3].to_string(),
434 committed_at,
435 parent,
436 files,
437 })
438}
439
440pub async fn git_diff(repo: &Path, path: Option<&str>, staged: bool) -> Result<String, GitError> {
446 if let Some(p) = path {
447 validate_path(p)?;
448 }
449
450 let mut args: Vec<&str> = vec!["diff", "-U5"];
451 if staged {
452 args.push("--cached");
453 }
454 if let Some(p) = path {
455 args.push("--");
456 args.push(p);
457 }
458
459 git_run(&args, repo).await
461}
462
463pub async fn git_add(repo: &Path, paths: &[String], all: bool) -> Result<(), GitError> {
466 if all {
467 git_run(&["add", "-A"], repo).await?;
468 } else {
469 for p in paths {
470 validate_path(p)?;
471 }
472 let mut args = vec!["add", "--"];
473 let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
474 args.extend_from_slice(&path_refs);
475 git_run(&args, repo).await?;
476 }
477 Ok(())
478}
479
480pub async fn git_unstage(repo: &Path, paths: &[String], all: bool) -> Result<(), GitError> {
486 if all {
487 git_run_tolerant(&["reset", "HEAD", "--", "."], repo).await?;
488 } else {
489 for p in paths {
490 validate_path(p)?;
491 }
492 let mut args = vec!["reset", "HEAD", "--"];
493 let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
494 args.extend_from_slice(&path_refs);
495 git_run_tolerant(&args, repo).await?;
496 }
497 Ok(())
498}
499
500pub async fn git_commit(
503 repo: &Path,
504 message: &str,
505 author_name: &str,
506 author_email: &str,
507) -> Result<CommitResult, GitError> {
508 let author_str = format!("{author_name} <{author_email}>");
509 git_run(&["commit", "-m", message, "--author", &author_str], repo).await?;
510
511 let hash = git_run(&["rev-parse", "HEAD"], repo).await?;
512 let hash = hash.trim().to_string();
513 let short_hash = if hash.len() >= 7 {
514 hash[..7].to_string()
515 } else {
516 hash.clone()
517 };
518
519 let summary = git_run(&["log", "-1", "--format=%s", &hash], repo)
521 .await
522 .unwrap_or_else(|_| message.to_string());
523 let summary = summary.trim().to_string();
524
525 Ok(CommitResult {
526 hash,
527 short_hash,
528 summary,
529 })
530}
531
532pub async fn git_branches(repo: &Path) -> Result<BranchInfo, GitError> {
534 let raw = match git_run(&["branch", "--format=%(HEAD) %(refname:short)"], repo).await {
535 Ok(v) => v,
536 Err(GitError::BackendFailed { ref stderr, .. })
537 if stderr.contains("does not have any commits")
538 || stderr.contains("bad default revision") =>
539 {
540 return Ok(BranchInfo {
541 current: "main".to_string(),
542 local: vec![],
543 });
544 }
545 Err(e) => return Err(e),
546 };
547
548 if raw.trim().is_empty() {
549 return Ok(BranchInfo {
550 current: "main".to_string(),
551 local: vec![],
552 });
553 }
554
555 let mut current = String::from("main");
556 let mut local: Vec<String> = Vec::new();
557
558 for line in raw.lines() {
559 let is_current = line.starts_with("* ");
561 let name = line
562 .trim_start_matches("* ")
563 .trim_start_matches(" ")
564 .trim();
565 if name.is_empty() {
566 continue;
567 }
568 if is_current {
569 current = name.to_string();
570 }
571 local.push(name.to_string());
572 }
573
574 Ok(BranchInfo { current, local })
575}
576
577pub async fn git_create_branch(repo: &Path, name: &str) -> Result<(), GitError> {
579 if name.contains("..") || name.contains(' ') || name.starts_with('-') {
581 return Err(GitError::PathTraversal(format!(
582 "invalid branch name: {name}"
583 )));
584 }
585 git_run(&["checkout", "-b", name], repo).await?;
586 Ok(())
587}
588
589pub async fn git_discard(repo: &Path, paths: &[String]) -> Result<(), GitError> {
592 for p in paths {
593 validate_path(p)?;
594 }
595 let mut args = vec!["checkout", "--"];
596 let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
597 args.extend_from_slice(&path_refs);
598 git_run(&args, repo).await?;
599 Ok(())
600}
601
602#[cfg(test)]
607mod tests {
608 use super::*;
609
610 fn make_status(raw: &str) -> StatusReport {
611 parse_status_output(raw).expect("parse failed")
612 }
613
614 #[test]
615 fn parse_clean_repo() {
616 let raw = "## main...origin/main\n";
617 let s = make_status(raw);
618 assert_eq!(s.branch, "main");
619 assert_eq!(s.ahead, 0);
620 assert_eq!(s.behind, 0);
621 assert!(s.is_clean);
622 assert!(s.staged.is_empty());
623 assert!(s.unstaged.is_empty());
624 assert!(s.untracked.is_empty());
625 }
626
627 #[test]
628 fn parse_ahead_behind() {
629 let raw = "## main...origin/main [ahead 3, behind 1]\n";
630 let s = make_status(raw);
631 assert_eq!(s.branch, "main");
632 assert_eq!(s.ahead, 3);
633 assert_eq!(s.behind, 1);
634 }
635
636 #[test]
637 fn parse_ahead_only() {
638 let raw = "## feature...origin/feature [ahead 2]\n";
639 let s = make_status(raw);
640 assert_eq!(s.branch, "feature");
641 assert_eq!(s.ahead, 2);
642 assert_eq!(s.behind, 0);
643 }
644
645 #[test]
646 fn parse_no_commits_yet() {
647 let raw = "## No commits yet on main\n";
648 let s = make_status(raw);
649 assert_eq!(s.branch, "main");
650 assert_eq!(s.ahead, 0);
651 assert_eq!(s.behind, 0);
652 assert!(s.is_clean);
653 }
654
655 #[test]
656 fn parse_no_commits_yet_with_staged() {
657 let raw = "## No commits yet on main\nA README.md\n";
658 let s = make_status(raw);
659 assert_eq!(s.branch, "main");
660 assert_eq!(s.staged.len(), 1);
661 assert_eq!(s.staged[0].change_type, ChangeType::Added);
662 assert_eq!(s.staged[0].path, "README.md");
663 assert!(!s.is_clean);
664 }
665
666 #[test]
667 fn parse_modified_staged_and_unstaged() {
668 let raw = "## main\nMM src/lib.rs\n";
670 let s = make_status(raw);
671 assert_eq!(s.staged.len(), 1);
672 assert_eq!(s.staged[0].change_type, ChangeType::Modified);
673 assert_eq!(s.unstaged.len(), 1);
674 assert_eq!(s.unstaged[0].change_type, ChangeType::Modified);
675 }
676
677 #[test]
678 fn parse_untracked() {
679 let raw = "## main\n?? newfile.txt\n";
680 let s = make_status(raw);
681 assert_eq!(s.untracked, vec!["newfile.txt"]);
682 assert!(!s.is_clean);
683 }
684
685 #[test]
686 fn parse_deleted_staged() {
687 let raw = "## main\nD old.txt\n";
688 let s = make_status(raw);
689 assert_eq!(s.staged.len(), 1);
690 assert_eq!(s.staged[0].change_type, ChangeType::Deleted);
691 assert_eq!(s.staged[0].path, "old.txt");
692 }
693
694 #[test]
695 fn parse_renamed_staged() {
696 let raw = "## main\nR new.txt -> old.txt\n";
697 let s = make_status(raw);
698 assert_eq!(s.staged.len(), 1);
699 assert_eq!(s.staged[0].change_type, ChangeType::Renamed);
700 assert_eq!(s.staged[0].path, "new.txt");
701 assert_eq!(s.staged[0].old_path.as_deref(), Some("old.txt"));
702 }
703
704 #[test]
705 fn parse_branch_no_tracking() {
706 let raw = "## detached-head\nM foo.rs\n";
707 let s = make_status(raw);
708 assert_eq!(s.branch, "detached-head");
709 assert_eq!(s.ahead, 0);
710 assert_eq!(s.behind, 0);
711 }
712
713 #[test]
714 fn validate_path_rejects_dotdot() {
715 assert!(validate_path("../etc/passwd").is_err());
716 assert!(validate_path("foo/../../bar").is_err());
717 }
718
719 #[test]
720 fn validate_path_rejects_absolute() {
721 assert!(validate_path("/etc/passwd").is_err());
722 }
723
724 #[test]
725 fn validate_path_accepts_normal() {
726 assert!(validate_path("src/lib.rs").is_ok());
727 assert!(validate_path("README.md").is_ok());
728 }
729
730 #[tokio::test]
731 async fn resolve_commit_rejects_malformed_rev() {
732 let repo = std::path::Path::new("/nonexistent");
735 for bad in [
736 "",
737 "../etc",
738 "deadbeef; rm -rf /",
739 &"a".repeat(65),
740 "g00dbeef",
741 ] {
742 assert!(
743 matches!(
744 resolve_commit(repo, bad).await,
745 Err(GitError::PathTraversal(_))
746 ),
747 "malformed rev {bad:?} must be rejected pre-git"
748 );
749 }
750 }
751}