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
340pub async fn git_diff(repo: &Path, path: Option<&str>, staged: bool) -> Result<String, GitError> {
346 if let Some(p) = path {
347 validate_path(p)?;
348 }
349
350 let mut args: Vec<&str> = vec!["diff", "-U5"];
351 if staged {
352 args.push("--cached");
353 }
354 if let Some(p) = path {
355 args.push("--");
356 args.push(p);
357 }
358
359 git_run(&args, repo).await
361}
362
363pub async fn git_add(repo: &Path, paths: &[String], all: bool) -> Result<(), GitError> {
366 if all {
367 git_run(&["add", "-A"], repo).await?;
368 } else {
369 for p in paths {
370 validate_path(p)?;
371 }
372 let mut args = vec!["add", "--"];
373 let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
374 args.extend_from_slice(&path_refs);
375 git_run(&args, repo).await?;
376 }
377 Ok(())
378}
379
380pub async fn git_unstage(repo: &Path, paths: &[String], all: bool) -> Result<(), GitError> {
386 if all {
387 git_run_tolerant(&["reset", "HEAD", "--", "."], repo).await?;
388 } else {
389 for p in paths {
390 validate_path(p)?;
391 }
392 let mut args = vec!["reset", "HEAD", "--"];
393 let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
394 args.extend_from_slice(&path_refs);
395 git_run_tolerant(&args, repo).await?;
396 }
397 Ok(())
398}
399
400pub async fn git_commit(
403 repo: &Path,
404 message: &str,
405 author_name: &str,
406 author_email: &str,
407) -> Result<CommitResult, GitError> {
408 let author_str = format!("{author_name} <{author_email}>");
409 git_run(
410 &["commit", "-m", message, "--author", &author_str],
411 repo,
412 )
413 .await?;
414
415 let hash = git_run(&["rev-parse", "HEAD"], repo).await?;
416 let hash = hash.trim().to_string();
417 let short_hash = if hash.len() >= 7 {
418 hash[..7].to_string()
419 } else {
420 hash.clone()
421 };
422
423 let summary = git_run(&["log", "-1", "--format=%s", &hash], repo)
425 .await
426 .unwrap_or_else(|_| message.to_string());
427 let summary = summary.trim().to_string();
428
429 Ok(CommitResult {
430 hash,
431 short_hash,
432 summary,
433 })
434}
435
436pub async fn git_branches(repo: &Path) -> Result<BranchInfo, GitError> {
438 let raw = match git_run(
439 &["branch", "--format=%(HEAD) %(refname:short)"],
440 repo,
441 )
442 .await
443 {
444 Ok(v) => v,
445 Err(GitError::BackendFailed { ref stderr, .. })
446 if stderr.contains("does not have any commits")
447 || stderr.contains("bad default revision") =>
448 {
449 return Ok(BranchInfo {
450 current: "main".to_string(),
451 local: vec![],
452 });
453 }
454 Err(e) => return Err(e),
455 };
456
457 if raw.trim().is_empty() {
458 return Ok(BranchInfo {
459 current: "main".to_string(),
460 local: vec![],
461 });
462 }
463
464 let mut current = String::from("main");
465 let mut local: Vec<String> = Vec::new();
466
467 for line in raw.lines() {
468 let is_current = line.starts_with("* ");
470 let name = line.trim_start_matches("* ").trim_start_matches(" ").trim();
471 if name.is_empty() {
472 continue;
473 }
474 if is_current {
475 current = name.to_string();
476 }
477 local.push(name.to_string());
478 }
479
480 Ok(BranchInfo { current, local })
481}
482
483pub async fn git_create_branch(repo: &Path, name: &str) -> Result<(), GitError> {
485 if name.contains("..") || name.contains(' ') || name.starts_with('-') {
487 return Err(GitError::PathTraversal(format!(
488 "invalid branch name: {name}"
489 )));
490 }
491 git_run(&["checkout", "-b", name], repo).await?;
492 Ok(())
493}
494
495pub async fn git_discard(repo: &Path, paths: &[String]) -> Result<(), GitError> {
498 for p in paths {
499 validate_path(p)?;
500 }
501 let mut args = vec!["checkout", "--"];
502 let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
503 args.extend_from_slice(&path_refs);
504 git_run(&args, repo).await?;
505 Ok(())
506}
507
508#[cfg(test)]
513mod tests {
514 use super::*;
515
516 fn make_status(raw: &str) -> StatusReport {
517 parse_status_output(raw).expect("parse failed")
518 }
519
520 #[test]
521 fn parse_clean_repo() {
522 let raw = "## main...origin/main\n";
523 let s = make_status(raw);
524 assert_eq!(s.branch, "main");
525 assert_eq!(s.ahead, 0);
526 assert_eq!(s.behind, 0);
527 assert!(s.is_clean);
528 assert!(s.staged.is_empty());
529 assert!(s.unstaged.is_empty());
530 assert!(s.untracked.is_empty());
531 }
532
533 #[test]
534 fn parse_ahead_behind() {
535 let raw = "## main...origin/main [ahead 3, behind 1]\n";
536 let s = make_status(raw);
537 assert_eq!(s.branch, "main");
538 assert_eq!(s.ahead, 3);
539 assert_eq!(s.behind, 1);
540 }
541
542 #[test]
543 fn parse_ahead_only() {
544 let raw = "## feature...origin/feature [ahead 2]\n";
545 let s = make_status(raw);
546 assert_eq!(s.branch, "feature");
547 assert_eq!(s.ahead, 2);
548 assert_eq!(s.behind, 0);
549 }
550
551 #[test]
552 fn parse_no_commits_yet() {
553 let raw = "## No commits yet on main\n";
554 let s = make_status(raw);
555 assert_eq!(s.branch, "main");
556 assert_eq!(s.ahead, 0);
557 assert_eq!(s.behind, 0);
558 assert!(s.is_clean);
559 }
560
561 #[test]
562 fn parse_no_commits_yet_with_staged() {
563 let raw = "## No commits yet on main\nA README.md\n";
564 let s = make_status(raw);
565 assert_eq!(s.branch, "main");
566 assert_eq!(s.staged.len(), 1);
567 assert_eq!(s.staged[0].change_type, ChangeType::Added);
568 assert_eq!(s.staged[0].path, "README.md");
569 assert!(!s.is_clean);
570 }
571
572 #[test]
573 fn parse_modified_staged_and_unstaged() {
574 let raw = "## main\nMM src/lib.rs\n";
576 let s = make_status(raw);
577 assert_eq!(s.staged.len(), 1);
578 assert_eq!(s.staged[0].change_type, ChangeType::Modified);
579 assert_eq!(s.unstaged.len(), 1);
580 assert_eq!(s.unstaged[0].change_type, ChangeType::Modified);
581 }
582
583 #[test]
584 fn parse_untracked() {
585 let raw = "## main\n?? newfile.txt\n";
586 let s = make_status(raw);
587 assert_eq!(s.untracked, vec!["newfile.txt"]);
588 assert!(!s.is_clean);
589 }
590
591 #[test]
592 fn parse_deleted_staged() {
593 let raw = "## main\nD old.txt\n";
594 let s = make_status(raw);
595 assert_eq!(s.staged.len(), 1);
596 assert_eq!(s.staged[0].change_type, ChangeType::Deleted);
597 assert_eq!(s.staged[0].path, "old.txt");
598 }
599
600 #[test]
601 fn parse_renamed_staged() {
602 let raw = "## main\nR new.txt -> old.txt\n";
603 let s = make_status(raw);
604 assert_eq!(s.staged.len(), 1);
605 assert_eq!(s.staged[0].change_type, ChangeType::Renamed);
606 assert_eq!(s.staged[0].path, "new.txt");
607 assert_eq!(s.staged[0].old_path.as_deref(), Some("old.txt"));
608 }
609
610 #[test]
611 fn parse_branch_no_tracking() {
612 let raw = "## detached-head\nM foo.rs\n";
613 let s = make_status(raw);
614 assert_eq!(s.branch, "detached-head");
615 assert_eq!(s.ahead, 0);
616 assert_eq!(s.behind, 0);
617 }
618
619 #[test]
620 fn validate_path_rejects_dotdot() {
621 assert!(validate_path("../etc/passwd").is_err());
622 assert!(validate_path("foo/../../bar").is_err());
623 }
624
625 #[test]
626 fn validate_path_rejects_absolute() {
627 assert!(validate_path("/etc/passwd").is_err());
628 }
629
630 #[test]
631 fn validate_path_accepts_normal() {
632 assert!(validate_path("src/lib.rs").is_ok());
633 assert!(validate_path("README.md").is_ok());
634 }
635}