oxedyne/ore/cli/tests/oracle.rs
14.1 KiB, 24 runs
created by r2848102244:33, 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 | //! The test that matters: git is the oracle. |
| 2 | //! |
| 3 | //! Everything else here proves the tool self-consistent, which proves nothing |
| 4 | //! about whether an imported repository is the repository that was imported. |
| 5 | //! This builds a real git repository with the git binary -- several commits, a |
| 6 | //! rename, a binary file, an executable script, a merge whose two sides edited |
| 7 | //! the same line, and a deletion -- imports it, and then compares every file of |
| 8 | //! the working copy, byte for byte and mode for mode, against what |
| 9 | //! `git checkout` of the same repository produces. |
| 10 | //! |
| 11 | //! It is not ignored. Git is on this machine, and a test of the importer that |
| 12 | //! does not run is a test of nothing. |
| 13 | |
| 14 | mod support; |
| 15 | |
| 16 | use support::{ |
| 17 | exec_bits_of, |
| 18 | git, |
| 19 | git_fixture, |
| 20 | git_init, |
| 21 | make_exec, |
| 22 | ore, |
| 23 | tree_of, |
| 24 | tree_raw, |
| 25 | write, |
| 26 | write_raw, |
| 27 | Scratch, |
| 28 | BINARY, |
| 29 | }; |
| 30 | |
| 31 | use oxedyne_fe2o3_core::prelude::*; |
| 32 | |
| 33 | use std::fs; |
| 34 | use std::os::unix::fs::PermissionsExt; |
| 35 | |
| 36 | |
| 37 | /// An imported repository is the repository that was imported. |
| 38 | #[test] |
| 39 | fn import_matches_git_byte_for_byte() -> Outcome<()> { |
| 40 | let scratch = res!(Scratch::new("oracle")); |
| 41 | let src = res!(scratch.sub("src")); |
| 42 | let into = res!(scratch.sub("ore")); |
| 43 | let check = scratch.path.join("check"); |
| 44 | |
| 45 | let commits = res!(git_fixture(&src)); |
| 46 | |
| 47 | res!(res!(ore(&into, &["init"])).good("init")); |
| 48 | let out = res!(ore(&into, &["import", &fmt!("{}", src.display())])); |
| 49 | let text = fmt!("{}", res!(out.good("import"))); |
| 50 | assert!(text.contains(&fmt!("imported {} commits", commits)), |
| 51 | "every commit is imported: {}", text); |
| 52 | |
| 53 | // git checks itself out into a directory of its own, and that is the answer |
| 54 | // the import has to match. |
| 55 | res!(git(&scratch.path, &["clone", "--quiet", |
| 56 | &fmt!("{}", src.display()), &fmt!("{}", check.display())])); |
| 57 | res!(git(&check, &["checkout", "--quiet", "main"])); |
| 58 | |
| 59 | let theirs = res!(tree_of(&check)); |
| 60 | let ours = res!(tree_of(&into)); |
| 61 | assert!(!theirs.is_empty(), "git checked something out"); |
| 62 | let mine: Vec<&String> = ours.keys().collect(); |
| 63 | let yours: Vec<&String> = theirs.keys().collect(); |
| 64 | assert_eq!(mine, yours, "the same files, and no others"); |
| 65 | for (path, want) in &theirs { |
| 66 | let got = match ours.get(path) { |
| 67 | Some(g) => g, |
| 68 | None => return Err(err!("The import has no {:?}.", path; Test, Missing)), |
| 69 | }; |
| 70 | assert_eq!(got.len(), want.len(), "{:?} is the same length", path); |
| 71 | assert!(got == want, "{:?} is byte for byte what git holds", path); |
| 72 | } |
| 73 | |
| 74 | // The same tree is not the same working copy if a script cannot be run, so |
| 75 | // the modes are compared beside the bytes and against the same oracle. |
| 76 | let their_modes = res!(exec_bits_of(&check)); |
| 77 | let our_modes = res!(exec_bits_of(&into)); |
| 78 | assert_eq!(our_modes, their_modes, "every file is executable exactly where \ |
| 79 | git's own checkout says it is"); |
| 80 | // And named outright, so the assertion above cannot pass by both sides being |
| 81 | // wrong in the same way. |
| 82 | assert_eq!(their_modes.get("tools/build.sh"), Some(&true), |
| 83 | "git checks the script out executable"); |
| 84 | assert_eq!(our_modes.get("tools/build.sh"), Some(&true), |
| 85 | "and so does the import, through a rename and an edit"); |
| 86 | assert_eq!(our_modes.get("later.sh"), Some(&false), |
| 87 | "a bit given and then taken away is taken away"); |
| 88 | assert_eq!(our_modes.get("f.txt"), Some(&false), |
| 89 | "an ordinary file stays ordinary"); |
| 90 | |
| 91 | // One mark per commit, and nothing left to capture: the working copy is |
| 92 | // exactly what the operations say it is. |
| 93 | let out = res!(ore(&into, &["log"])); |
| 94 | let text = fmt!("{}", res!(out.good("log"))); |
| 95 | let marks = text.lines().filter(|l| l.starts_with("mark ")).count(); |
| 96 | assert_eq!(marks, commits, "one mark per commit: {}", text); |
| 97 | assert!(text.contains("captured nothing"), |
| 98 | "the import left the working copy where the history says: {}", text); |
| 99 | for name in ["base", "trunk edit and rename", "side edit", "merge side", |
| 100 | "drop the deep file"] |
| 101 | { |
| 102 | assert!(text.contains(&fmt!("mark {:?}", name)), |
| 103 | "the mark {:?} is named after the commit: {}", name, text); |
| 104 | } |
| 105 | Ok(()) |
| 106 | } |
| 107 | |
| 108 | /// The two sides of the merge were concurrent, and the tool says so. |
| 109 | #[test] |
| 110 | fn a_merged_conflict_is_flagged_and_attributed() -> Outcome<()> { |
| 111 | let scratch = res!(Scratch::new("oracle_flags")); |
| 112 | let src = res!(scratch.sub("src")); |
| 113 | let into = res!(scratch.sub("ore")); |
| 114 | res!(git_fixture(&src)); |
| 115 | res!(res!(ore(&into, &["init"])).good("init")); |
| 116 | res!(res!(ore(&into, &["import", &fmt!("{}", src.display())])).good("import")); |
| 117 | |
| 118 | let out = res!(ore(&into, &["flags"])); |
| 119 | let text = fmt!("{}", res!(out.good("flags"))); |
| 120 | assert!(text.contains("overlap"), |
| 121 | "the two sides of the merge named the same bytes: {}", text); |
| 122 | assert!(text.contains("f.txt"), "and it says which file: {}", text); |
| 123 | |
| 124 | // The resolution is in the file, whoever the bytes came from. |
| 125 | let out = res!(ore(&into, &["who", "f.txt"])); |
| 126 | let text = fmt!("{}", res!(out.good("who f.txt"))); |
| 127 | assert!(text.contains("resolved"), |
| 128 | "the resolved bytes are shown against the operation that wrote them: {}", text); |
| 129 | Ok(()) |
| 130 | } |
| 131 | |
| 132 | /// An import will not be laid over a history that is already there. |
| 133 | #[test] |
| 134 | fn import_refuses_a_repository_that_holds_history() -> Outcome<()> { |
| 135 | let scratch = res!(Scratch::new("oracle_twice")); |
| 136 | let src = res!(scratch.sub("src")); |
| 137 | let into = res!(scratch.sub("ore")); |
| 138 | res!(git_fixture(&src)); |
| 139 | res!(res!(ore(&into, &["init"])).good("init")); |
| 140 | res!(write(&into, "mine.txt", b"already here\n")); |
| 141 | res!(res!(ore(&into, &["mark", "mine"])).good("mark mine")); |
| 142 | let out = res!(ore(&into, &["import", &fmt!("{}", src.display())])); |
| 143 | assert!(!out.ok, "the import is refused"); |
| 144 | assert!(out.err.contains("already holds"), "and says why: {}", out.err); |
| 145 | Ok(()) |
| 146 | } |
| 147 | |
| 148 | /// A path git permits and UTF-8 does not is imported like any other, and the |
| 149 | /// working copy holds the same bytes git checks out. |
| 150 | /// |
| 151 | /// Git stores a path as bytes and this vocabulary does too, so there is nothing |
| 152 | /// in the middle to lose them. The stream quotes such a path with octal escapes; |
| 153 | /// the parser unquotes it; nothing between there and the filesystem turns it into |
| 154 | /// a string. |
| 155 | #[test] |
| 156 | fn a_path_that_is_not_utf8_imports() -> Outcome<()> { |
| 157 | let scratch = res!(Scratch::new("oracle_bytes")); |
| 158 | let src = res!(scratch.sub("src")); |
| 159 | let into = res!(scratch.sub("ore")); |
| 160 | let check = scratch.path.join("check"); |
| 161 | |
| 162 | // Latin-1 "café.txt", a lone 0xff, and a directory component that is not |
| 163 | // UTF-8 either: three paths no string type will hold. |
| 164 | let cafe: &[u8] = b"caf\xe9.txt"; |
| 165 | let lone: &[u8] = b"\xff\xfe.bin"; |
| 166 | let deep: &[u8] = b"d\x80ir/n\x81ame.txt"; |
| 167 | |
| 168 | res!(git_init(&src)); |
| 169 | res!(write(&src, "plain.txt", b"ordinary\n")); |
| 170 | res!(write_raw(&src, cafe, b"latin one\n")); |
| 171 | res!(write_raw(&src, lone, BINARY)); |
| 172 | res!(write_raw(&src, deep, b"deep and unreadable\n")); |
| 173 | res!(git(&src, &["add", "-A"])); |
| 174 | res!(git(&src, &["commit", "--quiet", "--no-verify", "-m", "paths that are bytes"])); |
| 175 | |
| 176 | // And edited afterwards, so the importer splices against a path it can only |
| 177 | // have matched by bytes. |
| 178 | res!(write_raw(&src, cafe, b"latin one\nand two\n")); |
| 179 | res!(git(&src, &["add", "-A"])); |
| 180 | res!(git(&src, &["commit", "--quiet", "--no-verify", "-m", "edit the odd one"])); |
| 181 | |
| 182 | res!(res!(ore(&into, &["init"])).good("init")); |
| 183 | let out = res!(ore(&into, &["import", &fmt!("{}", src.display())])); |
| 184 | res!(out.good("import")); |
| 185 | |
| 186 | res!(git(&scratch.path, &["clone", "--quiet", |
| 187 | &fmt!("{}", src.display()), &fmt!("{}", check.display())])); |
| 188 | res!(git(&check, &["checkout", "--quiet", "main"])); |
| 189 | |
| 190 | let theirs = res!(tree_raw(&check)); |
| 191 | let ours = res!(tree_raw(&into)); |
| 192 | assert!(theirs.contains_key(cafe), "git holds the latin-1 path"); |
| 193 | let mine: Vec<&Vec<u8>> = ours.keys().collect(); |
| 194 | let yours: Vec<&Vec<u8>> = theirs.keys().collect(); |
| 195 | assert_eq!(mine, yours, "the same paths, byte for byte, and no others"); |
| 196 | for (path, want) in &theirs { |
| 197 | let got = match ours.get(path) { |
| 198 | Some(g) => g, |
| 199 | None => return Err(err!( |
| 200 | "The import has no {:?}.", String::from_utf8_lossy(path); Test, Missing)), |
| 201 | }; |
| 202 | assert!(got == want, "{:?} is byte for byte what git holds", |
| 203 | String::from_utf8_lossy(path)); |
| 204 | } |
| 205 | assert_eq!(ours.get(cafe).map(|v| v.as_slice()), Some(&b"latin one\nand two\n"[..]), |
| 206 | "the edit landed in the file it named"); |
| 207 | |
| 208 | // And the working copy is what the history says it is, so nothing about |
| 209 | // those paths is captured a second time. |
| 210 | let out = res!(ore(&into, &["log"])); |
| 211 | let text = fmt!("{}", res!(out.good("log"))); |
| 212 | assert!(text.contains("captured nothing"), |
| 213 | "a path that is not UTF-8 round trips through the working copy: {}", text); |
| 214 | Ok(()) |
| 215 | } |
| 216 | |
| 217 | /// A lightweight tag imports as a mark on the commit it named. |
| 218 | /// |
| 219 | /// A mark is what says "this point in history, by this name", which is exactly |
| 220 | /// what a lightweight tag is: a reference and nothing more. So no wire cost, no |
| 221 | /// new operation, and `ore back <tag>` works the moment the import finishes. |
| 222 | #[test] |
| 223 | fn a_lightweight_tag_imports_as_a_mark() -> Outcome<()> { |
| 224 | let scratch = res!(Scratch::new("oracle_tag")); |
| 225 | let src = res!(scratch.sub("src")); |
| 226 | let into = res!(scratch.sub("ore")); |
| 227 | |
| 228 | res!(git_init(&src)); |
| 229 | res!(write(&src, "a.txt", b"first\n")); |
| 230 | res!(git(&src, &["add", "-A"])); |
| 231 | res!(git(&src, &["commit", "--quiet", "--no-verify", "-m", "first"])); |
| 232 | res!(git(&src, &["tag", "v1.0"])); |
| 233 | res!(write(&src, "a.txt", b"first\nsecond\n")); |
| 234 | res!(git(&src, &["add", "-A"])); |
| 235 | res!(git(&src, &["commit", "--quiet", "--no-verify", "-m", "second"])); |
| 236 | res!(git(&src, &["tag", "v1.1"])); |
| 237 | |
| 238 | res!(res!(ore(&into, &["init"])).good("init")); |
| 239 | let out = res!(ore(&into, &["import", &fmt!("{}", src.display())])); |
| 240 | let text = fmt!("{}", res!(out.good("import"))); |
| 241 | assert!(text.contains("2 lightweight tags recorded as marks"), |
| 242 | "both tags are recorded, and the import says so: {}", text); |
| 243 | |
| 244 | let out = res!(ore(&into, &["log"])); |
| 245 | let text = fmt!("{}", res!(out.good("log"))); |
| 246 | assert!(text.contains("mark \"v1.0\""), "the tag is a mark: {}", text); |
| 247 | assert!(text.contains("mark \"v1.1\""), "and so is the other: {}", text); |
| 248 | |
| 249 | // And it names the point it tagged, which is what makes it worth having: |
| 250 | // v1.0 is the first commit's state, before the second line arrived. |
| 251 | res!(res!(ore(&into, &["back", "v1.0"])).good("back v1.0")); |
| 252 | let tree = res!(tree_of(&into)); |
| 253 | assert_eq!(tree.get("a.txt").map(|v| v.as_slice()), Some(&b"first\n"[..]), |
| 254 | "the tag stands where git put it"); |
| 255 | Ok(()) |
| 256 | } |
| 257 | |
| 258 | /// An annotated tag is refused, by name, rather than flattened into a mark that |
| 259 | /// could not carry its message. |
| 260 | #[test] |
| 261 | fn an_annotated_tag_is_refused() -> Outcome<()> { |
| 262 | let scratch = res!(Scratch::new("oracle_annotated")); |
| 263 | let src = res!(scratch.sub("src")); |
| 264 | let into = res!(scratch.sub("ore")); |
| 265 | |
| 266 | res!(git_init(&src)); |
| 267 | res!(write(&src, "a.txt", b"content\n")); |
| 268 | res!(git(&src, &["add", "-A"])); |
| 269 | res!(git(&src, &["commit", "--quiet", "--no-verify", "-m", "only commit"])); |
| 270 | res!(git(&src, &["tag", "-a", "release-1", "-m", "the first release"])); |
| 271 | |
| 272 | res!(res!(ore(&into, &["init"])).good("init")); |
| 273 | let out = res!(ore(&into, &["import", &fmt!("{}", src.display())])); |
| 274 | assert!(!out.ok, "the import is refused"); |
| 275 | assert!(out.err.contains("release-1"), "and names the tag: {}", out.err); |
| 276 | assert!(out.err.contains("annotated"), "and says what it could not carry: {}", |
| 277 | out.err); |
| 278 | Ok(()) |
| 279 | } |
| 280 | |
| 281 | /// A submodule is refused, by path, rather than skipped in silence. |
| 282 | /// |
| 283 | /// Silently leaving the entry out was the old behaviour, and it is the wrong |
| 284 | /// kind of nothing: a repository with submodules would have imported with a |
| 285 | /// hole in every tree from that commit on and no word said. |
| 286 | #[test] |
| 287 | fn a_submodule_is_refused() -> Outcome<()> { |
| 288 | let scratch = res!(Scratch::new("oracle_gitlink")); |
| 289 | let src = res!(scratch.sub("src")); |
| 290 | let into = res!(scratch.sub("ore")); |
| 291 | |
| 292 | res!(git_init(&src)); |
| 293 | res!(write(&src, "a.txt", b"content\n")); |
| 294 | res!(git(&src, &["add", "-A"])); |
| 295 | res!(git(&src, &["commit", "--quiet", "--no-verify", "-m", "base"])); |
| 296 | // A gitlink written straight into the index, which is what a submodule |
| 297 | // leaves in a tree once the dance around it is done. |
| 298 | res!(git(&src, &["update-index", "--add", "--cacheinfo", |
| 299 | "160000,0000000000000000000000000000000000000001,vendor"])); |
| 300 | res!(git(&src, &["commit", "--quiet", "--no-verify", "-m", "add a submodule"])); |
| 301 | |
| 302 | res!(res!(ore(&into, &["init"])).good("init")); |
| 303 | let out = res!(ore(&into, &["import", &fmt!("{}", src.display())])); |
| 304 | assert!(!out.ok, "the import is refused"); |
| 305 | assert!(out.err.contains("vendor"), "and names the path: {}", out.err); |
| 306 | assert!(out.err.contains("submodule"), "and says what it is: {}", out.err); |
| 307 | assert!(out.err.contains("160000"), "and the mode git recorded: {}", out.err); |
| 308 | Ok(()) |
| 309 | } |
| 310 | |
| 311 | /// Materialising a state puts a file's mode back, even where not one byte of it |
| 312 | /// changed. |
| 313 | /// |
| 314 | /// A script whose executable bit has been cleared is not the file the history |
| 315 | /// says it is, however well its bytes match, so the bit is asserted on every |
| 316 | /// path of the tree rather than only on the ones being rewritten. |
| 317 | #[test] |
| 318 | fn back_restores_a_mode_the_working_copy_lost() -> Outcome<()> { |
| 319 | let scratch = res!(Scratch::new("oracle_remode")); |
| 320 | let src = res!(scratch.sub("src")); |
| 321 | let into = res!(scratch.sub("ore")); |
| 322 | |
| 323 | res!(git_init(&src)); |
| 324 | res!(write(&src, "run.sh", b"#!/bin/sh\necho go\n")); |
| 325 | res!(make_exec(&src.join("run.sh"))); |
| 326 | res!(write(&src, "plain.txt", b"ordinary\n")); |
| 327 | res!(git(&src, &["add", "-A"])); |
| 328 | res!(git(&src, &["commit", "--quiet", "--no-verify", "-m", "base"])); |
| 329 | |
| 330 | res!(res!(ore(&into, &["init"])).good("init")); |
| 331 | res!(res!(ore(&into, &["import", &fmt!("{}", src.display())])).good("import")); |
| 332 | let script = into.join("run.sh"); |
| 333 | assert!(res!(fs::metadata(&script)).permissions().mode() & 0o100 != 0, |
| 334 | "the import wrote it executable"); |
| 335 | |
| 336 | // Somebody clears the bit. Not a byte of the file changed, so there is |
| 337 | // nothing for a capture to see and nothing it should invent. |
| 338 | let mode = res!(fs::metadata(&script)).permissions().mode(); |
| 339 | res!(fs::set_permissions(&script, fs::Permissions::from_mode(mode & !0o111))); |
| 340 | let out = res!(ore(&into, &["log"])); |
| 341 | let text = fmt!("{}", res!(out.good("log"))); |
| 342 | assert!(text.contains("captured nothing"), |
| 343 | "a mode is not something the working copy scan records: {}", text); |
| 344 | |
| 345 | res!(res!(ore(&into, &["back", "base"])).good("back base")); |
| 346 | assert!(res!(fs::metadata(&script)).permissions().mode() & 0o100 != 0, |
| 347 | "and materialising the state puts it back"); |
| 348 | assert!(res!(fs::metadata(into.join("plain.txt"))).permissions().mode() & 0o111 == 0, |
| 349 | "while an ordinary file is left ordinary"); |
| 350 | Ok(()) |
| 351 | } |