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;
6pub mod permalink;
7pub mod ssh;
8
9mod diff;
10mod numstat;
11mod repo;
12mod revision;
13
14pub use diff::{DiffLine, FileDiff, Hunk, HunkKind, LineOrigin, Sign};
15pub use repo::{GitContext, GitError, Repo};
16pub use revision::{GitRevision, SourceLocation};
17
18#[cfg(test)]
19mod tests {
20    use super::*;
21    use std::path::{Path, PathBuf};
22    use std::process::Command;
23
24    pub(crate) fn git(root: &std::path::Path, args: &[&str]) {
25        Command::new("git")
26            .args(args)
27            .current_dir(root)
28            .output()
29            .unwrap();
30    }
31
32    pub(crate) fn fixture() -> (tempfile::TempDir, Repo, PathBuf) {
33        let dir = tempfile::tempdir().unwrap();
34        let root = dir.path();
35        git(root, &["init", "-q"]);
36        git(root, &["config", "user.email", "t@t.t"]);
37        git(root, &["config", "user.name", "t"]);
38        std::fs::write(root.join("f.rs"), "fn a() {}\nfn b() {}\nfn c() {}\n").unwrap();
39        git(root, &["add", "."]);
40        git(root, &["commit", "-qm", "init"]);
41        let repo = Repo::discover(root).unwrap();
42        let file = root.join("f.rs");
43        (dir, repo, file)
44    }
45
46    #[test]
47    fn clean_buffer_has_no_hunks() {
48        let (_d, repo, path) = fixture();
49        let content = repo.head_content(&path).unwrap();
50        assert!(repo.hunks(&path, &content).unwrap().is_empty());
51    }
52
53    #[test]
54    fn change_and_add_and_delete() {
55        let (_d, repo, path) = fixture();
56        let edited = "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n";
57        let hunks = repo.hunks(&path, edited).unwrap();
58        assert_eq!(hunks.len(), 1);
59        assert_eq!(hunks[0].kind, HunkKind::Change);
60        assert!(hunks[0].covers(2, 4));
61        assert!(hunks[0].covers(4, 4));
62        assert!(!hunks[0].covers(1, 4));
63        assert!(hunks[0]
64            .lines
65            .iter()
66            .any(|l| l.origin == LineOrigin::Addition && l.text.starts_with(b"fn d")));
67    }
68
69    /// The typed structure carries both sides' 1-based numbers: the
70    /// renderer never guesses them from text (0010 §1).
71    #[test]
72    fn line_numbers_track_both_sides() {
73        let (_d, repo, path) = fixture();
74        let edited = "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n";
75        let hunks = repo.hunks(&path, edited).unwrap();
76        assert_eq!(hunks.len(), 1);
77        let h = &hunks[0];
78        let ctx = h
79            .lines
80            .iter()
81            .find(|l| l.origin == LineOrigin::Context)
82            .unwrap();
83        assert_eq!(
84            (ctx.old_lineno, ctx.new_lineno),
85            (Some(1), Some(1)),
86            "context lines carry both numbers, 1-based"
87        );
88        let add = h
89            .lines
90            .iter()
91            .find(|l| l.origin == LineOrigin::Addition && l.text.starts_with(b"fn d"))
92            .unwrap();
93        assert_eq!((add.old_lineno, add.new_lineno), (None, Some(4)));
94        let del = h
95            .lines
96            .iter()
97            .find(|l| l.origin == LineOrigin::Deletion)
98            .unwrap();
99        assert_eq!((del.old_lineno, del.new_lineno), (Some(2), None));
100    }
101
102    #[test]
103    fn pure_delete_marks_following_line() {
104        let (_d, repo, path) = fixture();
105        let edited = "fn a() {}\nfn c() {}\n";
106        let hunks = repo.hunks(&path, edited).unwrap();
107        assert_eq!(hunks.len(), 1);
108        assert_eq!(hunks[0].kind, HunkKind::Delete);
109        assert!(hunks[0].covers(2, 4)); // sign on the line after the gap
110    }
111
112    #[test]
113    fn stage_hunk_applies_to_index() {
114        let (_d, repo, path) = fixture();
115        let edited = "fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n";
116        let hunks = repo.hunks(&path, edited).unwrap();
117        assert_eq!(hunks.len(), 1);
118        assert_eq!(hunks[0].kind, HunkKind::Add);
119        let root = repo.workdir.clone();
120        repo.stage_hunk(Path::new("f.rs"), &hunks[0]).unwrap();
121        let out = Command::new("git")
122            .args([
123                "-C",
124                &root.display().to_string(),
125                "diff",
126                "--cached",
127                "--stat",
128            ])
129            .output()
130            .unwrap();
131        let stat = String::from_utf8_lossy(&out.stdout);
132        assert!(stat.contains("f.rs"), "{stat}");
133    }
134
135    /// Structured staging is byte-precise (0018): stage a hunk, and
136    /// the index holds exactly the post-edit bytes — including a
137    /// missing final newline, which the old patch path could not
138    /// represent.
139    #[test]
140    fn stage_hunk_is_byte_precise() {
141        let (_d, repo, path) = fixture();
142        let edited = "fn a() {}\nfn b2() {}\nfn c() {}\n";
143        let hunks = repo.hunks(&path, edited).unwrap();
144        assert_eq!(hunks.len(), 1);
145        repo.stage_hunk(Path::new("f.rs"), &hunks[0]).unwrap();
146        // the index now holds the edited text; HEAD is untouched
147        assert_eq!(
148            repo.index_content(&path).as_deref(),
149            Some("fn a() {}\nfn b2() {}\nfn c() {}\n")
150        );
151        assert_eq!(
152            repo.head_content(&path).as_deref(),
153            Some("fn a() {}\nfn b() {}\nfn c() {}\n")
154        );
155        // and unstaging the same hunk restores the index to HEAD
156        let staged = repo.staged_hunks(&path).unwrap();
157        assert_eq!(staged.len(), 1);
158        repo.unstage_hunk(Path::new("f.rs"), &staged[0]).unwrap();
159        assert_eq!(
160            repo.index_content(&path).as_deref(),
161            Some("fn a() {}\nfn b() {}\nfn c() {}\n")
162        );
163    }
164
165    #[test]
166    fn stage_hunk_preserves_a_missing_final_newline() {
167        let (_d, repo, path) = fixture();
168        // the worktree file drops its trailing newline
169        let edited = "fn a() {}\nfn b() {}\nfn c() {}";
170        let hunks = repo.hunks(&path, edited).unwrap();
171        repo.stage_hunk(Path::new("f.rs"), &hunks[0]).unwrap();
172        assert_eq!(repo.index_content(&path).as_deref(), Some(edited));
173        let staged = repo.staged_hunks(&path).unwrap();
174        repo.unstage_hunk(Path::new("f.rs"), &staged[0]).unwrap();
175        assert_eq!(
176            repo.index_content(&path).as_deref(),
177            Some("fn a() {}\nfn b() {}\nfn c() {}\n"),
178            "unstage restores the newline-terminated HEAD text"
179        );
180    }
181
182    /// The commit delta view's data: structured hunks at a SHA, via
183    /// libgit2 — the `git show` shell-out replacement.
184    #[test]
185    fn commit_file_diff_is_structured() {
186        let (_d, repo, path) = fixture();
187        let root = repo.workdir.clone();
188        std::fs::write(root.join("f.rs"), "fn a() {}\nfn b2() {}\nfn c() {}\n").unwrap();
189        git(&root, &["add", "."]);
190        git(&root, &["commit", "-qm", "change b"]);
191        let sha = String::from_utf8_lossy(
192            &Command::new("git")
193                .args(["-C", &root.display().to_string(), "rev-parse", "HEAD"])
194                .output()
195                .unwrap()
196                .stdout,
197        )
198        .trim()
199        .to_string();
200        let diff = repo.commit_file_diff(&sha, Path::new("f.rs")).unwrap();
201        assert_eq!(diff.added, 1);
202        assert_eq!(diff.deleted, 1);
203        assert_eq!(diff.hunks.len(), 1);
204        assert_eq!(diff.hunks[0].kind, HunkKind::Change);
205        assert!(diff.hunks[0].lines.iter().any(|l| l.text == b"fn b2() {}"));
206        let _ = path;
207    }
208
209    /// Root commits diff against the empty tree: the init commit shows
210    /// as one all-addition hunk, not an error.
211    #[test]
212    fn commit_file_diff_root_commit() {
213        let (_d, repo, _path) = fixture();
214        let root = repo.workdir.clone();
215        let sha = String::from_utf8_lossy(
216            &Command::new("git")
217                .args(["-C", &root.display().to_string(), "rev-parse", "HEAD"])
218                .output()
219                .unwrap()
220                .stdout,
221        )
222        .trim()
223        .to_string();
224        let diff = repo.commit_file_diff(&sha, Path::new("f.rs")).unwrap();
225        assert_eq!(diff.added, 3);
226        assert_eq!(diff.deleted, 0);
227        assert!(diff
228            .hunks
229            .iter()
230            .all(|h| h.lines.iter().all(|l| l.old_lineno.is_none())));
231    }
232
233    /// R9: a path outside the workdir is a typed refusal, not an
234    /// empty Vec masquerading as "no hunks".
235    #[test]
236    fn outside_workdir_is_typed_not_empty() {
237        let (_d, repo, _path) = fixture();
238        let outside = std::env::temp_dir().join("strop-outside-f.rs");
239        assert!(matches!(
240            repo.hunks(&outside, "x\n"),
241            Err(GitError::OutsideWorkdir)
242        ));
243        assert!(matches!(
244            repo.unstaged_hunks(&outside, "x\n"),
245            Err(GitError::OutsideWorkdir)
246        ));
247        assert!(matches!(
248            repo.staged_hunks(&outside),
249            Err(GitError::OutsideWorkdir)
250        ));
251    }
252
253    /// An untracked file's unstaged set is one all-add hunk against
254    /// empty — a useful case, distinct from failure; is_untracked
255    /// says so without touching content.
256    #[test]
257    fn untracked_file_is_all_add_not_failure() {
258        let (d, repo, _path) = fixture();
259        let untracked = d.path().join("new.rs");
260        std::fs::write(&untracked, "fn n() {}\n").unwrap();
261        assert!(repo.is_untracked(&untracked).unwrap());
262        let hunks = repo.unstaged_hunks(&untracked, "fn n() {}\n").unwrap();
263        assert_eq!(hunks.len(), 1);
264        assert_eq!(hunks[0].kind, HunkKind::Add);
265        assert_eq!(hunks[0].old_count, 0);
266        // an empty untracked buffer is an honest empty set
267        assert!(repo.unstaged_hunks(&untracked, "").unwrap().is_empty());
268        // the committed file is tracked: empty diff, not all-add
269        assert!(!repo.is_untracked(&_path).unwrap());
270    }
271
272    /// An unborn HEAD (fresh init, no commits) still diffs: staging a
273    /// new file yields an all-add staged set against empty.
274    #[test]
275    fn unborn_head_stages_all_add() {
276        let dir = tempfile::tempdir().unwrap();
277        let root = dir.path();
278        git(root, &["init", "-q"]);
279        let repo = Repo::discover(root).unwrap();
280        std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
281        git(root, &["add", "f.rs"]);
282        let staged = repo.staged_hunks(&root.join("f.rs")).unwrap();
283        assert_eq!(staged.len(), 1);
284        assert_eq!(staged[0].kind, HunkKind::Add);
285        // context snapshot: head_sha absent until the first commit
286        let ctx = repo.context();
287        assert_eq!(ctx.head_sha, None);
288        assert_eq!(ctx.workdir, root.to_path_buf());
289    }
290
291    /// The pure context round-trips through serde (replay tapes carry
292    /// it) and equality tracks the repository state it captured.
293    #[test]
294    fn git_context_serde_and_equality() {
295        let (d, repo, _path) = fixture();
296        let ctx = repo.context();
297        let wire = serde_json::to_string(&ctx).unwrap();
298        assert_eq!(serde_json::from_str::<GitContext>(&wire).unwrap(), ctx);
299        assert!(ctx.head_sha.is_some());
300        // a new commit changes HEAD: the context is no longer equal —
301        // cached diffs built against it are stale
302        std::fs::write(d.path().join("f.rs"), "fn a() {}\nfn z() {}\n").unwrap();
303        git(d.path(), &["commit", "-qam", "z"]);
304        let repo2 = Repo::discover(d.path()).unwrap();
305        assert_ne!(repo2.context(), ctx);
306    }
307}