Oregami
Repositories/oxedyne/ore

oxedyne/ore/cli/src/stat.rs

29.1 KiB, 1 run

created by r2848102244:738, 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//! What the working copy looked like when it last matched the history.
2//!
3//! Every verb but `init` begins by capturing, and capturing means rendering the
4//! whole history and then reading every tracked file off the disk to compare
5//! against it. On a 44,628 operation repository the render alone is 100 MB of
6//! the 169 MB an `ore log` costs, and `ore log` never looks at a file. Git does
7//! not do this: it keeps each file's size and modification time beside its name
8//! and skips anything those two say is untouched, which is why `git status`
9//! answers in milliseconds.
10//!
11//! This is that index. When it says the working copy is exactly where it was,
12//! the capture reads no file and renders nothing at all.
13//!
14//! # It is a guess about content, and that is the whole difficulty
15//!
16//! The verdict cache next door could gamble safely because a lost gamble was
17//! caught afterwards by a digest of the bytes. **Nothing catches this one.** If
18//! the index says unchanged and the file changed, the edit is not refused and
19//! not reported -- it is gone, and the history says it never happened. So every
20//! rule here is written to fail towards reading, and an index that is
21//! occasionally slow is right where one that is occasionally wrong is not:
22//!
23//! - **A path the walk finds and the index does not know, or the index knows
24//! and the walk cannot find, is a change.** No attempt is made to work out
25//! what happened to it.
26//! - **Four things must all agree**, not two: the size, the modification time,
27//! the change time and the inode. `touch -r` sets the modification time and
28//! cannot set the change time; a restore from a backup, a `cp -p` or a `tar
29//! -x` writes a new inode. Git checks these too and turns them off on
30//! platforms that cannot supply them; here they are simply required, because
31//! the tool is built for one that can.
32//! - **The history must not have moved.** The index names the frontier it was
33//! taken at, because a working copy that has not changed still differs from a
34//! render that has.
35//! - **Anything read too recently to be sure about is read again.** See below.
36//! - **It is only written after a capture that appended nothing**, which is the
37//! one moment the working copy is known to be what the history says. A
38//! capture that recorded something leaves no index, and the command after it
39//! pays one full capture to establish a fresh one.
40//!
41//! # The racily clean file, and the number this rests on
42//!
43//! A file written in the same clock tick that its size and time were recorded
44//! in cannot be told apart from one that was not, if the write left the size
45//! alone. Git meets this by comparing each file's time against the index file's
46//! own and re-reading anything not strictly older.
47//!
48//! The rule here is stricter and needs no comparison with a file: the clock is
49//! read **before** any file is looked at, that reading is what the index
50//! records as `taken`, and an entry is believed only where its modification
51//! time is at least [`RACY`] older than it. Any write from the moment the
52//! command began stamps a time at or after `taken`, so it cannot leave a
53//! recorded time that satisfies that; and a write in the recorded time's own
54//! tick is excluded because that tick is required to have ended before the
55//! command started.
56//!
57//! [`RACY`] is two seconds, and it is **not** a measurement of the filesystem
58//! this was written on. Measured there, on ext4: twenty thousand consecutive
59//! writes to one file produced twenty thousand distinct modification times, the
60//! smallest gap being 16.8 microseconds, and three thousand files created back
61//! to back shared no time with each other. So the real figure here is under
62//! twenty microseconds. Two seconds is chosen to be coarser than any filesystem
63//! the tool is likely to meet -- FAT stamps to two seconds, HFS+ to one, and an
64//! NFS server's clock may simply be behind this one's -- and what it costs is
65//! that a file touched in the last two seconds is read rather than believed,
66//! which is the file the reader wanted to read anyway.
67//!
68//! # It is replica-local, derived, and safe to delete
69//!
70//! It sits beside the log, it is never put in a segment or a sync message, and
71//! losing it costs one full capture. Anything about it that does not parse is
72//! treated as an absent index rather than as an error, which is the same
73//! failure the absence itself is: read everything.
74
75use ore_store::keys::{
76 bytes_of,
77 text_of,
78};
79
80use oxedyne_fe2o3_core::prelude::*;
81use oxedyne_fe2o3_ore::id::OpId;
82use oxedyne_fe2o3_ore::op::Op;
83
84use std::collections::BTreeMap;
85use std::fs;
86use std::os::unix::fs::MetadataExt;
87use std::path::{
88 Path,
89 PathBuf,
90};
91use std::str::FromStr;
92use std::time::{
93 SystemTime,
94 UNIX_EPOCH,
95};
96
97
98// The file, and what it says about itself
99pub const STAT_FILE: &str = "stat";
100pub const STAT_FORMAT: &str = "ORESTAT 1";
101
102/// How much older than the reading of the clock a file's modification time must
103/// be before it is believed. See the module note: two seconds, deliberately far
104/// coarser than the filesystem this was written on.
105pub const RACY: i128 = 2_000_000_000;
106
107
108/// What was seen at one path when it was last read.
109#[derive(Clone, Copy, Debug, Eq, PartialEq)]
110pub struct Seen {
111 len: u64, // bytes
112 mtime: i128, // nanoseconds since the epoch
113 ctime: i128, // which a write bumps and which `touch` cannot set
114 ino: u64, // which a restore, a copy or a checkout replaces
115}
116
117impl Seen {
118 /// Reads what the filesystem says about a path, without following a link.
119 ///
120 /// `None` is anything that is not a regular file, a link included: the walk
121 /// records only what it would have read, and nothing else is content.
122 pub fn of(path: &Path)
123 -> Option<Self>
124 {
125 let meta = match fs::symlink_metadata(path) {
126 Ok(m) => m,
127 Err(_) => return None,
128 };
129 if !meta.is_file() {
130 return None;
131 }
132 Some(Self {
133 len: meta.len(),
134 mtime: meta.mtime() as i128 * 1_000_000_000 + meta.mtime_nsec() as i128,
135 ctime: meta.ctime() as i128 * 1_000_000_000 + meta.ctime_nsec() as i128,
136 ino: meta.ino(),
137 })
138 }
139
140 /// Is this old enough to be believed, for an index taken at `taken`?
141 pub fn settled(&self, taken: i128) -> bool {
142 match self.mtime.checked_add(RACY) {
143 Some(after) => after <= taken,
144 None => false,
145 }
146 }
147}
148
149
150/// The working copy as it stood when it last matched the history.
151#[derive(Clone, Debug, Default)]
152pub struct Stats {
153 taken: i128, // the clock, read before a single file was looked at
154 at: Vec<OpId>, // the frontier this describes
155 seen: BTreeMap<Vec<u8>, Seen>,
156}
157
158impl Stats {
159
160 /// Begins an index, reading the clock now, before anything is looked at.
161 ///
162 /// The order is the whole of the racy rule: a reading taken afterwards would
163 /// be later than a write that raced the scan, and would believe it.
164 pub fn beginning()
165 -> Outcome<Self>
166 {
167 let now = res!(SystemTime::now().duration_since(UNIX_EPOCH));
168 Ok(Self {
169 taken: now.as_nanos() as i128,
170 at: Vec::new(),
171 seen: BTreeMap::new(),
172 })
173 }
174
175 pub fn put(&mut self, path: &[u8], seen: Seen) {
176 self.seen.insert(path.to_vec(), seen);
177 }
178
179 pub fn describes(&mut self, frontier: &[OpId]) {
180 self.at = frontier.to_vec();
181 }
182
183 pub fn path_of(dir: &Path) -> PathBuf {
184 dir.join(STAT_FILE)
185 }
186
187 /// The index of the `.ore` directory `dir`, or nothing.
188 ///
189 /// Absent, unreadable, a format this build does not know, one malformed line
190 /// -- all the same answer, which is that nothing is known and every file will
191 /// be read. A partial read is refused rather than half believed.
192 pub fn read(dir: &Path)
193 -> Option<Self>
194 {
195 let text = match fs::read_to_string(Self::path_of(dir)) {
196 Ok(t) => t,
197 Err(_) => return None,
198 };
199 let mut lines = text.lines();
200 match lines.next() {
201 Some(first) if first.trim() == STAT_FORMAT => (),
202 _ => return None,
203 }
204 let mut out = Self::default();
205 let mut said_taken = false;
206 for line in lines {
207 if line.trim().is_empty() {
208 continue;
209 }
210 let field: Vec<&str> = line.split_whitespace().collect();
211 match field.first() {
212 Some(&"taken") => {
213 let n = match field.get(1).map(|s| s.parse::<i128>()) {
214 Some(Ok(n)) => n,
215 _ => return None,
216 };
217 out.taken = n;
218 said_taken = true;
219 },
220 Some(&"at") => {
221 for said in &field[1..] {
222 match OpId::from_str(said) {
223 Ok(id) => out.at.push(id),
224 Err(_) => return None,
225 }
226 }
227 },
228 _ => {
229 if field.len() != 5 {
230 return None;
231 }
232 let len = match field[0].parse::<u64>() {
233 Ok(n) => n,
234 Err(_) => return None,
235 };
236 let mtime = match field[1].parse::<i128>() {
237 Ok(n) => n,
238 Err(_) => return None,
239 };
240 let ctime = match field[2].parse::<i128>() {
241 Ok(n) => n,
242 Err(_) => return None,
243 };
244 let ino = match field[3].parse::<u64>() {
245 Ok(n) => n,
246 Err(_) => return None,
247 };
248 let path = match bytes_of(field[4]) {
249 Ok(b) => b,
250 Err(_) => return None,
251 };
252 out.seen.insert(path, Seen { len, mtime, ctime, ino });
253 },
254 }
255 }
256 match said_taken {
257 true => Some(out),
258 false => None,
259 }
260 }
261
262 /// Writes the index, replacing whatever was there.
263 ///
264 /// Aside and renamed over, so that a reader never meets half of one; a
265 /// half-written index would be discarded on the next read anyway, and this
266 /// costs nothing.
267 pub fn write(&self, dir: &Path)
268 -> Outcome<()>
269 {
270 let path = Self::path_of(dir);
271 let mut text = fmt!("{}\n", STAT_FORMAT);
272 text.push_str(&fmt!("taken {}\n", self.taken));
273 let said: Vec<String> = self.at.iter().map(|id| fmt!("{}", id)).collect();
274 text.push_str(&fmt!("at {}\n", said.join(" ")));
275 for (path, seen) in &self.seen {
276 text.push_str(&fmt!("{} {} {} {} {}\n",
277 seen.len, seen.mtime, seen.ctime, seen.ino, text_of(path)));
278 }
279 let aside = path.with_extension("new");
280 match fs::write(&aside, text.as_bytes()) {
281 Ok(()) => (),
282 Err(e) => return Err(err!(e,
283 "The working copy index could not be written aside to {:?}.", aside;
284 IO, File, Write)),
285 }
286 match fs::rename(&aside, &path) {
287 Ok(()) => Ok(()),
288 Err(e) => {
289 let _ = fs::remove_file(&aside);
290 Err(err!(e,
291 "The working copy index at {:?} could not be moved over {:?}.",
292 aside, path;
293 IO, File, Write))
294 },
295 }
296 }
297
298 /// Removes the index, because what it described is no longer what is there.
299 pub fn forget(dir: &Path) {
300 let _ = fs::remove_file(Self::path_of(dir));
301 }
302
303 /// Does this index describe the history as it stands?
304 pub fn taken_at(&self, frontier: &[OpId]) -> bool {
305 self.at == frontier
306 }
307
308 /// The paths it holds, which is what the last scan read.
309 pub fn paths(&self) -> impl Iterator<Item = &Vec<u8>> {
310 self.seen.keys()
311 }
312
313 /// Is every path still exactly what it was, and old enough to say so?
314 ///
315 /// `found` is what a stat-only walk of the working copy turned up, gathered
316 /// under the same rules the reading walk uses. Everything is a reason to say
317 /// no: a path on one side and not the other, a field that differs, or an
318 /// entry recorded too recently for its time to mean anything.
319 pub fn agrees(&self, found: &BTreeMap<Vec<u8>, Seen>) -> bool {
320 if found.len() != self.seen.len() {
321 return false;
322 }
323 for (path, was) in &self.seen {
324 if !was.settled(self.taken) {
325 return false;
326 }
327 match found.get(path) {
328 Some(now) if now == was => (),
329 _ => return false,
330 }
331 }
332 true
333 }
334}
335
336
337
338/// Does this operation leave every file exactly where it was?
339///
340/// The question the index needs answered, and it is narrower than "renders
341/// nothing": what the index claims is that the working copy equals the history,
342/// and a working copy is paths, bytes and modes. An operation that changes none
343/// of those leaves the claim true however much else it adds.
344///
345/// [`Op::Mark`] and [`Op::Note`] both answer yes, and the reason is in what the
346/// placement machinery asks of an operation. `Slots::place_without`,
347/// `Claims::build_without` and `Dead::build` read `origins()`, `regions()` and
348/// the inserted bytes; `Op::Mark` and `Op::Note` have no `insert`, and both
349/// return `(None, None)` and `&[]` from the first two. Neither answers
350/// `names_file()`. Neither is named anywhere in `seq/slot.rs`, `seq/claim.rs`
351/// or `seq/atom.rs` at all. A note does name content -- through `note_on()`,
352/// which is a separate accessor -- and what that buys it is a place in
353/// `Rendered::notes`, which no working copy materialises.
354///
355/// **The match is exhaustive on purpose.** A new operation in the vocabulary
356/// must fail to compile here and be decided, because a catch-all answering yes
357/// would be an operation that changed a file and an index that said it had not,
358/// and what that costs is somebody's edit.
359///
360/// Read as an assertion this would not be worth having; it is asserted on
361/// running code by `a_mark_and_a_note_leave_every_file_exactly_where_it_was` in
362/// `cli/tests/stat.rs`, which renders a repository before and after each and
363/// compares the bytes and the modes of every file.
364pub fn leaves_the_files_alone(op: &Op) -> bool {
365 match op {
366 Op::Mark { .. } => true,
367 Op::Note { .. } => true,
368 // Everything below writes, moves, names or removes content, or is a
369 // claim this lane has not measured. No is the safe answer and it costs
370 // one full capture.
371 Op::FileCreate { .. } => false,
372 Op::FileDelete { .. } => false,
373 Op::FileRename { .. } => false,
374 Op::FileMode { .. } => false,
375 Op::Splice { .. } => false,
376 Op::Move { .. } => false,
377 Op::Proposal { .. } => false,
378 Op::Said { .. } => false,
379 Op::Settled { .. } => false,
380 Op::Reverts { .. } => false,
381 }
382}
383
384/// Moves an index's frontier on, where everything appended since it was taken
385/// left every file where it was.
386///
387/// Every command that appends anything ends by marking the point it reached, so
388/// without this an `ore mark` over an unchanged working copy costs the command
389/// after it a full capture: 0.65 s and 169 MB against 0.34 s and 69 MB,
390/// measured. The mark moves the frontier and moves nothing else, so what is
391/// stale about the index is one field.
392///
393/// **Only that field is written.** Every file fact stays exactly as it was
394/// measured, which is what keeps this safe rather than convenient: a verb that
395/// wrote to the working copy is caught by the size, the times and the inode
396/// afterwards exactly as it was before, and a verb that appended anything else
397/// takes the index away instead.
398pub fn restamp<'a, I>(dir: &Path, was: &[OpId], now: &[OpId], appended: I)
399 -> Outcome<()>
400where
401 I: IntoIterator<Item = &'a Op>,
402{
403 if was == now {
404 return Ok(());
405 }
406 for op in appended {
407 if !leaves_the_files_alone(op) {
408 // The index describes a state that has gone. The frontier check would
409 // refuse it anyway; taking it away says so rather than leaving it to
410 // be refused again on every command until something replaces it.
411 Stats::forget(dir);
412 return Ok(());
413 }
414 }
415 let mut index = match Stats::read(dir) {
416 Some(index) => index,
417 None => return Ok(()),
418 };
419 if !index.taken_at(was) {
420 Stats::forget(dir);
421 return Ok(());
422 }
423 index.describes(now);
424 index.write(dir)
425}
426
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 fn seen_at(mtime: i128) -> Seen {
433 Seen { len: 10, mtime, ctime: mtime, ino: 7 }
434 }
435
436 fn one(taken: i128, mtime: i128) -> Stats {
437 let mut out = Stats { taken, at: Vec::new(), seen: BTreeMap::new() };
438 out.seen.insert(b"a.txt".to_vec(), seen_at(mtime));
439 out
440 }
441
442 /// A file whose time is not [`RACY`] older than the clock reading is read
443 /// again, however exactly the disk agrees with what was recorded.
444 ///
445 /// This is the case nothing else can catch: a write in the same tick that
446 /// leaves the size alone. The fixture is built so that the disk agrees
447 /// **perfectly** -- the same entry, byte for byte -- so the only thing that
448 /// can make the answer no is the age.
449 #[test]
450 fn a_time_too_recent_to_mean_anything_is_not_believed() -> Outcome<()> {
451 let taken = 10 * RACY;
452 let mut found = BTreeMap::new();
453
454 // One nanosecond too young, and the disk agrees exactly.
455 let index = one(taken, taken - RACY + 1);
456 found.insert(b"a.txt".to_vec(), seen_at(taken - RACY + 1));
457 assert!(!index.agrees(&found), "a time inside the window means nothing");
458
459 // One nanosecond older, and the same comparison is believed.
460 let index = one(taken, taken - RACY);
461 let mut found = BTreeMap::new();
462 found.insert(b"a.txt".to_vec(), seen_at(taken - RACY));
463 assert!(index.agrees(&found), "and outside it, it does");
464
465 // A time in the future says the clock or the filesystem is not to be
466 // reasoned with, so it is not reasoned with.
467 let index = one(taken, taken + RACY);
468 let mut found = BTreeMap::new();
469 found.insert(b"a.txt".to_vec(), seen_at(taken + RACY));
470 assert!(!index.agrees(&found), "and a time after the reading is not believed");
471 Ok(())
472 }
473
474 /// Each of the four fields is enough on its own to make an entry disagree.
475 #[test]
476 fn every_field_is_load_bearing() -> Outcome<()> {
477 let taken = 10 * RACY;
478 let mtime = taken - 2 * RACY;
479 let index = one(taken, mtime);
480 let same = Seen { len: 10, mtime, ctime: mtime, ino: 7 };
481
482 let mut found = BTreeMap::new();
483 found.insert(b"a.txt".to_vec(), same);
484 assert!(index.agrees(&found), "the fixture agrees before it is disturbed");
485
486 for (what, changed) in [
487 ("size", Seen { len: 11, ..same }),
488 ("time", Seen { mtime: mtime + 1, ..same }),
489 ("change time", Seen { ctime: mtime + 1, ..same }),
490 ("inode", Seen { ino: 8, ..same }),
491 ] {
492 let mut found = BTreeMap::new();
493 found.insert(b"a.txt".to_vec(), changed);
494 assert!(!index.agrees(&found), "a different {} is a change", what);
495 }
496
497 // And a path on one side and not the other.
498 let mut found = BTreeMap::new();
499 found.insert(b"a.txt".to_vec(), same);
500 found.insert(b"b.txt".to_vec(), same);
501 assert!(!index.agrees(&found), "a path that arrived is a change");
502 assert!(!index.agrees(&BTreeMap::new()), "and one that left is too");
503 Ok(())
504 }
505
506 /// A working copy that has not moved still differs from a history that has.
507 #[test]
508 fn an_index_names_the_frontier_it_was_taken_at() -> Outcome<()> {
509 use oxedyne_fe2o3_ore::id::ReplicaId;
510
511 let mut index = one(10 * RACY, 0);
512 let was = vec![OpId::new(ReplicaId::new(1), 4)];
513 index.describes(&was);
514 assert!(index.taken_at(&was));
515 assert!(!index.taken_at(&[OpId::new(ReplicaId::new(1), 5)]));
516 assert!(!index.taken_at(&[]));
517 Ok(())
518 }
519
520 /// **The claim `leaves_the_files_alone` makes, put to the render itself.**
521 ///
522 /// The integration test in `cli/tests/stat.rs` compares the working copy,
523 /// which a mark never writes to, so it could not tell a mark that changed the
524 /// render from one that did not. This compares what the render produces:
525 /// every path, every byte and every mode, before and after.
526 ///
527 /// **The fixture is built to be able to disagree**, and that is asserted
528 /// before anything else: two files, one filled and then spliced again so its
529 /// bytes come from more than one operation, one carrying bytes that are not
530 /// text, one with a mode that is not the default. An ordinary splice is
531 /// appended first and the comparison is required to notice it.
532 #[test]
533 fn a_mark_and_a_note_change_nothing_the_render_produces() -> Outcome<()> {
534 use oxedyne_fe2o3_ore::id::{
535 Anchor,
536 ReplicaId,
537 };
538 use oxedyne_fe2o3_ore::log::OpLog;
539 use oxedyne_fe2o3_ore::op::{
540 Header,
541 Mode,
542 Record,
543 };
544 use ore_store::tree;
545
546 let me = ReplicaId::new(1);
547 let mut log = OpLog::new();
548 let mut counter = 0u64;
549 let mut next = |counter: &mut u64| { *counter += 1; OpId::new(me, *counter) };
550 let mut parents: Vec<OpId> = Vec::new();
551
552 let one = next(&mut counter);
553 res!(log.append(Record::new(res!(Header::new(one, parents.clone())),
554 Op::FileCreate { path: b"one.txt".to_vec() })));
555 parents = vec![one];
556 let id = next(&mut counter);
557 res!(log.append(Record::new(res!(Header::new(id, parents.clone())), Op::Splice {
558 left: Some(Anchor::origin(one)),
559 right: None,
560 remove: Vec::new(),
561 insert: b"alpha\nbeta\ngamma\n".to_vec().into(),
562 })));
563 parents = vec![id];
564 let two = next(&mut counter);
565 res!(log.append(Record::new(res!(Header::new(two, parents.clone())),
566 Op::FileCreate { path: b"deep/two.txt".to_vec() })));
567 parents = vec![two];
568 let id = next(&mut counter);
569 res!(log.append(Record::new(res!(Header::new(id, parents.clone())), Op::Splice {
570 left: Some(Anchor::origin(two)),
571 right: None,
572 remove: Vec::new(),
573 insert: vec![0u8, 1, 2, 0xff, 0xfe, b'\n', 0x80].into(),
574 })));
575 parents = vec![id];
576 let id = next(&mut counter);
577 res!(log.append(Record::new(res!(Header::new(id, parents.clone())),
578 Op::FileMode { file: two, mode: Mode::Executable })));
579 parents = vec![id];
580 // A second edit inside the first file, so its bytes come from two
581 // operations and the render has runs to order rather than one run a file.
582 let built = res!(tree::whole(&log));
583 let edit = {
584 let view = res!(built.repo.file(one).ok_or_else(|| err!(
585 "the render lost a file it was just given"; Test, Missing)));
586 res!(view.splice(6, 4, b"BETA".to_vec()))
587 };
588 let id = next(&mut counter);
589 res!(log.append(Record::new(res!(Header::new(id, parents.clone())), edit)));
590 parents = vec![id];
591
592 let built = res!(tree::whole(&log));
593 let was = res!(built.contents());
594 let modes = res!(built.modes());
595 assert_eq!(was.len(), 2, "the fixture holds both files");
596 assert!(was.values().any(|b| b.contains(&0xff)), "and bytes that are not text");
597 assert!(modes.values().any(|m| *m == Mode::Executable),
598 "and a mode that is not the default");
599
600 // FIRST: the comparison must be able to fail.
601 let disturb = {
602 let view = res!(built.repo.file(one).ok_or_else(|| err!(
603 "missing file"; Test, Missing)));
604 res!(view.splice(0, 0, b"zero\n".to_vec()))
605 };
606 let mut probe = log.clone();
607 let id = next(&mut counter);
608 res!(probe.append(Record::new(res!(Header::new(id, parents.clone())), disturb)));
609 assert_ne!(was, res!(res!(tree::whole(&probe)).contents()),
610 "an ordinary splice must show up in this comparison, or it proves nothing");
611
612 // A MARK.
613 let mut probe = log.clone();
614 let id = next(&mut counter);
615 res!(probe.append(Record::new(res!(Header::new(id, parents.clone())),
616 Op::Mark { name: fmt!("a point"), body: None, time: Some(1) })));
617 let after = res!(tree::whole(&probe));
618 assert_eq!(was, res!(after.contents()), "a mark changes no path and no byte");
619 assert_eq!(modes, res!(after.modes()), "and no mode");
620
621 // A NOTE, which is the one of the two that names content.
622 let on = {
623 let view = res!(built.repo.file(one).ok_or_else(|| err!(
624 "missing file"; Test, Missing)));
625 res!(view.span(0, 5))
626 };
627 assert!(!on.is_empty(), "the note names real content, not nothing");
628 let mut probe = log.clone();
629 let id = next(&mut counter);
630 res!(probe.append(Record::new(res!(Header::new(id, parents.clone())),
631 Op::Note { on, text: b"about the first line".to_vec() })));
632 let after = res!(tree::whole(&probe));
633 assert_eq!(was, res!(after.contents()), "a note changes no path and no byte");
634 assert_eq!(modes, res!(after.modes()), "and no mode");
635 assert!(!after.repo.notes().is_empty(),
636 "while the note really did reach the render, so this is not comparing a \
637 repository it never got to");
638 Ok(())
639 }
640
641 /// An operation that moves a byte takes the index away, and one that does not
642 /// moves its frontier on.
643 ///
644 /// The check `restamp` makes, asserted where it can be decided: driving the
645 /// binary cannot reach it, because every verb whose own operations change
646 /// content also either materialises the working copy or leaves it for the
647 /// next capture, and in both cases the FILE FACTS disagree and the index is
648 /// refused before this ever matters. That makes the check depth rather than
649 /// the only line -- worth having, because the case it guards is a render that
650 /// moved while the disk did not, which is a working copy walking away from
651 /// its history rather than an edit being lost -- and it means the assertion
652 /// belongs here.
653 #[test]
654 fn only_an_operation_that_moves_nothing_carries_the_index_forward() -> Outcome<()> {
655 use oxedyne_fe2o3_ore::id::{
656 Anchor,
657 ReplicaId,
658 };
659
660 let dir = std::env::temp_dir().join(fmt!(
661 "ore_stat_restamp_{}_{}", std::process::id(),
662 res!(SystemTime::now().duration_since(UNIX_EPOCH)).as_nanos()));
663 let was = vec![OpId::new(ReplicaId::new(1), 4)];
664 let now = vec![OpId::new(ReplicaId::new(1), 5)];
665 let laid = |dir: &Path| -> Outcome<()> {
666 let _ = fs::remove_dir_all(dir);
667 res!(fs::create_dir_all(dir));
668 let mut index = res!(Stats::beginning());
669 index.describes(&was);
670 index.put(b"a.txt", seen_at(1));
671 res!(index.write(dir));
672 Ok(())
673 };
674
675 // A mark carries it forward, and only the frontier moves.
676 res!(laid(&dir));
677 let before = res!(fs::read_to_string(Stats::path_of(&dir)));
678 let mark = Op::Mark { name: fmt!("a point"), body: None, time: Some(1) };
679 res!(restamp(&dir, &was, &now, [&mark]));
680 let after = res!(fs::read_to_string(Stats::path_of(&dir)));
681 let facts = |t: &str| -> Vec<String> {
682 t.lines().filter(|l| !l.starts_with("at ")).map(|l| fmt!("{}", l)).collect()
683 };
684 assert_eq!(facts(&before), facts(&after), "every file fact is what it was");
685 assert_ne!(before, after, "and the frontier is not");
686 let held = res!(Stats::read(&dir).ok_or_else(|| err!("gone"; Test, Missing)));
687 assert!(held.taken_at(&now), "the index now names where the history is");
688
689 // A note likewise.
690 res!(laid(&dir));
691 let note = Op::Note { on: Vec::new(), text: b"said".to_vec() };
692 res!(restamp(&dir, &was, &now, [&note]));
693 assert!(res!(Stats::read(&dir).ok_or_else(|| err!("gone"; Test, Missing)))
694 .taken_at(&now), "a note carries it forward too");
695
696 // Anything that moves a byte takes it away, even beside a mark.
697 let splice = Op::Splice {
698 left: Some(Anchor::origin(OpId::new(ReplicaId::new(1), 1))),
699 right: None,
700 remove: Vec::new(),
701 insert: b"more".to_vec().into(),
702 };
703 for (what, ops) in [
704 ("a splice", vec![&splice]),
705 ("a splice after a mark", vec![&mark, &splice]),
706 ("a splice before a mark", vec![&splice, &mark]),
707 ] {
708 res!(laid(&dir));
709 res!(restamp(&dir, &was, &now, ops));
710 assert!(Stats::read(&dir).is_none(), "{} takes the index away", what);
711 }
712
713 // And an index taken somewhere else is not carried forward either.
714 res!(laid(&dir));
715 let elsewhere = vec![OpId::new(ReplicaId::new(9), 9)];
716 res!(restamp(&dir, &elsewhere, &now, [&mark]));
717 assert!(Stats::read(&dir).is_none(),
718 "an index describing another point is not re-stamped, it is discarded");
719 let _ = fs::remove_dir_all(&dir);
720 Ok(())
721 }
722
723 /// A line this build cannot read discards the whole index.
724 ///
725 /// Half a cache is not worth the question of which half, and a format that
726 /// grows will be met by a build that does not know it. Nothing downstream
727 /// depends on this being strict -- an index that lost an entry disagrees on
728 /// the path count and is refused anyway -- so this is depth rather than the
729 /// only line, and it is asserted here because the integration test cannot
730 /// tell the two apart: every fixture it could use is caught by the count.
731 #[test]
732 fn a_line_this_build_cannot_read_discards_the_index() -> Outcome<()> {
733 let dir = std::env::temp_dir().join(fmt!(
734 "ore_stat_bad_{}_{}", std::process::id(),
735 res!(SystemTime::now().duration_since(UNIX_EPOCH)).as_nanos()));
736 res!(fs::create_dir_all(&dir));
737 let good = fmt!("{}\ntaken 5000000000\nat r1:2\n10 1 1 7 {}\n",
738 STAT_FORMAT, text_of(b"a.txt"));
739
740 assert!(Stats::read(&dir).is_none(), "an absent index is no index");
741
742 res!(fs::write(Stats::path_of(&dir), good.as_bytes()));
743 let read = res!(Stats::read(&dir).ok_or_else(|| err!(
744 "a sound index did not read"; Test, Missing)));
745 assert_eq!(read.seen.len(), 1, "the fixture reads before it is disturbed");
746
747 for bad in [
748 fmt!("{}\ntaken 5000000000\nat r1:2\n10 1 1 7\n", STAT_FORMAT),
749 fmt!("{}\ntaken 5000000000\nat r1:2\n10 1 1 7 {} extra\n",
750 STAT_FORMAT, text_of(b"a.txt")),
751 fmt!("{}\ntaken 5000000000\nat r1:2\nten 1 1 7 {}\n",
752 STAT_FORMAT, text_of(b"a.txt")),
753 fmt!("{}\ntaken 5000000000\nat r1:2\n10 1 1 7 !!not-encodable!!\n",
754 STAT_FORMAT),
755 fmt!("{}\nat r1:2\n10 1 1 7 {}\n", STAT_FORMAT, text_of(b"a.txt")),
756 fmt!("ORESTAT 2\ntaken 5000000000\nat r1:2\n10 1 1 7 {}\n",
757 text_of(b"a.txt")),
758 fmt!("{}\ntaken 5000000000\nat not-an-op-id\n", STAT_FORMAT),
759 ] {
760 res!(fs::write(Stats::path_of(&dir), bad.as_bytes()));
761 assert!(Stats::read(&dir).is_none(),
762 "this must read as no index at all: {:?}", bad);
763 }
764 let _ = fs::remove_dir_all(&dir);
765 Ok(())
766 }
767
768 /// It round-trips, including a path that is not UTF-8.
769 #[test]
770 fn what_is_written_is_what_is_read() -> Outcome<()> {
771 use oxedyne_fe2o3_ore::id::ReplicaId;
772
773 let dir = std::env::temp_dir().join(fmt!(
774 "ore_stat_{}_{}", std::process::id(),
775 res!(SystemTime::now().duration_since(UNIX_EPOCH)).as_nanos()));
776 res!(fs::create_dir_all(&dir));
777
778 let mut index = res!(Stats::beginning());
779 index.describes(&[OpId::new(ReplicaId::new(9), 3)]);
780 index.put(b"plain.txt", seen_at(1));
781 index.put(&[0xff, 0xfe, b'/', 0x80], seen_at(2));
782 res!(index.write(&dir));
783
784 let back = res!(Stats::read(&dir).ok_or_else(|| err!(
785 "What was just written did not read back."; Test, Missing)));
786 assert_eq!(back.taken, index.taken);
787 assert_eq!(back.at, index.at);
788 assert_eq!(back.seen, index.seen, "a path that is not text survives it");
789
790 let _ = fs::remove_dir_all(&dir);
791 Ok(())
792 }
793}