Skip to main content

strop_git/
lib.rs

1//! strop-git: the working surface (0001 pillar 3.1). libgit2 for the hot
2//! paths — no process spawn per keystroke. HEAD vs the *live buffer*
3//! (not the disk file), so gutter signs track unsaved edits.
4
5pub mod memory;
6
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum HunkKind {
11    Add,
12    Change,
13    Delete,
14}
15
16/// Where a diff line comes from — addition/deletion carry which side's
17/// line number applies (0010 §1: typed origins, never `+`-sniffing).
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum LineOrigin {
20    Context,
21    Addition,
22    Deletion,
23}
24
25/// One line of a hunk: content without prefix, plus the 1-based line
26/// number on each side that has one (absent side: `None`, never `0`).
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct DiffLine {
29    pub origin: LineOrigin,
30    pub old_lineno: Option<usize>,
31    pub new_lineno: Option<usize>,
32    pub text: String,
33}
34
35/// One diff hunk between two versions of a file, in 1-based lines.
36#[derive(Debug, Clone)]
37pub struct Hunk {
38    pub kind: HunkKind,
39    /// First affected line in the new version (1-based). For pure
40    /// deletions this is the line *after* which content vanished.
41    pub new_start: usize,
42    pub new_count: usize,
43    pub old_start: usize,
44    pub old_count: usize,
45    pub lines: Vec<DiffLine>,
46}
47
48/// One file's diff at a commit (vs its parent): the delta view's data.
49#[derive(Debug, Clone)]
50pub struct FileDiff {
51    pub path: PathBuf,
52    pub hunks: Vec<Hunk>,
53    pub added: usize,
54    pub deleted: usize,
55}
56
57/// One changed line, for gutter signs. Hunk headers include context
58/// lines, so signs track the +/- lines, not the header range.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Sign {
61    /// Buffer line was added or changed.
62    AddOrChange,
63    /// Buffer line sits right below a deletion (the line number may be
64    /// one past the buffer end for an EOF deletion — clamp on render).
65    DeleteAfter,
66}
67
68impl Hunk {
69    /// Signs this hunk produces, derived from its line origins.
70    pub fn signs(&self) -> Vec<(usize, Sign)> {
71        let mut out = Vec::new();
72        let mut nl = self.new_start;
73        for line in &self.lines {
74            match line.origin {
75                LineOrigin::Addition => {
76                    out.push((nl, Sign::AddOrChange));
77                    nl += 1;
78                }
79                LineOrigin::Deletion => out.push((nl, Sign::DeleteAfter)),
80                LineOrigin::Context => nl += 1,
81            }
82        }
83        out
84    }
85
86    /// The actual changed region (from add/del lines, not the header,
87    /// which includes context): new-side `new_first`/`new_count`
88    /// (1-based) and old-side `old_first`/`old_count`. For pure
89    /// deletions `new_first` is the new line *following* the gap.
90    pub fn changed_region(&self) -> (usize, usize, usize, usize) {
91        let mut nl = self.new_start;
92        let mut ol = self.old_start;
93        let mut new_lines = Vec::new();
94        let mut old_lines = Vec::new();
95        for line in &self.lines {
96            match line.origin {
97                LineOrigin::Addition => {
98                    new_lines.push(nl);
99                    nl += 1;
100                }
101                LineOrigin::Deletion => {
102                    old_lines.push(ol);
103                    ol += 1;
104                }
105                LineOrigin::Context => {
106                    nl += 1;
107                    ol += 1;
108                }
109            }
110        }
111        let new_first = new_lines.first().copied().unwrap_or(nl);
112        let old_first = old_lines.first().copied().unwrap_or(ol);
113        (new_first, new_lines.len(), old_first, old_lines.len())
114    }
115
116    /// Buffer lines covered (signs render on these); `total_lines`
117    /// clamps an EOF deletion onto the last line.
118    pub fn covers(&self, line_1based: usize, total_lines: usize) -> bool {
119        self.signs().iter().any(|&(l, kind)| match kind {
120            Sign::AddOrChange => l == line_1based,
121            Sign::DeleteAfter => l.min(total_lines) == line_1based,
122        })
123    }
124
125    /// The hunk as a unified-diff patch fragment (`git apply` input).
126    /// The prefixed form is derived here — the one place it exists.
127    pub fn to_patch(&self, rel: &Path) -> String {
128        let mut patch = format!("--- a/{}\n+++ b/{}\n", rel.display(), rel.display());
129        patch.push_str(&format!(
130            "@@ -{},{} +{},{} @@\n",
131            self.old_start, self.old_count, self.new_start, self.new_count
132        ));
133        for line in &self.lines {
134            let prefix = match line.origin {
135                LineOrigin::Addition => '+',
136                LineOrigin::Deletion => '-',
137                LineOrigin::Context => ' ',
138            };
139            patch.push(prefix);
140            patch.push_str(&line.text);
141            patch.push('\n');
142        }
143        patch
144    }
145
146    /// The `@@ -a,b +c,d @@` header row as the diff surface shows it.
147    pub fn header(&self) -> String {
148        format!(
149            "@@ -{},{} +{},{} @@",
150            self.old_start, self.old_count, self.new_start, self.new_count
151        )
152    }
153
154    /// Assemble a hunk from its header numbers and typed lines; the
155    /// kind comes from the actual origins — header counts include
156    /// context lines, which would mislabel small-file hunks.
157    pub fn build(
158        old_start: usize,
159        old_count: usize,
160        new_start: usize,
161        new_count: usize,
162        lines: Vec<DiffLine>,
163    ) -> Self {
164        let has_add = lines.iter().any(|l| l.origin == LineOrigin::Addition);
165        let has_del = lines.iter().any(|l| l.origin == LineOrigin::Deletion);
166        let kind = match (has_add, has_del) {
167            (true, false) => HunkKind::Add,
168            (false, true) => HunkKind::Delete,
169            _ => HunkKind::Change,
170        };
171        Hunk {
172            kind,
173            new_start,
174            new_count,
175            old_start,
176            old_count,
177            lines,
178        }
179    }
180}
181
182pub struct Repo {
183    inner: git2::Repository,
184    workdir: PathBuf,
185}
186
187impl Repo {
188    /// Discover the repository containing `path` (buffer path or cwd).
189    pub fn discover(from: &Path) -> Option<Self> {
190        let inner = git2::Repository::discover(from).ok()?;
191        let workdir = inner.workdir()?.to_path_buf();
192        Some(Self { inner, workdir })
193    }
194
195    pub fn workdir(&self) -> &Path {
196        &self.workdir
197    }
198
199    /// Remotes as (name, url) pairs — libgit2 config, no spawn.
200    pub fn remotes(&self) -> Vec<(String, String)> {
201        let Ok(remotes) = self.inner.remotes() else {
202            return vec![];
203        };
204        remotes
205            .iter()
206            .flatten()
207            .filter_map(|name| {
208                self.inner
209                    .find_remote(name)
210                    .ok()
211                    .and_then(|r| r.url().map(|u| (name.to_string(), u.to_string())))
212            })
213            .collect()
214    }
215
216    /// HEAD's full SHA (permalink base — branch always resolves to SHA).
217    pub fn head_sha(&self) -> Option<String> {
218        Some(
219            self.inner
220                .head()
221                .ok()?
222                .peel_to_commit()
223                .ok()?
224                .id()
225                .to_string(),
226        )
227    }
228
229    /// Repo-relative path for a buffer path (diff keys are relative).
230    fn rel_path(&self, path: &Path) -> Option<PathBuf> {
231        let abs = if path.is_absolute() {
232            path.to_path_buf()
233        } else {
234            self.workdir.join(path)
235        };
236        abs.strip_prefix(&self.workdir)
237            .ok()
238            .map(|p| p.to_path_buf())
239    }
240
241    /// HEAD's content for `path`, if tracked.
242    pub fn head_content(&self, path: &Path) -> Option<String> {
243        let rel = self.rel_path(path)?;
244        let head = self.inner.head().ok()?.peel_to_tree().ok()?;
245        let entry = head.get_path(&rel).ok()?;
246        let blob = self.inner.find_blob(entry.id()).ok()?;
247        String::from_utf8(blob.content().to_vec()).ok()
248    }
249
250    /// Hunks between HEAD and `content` for `path`. Untracked files
251    /// report a single all-Add hunk.
252    pub fn hunks(&self, path: &Path, content: &str) -> Vec<Hunk> {
253        let Some(rel) = self.rel_path(path) else {
254            return vec![];
255        };
256        let old = self.head_content(path);
257        match old {
258            None => {
259                let count = content.lines().count();
260                if count == 0 {
261                    return vec![];
262                }
263                vec![Hunk {
264                    kind: HunkKind::Add,
265                    new_start: 1,
266                    new_count: count,
267                    old_start: 0,
268                    old_count: 0,
269                    lines: content
270                        .lines()
271                        .enumerate()
272                        .map(|(i, l)| DiffLine {
273                            origin: LineOrigin::Addition,
274                            old_lineno: None,
275                            new_lineno: Some(i + 1),
276                            text: l.to_string(),
277                        })
278                        .collect(),
279                }]
280            }
281            Some(old) => self.diff_strings(&old, content, &rel),
282        }
283    }
284
285    fn diff_strings(&self, old: &str, new: &str, rel: &Path) -> Vec<Hunk> {
286        let mut opts = git2::DiffOptions::new();
287        opts.context_lines(3);
288        let Ok(patch) = git2::Patch::from_buffers(
289            old.as_bytes(),
290            Some(rel),
291            new.as_bytes(),
292            Some(rel),
293            Some(&mut opts),
294        ) else {
295            return vec![];
296        };
297        hunks_from_patch(&patch)
298    }
299
300    /// One file's diff at `sha` vs its first parent, as structured
301    /// hunks. The delta view's data (0010 §1) — libgit2, no shell-out,
302    /// no re-parsing our own text.
303    pub fn commit_file_diff(&self, sha: &str, path: &Path) -> Result<FileDiff, String> {
304        let commit = self
305            .inner
306            .find_commit(git2::Oid::from_str(sha).map_err(|e| e.to_string())?)
307            .map_err(|e| e.to_string())?;
308        let new_tree = commit.tree().map_err(|e| e.to_string())?;
309        let old_tree = match commit.parent(0) {
310            Ok(parent) => Some(parent.tree().map_err(|e| e.to_string())?),
311            // root commit: diff against no tree at all
312            Err(_) => None,
313        };
314        let mut opts = git2::DiffOptions::new();
315        opts.context_lines(3)
316            .pathspec(path)
317            .include_unmodified(false);
318        let diff = self
319            .inner
320            .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))
321            .map_err(|e| e.to_string())?;
322        let mut file = None;
323        for (d, _delta) in diff.deltas().enumerate() {
324            let Some(patch) = git2::Patch::from_diff(&diff, d).map_err(|e| e.to_string())? else {
325                continue; // binary or unrenderable: nothing to show
326            };
327            let hunks = hunks_from_patch(&patch);
328            let added = hunks
329                .iter()
330                .flat_map(|h| &h.lines)
331                .filter(|l| l.origin == LineOrigin::Addition)
332                .count();
333            let deleted = hunks
334                .iter()
335                .flat_map(|h| &h.lines)
336                .filter(|l| l.origin == LineOrigin::Deletion)
337                .count();
338            file = Some(FileDiff {
339                path: path.to_path_buf(),
340                hunks,
341                added,
342                deleted,
343            });
344        }
345        file.ok_or_else(|| "no diff for path".to_string())
346    }
347
348    /// Stage one hunk. Prototype path: synthesize a single-hunk patch and
349    /// `git apply --cached` it (shell git is the write path per 0001 §3;
350    /// libgit2 owns the read hot paths). `rel` is repo-relative.
351    pub fn stage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
352        let patch = hunk.to_patch(rel);
353        let mut child = std::process::Command::new("git")
354            .args([
355                "-C",
356                &self.workdir.display().to_string(),
357                "apply",
358                "--cached",
359                "--unidiff-zero",
360            ])
361            .stdin(std::process::Stdio::piped())
362            .stdout(std::process::Stdio::null())
363            .stderr(std::process::Stdio::piped())
364            .spawn()
365            .map_err(|e| format!("spawn git: {e}"))?;
366        use std::io::Write;
367        child
368            .stdin
369            .as_mut()
370            .expect("piped")
371            .write_all(patch.as_bytes())
372            .map_err(|e| e.to_string())?;
373        let out = child.wait_with_output().map_err(|e| e.to_string())?;
374        if out.status.success() {
375            Ok(())
376        } else {
377            Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
378        }
379    }
380}
381
382/// Typed hunks from a libgit2 patch — the one place line origins and
383/// both sides' 1-based numbers are read off the wire.
384fn hunks_from_patch(patch: &git2::Patch) -> Vec<Hunk> {
385    let mut hunks = Vec::new();
386    for h in 0..patch.num_hunks() {
387        let Ok((header, line_count)) = patch.hunk(h) else {
388            continue;
389        };
390        let mut lines = Vec::with_capacity(line_count);
391        for l in 0..line_count {
392            let Ok(line) = patch.line_in_hunk(h, l) else {
393                continue;
394            };
395            let origin = match line.origin() {
396                '+' => LineOrigin::Addition,
397                '-' => LineOrigin::Deletion,
398                _ => LineOrigin::Context,
399            };
400            // libgit2 numbers are 1-based; the absent side is None.
401            let old_lineno = line.old_lineno().map(|n| n as usize);
402            let new_lineno = line.new_lineno().map(|n| n as usize);
403            let text = String::from_utf8_lossy(line.content())
404                .trim_end_matches('\n')
405                .to_string();
406            lines.push(DiffLine {
407                origin,
408                old_lineno,
409                new_lineno,
410                text,
411            });
412        }
413        hunks.push(Hunk::build(
414            header.old_start() as usize,
415            header.old_lines() as usize,
416            header.new_start() as usize,
417            header.new_lines() as usize,
418            lines,
419        ));
420    }
421    hunks
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use std::process::Command;
428
429    fn git(root: &std::path::Path, args: &[&str]) {
430        Command::new("git")
431            .args(args)
432            .current_dir(root)
433            .output()
434            .unwrap();
435    }
436
437    fn fixture() -> (tempfile::TempDir, Repo, PathBuf) {
438        let dir = tempfile::tempdir().unwrap();
439        let root = dir.path();
440        git(root, &["init", "-q"]);
441        git(root, &["config", "user.email", "t@t.t"]);
442        git(root, &["config", "user.name", "t"]);
443        std::fs::write(root.join("f.rs"), "fn a() {}\nfn b() {}\nfn c() {}\n").unwrap();
444        git(root, &["add", "."]);
445        git(root, &["commit", "-qm", "init"]);
446        let repo = Repo::discover(root).unwrap();
447        let file = root.join("f.rs");
448        (dir, repo, file)
449    }
450
451    #[test]
452    fn clean_buffer_has_no_hunks() {
453        let (_d, repo, path) = fixture();
454        let content = repo.head_content(&path).unwrap();
455        assert!(repo.hunks(&path, &content).is_empty());
456    }
457
458    #[test]
459    fn change_and_add_and_delete() {
460        let (_d, repo, path) = fixture();
461        let edited = "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n";
462        let hunks = repo.hunks(&path, edited);
463        assert_eq!(hunks.len(), 1);
464        assert_eq!(hunks[0].kind, HunkKind::Change);
465        assert!(hunks[0].covers(2, 4));
466        assert!(hunks[0].covers(4, 4));
467        assert!(!hunks[0].covers(1, 4));
468        assert!(hunks[0]
469            .lines
470            .iter()
471            .any(|l| l.origin == LineOrigin::Addition && l.text.starts_with("fn d")));
472    }
473
474    /// The typed structure carries both sides' 1-based numbers: the
475    /// renderer never guesses them from text (0010 §1).
476    #[test]
477    fn line_numbers_track_both_sides() {
478        let (_d, repo, path) = fixture();
479        let edited = "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n";
480        let hunks = repo.hunks(&path, edited);
481        assert_eq!(hunks.len(), 1);
482        let h = &hunks[0];
483        let ctx = h
484            .lines
485            .iter()
486            .find(|l| l.origin == LineOrigin::Context)
487            .unwrap();
488        assert_eq!(
489            (ctx.old_lineno, ctx.new_lineno),
490            (Some(1), Some(1)),
491            "context lines carry both numbers, 1-based"
492        );
493        let add = h
494            .lines
495            .iter()
496            .find(|l| l.origin == LineOrigin::Addition && l.text.starts_with("fn d"))
497            .unwrap();
498        assert_eq!((add.old_lineno, add.new_lineno), (None, Some(4)));
499        let del = h
500            .lines
501            .iter()
502            .find(|l| l.origin == LineOrigin::Deletion)
503            .unwrap();
504        assert_eq!((del.old_lineno, del.new_lineno), (Some(2), None));
505    }
506
507    #[test]
508    fn pure_delete_marks_following_line() {
509        let (_d, repo, path) = fixture();
510        let edited = "fn a() {}\nfn c() {}\n";
511        let hunks = repo.hunks(&path, edited);
512        assert_eq!(hunks.len(), 1);
513        assert_eq!(hunks[0].kind, HunkKind::Delete);
514        assert!(hunks[0].covers(2, 4)); // sign on the line after the gap
515    }
516
517    #[test]
518    fn stage_hunk_applies_to_index() {
519        let (_d, repo, path) = fixture();
520        let edited = "fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n";
521        let hunks = repo.hunks(&path, edited);
522        assert_eq!(hunks.len(), 1);
523        assert_eq!(hunks[0].kind, HunkKind::Add);
524        let root = repo.workdir.clone();
525        repo.stage_hunk(Path::new("f.rs"), &hunks[0]).unwrap();
526        let out = Command::new("git")
527            .args([
528                "-C",
529                &root.display().to_string(),
530                "diff",
531                "--cached",
532                "--stat",
533            ])
534            .output()
535            .unwrap();
536        let stat = String::from_utf8_lossy(&out.stdout);
537        assert!(stat.contains("f.rs"), "{stat}");
538    }
539
540    /// `to_patch` is real `git apply` input: the prefixed form exists
541    /// only here, derived from typed origins.
542    #[test]
543    fn to_patch_is_applyable() {
544        let (_d, repo, path) = fixture();
545        let edited = "fn a() {}\nfn b2() {}\nfn c() {}\n";
546        let hunks = repo.hunks(&path, edited);
547        assert_eq!(hunks.len(), 1);
548        let patch = hunks[0].to_patch(Path::new("f.rs"));
549        assert!(
550            patch.starts_with("--- a/f.rs\n+++ b/f.rs\n@@ -1,3 +1,3 @@\n"),
551            "{patch}"
552        );
553        assert!(patch.contains("-fn b() {}\n+fn b2() {}\n"), "{patch}");
554        assert!(patch.ends_with(" fn c() {}\n"), "{patch}");
555    }
556
557    /// The commit delta view's data: structured hunks at a SHA, via
558    /// libgit2 — the `git show` shell-out replacement.
559    #[test]
560    fn commit_file_diff_is_structured() {
561        let (_d, repo, path) = fixture();
562        let root = repo.workdir.clone();
563        std::fs::write(root.join("f.rs"), "fn a() {}\nfn b2() {}\nfn c() {}\n").unwrap();
564        git(&root, &["add", "."]);
565        git(&root, &["commit", "-qm", "change b"]);
566        let sha = String::from_utf8_lossy(
567            &Command::new("git")
568                .args(["-C", &root.display().to_string(), "rev-parse", "HEAD"])
569                .output()
570                .unwrap()
571                .stdout,
572        )
573        .trim()
574        .to_string();
575        let diff = repo.commit_file_diff(&sha, Path::new("f.rs")).unwrap();
576        assert_eq!(diff.added, 1);
577        assert_eq!(diff.deleted, 1);
578        assert_eq!(diff.hunks.len(), 1);
579        assert_eq!(diff.hunks[0].kind, HunkKind::Change);
580        assert!(diff.hunks[0].lines.iter().any(|l| l.text == "fn b2() {}"));
581        let _ = path;
582    }
583
584    /// Root commits diff against the empty tree: the init commit shows
585    /// as one all-addition hunk, not an error.
586    #[test]
587    fn commit_file_diff_root_commit() {
588        let (_d, repo, _path) = fixture();
589        let root = repo.workdir.clone();
590        let sha = String::from_utf8_lossy(
591            &Command::new("git")
592                .args(["-C", &root.display().to_string(), "rev-parse", "HEAD"])
593                .output()
594                .unwrap()
595                .stdout,
596        )
597        .trim()
598        .to_string();
599        let diff = repo.commit_file_diff(&sha, Path::new("f.rs")).unwrap();
600        assert_eq!(diff.added, 3);
601        assert_eq!(diff.deleted, 0);
602        assert!(diff
603            .hunks
604            .iter()
605            .all(|h| h.lines.iter().all(|l| l.old_lineno.is_none())));
606    }
607}
608
609#[cfg(test)]
610mod head_tests {
611    use super::*;
612    use std::process::Command;
613
614    #[test]
615    fn head_content_probe() {
616        let dir = tempfile::tempdir().unwrap();
617        let root = dir.path();
618        let git = |args: &[&str]| {
619            Command::new("git")
620                .args(args)
621                .current_dir(root)
622                .output()
623                .unwrap();
624        };
625        git(&["init", "-q"]);
626        git(&["config", "user.email", "t@t.t"]);
627        git(&["config", "user.name", "t"]);
628        std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
629        git(&["add", "."]);
630        git(&["commit", "-qm", "init"]);
631        let repo = Repo::discover(root).unwrap();
632        eprintln!("workdir: {:?}", repo.workdir());
633        let abs = root.join("f.rs");
634        eprintln!("abs: {:?} rel: {:?}", abs, repo.rel_path(&abs));
635        eprintln!("head: {:?}", repo.head_content(&abs));
636        assert!(repo.head_content(&abs).is_some());
637    }
638}