1pub 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 #[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)); }
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 #[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 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 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 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 #[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 #[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 #[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 #[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 assert!(repo.unstaged_hunks(&untracked, "").unwrap().is_empty());
283 assert!(!repo.is_untracked(&_path).unwrap());
285 }
286
287 #[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 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 #[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 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 #[test]
335 fn gutter_from_contents_matches_repo_semantics() {
336 let (d, repo, path) = fixture();
337 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 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}