Skip to main content

strop_git/
lib.rs

1//! strop-git: the working surface (0001 pillar 3.1). libgit2 for the hot
2//! paths — no process spawn per keystroke. HEAD vs the *live buffer*
3//! (not the disk file), so gutter signs track unsaved edits.
4
5pub mod memory;
6
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum HunkKind {
11    Add,
12    Change,
13    Delete,
14}
15
16/// Where a diff line comes from — addition/deletion carry which side's
17/// line number applies (0010 §1: typed origins, never `+`-sniffing).
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum LineOrigin {
20    Context,
21    Addition,
22    Deletion,
23}
24
25/// One line of a hunk: content without prefix, plus the 1-based line
26/// number on each side that has one (absent side: `None`, never `0`).
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct DiffLine {
29    pub origin: LineOrigin,
30    pub old_lineno: Option<usize>,
31    pub new_lineno: Option<usize>,
32    pub text: String,
33}
34
35/// One diff hunk between two versions of a file, in 1-based lines.
36#[derive(Debug, Clone)]
37pub struct Hunk {
38    pub kind: HunkKind,
39    /// First affected line in the new version (1-based). For pure
40    /// deletions this is the line *after* which content vanished.
41    pub new_start: usize,
42    pub new_count: usize,
43    pub old_start: usize,
44    pub old_count: usize,
45    pub lines: Vec<DiffLine>,
46}
47
48/// One file's diff at a commit (vs its parent): the delta view's data.
49#[derive(Debug, Clone)]
50pub struct FileDiff {
51    pub path: PathBuf,
52    pub hunks: Vec<Hunk>,
53    pub added: usize,
54    pub deleted: usize,
55}
56
57/// One changed line, for gutter signs. Hunk headers include context
58/// lines, so signs track the +/- lines, not the header range.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Sign {
61    /// Buffer line was added or changed.
62    AddOrChange,
63    /// Buffer line sits right below a deletion (the line number may be
64    /// one past the buffer end for an EOF deletion — clamp on render).
65    DeleteAfter,
66}
67
68impl Hunk {
69    /// Signs this hunk produces, derived from its line origins.
70    pub fn signs(&self) -> Vec<(usize, Sign)> {
71        let mut out = Vec::new();
72        let mut nl = self.new_start;
73        for line in &self.lines {
74            match line.origin {
75                LineOrigin::Addition => {
76                    out.push((nl, Sign::AddOrChange));
77                    nl += 1;
78                }
79                LineOrigin::Deletion => out.push((nl, Sign::DeleteAfter)),
80                LineOrigin::Context => nl += 1,
81            }
82        }
83        out
84    }
85
86    /// The actual changed region (from add/del lines, not the header,
87    /// which includes context): new-side `new_first`/`new_count`
88    /// (1-based) and old-side `old_first`/`old_count`. For pure
89    /// deletions `new_first` is the new line *following* the gap.
90    pub fn changed_region(&self) -> (usize, usize, usize, usize) {
91        let mut nl = self.new_start;
92        let mut ol = self.old_start;
93        let mut new_lines = Vec::new();
94        let mut old_lines = Vec::new();
95        for line in &self.lines {
96            match line.origin {
97                LineOrigin::Addition => {
98                    new_lines.push(nl);
99                    nl += 1;
100                }
101                LineOrigin::Deletion => {
102                    old_lines.push(ol);
103                    ol += 1;
104                }
105                LineOrigin::Context => {
106                    nl += 1;
107                    ol += 1;
108                }
109            }
110        }
111        let new_first = new_lines.first().copied().unwrap_or(nl);
112        let old_first = old_lines.first().copied().unwrap_or(ol);
113        (new_first, new_lines.len(), old_first, old_lines.len())
114    }
115
116    /// Buffer lines covered (signs render on these); `total_lines`
117    /// clamps an EOF deletion onto the last line.
118    pub fn covers(&self, line_1based: usize, total_lines: usize) -> bool {
119        self.signs().iter().any(|&(l, kind)| match kind {
120            Sign::AddOrChange => l == line_1based,
121            Sign::DeleteAfter => l.min(total_lines) == line_1based,
122        })
123    }
124
125    /// The hunk as a unified-diff patch fragment (`git apply` input).
126    /// The prefixed form is derived here — the one place it exists.
127    pub fn to_patch(&self, rel: &Path) -> String {
128        let mut patch = format!("--- a/{}\n+++ b/{}\n", rel.display(), rel.display());
129        patch.push_str(&format!(
130            "@@ -{},{} +{},{} @@\n",
131            self.old_start, self.old_count, self.new_start, self.new_count
132        ));
133        for line in &self.lines {
134            let prefix = match line.origin {
135                LineOrigin::Addition => '+',
136                LineOrigin::Deletion => '-',
137                LineOrigin::Context => ' ',
138            };
139            patch.push(prefix);
140            patch.push_str(&line.text);
141            patch.push('\n');
142        }
143        patch
144    }
145
146    /// The `@@ -a,b +c,d @@` header row as the diff surface shows it.
147    pub fn header(&self) -> String {
148        format!(
149            "@@ -{},{} +{},{} @@",
150            self.old_start, self.old_count, self.new_start, self.new_count
151        )
152    }
153
154    /// Assemble a hunk from its header numbers and typed lines; the
155    /// kind comes from the actual origins — header counts include
156    /// context lines, which would mislabel small-file hunks.
157    pub fn build(
158        old_start: usize,
159        old_count: usize,
160        new_start: usize,
161        new_count: usize,
162        lines: Vec<DiffLine>,
163    ) -> Self {
164        let has_add = lines.iter().any(|l| l.origin == LineOrigin::Addition);
165        let has_del = lines.iter().any(|l| l.origin == LineOrigin::Deletion);
166        let kind = match (has_add, has_del) {
167            (true, false) => HunkKind::Add,
168            (false, true) => HunkKind::Delete,
169            _ => HunkKind::Change,
170        };
171        Hunk {
172            kind,
173            new_start,
174            new_count,
175            old_start,
176            old_count,
177            lines,
178        }
179    }
180}
181
182pub struct Repo {
183    inner: git2::Repository,
184    workdir: PathBuf,
185}
186
187impl Repo {
188    /// Discover the repository containing `path` (buffer path or cwd).
189    pub fn discover(from: &Path) -> Option<Self> {
190        let inner = git2::Repository::discover(from).ok()?;
191        let workdir = inner.workdir()?.to_path_buf();
192        Some(Self { inner, workdir })
193    }
194
195    pub fn workdir(&self) -> &Path {
196        &self.workdir
197    }
198
199    /// Remotes as (name, url) pairs — libgit2 config, no spawn.
200    pub fn remotes(&self) -> Vec<(String, String)> {
201        let Ok(remotes) = self.inner.remotes() else {
202            return vec![];
203        };
204        remotes
205            .iter()
206            .flatten()
207            .filter_map(|name| {
208                self.inner
209                    .find_remote(name)
210                    .ok()
211                    .and_then(|r| r.url().map(|u| (name.to_string(), u.to_string())))
212            })
213            .collect()
214    }
215
216    /// HEAD's full SHA (permalink base — branch always resolves to SHA).
217    pub fn head_sha(&self) -> Option<String> {
218        Some(
219            self.inner
220                .head()
221                .ok()?
222                .peel_to_commit()
223                .ok()?
224                .id()
225                .to_string(),
226        )
227    }
228
229    /// Current branch (short name; detached HEAD gives the sha prefix).
230    pub fn head_branch(&self) -> Option<String> {
231        self.inner
232            .head()
233            .ok()
234            .and_then(|h| h.shorthand().map(String::from))
235    }
236
237    /// Repo-relative path for a buffer path (diff keys are relative).
238    fn rel_path(&self, path: &Path) -> Option<PathBuf> {
239        let abs = if path.is_absolute() {
240            path.to_path_buf()
241        } else {
242            self.workdir.join(path)
243        };
244        abs.strip_prefix(&self.workdir)
245            .ok()
246            .map(|p| p.to_path_buf())
247    }
248
249    /// HEAD's content for `path`, if tracked.
250    pub fn head_content(&self, path: &Path) -> Option<String> {
251        let rel = self.rel_path(path)?;
252        let head = self.inner.head().ok()?.peel_to_tree().ok()?;
253        let entry = head.get_path(&rel).ok()?;
254        let blob = self.inner.find_blob(entry.id()).ok()?;
255        String::from_utf8(blob.content().to_vec()).ok()
256    }
257
258    /// The index's content for `path` (the staged version), if any.
259    pub fn index_content(&self, path: &Path) -> Option<String> {
260        let rel = self.rel_path(path)?;
261        // the shell write path (git apply --cached) owns the on-disk
262        // index — reload before reading or we serve a cached snapshot
263        let mut index = self.inner.index().ok()?;
264        index.read(true).ok()?;
265        let entry = index.get_path(&rel, 0)?;
266        let blob = self.inner.find_blob(entry.id).ok()?;
267        String::from_utf8(blob.content().to_vec()).ok()
268    }
269
270    /// Hunks between HEAD and the index — the STAGED set (0014 wave 4:
271    /// the four states are HEAD → index → worktree → live document, and
272    /// every command names its edge).
273    pub fn staged_hunks(&self, path: &Path) -> Vec<Hunk> {
274        let Some(rel) = self.rel_path(path) else {
275            return vec![];
276        };
277        let (Some(head), Some(index)) = (self.head_content(path), self.index_content(path)) else {
278            return vec![];
279        };
280        self.diff_strings(&head, &index, &rel)
281    }
282
283    /// Hunks between the index and `content` — the UNSTAGED set (what
284    /// the gutter shows while you edit). When nothing is staged this
285    /// equals HEAD↔content, matching pre-0.5 behavior.
286    pub fn unstaged_hunks(&self, path: &Path, content: &str) -> Vec<Hunk> {
287        let Some(rel) = self.rel_path(path) else {
288            return vec![];
289        };
290        let base = self.index_content(path).or_else(|| self.head_content(path));
291        match base {
292            None => self.hunks(path, content), // untracked: all-add
293            Some(base) => self.diff_strings(&base, content, &rel),
294        }
295    }
296
297    /// Unstage one hunk: the index→HEAD edge, `git apply --cached -R`.
298    pub fn unstage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
299        let patch = hunk.to_patch(rel);
300        let mut child = std::process::Command::new("git")
301            .args([
302                "-C",
303                &self.workdir.display().to_string(),
304                "apply",
305                "--cached",
306                "--reverse",
307                "--unidiff-zero",
308            ])
309            .stdin(std::process::Stdio::piped())
310            .stdout(std::process::Stdio::null())
311            .stderr(std::process::Stdio::piped())
312            .spawn()
313            .map_err(|e| format!("spawn git: {e}"))?;
314        use std::io::Write;
315        child
316            .stdin
317            .as_mut()
318            .expect("piped")
319            .write_all(patch.as_bytes())
320            .map_err(|e| e.to_string())?;
321        let out = child.wait_with_output().map_err(|e| e.to_string())?;
322        if out.status.success() {
323            Ok(())
324        } else {
325            Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
326        }
327    }
328
329    /// Hunks between HEAD and `content` for `path`. Untracked files
330    /// report a single all-Add hunk.
331    pub fn hunks(&self, path: &Path, content: &str) -> Vec<Hunk> {
332        let Some(rel) = self.rel_path(path) else {
333            return vec![];
334        };
335        let old = self.head_content(path);
336        match old {
337            None => {
338                let count = content.lines().count();
339                if count == 0 {
340                    return vec![];
341                }
342                vec![Hunk {
343                    kind: HunkKind::Add,
344                    new_start: 1,
345                    new_count: count,
346                    old_start: 0,
347                    old_count: 0,
348                    lines: content
349                        .lines()
350                        .enumerate()
351                        .map(|(i, l)| DiffLine {
352                            origin: LineOrigin::Addition,
353                            old_lineno: None,
354                            new_lineno: Some(i + 1),
355                            text: l.to_string(),
356                        })
357                        .collect(),
358                }]
359            }
360            Some(old) => self.diff_strings(&old, content, &rel),
361        }
362    }
363
364    fn diff_strings(&self, old: &str, new: &str, rel: &Path) -> Vec<Hunk> {
365        let mut opts = git2::DiffOptions::new();
366        opts.context_lines(3);
367        let Ok(patch) = git2::Patch::from_buffers(
368            old.as_bytes(),
369            Some(rel),
370            new.as_bytes(),
371            Some(rel),
372            Some(&mut opts),
373        ) else {
374            return vec![];
375        };
376        hunks_from_patch(&patch)
377    }
378
379    /// One file's diff at `sha` vs its first parent, as structured
380    /// hunks. The delta view's data (0010 §1) — libgit2, no shell-out,
381    /// no re-parsing our own text.
382    pub fn commit_file_diff(&self, sha: &str, path: &Path) -> Result<FileDiff, String> {
383        let commit = self
384            .inner
385            .find_commit(git2::Oid::from_str(sha).map_err(|e| e.to_string())?)
386            .map_err(|e| e.to_string())?;
387        let new_tree = commit.tree().map_err(|e| e.to_string())?;
388        let old_tree = match commit.parent(0) {
389            Ok(parent) => Some(parent.tree().map_err(|e| e.to_string())?),
390            // root commit: diff against no tree at all
391            Err(_) => None,
392        };
393        let mut opts = git2::DiffOptions::new();
394        opts.context_lines(3)
395            .pathspec(path)
396            .include_unmodified(false);
397        let diff = self
398            .inner
399            .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))
400            .map_err(|e| e.to_string())?;
401        let mut file = None;
402        for (d, _delta) in diff.deltas().enumerate() {
403            let Some(patch) = git2::Patch::from_diff(&diff, d).map_err(|e| e.to_string())? else {
404                continue; // binary or unrenderable: nothing to show
405            };
406            let hunks = hunks_from_patch(&patch);
407            let added = hunks
408                .iter()
409                .flat_map(|h| &h.lines)
410                .filter(|l| l.origin == LineOrigin::Addition)
411                .count();
412            let deleted = hunks
413                .iter()
414                .flat_map(|h| &h.lines)
415                .filter(|l| l.origin == LineOrigin::Deletion)
416                .count();
417            file = Some(FileDiff {
418                path: path.to_path_buf(),
419                hunks,
420                added,
421                deleted,
422            });
423        }
424        file.ok_or_else(|| "no diff for path".to_string())
425    }
426
427    /// Stage one hunk. Prototype path: synthesize a single-hunk patch and
428    /// `git apply --cached` it (shell git is the write path per 0001 §3;
429    /// libgit2 owns the read hot paths). `rel` is repo-relative.
430    pub fn stage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
431        let patch = hunk.to_patch(rel);
432        let mut child = std::process::Command::new("git")
433            .args([
434                "-C",
435                &self.workdir.display().to_string(),
436                "apply",
437                "--cached",
438                "--unidiff-zero",
439            ])
440            .stdin(std::process::Stdio::piped())
441            .stdout(std::process::Stdio::null())
442            .stderr(std::process::Stdio::piped())
443            .spawn()
444            .map_err(|e| format!("spawn git: {e}"))?;
445        use std::io::Write;
446        child
447            .stdin
448            .as_mut()
449            .expect("piped")
450            .write_all(patch.as_bytes())
451            .map_err(|e| e.to_string())?;
452        let out = child.wait_with_output().map_err(|e| e.to_string())?;
453        if out.status.success() {
454            Ok(())
455        } else {
456            Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
457        }
458    }
459}
460
461/// Typed hunks from a libgit2 patch — the one place line origins and
462/// both sides' 1-based numbers are read off the wire.
463fn hunks_from_patch(patch: &git2::Patch) -> Vec<Hunk> {
464    let mut hunks = Vec::new();
465    for h in 0..patch.num_hunks() {
466        let Ok((header, line_count)) = patch.hunk(h) else {
467            continue;
468        };
469        let mut lines = Vec::with_capacity(line_count);
470        for l in 0..line_count {
471            let Ok(line) = patch.line_in_hunk(h, l) else {
472                continue;
473            };
474            let origin = match line.origin() {
475                '+' => LineOrigin::Addition,
476                '-' => LineOrigin::Deletion,
477                _ => LineOrigin::Context,
478            };
479            // libgit2 numbers are 1-based; the absent side is None.
480            let old_lineno = line.old_lineno().map(|n| n as usize);
481            let new_lineno = line.new_lineno().map(|n| n as usize);
482            let text = String::from_utf8_lossy(line.content())
483                .trim_end_matches('\n')
484                .to_string();
485            lines.push(DiffLine {
486                origin,
487                old_lineno,
488                new_lineno,
489                text,
490            });
491        }
492        hunks.push(Hunk::build(
493            header.old_start() as usize,
494            header.old_lines() as usize,
495            header.new_start() as usize,
496            header.new_lines() as usize,
497            lines,
498        ));
499    }
500    hunks
501}
502
503/// A revisioned source location (0014 wave 4): permalinks, jumps into
504/// history, and blame's parent-hop all speak this — no more "permalink
505/// from a historical view links HEAD's file".
506#[derive(Debug, Clone, PartialEq, Eq)]
507pub struct SourceLocation {
508    pub revision: GitRevision,
509    /// Repo-relative path.
510    pub path: PathBuf,
511    /// 1-based line range, when the location is a selection.
512    pub lines: Option<(usize, usize)>,
513}
514
515#[derive(Debug, Clone, PartialEq, Eq)]
516pub enum GitRevision {
517    /// The checked-out branch head.
518    Head,
519    /// A specific commit (surfaces carry this).
520    Commit(String),
521}
522
523impl SourceLocation {
524    /// The URL slug: a pinned commit sha or the branch's name.
525    pub fn revision_slug(&self) -> String {
526        match &self.revision {
527            GitRevision::Head => "HEAD".into(),
528            GitRevision::Commit(sha) => sha.clone(),
529        }
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use std::process::Command;
537
538    pub(crate) fn git(root: &std::path::Path, args: &[&str]) {
539        Command::new("git")
540            .args(args)
541            .current_dir(root)
542            .output()
543            .unwrap();
544    }
545
546    pub(crate) fn fixture() -> (tempfile::TempDir, Repo, PathBuf) {
547        let dir = tempfile::tempdir().unwrap();
548        let root = dir.path();
549        git(root, &["init", "-q"]);
550        git(root, &["config", "user.email", "t@t.t"]);
551        git(root, &["config", "user.name", "t"]);
552        std::fs::write(root.join("f.rs"), "fn a() {}\nfn b() {}\nfn c() {}\n").unwrap();
553        git(root, &["add", "."]);
554        git(root, &["commit", "-qm", "init"]);
555        let repo = Repo::discover(root).unwrap();
556        let file = root.join("f.rs");
557        (dir, repo, file)
558    }
559
560    #[test]
561    fn clean_buffer_has_no_hunks() {
562        let (_d, repo, path) = fixture();
563        let content = repo.head_content(&path).unwrap();
564        assert!(repo.hunks(&path, &content).is_empty());
565    }
566
567    #[test]
568    fn change_and_add_and_delete() {
569        let (_d, repo, path) = fixture();
570        let edited = "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n";
571        let hunks = repo.hunks(&path, edited);
572        assert_eq!(hunks.len(), 1);
573        assert_eq!(hunks[0].kind, HunkKind::Change);
574        assert!(hunks[0].covers(2, 4));
575        assert!(hunks[0].covers(4, 4));
576        assert!(!hunks[0].covers(1, 4));
577        assert!(hunks[0]
578            .lines
579            .iter()
580            .any(|l| l.origin == LineOrigin::Addition && l.text.starts_with("fn d")));
581    }
582
583    /// The typed structure carries both sides' 1-based numbers: the
584    /// renderer never guesses them from text (0010 §1).
585    #[test]
586    fn line_numbers_track_both_sides() {
587        let (_d, repo, path) = fixture();
588        let edited = "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n";
589        let hunks = repo.hunks(&path, edited);
590        assert_eq!(hunks.len(), 1);
591        let h = &hunks[0];
592        let ctx = h
593            .lines
594            .iter()
595            .find(|l| l.origin == LineOrigin::Context)
596            .unwrap();
597        assert_eq!(
598            (ctx.old_lineno, ctx.new_lineno),
599            (Some(1), Some(1)),
600            "context lines carry both numbers, 1-based"
601        );
602        let add = h
603            .lines
604            .iter()
605            .find(|l| l.origin == LineOrigin::Addition && l.text.starts_with("fn d"))
606            .unwrap();
607        assert_eq!((add.old_lineno, add.new_lineno), (None, Some(4)));
608        let del = h
609            .lines
610            .iter()
611            .find(|l| l.origin == LineOrigin::Deletion)
612            .unwrap();
613        assert_eq!((del.old_lineno, del.new_lineno), (Some(2), None));
614    }
615
616    #[test]
617    fn pure_delete_marks_following_line() {
618        let (_d, repo, path) = fixture();
619        let edited = "fn a() {}\nfn c() {}\n";
620        let hunks = repo.hunks(&path, edited);
621        assert_eq!(hunks.len(), 1);
622        assert_eq!(hunks[0].kind, HunkKind::Delete);
623        assert!(hunks[0].covers(2, 4)); // sign on the line after the gap
624    }
625
626    #[test]
627    fn stage_hunk_applies_to_index() {
628        let (_d, repo, path) = fixture();
629        let edited = "fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n";
630        let hunks = repo.hunks(&path, edited);
631        assert_eq!(hunks.len(), 1);
632        assert_eq!(hunks[0].kind, HunkKind::Add);
633        let root = repo.workdir.clone();
634        repo.stage_hunk(Path::new("f.rs"), &hunks[0]).unwrap();
635        let out = Command::new("git")
636            .args([
637                "-C",
638                &root.display().to_string(),
639                "diff",
640                "--cached",
641                "--stat",
642            ])
643            .output()
644            .unwrap();
645        let stat = String::from_utf8_lossy(&out.stdout);
646        assert!(stat.contains("f.rs"), "{stat}");
647    }
648
649    /// `to_patch` is real `git apply` input: the prefixed form exists
650    /// only here, derived from typed origins.
651    #[test]
652    fn to_patch_is_applyable() {
653        let (_d, repo, path) = fixture();
654        let edited = "fn a() {}\nfn b2() {}\nfn c() {}\n";
655        let hunks = repo.hunks(&path, edited);
656        assert_eq!(hunks.len(), 1);
657        let patch = hunks[0].to_patch(Path::new("f.rs"));
658        assert!(
659            patch.starts_with("--- a/f.rs\n+++ b/f.rs\n@@ -1,3 +1,3 @@\n"),
660            "{patch}"
661        );
662        assert!(patch.contains("-fn b() {}\n+fn b2() {}\n"), "{patch}");
663        assert!(patch.ends_with(" fn c() {}\n"), "{patch}");
664    }
665
666    /// The commit delta view's data: structured hunks at a SHA, via
667    /// libgit2 — the `git show` shell-out replacement.
668    #[test]
669    fn commit_file_diff_is_structured() {
670        let (_d, repo, path) = fixture();
671        let root = repo.workdir.clone();
672        std::fs::write(root.join("f.rs"), "fn a() {}\nfn b2() {}\nfn c() {}\n").unwrap();
673        git(&root, &["add", "."]);
674        git(&root, &["commit", "-qm", "change b"]);
675        let sha = String::from_utf8_lossy(
676            &Command::new("git")
677                .args(["-C", &root.display().to_string(), "rev-parse", "HEAD"])
678                .output()
679                .unwrap()
680                .stdout,
681        )
682        .trim()
683        .to_string();
684        let diff = repo.commit_file_diff(&sha, Path::new("f.rs")).unwrap();
685        assert_eq!(diff.added, 1);
686        assert_eq!(diff.deleted, 1);
687        assert_eq!(diff.hunks.len(), 1);
688        assert_eq!(diff.hunks[0].kind, HunkKind::Change);
689        assert!(diff.hunks[0].lines.iter().any(|l| l.text == "fn b2() {}"));
690        let _ = path;
691    }
692
693    /// Root commits diff against the empty tree: the init commit shows
694    /// as one all-addition hunk, not an error.
695    #[test]
696    fn commit_file_diff_root_commit() {
697        let (_d, repo, _path) = fixture();
698        let root = repo.workdir.clone();
699        let sha = String::from_utf8_lossy(
700            &Command::new("git")
701                .args(["-C", &root.display().to_string(), "rev-parse", "HEAD"])
702                .output()
703                .unwrap()
704                .stdout,
705        )
706        .trim()
707        .to_string();
708        let diff = repo.commit_file_diff(&sha, Path::new("f.rs")).unwrap();
709        assert_eq!(diff.added, 3);
710        assert_eq!(diff.deleted, 0);
711        assert!(diff
712            .hunks
713            .iter()
714            .all(|h| h.lines.iter().all(|l| l.old_lineno.is_none())));
715    }
716}
717
718#[cfg(test)]
719mod head_tests {
720    use super::tests::fixture;
721    use super::*;
722    use std::process::Command;
723
724    #[test]
725    fn head_content_probe() {
726        let dir = tempfile::tempdir().unwrap();
727        let root = dir.path();
728        let git = |args: &[&str]| {
729            Command::new("git")
730                .args(args)
731                .current_dir(root)
732                .output()
733                .unwrap();
734        };
735        git(&["init", "-q"]);
736        git(&["config", "user.email", "t@t.t"]);
737        git(&["config", "user.name", "t"]);
738        std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
739        git(&["add", "."]);
740        git(&["commit", "-qm", "init"]);
741        let repo = Repo::discover(root).unwrap();
742        eprintln!("workdir: {:?}", repo.workdir());
743        let abs = root.join("f.rs");
744        eprintln!("abs: {:?} rel: {:?}", abs, repo.rel_path(&abs));
745        eprintln!("head: {:?}", repo.head_content(&abs));
746        assert!(repo.head_content(&abs).is_some());
747    }
748
749    /// 0014 wave 4: the four states are real and separately diffable.
750    #[test]
751    fn four_state_edges() {
752        let (_d, repo, path) = fixture();
753        // worktree edit, stage it, then edit again (live-only)
754        std::fs::write(&path, "fn a() {}\nfn STAGED() {}\nfn c() {}\n").unwrap();
755        let staged = repo.unstaged_hunks(&path, &std::fs::read_to_string(&path).unwrap());
756        assert_eq!(staged.len(), 1);
757        let hunk = staged.into_iter().next().unwrap();
758        repo.stage_hunk(Path::new("f.rs"), &hunk).unwrap();
759        // index now differs from HEAD
760        let idx = repo.index_content(&path).unwrap();
761        assert!(idx.contains("STAGED"));
762        let head = repo.head_content(&path).unwrap();
763        assert!(!head.contains("STAGED"));
764        // staged set: HEAD↔index has the hunk; unstaged (index↔same content) is empty
765        assert_eq!(repo.staged_hunks(&path).len(), 1);
766        let wt = std::fs::read_to_string(&path).unwrap();
767        assert!(repo.unstaged_hunks(&path, &wt).is_empty());
768        // a further live-only edit shows in the unstaged set only
769        let live = "fn a() {}\nfn STAGED() {}\nfn c() {}\nfn live()\n";
770        let unstaged = repo.unstaged_hunks(&path, live);
771        assert_eq!(unstaged.len(), 1);
772        assert!(unstaged[0]
773            .lines
774            .iter()
775            .any(|l| l.text.starts_with("fn live")));
776        assert_eq!(repo.staged_hunks(&path).len(), 1, "staged untouched");
777        // unstage reverses the edge
778        let staged = repo.staged_hunks(&path);
779        repo.unstage_hunk(Path::new("f.rs"), &staged[0]).unwrap();
780        assert!(repo.staged_hunks(&path).is_empty());
781        assert!(!repo.index_content(&path).unwrap().contains("STAGED"));
782    }
783}