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