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    /// The line's bytes WITHOUT its terminator (0018: byte-precise —
33    /// a `\r` stays; non-UTF-8 stays bytes).
34    pub text: Vec<u8>,
35    /// Whether the source line ended in a newline — the missing-final-
36    /// newline marker is data, not a patch-text nuance (0018).
37    pub has_newline: bool,
38}
39
40impl DiffLine {
41    /// Display form (lossy at the render edge only).
42    pub fn text_str(&self) -> std::borrow::Cow<'_, str> {
43        String::from_utf8_lossy(&self.text)
44    }
45    /// The line's bytes WITH its terminator, exactly as stored.
46    pub fn bytes_with_terminator(&self) -> Vec<u8> {
47        let mut b = self.text.clone();
48        if self.has_newline {
49            b.push(b'\n');
50        }
51        b
52    }
53}
54
55/// One diff hunk between two versions of a file, in 1-based lines.
56#[derive(Debug, Clone)]
57pub struct Hunk {
58    pub kind: HunkKind,
59    /// First affected line in the new version (1-based). For pure
60    /// deletions this is the line *after* which content vanished.
61    pub new_start: usize,
62    pub new_count: usize,
63    pub old_start: usize,
64    pub old_count: usize,
65    pub lines: Vec<DiffLine>,
66}
67
68/// One file's diff at a commit (vs its parent): the delta view's data.
69#[derive(Debug, Clone)]
70pub struct FileDiff {
71    pub path: PathBuf,
72    pub hunks: Vec<Hunk>,
73    pub added: usize,
74    pub deleted: usize,
75}
76
77/// One changed line, for gutter signs. Hunk headers include context
78/// lines, so signs track the +/- lines, not the header range.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum Sign {
81    /// Buffer line was added or changed.
82    AddOrChange,
83    /// Buffer line sits right below a deletion (the line number may be
84    /// one past the buffer end for an EOF deletion — clamp on render).
85    DeleteAfter,
86}
87
88impl Hunk {
89    /// Signs this hunk produces, derived from its line origins.
90    pub fn signs(&self) -> Vec<(usize, Sign)> {
91        let mut out = Vec::new();
92        let mut nl = self.new_start;
93        for line in &self.lines {
94            match line.origin {
95                LineOrigin::Addition => {
96                    out.push((nl, Sign::AddOrChange));
97                    nl += 1;
98                }
99                LineOrigin::Deletion => out.push((nl, Sign::DeleteAfter)),
100                LineOrigin::Context => nl += 1,
101            }
102        }
103        out
104    }
105
106    /// The actual changed region (from add/del lines, not the header,
107    /// which includes context): new-side `new_first`/`new_count`
108    /// (1-based) and old-side `old_first`/`old_count`. For pure
109    /// deletions `new_first` is the new line *following* the gap.
110    pub fn changed_region(&self) -> (usize, usize, usize, usize) {
111        let mut nl = self.new_start;
112        let mut ol = self.old_start;
113        let mut new_lines = Vec::new();
114        let mut old_lines = Vec::new();
115        for line in &self.lines {
116            match line.origin {
117                LineOrigin::Addition => {
118                    new_lines.push(nl);
119                    nl += 1;
120                }
121                LineOrigin::Deletion => {
122                    old_lines.push(ol);
123                    ol += 1;
124                }
125                LineOrigin::Context => {
126                    nl += 1;
127                    ol += 1;
128                }
129            }
130        }
131        let new_first = new_lines.first().copied().unwrap_or(nl);
132        let old_first = old_lines.first().copied().unwrap_or(ol);
133        (new_first, new_lines.len(), old_first, old_lines.len())
134    }
135
136    /// Buffer lines covered (signs render on these); `total_lines`
137    /// clamps an EOF deletion onto the last line.
138    pub fn covers(&self, line_1based: usize, total_lines: usize) -> bool {
139        self.signs().iter().any(|&(l, kind)| match kind {
140            Sign::AddOrChange => l == line_1based,
141            Sign::DeleteAfter => l.min(total_lines) == line_1based,
142        })
143    }
144
145    /// The `@@ -a,b +c,d @@` header row as the diff surface shows it.
146    pub fn header(&self) -> String {
147        format!(
148            "@@ -{},{} +{},{} @@",
149            self.old_start, self.old_count, self.new_start, self.new_count
150        )
151    }
152
153    /// Assemble a hunk from its header numbers and typed lines; the
154    /// kind comes from the actual origins — header counts include
155    /// context lines, which would mislabel small-file hunks.
156    pub fn build(
157        old_start: usize,
158        old_count: usize,
159        new_start: usize,
160        new_count: usize,
161        lines: Vec<DiffLine>,
162    ) -> Self {
163        let has_add = lines.iter().any(|l| l.origin == LineOrigin::Addition);
164        let has_del = lines.iter().any(|l| l.origin == LineOrigin::Deletion);
165        let kind = match (has_add, has_del) {
166            (true, false) => HunkKind::Add,
167            (false, true) => HunkKind::Delete,
168            _ => HunkKind::Change,
169        };
170        Hunk {
171            kind,
172            new_start,
173            new_count,
174            old_start,
175            old_count,
176            lines,
177        }
178    }
179}
180
181pub struct Repo {
182    inner: git2::Repository,
183    workdir: PathBuf,
184}
185
186impl Repo {
187    /// Discover the repository containing `path` (buffer path or cwd).
188    pub fn discover(from: &Path) -> Option<Self> {
189        let inner = git2::Repository::discover(from).ok()?;
190        let workdir = inner.workdir()?.to_path_buf();
191        Some(Self { inner, workdir })
192    }
193
194    pub fn workdir(&self) -> &Path {
195        &self.workdir
196    }
197
198    /// Remotes as (name, url) pairs — libgit2 config, no spawn.
199    pub fn remotes(&self) -> Vec<(String, String)> {
200        let Ok(remotes) = self.inner.remotes() else {
201            return vec![];
202        };
203        remotes
204            .iter()
205            .flatten()
206            .filter_map(|name| {
207                self.inner
208                    .find_remote(name)
209                    .ok()
210                    .and_then(|r| r.url().map(|u| (name.to_string(), u.to_string())))
211            })
212            .collect()
213    }
214
215    /// HEAD's full SHA (permalink base — branch always resolves to SHA).
216    pub fn head_sha(&self) -> Option<String> {
217        Some(
218            self.inner
219                .head()
220                .ok()?
221                .peel_to_commit()
222                .ok()?
223                .id()
224                .to_string(),
225        )
226    }
227
228    /// Current branch (short name; detached HEAD gives the sha prefix).
229    pub fn head_branch(&self) -> Option<String> {
230        self.inner
231            .head()
232            .ok()
233            .and_then(|h| h.shorthand().map(String::from))
234    }
235
236    /// Repo-relative path for a buffer path (diff keys are relative).
237    fn rel_path(&self, path: &Path) -> Option<PathBuf> {
238        let abs = if path.is_absolute() {
239            path.to_path_buf()
240        } else {
241            self.workdir.join(path)
242        };
243        abs.strip_prefix(&self.workdir)
244            .ok()
245            .map(|p| p.to_path_buf())
246    }
247
248    /// HEAD's content for `path`, if tracked.
249    /// HEAD's blob bytes for a repo-relative path (typed, not lossy).
250    pub fn head_bytes(&self, rel: &Path) -> Option<Vec<u8>> {
251        let commit = self.inner.head().ok()?.peel_to_commit().ok()?;
252        let tree = commit.tree().ok()?;
253        let entry = tree.get_path(rel).ok()?;
254        let blob = self.inner.find_blob(entry.id()).ok()?;
255        Some(blob.content().to_vec())
256    }
257
258    /// A commit's blob bytes for a repo-relative path.
259    pub fn commit_bytes(&self, sha: &str, rel: &Path) -> Option<Vec<u8>> {
260        let oid = self.inner.revparse_single(sha).ok()?.id();
261        let commit = self.inner.find_commit(oid).ok()?;
262        let tree = commit.tree().ok()?;
263        let entry = tree.get_path(rel).ok()?;
264        let blob = self.inner.find_blob(entry.id()).ok()?;
265        Some(blob.content().to_vec())
266    }
267
268    /// The index's blob bytes for a repo-relative path (reloads — never
269    /// a stale snapshot).
270    pub fn index_bytes(&self, rel: &Path) -> Option<Vec<u8>> {
271        let mut index = self.inner.index().ok()?;
272        index.read(true).ok()?;
273        let entry = index.get_path(rel, 0)?;
274        let blob = self.inner.find_blob(entry.id).ok()?;
275        Some(blob.content().to_vec())
276    }
277
278    /// Merge-base oid of two revisions.
279    pub fn merge_base(&self, a: &str, b: &str) -> Option<String> {
280        let a = self.inner.revparse_single(a).ok()?.id();
281        let b = self.inner.revparse_single(b).ok()?.id();
282        let base = self.inner.merge_base(a, b).ok()?;
283        Some(base.to_string())
284    }
285
286    pub fn head_content(&self, path: &Path) -> Option<String> {
287        let rel = self.rel_path(path)?;
288        let head = self.inner.head().ok()?.peel_to_tree().ok()?;
289        let entry = head.get_path(&rel).ok()?;
290        let blob = self.inner.find_blob(entry.id()).ok()?;
291        String::from_utf8(blob.content().to_vec()).ok()
292    }
293
294    /// The index's content for `path` (the staged version), if any.
295    pub fn index_content(&self, path: &Path) -> Option<String> {
296        let rel = self.rel_path(path)?;
297        // the shell write path (git apply --cached) owns the on-disk
298        // index — reload before reading or we serve a cached snapshot
299        let mut index = self.inner.index().ok()?;
300        index.read(true).ok()?;
301        let entry = index.get_path(&rel, 0)?;
302        let blob = self.inner.find_blob(entry.id).ok()?;
303        String::from_utf8(blob.content().to_vec()).ok()
304    }
305
306    /// Hunks between HEAD and the index — the STAGED set (0014 wave 4:
307    /// the four states are HEAD → index → worktree → live document, and
308    /// every command names its edge).
309    pub fn staged_hunks(&self, path: &Path) -> Vec<Hunk> {
310        let Some(rel) = self.rel_path(path) else {
311            return vec![];
312        };
313        let (Some(head), Some(index)) = (self.head_content(path), self.index_content(path)) else {
314            return vec![];
315        };
316        self.diff_strings(&head, &index, &rel)
317    }
318
319    /// Hunks between the index and `content` — the UNSTAGED set (what
320    /// the gutter shows while you edit). When nothing is staged this
321    /// equals HEAD↔content, matching pre-0.5 behavior.
322    pub fn unstaged_hunks(&self, path: &Path, content: &str) -> Vec<Hunk> {
323        let Some(rel) = self.rel_path(path) else {
324            return vec![];
325        };
326        let base = self.index_content(path).or_else(|| self.head_content(path));
327        match base {
328            None => self.hunks(path, content), // untracked: all-add
329            Some(base) => self.diff_strings(&base, content, &rel),
330        }
331    }
332
333    /// Unstage one hunk, STRUCTURED (0018): the staged hunk's new side
334    /// is what's in the index; swap that region for the old side.
335    pub fn unstage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
336        let old_side: Vec<&DiffLine> = hunk
337            .lines
338            .iter()
339            .filter(|l| l.origin != LineOrigin::Addition)
340            .collect();
341        self.index_region_edit(rel, hunk.new_start, hunk.new_count, &old_side)
342    }
343
344    /// Hunks between HEAD and `content` for `path`. Untracked files
345    /// report a single all-Add hunk.
346    pub fn hunks(&self, path: &Path, content: &str) -> Vec<Hunk> {
347        let Some(rel) = self.rel_path(path) else {
348            return vec![];
349        };
350        let old = self.head_content(path);
351        match old {
352            None => {
353                let count = content.lines().count();
354                if count == 0 {
355                    return vec![];
356                }
357                vec![Hunk {
358                    kind: HunkKind::Add,
359                    new_start: 1,
360                    new_count: count,
361                    old_start: 0,
362                    old_count: 0,
363                    lines: split_lines_bytes(content.as_bytes())
364                        .into_iter()
365                        .enumerate()
366                        .map(|(i, (text, has_newline))| DiffLine {
367                            origin: LineOrigin::Addition,
368                            old_lineno: None,
369                            new_lineno: Some(i + 1),
370                            text,
371                            has_newline,
372                        })
373                        .collect(),
374                }]
375            }
376            Some(old) => self.diff_strings(&old, content, &rel),
377        }
378    }
379
380    fn diff_strings(&self, old: &str, new: &str, rel: &Path) -> Vec<Hunk> {
381        let mut opts = git2::DiffOptions::new();
382        opts.context_lines(3);
383        let Ok(patch) = git2::Patch::from_buffers(
384            old.as_bytes(),
385            Some(rel),
386            new.as_bytes(),
387            Some(rel),
388            Some(&mut opts),
389        ) else {
390            return vec![];
391        };
392        hunks_from_patch(&patch)
393    }
394
395    /// One file's diff at `sha` vs its first parent, as structured
396    /// hunks. The delta view's data (0010 §1) — libgit2, no shell-out,
397    /// no re-parsing our own text.
398    pub fn commit_file_diff(&self, sha: &str, path: &Path) -> Result<FileDiff, String> {
399        let commit = self
400            .inner
401            .find_commit(git2::Oid::from_str(sha).map_err(|e| e.to_string())?)
402            .map_err(|e| e.to_string())?;
403        let new_tree = commit.tree().map_err(|e| e.to_string())?;
404        let old_tree = match commit.parent(0) {
405            Ok(parent) => Some(parent.tree().map_err(|e| e.to_string())?),
406            // root commit: diff against no tree at all
407            Err(_) => None,
408        };
409        let mut opts = git2::DiffOptions::new();
410        opts.context_lines(3)
411            .pathspec(path)
412            .include_unmodified(false);
413        let diff = self
414            .inner
415            .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))
416            .map_err(|e| e.to_string())?;
417        let mut file = None;
418        for (d, _delta) in diff.deltas().enumerate() {
419            let Some(patch) = git2::Patch::from_diff(&diff, d).map_err(|e| e.to_string())? else {
420                continue; // binary or unrenderable: nothing to show
421            };
422            let hunks = hunks_from_patch(&patch);
423            let added = hunks
424                .iter()
425                .flat_map(|h| &h.lines)
426                .filter(|l| l.origin == LineOrigin::Addition)
427                .count();
428            let deleted = hunks
429                .iter()
430                .flat_map(|h| &h.lines)
431                .filter(|l| l.origin == LineOrigin::Deletion)
432                .count();
433            file = Some(FileDiff {
434                path: path.to_path_buf(),
435                hunks,
436                added,
437                deleted,
438            });
439        }
440        file.ok_or_else(|| "no diff for path".to_string())
441    }
442
443    /// Stage one hunk, STRUCTURED (0018): read the index blob, swap the
444    /// hunk's old-side region for its new-side lines, write the blob
445    /// back into the index. No patch serialization — path quoting,
446    /// CRLF, and missing-final-newline can't go wrong because nothing
447    /// is serialized. `rel` is repo-relative.
448    pub fn stage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
449        let new_side: Vec<&DiffLine> = hunk
450            .lines
451            .iter()
452            .filter(|l| l.origin != LineOrigin::Deletion)
453            .collect();
454        self.index_region_edit(rel, hunk.old_start, hunk.old_count, &new_side)
455    }
456
457    /// Replace 1-based line region [start, start+count) of `rel`'s
458    /// INDEX blob with the given lines, byte-precise. With an empty
459    /// index entry (untracked file) the region is the whole file.
460    fn index_region_edit(
461        &self,
462        rel: &Path,
463        start: usize,
464        count: usize,
465        new_lines: &[&DiffLine],
466    ) -> Result<(), String> {
467        let mut index = self.inner.index().map_err(|e| e.to_string())?;
468        index.read(true).map_err(|e| e.to_string())?; // never a stale in-memory index
469        let entry = index.get_path(rel, 0);
470        let (old_bytes, mode) = match entry {
471            Some(e) => {
472                let blob = self
473                    .inner
474                    .find_blob(e.id)
475                    .map_err(|e| format!("index blob: {e}"))?;
476                (blob.content().to_vec(), e.mode)
477            }
478            None => (Vec::new(), 0o100644), // untracked: stage from empty
479        };
480        let lines = split_lines_bytes(&old_bytes);
481        let lo = start.saturating_sub(1).min(lines.len());
482        let hi = (lo + count).min(lines.len());
483        let mut out: Vec<u8> = Vec::with_capacity(old_bytes.len() + 64);
484        for (text, nl) in &lines[..lo] {
485            out.extend_from_slice(text);
486            if *nl {
487                out.push(b'\n');
488            }
489        }
490        for l in new_lines {
491            out.extend_from_slice(&l.bytes_with_terminator());
492        }
493        for (text, nl) in &lines[hi..] {
494            out.extend_from_slice(text);
495            if *nl {
496                out.push(b'\n');
497            }
498        }
499        let oid = self.inner.blob(&out).map_err(|e| e.to_string())?;
500        index
501            .add(&git2::IndexEntry {
502                ctime: git2::IndexTime::new(0, 0),
503                mtime: git2::IndexTime::new(0, 0),
504                dev: 0,
505                ino: 0,
506                mode,
507                uid: 0,
508                gid: 0,
509                file_size: 0,
510                id: oid,
511                flags: 0,
512                flags_extended: 0,
513                path: rel.to_string_lossy().replace('\\', "/").into_bytes(),
514            })
515            .map_err(|e| e.to_string())?;
516        index.write().map_err(|e| e.to_string())?;
517        Ok(())
518    }
519}
520
521/// Byte-precise line split: (content-without-terminator, had-newline)
522/// pairs. Unlike str::lines, the final unterminated line keeps its
523/// identity — staging round-trips a missing trailing newline (0018).
524fn split_lines_bytes(bytes: &[u8]) -> Vec<(Vec<u8>, bool)> {
525    let mut out = Vec::new();
526    let mut start = 0;
527    for (i, b) in bytes.iter().enumerate() {
528        if *b == b'\n' {
529            out.push((bytes[start..i].to_vec(), true));
530            start = i + 1;
531        }
532    }
533    if start < bytes.len() {
534        out.push((bytes[start..].to_vec(), false));
535    }
536    out
537}
538
539/// Typed hunks from a libgit2 patch — the one place line origins and
540/// both sides' 1-based numbers are read off the wire.
541fn hunks_from_patch(patch: &git2::Patch) -> Vec<Hunk> {
542    let mut hunks = Vec::new();
543    for h in 0..patch.num_hunks() {
544        let Ok((header, line_count)) = patch.hunk(h) else {
545            continue;
546        };
547        let mut lines = Vec::with_capacity(line_count);
548        for l in 0..line_count {
549            let Ok(line) = patch.line_in_hunk(h, l) else {
550                continue;
551            };
552            // the "\ No newline at end of file" marker arrives as a
553            // Context-origin line (libgit2 quirk) — it's patch
554            // metadata, not content; has_newline carries its truth
555            let raw = line.content();
556            if raw.starts_with(b"\\ No newline") || raw.starts_with(b"\n\\ No newline") {
557                continue;
558            }
559            let origin = match line.origin() {
560                '+' => LineOrigin::Addition,
561                '-' => LineOrigin::Deletion,
562                _ => LineOrigin::Context,
563            };
564            // libgit2 numbers are 1-based; the absent side is None.
565            let old_lineno = line.old_lineno().map(|n| n as usize);
566            let new_lineno = line.new_lineno().map(|n| n as usize);
567            let content = line.content();
568            let (text, has_newline) = match content.last() {
569                Some(b'\n') => (&content[..content.len() - 1], true),
570                _ => (content, false),
571            };
572            lines.push(DiffLine {
573                origin,
574                old_lineno,
575                new_lineno,
576                text: text.to_vec(),
577                has_newline,
578            });
579        }
580        hunks.push(Hunk::build(
581            header.old_start() as usize,
582            header.old_lines() as usize,
583            header.new_start() as usize,
584            header.new_lines() as usize,
585            lines,
586        ));
587    }
588    hunks
589}
590
591/// A revisioned source location (0014 wave 4): permalinks, jumps into
592/// history, and blame's parent-hop all speak this — no more "permalink
593/// from a historical view links HEAD's file".
594#[derive(Debug, Clone, PartialEq, Eq)]
595pub struct SourceLocation {
596    pub revision: GitRevision,
597    /// Repo-relative path.
598    pub path: PathBuf,
599    /// 1-based line range, when the location is a selection.
600    pub lines: Option<(usize, usize)>,
601}
602
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub enum GitRevision {
605    /// The checked-out branch head.
606    Head,
607    /// A specific commit (surfaces carry this).
608    Commit(String),
609    /// The staged content (the index) — 0018's four-state model makes
610    /// it a first-class revision, not an implicit middle.
611    Index,
612    /// The on-disk worktree file.
613    Worktree,
614    /// The merge-base of two commits (review starts here).
615    MergeBase(String, String),
616}
617
618impl GitRevision {
619    /// Read a file's bytes AT this revision. Live (the editor's buffer)
620    /// never crosses this seam — the editor owns that copy.
621    pub fn read(&self, repo: &Repo, rel: &Path) -> Option<Vec<u8>> {
622        match self {
623            GitRevision::Head => repo.head_bytes(rel),
624            GitRevision::Commit(sha) => repo.commit_bytes(sha, rel),
625            GitRevision::Index => repo.index_bytes(rel),
626            GitRevision::Worktree => std::fs::read(repo.workdir.join(rel)).ok(),
627            GitRevision::MergeBase(a, b) => {
628                let base = repo.merge_base(a, b)?;
629                repo.commit_bytes(&base, rel)
630            }
631        }
632    }
633}
634
635impl SourceLocation {
636    /// The URL slug: a pinned commit sha or the branch's name.
637    pub fn revision_slug(&self) -> String {
638        match &self.revision {
639            GitRevision::Head => "HEAD".into(),
640            GitRevision::Commit(sha) => sha.clone(),
641            GitRevision::Index => "index".into(),
642            GitRevision::Worktree => "worktree".into(),
643            GitRevision::MergeBase(a, b) => format!("{a}...{b}"),
644        }
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use std::process::Command;
652
653    pub(crate) fn git(root: &std::path::Path, args: &[&str]) {
654        Command::new("git")
655            .args(args)
656            .current_dir(root)
657            .output()
658            .unwrap();
659    }
660
661    pub(crate) fn fixture() -> (tempfile::TempDir, Repo, PathBuf) {
662        let dir = tempfile::tempdir().unwrap();
663        let root = dir.path();
664        git(root, &["init", "-q"]);
665        git(root, &["config", "user.email", "t@t.t"]);
666        git(root, &["config", "user.name", "t"]);
667        std::fs::write(root.join("f.rs"), "fn a() {}\nfn b() {}\nfn c() {}\n").unwrap();
668        git(root, &["add", "."]);
669        git(root, &["commit", "-qm", "init"]);
670        let repo = Repo::discover(root).unwrap();
671        let file = root.join("f.rs");
672        (dir, repo, file)
673    }
674
675    #[test]
676    fn clean_buffer_has_no_hunks() {
677        let (_d, repo, path) = fixture();
678        let content = repo.head_content(&path).unwrap();
679        assert!(repo.hunks(&path, &content).is_empty());
680    }
681
682    #[test]
683    fn change_and_add_and_delete() {
684        let (_d, repo, path) = fixture();
685        let edited = "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n";
686        let hunks = repo.hunks(&path, edited);
687        assert_eq!(hunks.len(), 1);
688        assert_eq!(hunks[0].kind, HunkKind::Change);
689        assert!(hunks[0].covers(2, 4));
690        assert!(hunks[0].covers(4, 4));
691        assert!(!hunks[0].covers(1, 4));
692        assert!(hunks[0]
693            .lines
694            .iter()
695            .any(|l| l.origin == LineOrigin::Addition && l.text.starts_with(b"fn d")));
696    }
697
698    /// The typed structure carries both sides' 1-based numbers: the
699    /// renderer never guesses them from text (0010 §1).
700    #[test]
701    fn line_numbers_track_both_sides() {
702        let (_d, repo, path) = fixture();
703        let edited = "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n";
704        let hunks = repo.hunks(&path, edited);
705        assert_eq!(hunks.len(), 1);
706        let h = &hunks[0];
707        let ctx = h
708            .lines
709            .iter()
710            .find(|l| l.origin == LineOrigin::Context)
711            .unwrap();
712        assert_eq!(
713            (ctx.old_lineno, ctx.new_lineno),
714            (Some(1), Some(1)),
715            "context lines carry both numbers, 1-based"
716        );
717        let add = h
718            .lines
719            .iter()
720            .find(|l| l.origin == LineOrigin::Addition && l.text.starts_with(b"fn d"))
721            .unwrap();
722        assert_eq!((add.old_lineno, add.new_lineno), (None, Some(4)));
723        let del = h
724            .lines
725            .iter()
726            .find(|l| l.origin == LineOrigin::Deletion)
727            .unwrap();
728        assert_eq!((del.old_lineno, del.new_lineno), (Some(2), None));
729    }
730
731    #[test]
732    fn pure_delete_marks_following_line() {
733        let (_d, repo, path) = fixture();
734        let edited = "fn a() {}\nfn c() {}\n";
735        let hunks = repo.hunks(&path, edited);
736        assert_eq!(hunks.len(), 1);
737        assert_eq!(hunks[0].kind, HunkKind::Delete);
738        assert!(hunks[0].covers(2, 4)); // sign on the line after the gap
739    }
740
741    #[test]
742    fn stage_hunk_applies_to_index() {
743        let (_d, repo, path) = fixture();
744        let edited = "fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n";
745        let hunks = repo.hunks(&path, edited);
746        assert_eq!(hunks.len(), 1);
747        assert_eq!(hunks[0].kind, HunkKind::Add);
748        let root = repo.workdir.clone();
749        repo.stage_hunk(Path::new("f.rs"), &hunks[0]).unwrap();
750        let out = Command::new("git")
751            .args([
752                "-C",
753                &root.display().to_string(),
754                "diff",
755                "--cached",
756                "--stat",
757            ])
758            .output()
759            .unwrap();
760        let stat = String::from_utf8_lossy(&out.stdout);
761        assert!(stat.contains("f.rs"), "{stat}");
762    }
763
764    /// Structured staging is byte-precise (0018): stage a hunk, and
765    /// the index holds exactly the post-edit bytes — including a
766    /// missing final newline, which the old patch path could not
767    /// represent.
768    #[test]
769    fn stage_hunk_is_byte_precise() {
770        let (_d, repo, path) = fixture();
771        let edited = "fn a() {}\nfn b2() {}\nfn c() {}\n";
772        let hunks = repo.hunks(&path, edited);
773        assert_eq!(hunks.len(), 1);
774        repo.stage_hunk(Path::new("f.rs"), &hunks[0]).unwrap();
775        // the index now holds the edited text; HEAD is untouched
776        assert_eq!(
777            repo.index_content(&path).as_deref(),
778            Some("fn a() {}\nfn b2() {}\nfn c() {}\n")
779        );
780        assert_eq!(
781            repo.head_content(&path).as_deref(),
782            Some("fn a() {}\nfn b() {}\nfn c() {}\n")
783        );
784        // and unstaging the same hunk restores the index to HEAD
785        let staged = repo.staged_hunks(&path);
786        assert_eq!(staged.len(), 1);
787        repo.unstage_hunk(Path::new("f.rs"), &staged[0]).unwrap();
788        assert_eq!(
789            repo.index_content(&path).as_deref(),
790            Some("fn a() {}\nfn b() {}\nfn c() {}\n")
791        );
792    }
793
794    #[test]
795    fn stage_hunk_preserves_a_missing_final_newline() {
796        let (_d, repo, path) = fixture();
797        // the worktree file drops its trailing newline
798        let edited = "fn a() {}\nfn b() {}\nfn c() {}";
799        let hunks = repo.hunks(&path, edited);
800        repo.stage_hunk(Path::new("f.rs"), &hunks[0]).unwrap();
801        assert_eq!(repo.index_content(&path).as_deref(), Some(edited));
802        let staged = repo.staged_hunks(&path);
803        repo.unstage_hunk(Path::new("f.rs"), &staged[0]).unwrap();
804        assert_eq!(
805            repo.index_content(&path).as_deref(),
806            Some("fn a() {}\nfn b() {}\nfn c() {}\n"),
807            "unstage restores the newline-terminated HEAD text"
808        );
809    }
810
811    /// The commit delta view's data: structured hunks at a SHA, via
812    /// libgit2 — the `git show` shell-out replacement.
813    #[test]
814    fn commit_file_diff_is_structured() {
815        let (_d, repo, path) = fixture();
816        let root = repo.workdir.clone();
817        std::fs::write(root.join("f.rs"), "fn a() {}\nfn b2() {}\nfn c() {}\n").unwrap();
818        git(&root, &["add", "."]);
819        git(&root, &["commit", "-qm", "change b"]);
820        let sha = String::from_utf8_lossy(
821            &Command::new("git")
822                .args(["-C", &root.display().to_string(), "rev-parse", "HEAD"])
823                .output()
824                .unwrap()
825                .stdout,
826        )
827        .trim()
828        .to_string();
829        let diff = repo.commit_file_diff(&sha, Path::new("f.rs")).unwrap();
830        assert_eq!(diff.added, 1);
831        assert_eq!(diff.deleted, 1);
832        assert_eq!(diff.hunks.len(), 1);
833        assert_eq!(diff.hunks[0].kind, HunkKind::Change);
834        assert!(diff.hunks[0].lines.iter().any(|l| l.text == b"fn b2() {}"));
835        let _ = path;
836    }
837
838    /// Root commits diff against the empty tree: the init commit shows
839    /// as one all-addition hunk, not an error.
840    #[test]
841    fn commit_file_diff_root_commit() {
842        let (_d, repo, _path) = fixture();
843        let root = repo.workdir.clone();
844        let sha = String::from_utf8_lossy(
845            &Command::new("git")
846                .args(["-C", &root.display().to_string(), "rev-parse", "HEAD"])
847                .output()
848                .unwrap()
849                .stdout,
850        )
851        .trim()
852        .to_string();
853        let diff = repo.commit_file_diff(&sha, Path::new("f.rs")).unwrap();
854        assert_eq!(diff.added, 3);
855        assert_eq!(diff.deleted, 0);
856        assert!(diff
857            .hunks
858            .iter()
859            .all(|h| h.lines.iter().all(|l| l.old_lineno.is_none())));
860    }
861}
862
863#[cfg(test)]
864mod head_tests {
865    use super::tests::fixture;
866    use super::*;
867    use std::process::Command;
868
869    #[test]
870    fn head_content_probe() {
871        let dir = tempfile::tempdir().unwrap();
872        let root = dir.path();
873        let git = |args: &[&str]| {
874            Command::new("git")
875                .args(args)
876                .current_dir(root)
877                .output()
878                .unwrap();
879        };
880        git(&["init", "-q"]);
881        git(&["config", "user.email", "t@t.t"]);
882        git(&["config", "user.name", "t"]);
883        std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
884        git(&["add", "."]);
885        git(&["commit", "-qm", "init"]);
886        let repo = Repo::discover(root).unwrap();
887        eprintln!("workdir: {:?}", repo.workdir());
888        let abs = root.join("f.rs");
889        eprintln!("abs: {:?} rel: {:?}", abs, repo.rel_path(&abs));
890        eprintln!("head: {:?}", repo.head_content(&abs));
891        assert!(repo.head_content(&abs).is_some());
892    }
893
894    /// 0014 wave 4: the four states are real and separately diffable.
895    #[test]
896    fn four_state_edges() {
897        let (_d, repo, path) = fixture();
898        // worktree edit, stage it, then edit again (live-only)
899        std::fs::write(&path, "fn a() {}\nfn STAGED() {}\nfn c() {}\n").unwrap();
900        let staged = repo.unstaged_hunks(&path, &std::fs::read_to_string(&path).unwrap());
901        assert_eq!(staged.len(), 1);
902        let hunk = staged.into_iter().next().unwrap();
903        repo.stage_hunk(Path::new("f.rs"), &hunk).unwrap();
904        // index now differs from HEAD
905        let idx = repo.index_content(&path).unwrap();
906        assert!(idx.contains("STAGED"));
907        let head = repo.head_content(&path).unwrap();
908        assert!(!head.contains("STAGED"));
909        // staged set: HEAD↔index has the hunk; unstaged (index↔same content) is empty
910        assert_eq!(repo.staged_hunks(&path).len(), 1);
911        let wt = std::fs::read_to_string(&path).unwrap();
912        assert!(repo.unstaged_hunks(&path, &wt).is_empty());
913        // a further live-only edit shows in the unstaged set only
914        let live = "fn a() {}\nfn STAGED() {}\nfn c() {}\nfn live()\n";
915        let unstaged = repo.unstaged_hunks(&path, live);
916        assert_eq!(unstaged.len(), 1);
917        assert!(unstaged[0]
918            .lines
919            .iter()
920            .any(|l| l.text.starts_with(b"fn live")));
921        assert_eq!(repo.staged_hunks(&path).len(), 1, "staged untouched");
922        // unstage reverses the edge
923        let staged = repo.staged_hunks(&path);
924        repo.unstage_hunk(Path::new("f.rs"), &staged[0]).unwrap();
925        assert!(repo.staged_hunks(&path).is_empty());
926        assert!(!repo.index_content(&path).unwrap().contains("STAGED"));
927    }
928}