strop-git 0.28.0

strop git: libgit2 hunks vs live buffers, log/blame/permalinks
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
//! The repository: libgit2 in-process (0001 §3 — no shell-outs on
//! hot paths), hunk staging, content by revision.

use std::path::{Path, PathBuf};

use strop_workspace::RemoteEndpoint;

use crate::diff::{DiffLine, FileDiff, Hunk, HunkKind, LineOrigin};
use crate::target::RepoTarget;

/// Why a repository operation failed — typed, not a string and not an
/// empty Vec standing in for "something went wrong" (R9).
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum GitError {
    /// libgit2 failed underneath (corrupt index, blob read, config…).
    Native(String),
    /// The path is not inside the repository workdir — the caller's
    /// buffer cannot take part in this repository's edges at all.
    OutsideWorkdir,
}

impl std::fmt::Display for GitError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Native(message) => write!(f, "{message}"),
            Self::OutsideWorkdir => write!(f, "path is outside the repository workdir"),
        }
    }
}

/// The pure cached view of a repository (R6): no libgit2 handle, no
/// locks, no IO to read — render and command decisions consult this,
/// while every native read runs on a worker. Equality is meaningful:
/// an unchanged context (same HEAD, branch, remotes) means cached
/// diffs stay valid.
///
/// `repo` is the typed machine boundary (0036 RW8): a local workdir
/// or a remote endpoint's workdir. A remote context is never a valid
/// libgit2 input — callers that need the local repository must match.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GitContext {
    pub repo: RepoTarget,
    pub head_sha: Option<String>,
    pub head_branch: Option<String>,
    /// (name, url) pairs; permalink selection is a pure fold over them.
    pub remotes: Vec<(String, String)>,
}

impl GitContext {
    /// The repository root — a remote path for remote repositories,
    /// valid only on `endpoint()`.
    pub fn workdir(&self) -> &Path {
        self.repo.workdir()
    }

    /// The host whose filesystem this repository lives on, if remote.
    pub fn endpoint(&self) -> Option<&RemoteEndpoint> {
        self.repo.endpoint()
    }

    /// True when this context describes a repository on another
    /// machine: every mutating verb must refuse (RW4), and no local
    /// git call may run against it.
    pub fn is_remote(&self) -> bool {
        self.repo.is_remote()
    }
}

pub struct Repo {
    inner: git2::Repository,
    pub(crate) workdir: PathBuf,
}

impl Repo {
    /// Discover the repository containing `path` (buffer path or cwd).
    pub fn discover(from: &Path) -> Option<Self> {
        let inner = git2::Repository::discover(from).ok()?;
        let workdir = inner.workdir()?.to_path_buf();
        Some(Self { inner, workdir })
    }

    pub fn workdir(&self) -> &Path {
        &self.workdir
    }

    /// Remotes as (name, url) pairs — libgit2 config, no spawn.
    pub fn remotes(&self) -> Vec<(String, String)> {
        let Ok(remotes) = self.inner.remotes() else {
            return vec![];
        };
        remotes
            .iter()
            .flatten()
            .filter_map(|name| {
                self.inner
                    .find_remote(name)
                    .ok()
                    .and_then(|r| r.url().map(|u| (name.to_string(), u.to_string())))
            })
            .collect()
    }

    /// HEAD's full SHA (permalink base — branch always resolves to SHA).
    pub fn head_sha(&self) -> Option<String> {
        Some(
            self.inner
                .head()
                .ok()?
                .peel_to_commit()
                .ok()?
                .id()
                .to_string(),
        )
    }

    /// Current branch (short name; detached HEAD gives the sha prefix).
    pub fn head_branch(&self) -> Option<String> {
        self.inner
            .head()
            .ok()
            .and_then(|h| h.shorthand().map(String::from))
    }

    /// The pure cached view (R6): snapshot HEAD, branch and remotes
    /// once — on a worker — and let render/decisions consult it with
    /// zero native work. An equal context means nothing changed.
    pub fn context(&self) -> GitContext {
        GitContext {
            repo: RepoTarget::Local {
                workdir: self.workdir.clone(),
            },
            head_sha: self.head_sha(),
            head_branch: self.head_branch(),
            remotes: self.remotes(),
        }
    }

