oxedyne/ore/cli/tests/divergence.rs
16.6 KiB, 33 runs
created by r2848102244:27, 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 | //! Two writers, two branches, one line: what the tool says about it. |
| 2 | //! |
| 3 | //! A second replica is awkward to conjure in version zero, there being no sync |
| 4 | //! verb yet to bring one replica's operations to another. The importer supplies |
| 5 | //! one for nothing: each git author becomes a replica of their own, so a |
| 6 | //! repository two people committed to imports as a history two replicas wrote. |
| 7 | //! That is what these tests use. |
| 8 | |
| 9 | mod support; |
| 10 | |
| 11 | use support::{ |
| 12 | git, |
| 13 | git_as, |
| 14 | git_init, |
| 15 | marks, |
| 16 | ore, |
| 17 | write, |
| 18 | Scratch, |
| 19 | }; |
| 20 | |
| 21 | use oxedyne_fe2o3_core::prelude::*; |
| 22 | |
| 23 | use std::collections::BTreeSet; |
| 24 | use std::path::Path; |
| 25 | |
| 26 | |
| 27 | /// Builds a repository whose two branches were written by two people and never |
| 28 | /// merged, both of them editing the same line of one file. |
| 29 | fn diverge(src: &Path) |
| 30 | -> Outcome<()> |
| 31 | { |
| 32 | res!(git_init(src)); |
| 33 | res!(write(src, "f.txt", b"alpha\nbeta\ngamma\n")); |
| 34 | res!(git(src, &["add", "."])); |
| 35 | res!(git(src, &["commit", "--quiet", "--no-verify", "-m", "base"])); |
| 36 | |
| 37 | res!(write(src, "f.txt", b"alpha\nBETA by Ada\ngamma\n")); |
| 38 | res!(git(src, &["add", "."])); |
| 39 | res!(git(src, &["commit", "--quiet", "--no-verify", "-m", "Ada edits the middle"])); |
| 40 | |
| 41 | res!(git(src, &["checkout", "--quiet", "-b", "side", "HEAD~1"])); |
| 42 | res!(write(src, "f.txt", b"alpha\nBETA by Grace\ngamma\n")); |
| 43 | res!(git_as(src, "Grace Hopper", "grace@example.org", |
| 44 | &["add", "."])); |
| 45 | res!(git_as(src, "Grace Hopper", "grace@example.org", |
| 46 | &["commit", "--quiet", "--no-verify", "-m", "Grace edits the middle"])); |
| 47 | res!(git(src, &["checkout", "--quiet", "main"])); |
| 48 | Ok(()) |
| 49 | } |
| 50 | |
| 51 | /// Returns the replicas the runs of a `who` listing name. |
| 52 | fn replicas_in(text: &str) -> BTreeSet<String> { |
| 53 | let mut out = BTreeSet::new(); |
| 54 | for line in text.lines() { |
| 55 | let trimmed = line.trim_start(); |
| 56 | // A run line begins with its byte range, so the replica is the field that |
| 57 | // follows it. |
| 58 | if !trimmed.starts_with(|c: char| c.is_ascii_digit()) { |
| 59 | continue; |
| 60 | } |
| 61 | for field in trimmed.split_whitespace() { |
| 62 | if field.starts_with('r') && field.contains(':') { |
| 63 | if let Some(name) = field.split(':').next() { |
| 64 | out.insert(fmt!("{}", name)); |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | } |
| 69 | out |
| 70 | } |
| 71 | |
| 72 | /// Two branches that touched the same line are concurrent, and `flags` says so. |
| 73 | #[test] |
| 74 | fn divergent_branches_raise_an_overlap() -> Outcome<()> { |
| 75 | let scratch = res!(Scratch::new("diverge")); |
| 76 | let src = res!(scratch.sub("src")); |
| 77 | let into = res!(scratch.sub("ore")); |
| 78 | res!(diverge(&src)); |
| 79 | res!(res!(ore(&into, &["init"])).good("init")); |
| 80 | let out = res!(ore(&into, &["import", &fmt!("{}", src.display())])); |
| 81 | let text = fmt!("{}", res!(out.good("import"))); |
| 82 | assert!(text.contains("2 authors"), "two people wrote it: {}", text); |
| 83 | |
| 84 | let out = res!(ore(&into, &["flags"])); |
| 85 | let text = fmt!("{}", res!(out.good("flags"))); |
| 86 | assert!(text.contains("overlap"), |
| 87 | "the two edits were concurrent and neither author could see the other: {}", |
| 88 | text); |
| 89 | assert!(text.contains("f.txt"), "and it says which file: {}", text); |
| 90 | assert!(!text.contains("clean"), "which is not a clean history: {}", text); |
| 91 | Ok(()) |
| 92 | } |
| 93 | |
| 94 | /// `who` attributes the bytes of a file to the replicas whose work is in it, and |
| 95 | /// the arbitration decides whose that is. |
| 96 | /// |
| 97 | /// Both writers replaced the same line, so they are one overlap group: the member |
| 98 | /// highest in op order prevails and the other yields, its insertion buried whole. |
| 99 | /// The file therefore shows one writer's bytes over the contended line, not two, |
| 100 | /// and `who` says so rather than attributing half a line to each. The writer who |
| 101 | /// yielded is not silently gone -- `flags` names them -- and that is the division |
| 102 | /// of labour the rule was adopted for: the file is one author's, and the other's |
| 103 | /// is one flag away. |
| 104 | #[test] |
| 105 | fn who_attributes_only_the_bytes_that_prevailed() -> Outcome<()> { |
| 106 | let scratch = res!(Scratch::new("who_two")); |
| 107 | let src = res!(scratch.sub("src")); |
| 108 | let into = res!(scratch.sub("ore")); |
| 109 | res!(diverge(&src)); |
| 110 | res!(res!(ore(&into, &["init"])).good("init")); |
| 111 | res!(res!(ore(&into, &["import", &fmt!("{}", src.display())])).good("import")); |
| 112 | |
| 113 | let out = res!(ore(&into, &["who", "f.txt"])); |
| 114 | let text = fmt!("{}", res!(out.good("who f.txt"))); |
| 115 | let replicas = replicas_in(&text); |
| 116 | assert_eq!(replicas.len(), 1, |
| 117 | "the contended line reads as one writer, whole, not {:?}: {}", replicas, text); |
| 118 | assert!(text.contains("yielded"), |
| 119 | "and the writer who did not prevail is named rather than dropped: {}", text); |
| 120 | |
| 121 | // An import authors operations here, on those writers' behalf, and seals |
| 122 | // them with this repository's key: the operations carry the git authors' |
| 123 | // replicas and the signature says who actually vouches for the bytes, which |
| 124 | // is whoever ran the import. |
| 125 | for run in support::runs_in(&text) { |
| 126 | assert_eq!(run.mark, '+', |
| 127 | "an imported operation is signed by the importer: {}", text); |
| 128 | } |
| 129 | Ok(()) |
| 130 | } |
| 131 | |
| 132 | /// The whole history is written into the working copy, not one branch of it, and |
| 133 | /// a branch is reached by the mark that stands for its commit. |
| 134 | /// |
| 135 | /// Ore has no branches to point at: the state is the operation set, and the |
| 136 | /// render of the whole set is what the history says the working copy is. Writing |
| 137 | /// out one branch instead would leave the very next capture asserting that branch |
| 138 | /// as the truth, which is one splice's worth of noise when two branches share a |
| 139 | /// file and a file deletion when they do not. |
| 140 | #[test] |
| 141 | fn the_working_copy_is_the_whole_history() -> Outcome<()> { |
| 142 | let scratch = res!(Scratch::new("head_only")); |
| 143 | let src = res!(scratch.sub("src")); |
| 144 | let into = res!(scratch.sub("ore")); |
| 145 | res!(diverge(&src)); |
| 146 | res!(res!(ore(&into, &["init"])).good("init")); |
| 147 | res!(res!(ore(&into, &["import", &fmt!("{}", src.display())])).good("import")); |
| 148 | |
| 149 | // The file is the arbitrated merge of the two edits. Two writers who |
| 150 | // concurrently replaced one line are one overlap group; the member highest in |
| 151 | // op order prevails and the other yields, its removals not burying and its |
| 152 | // insertion buried whole, so the line reads as one of them wrote it. What is |
| 153 | // being asserted here is not which of them: it is that the whole history was |
| 154 | // written out -- the lines neither of them touched are present, and the state |
| 155 | // on disk is a state one of them actually authored rather than a mixture no |
| 156 | // compiler would take. |
| 157 | let bytes = res!(std::fs::read(into.join("f.txt"))); |
| 158 | let text = fmt!("{}", String::from_utf8_lossy(&bytes)); |
| 159 | assert!(text == "alpha\nBETA by Ada\ngamma\n" || text == "alpha\nBETA by Grace\ngamma\n", |
| 160 | "the contended line is one writer's, whole: {:?}", text); |
| 161 | assert!(text.starts_with("alpha\n") && text.ends_with("gamma\n"), |
| 162 | "and the lines neither of them touched: {:?}", text); |
| 163 | |
| 164 | // The version that is not on disk is in the log and flagged, not discarded, |
| 165 | // and the spill puts it beside the working copy for a reader with no forge. |
| 166 | let out = res!(ore(&into, &["flags"])); |
| 167 | let said = fmt!("{}", res!(out.good("flags"))); |
| 168 | assert!(said.contains("yielded"), |
| 169 | "the buried version is named rather than dropped: {}", said); |
| 170 | let spilled = res!(std::fs::read_dir(into.join(".ore").join("collisions"))); |
| 171 | let mut buried: Vec<Vec<u8>> = Vec::new(); |
| 172 | for entry in spilled { |
| 173 | buried.push(res!(std::fs::read(res!(entry).path()))); |
| 174 | } |
| 175 | assert_eq!(buried.len(), 1, "one version yielded, so one is spilled"); |
| 176 | let held = fmt!("{}", String::from_utf8_lossy(&buried[0])); |
| 177 | assert!(held.contains("BETA by "), |
| 178 | "and the spill holds the line its author wrote: {:?}", held); |
| 179 | assert!(!text.contains(held.trim_end_matches('\n')), |
| 180 | "which is the version the file does not hold: {:?} against {:?}", held, text); |
| 181 | |
| 182 | // Both branches are in the history: three commits, three marks. The marks the |
| 183 | // import named are counted apart from the one the tool wrote when the command |
| 184 | // finished, because the claim is about commits and that one stands for no |
| 185 | // commit. `--auto` because the plain listing shows only the named ones, which |
| 186 | // is the point of it. |
| 187 | let out = res!(ore(&into, &["log", "--auto"])); |
| 188 | let text = fmt!("{}", res!(out.good("log"))); |
| 189 | let (named, automatic) = marks(&text); |
| 190 | assert_eq!(named, 3, "every commit of every branch is a mark: {}", text); |
| 191 | assert_eq!(automatic, 1, "and the import ends at one point of its own: {}", text); |
| 192 | assert!(text.contains("captured nothing"), |
| 193 | "the import wrote what the history says, so there is nothing to capture: {}", |
| 194 | text); |
| 195 | |
| 196 | // One branch's state is reached by the mark that stands for its commit. |
| 197 | res!(res!(ore(&into, &["back", "Ada edits the middle"])).good("back")); |
| 198 | let bytes = res!(std::fs::read(into.join("f.txt"))); |
| 199 | assert_eq!(bytes, b"alpha\nBETA by Ada\ngamma\n".to_vec(), |
| 200 | "a mark names the state its commit left behind"); |
| 201 | Ok(()) |
| 202 | } |
| 203 | |
| 204 | /// Builds a repository whose two branches each *created* one path, and were |
| 205 | /// never merged: two files at one name, which is the state a path cannot |
| 206 | /// describe and an identity can. |
| 207 | fn clash(src: &Path) |
| 208 | -> Outcome<()> |
| 209 | { |
| 210 | res!(git_init(src)); |
| 211 | res!(write(src, "a.txt", b"the file they agree about\n")); |
| 212 | res!(git(src, &["add", "."])); |
| 213 | res!(git(src, &["commit", "--quiet", "--no-verify", "-m", "base"])); |
| 214 | let base = res!(git(src, &["rev-parse", "HEAD"])); |
| 215 | let base = fmt!("{}", String::from_utf8_lossy(&base).trim()); |
| 216 | |
| 217 | res!(write(src, "notes.md", MAIN_NOTES)); |
| 218 | res!(git(src, &["add", "."])); |
| 219 | res!(git(src, &["commit", "--quiet", "--no-verify", "-m", "Ada starts the notes"])); |
| 220 | |
| 221 | res!(git(src, &["checkout", "--quiet", "-b", "side", &base])); |
| 222 | res!(write(src, "notes.md", SIDE_NOTES)); |
| 223 | res!(git_as(src, "Grace Hopper", "grace@example.org", &["add", "."])); |
| 224 | res!(git_as(src, "Grace Hopper", "grace@example.org", |
| 225 | &["commit", "--quiet", "--no-verify", "-m", "Grace starts the notes"])); |
| 226 | res!(git(src, &["checkout", "--quiet", "main"])); |
| 227 | Ok(()) |
| 228 | } |
| 229 | |
| 230 | /// The notes one branch wrote. |
| 231 | const MAIN_NOTES: &[u8] = b"notes from the trunk\n"; |
| 232 | /// The notes the other branch wrote, at the same path and knowing nothing of it. |
| 233 | const SIDE_NOTES: &[u8] = b"notes from the side\n"; |
| 234 | |
| 235 | /// Two branches that each created one path are two files, and both of them reach |
| 236 | /// the working copy: one under the name, one under a derived name. |
| 237 | /// |
| 238 | /// A path is metadata and a file is an identity, so this is not a conflict to be |
| 239 | /// resolved; it is a state the repository is genuinely in. What the tool owes the |
| 240 | /// reader is a deterministic name for the second file and a plain sentence saying |
| 241 | /// what it did. |
| 242 | #[test] |
| 243 | fn two_files_at_one_path_both_reach_the_working_copy() -> Outcome<()> { |
| 244 | let scratch = res!(Scratch::new("clash")); |
| 245 | let src = res!(scratch.sub("src")); |
| 246 | let into = res!(scratch.sub("ore")); |
| 247 | res!(clash(&src)); |
| 248 | res!(res!(ore(&into, &["init"])).good("init")); |
| 249 | let out = res!(ore(&into, &["import", &fmt!("{}", src.display())])); |
| 250 | let text = fmt!("{}", res!(out.good("import"))); |
| 251 | assert!(text.contains("clash at notes.md"), |
| 252 | "the import says what it did about the clash: {}", text); |
| 253 | |
| 254 | // Both files are on disk: one at the path, one at the derived name. |
| 255 | let now = res!(std::fs::read_dir(&into)); |
| 256 | let mut derived: Vec<String> = Vec::new(); |
| 257 | for entry in now { |
| 258 | let name = res!(entry).file_name().to_string_lossy().into_owned(); |
| 259 | if name.starts_with("notes.md.clash-") { |
| 260 | derived.push(name); |
| 261 | } |
| 262 | } |
| 263 | assert_eq!(derived.len(), 1, "one file lost the name: {:?}", derived); |
| 264 | let kept = res!(std::fs::read(into.join("notes.md"))); |
| 265 | let moved = res!(std::fs::read(into.join(&derived[0]))); |
| 266 | let mut both = vec![kept.clone(), moved.clone()]; |
| 267 | both.sort(); |
| 268 | let mut want = vec![MAIN_NOTES.to_vec(), SIDE_NOTES.to_vec()]; |
| 269 | want.sort(); |
| 270 | assert_eq!(both, want, "both branches' notes are there, and nothing else is"); |
| 271 | |
| 272 | // The derived name names its file's identity, so it is the same name every |
| 273 | // time and on every replica. |
| 274 | let tail = match derived[0].strip_prefix("notes.md.clash-r") { |
| 275 | Some(t) => t, |
| 276 | None => return Err(err!( |
| 277 | "The derived name {:?} does not name a replica.", derived[0]; Test)), |
| 278 | }; |
| 279 | let mut parts = tail.split('-'); |
| 280 | let replica = parts.next().unwrap_or(""); |
| 281 | let counter = parts.next().unwrap_or(""); |
| 282 | assert!(!replica.is_empty() && replica.chars().all(|c| c.is_ascii_digit()), |
| 283 | "the derived name carries a replica: {:?}", derived[0]); |
| 284 | assert!(!counter.is_empty() && counter.chars().all(|c| c.is_ascii_digit()), |
| 285 | "and the counter of the operation that created the file: {:?}", derived[0]); |
| 286 | |
| 287 | // The clash is reported, and it is not something the renderer flagged: two |
| 288 | // files at one path is a fact about the repository, not a fault in it. |
| 289 | let out = res!(ore(&into, &["flags"])); |
| 290 | let text = fmt!("{}", res!(out.good("flags"))); |
| 291 | assert!(text.contains("captured nothing"), |
| 292 | "the working copy already holds both files: {}", text); |
| 293 | assert!(text.contains("clash at notes.md: 2 files hold that path"), |
| 294 | "`ore flags` says what happened: {}", text); |
| 295 | assert!(text.contains("keeps the name, being highest in op order"), |
| 296 | "and how it was decided: {}", text); |
| 297 | assert!(text.contains("is written as notes.md.clash-r"), |
| 298 | "and where the other went: {}", text); |
| 299 | assert!(text.contains("1 path claimed by more than one"), |
| 300 | "a clash is not a flag, and the summary does not call the tree clean: {}", text); |
| 301 | assert!(!text.contains("clean:"), "which is what `clean` would have said: {}", text); |
| 302 | |
| 303 | // The file under the derived name is accounted for under that name, since |
| 304 | // that is the name a person has in front of them. |
| 305 | let out = res!(ore(&into, &["who", &derived[0]])); |
| 306 | let text = fmt!("{}", res!(out.good("who"))); |
| 307 | assert!(text.contains("recorded at notes.md"), |
| 308 | "`ore who` says which path the file really holds: {}", text); |
| 309 | |
| 310 | // And the history is untouched by any of it: three commits, three marks, |
| 311 | // beside the one the import's own command wrote at the end. |
| 312 | let out = res!(ore(&into, &["log", "--auto"])); |
| 313 | let text = fmt!("{}", res!(out.good("log"))); |
| 314 | let (named, automatic) = marks(&text); |
| 315 | assert_eq!(named, 3, "every commit is a mark: {}", text); |
| 316 | assert_eq!(automatic, 1, "and the import ends at one point of its own: {}", text); |
| 317 | Ok(()) |
| 318 | } |
| 319 | |
| 320 | |
| 321 | /// One person writing under several identity lines imports as one replica, when |
| 322 | /// the configuration says they are one person. |
| 323 | /// |
| 324 | /// A replica identifier is derived from the git identity line, so a name that |
| 325 | /// changed or an address that lapsed imports as two authors who never met. That |
| 326 | /// cannot be corrected afterwards: the identifier is part of every operation |
| 327 | /// identifier the author writes, and the header carrying it is covered by the |
| 328 | /// signature. The mapping in `.ore/config` is the only chance to say so, and the |
| 329 | /// import consults it before deriving anything. |
| 330 | #[test] |
| 331 | fn several_identity_lines_import_as_one_author_when_the_map_says_so() -> Outcome<()> { |
| 332 | let scratch = res!(Scratch::new("diverge_authors")); |
| 333 | let src = res!(scratch.sub("src")); |
| 334 | let into = res!(scratch.sub("ore")); |
| 335 | |
| 336 | // One person, two identity lines, committing in turn. |
| 337 | res!(git_init(&src)); |
| 338 | res!(write(&src, "f.txt", b"one\n")); |
| 339 | res!(git(&src, &["add", "."])); |
| 340 | res!(git(&src, &["commit", "--quiet", "--no-verify", "-m", "first"])); |
| 341 | res!(write(&src, "f.txt", b"one\ntwo\n")); |
| 342 | res!(git_as(&src, "Ada L", "ada@old.example", &["add", "."])); |
| 343 | res!(git_as(&src, "Ada L", "ada@old.example", |
| 344 | &["commit", "--quiet", "--no-verify", "-m", "second"])); |
| 345 | |
| 346 | res!(res!(ore(&into, &["init"])).good("init")); |
| 347 | |
| 348 | // Say, before importing, that the two lines are one person. The identity line |
| 349 | // is exactly what git writes: `name <email>`. |
| 350 | let cfg = into.join(".ore").join("config"); |
| 351 | let text = fmt!("{}", String::from_utf8_lossy(&res!(std::fs::read(&cfg)))); |
| 352 | let ran = res!(ore(&into, &["log"])); |
| 353 | let named = fmt!("{}", res!(ran.good("log"))); |
| 354 | let mut mine: Option<String> = None; |
| 355 | for field in named.split_whitespace() { |
| 356 | if let Some(rest) = field.strip_prefix('r') { |
| 357 | if rest.chars().all(|c| c.is_ascii_digit()) && !rest.is_empty() { |
| 358 | mine = Some(fmt!("{}", rest)); |
| 359 | break; |
| 360 | } |
| 361 | } |
| 362 | } |
| 363 | let mine = res!(mine.ok_or_else(|| err!( |
| 364 | "`ore log` named no replica for the repository just made: {}", named; |
| 365 | Test, Missing))); |
| 366 | // Every identity line the history carries, including the one the harness's |
| 367 | // own git configuration writes. An unmapped line is derived, which is the |
| 368 | // behaviour being distinguished from. |
| 369 | let map = fmt!( |
| 370 | "\"authors\": {{\n \"Ada L <ada@old.example>\": (u64|{}),\n \ |
| 371 | \"Ada Lovelace <ada@example.org>\": (u64|{}),\n \ |
| 372 | \"Test User <test@example.org>\": (u64|{}),\n }},", mine, mine, mine); |
| 373 | let text = text.replacen("\"authors\": {},", &map, 1); |
| 374 | res!(std::fs::write(&cfg, text.as_bytes())); |
| 375 | |
| 376 | let out = res!(ore(&into, &["import", &fmt!("{}", src.display())])); |
| 377 | let said = fmt!("{}", res!(out.good("import"))); |
| 378 | |
| 379 | // Both commits are one author's, so the import says one and `who` names one. |
| 380 | assert!(said.contains("1 author"), |
| 381 | "two identity lines mapped to one replica import as one author: {}", said); |
| 382 | let out = res!(ore(&into, &["who", "f.txt"])); |
| 383 | let text = fmt!("{}", res!(out.good("who"))); |
| 384 | let named = replicas_in(&text); |
| 385 | assert_eq!(named.len(), 1, |
| 386 | "the file's bytes are one person's, and `who` names {:?}: {}", named, text); |
| 387 | Ok(()) |
| 388 | } |