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> {
374 if sha.is_empty() || sha.len() > 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
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(
384 &["show", "--no-patch", &format!("--format={fmt}"), sha],
385 repo,
386 )
387 .await
388 {
389 Ok(v) => v,
390 Err(GitError::BackendFailed { ref stderr, .. })
391 if stderr.contains("unknown revision")
392 || stderr.contains("bad revision")
393 || stderr.contains("bad object")
394 || stderr.contains("ambiguous argument")
395 || stderr.contains("does not have any commits") =>
396 {
397 return Err(GitError::NotARepository(format!("unknown commit {sha}")));
398 }
399 Err(e) => return Err(e),
400 };
401 let line = meta.lines().next().unwrap_or("");
402 let parts: Vec<&str> = line.splitn(6, '\x1F').collect();
403 if parts.len() < 5 {
404 return Err(GitError::MalformedCgi(format!(
405 "git show metadata for {sha}"
406 )));
407 }
408 let committed_at = parts[4].trim().parse::<u64>().unwrap_or(0);
409 let parent = parts
410 .get(5)
411 .map(|s| s.trim())
412 .filter(|s| !s.is_empty())
413 .and_then(|s| s.split_whitespace().next())
415 .map(str::to_string);
416
417 let files_raw = git_run(&["show", "--name-only", "--format=", sha], repo)
419 .await
420 .unwrap_or_default();
421 let files: Vec<String> = files_raw
422 .lines()
423 .map(str::trim)
424 .filter(|l| !l.is_empty())
425 .map(str::to_string)
426 .collect();
427
428 Ok(ResolvedCommit {
429 hash: parts[0].to_string(),
430 author_email: parts[1].to_string(),
431 author_name: parts[2].to_string(),
432 subject: parts[3].to_string(),
433 committed_at,
434 parent,
435 files,
436 })
437}
438
439pub async fn git_diff(repo: &Path, path: Option<&str>, staged: bool) -> Result<String, GitError> {
445 if let Some(p) = path {
446 validate_path(p)?;
447 }
448
449 let mut args: Vec<&str> = vec!["diff", "-U5"];
450 if staged {
451 args.push("--cached");
452 }
453 if let Some(p) = path {
454 args.push("--");
455 args.push(p);
456 }
457
458 git_run(&args, repo).await
460}
461
462pub async fn git_add(repo: &Path, paths: &[String], all: bool) -> Result<(), GitError> {
465 if all {
466 git_run(&["add", "-A"], repo).await?;
467 } else {
468 for p in paths {
469 validate_path(p)?;
470 }
471 let mut args = vec!["add", "--"];
472 let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
473 args.extend_from_slice(&path_refs);
474 git_run(&args, repo).await?;
475 }
476 Ok(())
477}
478
479pub async fn git_unstage(repo: &Path, paths: &[String], all: bool) -> Result<(), GitError> {
485 if all {
486 git_run_tolerant(&["reset", "HEAD", "--", "."], repo).await?;
487 } else {
488 for p in paths {
489 validate_path(p)?;
490 }
491 let mut args = vec!["reset", "HEAD", "--"];
492 let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
493 args.extend_from_slice(&path_refs);
494 git_run_tolerant(&args, repo).await?;
495 }
496 Ok(())
497}
498
499pub async fn git_commit(
502 repo: &Path,
503 message: &str,
504 author_name: &str,
505 author_email: &str,
506) -> Result<CommitResult, GitError> {
507 let author_str = format!("{author_name} <{author_email}>");
508 git_run(&["commit", "-m", message, "--author", &author_str], repo).await?;
509
510 let hash = git_run(&["rev-parse", "HEAD"], repo).await?;
511 let hash = hash.trim().to_string();
512 let short_hash = if hash.len() >= 7 {
513 hash[..7].to_string()
514 } else {
515 hash.clone()
516 };
517
518 let summary = git_run(&["log", "-1", "--format=%s", &hash], repo)
520 .await
521 .unwrap_or_else(|_| message.to_string());
522 let summary = summary.trim().to_string();
523
524 Ok(CommitResult {
525 hash,
526 short_hash,
527 summary,
528 })
529}
530
531pub async fn git_branches(repo: &Path) -> Result<BranchInfo, GitError> {
533 let raw = match git_run(&["branch", "--format=%(HEAD) %(refname:short)"], repo).await {
534 Ok(v) => v,
535 Err(GitError::BackendFailed { ref stderr, .. })
536 if stderr.contains("does not have any commits")
537 || stderr.contains("bad default revision") =>
538 {
539 return Ok(BranchInfo {
540 current: "main".to_string(),
541 local: vec![],
542 });
543 }
544 Err(e) => return Err(e),
545 };
546
547 if raw.trim().is_empty() {
548 return Ok(BranchInfo {
549 current: "main".to_string(),
550 local: vec![],
551 });
552 }
553
554 let mut current = String::from("main");
555 let mut local: Vec<String> = Vec::new();
556
557 for line in raw.lines() {
558 let is_current = line.starts_with("* ");
560 let name = line
561 .trim_start_matches("* ")
562 .trim_start_matches(" ")
563 .trim();
564 if name.is_empty() {
565 continue;
566 }
567 if is_current {
568 current = name.to_string();
569 }
570 local.push(name.to_string());
571 }
572
573 Ok(BranchInfo { current, local })
574}
575
576pub async fn git_create_branch(repo: &Path, name: &str) -> Result<(), GitError> {
578 if name.contains("..") || name.contains(' ') || name.starts_with('-') {
580 return Err(GitError::PathTraversal(format!(
581 "invalid branch name: {name}"
582 )));
583 }
584 git_run(&["checkout", "-b", name], repo).await?;
585 Ok(())
586}
587
588pub async fn git_discard(repo: &Path, paths: &[String]) -> Result<(), GitError> {
591 for p in paths {
592 validate_path(p)?;
593 }
594 let mut args = vec!["checkout", "--"];
595 let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
596 args.extend_from_slice(&path_refs);
597 git_run(&args, repo).await?;
598 Ok(())
599}
600
601#[cfg(test)]
606mod tests {
607 use super::*;
608
609 fn make_status(raw: &str) -> StatusReport {
610 parse_status_output(raw).expect("parse failed")
611 }
612
613 #[test]
614 fn parse_clean_repo() {
615 let raw = "## main...origin/main\n";
616 let s = make_status(raw);
617 assert_eq!(s.branch, "main");
618 assert_eq!(s.ahead, 0);
619 assert_eq!(s.behind, 0);
620 assert!(s.is_clean);
621 assert!(s.staged.is_empty());
622 assert!(s.unstaged.is_empty());
623 assert!(s.untracked.is_empty());
624 }
625
626 #[test]
627 fn parse_ahead_behind() {
628 let raw = "## main...origin/main [ahead 3, behind 1]\n";
629 let s = make_status(raw);
630 assert_eq!(s.branch, "main");
631 assert_eq!(s.ahead, 3);
632 assert_eq!(s.behind, 1);
633 }
634
635 #[test]
636 fn parse_ahead_only() {
637 let raw = "## feature...origin/feature [ahead 2]\n";
638 let s = make_status(raw);
639 assert_eq!(s.branch, "feature");
640 assert_eq!(s.ahead, 2);
641 assert_eq!(s.behind, 0);
642 }
643
644 #[test]
645 fn parse_no_commits_yet() {
646 let raw = "## No commits yet on main\n";
647 let s = make_status(raw);
648 assert_eq!(s.branch, "main");
649 assert_eq!(s.ahead, 0);
650 assert_eq!(s.behind, 0);
651 assert!(s.is_clean);
652 }
653
654 #[test]
655 fn parse_no_commits_yet_with_staged() {
656 let raw = "## No commits yet on main\nA README.md\n";
657 let s = make_status(raw);
658 assert_eq!(s.branch, "main");
659 assert_eq!(s.staged.len(), 1);
660 assert_eq!(s.staged[0].change_type, ChangeType::Added);
661 assert_eq!(s.staged[0].path, "README.md");
662 assert!(!s.is_clean);
663 }
664
665 #[test]
666 fn parse_modified_staged_and_unstaged() {
667 let raw = "## main\nMM src/lib.rs\n";
669 let s = make_status(raw);
670 assert_eq!(s.staged.len(), 1);
671 assert_eq!(s.staged[0].change_type, ChangeType::Modified);
672 assert_eq!(s.unstaged.len(), 1);
673 assert_eq!(s.unstaged[0].change_type, ChangeType::Modified);
674 }
675
676 #[test]
677 fn parse_untracked() {
678 let raw = "## main\n?? newfile.txt\n";
679 let s = make_status(raw);
680 assert_eq!(s.untracked, vec!["newfile.txt"]);
681 assert!(!s.is_clean);
682 }
683
684 #[test]
685 fn parse_deleted_staged() {
686 let raw = "## main\nD old.txt\n";
687 let s = make_status(raw);
688 assert_eq!(s.staged.len(), 1);
689 assert_eq!(s.staged[0].change_type, ChangeType::Deleted);
690 assert_eq!(s.staged[0].path, "old.txt");
691 }
692
693 #[test]
694 fn parse_renamed_staged() {
695 let raw = "## main\nR new.txt -> old.txt\n";
696 let s = make_status(raw);
697 assert_eq!(s.staged.len(), 1);
698 assert_eq!(s.staged[0].change_type, ChangeType::Renamed);
699 assert_eq!(s.staged[0].path, "new.txt");
700 assert_eq!(s.staged[0].old_path.as_deref(), Some("old.txt"));
701 }
702
703 #[test]
704 fn parse_branch_no_tracking() {
705 let raw = "## detached-head\nM foo.rs\n";
706 let s = make_status(raw);
707 assert_eq!(s.branch, "detached-head");
708 assert_eq!(s.ahead, 0);
709 assert_eq!(s.behind, 0);
710 }
711
712 #[test]
713 fn validate_path_rejects_dotdot() {
714 assert!(validate_path("../etc/passwd").is_err());
715 assert!(validate_path("foo/../../bar").is_err());
716 }
717
718 #[test]
719 fn validate_path_rejects_absolute() {
720 assert!(validate_path("/etc/passwd").is_err());
721 }
722
723 #[test]
724 fn validate_path_accepts_normal() {
725 assert!(validate_path("src/lib.rs").is_ok());
726 assert!(validate_path("README.md").is_ok());
727 }
728
729 #[tokio::test]
730 async fn resolve_commit_rejects_malformed_rev() {
731 let repo = std::path::Path::new("/nonexistent");
734 for bad in [
735 "",
736 "../etc",
737 "deadbeef; rm -rf /",
738 &"a".repeat(65),
739 "g00dbeef",
740 ] {
741 assert!(
742 matches!(
743 resolve_commit(repo, bad).await,
744 Err(GitError::PathTraversal(_))
745 ),
746 "malformed rev {bad:?} must be rejected pre-git"
747 );
748 }
749 }
750}