    /// Repo-relative path for a buffer path (diff keys are relative).
    fn rel_path(&self, path: &Path) -> Option<PathBuf> {
        let abs = if path.is_absolute() {
            path.to_path_buf()
        } else {
            self.workdir.join(path)
        };
        abs.strip_prefix(&self.workdir)
            .ok()
            .map(|p| p.to_path_buf())
    }

    /// HEAD's content for `path`, if tracked.
    /// HEAD's blob bytes for a repo-relative path (typed, not lossy).
    pub fn head_bytes(&self, rel: &Path) -> Option<Vec<u8>> {
        let commit = self.inner.head().ok()?.peel_to_commit().ok()?;
        let tree = commit.tree().ok()?;
        let entry = tree.get_path(rel).ok()?;
        let blob = self.inner.find_blob(entry.id()).ok()?;
        Some(blob.content().to_vec())
    }

    /// A commit's blob bytes for a repo-relative path.
    pub fn commit_bytes(&self, sha: &str, rel: &Path) -> Option<Vec<u8>> {
        let oid = self.inner.revparse_single(sha).ok()?.id();
        let commit = self.inner.find_commit(oid).ok()?;
        let tree = commit.tree().ok()?;
        let entry = tree.get_path(rel).ok()?;
        let blob = self.inner.find_blob(entry.id()).ok()?;
        Some(blob.content().to_vec())
    }

    /// The index's blob bytes for a repo-relative path (reloads — never
    /// a stale snapshot).
    pub fn index_bytes(&self, rel: &Path) -> Option<Vec<u8>> {
        let mut index = self.inner.index().ok()?;
        index.read(true).ok()?;
        let entry = index.get_path(rel, 0)?;
        let blob = self.inner.find_blob(entry.id).ok()?;
        Some(blob.content().to_vec())
    }

    /// Merge-base oid of two revisions.
    pub fn merge_base(&self, a: &str, b: &str) -> Option<String> {
        let a = self.inner.revparse_single(a).ok()?.id();
        let b = self.inner.revparse_single(b).ok()?.id();
        let base = self.inner.merge_base(a, b).ok()?;
        Some(base.to_string())
    }

    pub fn head_content(&self, path: &Path) -> Option<String> {
        self.head_content_res(path).ok().flatten()
    }

    /// HEAD's content for `path`, distinguishing "not in HEAD's tree"
    /// (Ok(None) — untracked or unborn) from a native failure.
    fn head_content_res(&self, path: &Path) -> Result<Option<String>, GitError> {
        let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
        let head = match self.inner.head() {
            Ok(reference) => reference
                .peel_to_tree()
                .map_err(|e| GitError::Native(format!("read HEAD: {e}")))?,
            // an unborn branch has no commits: HEAD knows nothing —
            // the file is new, not failed
            Err(error) if error.code() == git2::ErrorCode::UnbornBranch => {
                return Ok(None);
            }
            Err(error) => return Err(GitError::Native(format!("read HEAD: {error}"))),
        };
        match head.get_path(&rel) {
            // not in HEAD's tree: untracked — the file is new
            Err(_) => Ok(None),
            Ok(entry) => self.blob_utf8(entry.id(), "HEAD").map(Some),
        }
    }

    /// The index's content for `path` (the staged version), if any.
    pub fn index_content(&self, path: &Path) -> Option<String> {
        self.index_content_res(path).ok().flatten()
    }

    /// The index's content distinguishing "nothing staged" (Ok(None))
    /// from a native failure. Reloads — never a stale snapshot.
    fn index_content_res(&self, path: &Path) -> Result<Option<String>, GitError> {
        let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
        let mut index = self
            .inner
            .index()
            .map_err(|e| GitError::Native(format!("open index: {e}")))?;
        index
            .read(true)
            .map_err(|e| GitError::Native(format!("reload index: {e}")))?;
        match index.get_path(&rel, 0) {
            Some(entry) => self.blob_utf8(entry.id, "index").map(Some),
            None => Ok(None),
        }
    }

