Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_ore/tests/fastexport_git.rs

9.1 KiB, 30 runs

created by r1870400018:17873, 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 parser against git itself.
2//!
3//! The unit tests in the module read streams written by hand, which proves the
4//! parser self-consistent and nothing more. This test builds a real repository
5//! with the git binary, has git write the stream, and asserts on what comes
6//! back out: git is the oracle, and the fixtures were not authored by the same
7//! hand as the parser.
8//!
9//! It is marked `#[ignore]` because it needs a `git` binary on the path and
10//! writes to a temporary directory, neither of which a plain `cargo test`
11//! should assume. Run it with:
12//!
13//! ```text
14//! cargo test -p oxedyne_fe2o3_ore --test fastexport_git -- --ignored --nocapture
15//! ```
16//!
17//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
18//! Anthropic Claude
19
20use oxedyne_fe2o3_core::prelude::*;
21use oxedyne_fe2o3_ore::fastexport::{
22 BlobRef,
23 Event,
24 FileChange,
25 FileMode,
26 ObjRef,
27 Parser,
28};
29
30use std::{
31 env,
32 fs,
33 path::{
34 Path,
35 PathBuf,
36 },
37 process::Command,
38 time::{
39 SystemTime,
40 UNIX_EPOCH,
41 },
42};
43
44
45// NUL bytes, a bare line feed and a byte no text encoding will leave alone.
46const BINARY: &[u8] = b"\x00\x01\n\x02binary\x00\xff\n";
47
48const WHEN: &str = "1700000000 +1000"; // fixed, so identity lines are predictable
49
50
51/// The caller's git configuration is stripped, so that a developer's global
52/// settings, hooks or signing keys cannot change what the stream says.
53fn git(dir: &Path, args: &[&str])
54 -> Outcome<Vec<u8>>
55{
56 let mut cmd = Command::new("git");
57 cmd.current_dir(dir)
58 .args(args)
59 .env("GIT_CONFIG_GLOBAL", "/dev/null")
60 .env("GIT_CONFIG_NOSYSTEM", "1")
61 .env("GIT_AUTHOR_NAME", "Ada Lovelace")
62 .env("GIT_AUTHOR_EMAIL", "ada@example.org")
63 .env("GIT_COMMITTER_NAME", "Ada Lovelace")
64 .env("GIT_COMMITTER_EMAIL", "ada@example.org")
65 .env("GIT_AUTHOR_DATE", WHEN)
66 .env("GIT_COMMITTER_DATE", WHEN);
67 let out = res!(cmd.output());
68 if !out.status.success() {
69 return Err(err!(
70 "git {} failed: {}", args.join(" "),
71 String::from_utf8_lossy(&out.stderr);
72 Test, IO));
73 }
74 Ok(out.stdout)
75}
76
77fn scratch_dir()
78 -> Outcome<PathBuf>
79{
80 let stamp = res!(SystemTime::now().duration_since(UNIX_EPOCH));
81 let dir = env::temp_dir().join(fmt!(
82 "fe2o3_ore_fastexport_{}_{}",
83 std::process::id(),
84 stamp.as_nanos(),
85 ));
86 res!(fs::create_dir_all(&dir));
87 Ok(dir)
88}
89
90/// The repository holds a rename, a binary file, a merge and an annotated tag.
91fn build_repo_and_export(dir: &Path)
92 -> Outcome<Vec<u8>>
93{
94 res!(git(dir, &["-c", "init.defaultBranch=main", "init", "--quiet", "."]));
95 // A hooks path that does not exist keeps any hook out of the way.
96 res!(git(dir, &["config", "core.hooksPath", "nohooks"]));
97 res!(git(dir, &["config", "commit.gpgsign", "false"]));
98 res!(git(dir, &["config", "tag.gpgsign", "false"]));
99 res!(git(dir, &["config", "user.name", "Ada Lovelace"]));
100 res!(git(dir, &["config", "user.email", "ada@example.org"]));
101
102 res!(fs::write(dir.join("a.txt"), b"hello\n"));
103 res!(fs::write(dir.join("bin.dat"), BINARY));
104 res!(git(dir, &["add", "a.txt", "bin.dat"]));
105 res!(git(dir, &["commit", "--quiet", "--no-verify", "-m", "first commit"]));
106
107 res!(git(dir, &["mv", "a.txt", "renamed.txt"]));
108 res!(git(dir, &["commit", "--quiet", "--no-verify", "-m", "rename a"]));
109
110 res!(git(dir, &["checkout", "--quiet", "-b", "side", "HEAD~1"]));
111 res!(fs::write(dir.join("side.txt"), b"side\n"));
112 res!(git(dir, &["add", "side.txt"]));
113 res!(git(dir, &["commit", "--quiet", "--no-verify", "-m", "side work"]));
114
115 res!(git(dir, &["checkout", "--quiet", "main"]));
116 res!(git(dir, &["merge", "--quiet", "--no-ff", "--no-verify", "side", "-m", "merge side"]));
117
118 res!(git(dir, &["tag", "-a", "v1", "-m", "release one"]));
119
120 git(dir, &["fast-export", "--all", "-M"])
121}
122
123/// The chunks are awkward on purpose, as a pipe would deliver them.
124fn parse_in_chunks(stream: &[u8])
125 -> Outcome<Vec<Event>>
126{
127 let mut parser = Parser::new();
128 let mut events = Vec::new();
129 for chunk in stream.chunks(13) {
130 parser.feed(chunk);
131 while let Some(event) = res!(parser.next_event()) {
132 events.push(event);
133 }
134 }
135 parser.end();
136 while let Some(event) = res!(parser.next_event()) {
137 events.push(event);
138 }
139 Ok(events)
140}
141
142#[test]
143#[ignore = "needs a git binary and a writable temporary directory"]
144fn parses_a_stream_written_by_git() -> Outcome<()> {
145 let dir = res!(scratch_dir());
146 let outcome = run_against_git(&dir);
147 // Clear up whatever happened, then report.
148 let _ = fs::remove_dir_all(&dir);
149 outcome
150}
151
152/// Kept out of the test so the scratch directory is removed however it ends.
153fn run_against_git(dir: &Path)
154 -> Outcome<()>
155{
156 let stream = res!(build_repo_and_export(dir));
157 assert!(!stream.is_empty(), "git wrote no stream");
158
159 let events = res!(parse_in_chunks(&stream));
160 // Parsing the same bytes in one go must give the same events.
161 let mut whole = Parser::new();
162 whole.feed(&stream);
163 whole.end();
164 let mut at_once = Vec::new();
165 while let Some(event) = res!(whole.next_event()) {
166 at_once.push(event);
167 }
168 assert_eq!(events, at_once, "chunked parse differed from whole parse");
169
170 let mut commits = Vec::new();
171 let mut blobs = Vec::new();
172 let mut tags = Vec::new();
173 let mut resets = Vec::new();
174 for event in &events {
175 match event {
176 Event::Commit(commit) => commits.push(commit),
177 Event::Blob(blob) => blobs.push(blob),
178 Event::Tag(tag) => tags.push(tag),
179 Event::Reset { refname, .. } => resets.push(refname.clone()),
180 Event::Progress(_) => {},
181 Event::Checkpoint => {},
182 Event::Alias { .. } => {},
183 Event::Feature { .. } => {},
184 Event::Opt(_) => {},
185 Event::Done => {},
186 }
187 }
188
189 // Four commits: the root, the rename, the side branch and the merge.
190 assert_eq!(commits.len(), 4, "commits were {:?}",
191 commits.iter().map(|c| String::from_utf8_lossy(&c.message)).collect::<Vec<_>>());
192
193 let messages: Vec<String> = commits.iter()
194 .map(|c| String::from_utf8_lossy(&c.message).trim_end().to_string())
195 .collect();
196 for want in ["first commit", "rename a", "side work", "merge side"] {
197 assert!(messages.iter().any(|m| m == want), "no commit said {:?}: {:?}",
198 want, messages);
199 }
200
201 // Every commit carries the identity git was told to use, parsed into
202 // fields rather than left as a line.
203 for commit in &commits {
204 assert_eq!(commit.committer.email, b"ada@example.org".to_vec());
205 assert_eq!(commit.committer.name, b"Ada Lovelace".to_vec());
206 assert_eq!(commit.committer.when.secs, 1_700_000_000);
207 assert_eq!(commit.committer.when.tz.mins, 600);
208 assert!(commit.mark.is_some(), "commit has no mark");
209 }
210
211 // The rename survives as a rename, not as a delete and an add.
212 let renames: Vec<&FileChange> = commits.iter()
213 .flat_map(|c| c.changes.iter())
214 .filter(|change| matches!(change, FileChange::Rename { .. }))
215 .collect();
216 assert_eq!(renames.len(), 1, "expected exactly one rename, got {:?}", renames);
217 assert_eq!(renames[0], &FileChange::Rename {
218 src: b"a.txt".to_vec(),
219 dst: b"renamed.txt".to_vec(),
220 });
221
222 // The merge commit has a first parent and one other.
223 let merges: Vec<&&oxedyne_fe2o3_ore::fastexport::Commit> = commits.iter()
224 .filter(|c| !c.merges.is_empty())
225 .collect();
226 assert_eq!(merges.len(), 1, "expected exactly one merge commit");
227 assert_eq!(merges[0].merges.len(), 1);
228 assert!(merges[0].from.is_some(), "merge commit has no first parent");
229 assert_eq!(String::from_utf8_lossy(&merges[0].message).trim_end(), "merge side");
230 assert!(matches!(merges[0].merges[0], ObjRef::Mark(_)),
231 "the second parent was not a mark");
232
233 // The binary blob arrives byte for byte.
234 assert!(blobs.iter().any(|b| b.data == BINARY),
235 "no blob held the binary file; blobs were {:?}",
236 blobs.iter().map(|b| b.data.len()).collect::<Vec<_>>());
237 assert!(blobs.iter().any(|b| b.data == b"hello\n".to_vec()),
238 "no blob held the text file");
239
240 // Every file entry points at a blob the stream has already marked, and
241 // every one is a normal file.
242 let marked: Vec<u64> = blobs.iter().filter_map(|b| b.mark).collect();
243 for commit in &commits {
244 for change in &commit.changes {
245 if let FileChange::Modify { mode, data, .. } = change {
246 assert_eq!(*mode, FileMode::Normal);
247 match data {
248 BlobRef::Mark(n) => assert!(marked.contains(n),
249 "file entry names mark :{}, which no blob minted", n),
250 other => return Err(err!(
251 "Expected a mark, got {:?}.", other; Test)),
252 }
253 }
254 }
255 }
256
257 // The annotated tag arrives with its tagger and its message.
258 assert_eq!(tags.len(), 1, "expected exactly one tag");
259 assert_eq!(tags[0].name, "v1");
260 assert!(String::from_utf8_lossy(&tags[0].message).starts_with("release one"),
261 "tag message was {:?}", String::from_utf8_lossy(&tags[0].message));
262 match &tags[0].tagger {
263 Some(person) => assert_eq!(person.email, b"ada@example.org".to_vec()),
264 None => return Err(err!("The tag has no tagger."; Test)),
265 }
266
267 // Both branches are named somewhere in the stream.
268 let refnames: Vec<&str> = commits.iter()
269 .map(|c| c.refname.as_str())
270 .chain(resets.iter().map(|r| r.as_str()))
271 .collect();
272 for want in ["refs/heads/main", "refs/heads/side"] {
273 assert!(refnames.iter().any(|r| *r == want),
274 "no reference named {:?}: {:?}", want, refnames);
275 }
276
277 // Nothing was left unparsed.
278 assert!(whole.is_exhausted(), "bytes were left over: {:?}",
279 String::from_utf8_lossy(whole.remaining()));
280
281 Ok(())
282}