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            .disable_pathspec_match(true)
316            .include_unmodified(false);
317        let diff = self
318            .inner
319            .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))
320            .map_err(|e| e.to_string())?;
321        let mut file = None;
322        for (d, _delta) in diff.deltas().enumerate() {
323            let Some(patch) = git2::Patch::from_diff(&diff, d).map_err(|e| e.to_string())? else {
324                continue; // binary or unrenderable: nothing to show
325            };
326            let hunks = hunks_from_patch(&patch);
327            let added = hunks
328                .iter()
329                .flat_map(|h| &h.lines)
330                .filter(|l| l.origin == LineOrigin::Addition)
331                .count();
332            let deleted = hunks
333                .iter()
334                .flat_map(|h| &h.lines)
335                .filter(|l| l.origin == LineOrigin::Deletion)
336                .count();
337            file = Some(FileDiff {
338                path: path.to_path_buf(),
339                hunks,
340                added,
341                deleted,
342            });
343        }
344        file.ok_or_else(|| "no diff for path".to_string())
345    }
346
347    /// Stage one hunk, STRUCTURED (0018): read the index blob, swap the
348    /// hunk's old-side region for its new-side lines, write the blob
349    /// back into the index. No patch serialization — path quoting,
350    /// CRLF, and missing-final-newline can't go wrong because nothing
351    /// is serialized. `rel` is repo-relative.
352    pub fn stage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
353        let new_side: Vec<&DiffLine> = hunk
354            .lines
355            .iter()
356            .filter(|l| l.origin != LineOrigin::Deletion)
357            .collect();
358        self.index_region_edit(rel, hunk.old_start, hunk.old_count, &new_side)
359    }
360
361    /// Replace 1-based line region [start, start+count) of `rel`'s
362    /// INDEX blob with the given lines, byte-precise. With an empty
363    /// index entry (untracked file) the region is the whole file.
364    fn index_region_edit(
365        &self,
366        rel: &Path,
367        start: usize,
368        count: usize,
369        new_lines: &[&DiffLine],
370    ) -> Result<(), String> {
371        let mut index = self.inner.index().map_err(|e| e.to_string())?;
372        index.read(true).map_err(|e| e.to_string())?; // never a stale in-memory index
373        let entry = index.get_path(rel, 0);
374        let (old_bytes, mode) = match entry {
375            Some(e) => {
376                let blob = self
377                    .inner
378                    .find_blob(e.id)
379                    .map_err(|e| format!("index blob: {e}"))?;
380                (blob.content().to_vec(), e.mode)
381            }
382            None => (Vec::new(), 0o100644), // untracked: stage from empty
383        };
384        let lines = split_lines_bytes(&old_bytes);
385        let lo = start.saturating_sub(1).min(lines.len());
386        let hi = (lo + count).min(lines.len());
387        let mut out: Vec<u8> = Vec::with_capacity(old_bytes.len() + 64);
388        for (text, nl) in &lines[..lo] {
389            out.extend_from_slice(text);
390            if *nl {
391                out.push(b'\n');
392            }
393        }
394        for l in new_lines {
395            out.extend_from_slice(&l.bytes_with_terminator());
396        }
397        for (text, nl) in &lines[hi..] {
398            out.extend_from_slice(text);
399            if *nl {
400                out.push(b'\n');
401            }
402        }
403        let oid = self.inner.blob(&out).map_err(|e| e.to_string())?;
404        index
405            .add(&git2::IndexEntry {
406                ctime: git2::IndexTime::new(0, 0),
407                mtime: git2::IndexTime::new(0, 0),
408                dev: 0,
409                ino: 0,
410                mode,
411                uid: 0,
412                gid: 0,
413                file_size: 0,
414                id: oid,
415                flags: 0,
416                flags_extended: 0,
417                path: rel.to_string_lossy().replace('\\', "/").into_bytes(),
418            })
419            .map_err(|e| e.to_string())?;
420        index.write().map_err(|e| e.to_string())?;
421        Ok(())
422    }
423}
424
425/// The untracked-file hunk: everything added, against nothing. Empty
426/// content is an honest empty set.
427fn all_add_hunk(content: &str) -> Vec<Hunk> {
428    let count = content.lines().count();
429    if count == 0 {
430        return vec![];
431    }
432    vec![Hunk {
433        kind: HunkKind::Add,
434        new_start: 1,
435        new_count: count,
436        old_start: 0,
437        old_count: 0,
438        lines: split_lines_bytes(content.as_bytes())
439            .into_iter()
440            .enumerate()
441            .map(|(i, (text, has_newline))| DiffLine {
442                origin: LineOrigin::Addition,
443                old_lineno: None,
444                new_lineno: Some(i + 1),
445                text,
446                has_newline,
447            })
448            .collect(),
449    }]
450}
451
452/// Byte-precise line split: (content-without-terminator, had-newline)
453/// pairs. Unlike str::lines, the final unterminated line keeps its
454/// identity — staging round-trips a missing trailing newline (0018).
455fn split_lines_bytes(bytes: &[u8]) -> Vec<(Vec<u8>, bool)> {
456    let mut out = Vec::new();
457    let mut start = 0;
458    for (i, b) in bytes.iter().enumerate() {
459        if *b == b'\n' {
460            out.push((bytes[start..i].to_vec(), true));
461            start = i + 1;
462        }
463    }
464    if start < bytes.len() {
465        out.push((bytes[start..].to_vec(), false));
466    }
467    out
468}
469
470/// Typed hunks from a libgit2 patch — the one place line origins and
471/// both sides' 1-based numbers are read off the wire.
472fn hunks_from_patch(patch: &git2::Patch) -> Vec<Hunk> {
473    let mut hunks = Vec::new();
474    for h in 0..patch.num_hunks() {
475        let Ok((header, line_count)) = patch.hunk(h) else {
476            continue;
477        };
478        let mut lines = Vec::with_capacity(line_count);
479        for l in 0..line_count {
480            let Ok(line) = patch.line_in_hunk(h, l) else {
481                continue;
482            };
483            // the "\ No newline at end of file" marker arrives as a
484            // Context-origin line (libgit2 quirk) — it's patch
485            // metadata, not content; has_newline carries its truth
486            let raw = line.content();
487            if raw.starts_with(b"\\ No newline") || raw.starts_with(b"\n\\ No newline") {
488                continue;
489            }
490            let origin = match line.origin() {
491                '+' => LineOrigin::Addition,
492                '-' => LineOrigin::Deletion,
493                _ => LineOrigin::Context,
494            };
495            // libgit2 numbers are 1-based; the absent side is None.
496            let old_lineno = line.old_lineno().map(|n| n as usize);
497            let new_lineno = line.new_lineno().map(|n| n as usize);
498            let content = line.content();
499            let (text, has_newline) = match content.last() {
500                Some(b'\n') => (&content[..content.len() - 1], true),
501                _ => (content, false),
502            };
503            lines.push(DiffLine {
504                origin,
505                old_lineno,
506                new_lineno,
507                text: text.to_vec(),
508                has_newline,
509            });
510        }
511        hunks.push(Hunk::build(
512            header.old_start() as usize,
513            header.old_lines() as usize,
514            header.new_start() as usize,
515            header.new_lines() as usize,
516            lines,
517        ));
518    }
519    hunks
520}
521
522#[cfg(test)]
523mod head_tests {
524    use super::*;
525    use crate::tests::fixture;
526    use std::process::Command;
527
528    #[test]
529    fn head_content_probe() {
530        let dir = tempfile::tempdir().unwrap();
531        let root = dir.path();
532        let git = |args: &[&str]| {
533            Command::new("git")
534                .args(args)
535                .current_dir(root)
536                .output()
537                .unwrap();
538        };
539        git(&["init", "-q"]);
540        git(&["config", "user.email", "t@t.t"]);
541        git(&["config", "user.name", "t"]);
542        std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
543        git(&["add", "."]);
544        git(&["commit", "-qm", "init"]);
545        let repo = Repo::discover(root).unwrap();
546        eprintln!("workdir: {:?}", repo.workdir());
547        let abs = root.join("f.rs");
548        eprintln!("abs: {:?} rel: {:?}", abs, repo.rel_path(&abs));
549        eprintln!("head: {:?}", repo.head_content(&abs));
550        assert!(repo.head_content(&abs).is_some());
551    }
552
553    /// 0014 wave 4: the four states are real and separately diffable.
554    #[test]
555    fn four_state_edges() {
556        let (_d, repo, path) = fixture();
557        // worktree edit, stage it, then edit again (live-only)
558        std::fs::write(&path, "fn a() {}\nfn STAGED() {}\nfn c() {}\n").unwrap();
559        let staged = repo
560            .unstaged_hunks(&path, &std::fs::read_to_string(&path).unwrap())
561            .unwrap();
562        assert_eq!(staged.len(), 1);
563        let hunk = staged.into_iter().next().unwrap();
564        repo.stage_hunk(Path::new("f.rs"), &hunk).unwrap();
565        // index now differs from HEAD
566        let idx = repo.index_content(&path).unwrap();
567        assert!(idx.contains("STAGED"));
568        let head = repo.head_content(&path).unwrap();
569        assert!(!head.contains("STAGED"));
570        // staged set: HEAD↔index has the hunk; unstaged (index↔same content) is empty
571        assert_eq!(repo.staged_hunks(&path).unwrap().len(), 1);
572        let wt = std::fs::read_to_string(&path).unwrap();
573        assert!(repo.unstaged_hunks(&path, &wt).unwrap().is_empty());
574        // a further live-only edit shows in the unstaged set only
575        let live = "fn a() {}\nfn STAGED() {}\nfn c() {}\nfn live()\n";
576        let unstaged = repo.unstaged_hunks(&path, live).unwrap();
577        assert_eq!(unstaged.len(), 1);
578        assert!(unstaged[0]
579            .lines
580            .iter()
581            .any(|l| l.text.starts_with(b"fn live")));
582        assert_eq!(
583            repo.staged_hunks(&path).unwrap().len(),
584            1,
585            "staged untouched"
586        );
587        // unstage reverses the edge
588        let staged = repo.staged_hunks(&path).unwrap();
589        repo.unstage_hunk(Path::new("f.rs"), &staged[0]).unwrap();
590        assert!(repo.staged_hunks(&path).unwrap().is_empty());
591        assert!(!repo.index_content(&path).unwrap().contains("STAGED"));
592    }
593}