    /// One blob's content as UTF-8; the caller names the edge in the
    /// error so failures read "index blob: …", never anonymous.
    fn blob_utf8(&self, id: git2::Oid, edge: &str) -> Result<String, GitError> {
        let blob = self
            .inner
            .find_blob(id)
            .map_err(|e| GitError::Native(format!("{edge} blob: {e}")))?;
        String::from_utf8(blob.content().to_vec())
            .map_err(|_| GitError::Native(format!("{edge} blob is not UTF-8")))
    }

    /// True when neither the index nor HEAD knows `path` — the buffer
    /// is untracked, so hunk undo has nothing to restore from.
    pub fn is_untracked(&self, path: &Path) -> Result<bool, GitError> {
        Ok(self.index_content_res(path)?.is_none() && self.head_content_res(path)?.is_none())
    }

    /// Hunks between HEAD and the index — the STAGED set (0014 wave 4:
    /// the four states are HEAD → index → worktree → live document, and
    /// every command names its edge). Nothing staged is an honest
    /// empty set; an unborn HEAD diffs the index against empty.
    pub fn staged_hunks(&self, path: &Path) -> Result<Vec<Hunk>, GitError> {
        let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
        let Some(index) = self.index_content_res(path)? else {
            return Ok(vec![]);
        };
        let head = self.head_content_res(path)?.unwrap_or_default();
        self.diff_strings(&head, &index, &rel)
    }

    /// Hunks between the index and `content` — the UNSTAGED set (what
    /// the gutter shows while you edit). When nothing is staged this
    /// equals HEAD↔content, matching pre-0.5 behavior. An untracked
    /// file reports one all-add hunk against empty.
    pub fn unstaged_hunks(&self, path: &Path, content: &str) -> Result<Vec<Hunk>, GitError> {
        let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
        let base = match self.index_content_res(path)? {
            Some(index) => Some(index),
            None => self.head_content_res(path)?,
        };
        match base {
            Some(base) => self.diff_strings(&base, content, &rel),
            None => self.hunks(path, content),
        }
    }

