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