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