    /// Unstage one hunk, STRUCTURED (0018): the staged hunk's new side
    /// is what's in the index; swap that region for the old side.
    pub fn unstage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
        let old_side: Vec<&DiffLine> = hunk
            .lines
            .iter()
            .filter(|l| l.origin != LineOrigin::Addition)
            .collect();
        self.index_region_edit(rel, hunk.new_start, hunk.new_count, &old_side)
    }

    /// Hunks between HEAD and `content` for `path`. Untracked files
    /// report a single all-Add hunk — a useful empty-history case, not
    /// a failure. Native failures are typed, never empty.
    pub fn hunks(&self, path: &Path, content: &str) -> Result<Vec<Hunk>, GitError> {
        let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
        match self.head_content_res(path)? {
            Some(old) => self.diff_strings(&old, content, &rel),
            None => Ok(all_add_hunk(content)),
        }
    }

    fn diff_strings(&self, old: &str, new: &str, rel: &Path) -> Result<Vec<Hunk>, GitError> {
        let mut opts = git2::DiffOptions::new();
        opts.context_lines(3);
        let patch = git2::Patch::from_buffers(
            old.as_bytes(),
            Some(rel),
            new.as_bytes(),
            Some(rel),
            Some(&mut opts),
        )
        .map_err(|e| GitError::Native(format!("diff {rel:?}: {e}")))?;
        Ok(hunks_from_patch(&patch))
    }

    /// One file's diff at `sha` vs its first parent, as structured
    /// hunks. The delta view's data (0010 §1) — libgit2, no shell-out,
    /// no re-parsing our own text.
    pub fn commit_file_diff(&self, sha: &str, path: &Path) -> Result<FileDiff, String> {
        let commit = self
            .inner
            .find_commit(git2::Oid::from_str(sha).map_err(|e| e.to_string())?)
            .map_err(|e| e.to_string())?;
        let new_tree = commit.tree().map_err(|e| e.to_string())?;
        let old_tree = match commit.parent(0) {
            Ok(parent) => Some(parent.tree().map_err(|e| e.to_string())?),
            // root commit: diff against no tree at all
            Err(_) => None,
        };
        let mut opts = git2::DiffOptions::new();
        opts.context_lines(3)
            .pathspec(path)
            .disable_pathspec_match(true)
            .include_unmodified(false);
        let diff = self
            .inner
            .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))
            .map_err(|e| e.to_string())?;
        let mut file = None;
        for (d, _delta) in diff.deltas().enumerate() {
            let Some(patch) = git2::Patch::from_diff(&diff, d).map_err(|e| e.to_string())? else {
                continue; // binary or unrenderable: nothing to show
            };
            file = Some(FileDiff::from_hunks(
                path.to_path_buf(),
                hunks_from_patch(&patch),
            ));
        }
        file.ok_or_else(|| "no diff for path".to_string())
    }

    /// Stage one hunk, STRUCTURED (0018): read the index blob, swap the
    /// hunk's old-side region for its new-side lines, write the blob
    /// back into the index. No patch serialization — path quoting,
    /// CRLF, and missing-final-newline can't go wrong because nothing
    /// is serialized. `rel` is repo-relative.
    pub fn stage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
        let new_side: Vec<&DiffLine> = hunk
            .lines
            .iter()
            .filter(|l| l.origin != LineOrigin::Deletion)
            .collect();
        self.index_region_edit(rel, hunk.old_start, hunk.old_count, &new_side)
    }

    /// Replace 1-based line region [start, start+count) of `rel`'s
    /// INDEX blob with the given lines, byte-precise. With an empty
    /// index entry (untracked file) the region is the whole file.
    fn index_region_edit(
        &self,
        rel: &Path,
        start: usize,
        count: usize,
        new_lines: &[&DiffLine],
    ) -> Result<(), String> {
        let mut index = self.inner.index().map_err(|e| e.to_string())?;
        index.read(true).map_err(|e| e.to_string())?; // never a stale in-memory index
        let entry = index.get_path(rel, 0);
        let (old_bytes, mode) = match entry {
            Some(e) => {
                let blob = self
                    .inner
                    .find_blob(e.id)
                    .map_err(|e| format!("index blob: {e}"))?;
                (blob.content().to_vec(), e.mode)
            }
            None => (Vec::new(), 0o100644), // untracked: stage from empty
        };
        let lines = split_lines_bytes(&old_bytes);
        let lo = start.saturating_sub(1).min(lines.len());
        let hi = (lo + count).min(lines.len());
        let mut out: Vec<u8> = Vec::with_capacity(old_bytes.len() + 64);
        for (text, nl) in &lines[..lo] {
            out.extend_from_slice(text);
            if *nl {
                out.push(b'\n');
            }
        }
        for l in new_lines {
            out.extend_from_slice(&l.bytes_with_terminator());
        }
        for (text, nl) in &lines[hi..] {
            out.extend_from_slice(text);
            if *nl {
                out.push(b'\n');
            }
        }
        let oid = self.inner.blob(&out).map_err(|e| e.to_string())?;
        index
            .add(&git2::IndexEntry {
                ctime: git2::IndexTime::new(0, 0),
                mtime: git2::IndexTime::new(0, 0),
                dev: 0,
                ino: 0,
                mode,
                uid: 0,
                gid: 0,
                file_size: 0,
                id: oid,
                flags: 0,
                flags_extended: 0,
                path: rel.to_string_lossy().replace('\\', "/").into_bytes(),
            })
            .map_err(|e| e.to_string())?;
        index.write().map_err(|e| e.to_string())?;
        Ok(())
    }
}

/// The untracked-file hunk: everything added, against nothing. Empty
/// content is an honest empty set.
fn all_add_hunk(content: &str) -> Vec<Hunk> {
    let count = content.lines().count();
    if count == 0 {
        return vec![];
    }
    vec![Hunk {
        kind: HunkKind::Add,
        new_start: 1,
        new_count: count,
        old_start: 0,
        old_count: 0,
        lines: split_lines_bytes(content.as_bytes())
            .into_iter()
            .enumerate()
            .map(|(i, (text, has_newline))| DiffLine {
                origin: LineOrigin::Addition,
                old_lineno: None,
                new_lineno: Some(i + 1),
                text,
                has_newline,
            })
            .collect(),
    }]
}

