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