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) = if (x == 'R' || x == 'C' || y == 'R' || y == 'C')
261 && rest.contains(" -> ")
262 {
263 let mut parts = rest.splitn(2, " -> ");
264 let dest = parts.next().unwrap_or(rest).to_string();
265 let orig = parts.next().map(str::to_string);
266 (dest, orig)
267 } else {
268 (rest.to_string(), None)
269 };
270
271 if let Some(ct) = staged_change {
272 staged.push(FileStatus {
273 path: path.clone(),
274 change_type: ct,
275 old_path: old_path.clone(),
276 });
277 }
278 if let Some(ct) = unstaged_change {
279 unstaged.push(FileStatus {
280 path: path.clone(),
281 change_type: ct,
282 old_path,
283 });
284 }
285 }
286
287 let is_clean = staged.is_empty() && unstaged.is_empty() && untracked.is_empty();
288
289 Ok(StatusReport {
290 branch,
291 ahead,
292 behind,
293 staged,
294 unstaged,
295 untracked,
296 is_clean,
297 })
298}
299
300pub async fn git_log(repo: &Path, limit: u32) -> Result<Vec<CommitEntry>, GitError> {
302 let cap = limit.min(100);
303 let cap_str = cap.to_string();
304 let format = "%H\x1F%h\x1F%s\x1F%an\x1F%aI\x1F%ar";
305 let raw = match git_run(
306 &["log", &format!("--format={format}"), "-n", &cap_str],
307 repo,
308 )
309 .await
310 {
311 Ok(v) => v,
312 Err(GitError::BackendFailed { ref stderr, .. }) if stderr.contains("does not have any commits") || stderr.contains("bad default revision") || stderr.contains("fatal: your current branch") => {
313 return Ok(Vec::new());
314 }
315 Err(e) => return Err(e),
316 };
317
318 if raw.trim().is_empty() {
319 return Ok(Vec::new());
320 }
321
322 let mut entries = Vec::new();
323 for line in raw.lines() {
324 let parts: Vec<&str> = line.splitn(6, '\x1F').collect();
325 if parts.len() < 6 {
326 continue;
327 }
328 entries.push(CommitEntry {
329 hash: parts[0].to_string(),
330 short_hash: parts[1].to_string(),
331 message: parts[2].to_string(),
332 author: parts[3].to_string(),
333 date: parts[4].to_string(),
334 date_relative: parts[5].to_string(),
335 });
336 }
337 Ok(entries)
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
343#[serde(rename_all = "camelCase")]
344pub struct ResolvedCommit {
345 pub hash: String,
347 pub author_email: String,
350 pub author_name: String,
352 pub subject: String,
354 pub committed_at: u64,
356 pub parent: Option<String>,
359 pub files: Vec<String>,
361}
362
363pub async fn resolve_commit(repo: &Path, sha: &str) -> Result<ResolvedCommit, GitError> {
371 if sha.is_empty()
374 || sha.len() > 64
375 || !sha.bytes().all(|b| b.is_ascii_hexdigit())
376 {
377 return Err(GitError::PathTraversal(format!("invalid commit id: {sha}")));
378 }
379
380 let fmt = "%H\x1F%ae\x1F%an\x1F%s\x1F%at\x1F%P";
383 let meta = match git_run(&["show", "--no-patch", &format!("--format={fmt}"), sha], repo).await {
384 Ok(v) => v,
385 Err(GitError::BackendFailed { ref stderr, .. })
386 if stderr.contains("unknown revision")
387 || stderr.contains("bad revision")
388 || stderr.contains("bad object")
389 || stderr.contains("ambiguous argument")
390 || stderr.contains("does not have any commits") =>
391 {
392 return Err(GitError::NotARepository(format!("unknown commit {sha}")));
393 }
394 Err(e) => return Err(e),
395 };
396 let line = meta.lines().next().unwrap_or("");
397 let parts: Vec<&str> = line.splitn(6, '\x1F').collect();
398 if parts.len() < 5 {
399 return Err(GitError::MalformedCgi(format!("git show metadata for {sha}")));
400 }
401 let committed_at = parts[4].trim().parse::<u64>().unwrap_or(0);
402 let parent = parts
403 .get(5)
404 .map(|s| s.trim())
405 .filter(|s| !s.is_empty())
406 .and_then(|s| s.split_whitespace().next())
408 .map(str::to_string);
409
410 let files_raw = git_run(&["show", "--name-only", "--format=", sha], repo)
412 .await
413 .unwrap_or_default();
414 let files: Vec<String> = files_raw
415 .lines()
416 .map(str::trim)
417 .filter(|l| !l.is_empty())
418 .map(str::to_string)
419 .collect();
420
421 Ok(ResolvedCommit {
422 hash: parts[0].to_string(),
423 author_email: parts[1].to_string(),
424 author_name: parts[2].to_string(),
425 subject: parts[3].to_string(),
426 committed_at,
427 parent,
428 files,
429 })
430}
431
432pub async fn git_diff(repo: &Path, path: Option<&str>, staged: bool) -> Result<String, GitError> {
438 if let Some(p) = path {
439 validate_path(p)?;
440 }
441
442 let mut args: Vec<&str> = vec!["diff", "-U5"];
443 if staged {
444 args.push("--cached");
445 }
446 if let Some(p) = path {
447 args.push("--");
448 args.push(p);
449 }
450
451 git_run(&args, repo).await
453}
454
455pub async fn git_add(repo: &Path, paths: &[String], all: bool) -> Result<(), GitError> {
458 if all {
459 git_run(&["add", "-A"], repo).await?;
460 } else {
461 for p in paths {
462 validate_path(p)?;
463 }
464 let mut args = vec!["add", "--"];
465 let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
466 args.extend_from_slice(&path_refs);
467 git_run(&args, repo).await?;
468 }
469 Ok(())
470}
471
472pub async fn git_unstage(repo: &Path, paths: &[String], all: bool) -> Result<(), GitError> {
478 if all {
479 git_run_tolerant(&["reset", "HEAD", "--", "."], repo).await?;
480 } else {
481 for p in paths {
482 validate_path(p)?;
483 }
484 let mut args = vec!["reset", "HEAD", "--"];
485 let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
486 args.extend_from_slice(&path_refs);
487 git_run_tolerant(&args, repo).await?;
488 }
489 Ok(())
490}
491
492pub async fn git_commit(
495 repo: &Path,
496 message: &str,
497 author_name: &str,
498 author_email: &str,
499) -> Result<CommitResult, GitError> {
500 let author_str = format!("{author_name} <{author_email}>");
501 git_run(
502 &["commit", "-m", message, "--author", &author_str],
503 repo,
504 )
505 .await?;
506
507 let hash = git_run(&["rev-parse", "HEAD"], repo).await?;
508 let hash = hash.trim().to_string();
509 let short_hash = if hash.len() >= 7 {
510 hash[..7].to_string()
511 } else {
512 hash.clone()
513 };
514
515 let summary = git_run(&["log", "-1", "--format=%s", &hash], repo)
517 .await
518 .unwrap_or_else(|_| message.to_string());
519 let summary = summary.trim().to_string();
520
521 Ok(CommitResult {
522 hash,
523 short_hash,
524 summary,
525 })
526}
527
528pub async fn git_branches(repo: &Path) -> Result<BranchInfo, GitError> {
530 let raw = match git_run(
531 &["branch", "--format=%(HEAD) %(refname:short)"],
532 repo,
533 )
534 .await
535 {
536 Ok(v) => v,
537 Err(GitError::BackendFailed { ref stderr, .. })
538 if stderr.contains("does not have any commits")
539 || stderr.contains("bad default revision") =>
540 {
541 return Ok(BranchInfo {
542 current: "main".to_string(),
543 local: vec![],
544 });
545 }
546 Err(e) => return Err(e),
547 };
548
549 if raw.trim().is_empty() {
550 return Ok(BranchInfo {
551 current: "main".to_string(),
552 local: vec![],
553 });
554 }
555
556 let mut current = String::from("main");
557 let mut local: Vec<String> = Vec::new();
558
559 for line in raw.lines() {
560 let is_current = line.starts_with("* ");
562 let name = line.trim_start_matches("* ").trim_start_matches(" ").trim();
563 if name.is_empty() {
564 continue;
565 }
566 if is_current {
567 current = name.to_string();
568 }
569 local.push(name.to_string());
570 }
571
572 Ok(BranchInfo { current, local })
573}
574
575pub async fn git_create_branch(repo: &Path, name: &str) -> Result<(), GitError> {
577 if name.contains("..") || name.contains(' ') || name.starts_with('-') {
579 return Err(GitError::PathTraversal(format!(
580 "invalid branch name: {name}"
581 )));
582 }
583 git_run(&["checkout", "-b", name], repo).await?;
584 Ok(())
585}
586
587pub async fn git_discard(repo: &Path, paths: &[String]) -> Result<(), GitError> {
590 for p in paths {
591 validate_path(p)?;
592 }
593 let mut args = vec!["checkout", "--"];
594 let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
595 args.extend_from_slice(&path_refs);
596 git_run(&args, repo).await?;
597 Ok(())
598}
599
600#[cfg(test)]
605mod tests {
606 use super::*;
607
608 fn make_status(raw: &str) -> StatusReport {
609 parse_status_output(raw).expect("parse failed")
610 }
611
612 #[test]
613 fn parse_clean_repo() {
614 let raw = "## main...origin/main\n";
615 let s = make_status(raw);
616 assert_eq!(s.branch, "main");
617 assert_eq!(s.ahead, 0);
618 assert_eq!(s.behind, 0);
619 assert!(s.is_clean);
620 assert!(s.staged.is_empty());
621 assert!(s.unstaged.is_empty());
622 assert!(s.untracked.is_empty());
623 }
624
625 #[test]
626 fn parse_ahead_behind() {
627 let raw = "## main...origin/main [ahead 3, behind 1]\n";
628 let s = make_status(raw);
629 assert_eq!(s.branch, "main");
630 assert_eq!(s.ahead, 3);
631 assert_eq!(s.behind, 1);
632 }
633
634 #[test]
635 fn parse_ahead_only() {
636 let raw = "## feature...origin/feature [ahead 2]\n";
637 let s = make_status(raw);
638 assert_eq!(s.branch, "feature");
639 assert_eq!(s.ahead, 2);
640 assert_eq!(s.behind, 0);
641 }
642
643 #[test]
644 fn parse_no_commits_yet() {
645 let raw = "## No commits yet on main\n";
646 let s = make_status(raw);
647 assert_eq!(s.branch, "main");
648 assert_eq!(s.ahead, 0);
649 assert_eq!(s.behind, 0);
650 assert!(s.is_clean);
651 }
652
653 #[test]
654 fn parse_no_commits_yet_with_staged() {
655 let raw = "## No commits yet on main\nA README.md\n";
656 let s = make_status(raw);
657 assert_eq!(s.branch, "main");
658 assert_eq!(s.staged.len(), 1);
659 assert_eq!(s.staged[0].change_type, ChangeType::Added);
660 assert_eq!(s.staged[0].path, "README.md");
661 assert!(!s.is_clean);
662 }
663
664 #[test]
665 fn parse_modified_staged_and_unstaged() {
666 let raw = "## main\nMM src/lib.rs\n";
668 let s = make_status(raw);
669 assert_eq!(s.staged.len(), 1);
670 assert_eq!(s.staged[0].change_type, ChangeType::Modified);
671 assert_eq!(s.unstaged.len(), 1);
672 assert_eq!(s.unstaged[0].change_type, ChangeType::Modified);
673 }
674
675 #[test]
676 fn parse_untracked() {
677 let raw = "## main\n?? newfile.txt\n";
678 let s = make_status(raw);
679 assert_eq!(s.untracked, vec!["newfile.txt"]);
680 assert!(!s.is_clean);
681 }
682
683 #[test]
684 fn parse_deleted_staged() {
685 let raw = "## main\nD old.txt\n";
686 let s = make_status(raw);
687 assert_eq!(s.staged.len(), 1);
688 assert_eq!(s.staged[0].change_type, ChangeType::Deleted);
689 assert_eq!(s.staged[0].path, "old.txt");
690 }
691
692 #[test]
693 fn parse_renamed_staged() {
694 let raw = "## main\nR new.txt -> old.txt\n";
695 let s = make_status(raw);
696 assert_eq!(s.staged.len(), 1);
697 assert_eq!(s.staged[0].change_type, ChangeType::Renamed);
698 assert_eq!(s.staged[0].path, "new.txt");
699 assert_eq!(s.staged[0].old_path.as_deref(), Some("old.txt"));
700 }
701
702 #[test]
703 fn parse_branch_no_tracking() {
704 let raw = "## detached-head\nM foo.rs\n";
705 let s = make_status(raw);
706 assert_eq!(s.branch, "detached-head");
707 assert_eq!(s.ahead, 0);
708 assert_eq!(s.behind, 0);
709 }
710
711 #[test]
712 fn validate_path_rejects_dotdot() {
713 assert!(validate_path("../etc/passwd").is_err());
714 assert!(validate_path("foo/../../bar").is_err());
715 }
716
717 #[test]
718 fn validate_path_rejects_absolute() {
719 assert!(validate_path("/etc/passwd").is_err());
720 }
721
722 #[test]
723 fn validate_path_accepts_normal() {
724 assert!(validate_path("src/lib.rs").is_ok());
725 assert!(validate_path("README.md").is_ok());
726 }
727
728 #[tokio::test]
729 async fn resolve_commit_rejects_malformed_rev() {
730 let repo = std::path::Path::new("/nonexistent");
733 for bad in ["", "../etc", "deadbeef; rm -rf /", &"a".repeat(65), "g00dbeef"] {
734 assert!(
735 matches!(
736 resolve_commit(repo, bad).await,
737 Err(GitError::PathTraversal(_))
738 ),
739 "malformed rev {bad:?} must be rejected pre-git"
740 );
741 }
742 }
743}