/// The gutter's typed hunk sets from the three content states — the
/// shared semantics both backends feed (local libgit2 reads here,
/// bounded remote blob fetches in `remote::gutter`): unstaged =
/// index (or HEAD, or nothing staged)↔live text, staged = HEAD↔index,
/// untracked = neither the index nor HEAD knows the path. Absent
/// states are honest `None`s and behave exactly like the `Repo`
/// methods local buffers use.
pub(crate) fn gutter_from_contents(
    head: Option<&str>,
    index: Option<&str>,
    text: &str,
    rel: &Path,
) -> Result<(Vec<Hunk>, Vec<Hunk>, bool), GitError> {
    let staged = match index {
        Some(index) => hunks_from_strings(head.unwrap_or_default(), index, rel)?,
        None => Vec::new(),
    };
    let unstaged = match index.or(head) {
        Some(base) => hunks_from_strings(base, text, rel)?,
        None => all_add_hunk(text),
    };
    let untracked = index.is_none() && head.is_none();
    Ok((unstaged, staged, untracked))
}

/// Typed hunks between two content strings (libgit2 diffs buffers in
/// memory; no repository handle needed).
fn hunks_from_strings(old: &str, new: &str, rel: &Path) -> Result<Vec<Hunk>, GitError> {
    let mut opts = git2::DiffOptions::new();
    opts.context_lines(3);
    let patch = git2::Patch::from_buffers(
        old.as_bytes(),
        Some(rel),
        new.as_bytes(),
        Some(rel),
        Some(&mut opts),
    )
    .map_err(|e| GitError::Native(format!("diff {rel:?}: {e}")))?;
    Ok(hunks_from_patch(&patch))
}

/// Typed hunks between two blob byte slices — the shared delta
/// builder for the commit view: the local path diffs trees, the
/// remote path fetches the two blobs, and both end here. `old = None`
/// is the empty side (a root commit's delta). Bytes stay bytes:
/// non-UTF-8 content diffs as content, like the local tree path.
pub(crate) fn hunks_from_buffers(
    old: Option<&[u8]>,
    new: &[u8],
    rel: &Path,
) -> Result<Vec<Hunk>, GitError> {
    let mut opts = git2::DiffOptions::new();
    opts.context_lines(3);
    let patch = git2::Patch::from_buffers(
        old.unwrap_or(&[]),
        Some(rel),
        new,
        Some(rel),
        Some(&mut opts),
    )
    .map_err(|e| GitError::Native(format!("diff {rel:?}: {e}")))?;
    Ok(hunks_from_patch(&patch))
}

/// Byte-precise line split: (content-without-terminator, had-newline)
/// pairs. Unlike str::lines, the final unterminated line keeps its
/// identity — staging round-trips a missing trailing newline (0018).
fn split_lines_bytes(bytes: &[u8]) -> Vec<(Vec<u8>, bool)> {
    let mut out = Vec::new();
    let mut start = 0;
    for (i, b) in bytes.iter().enumerate() {
        if *b == b'\n' {
            out.push((bytes[start..i].to_vec(), true));
            start = i + 1;
        }
    }
    if start < bytes.len() {
        out.push((bytes[start..].to_vec(), false));
    }
    out
}

