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    /// Current branch (short name; detached HEAD gives the sha prefix).
230    pub fn head_branch(&self) -> Option<String> {
231        self.inner
232            .head()
233            .ok()
234            .and_then(|h| h.shorthand().map(String::from))
235    }
236
237    /// Repo-relative path for a buffer path (diff keys are relative).
238    fn rel_path(&self, path: &Path) -> Option<PathBuf> {
239        let abs = if path.is_absolute() {
240            path.to_path_buf()
241        } else {
242            self.workdir.join(path)
243        };
244        abs.strip_prefix(&self.workdir)
245            .ok()
246            .map(|p| p.to_path_buf())
247    }
248
249    /// HEAD's content for `path`, if tracked.
250    pub fn head_content(&self, path: &Path) -> Option<String> {
251        let rel = self.rel_path(path)?;
252        let head = self.inner.head().ok()?.peel_to_tree().ok()?;
253        let entry = head.get_path(&rel).ok()?;
254        let blob = self.inner.find_blob(entry.id()).ok()?;
255        String::from_utf8(blob.content().to_vec()).ok()
256    }
257
258    /// Hunks between HEAD and `content` for `path`. Untracked files
259    /// report a single all-Add hunk.
260    pub fn hunks(&self, path: &Path, content: &str) -> Vec<Hunk> {
261        let Some(rel) = self.rel_path(path) else {
262            return vec![];
263        };
264        let old = self.head_content(path);
265        match old {
266            None => {
267                let count = content.lines().count();
268                if count == 0 {
269                    return vec![];
270                }
271                vec![Hunk {
272                    kind: HunkKind::Add,
273                    new_start: 1,
274                    new_count: count,
275                    old_start: 0,
276                    old_count: 0,
277                    lines: content
278                        .lines()
279                        .enumerate()
280                        .map(|(i, l)| DiffLine {
281                            origin: LineOrigin::Addition,
282                            old_lineno: None,
283                            new_lineno: Some(i + 1),
284                            text: l.to_string(),
285                        })
286                        .collect(),
287                }]
288            }
289            Some(old) => self.diff_strings(&old, content, &rel),
290        }
291    }
292
293    fn diff_strings(&self, old: &str, new: &str, rel: &Path) -> Vec<Hunk> {
294        let mut opts = git2::DiffOptions::new();
295        opts.context_lines(3);
296        let Ok(patch) = git2::Patch::from_buffers(
297            old.as_bytes(),
298            Some(rel),
299            new.as_bytes(),
300            Some(rel),
301            Some(&mut opts),
302        ) else {
303            return vec![];
304        };
305        hunks_from_patch(&patch)
306    }
307
308    /// One file's diff at `sha` vs its first parent, as structured
309    /// hunks. The delta view's data (0010 §1) — libgit2, no shell-out,
310    /// no re-parsing our own text.
311    pub fn commit_file_diff(&self, sha: &str, path: &Path) -> Result<FileDiff, String> {
312        let commit = self
313            .inner
314            .find_commit(git2::Oid::from_str(sha).map_err(|e| e.to_string())?)
315            .map_err(|e| e.to_string())?;
316        let new_tree = commit.tree().map_err(|e| e.to_string())?;
317        let old_tree = match commit.parent(0) {
318            Ok(parent) => Some(parent.tree().map_err(|e| e.to_string())?),
319            // root commit: diff against no tree at all
320            Err(_) => None,
321        };
322        let mut opts = git2::DiffOptions::new();
323        opts.context_lines(3)
324            .pathspec(path)
325            .include_unmodified(false);
326        let diff = self
327            .inner
328            .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))
329            .map_err(|e| e.to_string())?;
330        let mut file = None;
331        for (d, _delta) in diff.deltas().enumerate() {
332            let Some(patch) = git2::Patch::from_diff(&diff, d).map_err(|e| e.to_string())? else {
333                continue; // binary or unrenderable: nothing to show
334            };
335            let hunks = hunks_from_patch(&patch);
336            let added = hunks
337                .iter()
338                .flat_map(|h| &h.lines)
339                .filter(|l| l.origin == LineOrigin::Addition)
340                .count();
341            let deleted = hunks
342                .iter()
343                .flat_map(|h| &h.lines)
344                .filter(|l| l.origin == LineOrigin::Deletion)
345                .count();
346            file = Some(FileDiff {
347                path: path.to_path_buf(),
348                hunks,
349                added,
350                deleted,
351            });
352        }
353        file.ok_or_else(|| "no diff for path".to_string())
354    }
355
356    /// Stage one hunk. Prototype path: synthesize a single-hunk patch and
357    /// `git apply --cached` it (shell git is the write path per 0001 §3;
358    /// libgit2 owns the read hot paths). `rel` is repo-relative.
359    pub fn stage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
360        let patch = hunk.to_patch(rel);
361        let mut child = std::process::Command::new("git")
362            .args([
363                "-C",
364                &self.workdir.display().to_string(),
365                "apply",
366                "--cached",
367                "--unidiff-zero",
368            ])
369            .stdin(std::process::Stdio::piped())
370            .stdout(std::process::Stdio::null())
371            .stderr(std::process::Stdio::piped())
372            .spawn()
373            .map_err(|e| format!("spawn git: {e}"))?;
374        use std::io::Write;
375        child
376            .stdin
377            .as_mut()
378            .expect("piped")
379            .write_all(patch.as_bytes())
380            .map_err(|e| e.to_string())?;
381        let out = child.wait_with_output().map_err(|e| e.to_string())?;
382        if out.status.success() {
383            Ok(())
384        } else {
385            Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
386        }
387    }
388}
389
390/// Typed hunks from a libgit2 patch — the one place line origins and
391/// both sides' 1-based numbers are read off the wire.
392fn hunks_from_patch(patch: &git2::Patch) -> Vec<Hunk> {
393    let mut hunks = Vec::new();
394    for h in 0..patch.num_hunks() {
395        let Ok((header, line_count)) = patch.hunk(h) else {
396            continue;
397        };
398        let mut lines = Vec::with_capacity(line_count);
399        for l in 0..line_count {
400            let Ok(line) = patch.line_in_hunk(h, l) else {
401                continue;
402            };
403            let origin = match line.origin() {
404                '+' => LineOrigin::Addition,
405                '-' => LineOrigin::Deletion,
406                _ => LineOrigin::Context,
407            };
408            // libgit2 numbers are 1-based; the absent side is None.
409            let old_lineno = line.old_lineno().map(|n| n as usize);
410            let new_lineno = line.new_lineno().map(|n| n as usize);
411            let text = String::from_utf8_lossy(line.content())
412                .trim_end_matches('\n')
413                .to_string();
414            lines.push(DiffLine {
415                origin,
416                old_lineno,
417                new_lineno,
418                text,
419            });
420        }
421        hunks.push(Hunk::build(
422            header.old_start() as usize,
423            header.old_lines() as usize,
424            header.new_start() as usize,
425            header.new_lines() as usize,
426            lines,
427        ));
428    }
429    hunks
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use std::process::Command;
436
437    fn git(root: &std::path::Path, args: &[&str]) {
438        Command::new("git")
439            .args(args)
440            .current_dir(root)
441            .output()
442            .unwrap();
443    }
444
445    fn fixture() -> (tempfile::TempDir, Repo, PathBuf) {
446        let dir = tempfile::tempdir().unwrap();
447        let root = dir.path();
448        git(root, &["init", "-q"]);
449        git(root, &["config", "user.email", "t@t.t"]);
450        git(root, &["config", "user.name", "t"]);
451        std::fs::write(root.join("f.rs"), "fn a() {}\nfn b() {}\nfn c() {}\n").unwrap();
452        git(root, &["add", "."]);
453        git(root, &["commit", "-qm", "init"]);
454        let repo = Repo::discover(root).unwrap();
455        let file = root.join("f.rs");
456        (dir, repo, file)
457    }
458
459    #[test]
460    fn clean_buffer_has_no_hunks() {
461        let (_d, repo, path) = fixture();
462        let content = repo.head_content(&path).unwrap();
463        assert!(repo.hunks(&path, &content).is_empty());
464    }
465
466    #[test]
467    fn change_and_add_and_delete() {
468        let (_d, repo, path) = fixture();
469        let edited = "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n";
470        let hunks = repo.hunks(&path, edited);
471        assert_eq!(hunks.len(), 1);
472        assert_eq!(hunks[0].kind, HunkKind::Change);
473        assert!(hunks[0].covers(2, 4));
474        assert!(hunks[0].covers(4, 4));
475        assert!(!hunks[0].covers(1, 4));
476        assert!(hunks[0]
477            .lines
478            .iter()
479            .any(|l| l.origin == LineOrigin::Addition && l.text.starts_with("fn d")));
480    }
481
482    /// The typed structure carries both sides' 1-based numbers: the
483    /// renderer never guesses them from text (0010 §1).
484    #[test]
485    fn line_numbers_track_both_sides() {
486        let (_d, repo, path) = fixture();
487        let edited = "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n";
488        let hunks = repo.hunks(&path, edited);
489        assert_eq!(hunks.len(), 1);
490        let h = &hunks[0];
491        let ctx = h
492            .lines
493            .iter()
494            .find(|l| l.origin == LineOrigin::Context)
495            .unwrap();
496        assert_eq!(
497            (ctx.old_lineno, ctx.new_lineno),
498            (Some(1), Some(1)),
499            "context lines carry both numbers, 1-based"
500        );
501        let add = h
502            .lines
503            .iter()
504            .find(|l| l.origin == LineOrigin::Addition && l.text.starts_with("fn d"))
505            .unwrap();
506        assert_eq!((add.old_lineno, add.new_lineno), (None, Some(4)));
507        let del = h
508            .lines
509            .iter()
510            .find(|l| l.origin == LineOrigin::Deletion)
511            .unwrap();
512        assert_eq!((del.old_lineno, del.new_lineno), (Some(2), None));
513    }
514
515    #[test]
516    fn pure_delete_marks_following_line() {
517        let (_d, repo, path) = fixture();
518        let edited = "fn a() {}\nfn c() {}\n";
519        let hunks = repo.hunks(&path, edited);
520        assert_eq!(hunks.len(), 1);
521        assert_eq!(hunks[0].kind, HunkKind::Delete);
522        assert!(hunks[0].covers(2, 4)); // sign on the line after the gap
523    }
524
525    #[test]
526    fn stage_hunk_applies_to_index() {
527        let (_d, repo, path) = fixture();
528        let edited = "fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n";
529        let hunks = repo.hunks(&path, edited);
530        assert_eq!(hunks.len(), 1);
531        assert_eq!(hunks[0].kind, HunkKind::Add);
532        let root = repo.workdir.clone();
533        repo.stage_hunk(Path::new("f.rs"), &hunks[0]).unwrap();
534        let out = Command::new("git")
535            .args([
536                "-C",
537                &root.display().to_string(),
538                "diff",
539                "--cached",
540                "--stat",
541            ])
542            .output()
543            .unwrap();
544        let stat = String::from_utf8_lossy(&out.stdout);
545        assert!(stat.contains("f.rs"), "{stat}");
546    }
547
548    /// `to_patch` is real `git apply` input: the prefixed form exists
549    /// only here, derived from typed origins.
550    #[test]
551    fn to_patch_is_applyable() {
552        let (_d, repo, path) = fixture();
553        let edited = "fn a() {}\nfn b2() {}\nfn c() {}\n";
554        let hunks = repo.hunks(&path, edited);
555        assert_eq!(hunks.len(), 1);
556        let patch = hunks[0].to_patch(Path::new("f.rs"));
557        assert!(
558            patch.starts_with("--- a/f.rs\n+++ b/f.rs\n@@ -1,3 +1,3 @@\n"),
559            "{patch}"
560        );
561        assert!(patch.contains("-fn b() {}\n+fn b2() {}\n"), "{patch}");
562        assert!(patch.ends_with(" fn c() {}\n"), "{patch}");
563    }
564
565    /// The commit delta view's data: structured hunks at a SHA, via
566    /// libgit2 — the `git show` shell-out replacement.
567    #[test]
568    fn commit_file_diff_is_structured() {
569        let (_d, repo, path) = fixture();
570        let root = repo.workdir.clone();
571        std::fs::write(root.join("f.rs"), "fn a() {}\nfn b2() {}\nfn c() {}\n").unwrap();
572        git(&root, &["add", "."]);
573        git(&root, &["commit", "-qm", "change b"]);
574        let sha = String::from_utf8_lossy(
575            &Command::new("git")
576                .args(["-C", &root.display().to_string(), "rev-parse", "HEAD"])
577                .output()
578                .unwrap()
579                .stdout,
580        )
581        .trim()
582        .to_string();
583        let diff = repo.commit_file_diff(&sha, Path::new("f.rs")).unwrap();
584        assert_eq!(diff.added, 1);
585        assert_eq!(diff.deleted, 1);
586        assert_eq!(diff.hunks.len(), 1);
587        assert_eq!(diff.hunks[0].kind, HunkKind::Change);
588        assert!(diff.hunks[0].lines.iter().any(|l| l.text == "fn b2() {}"));
589        let _ = path;
590    }
591
592    /// Root commits diff against the empty tree: the init commit shows
593    /// as one all-addition hunk, not an error.
594    #[test]
595    fn commit_file_diff_root_commit() {
596        let (_d, repo, _path) = fixture();
597        let root = repo.workdir.clone();
598        let sha = String::from_utf8_lossy(
599            &Command::new("git")
600                .args(["-C", &root.display().to_string(), "rev-parse", "HEAD"])
601                .output()
602                .unwrap()
603                .stdout,
604        )
605        .trim()
606        .to_string();
607        let diff = repo.commit_file_diff(&sha, Path::new("f.rs")).unwrap();
608        assert_eq!(diff.added, 3);
609        assert_eq!(diff.deleted, 0);
610        assert!(diff
611            .hunks
612            .iter()
613            .all(|h| h.lines.iter().all(|l| l.old_lineno.is_none())));
614    }
615}
616
617#[cfg(test)]
618mod head_tests {
619    use super::*;
620    use std::process::Command;
621
622    #[test]
623    fn head_content_probe() {
624        let dir = tempfile::tempdir().unwrap();
625        let root = dir.path();
626        let git = |args: &[&str]| {
627            Command::new("git")
628                .args(args)
629                .current_dir(root)
630                .output()
631                .unwrap();
632        };
633        git(&["init", "-q"]);
634        git(&["config", "user.email", "t@t.t"]);
635        git(&["config", "user.name", "t"]);
636        std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
637        git(&["add", "."]);
638        git(&["commit", "-qm", "init"]);
639        let repo = Repo::discover(root).unwrap();
640        eprintln!("workdir: {:?}", repo.workdir());
641        let abs = root.join("f.rs");
642        eprintln!("abs: {:?} rel: {:?}", abs, repo.rel_path(&abs));
643        eprintln!("head: {:?}", repo.head_content(&abs));
644        assert!(repo.head_content(&abs).is_some());
645    }
646}