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