1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use thiserror::Error;
4
5#[doc = include_str!("docs/git_diff_document.md")]
6#[allow(dead_code)]
7pub struct GitDiffDocument {
8 pub repo_root: PathBuf,
9 pub files: Vec<FileDiff>,
10}
11
12pub struct FileDiff {
13 pub old_path: Option<String>,
14 pub path: String,
15 pub status: FileStatus,
16 pub staged: StageState,
17 pub hunks: Vec<Hunk>,
18 pub binary: bool,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum DiffScope {
23 Unstaged,
24 Staged,
25 #[default]
26 Both,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum FileStatus {
31 Modified,
32 Added,
33 Deleted,
34 Renamed,
35 Untracked,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum StageState {
40 Unstaged,
41 Staged,
42 PartiallyStaged,
43}
44
45#[allow(dead_code)]
46pub struct Hunk {
47 pub header: String,
48 pub old_start: usize,
49 pub old_count: usize,
50 pub new_start: usize,
51 pub new_count: usize,
52 pub lines: Vec<PatchLine>,
53}
54
55pub struct PatchLine {
56 pub kind: PatchLineKind,
57 pub text: String,
58 pub old_line_no: Option<usize>,
59 pub new_line_no: Option<usize>,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum PatchLineKind {
64 HunkHeader,
65 Context,
66 Added,
67 Removed,
68 Meta,
69}
70
71#[doc = include_str!("docs/git_diff_error.md")]
72#[derive(Debug, Error)]
73pub enum GitDiffError {
74 #[error("Not a git repository")]
75 NotARepository,
76 #[error("Git command failed: {stderr}")]
77 CommandFailed { stderr: String },
78 #[error("Failed to parse diff: {0}")]
79 ParseError(String),
80}
81
82impl DiffScope {
83 pub fn label(self) -> &'static str {
84 match self {
85 Self::Unstaged => "Unstaged",
86 Self::Staged => "Staged",
87 Self::Both => "Both",
88 }
89 }
90
91 pub(crate) fn next(self) -> Self {
92 match self {
93 Self::Both => Self::Unstaged,
94 Self::Unstaged => Self::Staged,
95 Self::Staged => Self::Both,
96 }
97 }
98
99 fn includes_untracked(self) -> bool {
100 !matches!(self, Self::Staged)
101 }
102}
103
104impl FileStatus {
105 pub fn marker(self) -> char {
106 match self {
107 Self::Modified => 'M',
108 Self::Added => 'A',
109 Self::Deleted => 'D',
110 Self::Renamed => 'R',
111 Self::Untracked => '?',
112 }
113 }
114
115 pub fn label(self) -> &'static str {
116 match self {
117 Self::Modified => "modified",
118 Self::Added => "new file",
119 Self::Deleted => "deleted",
120 Self::Renamed => "renamed",
121 Self::Untracked => "untracked",
122 }
123 }
124}
125
126impl FileDiff {
127 pub fn additions(&self) -> usize {
128 self.hunks.iter().map(Hunk::additions).sum()
129 }
130
131 pub fn deletions(&self) -> usize {
132 self.hunks.iter().map(Hunk::deletions).sum()
133 }
134
135 pub fn max_line_no(&self) -> usize {
136 self.hunks
137 .iter()
138 .flat_map(|hunk| &hunk.lines)
139 .flat_map(|line| line.old_line_no.into_iter().chain(line.new_line_no))
140 .max()
141 .unwrap_or(0)
142 }
143}
144
145impl Hunk {
146 pub fn additions(&self) -> usize {
147 self.lines.iter().filter(|line| line.kind == PatchLineKind::Added).count()
148 }
149
150 pub fn deletions(&self) -> usize {
151 self.lines.iter().filter(|line| line.kind == PatchLineKind::Removed).count()
152 }
153}
154
155pub(crate) async fn load_git_diff(
156 working_dir: &Path,
157 cached_repo_root: Option<&Path>,
158 scope: DiffScope,
159) -> Result<GitDiffDocument, GitDiffError> {
160 let repo_root = match cached_repo_root {
161 Some(root) => root.to_path_buf(),
162 None => resolve_repo_root(working_dir).await?,
163 };
164 let diff_args = match scope {
165 DiffScope::Unstaged => &["diff", "--no-ext-diff", "--find-renames"][..],
166 DiffScope::Staged => &["diff", "--cached", "--no-ext-diff", "--find-renames"][..],
167 DiffScope::Both => &["diff", "--no-ext-diff", "--find-renames", "HEAD"][..],
168 };
169 let diff_output = git(&repo_root, diff_args).await?;
170
171 let mut files = if diff_output.trim().is_empty() { Vec::new() } else { parse_unified_diff(&diff_output)? };
172
173 if scope.includes_untracked() {
174 let untracked_stdout = git(&repo_root, &["ls-files", "--others", "--exclude-standard"]).await?;
175 for path in untracked_stdout.lines().filter(|l| !l.is_empty()).map(String::from) {
176 files.push(build_untracked_file_diff(&repo_root, path).await);
177 }
178 }
179
180 let status_map = load_status_map(&repo_root).await?;
181 for file in &mut files {
182 file.staged = status_map.get(&file.path).copied().unwrap_or(StageState::Unstaged);
183 }
184
185 Ok(GitDiffDocument { repo_root, files })
186}
187
188pub(crate) async fn stage_files(repo_root: &Path, paths: &[&str]) -> Result<(), GitDiffError> {
189 let mut args = vec!["add", "--"];
190 args.extend_from_slice(paths);
191 git(repo_root, &args).await.map(drop)
192}
193
194pub(crate) async fn unstage_files(repo_root: &Path, paths: &[&str]) -> Result<(), GitDiffError> {
195 let mut args = vec!["reset", "--quiet", "--"];
196 args.extend_from_slice(paths);
197 git(repo_root, &args).await.map(drop)
198}
199
200pub(crate) async fn stage_all(repo_root: &Path) -> Result<(), GitDiffError> {
201 git(repo_root, &["add", "-A"]).await.map(drop)
202}
203
204pub(crate) async fn unstage_all(repo_root: &Path) -> Result<(), GitDiffError> {
205 git(repo_root, &["reset", "--quiet"]).await.map(drop)
206}
207
208pub(crate) async fn discard_file(repo_root: &Path, path: &str, status: FileStatus) -> Result<(), GitDiffError> {
209 match status {
210 FileStatus::Untracked => git(repo_root, &["clean", "-f", "--", path]).await.map(drop),
211 _ => git(repo_root, &["restore", "--source=HEAD", "--staged", "--worktree", "--", path]).await.map(drop),
212 }
213}
214
215pub(crate) async fn commit(repo_root: &Path, message: &str) -> Result<(), GitDiffError> {
216 if message.trim().is_empty() {
217 return Err(GitDiffError::CommandFailed { stderr: "empty commit message".to_string() });
218 }
219 git(repo_root, &["commit", "-m", message]).await.map(drop)
220}
221
222async fn load_status_map(repo_root: &Path) -> Result<HashMap<String, StageState>, GitDiffError> {
223 let output = git(repo_root, &["status", "--porcelain=v1", "-z"]).await?;
224 Ok(parse_porcelain_status(&output))
225}
226
227fn parse_porcelain_status(input: &str) -> HashMap<String, StageState> {
228 let mut map = HashMap::new();
229 let mut tokens = input.split('\0').filter(|token| !token.is_empty());
230
231 while let Some(record) = tokens.next() {
232 if record.len() < 3 {
233 continue;
234 }
235 let bytes = record.as_bytes();
236 let index = bytes[0] as char;
237 let worktree = bytes[1] as char;
238 let path = &record[3..];
239
240 if matches!(index, 'R' | 'C') || matches!(worktree, 'R' | 'C') {
241 tokens.next();
242 }
243
244 let state = match (index, worktree) {
245 ('?', '?') | (' ', _) => StageState::Unstaged,
246 (_, ' ') => StageState::Staged,
247 _ => StageState::PartiallyStaged,
248 };
249 map.insert(path.to_string(), state);
250 }
251
252 map
253}
254
255async fn resolve_repo_root(working_dir: &Path) -> Result<PathBuf, GitDiffError> {
256 let output = tokio::process::Command::new("git")
257 .arg("rev-parse")
258 .arg("--show-toplevel")
259 .current_dir(working_dir)
260 .output()
261 .await
262 .map_err(|e| GitDiffError::CommandFailed { stderr: e.to_string() })?;
263
264 if !output.status.success() {
265 let stderr = String::from_utf8_lossy(&output.stderr);
266 if stderr.contains("not a git repository") {
267 return Err(GitDiffError::NotARepository);
268 }
269 return Err(GitDiffError::CommandFailed { stderr: stderr.into_owned() });
270 }
271
272 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
273 Ok(PathBuf::from(root))
274}
275
276async fn git(repo_root: &Path, args: &[&str]) -> Result<String, GitDiffError> {
277 let output = tokio::process::Command::new("git")
278 .args(args)
279 .current_dir(repo_root)
280 .output()
281 .await
282 .map_err(|e| GitDiffError::CommandFailed { stderr: e.to_string() })?;
283
284 if !output.status.success() {
285 let stderr = String::from_utf8_lossy(&output.stderr);
286 return Err(GitDiffError::CommandFailed { stderr: stderr.into_owned() });
287 }
288
289 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
290}
291
292async fn build_untracked_file_diff(repo_root: &Path, relative_path: String) -> FileDiff {
293 let full_path = repo_root.join(&relative_path);
294 let Ok(bytes) = tokio::fs::read(&full_path).await else {
295 return binary_untracked(relative_path);
296 };
297
298 if bytes.iter().take(8192).any(|&b| b == 0) {
299 return binary_untracked(relative_path);
300 }
301
302 let Ok(content) = String::from_utf8(bytes) else {
303 return binary_untracked(relative_path);
304 };
305
306 let text_lines: Vec<&str> = content.lines().collect();
307 let line_count = text_lines.len();
308
309 let hunk_header = format!("@@ -0,0 +1,{line_count} @@");
310
311 let mut patch_lines = vec![PatchLine {
312 kind: PatchLineKind::HunkHeader,
313 text: hunk_header.clone(),
314 old_line_no: None,
315 new_line_no: None,
316 }];
317
318 for (i, line) in text_lines.iter().enumerate() {
319 patch_lines.push(PatchLine {
320 kind: PatchLineKind::Added,
321 text: (*line).to_string(),
322 old_line_no: None,
323 new_line_no: Some(i + 1),
324 });
325 }
326
327 let hunk = Hunk {
328 header: hunk_header,
329 old_start: 0,
330 old_count: 0,
331 new_start: 1,
332 new_count: line_count,
333 lines: patch_lines,
334 };
335
336 FileDiff {
337 old_path: None,
338 path: relative_path,
339 status: FileStatus::Untracked,
340 staged: StageState::Unstaged,
341 hunks: vec![hunk],
342 binary: false,
343 }
344}
345
346fn binary_untracked(path: String) -> FileDiff {
347 FileDiff {
348 old_path: None,
349 path,
350 status: FileStatus::Untracked,
351 staged: StageState::Unstaged,
352 hunks: Vec::new(),
353 binary: true,
354 }
355}
356
357pub(crate) fn parse_unified_diff(input: &str) -> Result<Vec<FileDiff>, GitDiffError> {
358 split_diff_files(input).into_iter().map(parse_file_diff).collect()
359}
360
361fn split_diff_files(input: &str) -> Vec<&str> {
362 let mut chunks = Vec::new();
363 let mut start = None;
364 let mut line_start = 0;
365
366 while line_start < input.len() {
367 let line_end = input[line_start..].find('\n').map_or(input.len(), |idx| line_start + idx + 1);
368 let line = &input[line_start..line_end];
369
370 if line.starts_with("diff --git ") {
371 if let Some(s) = start {
372 chunks.push(&input[s..line_start]);
373 }
374 start = Some(line_start);
375 }
376
377 line_start = line_end;
378 }
379
380 if let Some(s) = start {
381 chunks.push(&input[s..]);
382 }
383
384 chunks
385}
386
387fn parse_file_diff(chunk: &str) -> Result<FileDiff, GitDiffError> {
388 let lines: Vec<&str> = chunk.lines().collect();
389 if lines.is_empty() {
390 return Err(GitDiffError::ParseError("Empty diff chunk".to_string()));
391 }
392
393 let (old_path, new_path) = parse_diff_header(lines[0])?;
394 let (status, binary, rename_from, hunk_start) = scan_file_metadata(&lines);
395 let hunks = if binary { Vec::new() } else { parse_file_hunks(&lines[hunk_start..])? };
396
397 Ok(FileDiff {
398 old_path: resolve_old_path(status, rename_from, old_path),
399 path: new_path,
400 status,
401 staged: StageState::Unstaged,
402 hunks,
403 binary,
404 })
405}
406
407fn scan_file_metadata(lines: &[&str]) -> (FileStatus, bool, Option<String>, usize) {
408 let mut status = FileStatus::Modified;
409 let mut binary = false;
410 let mut rename_from = None;
411 let mut i = 1;
412
413 while i < lines.len() {
414 let line = lines[i];
415 if line.starts_with("new file mode") {
416 status = FileStatus::Added;
417 } else if line.starts_with("deleted file mode") {
418 status = FileStatus::Deleted;
419 } else if let Some(from) = line.strip_prefix("rename from ") {
420 status = FileStatus::Renamed;
421 rename_from = Some(from.to_string());
422 } else if line.starts_with("rename to ") {
423 status = FileStatus::Renamed;
424 } else if line.starts_with("Binary files ") {
425 binary = true;
426 } else if line.starts_with("@@") {
427 break;
428 }
429 i += 1;
430 }
431
432 (status, binary, rename_from, i)
433}
434
435fn parse_file_hunks(lines: &[&str]) -> Result<Vec<Hunk>, GitDiffError> {
436 let mut hunks = Vec::new();
437 let mut i = 0;
438
439 while i < lines.len() {
440 if lines[i].starts_with("@@") {
441 let (hunk, consumed) = parse_hunk(&lines[i..])?;
442 hunks.push(hunk);
443 i += consumed;
444 } else {
445 i += 1;
446 }
447 }
448
449 Ok(hunks)
450}
451
452fn resolve_old_path(status: FileStatus, rename_from: Option<String>, old_path: String) -> Option<String> {
453 if status == FileStatus::Added || status == FileStatus::Untracked {
454 None
455 } else if status == FileStatus::Renamed {
456 rename_from.or(Some(old_path))
457 } else {
458 Some(old_path)
459 }
460}
461
462fn parse_diff_header(line: &str) -> Result<(String, String), GitDiffError> {
463 let rest = line
464 .strip_prefix("diff --git ")
465 .ok_or_else(|| GitDiffError::ParseError(format!("Invalid diff header: {line}")))?;
466
467 if let Some((a, b)) = rest.split_once(" b/") {
468 let old = a.strip_prefix("a/").unwrap_or(a).to_string();
469 let new = b.to_string();
470 Ok((old, new))
471 } else {
472 Err(GitDiffError::ParseError(format!("Cannot parse paths from: {line}")))
473 }
474}
475
476fn parse_hunk(lines: &[&str]) -> Result<(Hunk, usize), GitDiffError> {
477 let header = lines[0];
478 let (old_start, old_count, new_start, new_count) = parse_hunk_header(header)?;
479
480 let mut patch_lines = Vec::new();
481 patch_lines.push(PatchLine {
482 kind: PatchLineKind::HunkHeader,
483 text: header.to_string(),
484 old_line_no: None,
485 new_line_no: None,
486 });
487
488 let mut old_line = old_start;
489 let mut new_line = new_start;
490 let mut i = 1;
491
492 while i < lines.len() {
493 let line = lines[i];
494 if line.starts_with("@@") {
495 break;
496 }
497
498 if let Some(text) = line.strip_prefix('+') {
499 patch_lines.push(PatchLine {
500 kind: PatchLineKind::Added,
501 text: text.to_string(),
502 old_line_no: None,
503 new_line_no: Some(new_line),
504 });
505 new_line += 1;
506 } else if let Some(text) = line.strip_prefix('-') {
507 patch_lines.push(PatchLine {
508 kind: PatchLineKind::Removed,
509 text: text.to_string(),
510 old_line_no: Some(old_line),
511 new_line_no: None,
512 });
513 old_line += 1;
514 } else if let Some(text) = line.strip_prefix(' ') {
515 patch_lines.push(PatchLine {
516 kind: PatchLineKind::Context,
517 text: text.to_string(),
518 old_line_no: Some(old_line),
519 new_line_no: Some(new_line),
520 });
521 old_line += 1;
522 new_line += 1;
523 } else if line.starts_with('\\') {
524 patch_lines.push(PatchLine {
525 kind: PatchLineKind::Meta,
526 text: line.to_string(),
527 old_line_no: None,
528 new_line_no: None,
529 });
530 } else {
531 patch_lines.push(PatchLine {
533 kind: PatchLineKind::Context,
534 text: line.to_string(),
535 old_line_no: Some(old_line),
536 new_line_no: Some(new_line),
537 });
538 old_line += 1;
539 new_line += 1;
540 }
541 i += 1;
542 }
543
544 Ok((Hunk { header: header.to_string(), old_start, old_count, new_start, new_count, lines: patch_lines }, i))
545}
546
547fn parse_hunk_header(header: &str) -> Result<(usize, usize, usize, usize), GitDiffError> {
548 let err = || GitDiffError::ParseError(format!("Invalid hunk header: {header}"));
550
551 let rest = header.strip_prefix("@@ -").ok_or_else(err)?;
552 let at_end = rest.find(" @@").ok_or_else(err)?;
553 let range_part = &rest[..at_end];
554
555 let (old_range, new_range) = range_part.split_once(" +").ok_or_else(err)?;
556
557 let (old_start, old_count) = parse_range(old_range).ok_or_else(err)?;
558 let (new_start, new_count) = parse_range(new_range).ok_or_else(err)?;
559
560 Ok((old_start, old_count, new_start, new_count))
561}
562
563fn parse_range(s: &str) -> Option<(usize, usize)> {
564 if let Some((start, count)) = s.split_once(',') {
565 Some((start.parse().ok()?, count.parse().ok()?))
566 } else {
567 let start: usize = s.parse().ok()?;
568 Some((start, 1))
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575
576 #[test]
577 fn parse_modified_file() {
578 let input = "\
579diff --git a/src/main.rs b/src/main.rs
580index abc1234..def5678 100644
581--- a/src/main.rs
582+++ b/src/main.rs
583@@ -1,3 +1,4 @@
584 fn main() {
585+ println!(\"hello\");
586 let x = 1;
587 }
588";
589 let files = parse_unified_diff(input).unwrap();
590 assert_eq!(files.len(), 1);
591 assert_eq!(files[0].path, "src/main.rs");
592 assert_eq!(files[0].status, FileStatus::Modified);
593 assert_eq!(files[0].additions(), 1);
594 assert_eq!(files[0].deletions(), 0);
595 assert!(!files[0].binary);
596 assert_eq!(files[0].hunks.len(), 1);
597 }
598
599 #[test]
600 fn parse_added_file() {
601 let input = "\
602diff --git a/new_file.txt b/new_file.txt
603new file mode 100644
604index 0000000..abc1234
605--- /dev/null
606+++ b/new_file.txt
607@@ -0,0 +1,2 @@
608+line one
609+line two
610";
611 let files = parse_unified_diff(input).unwrap();
612 assert_eq!(files.len(), 1);
613 assert_eq!(files[0].path, "new_file.txt");
614 assert_eq!(files[0].status, FileStatus::Added);
615 assert!(files[0].old_path.is_none());
616 assert_eq!(files[0].additions(), 2);
617 assert_eq!(files[0].deletions(), 0);
618 }
619
620 #[test]
621 fn parse_deleted_file() {
622 let input = "\
623diff --git a/old_file.txt b/old_file.txt
624deleted file mode 100644
625index abc1234..0000000
626--- a/old_file.txt
627+++ /dev/null
628@@ -1,2 +0,0 @@
629-line one
630-line two
631";
632 let files = parse_unified_diff(input).unwrap();
633 assert_eq!(files.len(), 1);
634 assert_eq!(files[0].path, "old_file.txt");
635 assert_eq!(files[0].status, FileStatus::Deleted);
636 assert_eq!(files[0].additions(), 0);
637 assert_eq!(files[0].deletions(), 2);
638 }
639
640 #[test]
641 fn parse_renamed_file() {
642 let input = "\
643diff --git a/old_name.rs b/new_name.rs
644similarity index 95%
645rename from old_name.rs
646rename to new_name.rs
647index abc1234..def5678 100644
648--- a/old_name.rs
649+++ b/new_name.rs
650@@ -1,3 +1,3 @@
651 fn main() {
652- old();
653+ new();
654 }
655";
656 let files = parse_unified_diff(input).unwrap();
657 assert_eq!(files.len(), 1);
658 assert_eq!(files[0].path, "new_name.rs");
659 assert_eq!(files[0].status, FileStatus::Renamed);
660 assert_eq!(files[0].old_path.as_deref(), Some("old_name.rs"));
661 assert_eq!(files[0].additions(), 1);
662 assert_eq!(files[0].deletions(), 1);
663 }
664
665 #[test]
666 fn parse_hunk_header_tracking() {
667 let input = "\
668diff --git a/file.rs b/file.rs
669index abc..def 100644
670--- a/file.rs
671+++ b/file.rs
672@@ -10,4 +10,5 @@ fn context_label() {
673 context
674-removed
675+added1
676+added2
677 context
678";
679 let files = parse_unified_diff(input).unwrap();
680 let hunk = &files[0].hunks[0];
681 assert_eq!(hunk.old_start, 10);
682 assert_eq!(hunk.old_count, 4);
683 assert_eq!(hunk.new_start, 10);
684 assert_eq!(hunk.new_count, 5);
685
686 let lines = &hunk.lines;
688 assert_eq!(lines[0].kind, PatchLineKind::HunkHeader);
690 assert_eq!(lines[1].kind, PatchLineKind::Context);
692 assert_eq!(lines[1].old_line_no, Some(10));
693 assert_eq!(lines[1].new_line_no, Some(10));
694 assert_eq!(lines[2].kind, PatchLineKind::Removed);
696 assert_eq!(lines[2].old_line_no, Some(11));
697 assert_eq!(lines[2].new_line_no, None);
698 assert_eq!(lines[3].kind, PatchLineKind::Added);
700 assert_eq!(lines[3].old_line_no, None);
701 assert_eq!(lines[3].new_line_no, Some(11));
702 assert_eq!(lines[4].kind, PatchLineKind::Added);
704 assert_eq!(lines[4].old_line_no, None);
705 assert_eq!(lines[4].new_line_no, Some(12));
706 assert_eq!(lines[5].kind, PatchLineKind::Context);
708 assert_eq!(lines[5].old_line_no, Some(12));
709 assert_eq!(lines[5].new_line_no, Some(13));
710 }
711
712 #[test]
713 fn parse_meta_line() {
714 let input = "\
715diff --git a/file.txt b/file.txt
716index abc..def 100644
717--- a/file.txt
718+++ b/file.txt
719@@ -1,1 +1,1 @@
720-old
721\\ No newline at end of file
722+new
723";
724 let files = parse_unified_diff(input).unwrap();
725 let hunk = &files[0].hunks[0];
726 let meta = hunk.lines.iter().find(|l| l.kind == PatchLineKind::Meta);
727 assert!(meta.is_some());
728 assert!(meta.unwrap().text.contains("No newline"));
729 }
730
731 #[test]
732 fn parse_binary_diff() {
733 let input = "\
734diff --git a/image.png b/image.png
735new file mode 100644
736index 0000000..abc1234
737Binary files /dev/null and b/image.png differ
738";
739 let files = parse_unified_diff(input).unwrap();
740 assert_eq!(files.len(), 1);
741 assert!(files[0].binary);
742 assert!(files[0].hunks.is_empty());
743 }
744
745 #[test]
746 fn parse_empty_diff() {
747 let files = parse_unified_diff("").unwrap();
748 assert!(files.is_empty());
749 }
750
751 #[test]
752 fn parse_multiple_files() {
753 let input = "\
754diff --git a/a.rs b/a.rs
755index abc..def 100644
756--- a/a.rs
757+++ b/a.rs
758@@ -1,1 +1,1 @@
759-old_a
760+new_a
761diff --git a/b.rs b/b.rs
762new file mode 100644
763index 0000000..abc1234
764--- /dev/null
765+++ b/b.rs
766@@ -0,0 +1,1 @@
767+new_b
768";
769 let files = parse_unified_diff(input).unwrap();
770 assert_eq!(files.len(), 2);
771 assert_eq!(files[0].path, "a.rs");
772 assert_eq!(files[0].status, FileStatus::Modified);
773 assert_eq!(files[1].path, "b.rs");
774 assert_eq!(files[1].status, FileStatus::Added);
775 }
776
777 #[test]
778 fn parse_diff_marker_inside_hunk_line() {
779 let input = "\
780diff --git a/file.rs b/file.rs
781index abc..def 100644
782--- a/file.rs
783+++ b/file.rs
784@@ -1,1 +1,2 @@
785 fn main() {
786+cannot parse paths from: diff --git /m)
787 }
788";
789 let files = parse_unified_diff(input).unwrap();
790 assert_eq!(files.len(), 1);
791 assert_eq!(files[0].path, "file.rs");
792 assert_eq!(files[0].status, FileStatus::Modified);
793 assert_eq!(files[0].additions(), 1);
794 }
795
796 #[test]
797 fn parse_multiple_hunks() {
798 let input = "\
799diff --git a/file.rs b/file.rs
800index abc..def 100644
801--- a/file.rs
802+++ b/file.rs
803@@ -1,3 +1,3 @@
804 fn a() {
805- old_a();
806+ new_a();
807 }
808@@ -10,3 +10,3 @@
809 fn b() {
810- old_b();
811+ new_b();
812 }
813";
814 let files = parse_unified_diff(input).unwrap();
815 assert_eq!(files[0].hunks.len(), 2);
816 assert_eq!(files[0].hunks[0].old_start, 1);
817 assert_eq!(files[0].hunks[1].old_start, 10);
818 }
819
820 #[test]
821 fn parse_hunk_header_without_comma() {
822 let (start, count, new_start, new_count) = parse_hunk_header("@@ -1 +1 @@ fn main()").unwrap();
823 assert_eq!(start, 1);
824 assert_eq!(count, 1);
825 assert_eq!(new_start, 1);
826 assert_eq!(new_count, 1);
827 }
828
829 #[test]
830 fn file_status_marker() {
831 assert_eq!(FileStatus::Modified.marker(), 'M');
832 assert_eq!(FileStatus::Added.marker(), 'A');
833 assert_eq!(FileStatus::Deleted.marker(), 'D');
834 assert_eq!(FileStatus::Renamed.marker(), 'R');
835 assert_eq!(FileStatus::Untracked.marker(), '?');
836 }
837
838 #[tokio::test]
839 async fn build_untracked_text_file() {
840 let dir = tempfile::tempdir().unwrap();
841 let file_path = dir.path().join("hello.txt");
842 std::fs::write(&file_path, "line one\nline two\nline three\n").unwrap();
843
844 let diff = build_untracked_file_diff(dir.path(), "hello.txt".to_string()).await;
845 assert_eq!(diff.path, "hello.txt");
846 assert!(diff.old_path.is_none());
847 assert_eq!(diff.status, FileStatus::Untracked);
848 assert!(!diff.binary);
849 assert_eq!(diff.hunks.len(), 1);
850 assert_eq!(diff.additions(), 3);
851 assert_eq!(diff.deletions(), 0);
852
853 let hunk = &diff.hunks[0];
854 assert_eq!(hunk.old_start, 0);
855 assert_eq!(hunk.old_count, 0);
856 assert_eq!(hunk.new_start, 1);
857 assert_eq!(hunk.new_count, 3);
858 assert_eq!(hunk.lines[1].new_line_no, Some(1));
859 assert_eq!(hunk.lines[1].text, "line one");
860 }
861
862 #[tokio::test]
863 async fn build_untracked_binary_file() {
864 let dir = tempfile::tempdir().unwrap();
865 let file_path = dir.path().join("image.bin");
866 std::fs::write(&file_path, b"PNG\x00\x00binary data").unwrap();
867
868 let diff = build_untracked_file_diff(dir.path(), "image.bin".to_string()).await;
869 assert_eq!(diff.status, FileStatus::Untracked);
870 assert!(diff.binary);
871 assert!(diff.hunks.is_empty());
872 }
873
874 #[tokio::test]
875 async fn build_untracked_missing_file() {
876 let dir = tempfile::tempdir().unwrap();
877 let diff = build_untracked_file_diff(dir.path(), "does_not_exist.txt".to_string()).await;
878 assert!(diff.binary);
879 assert!(diff.hunks.is_empty());
880 }
881
882 async fn init_repo() -> tempfile::TempDir {
883 let dir = tempfile::tempdir().unwrap();
884 let path = dir.path();
885 git(path, &["init", "--quiet"]).await.unwrap();
886 git(path, &["config", "user.email", "test@example.com"]).await.unwrap();
887 git(path, &["config", "user.name", "Test"]).await.unwrap();
888 git(path, &["config", "commit.gpgsign", "false"]).await.unwrap();
889 dir
890 }
891
892 async fn write_file(dir: &Path, name: &str, content: &str) {
893 tokio::fs::write(dir.join(name), content).await.unwrap();
894 }
895
896 async fn commit_file(dir: &Path, name: &str, content: &str, message: &str) {
897 write_file(dir, name, content).await;
898 git(dir, &["add", "-A"]).await.unwrap();
899 git(dir, &["commit", "--quiet", "-m", message]).await.unwrap();
900 }
901
902 fn staged_of(doc: &GitDiffDocument, path: &str) -> StageState {
903 doc.files.iter().find(|file| file.path == path).unwrap_or_else(|| panic!("{path} not in diff")).staged
904 }
905
906 #[tokio::test]
907 async fn stage_toggles_status() {
908 let repo = init_repo().await;
909 let dir = repo.path();
910 commit_file(dir, "a.txt", "one\n", "init").await;
911 write_file(dir, "a.txt", "one\ntwo\n").await;
912
913 let doc = load_git_diff(dir, None, DiffScope::Both).await.unwrap();
914 assert_eq!(staged_of(&doc, "a.txt"), StageState::Unstaged);
915
916 stage_files(dir, &["a.txt"]).await.unwrap();
917 let doc = load_git_diff(dir, None, DiffScope::Both).await.unwrap();
918 assert_eq!(staged_of(&doc, "a.txt"), StageState::Staged);
919
920 unstage_files(dir, &["a.txt"]).await.unwrap();
921 let doc = load_git_diff(dir, None, DiffScope::Both).await.unwrap();
922 assert_eq!(staged_of(&doc, "a.txt"), StageState::Unstaged);
923 }
924
925 #[tokio::test]
926 async fn stage_all_marks_all_staged() {
927 let repo = init_repo().await;
928 let dir = repo.path();
929 commit_file(dir, "a.txt", "a1\n", "init").await;
930 commit_file(dir, "b.txt", "b1\n", "add b").await;
931 write_file(dir, "a.txt", "a2\n").await;
932 write_file(dir, "b.txt", "b2\n").await;
933 write_file(dir, "c.txt", "c1\n").await;
934
935 stage_all(dir).await.unwrap();
936 let doc = load_git_diff(dir, None, DiffScope::Both).await.unwrap();
937 assert_eq!(staged_of(&doc, "a.txt"), StageState::Staged);
938 assert_eq!(staged_of(&doc, "b.txt"), StageState::Staged);
939 assert_eq!(staged_of(&doc, "c.txt"), StageState::Staged);
940 }
941
942 #[tokio::test]
943 async fn commit_creates_commit_and_clears_staged() {
944 let repo = init_repo().await;
945 let dir = repo.path();
946 commit_file(dir, "a.txt", "one\n", "init").await;
947 write_file(dir, "a.txt", "one\ntwo\n").await;
948 stage_files(dir, &["a.txt"]).await.unwrap();
949 commit(dir, "second commit").await.unwrap();
950 let log = git(dir, &["log", "--oneline", "-1"]).await.unwrap();
951 assert!(log.contains("second commit"), "log was: {log}");
952
953 let doc = load_git_diff(dir, None, DiffScope::Both).await.unwrap();
954 assert!(doc.files.is_empty(), "expected clean tree, got {} files", doc.files.len());
955 }
956
957 #[tokio::test]
958 async fn discard_reverts_tracked_file() {
959 let repo = init_repo().await;
960 let dir = repo.path();
961 commit_file(dir, "a.txt", "v1\n", "init").await;
962 write_file(dir, "a.txt", "v2\n").await;
963 discard_file(dir, "a.txt", FileStatus::Modified).await.unwrap();
964 let content = tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap();
965 assert_eq!(content, "v1\n");
966 }
967
968 #[tokio::test]
969 async fn discard_removes_untracked_file() {
970 let repo = init_repo().await;
971 let dir = repo.path();
972 commit_file(dir, "a.txt", "a1\n", "init").await;
973 write_file(dir, "new.txt", "scratch\n").await;
974 discard_file(dir, "new.txt", FileStatus::Untracked).await.unwrap();
975 assert!(!dir.join("new.txt").exists());
976 }
977
978 #[tokio::test]
979 async fn commit_with_empty_message_is_rejected() {
980 let repo = init_repo().await;
981 assert!(commit(repo.path(), " ").await.is_err());
982 }
983
984 #[tokio::test]
985 async fn unstage_on_unborn_branch_untracks_file() {
986 let repo = init_repo().await;
987 let dir = repo.path();
988 write_file(dir, "a.txt", "hi\n").await;
989 git(dir, &["add", "a.txt"]).await.unwrap();
990 unstage_files(dir, &["a.txt"]).await.unwrap();
991 let status = git(dir, &["status", "--porcelain"]).await.unwrap();
992 assert!(status.contains("?? a.txt"), "status was: {status}");
993 }
994
995 #[tokio::test]
996 async fn load_git_diff_filters_changes_by_scope() {
997 let repo = init_repo().await;
998 let dir = repo.path();
999 commit_file(dir, "a.txt", "one\n", "init").await;
1000
1001 write_file(dir, "a.txt", "two\n").await;
1002 stage_files(dir, &["a.txt"]).await.unwrap();
1003 write_file(dir, "a.txt", "three\n").await;
1004 write_file(dir, "untracked.txt", "scratch\n").await;
1005
1006 let unstaged = load_git_diff(dir, None, DiffScope::Unstaged).await.unwrap();
1007 assert_eq!(
1008 unstaged.files.iter().map(|file| file.path.as_str()).collect::<Vec<_>>(),
1009 vec!["a.txt", "untracked.txt"]
1010 );
1011 assert!(
1012 unstaged
1013 .files
1014 .iter()
1015 .any(|file| file.path == "a.txt" && file.hunks[0].lines.iter().any(|line| line.text == "three"))
1016 );
1017
1018 let staged = load_git_diff(dir, None, DiffScope::Staged).await.unwrap();
1019 assert_eq!(staged.files.iter().map(|file| file.path.as_str()).collect::<Vec<_>>(), vec!["a.txt"]);
1020 assert!(staged.files[0].hunks[0].lines.iter().any(|line| line.text == "two"));
1021
1022 let both = load_git_diff(dir, None, DiffScope::Both).await.unwrap();
1023 assert_eq!(
1024 both.files.iter().map(|file| file.path.as_str()).collect::<Vec<_>>(),
1025 vec!["a.txt", "untracked.txt"]
1026 );
1027 assert!(
1028 both.files
1029 .iter()
1030 .any(|file| file.path == "a.txt" && file.hunks[0].lines.iter().any(|line| line.text == "three"))
1031 );
1032 }
1033
1034 #[test]
1035 fn parse_porcelain_status_handles_rename() {
1036 let map = parse_porcelain_status("R new.txt\0old.txt\0 M other.txt\0");
1037 assert_eq!(map.get("new.txt"), Some(&StageState::Staged));
1038 assert_eq!(map.get("old.txt"), None);
1039 assert_eq!(map.get("other.txt"), Some(&StageState::Unstaged));
1040 }
1041
1042 #[test]
1043 fn parse_porcelain_status_classifies_states() {
1044 let map = parse_porcelain_status("M staged.txt\0 M unstaged.txt\0MM partial.txt\0?? untracked.txt\0");
1045 assert_eq!(map.get("staged.txt"), Some(&StageState::Staged));
1046 assert_eq!(map.get("unstaged.txt"), Some(&StageState::Unstaged));
1047 assert_eq!(map.get("partial.txt"), Some(&StageState::PartiallyStaged));
1048 assert_eq!(map.get("untracked.txt"), Some(&StageState::Unstaged));
1049 }
1050}