Crash safety: durable writes and the op head as commit point 4dcb8c0916b7
Every durable update now goes through oxy_core::durable: a uniquely named temporary file, fsync, rename, fsync of the directory. Loose objects, packs (pack before index), signature and seal indexes use it. Fixes a crash window found by the new test: a command inside a write batch moved .oxy/ops/head before the batch's objects, including the new operation, were on disk, so a crash there left a repository that would not open. Store::flush now makes pending objects durable before the head moves. A test stops commit (20 durable steps) and push (32) at every step and requires both repositories to open, pass fsck and accept the command again. Spec Draft 0.8h adds §13.6.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-27 23:52 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
4dcb8c0916b7cd6536097350a735aaf3354262451d2695e8454478c5dd8e8e20- Depends on
- 1983686fb7a7 387335ff7f4d 5b9c6ad45f6e 75167672ebcd 8f10a07a1dbe 982864068a59 ff0af1ee299d
- Depended on by
- ef7d4ea6f33b 8b3c9b2ba502 a39754d02c58 648189388f1a 236f35c0baba b30a880fa409 ff1f62fee654 314db9b4f3f8 fc1a466a1e66 0bdc3f91a39f 19555025a2ac
- In bookmarks
- agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 17 atoms, 1 tree operation, 6 files
Browse the tree at this change
added crates/oxy-core/src/durable.rs +129 −0
| 1 | + | //! Durable file updates (spec §13.6): every write that must survive a |
|
| 2 | + | //! crash goes through here. |
|
| 3 | + | //! |
|
| 4 | + | //! A file is replaced by writing a uniquely named temporary file in the |
|
| 5 | + | //! same directory, syncing it, renaming it over the target, and syncing |
|
| 6 | + | //! the directory so the rename itself is on disk. A crash at any point |
|
| 7 | + | //! leaves either the old file or the new one, never a torn one; at worst |
|
| 8 | + | //! a stray temporary file, which nothing reads. |
|
| 9 | + | //! |
|
| 10 | + | //! In tests, `crash_after(n)` makes the n-th durable step on the current |
|
| 11 | + | //! thread fail as if the process had died there, so a test can stop an |
|
| 12 | + | //! operation at every step and check what is left behind. |
|
| 13 | + | ||
| 14 | + | use rand::RngCore; |
|
| 15 | + | use std::fs; |
|
| 16 | + | use std::io::{self, Write}; |
|
| 17 | + | use std::path::Path; |
|
| 18 | + | ||
| 19 | + | /// Replace `path` with `data`, atomically and durably. |
|
| 20 | + | pub fn write_atomic(path: &Path, data: &[u8]) -> io::Result<()> { |
|
| 21 | + | let dir = path.parent().unwrap_or(Path::new(".")); |
|
| 22 | + | let name = path |
|
| 23 | + | .file_name() |
|
| 24 | + | .map(|n| n.to_string_lossy().into_owned()) |
|
| 25 | + | .unwrap_or_default(); |
|
| 26 | + | let mut nonce = [0u8; 6]; |
|
| 27 | + | rand::rngs::OsRng.fill_bytes(&mut nonce); |
|
| 28 | + | let tmp = dir.join(format!(".{name}.tmp-{}", hex::encode(nonce))); |
|
| 29 | + | step("create temporary file")?; |
|
| 30 | + | let written = (|| { |
|
| 31 | + | let mut f = fs::File::create(&tmp)?; |
|
| 32 | + | f.write_all(data)?; |
|
| 33 | + | f.sync_all()?; |
|
| 34 | + | step("rename into place")?; |
|
| 35 | + | fs::rename(&tmp, path) |
|
| 36 | + | })(); |
|
| 37 | + | if let Err(e) = written { |
|
| 38 | + | if !is_injected(&e) { |
|
| 39 | + | let _ = fs::remove_file(&tmp); |
|
| 40 | + | } |
|
| 41 | + | return Err(e); |
|
| 42 | + | } |
|
| 43 | + | sync_dir(dir) |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | /// Rename `from` to `to` and make the rename durable. |
|
| 47 | + | pub fn rename(from: &Path, to: &Path) -> io::Result<()> { |
|
| 48 | + | step("rename")?; |
|
| 49 | + | fs::rename(from, to)?; |
|
| 50 | + | sync_dir(to.parent().unwrap_or(Path::new("."))) |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | /// Flush a directory's entries (new, renamed or removed files) to disk. |
|
| 54 | + | /// A no-op where directories cannot be opened for syncing. |
|
| 55 | + | pub fn sync_dir(dir: &Path) -> io::Result<()> { |
|
| 56 | + | #[cfg(unix)] |
|
| 57 | + | { |
|
| 58 | + | fs::File::open(dir)?.sync_all() |
|
| 59 | + | } |
|
| 60 | + | #[cfg(not(unix))] |
|
| 61 | + | { |
|
| 62 | + | let _ = dir; |
|
| 63 | + | Ok(()) |
|
| 64 | + | } |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | /// A point where a crash may happen. Outside tests it does nothing. |
|
| 68 | + | pub fn step(what: &str) -> io::Result<()> { |
|
| 69 | + | #[cfg(test)] |
|
| 70 | + | { |
|
| 71 | + | failpoint::hit(what) |
|
| 72 | + | } |
|
| 73 | + | #[cfg(not(test))] |
|
| 74 | + | { |
|
| 75 | + | let _ = what; |
|
| 76 | + | Ok(()) |
|
| 77 | + | } |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | const INJECTED: &str = "injected crash"; |
|
| 81 | + | ||
| 82 | + | fn is_injected(e: &io::Error) -> bool { |
|
| 83 | + | e.to_string().starts_with(INJECTED) |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | #[cfg(test)] |
|
| 87 | + | pub(crate) mod failpoint { |
|
| 88 | + | use std::cell::Cell; |
|
| 89 | + | use std::io; |
|
| 90 | + | ||
| 91 | + | thread_local! { |
|
| 92 | + | static COUNTDOWN: Cell<Option<usize>> = const { Cell::new(None) }; |
|
| 93 | + | static FIRED: Cell<bool> = const { Cell::new(false) }; |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | /// Fail the `n`-th durable step from now (1-based) on this thread, |
|
| 97 | + | /// and every step after it, as a dead process would. |
|
| 98 | + | pub fn crash_after(n: usize) { |
|
| 99 | + | COUNTDOWN.with(|c| c.set(Some(n))); |
|
| 100 | + | FIRED.with(|f| f.set(false)); |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | /// Stop injecting; returns whether a crash was injected. |
|
| 104 | + | pub fn disarm() -> bool { |
|
| 105 | + | COUNTDOWN.with(|c| c.set(None)); |
|
| 106 | + | FIRED.with(|f| f.replace(false)) |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | pub(super) fn hit(what: &str) -> io::Result<()> { |
|
| 110 | + | if FIRED.with(|f| f.get()) { |
|
| 111 | + | return Err(io::Error::other(format!( |
|
| 112 | + | "{} (after) at {what}", |
|
| 113 | + | super::INJECTED |
|
| 114 | + | ))); |
|
| 115 | + | } |
|
| 116 | + | COUNTDOWN.with(|c| match c.get() { |
|
| 117 | + | Some(1) => { |
|
| 118 | + | c.set(None); |
|
| 119 | + | FIRED.with(|f| f.set(true)); |
|
| 120 | + | Err(io::Error::other(format!("{} at {what}", super::INJECTED))) |
|
| 121 | + | } |
|
| 122 | + | Some(n) => { |
|
| 123 | + | c.set(Some(n - 1)); |
|
| 124 | + | Ok(()) |
|
| 125 | + | } |
|
| 126 | + | None => Ok(()), |
|
| 127 | + | }) |
|
| 128 | + | } |
|
| 129 | + | } |
modified crates/oxy-core/src/lib.rs +1 −0
| … | 6 unchanged lines |
||
| 7 | 7 | pub mod cbor; |
|
| 8 | 8 | pub mod chunk; |
|
| 9 | 9 | pub mod diff; |
|
| 10 | + | pub mod durable; |
|
| 10 | 11 | pub mod graph; |
|
| 11 | 12 | pub mod hash; |
|
| 12 | 13 | pub mod identity; |
|
| … | 11 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +3 −8
| … | 102 unchanged lines |
||
| 103 | 103 | pub use workspace::{SnapshotLock, WorkspaceInfo}; |
|
| 104 | 104 | ||
| 105 | 105 | fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { |
|
| 106 | - | let tmp = path.with_extension("tmp"); |
|
| 107 | - | { |
|
| 108 | - | let mut f = fs::File::create(&tmp)?; |
|
| 109 | - | f.write_all(data)?; |
|
| 110 | - | f.sync_all()?; |
|
| 111 | - | } |
|
| 112 | - | fs::rename(&tmp, path)?; |
|
| 113 | - | Ok(()) |
|
| 106 | + | Ok(crate::durable::write_atomic(path, data)?) |
|
| 114 | 107 | } |
|
| 115 | 108 | ||
| 116 | 109 | pub struct LogEntry { |
|
| … | 447 unchanged lines |
||
| 564 | 557 | view, |
|
| 565 | 558 | }; |
|
| 566 | 559 | let r = self.store.put(ObjectType::Op, &op.encode())?; |
|
| 560 | + | // The head is the commit point: everything it names must be on disk first. |
|
| 561 | + | self.store.flush()?; |
|
| 567 | 562 | write_atomic(&self.oxy.join("ops").join("head"), r.to_hex().as_bytes())?; |
|
| 568 | 563 | Ok(r) |
|
| 569 | 564 | } |
|
| … | 656 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +117 −0
| … | 3971 unchanged lines |
||
| 3972 | 3972 | fs::write(&head, saved).unwrap(); |
|
| 3973 | 3973 | assert_eq!(a.fsck().unwrap().problems, vec![]); |
|
| 3974 | 3974 | } |
|
| 3975 | + | ||
| 3976 | + | /// Copy a directory tree (for restoring a pristine repository). |
|
| 3977 | + | fn copy_tree(from: &Path, to: &Path) { |
|
| 3978 | + | fs::create_dir_all(to).unwrap(); |
|
| 3979 | + | for e in fs::read_dir(from).unwrap() { |
|
| 3980 | + | let e = e.unwrap(); |
|
| 3981 | + | let target = to.join(e.file_name()); |
|
| 3982 | + | if e.file_type().unwrap().is_dir() { |
|
| 3983 | + | copy_tree(&e.path(), &target); |
|
| 3984 | + | } else { |
|
| 3985 | + | fs::copy(e.path(), &target).unwrap(); |
|
| 3986 | + | } |
|
| 3987 | + | } |
|
| 3988 | + | } |
|
| 3989 | + | ||
| 3990 | + | /// Spec §13.6: stop `commit` and `push` at every durable step, as a crash |
|
| 3991 | + | /// would, and check that both repositories still open, pass `fsck`, and |
|
| 3992 | + | /// accept the same operation again. |
|
| 3993 | + | #[test] |
|
| 3994 | + | fn crash_at_every_step_of_commit_and_push_leaves_sound_repositories() { |
|
| 3995 | + | use crate::durable::failpoint; |
|
| 3996 | + | hermetic(); |
|
| 3997 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 3998 | + | let pristine = dir.path().join("pristine"); |
|
| 3999 | + | let origin_path = pristine.join("origin"); |
|
| 4000 | + | Repo::init(&origin_path).unwrap(); |
|
| 4001 | + | let mut a = Repo::clone(&origin_path.to_string_lossy(), &pristine.join("a")) |
|
| 4002 | + | .unwrap() |
|
| 4003 | + | .with_home(dir.path().join("home")); |
|
| 4004 | + | a.create_identity("Ada", "[email protected]", IdentityScope::Repo) |
|
| 4005 | + | .unwrap(); |
|
| 4006 | + | write(&pristine.join("a"), "f.txt", b"one\n"); |
|
| 4007 | + | a.commit("first", &[]).unwrap(); |
|
| 4008 | + | assert!(a.push("origin", false).unwrap()[0].ok); |
|
| 4009 | + | drop(a); |
|
| 4010 | + | ||
| 4011 | + | type Op = fn(&Repo) -> Result<()>; |
|
| 4012 | + | let edit_and_commit: Op = |r| { |
|
| 4013 | + | write(r.root(), "f.txt", b"one\ntwo\n"); |
|
| 4014 | + | write(r.root(), "big.bin", &vec![7u8; 300_000]); |
|
| 4015 | + | r.commit("second", &[]).map(|_| ()) |
|
| 4016 | + | }; |
|
| 4017 | + | let push: Op = |r| { |
|
| 4018 | + | let res = r.push("origin", false)?; |
|
| 4019 | + | if res.iter().all(|x| x.ok) { |
|
| 4020 | + | Ok(()) |
|
| 4021 | + | } else { |
|
| 4022 | + | Err(RepoError::Msg(format!("{res:?}"))) |
|
| 4023 | + | } |
|
| 4024 | + | }; |
|
| 4025 | + | ||
| 4026 | + | for (name, prepare, op) in [ |
|
| 4027 | + | ("commit", None, edit_and_commit), |
|
| 4028 | + | ("push", Some(edit_and_commit), push), |
|
| 4029 | + | ] { |
|
| 4030 | + | let mut steps = 0; |
|
| 4031 | + | for n in 1.. { |
|
| 4032 | + | let run = dir.path().join(format!("{name}-{n}")); |
|
| 4033 | + | copy_tree(&pristine, &run); |
|
| 4034 | + | // Clones record their remote by path; point it at this copy. |
|
| 4035 | + | let open = |p: &Path| { |
|
| 4036 | + | let mut r = Repo::open(p).unwrap().with_home(dir.path().join("home")); |
|
| 4037 | + | r.config |
|
| 4038 | + | .set("remote.origin.url", &run.join("origin").to_string_lossy()) |
|
| 4039 | + | .unwrap(); |
|
| 4040 | + | r.save_config().unwrap(); |
|
| 4041 | + | r |
|
| 4042 | + | }; |
|
| 4043 | + | let a = open(&run.join("a")); |
|
| 4044 | + | if let Some(prep) = prepare { |
|
| 4045 | + | prep(&a).unwrap(); |
|
| 4046 | + | } |
|
| 4047 | + | failpoint::crash_after(n); |
|
| 4048 | + | let outcome = op(&a); |
|
| 4049 | + | let crashed = failpoint::disarm(); |
|
| 4050 | + | drop(a); |
|
| 4051 | + | if !crashed { |
|
| 4052 | + | outcome.unwrap_or_else(|e| panic!("{name} failed without a crash: {e}")); |
|
| 4053 | + | steps = n - 1; |
|
| 4054 | + | break; |
|
| 4055 | + | } |
|
| 4056 | + | assert!( |
|
| 4057 | + | outcome.is_err(), |
|
| 4058 | + | "{name}: a crash at step {n} was swallowed" |
|
| 4059 | + | ); |
|
| 4060 | + | for repo in ["a", "origin"] { |
|
| 4061 | + | let r = Repo::open(&run.join(repo)).unwrap_or_else(|e| { |
|
| 4062 | + | panic!("{name} crash at step {n}: {repo} no longer opens: {e}") |
|
| 4063 | + | }); |
|
| 4064 | + | let rep = r.fsck().unwrap(); |
|
| 4065 | + | assert_eq!( |
|
| 4066 | + | rep.errors(), |
|
| 4067 | + | 0, |
|
| 4068 | + | "{name} crash at step {n}: {repo} fails fsck: {:?}", |
|
| 4069 | + | rep.problems |
|
| 4070 | + | ); |
|
| 4071 | + | } |
|
| 4072 | + | // The interrupted operation can simply be run again. |
|
| 4073 | + | let a = open(&run.join("a")); |
|
| 4074 | + | // A crash after the commit point leaves the commit done. |
|
| 4075 | + | let landed = a |
|
| 4076 | + | .bookmark_heads() |
|
| 4077 | + | .unwrap() |
|
| 4078 | + | .iter() |
|
| 4079 | + | .any(|h| a.store().get_change(h).unwrap().message == "second"); |
|
| 4080 | + | let again = match (name, landed) { |
|
| 4081 | + | ("commit", true) => Ok(()), |
|
| 4082 | + | ("commit", false) => a.commit("second", &[]).map(|_| ()), |
|
| 4083 | + | _ => op(&a), |
|
| 4084 | + | }; |
|
| 4085 | + | again.unwrap_or_else(|e| panic!("{name} crash at step {n}: retry failed: {e}")); |
|
| 4086 | + | assert_eq!(a.fsck().unwrap().errors(), 0); |
|
| 4087 | + | fs::remove_dir_all(&run).unwrap(); |
|
| 4088 | + | } |
|
| 4089 | + | assert!(steps >= 3, "{name} has only {steps} durable steps?"); |
|
| 4090 | + | } |
|
| 4091 | + | } |
modified crates/oxy-core/src/store.rs +34 −8
| … | 437 unchanged lines |
||
| 438 | 438 | f.flush()?; |
|
| 439 | 439 | f.get_ref().sync_all()?; |
|
| 440 | 440 | } |
|
| 441 | - | fs::rename(tmp_pack, &pack_path)?; |
|
| 442 | - | fs::rename(&tmp_idx, &idx_path)?; |
|
| 441 | + | // The pack first: an index never names a pack that is not there. |
|
| 442 | + | crate::durable::rename(tmp_pack, &pack_path)?; |
|
| 443 | + | crate::durable::rename(&tmp_idx, &idx_path)?; |
|
| 443 | 444 | self.reload_packs() |
|
| 444 | 445 | } |
|
| 445 | 446 | ||
| … | 129 unchanged lines |
||
| 575 | 576 | let Some(pending) = self.pending.borrow_mut().take() else { |
|
| 576 | 577 | return Ok(()); |
|
| 577 | 578 | }; |
|
| 579 | + | self.write_pending(pending) |
|
| 580 | + | } |
|
| 581 | + | ||
| 582 | + | /// Make every object written so far durable, even inside an open |
|
| 583 | + | /// batch, which stays open. Call before a reference that names them |
|
| 584 | + | /// is moved (spec §13.6): the operation head, a bookmark pointer. |
|
| 585 | + | pub fn flush(&self) -> Result<()> { |
|
| 586 | + | let Some(pending) = self.pending.borrow_mut().take() else { |
|
| 587 | + | return Ok(()); |
|
| 588 | + | }; |
|
| 589 | + | self.write_pending(pending)?; |
|
| 590 | + | *self.pending.borrow_mut() = Some(Pending { |
|
| 591 | + | buffered: vec![], |
|
| 592 | + | buffered_bytes: 0, |
|
| 593 | + | lookup: Default::default(), |
|
| 594 | + | pack: None, |
|
| 595 | + | report: PackReport::default(), |
|
| 596 | + | }); |
|
| 597 | + | Ok(()) |
|
| 598 | + | } |
|
| 599 | + | ||
| 600 | + | fn write_pending(&self, pending: Pending) -> Result<()> { |
|
| 578 | 601 | match pending.pack { |
|
| 579 | 602 | None => { |
|
| 580 | 603 | for (ty, body) in &pending.buffered { |
|
| … | 154 unchanged lines |
||
| 735 | 758 | if path.is_file() { |
|
| 736 | 759 | return Ok(r); |
|
| 737 | 760 | } |
|
| 738 | - | fs::create_dir_all(path.parent().unwrap())?; |
|
| 761 | + | let fanout = path.parent().unwrap(); |
|
| 762 | + | if !fanout.is_dir() { |
|
| 763 | + | fs::create_dir_all(fanout)?; |
|
| 764 | + | crate::durable::sync_dir(&self.dir.join("loose"))?; |
|
| 765 | + | } |
|
| 739 | 766 | let mut nonce = [0u8; 8]; |
|
| 740 | 767 | rand::rngs::OsRng.fill_bytes(&mut nonce); |
|
| 741 | 768 | let tmp = self |
|
| 742 | 769 | .dir |
|
| 743 | 770 | .join("loose") |
|
| 744 | 771 | .join(format!("tmp-{}", hex::encode(nonce))); |
|
| 772 | + | crate::durable::step("write loose object")?; |
|
| 745 | 773 | { |
|
| 746 | 774 | let mut f = fs::File::create(&tmp)?; |
|
| 747 | 775 | f.write_all(&header(ty, body.len()))?; |
|
| 748 | 776 | f.write_all(body)?; |
|
| 749 | 777 | f.sync_all()?; |
|
| 750 | 778 | } |
|
| 751 | - | fs::rename(&tmp, &path)?; |
|
| 779 | + | crate::durable::rename(&tmp, &path)?; |
|
| 752 | 780 | Ok(r) |
|
| 753 | 781 | } |
|
| 754 | 782 | ||
| … | 105 unchanged lines |
||
| 860 | 888 | let path = self.seal_index_path(change); |
|
| 861 | 889 | fs::create_dir_all(path.parent().unwrap())?; |
|
| 862 | 890 | let text: String = seals.iter().map(|s| format!("{}\n", s.to_hex())).collect(); |
|
| 863 | - | let tmp = path.with_extension("tmp"); |
|
| 864 | - | fs::write(&tmp, text)?; |
|
| 865 | - | fs::rename(&tmp, &path)?; |
|
| 891 | + | crate::durable::write_atomic(&path, text.as_bytes())?; |
|
| 866 | 892 | Ok(()) |
|
| 867 | 893 | } |
|
| 868 | 894 | ||
| … | 44 unchanged lines |
||
| 913 | 939 | f.write_all(&Signature::encode_list(&sigs))?; |
|
| 914 | 940 | f.sync_all()?; |
|
| 915 | 941 | } |
|
| 916 | - | fs::rename(&tmp, &path)?; |
|
| 942 | + | crate::durable::rename(&tmp, &path)?; |
|
| 917 | 943 | Ok(()) |
|
| 918 | 944 | } |
|
| 919 | 945 | ||
| … | 396 unchanged lines |
modified docs/spec.md +5 −1
| … | 550 unchanged lines |
||
| 551 | 551 | ||
| 552 | 552 | A blob or chunk may be **absent** in a partial repository (§17). After a fetch with a blob filter, the repository records every blob its new changes reference but did not receive, together with the remote that withheld it (`.oxy/lazy/<ref>`), and fetches it through `objects` on first read, dropping the record once stored. Changes and checkpoints are never lazy: a repository that has a change has all of its inline data. |
|
| 553 | 553 | ||
| 554 | + | ### 13.6 Durability |
|
| 555 | + | ||
| 556 | + | Every update that must survive a crash replaces a file atomically: write a uniquely named temporary file in the same directory, `fsync` it, rename it over the target, and `fsync` the directory. Loose objects and packs are written the same way, a pack before its index. The operation head (`.oxy/ops/head`) is the commit point of every command: before it moves, every object the new operation names MUST be durable, including objects still held by an open write batch (§13.4), which is flushed first. A crash therefore leaves the repository at the operation before or after the interrupted command, never between; stray temporary files may remain and are ignored. The reference implementation's tests stop `commit` and `push` at each durable step and require both repositories to open, pass `oxy fsck` (§21.1) and accept the same command again. |
|
| 557 | + | ||
| 554 | 558 | ## 14. Identity, signatures, and trust policy |
|
| 555 | 559 | ||
| 556 | 560 | ### 14.1 Algorithms |
|
| … | 367 unchanged lines |
||
| 924 | 928 | ||
| 925 | 929 | --- |
|
| 926 | 930 | ||
| 927 | - | *Changelog.* Draft 0.8h (2026-09-28): `oxy fsck` (§21.1). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 931 | + | *Changelog.* Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 928 | 932 | ||
| 929 | 933 | Draft 0.8a (2026-09-27): every Draft 0.8 addition is implemented in the reference implementation (§26.1). Clarifications from doing so: an unregistered nested repository is left alone and a tracked subrepo whose directory is absent keeps its pin (§7.8); Oxygen pins are omitted from Git export (§23). |
|
| 930 | 934 | ||
| … | 59 unchanged lines |