/// Typed hunks from a libgit2 patch — the one place line origins and
/// both sides' 1-based numbers are read off the wire.
fn hunks_from_patch(patch: &git2::Patch) -> Vec<Hunk> {
    let mut hunks = Vec::new();
    for h in 0..patch.num_hunks() {
        let Ok((header, line_count)) = patch.hunk(h) else {
            continue;
        };
        let mut lines = Vec::with_capacity(line_count);
        for l in 0..line_count {
            let Ok(line) = patch.line_in_hunk(h, l) else {
                continue;
            };
            // the "\ No newline at end of file" marker arrives as a
            // Context-origin line (libgit2 quirk) — it's patch
            // metadata, not content; has_newline carries its truth
            let raw = line.content();
            if raw.starts_with(b"\\ No newline") || raw.starts_with(b"\n\\ No newline") {
                continue;
            }
            let origin = match line.origin() {
                '+' => LineOrigin::Addition,
                '-' => LineOrigin::Deletion,
                _ => LineOrigin::Context,
            };
            // libgit2 numbers are 1-based; the absent side is None.
            let old_lineno = line.old_lineno().map(|n| n as usize);
            let new_lineno = line.new_lineno().map(|n| n as usize);
            let content = line.content();
            let (text, has_newline) = match content.last() {
                Some(b'\n') => (&content[..content.len() - 1], true),
                _ => (content, false),
            };
            lines.push(DiffLine {
                origin,
                old_lineno,
                new_lineno,
                text: text.to_vec(),
                has_newline,
            });
        }
        hunks.push(Hunk::build(
            header.old_start() as usize,
            header.old_lines() as usize,
            header.new_start() as usize,
            header.new_lines() as usize,
            lines,
        ));
    }
    hunks
}

#[cfg(test)]
mod head_tests {
    use super::*;
    use crate::tests::fixture;
    use std::process::Command;

    #[test]
    fn head_content_probe() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let git = |args: &[&str]| {
            Command::new("git")
                .args(args)
                .current_dir(root)
                .output()
                .unwrap();
        };
        git(&["init", "-q"]);
        git(&["config", "user.email", "t@t.t"]);
        git(&["config", "user.name", "t"]);
        std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
        git(&["add", "."]);
        git(&["commit", "-qm", "init"]);
        let repo = Repo::discover(root).unwrap();
        eprintln!("workdir: {:?}", repo.workdir());
        let abs = root.join("f.rs");
        eprintln!("abs: {:?} rel: {:?}", abs, repo.rel_path(&abs));
        eprintln!("head: {:?}", repo.head_content(&abs));
        assert!(repo.head_content(&abs).is_some());
    }

    /// 0014 wave 4: the four states are real and separately diffable.
    #[test]
    fn four_state_edges() {
        let (_d, repo, path) = fixture();
        // worktree edit, stage it, then edit again (live-only)
        std::fs::write(&path, "fn a() {}\nfn STAGED() {}\nfn c() {}\n").unwrap();
        let staged = repo
            .unstaged_hunks(&path, &std::fs::read_to_string(&path).unwrap())
            .unwrap();
        assert_eq!(staged.len(), 1);
        let hunk = staged.into_iter().next().unwrap();
        repo.stage_hunk(Path::new("f.rs"), &hunk).unwrap();
        // index now differs from HEAD
        let idx = repo.index_content(&path).unwrap();
        assert!(idx.contains("STAGED"));
        let head = repo.head_content(&path).unwrap();
        assert!(!head.contains("STAGED"));
        // staged set: HEAD↔index has the hunk; unstaged (index↔same content) is empty
        assert_eq!(repo.staged_hunks(&path).unwrap().len(), 1);
        let wt = std::fs::read_to_string(&path).unwrap();
        assert!(repo.unstaged_hunks(&path, &wt).unwrap().is_empty());
        // a further live-only edit shows in the unstaged set only
        let live = "fn a() {}\nfn STAGED() {}\nfn c() {}\nfn live()\n";
        let unstaged = repo.unstaged_hunks(&path, live).unwrap();
        assert_eq!(unstaged.len(), 1);
        assert!(unstaged[0]
            .lines
            .iter()
            .any(|l| l.text.starts_with(b"fn live")));
        assert_eq!(
            repo.staged_hunks(&path).unwrap().len(),
            1,
            "staged untouched"
        );
        // unstage reverses the edge
        let staged = repo.staged_hunks(&path).unwrap();
        repo.unstage_hunk(Path::new("f.rs"), &staged[0]).unwrap();
        assert!(repo.staged_hunks(&path).unwrap().is_empty());
        assert!(!repo.index_content(&path).unwrap().contains("STAGED"));
    }
}