1pub 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 #[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)); }
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 #[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 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 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 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 #[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 #[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 #[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 #[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 assert!(repo.unstaged_hunks(&untracked, "").unwrap().is_empty());
280 assert!(!repo.is_untracked(&_path).unwrap());
282 }
283
284 #[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 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 #[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 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 #[test]
332 fn gutter_from_contents_matches_repo_semantics() {
333 let (d, repo, path) = fixture();
334 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 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}