Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_ore/tests/gitexport_git.rs

16.4 KiB, 48 runs

created by r1870400018:20381, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Checks the fast-import stream emitter against git itself.
2//!
3//! The unit tests in the module read the emitted stream back with the parser
4//! next door, which proves the two halves agree and nothing more: both were
5//! written by the same hand and could be wrong together. This test pipes what
6//! the emitter produces into a real `git fast-import`, checks the result out,
7//! and compares it byte for byte and mode for mode with what was asked for. Git
8//! is the oracle.
9//!
10//! It is marked `#[ignore]` because it needs a `git` binary on the path and
11//! writes to a temporary directory, neither of which a plain `cargo test`
12//! should assume. Run it with:
13//!
14//! ```text
15//! cargo test -p oxedyne_fe2o3_ore --test gitexport_git -- --ignored --nocapture
16//! ```
17//!
18//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
19//! Anthropic Claude
20
21use oxedyne_fe2o3_core::prelude::*;
22use oxedyne_fe2o3_ore::fastexport::{
23 BlobRef,
24 FileMode,
25 ObjRef,
26 Person,
27 TzOffset,
28 When,
29};
30use oxedyne_fe2o3_ore::gitexport::{
31 changes,
32 Change,
33 Commit,
34 Entry,
35 Stream,
36 Tree,
37};
38
39use std::collections::BTreeMap;
40use std::env;
41use std::fs;
42use std::io::Write;
43use std::os::unix::ffi::OsStrExt;
44use std::os::unix::fs::PermissionsExt;
45use std::path::{
46 Path,
47 PathBuf,
48};
49use std::process::{
50 Command,
51 Stdio,
52};
53use std::time::{
54 SystemTime,
55 UNIX_EPOCH,
56};
57
58
59// NUL bytes, a bare line feed and a byte that is not valid UTF-8.
60const BINARY: &[u8] = b"\x00\x01\n\x02binary\x00\xff\n";
61
62// Fixed, so a run is the same run every time.
63const WHEN: When = When { secs: 1_700_000_000, tz: TzOffset { mins: 600, neg: false } };
64
65
66/// The caller's git configuration, hooks and signing keys are all fenced off, so
67/// that nothing on the developer's machine can change what git does here.
68fn git(dir: &Path, args: &[&str])
69 -> Outcome<Vec<u8>>
70{
71 let mut cmd = Command::new("git");
72 cmd.current_dir(dir)
73 .args(args)
74 .env("GIT_CONFIG_GLOBAL", "/dev/null")
75 .env("GIT_CONFIG_NOSYSTEM", "1");
76 let out = res!(cmd.output());
77 if !out.status.success() {
78 return Err(err!(
79 "git {} failed: {}", args.join(" "),
80 String::from_utf8_lossy(&out.stderr);
81 Test, IO));
82 }
83 Ok(out.stdout)
84}
85
86fn scratch_dir(what: &str)
87 -> Outcome<PathBuf>
88{
89 let stamp = res!(SystemTime::now().duration_since(UNIX_EPOCH));
90 let dir = env::temp_dir().join(fmt!(
91 "fe2o3_ore_gitexport_{}_{}_{}", what, std::process::id(), stamp.as_nanos(),
92 ));
93 res!(fs::create_dir_all(&dir));
94 Ok(dir)
95}
96
97/// Git is run with `--done`, so a stream cut short is refused rather than half
98/// applied: what comes back is either the whole history or an error.
99fn build(dir: &Path, stream: &[u8])
100 -> Outcome<PathBuf>
101{
102 let repo = dir.join("mirror.git");
103 res!(fs::create_dir_all(&repo));
104 res!(git(&repo, &["init", "--bare", "--quiet", "."]));
105 let mut child = res!(Command::new("git")
106 .current_dir(&repo)
107 .args(["fast-import", "--quiet", "--done"])
108 .env("GIT_CONFIG_GLOBAL", "/dev/null")
109 .env("GIT_CONFIG_NOSYSTEM", "1")
110 .stdin(Stdio::piped())
111 .stderr(Stdio::piped())
112 .spawn());
113 match child.stdin.take() {
114 Some(mut pipe) => res!(pipe.write_all(stream)),
115 None => return Err(err!("git fast-import gave no standard input."; Test, IO)),
116 }
117 let out = res!(child.wait_with_output());
118 if !out.status.success() {
119 return Err(err!(
120 "git fast-import refused the stream: {}",
121 String::from_utf8_lossy(&out.stderr);
122 Test, IO));
123 }
124 Ok(repo)
125}
126
127/// Every file of a checkout, keyed by the bytes of its path.
128fn checkout(dir: &Path, repo: &Path, refname: &str, what: &str)
129 -> Outcome<BTreeMap<Vec<u8>, Entry>>
130{
131 let work = dir.join(what);
132 res!(fs::create_dir_all(&work));
133 res!(git(&work, &["clone", "--quiet", "--no-checkout",
134 &fmt!("{}", repo.display()), "."]));
135 res!(git(&work, &["checkout", "--quiet", "--force", refname]));
136 let mut out = BTreeMap::new();
137 res!(collect(&work, &[], &mut out));
138 Ok(out)
139}
140
141fn collect(dir: &Path, prefix: &[u8], out: &mut BTreeMap<Vec<u8>, Entry>)
142 -> Outcome<()>
143{
144 for entry in res!(fs::read_dir(dir)) {
145 let entry = res!(entry);
146 let name = entry.file_name();
147 let name = name.as_bytes();
148 if name == b".git" {
149 continue;
150 }
151 let mut rel = prefix.to_vec();
152 if !rel.is_empty() {
153 rel.push(b'/');
154 }
155 rel.extend_from_slice(name);
156 let kind = res!(entry.file_type());
157 if kind.is_dir() {
158 res!(collect(&entry.path(), &rel, out));
159 } else if kind.is_symlink() {
160 let target = res!(fs::read_link(entry.path()));
161 out.insert(rel, Entry {
162 mode: FileMode::Symlink,
163 data: target.as_os_str().as_bytes().to_vec(),
164 });
165 } else if kind.is_file() {
166 let meta = res!(entry.metadata());
167 out.insert(rel, Entry {
168 mode: if meta.permissions().mode() & 0o100 != 0 {
169 FileMode::Executable
170 } else {
171 FileMode::Normal
172 },
173 data: res!(fs::read(entry.path())),
174 });
175 }
176 }
177 Ok(())
178}
179
180fn ada() -> Person {
181 Person {
182 name: b"Ada Lovelace".to_vec(),
183 email: b"ada@example.org".to_vec(),
184 when: WHEN,
185 }
186}
187
188fn entry(mode: FileMode, data: &[u8]) -> Entry {
189 Entry { mode, data: data.to_vec() }
190}
191
192/// Says which paths differ, and how, so a failure names the file.
193fn same(got: &BTreeMap<Vec<u8>, Entry>, want: &Tree, what: &str)
194 -> Outcome<()>
195{
196 let mine: Vec<String> = got.keys()
197 .map(|p| String::from_utf8_lossy(p).into_owned()).collect();
198 let yours: Vec<String> = want.keys()
199 .map(|p| String::from_utf8_lossy(p).into_owned()).collect();
200 if mine != yours {
201 return Err(err!(
202 "{}: git checked out {:?} where {:?} was asked for.", what, mine, yours;
203 Test, Mismatch));
204 }
205 for (path, wanted) in want {
206 let shown = String::from_utf8_lossy(path).into_owned();
207 let held = match got.get(path) {
208 Some(h) => h,
209 None => return Err(err!("{}: git has no {:?}.", what, shown; Test, Missing)),
210 };
211 if held.mode != wanted.mode {
212 return Err(err!(
213 "{}: git checked {:?} out as {} where {} was asked for.",
214 what, shown, held.mode, wanted.mode;
215 Test, Mismatch));
216 }
217 if held.data != wanted.data {
218 return Err(err!(
219 "{}: git checked {:?} out as {} bytes where {} were asked for.",
220 what, shown, held.data.len(), wanted.data.len();
221 Test, Mismatch));
222 }
223 }
224 Ok(())
225}
226
227
228/// The tree holds everything the emitter has to survive: a binary file, an
229/// executable one, a path holding a space, a path holding bytes that are not
230/// UTF-8, a path holding a line feed, and a deep directory.
231#[test]
232#[ignore = "needs a git binary and a writable temporary directory"]
233fn a_tree_emitted_is_the_tree_git_holds() -> Outcome<()> {
234 let dir = res!(scratch_dir("tree"));
235 let mut want = Tree::new();
236 want.insert(b"plain.txt".to_vec(), entry(FileMode::Normal, b"hello\n"));
237 want.insert(b"bin.dat".to_vec(), entry(FileMode::Normal, BINARY));
238 want.insert(b"build.sh".to_vec(), entry(FileMode::Executable, b"#!/bin/sh\necho\n"));
239 want.insert(b"a space.txt".to_vec(), entry(FileMode::Normal, b"spaced\n"));
240 want.insert(b"line\nfeed.txt".to_vec(), entry(FileMode::Normal, b"odd\n"));
241 want.insert(vec![0xff, 0xfe, b'.', b'b'], entry(FileMode::Normal, b"not utf8\n"));
242 want.insert(b"deep/down/here.txt".to_vec(), entry(FileMode::Normal, b"deep\n"));
243
244 let mut stream = Stream::new();
245 res!(stream.commit(&Commit {
246 refname: fmt!("refs/heads/main"),
247 mark: Some(1),
248 author: None,
249 committer: ada(),
250 message: b"everything at once".to_vec(),
251 from: None,
252 merges: Vec::new(),
253 changes: res!(changes(&Tree::new(), &want)),
254 }));
255 stream.done();
256
257 let repo = res!(build(&dir, stream.bytes()));
258 let got = res!(checkout(&dir, &repo, "main", "check"));
259 let outcome = same(&got, &want, "the one commit");
260 let _ = fs::remove_dir_all(&dir);
261 outcome
262}
263
264/// Each commit's tree is what was asked for, a merge has two parents in the order
265/// given, and a lightweight tag names the commit it was pointed at.
266#[test]
267#[ignore = "needs a git binary and a writable temporary directory"]
268fn a_history_emitted_is_the_history_git_holds() -> Outcome<()> {
269 let dir = res!(scratch_dir("history"));
270
271 // The root: two files, one of them a script.
272 let mut base = Tree::new();
273 base.insert(b"f.txt".to_vec(), entry(FileMode::Normal, b"alpha\nbeta\n"));
274 base.insert(b"run.sh".to_vec(), entry(FileMode::Normal, b"#!/bin/sh\n"));
275
276 // One line of work renames a file and makes the script runnable.
277 let mut trunk = Tree::new();
278 trunk.insert(b"kept.txt".to_vec(), entry(FileMode::Normal, b"alpha\nbeta\n"));
279 trunk.insert(b"run.sh".to_vec(), entry(FileMode::Executable, b"#!/bin/sh\n"));
280
281 // Another, from the same root, adds a file of its own.
282 let mut side = base.clone();
283 side.insert(b"s.txt".to_vec(), entry(FileMode::Normal, b"only here\n"));
284
285 // And the two are joined, with the script's bit taken away again.
286 let mut merged = Tree::new();
287 merged.insert(b"kept.txt".to_vec(), entry(FileMode::Normal, b"alpha\nresolved\n"));
288 merged.insert(b"run.sh".to_vec(), entry(FileMode::Normal, b"#!/bin/sh\n"));
289 merged.insert(b"s.txt".to_vec(), entry(FileMode::Normal, b"only here\n"));
290
291 let mut stream = Stream::new();
292 let commit = |mark, from: Option<u64>, merges: Vec<u64>, msg: &str, ch| Commit {
293 refname: fmt!("refs/heads/main"),
294 mark: Some(mark),
295 author: None,
296 committer: ada(),
297 message: msg.as_bytes().to_vec(),
298 from: from.map(ObjRef::Mark),
299 merges: merges.into_iter().map(ObjRef::Mark).collect(),
300 changes: ch,
301 };
302 res!(stream.commit(&commit(1, None, vec![], "base",
303 res!(changes(&Tree::new(), &base)))));
304 res!(stream.commit(&commit(2, Some(1), vec![], "trunk",
305 res!(changes(&base, &trunk)))));
306 res!(stream.commit(&commit(3, Some(1), vec![], "side",
307 res!(changes(&base, &side)))));
308 // A merge's changes are applied over its first parent's tree, which is what
309 // the difference here is taken against.
310 res!(stream.commit(&commit(4, Some(2), vec![3], "merge",
311 res!(changes(&trunk, &merged)))));
312 res!(stream.reset("refs/tags/v1", Some(&ObjRef::Mark(2))));
313 stream.done();
314
315 let repo = res!(build(&dir, stream.bytes()));
316 let outcome = (|| -> Outcome<()> {
317 res!(same(&res!(checkout(&dir, &repo, "main", "at_head")), &merged, "the merge"));
318 res!(same(&res!(checkout(&dir, &repo, "v1", "at_tag")), &trunk, "the tag"));
319
320 // Two parents, in the order the stream gave them.
321 let out = res!(git(&repo, &["rev-list", "--parents", "-n", "1", "main"]));
322 let text = String::from_utf8_lossy(&out).into_owned();
323 let ids: Vec<&str> = text.trim().split(' ').collect();
324 assert_eq!(ids.len(), 3, "the head has two parents: {}", text.trim());
325 let first = res!(git(&repo, &["rev-parse", "main^1"]));
326 assert_eq!(ids[1], String::from_utf8_lossy(&first).trim(),
327 "and the first of them is the one named by from");
328
329 // Four commits, and no more.
330 let out = res!(git(&repo, &["rev-list", "--all", "--count"]));
331 assert_eq!(String::from_utf8_lossy(&out).trim(), "4",
332 "four commits were written and four are held");
333
334 // The tag points at a commit and mints no object of its own.
335 let kind = res!(git(&repo, &["cat-file", "-t", "refs/tags/v1"]));
336 assert_eq!(String::from_utf8_lossy(&kind).trim(), "commit",
337 "a lightweight tag names the commit directly");
338 Ok(())
339 })();
340 let _ = fs::remove_dir_all(&dir);
341 outcome
342}
343
344/// This is what makes a mirror checkable against the repository it mirrors: git's
345/// blob and tree names are hashes of content alone, so an exported tree can be
346/// compared with the original's by name and not merely by walking it. Commit names
347/// cannot, and the second half says so.
348#[test]
349#[ignore = "needs a git binary and a writable temporary directory"]
350fn a_tree_object_is_a_function_of_its_content_alone() -> Outcome<()> {
351 let dir = res!(scratch_dir("hashes"));
352 let mut want = Tree::new();
353 want.insert(b"f.txt".to_vec(), entry(FileMode::Normal, b"content\n"));
354 want.insert(b"sub/g.sh".to_vec(), entry(FileMode::Executable, b"#!/bin/sh\n"));
355 let ch = res!(changes(&Tree::new(), &want));
356
357 let mut trees = Vec::new();
358 let mut commits = Vec::new();
359 // Two repositories over the same tree, differing in everything a commit
360 // object holds besides that tree.
361 for (n, (msg, secs)) in [("one message", 1_000i64), ("another entirely", 2_000)]
362 .into_iter().enumerate()
363 {
364 let mut stream = Stream::new();
365 res!(stream.commit(&Commit {
366 refname: fmt!("refs/heads/main"),
367 mark: Some(1),
368 author: None,
369 committer: Person {
370 name: fmt!("Writer {}", n).into_bytes(),
371 email: fmt!("w{}@example.org", n).into_bytes(),
372 when: When { secs, tz: TzOffset::new(0) },
373 },
374 message: msg.as_bytes().to_vec(),
375 from: None,
376 merges: Vec::new(),
377 changes: ch.clone(),
378 }));
379 stream.done();
380 let sub = dir.join(fmt!("r{}", n));
381 res!(fs::create_dir_all(&sub));
382 let repo = res!(build(&sub, stream.bytes()));
383 let tree = res!(git(&repo, &["rev-parse", "main^{tree}"]));
384 let commit = res!(git(&repo, &["rev-parse", "main"]));
385 trees.push(String::from_utf8_lossy(&tree).trim().to_owned());
386 commits.push(String::from_utf8_lossy(&commit).trim().to_owned());
387 }
388 let outcome = (|| -> Outcome<()> {
389 assert_eq!(trees[0], trees[1],
390 "the same content is the same tree object: {:?}", trees);
391 assert_ne!(commits[0], commits[1],
392 "and a different message and date is a different commit: {:?}", commits);
393 Ok(())
394 })();
395 let _ = fs::remove_dir_all(&dir);
396 outcome
397}
398
399/// Git drops the file without a word. The half of this test that matters is the
400/// second: git's own behaviour is measured rather than assumed, so the emitter's
401/// refusal is answering something real.
402#[test]
403#[ignore = "needs a git binary and a writable temporary directory"]
404fn git_would_drop_a_file_the_emitter_refuses_to_write() -> Outcome<()> {
405 let dir = res!(scratch_dir("clash"));
406
407 // What the emitter does.
408 let mut want = Tree::new();
409 want.insert(b"a".to_vec(), entry(FileMode::Normal, b"a file\n"));
410 want.insert(b"a/b".to_vec(), entry(FileMode::Normal, b"and a directory\n"));
411 assert!(changes(&Tree::new(), &want).is_err(),
412 "a path that is both a file and a directory is refused");
413
414 // What git does, given the same thing said by hand.
415 let mut stream = Stream::new();
416 res!(stream.commit(&Commit {
417 refname: fmt!("refs/heads/main"),
418 mark: Some(1),
419 author: None,
420 committer: ada(),
421 message: b"both at once".to_vec(),
422 from: None,
423 merges: Vec::new(),
424 changes: vec![
425 Change::Modify {
426 mode: FileMode::Normal,
427 data: BlobRef::Inline(b"a file\n".to_vec()),
428 path: b"a".to_vec(),
429 },
430 Change::Modify {
431 mode: FileMode::Normal,
432 data: BlobRef::Inline(b"and a directory\n".to_vec()),
433 path: b"a/b".to_vec(),
434 },
435 ],
436 }));
437 stream.done();
438 let repo = res!(build(&dir, stream.bytes()));
439 let got = res!(checkout(&dir, &repo, "main", "check"));
440 let outcome = (|| -> Outcome<()> {
441 let paths: Vec<String> = got.keys()
442 .map(|p| String::from_utf8_lossy(p).into_owned()).collect();
443 assert_eq!(paths, vec![fmt!("a/b")],
444 "git kept the directory and dropped the file, saying nothing: {:?}", paths);
445 Ok(())
446 })();
447 let _ = fs::remove_dir_all(&dir);
448 outcome
449}
450
451/// A symbolic link is a mode and a blob whose bytes are the target.
452#[test]
453#[ignore = "needs a git binary and a writable temporary directory"]
454fn a_symlink_is_checked_out_as_a_link() -> Outcome<()> {
455 let dir = res!(scratch_dir("symlink"));
456 let mut want = Tree::new();
457 want.insert(b"real.txt".to_vec(), entry(FileMode::Normal, b"the target\n"));
458 want.insert(b"link".to_vec(), entry(FileMode::Symlink, b"real.txt"));
459
460 let mut stream = Stream::new();
461 res!(stream.commit(&Commit {
462 refname: fmt!("refs/heads/main"),
463 mark: Some(1),
464 author: None,
465 committer: ada(),
466 message: b"a link".to_vec(),
467 from: None,
468 merges: Vec::new(),
469 changes: res!(changes(&Tree::new(), &want)),
470 }));
471 stream.done();
472 let repo = res!(build(&dir, stream.bytes()));
473 let got = res!(checkout(&dir, &repo, "main", "check"));
474 let outcome = same(&got, &want, "the link");
475 let _ = fs::remove_dir_all(&dir);
476 outcome
477}
478
479/// This is what `done` and git's own `--done` are for.
480#[test]
481#[ignore = "needs a git binary and a writable temporary directory"]
482fn a_stream_that_never_says_done_is_refused() -> Outcome<()> {
483 let dir = res!(scratch_dir("cut"));
484 let mut want = Tree::new();
485 want.insert(b"f.txt".to_vec(), entry(FileMode::Normal, b"x\n"));
486 let mut stream = Stream::new();
487 res!(stream.commit(&Commit {
488 refname: fmt!("refs/heads/main"),
489 mark: Some(1),
490 author: None,
491 committer: ada(),
492 message: b"unfinished".to_vec(),
493 from: None,
494 merges: Vec::new(),
495 changes: res!(changes(&Tree::new(), &want)),
496 }));
497 // No `done`, as a producer that died part way through would leave it.
498 let outcome = match build(&dir, stream.bytes()) {
499 Ok(_) => Err(err!("git accepted a stream that never said done."; Test)),
500 Err(_) => Ok(()),
501 };
502 let _ = fs::remove_dir_all(&dir);
503 outcome
504}