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