Skip to main content

strop_git/
repo.rs

1//! The repository: libgit2 in-process (0001 §3 — no shell-outs on
2//! hot paths), hunk staging, content by revision.
3
4use std::path::{Path, PathBuf};
5
6use strop_remote::RemoteEndpoint;
7
8use crate::diff::{DiffLine, FileDiff, Hunk, HunkKind, LineOrigin};
9use crate::target::RepoTarget;
10
11/// Why a repository operation failed — typed, not a string and not an
12/// empty Vec standing in for "something went wrong" (R9).
13#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
14pub enum GitError {
15    /// libgit2 failed underneath (corrupt index, blob read, config…).
16    Native(String),
17    /// The path is not inside the repository workdir — the caller's
18    /// buffer cannot take part in this repository's edges at all.
19    OutsideWorkdir,
20}
21
22impl std::fmt::Display for GitError {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        match self {
25            Self::Native(message) => write!(f, "{message}"),
26            Self::OutsideWorkdir => write!(f, "path is outside the repository workdir"),
27        }
28    }
29}
30
31/// The pure cached view of a repository (R6): no libgit2 handle, no
32/// locks, no IO to read — render and command decisions consult this,
33/// while every native read runs on a worker. Equality is meaningful:
34/// an unchanged context (same HEAD, branch, remotes) means cached
35/// diffs stay valid.
36///
37/// `repo` is the typed machine boundary (0036 RW8): a local workdir
38/// or a remote endpoint's workdir. A remote context is never a valid
39/// libgit2 input — callers that need the local repository must match.
40#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
41pub struct GitContext {
42    pub repo: RepoTarget,
43    pub head_sha: Option<String>,
44    pub head_branch: Option<String>,
45    /// (name, url) pairs; permalink selection is a pure fold over them.
46    pub remotes: Vec<(String, String)>,
47}
48
49impl GitContext {
50    /// The repository root — a remote path for remote repositories,
51    /// valid only on `endpoint()`.
52    pub fn workdir(&self) -> &Path {
53        self.repo.workdir()
54    }
55
56    /// The host whose filesystem this repository lives on, if remote.
57    pub fn endpoint(&self) -> Option<&RemoteEndpoint> {
58        self.repo.endpoint()
59    }
60
61    /// True when this context describes a repository on another
62    /// machine: every mutating verb must refuse (RW4), and no local
63    /// git call may run against it.
64    pub fn is_remote(&self) -> bool {
65        self.repo.is_remote()
66    }
67}
68
69pub struct Repo {
70    inner: git2::Repository,
71    pub(crate) workdir: PathBuf,
72}
73
74impl Repo {
75    /// Discover the repository containing `path` (buffer path or cwd).
76    pub fn discover(from: &Path) -> Option<Self> {
77        let inner = git2::Repository::discover(from).ok()?;
78        let workdir = inner.workdir()?.to_path_buf();
79        Some(Self { inner, workdir })
80    }
81
82    pub fn workdir(&self) -> &Path {
83        &self.workdir
84    }
85
86    /// Remotes as (name, url) pairs — libgit2 config, no spawn.
87    pub fn remotes(&self) -> Vec<(String, String)> {
88        let Ok(remotes) = self.inner.remotes() else {
89            return vec![];
90        };
91        remotes
92            .iter()
93            .flatten()
94            .filter_map(|name| {
95                self.inner
96                    .find_remote(name)
97                    .ok()
98                    .and_then(|r| r.url().map(|u| (name.to_string(), u.to_string())))
99            })
100            .collect()
101    }
102
103    /// HEAD's full SHA (permalink base — branch always resolves to SHA).
104    pub fn head_sha(&self) -> Option<String> {
105        Some(
106            self.inner
107                .head()
108                .ok()?
109                .peel_to_commit()
110                .ok()?
111                .id()
112                .to_string(),
113        )
114    }
115
116    /// Current branch (short name; detached HEAD gives the sha prefix).
117    pub fn head_branch(&self) -> Option<String> {
118        self.inner
119            .head()
120            .ok()
121            .and_then(|h| h.shorthand().map(String::from))
122    }
123
124    /// The pure cached view (R6): snapshot HEAD, branch and remotes
125    /// once — on a worker — and let render/decisions consult it with
126    /// zero native work. An equal context means nothing changed.
127    pub fn context(&self) -> GitContext {
128        GitContext {
129            repo: RepoTarget::Local {
130                workdir: self.workdir.clone(),
131            },
132            head_sha: self.head_sha(),
133            head_branch: self.head_branch(),
134            remotes: self.remotes(),
135        }
136    }
137
138    /// Repo-relative path for a buffer path (diff keys are relative).
139    fn rel_path(&self, path: &Path) -> Option<PathBuf> {
140        let abs = if path.is_absolute() {
141            path.to_path_buf()
142        } else {
143            self.workdir.join(path)
144        };
145        abs.strip_prefix(&self.workdir)
146            .ok()
147            .map(|p| p.to_path_buf())
148    }
149
150    /// HEAD's content for `path`, if tracked.
151    /// HEAD's blob bytes for a repo-relative path (typed, not lossy).
152    pub fn head_bytes(&self, rel: &Path) -> Option<Vec<u8>> {
153        let commit = self.inner.head().ok()?.peel_to_commit().ok()?;
154        let tree = commit.tree().ok()?;
155        let entry = tree.get_path(rel).ok()?;
156        let blob = self.inner.find_blob(entry.id()).ok()?;
157        Some(blob.content().to_vec())
158    }
159
160    /// A commit's blob bytes for a repo-relative path.
161    pub fn commit_bytes(&self, sha: &str, rel: &Path) -> Option<Vec<u8>> {
162        let oid = self.inner.revparse_single(sha).ok()?.id();
163        let commit = self.inner.find_commit(oid).ok()?;
164        let tree = commit.tree().ok()?;
165        let entry = tree.get_path(rel).ok()?;
166        let blob = self.inner.find_blob(entry.id()).ok()?;
167        Some(blob.content().to_vec())
168    }
169
170    /// The index's blob bytes for a repo-relative path (reloads — never
171    /// a stale snapshot).
172    pub fn index_bytes(&self, rel: &Path) -> Option<Vec<u8>> {
173        let mut index = self.inner.index().ok()?;
174        index.read(true).ok()?;
175        let entry = index.get_path(rel, 0)?;
176        let blob = self.inner.find_blob(entry.id).ok()?;
177        Some(blob.content().to_vec())
178    }
179
180    /// Merge-base oid of two revisions.
181    pub fn merge_base(&self, a: &str, b: &str) -> Option<String> {
182        let a = self.inner.revparse_single(a).ok()?.id();
183        let b = self.inner.revparse_single(b).ok()?.id();
184        let base = self.inner.merge_base(a, b).ok()?;
185        Some(base.to_string())
186    }
187
188    pub fn head_content(&self, path: &Path) -> Option<String> {
189        self.head_content_res(path).ok().flatten()
190    }
191
192    /// HEAD's content for `path`, distinguishing "not in HEAD's tree"
193    /// (Ok(None) — untracked or unborn) from a native failure.
194    fn head_content_res(&self, path: &Path) -> Result<Option<String>, GitError> {
195        let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
196        let head = match self.inner.head() {
197            Ok(reference) => reference
198                .peel_to_tree()
199                .map_err(|e| GitError::Native(format!("read HEAD: {e}")))?,
200            // an unborn branch has no commits: HEAD knows nothing —
201            // the file is new, not failed
202            Err(error) if error.code() == git2::ErrorCode::UnbornBranch => {
203                return Ok(None);
204            }
205            Err(error) => return Err(GitError::Native(format!("read HEAD: {error}"))),
206        };
207        match head.get_path(&rel) {
208            // not in HEAD's tree: untracked — the file is new
209            Err(_) => Ok(None),
210            Ok(entry) => self.blob_utf8(entry.id(), "HEAD").map(Some),
211        }
212    }
213
214    /// The index's content for `path` (the staged version), if any.
215    pub fn index_content(&self, path: &Path) -> Option<String> {
216        self.index_content_res(path).ok().flatten()
217    }
218
219    /// The index's content distinguishing "nothing staged" (Ok(None))
220    /// from a native failure. Reloads — never a stale snapshot.
221    fn index_content_res(&self, path: &Path) -> Result<Option<String>, GitError> {
222        let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
223        let mut index = self
224            .inner
225            .index()
226            .map_err(|e| GitError::Native(format!("open index: {e}")))?;
227        index
228            .read(true)
229            .map_err(|e| GitError::Native(format!("reload index: {e}")))?;
230        match index.get_path(&rel, 0) {
231            Some(entry) => self.blob_utf8(entry.id, "index").map(Some),
232            None => Ok(None),
233        }
234    }
235
236    /// One blob's content as UTF-8; the caller names the edge in the
237    /// error so failures read "index blob: …", never anonymous.
238    fn blob_utf8(&self, id: git2::Oid, edge: &str) -> Result<String, GitError> {
239        let blob = self
240            .inner
241            .find_blob(id)
242            .map_err(|e| GitError::Native(format!("{edge} blob: {e}")))?;
243        String::from_utf8(blob.content().to_vec())
244            .map_err(|_| GitError::Native(format!("{edge} blob is not UTF-8")))
245    }
246
247    /// True when neither the index nor HEAD knows `path` — the buffer
248    /// is untracked, so hunk undo has nothing to restore from.
249    pub fn is_untracked(&self, path: &Path) -> Result<bool, GitError> {
250        Ok(self.index_content_res(path)?.is_none() && self.head_content_res(path)?.is_none())
251    }
252
253    /// Hunks between HEAD and the index — the STAGED set (0014 wave 4:
254    /// the four states are HEAD → index → worktree → live document, and
255    /// every command names its edge). Nothing staged is an honest
256    /// empty set; an unborn HEAD diffs the index against empty.
257    pub fn staged_hunks(&self, path: &Path) -> Result<Vec<Hunk>, GitError> {
258        let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
259        let Some(index) = self.index_content_res(path)? else {
260            return Ok(vec![]);
261        };
262        let head = self.head_content_res(path)?.unwrap_or_default();
263        self.diff_strings(&head, &index, &rel)
264    }
265
266    /// Hunks between the index and `content` — the UNSTAGED set (what
267    /// the gutter shows while you edit). When nothing is staged this
268    /// equals HEAD↔content, matching pre-0.5 behavior. An untracked
269    /// file reports one all-add hunk against empty.
270    pub fn unstaged_hunks(&self, path: &Path, content: &str) -> Result<Vec<Hunk>, GitError> {
271        let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
272        let base = match self.index_content_res(path)? {
273            Some(index) => Some(index),
274            None => self.head_content_res(path)?,
275        };
276        match base {
277            Some(base) => self.diff_strings(&base, content, &rel),
278            None => self.hunks(path, content),
279        }
280    }
281
282    /// Unstage one hunk, STRUCTURED (0018): the staged hunk's new side
283    /// is what's in the index; swap that region for the old side.
284    pub fn unstage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
285        let old_side: Vec<&DiffLine> = hunk
286            .lines
287            .iter()
288            .filter(|l| l.origin != LineOrigin::Addition)
289            .collect();
290        self.index_region_edit(rel, hunk.new_start, hunk.new_count, &old_side)
291    }
292
293    /// Hunks between HEAD and `content` for `path`. Untracked files
294    /// report a single all-Add hunk — a useful empty-history case, not
295    /// a failure. Native failures are typed, never empty.
296    pub fn hunks(&self, path: &Path, content: &str) -> Result<Vec<Hunk>, GitError> {
297        let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
298        match self.head_content_res(path)? {
299            Some(old) => self.diff_strings(&old, content, &rel),
300            None => Ok(all_add_hunk(content)),
301        }
302    }
303
304    fn diff_strings(&self, old: &str, new: &str, rel: &Path) -> Result<Vec<Hunk>, GitError> {
305        let mut opts = git2::DiffOptions::new();
306        opts.context_lines(3);
307        let patch = git2::Patch::from_buffers(
308            old.as_bytes(),
309            Some(rel),
310            new.as_bytes(),
311            Some(rel),
312            Some(&mut opts),
313        )
314        .map_err(|e| GitError::Native(format!("diff {rel:?}: {e}")))?;
315        Ok(hunks_from_patch(&patch))
316    }
317
318    /// One file's diff at `sha` vs its first parent, as structured
319    /// hunks. The delta view's data (0010 §1) — libgit2, no shell-out,
320    /// no re-parsing our own text.
321    pub fn commit_file_diff(&self, sha: &str, path: &Path) -> Result<FileDiff, String> {
322        let commit = self
323            .inner
324            .find_commit(git2::Oid::from_str(sha).map_err(|e| e.to_string())?)
325            .map_err(|e| e.to_string())?;
326        let new_tree = commit.tree().map_err(|e| e.to_string())?;
327        let old_tree = match commit.parent(0) {
328            Ok(parent) => Some(parent.tree().map_err(|e| e.to_string())?),
329            // root commit: diff against no tree at all
330            Err(_) => None,
331        };
332        let mut opts = git2::DiffOptions::new();
333        opts.context_lines(3)
334            .pathspec(path)
335            .disable_pathspec_match(true)
336            .include_unmodified(false);
337        let diff = self
338            .inner
339            .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))
340            .map_err(|e| e.to_string())?;
341        let mut file = None;
342        for (d, _delta) in diff.deltas().enumerate() {
343            let Some(patch) = git2::Patch::from_diff(&diff, d).map_err(|e| e.to_string())? else {
344                continue; // binary or unrenderable: nothing to show
345            };
346            file = Some(FileDiff::from_hunks(
347                path.to_path_buf(),
348                hunks_from_patch(&patch),
349            ));
350        }
351        file.ok_or_else(|| "no diff for path".to_string())
352    }
353
354    /// Stage one hunk, STRUCTURED (0018): read the index blob, swap the
355    /// hunk's old-side region for its new-side lines, write the blob
356    /// back into the index. No patch serialization — path quoting,
357    /// CRLF, and missing-final-newline can't go wrong because nothing
358    /// is serialized. `rel` is repo-relative.
359    pub fn stage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
360        let new_side: Vec<&DiffLine> = hunk
361            .lines
362            .iter()
363            .filter(|l| l.origin != LineOrigin::Deletion)
364            .collect();
365        self.index_region_edit(rel, hunk.old_start, hunk.old_count, &new_side)
366    }
367
368    /// Replace 1-based line region [start, start+count) of `rel`'s
369    /// INDEX blob with the given lines, byte-precise. With an empty
370    /// index entry (untracked file) the region is the whole file.
371    fn index_region_edit(
372        &self,
373        rel: &Path,
374        start: usize,
375        count: usize,
376        new_lines: &[&DiffLine],
377    ) -> Result<(), String> {
378        let mut index = self.inner.index().map_err(|e| e.to_string())?;
379        index.read(true).map_err(|e| e.to_string())?; // never a stale in-memory index
380        let entry = index.get_path(rel, 0);
381        let (old_bytes, mode) = match entry {
382            Some(e) => {
383                let blob = self
384                    .inner
385                    .find_blob(e.id)
386                    .map_err(|e| format!("index blob: {e}"))?;
387                (blob.content().to_vec(), e.mode)
388            }
389            None => (Vec::new(), 0o100644), // untracked: stage from empty
390        };
391        let lines = split_lines_bytes(&old_bytes);
392        let lo = start.saturating_sub(1).min(lines.len());
393        let hi = (lo + count).min(lines.len());
394        let mut out: Vec<u8> = Vec::with_capacity(old_bytes.len() + 64);
395        for (text, nl) in &lines[..lo] {
396            out.extend_from_slice(text);
397            if *nl {
398                out.push(b'\n');
399            }
400        }
401        for l in new_lines {
402            out.extend_from_slice(&l.bytes_with_terminator());
403        }
404        for (text, nl) in &lines[hi..] {
405            out.extend_from_slice(text);
406            if *nl {
407                out.push(b'\n');
408            }
409        }
410        let oid = self.inner.blob(&out).map_err(|e| e.to_string())?;
411        index
412            .add(&git2::IndexEntry {
413                ctime: git2::IndexTime::new(0, 0),
414                mtime: git2::IndexTime::new(0, 0),
415                dev: 0,
416                ino: 0,
417                mode,
418                uid: 0,
419                gid: 0,
420                file_size: 0,
421                id: oid,
422                flags: 0,
423                flags_extended: 0,
424                path: rel.to_string_lossy().replace('\\', "/").into_bytes(),
425            })
426            .map_err(|e| e.to_string())?;
427        index.write().map_err(|e| e.to_string())?;
428        Ok(())
429    }
430}
431
432/// The untracked-file hunk: everything added, against nothing. Empty
433/// content is an honest empty set.
434fn all_add_hunk(content: &str) -> Vec<Hunk> {
435    let count = content.lines().count();
436    if count == 0 {
437        return vec![];
438    }
439    vec![Hunk {
440        kind: HunkKind::Add,
441        new_start: 1,
442        new_count: count,
443        old_start: 0,
444        old_count: 0,
445        lines: split_lines_bytes(content.as_bytes())
446            .into_iter()
447            .enumerate()
448            .map(|(i, (text, has_newline))| DiffLine {
449                origin: LineOrigin::Addition,
450                old_lineno: None,
451                new_lineno: Some(i + 1),
452                text,
453                has_newline,
454            })
455            .collect(),
456    }]
457}
458
459/// The gutter's typed hunk sets from the three content states — the
460/// shared semantics both backends feed (local libgit2 reads here,
461/// bounded remote blob fetches in `remote::gutter`): unstaged =
462/// index (or HEAD, or nothing staged)↔live text, staged = HEAD↔index,
463/// untracked = neither the index nor HEAD knows the path. Absent
464/// states are honest `None`s and behave exactly like the `Repo`
465/// methods local buffers use.
466pub(crate) fn gutter_from_contents(
467    head: Option<&str>,
468    index: Option<&str>,
469    text: &str,
470    rel: &Path,
471) -> Result<(Vec<Hunk>, Vec<Hunk>, bool), GitError> {
472    let staged = match index {
473        Some(index) => hunks_from_strings(head.unwrap_or_default(), index, rel)?,
474        None => Vec::new(),
475    };
476    let unstaged = match index.or(head) {
477        Some(base) => hunks_from_strings(base, text, rel)?,
478        None => all_add_hunk(text),
479    };
480    let untracked = index.is_none() && head.is_none();
481    Ok((unstaged, staged, untracked))
482}
483
484/// Typed hunks between two content strings (libgit2 diffs buffers in
485/// memory; no repository handle needed).
486fn hunks_from_strings(old: &str, new: &str, rel: &Path) -> Result<Vec<Hunk>, GitError> {
487    let mut opts = git2::DiffOptions::new();
488    opts.context_lines(3);
489    let patch = git2::Patch::from_buffers(
490        old.as_bytes(),
491        Some(rel),
492        new.as_bytes(),
493        Some(rel),
494        Some(&mut opts),
495    )
496    .map_err(|e| GitError::Native(format!("diff {rel:?}: {e}")))?;
497    Ok(hunks_from_patch(&patch))
498}
499
500/// Typed hunks between two blob byte slices — the shared delta
501/// builder for the commit view: the local path diffs trees, the
502/// remote path fetches the two blobs, and both end here. `old = None`
503/// is the empty side (a root commit's delta). Bytes stay bytes:
504/// non-UTF-8 content diffs as content, like the local tree path.
505pub(crate) fn hunks_from_buffers(
506    old: Option<&[u8]>,
507    new: &[u8],
508    rel: &Path,
509) -> Result<Vec<Hunk>, GitError> {
510    let mut opts = git2::DiffOptions::new();
511    opts.context_lines(3);
512    let patch = git2::Patch::from_buffers(
513        old.unwrap_or(&[]),
514        Some(rel),
515        new,
516        Some(rel),
517        Some(&mut opts),
518    )
519    .map_err(|e| GitError::Native(format!("diff {rel:?}: {e}")))?;
520    Ok(hunks_from_patch(&patch))
521}
522
523/// Byte-precise line split: (content-without-terminator, had-newline)
524/// pairs. Unlike str::lines, the final unterminated line keeps its
525/// identity — staging round-trips a missing trailing newline (0018).
526fn split_lines_bytes(bytes: &[u8]) -> Vec<(Vec<u8>, bool)> {
527    let mut out = Vec::new();
528    let mut start = 0;
529    for (i, b) in bytes.iter().enumerate() {
530        if *b == b'\n' {
531            out.push((bytes[start..i].to_vec(), true));
532            start = i + 1;
533        }
534    }
535    if start < bytes.len() {
536        out.push((bytes[start..].to_vec(), false));
537    }
538    out
539}
540
541/// Typed hunks from a libgit2 patch — the one place line origins and
542/// both sides' 1-based numbers are read off the wire.
543fn hunks_from_patch(patch: &git2::Patch) -> Vec<Hunk> {
544    let mut hunks = Vec::new();
545    for h in 0..patch.num_hunks() {
546        let Ok((header, line_count)) = patch.hunk(h) else {
547            continue;
548        };
549        let mut lines = Vec::with_capacity(line_count);
550        for l in 0..line_count {
551            let Ok(line) = patch.line_in_hunk(h, l) else {
552                continue;
553            };
554            // the "\ No newline at end of file" marker arrives as a
555            // Context-origin line (libgit2 quirk) — it's patch
556            // metadata, not content; has_newline carries its truth
557            let raw = line.content();
558            if raw.starts_with(b"\\ No newline") || raw.starts_with(b"\n\\ No newline") {
559                continue;
560            }
561            let origin = match line.origin() {
562                '+' => LineOrigin::Addition,
563                '-' => LineOrigin::Deletion,
564                _ => LineOrigin::Context,
565            };
566            // libgit2 numbers are 1-based; the absent side is None.
567            let old_lineno = line.old_lineno().map(|n| n as usize);
568            let new_lineno = line.new_lineno().map(|n| n as usize);
569            let content = line.content();
570            let (text, has_newline) = match content.last() {
571                Some(b'\n') => (&content[..content.len() - 1], true),
572                _ => (content, false),
573            };
574            lines.push(DiffLine {
575                origin,
576                old_lineno,
577                new_lineno,
578                text: text.to_vec(),
579                has_newline,
580            });
581        }
582        hunks.push(Hunk::build(
583            header.old_start() as usize,
584            header.old_lines() as usize,
585            header.new_start() as usize,
586            header.new_lines() as usize,
587            lines,
588        ));
589    }
590    hunks
591}
592
593#[cfg(test)]
594mod head_tests {
595    use super::*;
596    use crate::tests::fixture;
597    use std::process::Command;
598
599    #[test]
600    fn head_content_probe() {
601        let dir = tempfile::tempdir().unwrap();
602        let root = dir.path();
603        let git = |args: &[&str]| {
604            Command::new("git")
605                .args(args)
606                .current_dir(root)
607                .output()
608                .unwrap();
609        };
610        git(&["init", "-q"]);
611        git(&["config", "user.email", "t@t.t"]);
612        git(&["config", "user.name", "t"]);
613        std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
614        git(&["add", "."]);
615        git(&["commit", "-qm", "init"]);
616        let repo = Repo::discover(root).unwrap();
617        eprintln!("workdir: {:?}", repo.workdir());
618        let abs = root.join("f.rs");
619        eprintln!("abs: {:?} rel: {:?}", abs, repo.rel_path(&abs));
620        eprintln!("head: {:?}", repo.head_content(&abs));
621        assert!(repo.head_content(&abs).is_some());
622    }
623
624    /// 0014 wave 4: the four states are real and separately diffable.
625    #[test]
626    fn four_state_edges() {
627        let (_d, repo, path) = fixture();
628        // worktree edit, stage it, then edit again (live-only)
629        std::fs::write(&path, "fn a() {}\nfn STAGED() {}\nfn c() {}\n").unwrap();
630        let staged = repo
631            .unstaged_hunks(&path, &std::fs::read_to_string(&path).unwrap())
632            .unwrap();
633        assert_eq!(staged.len(), 1);
634        let hunk = staged.into_iter().next().unwrap();
635        repo.stage_hunk(Path::new("f.rs"), &hunk).unwrap();
636        // index now differs from HEAD
637        let idx = repo.index_content(&path).unwrap();
638        assert!(idx.contains("STAGED"));
639        let head = repo.head_content(&path).unwrap();
640        assert!(!head.contains("STAGED"));
641        // staged set: HEAD↔index has the hunk; unstaged (index↔same content) is empty
642        assert_eq!(repo.staged_hunks(&path).unwrap().len(), 1);
643        let wt = std::fs::read_to_string(&path).unwrap();
644        assert!(repo.unstaged_hunks(&path, &wt).unwrap().is_empty());
645        // a further live-only edit shows in the unstaged set only
646        let live = "fn a() {}\nfn STAGED() {}\nfn c() {}\nfn live()\n";
647        let unstaged = repo.unstaged_hunks(&path, live).unwrap();
648        assert_eq!(unstaged.len(), 1);
649        assert!(unstaged[0]
650            .lines
651            .iter()
652            .any(|l| l.text.starts_with(b"fn live")));
653        assert_eq!(
654            repo.staged_hunks(&path).unwrap().len(),
655            1,
656            "staged untouched"
657        );
658        // unstage reverses the edge
659        let staged = repo.staged_hunks(&path).unwrap();
660        repo.unstage_hunk(Path::new("f.rs"), &staged[0]).unwrap();
661        assert!(repo.staged_hunks(&path).unwrap().is_empty());
662        assert!(!repo.index_content(&path).unwrap().contains("STAGED"));
663    }
664}