1use std::path::Path;
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::GitError;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22#[serde(rename_all = "lowercase")]
23pub enum ChangeType {
24 Modified,
26 Added,
28 Deleted,
30 Renamed,
32 Copied,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct FileStatus {
40 pub path: String,
42 pub change_type: ChangeType,
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub old_path: Option<String>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct StatusReport {
53 pub branch: String,
55 pub ahead: u32,
57 pub behind: u32,
59 pub staged: Vec<FileStatus>,
61 pub unstaged: Vec<FileStatus>,
63 pub untracked: Vec<String>,
65 pub is_clean: bool,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct CommitEntry {
73 pub hash: String,
75 pub short_hash: String,
77 pub message: String,
79 pub author: String,
81 pub date: String,
83 pub date_relative: String,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct BranchInfo {
91 pub current: String,
93 pub local: Vec<String>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(rename_all = "camelCase")]
100pub struct CommitResult {
101 pub hash: String,
103 pub short_hash: String,
105 pub summary: String,
107}
108
109async fn git_run(args: &[&str], repo: &Path) -> Result<String, GitError> {
116 let output = crate::hardened::hardened_git_async()
117 .args(args)
118 .current_dir(repo)
119 .output()
120 .await
121 .map_err(|e| {
122 if e.kind() == std::io::ErrorKind::NotFound {
123 GitError::BackendNotAvailable("git binary not found in PATH".into())
124 } else {
125 GitError::Io(e)
126 }
127 })?;
128
129 if output.status.success() {
130 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
131 } else {
132 Err(GitError::BackendFailed {
133 exit_code: output.status.code(),
134 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
135 })
136 }
137}
138
139async fn git_run_tolerant(args: &[&str], repo: &Path) -> Result<String, GitError> {
142 let output = crate::hardened::hardened_git_async()
143 .args(args)
144 .current_dir(repo)
145 .output()
146 .await
147 .map_err(|e| {
148 if e.kind() == std::io::ErrorKind::NotFound {
149 GitError::BackendNotAvailable("git binary not found in PATH".into())
150 } else {
151 GitError::Io(e)
152 }
153 })?;
154
155 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
156}
157
158fn validate_path(path: &str) -> Result<(), GitError> {
161 if path.starts_with('/') || path.contains("..") {
162 return Err(GitError::PathTraversal(path.to_string()));
163 }
164 Ok(())
165}
166
167fn parse_xy(x: char, y: char) -> (Option<ChangeType>, Option<ChangeType>) {
169 let map_char = |c: char| match c {
170 'M' => Some(ChangeType::Modified),
171 'A' => Some(ChangeType::Added),
172 'D' => Some(ChangeType::Deleted),
173 'R' => Some(ChangeType::Renamed),
174 'C' => Some(ChangeType::Copied),
175 _ => None,
176 };
177 (map_char(x), map_char(y))
178}
179
180pub async fn git_status(repo: &Path) -> Result<StatusReport, GitError> {
186 let raw = git_run(&["status", "--porcelain=v1", "-b"], repo).await?;
187 parse_status_output(&raw)
188}
189
190pub fn parse_status_output(raw: &str) -> Result<StatusReport, GitError> {
192 let mut lines = raw.lines();
193
194 let branch_line = lines.next().unwrap_or("");
196 let branch_line = branch_line.trim_start_matches("## ");
198
199 let mut ahead: u32 = 0;
200 let mut behind: u32 = 0;
201
202 let branch: String;
203 if branch_line.starts_with("No commits yet on ") {
204 branch = branch_line
205 .trim_start_matches("No commits yet on ")
206 .to_string();
207 } else {
208 let (branch_part, tracking_part) = if let Some(idx) = branch_line.find("...") {
211 (&branch_line[..idx], Some(&branch_line[idx + 3..]))
212 } else {
213 (branch_line, None)
214 };
215 branch = branch_part.to_string();
216
217 if let Some(tracking) = tracking_part {
218 if let Some(bracket_start) = tracking.find('[') {
220 let inside = &tracking[bracket_start + 1..];
221 let inside = inside.trim_end_matches(']');
222 for part in inside.split(',') {
223 let part = part.trim();
224 if let Some(n) = part.strip_prefix("ahead ") {
225 ahead = n.trim().parse().unwrap_or(0);
226 } else if let Some(n) = part.strip_prefix("behind ") {
227 behind = n.trim().parse().unwrap_or(0);
228 }
229 }
230 }
231 }
232 }
233
234 let mut staged: Vec<FileStatus> = Vec::new();
236 let mut unstaged: Vec<FileStatus> = Vec::new();
237 let mut untracked: Vec<String> = Vec::new();
238
239 for line in lines {
240 if line.len() < 4 {
241 continue;
242 }
243 let x = line.chars().next().unwrap_or(' ');
244 let y = line.chars().nth(1).unwrap_or(' ');
245 let rest = &line[3..]; if x == '?' && y == '?' {
248 untracked.push(rest.to_string());
249 continue;
250 }
251
252 let (staged_change, unstaged_change) = parse_xy(x, y);
253
254 let (path, old_path) =
260 if (x == 'R' || x == 'C' || y == 'R' || y == 'C') && rest.contains(" -> ") {
261 let mut parts = rest.splitn(2, " -> ");
262 let dest = parts.next().unwrap_or(rest).to_string();
263 let orig = parts.next().map(str::to_string);
264 (dest, orig)
265 } else {
266 (rest.to_string(), None)
267 };
268
269 if let Some(ct) = staged_change {
270 staged.push(FileStatus {
271 path: path.clone(),
272 change_type: ct,
273 old_path: old_path.clone(),
274 });
275 }
276 if let Some(ct) = unstaged_change {
277 unstaged.push(FileStatus {
278 path: path.clone(),
279 change_type: ct,
280 old_path,
281 });
282 }
283 }
284
285 let is_clean = staged.is_empty() && unstaged.is_empty() && untracked.is_empty();
286
287 Ok(StatusReport {
288 branch,
289 ahead,
290 behind,
291 staged,
292 unstaged,
293 untracked,
294 is_clean,
295 })
296}
297
298pub async fn git_log(repo: &Path, limit: u32) -> Result<Vec<CommitEntry>, GitError> {
300 let cap = limit.min(100);
301 let cap_str = cap.to_string();
302 let format = "%H\x1F%h\x1F%s\x1F%an\x1F%aI\x1F%ar";
303 let raw = match git_run(
304 &["log", &format!("--format={format}"), "-n", &cap_str],
305 repo,
306 )
307 .await
308 {
309 Ok(v) => v,
310 Err(GitError::BackendFailed { ref stderr, .. })
311 if stderr.contains("does not have any commits")
312 || stderr.contains("bad default revision")
313 || stderr.contains("fatal: your current branch") =>
314 {
315 return Ok(Vec::new());
316 }
317 Err(e) => return Err(e),
318 };
319
320 if raw.trim().is_empty() {
321 return Ok(Vec::new());
322 }
323
324 let mut entries = Vec::new();
325 for line in raw.lines() {
326 let parts: Vec<&str> = line.splitn(6, '\x1F').collect();
327 if parts.len() < 6 {
328 continue;
329 }
330 entries.push(CommitEntry {
331 hash: parts[0].to_string(),
332 short_hash: parts[1].to_string(),
333 message: parts[2].to_string(),
334 author: parts[3].to_string(),
335 date: parts[4].to_string(),
336 date_relative: parts[5].to_string(),
337 });
338 }
339 Ok(entries)
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
345#[serde(rename_all = "camelCase")]
346pub struct ResolvedCommit {
347 pub hash: String,
349 pub author_email: String,
352 pub author_name: String,
354 pub subject: String,
356 pub committed_at: u64,
358 pub parent: Option<String>,
361 pub files: Vec<String>,
363}
364
365pub 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}