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