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/// One diff hunk between HEAD and the buffer, in 1-based buffer lines.
17#[derive(Debug, Clone)]
18pub struct Hunk {
19    pub kind: HunkKind,
20    /// First affected line in the buffer (1-based). For pure deletions
21    /// this is the line *after* which content vanished.
22    pub new_start: usize,
23    pub new_count: usize,
24    pub old_start: usize,
25    pub old_count: usize,
26    /// Diff lines with their origin prefix (+ - space), for preview.
27    pub lines: Vec<String>,
28}
29
30/// One changed line, for gutter signs. Hunk headers include context
31/// lines, so signs track the +/- lines, not the header range.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Sign {
34    /// Buffer line was added or changed.
35    AddOrChange,
36    /// Buffer line sits right below a deletion (the line number may be
37    /// one past the buffer end for an EOF deletion — clamp on render).
38    DeleteAfter,
39}
40
41impl Hunk {
42    /// Signs this hunk produces, derived from its diff lines.
43    pub fn signs(&self) -> Vec<(usize, Sign)> {
44        let mut out = Vec::new();
45        let mut nl = self.new_start;
46        for line in &self.lines {
47            match line.as_bytes().first() {
48                Some(b'+') => {
49                    out.push((nl, Sign::AddOrChange));
50                    nl += 1;
51                }
52                Some(b'-') => out.push((nl, Sign::DeleteAfter)),
53                _ => nl += 1,
54            }
55        }
56        out
57    }
58
59    /// The actual changed region (from +/- lines, not the header, which
60    /// includes context): buffer-side `new_first`/`new_count` (1-based)
61    /// and HEAD-side `old_first`/`old_count`. For pure deletions
62    /// `new_first` is the buffer line *following* the gap.
63    pub fn changed_region(&self) -> (usize, usize, usize, usize) {
64        let mut nl = self.new_start;
65        let mut ol = self.old_start;
66        let mut new_lines = Vec::new();
67        let mut old_lines = Vec::new();
68        for line in &self.lines {
69            match line.as_bytes().first() {
70                Some(b'+') => {
71                    new_lines.push(nl);
72                    nl += 1;
73                }
74                Some(b'-') => {
75                    old_lines.push(ol);
76                    ol += 1;
77                }
78                _ => {
79                    nl += 1;
80                    ol += 1;
81                }
82            }
83        }
84        let new_first = new_lines.first().copied().unwrap_or(nl);
85        let old_first = old_lines.first().copied().unwrap_or(ol);
86        (new_first, new_lines.len(), old_first, old_lines.len())
87    }
88
89    /// Buffer lines covered (signs render on these); `total_lines`
90    /// clamps an EOF deletion onto the last line.
91    pub fn covers(&self, line_1based: usize, total_lines: usize) -> bool {
92        self.signs().iter().any(|&(l, kind)| match kind {
93            Sign::AddOrChange => l == line_1based,
94            Sign::DeleteAfter => l.min(total_lines) == line_1based,
95        })
96    }
97}
98
99pub struct Repo {
100    inner: git2::Repository,
101    workdir: PathBuf,
102}
103
104impl Repo {
105    /// Discover the repository containing `path` (buffer path or cwd).
106    pub fn discover(from: &Path) -> Option<Self> {
107        let inner = git2::Repository::discover(from).ok()?;
108        let workdir = inner.workdir()?.to_path_buf();
109        Some(Self { inner, workdir })
110    }
111
112    pub fn workdir(&self) -> &Path {
113        &self.workdir
114    }
115
116    /// Remotes as (name, url) pairs — libgit2 config, no spawn.
117    pub fn remotes(&self) -> Vec<(String, String)> {
118        let Ok(remotes) = self.inner.remotes() else {
119            return vec![];
120        };
121        remotes
122            .iter()
123            .flatten()
124            .filter_map(|name| {
125                self.inner
126                    .find_remote(name)
127                    .ok()
128                    .and_then(|r| r.url().map(|u| (name.to_string(), u.to_string())))
129            })
130            .collect()
131    }
132
133    /// HEAD's full SHA (permalink base — branch always resolves to SHA).
134    pub fn head_sha(&self) -> Option<String> {
135        Some(
136            self.inner
137                .head()
138                .ok()?
139                .peel_to_commit()
140                .ok()?
141                .id()
142                .to_string(),
143        )
144    }
145
146    /// Repo-relative path for a buffer path (diff keys are relative).
147    fn rel_path(&self, path: &Path) -> Option<PathBuf> {
148        let abs = if path.is_absolute() {
149            path.to_path_buf()
150        } else {
151            self.workdir.join(path)
152        };
153        abs.strip_prefix(&self.workdir)
154            .ok()
155            .map(|p| p.to_path_buf())
156    }
157
158    /// HEAD's content for `path`, if tracked.
159    pub fn head_content(&self, path: &Path) -> Option<String> {
160        let rel = self.rel_path(path)?;
161        let head = self.inner.head().ok()?.peel_to_tree().ok()?;
162        let entry = head.get_path(&rel).ok()?;
163        let blob = self.inner.find_blob(entry.id()).ok()?;
164        String::from_utf8(blob.content().to_vec()).ok()
165    }
166
167    /// Hunks between HEAD and `content` for `path`. Untracked files
168    /// report a single all-Add hunk.
169    pub fn hunks(&self, path: &Path, content: &str) -> Vec<Hunk> {
170        let Some(rel) = self.rel_path(path) else {
171            return vec![];
172        };
173        let old = self.head_content(path);
174        match old {
175            None => {
176                let count = content.lines().count();
177                if count == 0 {
178                    return vec![];
179                }
180                vec![Hunk {
181                    kind: HunkKind::Add,
182                    new_start: 1,
183                    new_count: count,
184                    old_start: 0,
185                    old_count: 0,
186                    lines: content.lines().map(|l| format!("+{l}")).collect(),
187                }]
188            }
189            Some(old) => self.diff_strings(&old, content, &rel),
190        }
191    }
192
193    fn diff_strings(&self, old: &str, new: &str, rel: &Path) -> Vec<Hunk> {
194        let mut opts = git2::DiffOptions::new();
195        opts.context_lines(3);
196        let Ok(patch) = git2::Patch::from_buffers(
197            old.as_bytes(),
198            Some(rel),
199            new.as_bytes(),
200            Some(rel),
201            Some(&mut opts),
202        ) else {
203            return vec![];
204        };
205        let mut hunks = Vec::new();
206        for h in 0..patch.num_hunks() {
207            let Ok((header, line_count)) = patch.hunk(h) else {
208                continue;
209            };
210            let mut lines = Vec::with_capacity(line_count);
211            for l in 0..line_count {
212                if let Ok(line) = patch.line_in_hunk(h, l) {
213                    let prefix = match line.origin() {
214                        '+' | '-' => line.origin(),
215                        _ => ' ',
216                    };
217                    let text = String::from_utf8_lossy(line.content())
218                        .trim_end_matches('\n')
219                        .to_string();
220                    lines.push(format!("{prefix}{text}"));
221                }
222            }
223            // kind from the actual +/- lines: header counts include
224            // context lines, which would mislabel small-file hunks
225            let has_plus = lines.iter().any(|l| l.starts_with('+'));
226            let has_minus = lines.iter().any(|l| l.starts_with('-'));
227            let kind = match (has_plus, has_minus) {
228                (true, false) => HunkKind::Add,
229                (false, true) => HunkKind::Delete,
230                _ => HunkKind::Change,
231            };
232            hunks.push(Hunk {
233                kind,
234                new_start: header.new_start() as usize,
235                new_count: header.new_lines() as usize,
236                old_start: header.old_start() as usize,
237                old_count: header.old_lines() as usize,
238                lines,
239            });
240        }
241        hunks
242    }
243
244    /// Stage one hunk. Prototype path: synthesize a single-hunk patch and
245    /// `git apply --cached` it (shell git is the write path per 0001 §3;
246    /// libgit2 owns the read hot paths). `rel` is repo-relative.
247    pub fn stage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
248        let mut patch = format!("--- a/{}\n+++ b/{}\n", rel.display(), rel.display());
249        patch.push_str(&format!(
250            "@@ -{},{} +{},{} @@\n",
251            hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count
252        ));
253        for line in &hunk.lines {
254            patch.push_str(line);
255            patch.push('\n');
256        }
257        let mut child = std::process::Command::new("git")
258            .args([
259                "-C",
260                &self.workdir.display().to_string(),
261                "apply",
262                "--cached",
263                "--unidiff-zero",
264            ])
265            .stdin(std::process::Stdio::piped())
266            .stdout(std::process::Stdio::null())
267            .stderr(std::process::Stdio::piped())
268            .spawn()
269            .map_err(|e| format!("spawn git: {e}"))?;
270        use std::io::Write;
271        child
272            .stdin
273            .as_mut()
274            .expect("piped")
275            .write_all(patch.as_bytes())
276            .map_err(|e| e.to_string())?;
277        let out = child.wait_with_output().map_err(|e| e.to_string())?;
278        if out.status.success() {
279            Ok(())
280        } else {
281            Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
282        }
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use std::process::Command;
290
291    fn git(root: &std::path::Path, args: &[&str]) {
292        Command::new("git")
293            .args(args)
294            .current_dir(root)
295            .output()
296            .unwrap();
297    }
298
299    fn fixture() -> (tempfile::TempDir, Repo, PathBuf) {
300        let dir = tempfile::tempdir().unwrap();
301        let root = dir.path();
302        git(root, &["init", "-q"]);
303        git(root, &["config", "user.email", "t@t.t"]);
304        git(root, &["config", "user.name", "t"]);
305        std::fs::write(root.join("f.rs"), "fn a() {}\nfn b() {}\nfn c() {}\n").unwrap();
306        git(root, &["add", "."]);
307        git(root, &["commit", "-qm", "init"]);
308        let repo = Repo::discover(root).unwrap();
309        let file = root.join("f.rs");
310        (dir, repo, file)
311    }
312
313    #[test]
314    fn clean_buffer_has_no_hunks() {
315        let (_d, repo, path) = fixture();
316        let content = repo.head_content(&path).unwrap();
317        assert!(repo.hunks(&path, &content).is_empty());
318    }
319
320    #[test]
321    fn change_and_add_and_delete() {
322        let (_d, repo, path) = fixture();
323        let edited = "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n";
324        let hunks = repo.hunks(&path, edited);
325        assert_eq!(hunks.len(), 1);
326        assert_eq!(hunks[0].kind, HunkKind::Change);
327        assert!(hunks[0].covers(2, 4));
328        assert!(hunks[0].covers(4, 4));
329        assert!(!hunks[0].covers(1, 4));
330        assert!(hunks[0]
331            .lines
332            .iter()
333            .any(|l| l.starts_with("+fn d2") || l.starts_with("+fn d()")));
334    }
335
336    #[test]
337    fn pure_delete_marks_following_line() {
338        let (_d, repo, path) = fixture();
339        let edited = "fn a() {}\nfn c() {}\n";
340        let hunks = repo.hunks(&path, edited);
341        assert_eq!(hunks.len(), 1);
342        assert_eq!(hunks[0].kind, HunkKind::Delete);
343        assert!(hunks[0].covers(2, 4)); // sign on the line after the gap
344    }
345
346    #[test]
347    fn stage_hunk_applies_to_index() {
348        let (_d, repo, path) = fixture();
349        let edited = "fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n";
350        let hunks = repo.hunks(&path, edited);
351        assert_eq!(hunks.len(), 1);
352        assert_eq!(hunks[0].kind, HunkKind::Add);
353        let root = repo.workdir.clone();
354        repo.stage_hunk(Path::new("f.rs"), &hunks[0]).unwrap();
355        let out = Command::new("git")
356            .args([
357                "-C",
358                &root.display().to_string(),
359                "diff",
360                "--cached",
361                "--stat",
362            ])
363            .output()
364            .unwrap();
365        let stat = String::from_utf8_lossy(&out.stdout);
366        assert!(stat.contains("f.rs"), "{stat}");
367    }
368}
369
370#[cfg(test)]
371mod head_tests {
372    use super::*;
373    use std::process::Command;
374
375    #[test]
376    fn head_content_probe() {
377        let dir = tempfile::tempdir().unwrap();
378        let root = dir.path();
379        let git = |args: &[&str]| {
380            Command::new("git")
381                .args(args)
382                .current_dir(root)
383                .output()
384                .unwrap();
385        };
386        git(&["init", "-q"]);
387        git(&["config", "user.email", "t@t.t"]);
388        git(&["config", "user.name", "t"]);
389        std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
390        git(&["add", "."]);
391        git(&["commit", "-qm", "init"]);
392        let repo = Repo::discover(root).unwrap();
393        eprintln!("workdir: {:?}", repo.workdir());
394        let abs = root.join("f.rs");
395        eprintln!("abs: {:?} rel: {:?}", abs, repo.rel_path(&abs));
396        eprintln!("head: {:?}", repo.head_content(&abs));
397        assert!(repo.head_content(&abs).is_some());
398    }
399}