Bookmarks point at state objects (spec 0.5, repository format 2) with migration from format 1 86e682c242c6
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-26 18:22 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
86e682c242c6d7cf28e6c0b3df731c14dc7fd6936744b213ed8fb9bafea7dd0d- Depends on
- 1983686fb7a7 20247a03a596 55e5a16a2b9c 5b1114a04905 f9c98deef326 ff0af1ee299d
- Depended on by
- d36ba2f79f95 733ea16a9701 f0a517d28237 3a6effc74835 530a0fc650a2 7ba155f20aa0 4fe7d6045860 6f8aeb996558 fc1a466a1e66 6a81dbab471d 1c2bd9abdd4f be451356e3ef 61e3bce7ea5b f8c695764c95 84b9e9ac0ab4 75167672ebcd 86fe29fd62ea cdb5beba7f05 132b884b9a8d 252e8cb23fbc 83e0677b07fe fce44ae9ad29 096f447ccda0 5b9c6ad45f6e
- 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
- 50 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified crates/oxy-core/src/hash.rs +4 −1
| … | 61 unchanged lines |
||
| 62 | 62 | Attest, |
|
| 63 | 63 | Identity, |
|
| 64 | 64 | Policy, |
|
| 65 | + | State, |
|
| 65 | 66 | } |
|
| 66 | 67 | ||
| 67 | 68 | impl ObjectType { |
|
| 68 | - | pub const ALL: [ObjectType; 8] = [ |
|
| 69 | + | pub const ALL: [ObjectType; 9] = [ |
|
| 69 | 70 | ObjectType::Chunk, |
|
| 70 | 71 | ObjectType::Blob, |
|
| 71 | 72 | ObjectType::Change, |
|
| … | 2 unchanged lines |
||
| 74 | 75 | ObjectType::Attest, |
|
| 75 | 76 | ObjectType::Identity, |
|
| 76 | 77 | ObjectType::Policy, |
|
| 78 | + | ObjectType::State, |
|
| 77 | 79 | ]; |
|
| 78 | 80 | ||
| 79 | 81 | pub fn name(self) -> &'static str { |
|
| … | 6 unchanged lines |
||
| 86 | 88 | ObjectType::Attest => "attest", |
|
| 87 | 89 | ObjectType::Identity => "identity", |
|
| 88 | 90 | ObjectType::Policy => "policy", |
|
| 91 | + | ObjectType::State => "state", |
|
| 89 | 92 | } |
|
| 90 | 93 | } |
|
| 91 | 94 | ||
| … | 59 unchanged lines |
modified crates/oxy-core/src/objects.rs +45 −0
| … | 555 unchanged lines |
||
| 556 | 556 | } |
|
| 557 | 557 | } |
|
| 558 | 558 | ||
| 559 | + | // ---------------------------------------------------------------- state |
|
| 560 | + | ||
| 561 | + | /// A state named by its heads (spec §10.1): what a bookmark or tag points at. |
|
| 562 | + | #[derive(Debug, Clone, PartialEq, Eq, Default)] |
|
| 563 | + | pub struct StateObject { |
|
| 564 | + | pub heads: Vec<ChangeHash>, |
|
| 565 | + | } |
|
| 566 | + | ||
| 567 | + | impl StateObject { |
|
| 568 | + | pub fn new(mut heads: Vec<ChangeHash>) -> StateObject { |
|
| 569 | + | heads.sort(); |
|
| 570 | + | heads.dedup(); |
|
| 571 | + | StateObject { heads } |
|
| 572 | + | } |
|
| 573 | + | pub fn encode(&self) -> Vec<u8> { |
|
| 574 | + | debug_assert!(self.heads.windows(2).all(|w| w[0] < w[1])); |
|
| 575 | + | cbor::encode(&Value::map([ |
|
| 576 | + | (0, Value::Uint(FORMAT_VERSION)), |
|
| 577 | + | (1, Value::Array(self.heads.iter().map(|h| Value::Bytes(h.0.to_vec())).collect())), |
|
| 578 | + | ])) |
|
| 579 | + | } |
|
| 580 | + | pub fn hash(&self) -> ObjectRef { |
|
| 581 | + | hash_object(ObjectType::State, &self.encode()) |
|
| 582 | + | } |
|
| 583 | + | pub fn decode(bytes: &[u8]) -> Result<StateObject, ObjectError> { |
|
| 584 | + | let v = cbor::decode(bytes)?; |
|
| 585 | + | let m = map(&v, "state")?; |
|
| 586 | + | check_version(m)?; |
|
| 587 | + | let heads = array(field(m, 1, "heads")?, "heads")?.iter().map(|h| oref(h, "head")).collect::<Result<Vec<_>, _>>()?; |
|
| 588 | + | if !heads.windows(2).all(|w| w[0] < w[1]) { |
|
| 589 | + | return schema("state heads must be sorted and unique"); |
|
| 590 | + | } |
|
| 591 | + | Ok(StateObject { heads }) |
|
| 592 | + | } |
|
| 593 | + | } |
|
| 594 | + | ||
| 559 | 595 | #[cfg(test)] |
|
| 560 | 596 | pub(crate) mod tests { |
|
| 561 | 597 | use super::*; |
|
| … | 88 unchanged lines |
||
| 650 | 686 | } |
|
| 651 | 687 | ||
| 652 | 688 | #[test] |
|
| 689 | + | fn state_object_round_trip() { |
|
| 690 | + | let st = StateObject::new(vec![ObjectRef([2; 32]), ObjectRef([1; 32]), ObjectRef([2; 32])]); |
|
| 691 | + | assert_eq!(st.heads, vec![ObjectRef([1; 32]), ObjectRef([2; 32])]); |
|
| 692 | + | assert_eq!(StateObject::decode(&st.encode()).unwrap(), st); |
|
| 693 | + | assert_eq!(StateObject::new(vec![]).heads, vec![]); |
|
| 694 | + | assert_ne!(StateObject::new(vec![]).hash(), st.hash()); |
|
| 695 | + | } |
|
| 696 | + | ||
| 697 | + | #[test] |
|
| 653 | 698 | fn blob_round_trip() { |
|
| 654 | 699 | let b = Blob { length: 10, chunks: vec![ObjectRef([1; 32]), ObjectRef([2; 32])] }; |
|
| 655 | 700 | assert_eq!(Blob::decode(&b.encode()).unwrap(), b); |
|
| … | 2 unchanged lines |
modified crates/oxy-core/src/oplog.rs +120 −23
| … | 1 unchanged lines |
||
| 2 | 2 | //! state records an `Op` whose `view` is the complete set of references and |
|
| 3 | 3 | //! workspaces afterwards. |
|
| 4 | 4 | //! |
|
| 5 | - | //! Encoding: `bookmarks` is an array of `[name, [heads]]` sorted by name and |
|
| 5 | + | //! Encoding: `bookmarks` is an array of `[name, state_ref]` sorted by name and |
|
| 6 | 6 | //! `workspaces` an array of `[workspace_id, change_hash]` sorted by id, since |
|
| 7 | - | //! canonical maps carry only integer keys. |
|
| 7 | + | //! canonical maps carry only integer keys. Repository format 1 encoded a |
|
| 8 | + | //! bookmark as `[name, [heads]]`; `Op::decode_format1` reads that for migration. |
|
| 8 | 9 | ||
| 9 | 10 | use crate::cbor::{self, Value}; |
|
| 10 | 11 | use crate::hash::{hash_object, ChangeHash, ObjectRef, ObjectType}; |
|
| … | 3 unchanged lines |
||
| 14 | 15 | ||
| 15 | 16 | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
|
| 16 | 17 | pub struct View { |
|
| 18 | + | /// Bookmark name → ObjectRef of a `state` object (spec §10.1). |
|
| 19 | + | pub bookmarks: BTreeMap<String, ObjectRef>, |
|
| 20 | + | pub workspaces: BTreeMap<WorkspaceId, ChangeHash>, |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | /// A format-1 view, only produced by `Op::decode_format1`. |
|
| 24 | + | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
|
| 25 | + | pub struct Format1View { |
|
| 17 | 26 | pub bookmarks: BTreeMap<String, Vec<ChangeHash>>, |
|
| 18 | 27 | pub workspaces: BTreeMap<WorkspaceId, ChangeHash>, |
|
| 19 | 28 | } |
|
| … | 14 unchanged lines |
||
| 34 | 43 | Value::Array( |
|
| 35 | 44 | self.bookmarks |
|
| 36 | 45 | .iter() |
|
| 37 | - | .map(|(n, hs)| { |
|
| 38 | - | Value::Array(vec![ |
|
| 39 | - | Value::Text(n.clone()), |
|
| 40 | - | Value::Array(hs.iter().map(|h| Value::Bytes(h.0.to_vec())).collect()), |
|
| 41 | - | ]) |
|
| 42 | - | }) |
|
| 46 | + | .map(|(n, st)| Value::Array(vec![Value::Text(n.clone()), Value::Bytes(st.0.to_vec())])) |
|
| 43 | 47 | .collect(), |
|
| 44 | 48 | ), |
|
| 45 | 49 | ), |
|
| … | 18 unchanged lines |
||
| 64 | 68 | return Err(ObjectError::Schema("bookmark: expected [name, heads]".into())); |
|
| 65 | 69 | } |
|
| 66 | 70 | let name = text(&pair[0], "bookmark name")?.to_owned(); |
|
| 71 | + | view.bookmarks.insert(name, oref(&pair[1], "bookmark state")?); |
|
| 72 | + | } |
|
| 73 | + | view.workspaces = workspaces_from_value(field(m, 1, "workspaces")?)?; |
|
| 74 | + | Ok(view) |
|
| 75 | + | } |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | fn workspaces_from_value(v: &Value) -> Result<BTreeMap<WorkspaceId, ChangeHash>, ObjectError> { |
|
| 79 | + | let mut out = BTreeMap::new(); |
|
| 80 | + | for w in array(v, "workspaces")? { |
|
| 81 | + | let pair = array(w, "workspace")?; |
|
| 82 | + | if pair.len() != 2 { |
|
| 83 | + | return Err(ObjectError::Schema("workspace: expected [id, change]".into())); |
|
| 84 | + | } |
|
| 85 | + | let id = WorkspaceId::from_slice(bytes(&pair[0], "workspace id")?) |
|
| 86 | + | .ok_or_else(|| ObjectError::Schema("workspace id: expected 16 bytes".into()))?; |
|
| 87 | + | out.insert(id, oref(&pair[1], "workspace change")?); |
|
| 88 | + | } |
|
| 89 | + | Ok(out) |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | impl Format1View { |
|
| 93 | + | fn from_value(v: &Value) -> Result<Format1View, ObjectError> { |
|
| 94 | + | let m = v.as_map().ok_or_else(|| ObjectError::Schema("view: expected map".into()))?; |
|
| 95 | + | let mut view = Format1View::default(); |
|
| 96 | + | for b in array(field(m, 0, "bookmarks")?, "bookmarks")? { |
|
| 97 | + | let pair = array(b, "bookmark")?; |
|
| 98 | + | if pair.len() != 2 { |
|
| 99 | + | return Err(ObjectError::Schema("bookmark: expected [name, heads]".into())); |
|
| 100 | + | } |
|
| 101 | + | let name = text(&pair[0], "bookmark name")?.to_owned(); |
|
| 67 | 102 | let mut heads = array(&pair[1], "heads")?.iter().map(|h| oref(h, "head")).collect::<Result<Vec<_>, _>>()?; |
|
| 68 | 103 | heads.sort(); |
|
| 69 | 104 | heads.dedup(); |
|
| 70 | 105 | view.bookmarks.insert(name, heads); |
|
| 71 | 106 | } |
|
| 72 | - | for w in array(field(m, 1, "workspaces")?, "workspaces")? { |
|
| 73 | - | let pair = array(w, "workspace")?; |
|
| 74 | - | if pair.len() != 2 { |
|
| 75 | - | return Err(ObjectError::Schema("workspace: expected [id, change]".into())); |
|
| 76 | - | } |
|
| 77 | - | let id = WorkspaceId::from_slice(bytes(&pair[0], "workspace id")?) |
|
| 78 | - | .ok_or_else(|| ObjectError::Schema("workspace id: expected 16 bytes".into()))?; |
|
| 79 | - | view.workspaces.insert(id, oref(&pair[1], "workspace change")?); |
|
| 80 | - | } |
|
| 107 | + | view.workspaces = workspaces_from_value(field(m, 1, "workspaces")?)?; |
|
| 81 | 108 | Ok(view) |
|
| 82 | 109 | } |
|
| 110 | + | ||
| 111 | + | /// Format-1 encoding, kept for the migration test. |
|
| 112 | + | #[cfg(test)] |
|
| 113 | + | pub(crate) fn to_value(&self) -> Value { |
|
| 114 | + | Value::map([ |
|
| 115 | + | ( |
|
| 116 | + | 0, |
|
| 117 | + | Value::Array( |
|
| 118 | + | self.bookmarks |
|
| 119 | + | .iter() |
|
| 120 | + | .map(|(n, hs)| { |
|
| 121 | + | Value::Array(vec![ |
|
| 122 | + | Value::Text(n.clone()), |
|
| 123 | + | Value::Array(hs.iter().map(|h| Value::Bytes(h.0.to_vec())).collect()), |
|
| 124 | + | ]) |
|
| 125 | + | }) |
|
| 126 | + | .collect(), |
|
| 127 | + | ), |
|
| 128 | + | ), |
|
| 129 | + | ( |
|
| 130 | + | 1, |
|
| 131 | + | Value::Array( |
|
| 132 | + | self.workspaces |
|
| 133 | + | .iter() |
|
| 134 | + | .map(|(w, h)| Value::Array(vec![Value::Bytes(w.0.to_vec()), Value::Bytes(h.0.to_vec())])) |
|
| 135 | + | .collect(), |
|
| 136 | + | ), |
|
| 137 | + | ), |
|
| 138 | + | ]) |
|
| 139 | + | } |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | /// A format-1 operation as read for migration. |
|
| 143 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 144 | + | pub struct Format1Op { |
|
| 145 | + | pub parents: Vec<ObjectRef>, |
|
| 146 | + | pub hlc: Hlc, |
|
| 147 | + | pub command: String, |
|
| 148 | + | pub view: Format1View, |
|
| 83 | 149 | } |
|
| 84 | 150 | ||
| 151 | + | impl Format1Op { |
|
| 152 | + | #[cfg(test)] |
|
| 153 | + | pub(crate) fn encode(&self) -> Vec<u8> { |
|
| 154 | + | cbor::encode(&Value::map([ |
|
| 155 | + | (0, Value::Uint(FORMAT_VERSION)), |
|
| 156 | + | (1, Value::Array(self.parents.iter().map(|p| Value::Bytes(p.0.to_vec())).collect())), |
|
| 157 | + | (2, self.hlc.to_value()), |
|
| 158 | + | (3, Value::Text(self.command.clone())), |
|
| 159 | + | (4, self.view.to_value()), |
|
| 160 | + | ])) |
|
| 161 | + | } |
|
| 162 | + | } |
|
| 163 | + | ||
| 85 | 164 | /// Suffix on the `command` of operations recorded by an automatic |
|
| 86 | 165 | /// working-copy snapshot rather than by a user command. |
|
| 87 | 166 | pub const SNAPSHOT_SUFFIX: &str = " (snapshot)"; |
|
| … | 30 unchanged lines |
||
| 118 | 197 | view: View::from_value(field(m, 4, "view")?)?, |
|
| 119 | 198 | }) |
|
| 120 | 199 | } |
|
| 200 | + | ||
| 201 | + | /// Read an operation written by repository format 1. |
|
| 202 | + | pub fn decode_format1(b: &[u8]) -> Result<Format1Op, ObjectError> { |
|
| 203 | + | let v = cbor::decode(b)?; |
|
| 204 | + | let m = v.as_map().ok_or_else(|| ObjectError::Schema("op: expected map".into()))?; |
|
| 205 | + | check_version(m)?; |
|
| 206 | + | Ok(Format1Op { |
|
| 207 | + | parents: array(field(m, 1, "parents")?, "parents")?.iter().map(|p| oref(p, "parent")).collect::<Result<Vec<_>, _>>()?, |
|
| 208 | + | hlc: Hlc::from_value(field(m, 2, "hlc")?)?, |
|
| 209 | + | command: text(field(m, 3, "command")?, "command")?.to_owned(), |
|
| 210 | + | view: Format1View::from_value(field(m, 4, "view")?)?, |
|
| 211 | + | }) |
|
| 212 | + | } |
|
| 121 | 213 | } |
|
| 122 | 214 | ||
| 123 | 215 | #[cfg(test)] |
|
| … | 3 unchanged lines |
||
| 127 | 219 | #[test] |
|
| 128 | 220 | fn op_round_trip() { |
|
| 129 | 221 | let mut view = View::default(); |
|
| 130 | - | view.bookmarks.insert("main".into(), vec![ObjectRef([2; 32]), ObjectRef([1; 32])]); |
|
| 131 | - | view.bookmarks.insert("dev".into(), vec![]); |
|
| 222 | + | view.bookmarks.insert("main".into(), ObjectRef([2; 32])); |
|
| 223 | + | view.bookmarks.insert("dev".into(), ObjectRef([3; 32])); |
|
| 132 | 224 | view.workspaces.insert(WorkspaceId([5; 16]), ObjectRef([9; 32])); |
|
| 133 | 225 | let op = Op { parents: vec![ObjectRef([7; 32])], hlc: Hlc { wall_ms: 5, logical: 1 }, command: "oxy commit".into(), view }; |
|
| 134 | 226 | let d = Op::decode(&op.encode()).unwrap(); |
|
| 135 | - | // Heads are normalized to sorted order on decode. |
|
| 136 | - | assert_eq!(d.view.bookmarks["main"], vec![ObjectRef([1; 32]), ObjectRef([2; 32])]); |
|
| 137 | - | assert_eq!(d.command, op.command); |
|
| 138 | - | assert_eq!(d.parents, op.parents); |
|
| 227 | + | assert_eq!(d, op); |
|
| 139 | 228 | assert_eq!(d.hash(), Op::decode(&d.encode()).unwrap().hash()); |
|
| 229 | + | // A format-1 op does not decode as format 2, but does through the migration reader. |
|
| 230 | + | let mut v1 = Format1View::default(); |
|
| 231 | + | v1.bookmarks.insert("main".into(), vec![ObjectRef([2; 32]), ObjectRef([1; 32])]); |
|
| 232 | + | v1.workspaces.insert(WorkspaceId([5; 16]), ObjectRef([9; 32])); |
|
| 233 | + | let old = Format1Op { parents: vec![], hlc: Hlc::default(), command: "oxy init".into(), view: v1 }; |
|
| 234 | + | assert!(Op::decode(&old.encode()).is_err()); |
|
| 235 | + | let back = Op::decode_format1(&old.encode()).unwrap(); |
|
| 236 | + | assert_eq!(back.view.bookmarks["main"], vec![ObjectRef([1; 32]), ObjectRef([2; 32])]); |
|
| 140 | 237 | } |
|
| 141 | 238 | } |
modified crates/oxy-core/src/repo.rs +85 −11
| … | 8 unchanged lines |
||
| 9 | 9 | use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType}; |
|
| 10 | 10 | use crate::identity::{self, Domain, Identity, SecretKey, Signature}; |
|
| 11 | 11 | use crate::ids::{ChangeId, NodeId, WorkspaceId}; |
|
| 12 | - | use crate::objects::{Blob, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, OpRef, TreeOp}; |
|
| 13 | - | use crate::oplog::{Op, View, SNAPSHOT_SUFFIX}; |
|
| 12 | + | use crate::objects::{Blob, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, OpRef, StateObject, TreeOp}; |
|
| 13 | + | use crate::oplog::{Format1Op, Op, View, SNAPSHOT_SUFFIX}; |
|
| 14 | 14 | use crate::state::{FileContent, Snapshot, State}; |
|
| 15 | 15 | use crate::store::{Store, StoreError}; |
|
| 16 | 16 | use crate::tree::OpKey; |
|
| … | 3 unchanged lines |
||
| 20 | 20 | use std::path::{Path, PathBuf}; |
|
| 21 | 21 | ||
| 22 | 22 | pub const OXY_DIR: &str = ".oxy"; |
|
| 23 | - | pub const FORMAT_LINE: &str = "oxy repository format 1\n"; |
|
| 23 | + | pub const FORMAT_LINE: &str = "oxy repository format 2\n"; |
|
| 24 | + | const FORMAT1_LINE: &str = "oxy repository format 1\n"; |
|
| 24 | 25 | const BINARY_SNIFF: usize = 8 * 1024; |
|
| 25 | 26 | ||
| 26 | 27 | #[derive(Debug, thiserror::Error)] |
|
| … | 211 unchanged lines |
||
| 238 | 239 | let wc = repo.new_working_copy(vec![], ""); |
|
| 239 | 240 | let wc_hash = repo.store.put_change(&wc)?; |
|
| 240 | 241 | let mut view = View::default(); |
|
| 241 | - | view.bookmarks.insert(repo.config.bookmark.clone(), vec![]); |
|
| 242 | + | view.bookmarks.insert(repo.config.bookmark.clone(), repo.store.put_state(&StateObject::new(vec![]))?); |
|
| 242 | 243 | view.workspaces.insert(workspace, wc_hash); |
|
| 243 | 244 | repo.record_op(&[], "oxy init", view)?; |
|
| 244 | 245 | Ok(repo) |
|
| … | 7 unchanged lines |
||
| 252 | 253 | let oxy = d.join(OXY_DIR); |
|
| 253 | 254 | if oxy.is_dir() { |
|
| 254 | 255 | let format = fs::read_to_string(oxy.join("format"))?; |
|
| 255 | - | if format != FORMAT_LINE { |
|
| 256 | - | return fail(format!("unsupported repository format: {}", format.trim())); |
|
| 257 | - | } |
|
| 258 | 256 | let config = Config::load(&oxy.join("config"))?; |
|
| 259 | 257 | let workspace = WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim()) |
|
| 260 | 258 | .ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))?; |
|
| 261 | 259 | let store = Store::open(oxy.join("objects"))?; |
|
| 262 | - | return Ok(Repo { root: d.to_path_buf(), oxy, store, workspace, keys_dir: default_keys_dir(), config }); |
|
| 260 | + | let repo = Repo { root: d.to_path_buf(), oxy, store, workspace, keys_dir: default_keys_dir(), config }; |
|
| 261 | + | if format == FORMAT1_LINE { |
|
| 262 | + | repo.migrate_format1()?; |
|
| 263 | + | } else if format != FORMAT_LINE { |
|
| 264 | + | return fail(format!("unsupported repository format: {}", format.trim())); |
|
| 265 | + | } |
|
| 266 | + | return Ok(repo); |
|
| 263 | 267 | } |
|
| 264 | 268 | dir = d.parent(); |
|
| 265 | 269 | } |
|
| 266 | 270 | fail(format!("no oxy repository found at or above {}", start.display())) |
|
| 267 | 271 | } |
|
| 268 | 272 | ||
| 273 | + | /// Rewrite a format-1 operation log, whose bookmarks were head lists, |
|
| 274 | + | /// into format 2, where each bookmark names a `state` object. |
|
| 275 | + | fn migrate_format1(&self) -> Result<()> { |
|
| 276 | + | let hex = fs::read_to_string(self.oxy.join("ops").join("head"))?; |
|
| 277 | + | let head = ObjectRef::from_hex(hex.trim()).ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into()))?; |
|
| 278 | + | // Collect the chain root-first. |
|
| 279 | + | let mut chain: Vec<Format1Op> = Vec::new(); |
|
| 280 | + | let mut cur = Some(head); |
|
| 281 | + | while let Some(r) = cur { |
|
| 282 | + | let op = Op::decode_format1(&self.store.get_typed(&r, ObjectType::Op)?)?; |
|
| 283 | + | cur = op.parents.first().copied(); |
|
| 284 | + | chain.push(op); |
|
| 285 | + | } |
|
| 286 | + | chain.reverse(); |
|
| 287 | + | let mut new_parent: Option<ObjectRef> = None; |
|
| 288 | + | for old in chain { |
|
| 289 | + | let mut view = View::default(); |
|
| 290 | + | for (name, heads) in &old.view.bookmarks { |
|
| 291 | + | view.bookmarks.insert(name.clone(), self.store.put_state(&StateObject::new(heads.clone()))?); |
|
| 292 | + | } |
|
| 293 | + | view.workspaces = old.view.workspaces.clone(); |
|
| 294 | + | let op = Op { parents: new_parent.into_iter().collect(), hlc: old.hlc, command: old.command, view }; |
|
| 295 | + | new_parent = Some(self.store.put(ObjectType::Op, &op.encode())?); |
|
| 296 | + | } |
|
| 297 | + | write_atomic(&self.oxy.join("ops").join("head"), new_parent.unwrap().to_hex().as_bytes())?; |
|
| 298 | + | write_atomic(&self.oxy.join("format"), FORMAT_LINE.as_bytes())?; |
|
| 299 | + | Ok(()) |
|
| 300 | + | } |
|
| 301 | + | ||
| 269 | 302 | pub fn root(&self) -> &Path { |
|
| 270 | 303 | &self.root |
|
| 271 | 304 | } |
|
| … | 209 unchanged lines |
||
| 481 | 514 | Ok((h, self.store.get_change(&h)?)) |
|
| 482 | 515 | } |
|
| 483 | 516 | ||
| 517 | + | /// Heads of a bookmark's state (spec §10.1). |
|
| 518 | + | pub fn heads_of(&self, view: &View, bookmark: &str) -> Result<Vec<ChangeHash>> { |
|
| 519 | + | match view.bookmarks.get(bookmark) { |
|
| 520 | + | Some(st) => Ok(self.store.get_state(st)?.heads), |
|
| 521 | + | None => Ok(vec![]), |
|
| 522 | + | } |
|
| 523 | + | } |
|
| 524 | + | ||
| 484 | 525 | pub fn bookmark_heads(&self) -> Result<Vec<ChangeHash>> { |
|
| 485 | - | Ok(self.view()?.bookmarks.get(&self.config.bookmark).cloned().unwrap_or_default()) |
|
| 526 | + | self.heads_of(&self.view()?, &self.config.bookmark) |
|
| 486 | 527 | } |
|
| 487 | 528 | ||
| 488 | 529 | // ------------------------------------------------------------ blobs |
|
| … | 171 unchanged lines |
||
| 660 | 701 | // The working copy continues on its own base plus the new change, which |
|
| 661 | 702 | // is exactly what the files on disk contain; the bookmark may hold |
|
| 662 | 703 | // other heads (and conflicts) the working copy has not checked out. |
|
| 663 | - | let old_heads = op.view.bookmarks.get(&self.config.bookmark).cloned().unwrap_or_default(); |
|
| 704 | + | let old_heads = self.heads_of(&op.view, &self.config.bookmark)?; |
|
| 664 | 705 | let old_base = snap.base.maximal(&snap.wc.deps); |
|
| 665 | 706 | let mut all: Vec<ChangeHash> = old_heads.clone(); |
|
| 666 | 707 | all.extend(old_base.iter().copied()); |
|
| … | 10 unchanged lines |
||
| 677 | 718 | let wc = self.new_working_copy(wc_base, ""); |
|
| 678 | 719 | let wc_hash = self.store.put_change(&wc)?; |
|
| 679 | 720 | let mut view = op.view.clone(); |
|
| 680 | - | view.bookmarks.insert(self.config.bookmark.clone(), new_heads); |
|
| 721 | + | view.bookmarks.insert(self.config.bookmark.clone(), self.store.put_state(&StateObject::new(new_heads))?); |
|
| 681 | 722 | view.workspaces.insert(self.workspace, wc_hash); |
|
| 682 | 723 | self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?; |
|
| 683 | 724 | Ok(hash) |
|
| … | 973 unchanged lines |
||
| 1657 | 1698 | } |
|
| 1658 | 1699 | ||
| 1659 | 1700 | #[test] |
|
| 1701 | + | fn format1_repositories_are_migrated_on_open() { |
|
| 1702 | + | use crate::oplog::{Format1Op, Format1View}; |
|
| 1703 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1704 | + | let root = dir.path(); |
|
| 1705 | + | let repo = Repo::init(root).unwrap(); |
|
| 1706 | + | write(root, "a.txt", b"a\n"); |
|
| 1707 | + | let h1 = repo.commit("one", &[]).unwrap(); |
|
| 1708 | + | let (wc_hash, _) = repo.working_copy().unwrap(); |
|
| 1709 | + | // Rewrite the op log the way format 1 stored it: two ops, head lists. |
|
| 1710 | + | let mut v1 = Format1View::default(); |
|
| 1711 | + | v1.bookmarks.insert("main".into(), vec![]); |
|
| 1712 | + | v1.workspaces.insert(repo.workspace(), wc_hash); |
|
| 1713 | + | let init = Format1Op { parents: vec![], hlc: Hlc::default(), command: "oxy init".into(), view: v1.clone() }; |
|
| 1714 | + | let init_ref = repo.store().put(ObjectType::Op, &init.encode()).unwrap(); |
|
| 1715 | + | v1.bookmarks.insert("main".into(), vec![h1]); |
|
| 1716 | + | let commit = Format1Op { parents: vec![init_ref], hlc: Hlc::default(), command: "oxy commit".into(), view: v1 }; |
|
| 1717 | + | let commit_ref = repo.store().put(ObjectType::Op, &commit.encode()).unwrap(); |
|
| 1718 | + | write_atomic(&root.join(".oxy/ops/head"), commit_ref.to_hex().as_bytes()).unwrap(); |
|
| 1719 | + | write_atomic(&root.join(".oxy/format"), FORMAT1_LINE.as_bytes()).unwrap(); |
|
| 1720 | + | drop(repo); |
|
| 1721 | + | ||
| 1722 | + | let repo = Repo::open(root).unwrap(); |
|
| 1723 | + | assert_eq!(fs::read_to_string(root.join(".oxy/format")).unwrap(), FORMAT_LINE); |
|
| 1724 | + | assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]); |
|
| 1725 | + | let ops = repo.op_log().unwrap(); |
|
| 1726 | + | assert_eq!(ops.iter().map(|(_, o)| o.command.as_str()).collect::<Vec<_>>(), ["oxy commit", "oxy init"]); |
|
| 1727 | + | assert_eq!(repo.heads_of(&ops[1].1.view, "main").unwrap(), vec![]); |
|
| 1728 | + | assert!(!repo.snapshot("status").unwrap().changed); |
|
| 1729 | + | // Reopening again is a no-op. |
|
| 1730 | + | assert_eq!(Repo::open(root).unwrap().op_log().unwrap().len(), 2); |
|
| 1731 | + | } |
|
| 1732 | + | ||
| 1733 | + | #[test] |
|
| 1660 | 1734 | fn undo_skips_snapshot_operations() { |
|
| 1661 | 1735 | let dir = tempfile::tempdir().unwrap(); |
|
| 1662 | 1736 | let root = dir.path(); |
|
| … | 79 unchanged lines |
modified crates/oxy-core/src/store.rs +9 −1
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | use crate::hash::{hash_object, header, ObjectRef, ObjectType}; |
|
| 6 | 6 | use crate::identity::{Identity, Signature}; |
|
| 7 | - | use crate::objects::{Change, ObjectError}; |
|
| 7 | + | use crate::objects::{Change, ObjectError, StateObject}; |
|
| 8 | 8 | use rand::RngCore; |
|
| 9 | 9 | use std::fs; |
|
| 10 | 10 | use std::io::{self, Write}; |
|
| … | 107 unchanged lines |
||
| 118 | 118 | self.put(ObjectType::Chunk, bytes) |
|
| 119 | 119 | } |
|
| 120 | 120 | ||
| 121 | + | pub fn put_state(&self, st: &StateObject) -> Result<ObjectRef> { |
|
| 122 | + | self.put(ObjectType::State, &st.encode()) |
|
| 123 | + | } |
|
| 124 | + | ||
| 125 | + | pub fn get_state(&self, r: &ObjectRef) -> Result<StateObject> { |
|
| 126 | + | Ok(StateObject::decode(&self.get_typed(r, ObjectType::State)?)?) |
|
| 127 | + | } |
|
| 128 | + | ||
| 121 | 129 | pub fn put_identity(&self, id: &Identity) -> Result<ObjectRef> { |
|
| 122 | 130 | self.put(ObjectType::Identity, &id.encode()) |
|
| 123 | 131 | } |
|
| … | 116 unchanged lines |
modified docs/spec.md +24 −15
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.4b | |
|
| 5 | + | | **Status** | Draft 0.5 | |
|
| 6 | 6 | | **Format version** | 1 | |
|
| 7 | 7 | | **Date** | 2026-09-26 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 111 unchanged lines |
||
| 120 | 120 | | **AtomId** | `(ChangeHash of introducing change, u32 index)`. | |
|
| 121 | 121 | | **Node** | A file, directory, or symlink, identified by a 128-bit random **NodeId** stable across renames. | |
|
| 122 | 122 | | **Placement** | The `(parent NodeId, name)` pair that positions a node in the tree. | |
|
| 123 | - | | **State** | A dependency-closed set of changes. Fully determined by its maximal elements (**heads**). | |
|
| 123 | + | | **State** | A dependency-closed set of changes. Fully determined by its maximal elements (**heads**), and named by a `state` object that lists them. | |
|
| 124 | 124 | | **Bookmark** | A named, mutable pointer to a state. Equivalent to a Git branch. | |
|
| 125 | 125 | | **Tag** | A named, immutable, signed pointer to a state. | |
|
| 126 | 126 | | **Checkpoint** | A materialized snapshot of a state, used as a cache. | |
|
| … | 17 unchanged lines |
||
| 144 | 144 | header = "oxy1 " || type-name || " " || decimal(len(body)) || 0x00 |
|
| 145 | 145 | ``` |
|
| 146 | 146 | ||
| 147 | - | `type-name` is one of `chunk`, `blob`, `change`, `checkpoint`, `op`, `attest`, `identity`, `policy`. The header prevents an object of one type from being presented as another. BLAKE3 with a 256-bit output retains 128-bit collision resistance against quantum attackers. |
|
| 147 | + | `type-name` is one of `chunk`, `blob`, `change`, `checkpoint`, `op`, `attest`, `identity`, `policy`, `state`. The header prevents an object of one type from being presented as another. BLAKE3 with a 256-bit output retains 128-bit collision resistance against quantum attackers. |
|
| 148 | 148 | ||
| 149 | 149 | The all-zero ObjectRef is reserved and means "self" inside a change (§7.4). |
|
| 150 | 150 | ||
| … | 9 unchanged lines |
||
| 160 | 160 | | `attest` | A signed statement. | §14.4 | |
|
| 161 | 161 | | `identity` | A contributor's keys. | §14.2 | |
|
| 162 | 162 | | `policy` | The repository's trust policy. | §14.5 | |
|
| 163 | + | | `state` | A sorted list of heads naming a state. | §10.1 | |
|
| 163 | 164 | ||
| 164 | 165 | ### 5.4 Signatures are outside the hash |
|
| 165 | 166 | ||
| … | 18 unchanged lines |
||
| 184 | 185 | | `OpRef` (placement or mode reference) | `[bytes(32) change hash, uint index]`; the all-zero hash means this change. | |
|
| 185 | 186 | | `AtomRef` | `0` for `START`, `1` for `END`, otherwise `[bytes(32) change hash, uint index]` with the all-zero hash meaning this change. | |
|
| 186 | 187 | | `AtomRange` | `[bytes(32) change hash, uint from, uint to]`, half-open, non-empty. | |
|
| 187 | - | | `View` (in `Op`) | Key 0 bookmarks: array of `[text name, [bytes(32) heads]]` sorted by name; key 1 workspaces: array of `[bytes(16) id, bytes(32) change]` sorted by id. Remote and tag namespaces are added in later drafts. | |
|
| 188 | + | | `View` (in `Op`) | Key 0 bookmarks: array of `[text name, bytes(32) state ref]` sorted by name; key 1 workspaces: array of `[bytes(16) id, bytes(32) change]` sorted by id. Remote and tag namespaces are added in later drafts. Repository format 1 stored `[name, [heads]]`; format 2 stores the state ref. | |
|
| 188 | 189 | | `Signature` | Map: 0 key id, 1 alg (1 = Ed25519 + ML-DSA-65), 2 Ed25519 bytes(64), 3 ML-DSA-65 bytes(3309). A signature list is a CBOR array of these. | |
|
| 189 | 190 | | Secret key file | Map: 0 version, 1 key id, 2 Ed25519 seed bytes(32), 3 ML-DSA-65 seed bytes(32). Kept only in the user's key directory. | |
|
| 190 | 191 | ||
| … | 200 unchanged lines |
||
| 391 | 392 | ||
| 392 | 393 | `<name>` is UTF-8, 1–255 bytes, without control characters, leading `-`, or the sequences `..`, `//`, `@{`. |
|
| 393 | 394 | ||
| 394 | - | A bookmark value is `{ heads: [ChangeHash] }` with one or more heads. Multiple heads are permitted and represent a bookmark whose state has more than one maximal change; this is not a conflict in the §9 sense and is shown by `oxy log` as parallel heads. |
|
| 395 | + | A bookmark or tag points at a **state object**: |
|
| 396 | + | ||
| 397 | + | ``` |
|
| 398 | + | State { version [0]: 1, heads [1]: [ChangeHash] } // sorted, unique; may be empty |
|
| 399 | + | ``` |
|
| 400 | + | ||
| 401 | + | Every reference, attestation, view, and checkpoint therefore names a state by one ObjectRef however many heads it has. Multiple heads are normal in this model: every independent change is a head until something depends on it, so a busy bookmark may have dozens. That is not a conflict in the §9 sense; `oxy log` shows the heads as parallel, and a state object keeps the cost of naming them constant for everything that refers to the bookmark. The empty state, `State { heads: [] }`, is what a new bookmark points at. |
|
| 395 | 402 | ||
| 396 | 403 | ### 10.2 Reference attestations (G2, G3) |
|
| 397 | 404 | ||
| 398 | - | Every published bookmark update is accompanied by an attestation (§14.4) of kind `ref` that binds the repository ID, the reference name, the previous heads, the new heads, and a per-reference monotonically increasing `serial`. Servers MUST store the latest attestation per reference and serve it with the reference. Clients MUST verify the attestation against policy before updating a `remotes/` reference and MUST reject an attestation whose `serial` is lower than one they have already recorded for that reference (rollback protection). Clients SHOULD warn when the newest attestation's `hlc.wall_ms` is older than `remote.max-age` (default 30 days) and the remote claims no update. |
|
| 405 | + | Every published bookmark update is accompanied by an attestation (§14.4) of kind `ref` that binds the repository ID, the reference name, the previous state, the new state, and a per-reference monotonically increasing `serial`. Servers MUST store the latest attestation per reference and serve it with the reference. Clients MUST verify the attestation against policy before updating a `remotes/` reference and MUST reject an attestation whose `serial` is lower than one they have already recorded for that reference (rollback protection). Clients SHOULD warn when the newest attestation's `hlc.wall_ms` is older than `remote.max-age` (default 30 days) and the remote claims no update. |
|
| 399 | 406 | ||
| 400 | 407 | Tags are attestations of kind `tag` and are never updated; a server MUST reject a second attestation for an existing tag name. |
|
| 401 | 408 | ||
| … | 26 unchanged lines |
||
| 428 | 435 | parents [1]: [ObjectRef] // usually one; two when merging concurrent operations |
|
| 429 | 436 | hlc [2]: HLC |
|
| 430 | 437 | command [3]: text // e.g. "oxy rebase -s k7m2p9 -d main" |
|
| 431 | - | view [4]: { bookmarks: map, remotes: map, tags: map, workspaces: map<WorkspaceId, ChangeHash> } |
|
| 438 | + | view [4]: { bookmarks: map<name, state ObjectRef>, remotes: map, tags: map, workspaces: map<WorkspaceId, ChangeHash> } |
|
| 432 | 439 | } |
|
| 433 | 440 | ``` |
|
| 434 | 441 | ||
| … | 26 unchanged lines |
||
| 461 | 468 | ||
| 462 | 469 | ``` |
|
| 463 | 470 | .oxy/ |
|
| 464 | - | format "oxy repository format 1\n" |
|
| 471 | + | format "oxy repository format 2\n" |
|
| 465 | 472 | config repository-local configuration (TOML) |
|
| 466 | 473 | objects/ |
|
| 467 | 474 | pack-<id>.pack append-only pack file |
|
| … | 96 unchanged lines |
||
| 564 | 571 | hlc [3]: HLC |
|
| 565 | 572 | body [4]: map // per kind |
|
| 566 | 573 | } |
|
| 567 | - | ref: { name, old_heads, new_heads, serial: u64 } |
|
| 568 | - | tag: { name, heads, message } |
|
| 574 | + | ref: { name, old_state: ObjectRef, new_state: ObjectRef, serial: u64 } |
|
| 575 | + | tag: { name, state: ObjectRef, message } |
|
| 569 | 576 | seal: { hashes: [ChangeHash] } // sorted, unique, 1..4096 entries |
|
| 570 | 577 | rotate: { new: KeyId } |
|
| 571 | 578 | revoke: { key: KeyId, reason } |
|
| … | 27 unchanged lines |
||
| 599 | 606 | ``` |
|
| 600 | 607 | ||
| 601 | 608 | - The initial policy is signed by the root keys; every update MUST be signed by `roots.threshold` root keys and have a higher `serial`. |
|
| 602 | - | - A change or attestation is **authorized** for reference `r` if its signer is listed in the first rule whose pattern matches `r`, and the rule's threshold of distinct authorized signers is met across the attestation and each change in the `new_heads` closure not previously in the `old_heads` closure. `require` selects which authenticators count for changes: individual signatures only, seals only, or either. Every change MUST additionally satisfy the author-or-delegate rule of §14.4 regardless of `require`. |
|
| 609 | + | - A change or attestation is **authorized** for reference `r` if its signer is listed in the first rule whose pattern matches `r`, and the rule's threshold of distinct authorized signers is met across the attestation and each change in the closure of `new_state` not previously in the closure of `old_state`. `require` selects which authenticators count for changes: individual signatures only, seals only, or either. Every change MUST additionally satisfy the author-or-delegate rule of §14.4 regardless of `require`. |
|
| 603 | 610 | - Clients MUST verify policy on fetch, MUST refuse to update a `remotes/` reference whose attestation is unauthorized, and MUST warn loudly on an expired policy. Servers MUST enforce the same rules on push. |
|
| 604 | 611 | - A repository without a `policy` reference is **unverified**; every command that talks to a remote MUST print a one-line warning until one is created. `oxy init` creates one by default. |
|
| 605 | 612 | ||
| … | 35 unchanged lines |
||
| 641 | 648 | | Endpoint | Request | Response | |
|
| 642 | 649 | |---|---|---| |
|
| 643 | 650 | | `GET capabilities` | — | `{ versions: [1], transports, features: ["lazy", "checkpoint", "seal", "lock"], max_frame }` | |
|
| 644 | - | | `GET refs` | `{ patterns }` | `[ { name, heads, attestation: Signed<Attest> } ]` plus the current `Signed<Policy>` | |
|
| 651 | + | | `GET refs` | `{ patterns }` | `[ { name, state: ObjectRef, heads, attestation: Signed<Attest> } ]` plus the current `Signed<Policy>` | |
|
| 645 | 652 | | `POST negotiate` | `{ have: [ChangeHash], want: [ChangeHash], filter }` | stream of `ChangeHash` the client lacks, in dependency order | |
|
| 646 | 653 | | `POST fetch` | `{ changes: [ChangeHash], include_blobs: bool, filter }` | stream of `Signed<Change>`, then every `Signed<Attest kind=seal>` covering them, then referenced `blob`/`chunk` objects | |
|
| 647 | 654 | | `POST objects` | `{ refs: [ObjectRef] }` | stream of objects | |
|
| … | 7 unchanged lines |
||
| 655 | 662 | ||
| 656 | 663 | ### 16.3 Push validation |
|
| 657 | 664 | ||
| 658 | - | On push, the server MUST, in order: verify that every change has a valid signature or seal from a key embedded in policy and satisfies the author-or-delegate rule (§14.4), verify the attestation for each reference, check that `old_heads` matches its current heads (compare-and-swap), check policy rules for the reference including `allow_conflicts`, check that every dependency of every change is either included or already stored, and only then update references atomically. A failure rejects the whole push. |
|
| 665 | + | On push, the server MUST, in order: verify that every change has a valid signature or seal from a key embedded in policy and satisfies the author-or-delegate rule (§14.4), verify the attestation for each reference, check that `old_state` matches its current state (compare-and-swap), check policy rules for the reference including `allow_conflicts`, check that every dependency of every change is either included or already stored, and only then update references atomically. A failure rejects the whole push. |
|
| 659 | 666 | ||
| 660 | 667 | ## 17. Partial, sparse, and shallow repositories |
|
| 661 | 668 | ||
| … | 39 unchanged lines |
||
| 701 | 708 | ||
| 702 | 709 | The `semantic-deps` hook runs during `oxy commit` after the working copy is snapshotted and before the change is finalized. It receives `{ change_id, touched_paths, candidate_deps: [ { change_id, hash, paths } ] }`, where `candidate_deps` are the changes in the working-copy state that touch any file the new change touches or that it references by symbol if the hook has such information, and it returns `{ deps: [ChangeHash] }` to add to `deps(C)`. It runs only when `hooks.semantic-deps = true` is set in `.oxy/config`, which is never set by clone. |
|
| 703 | 710 | ||
| 704 | - | Server policy is the trust policy (§14.5) plus server configuration for per-reference rules: protected patterns that additionally require CI status, forbid conflicted heads, forbid multiple heads, or forbid force (non-fast-forward) updates. A force update is a `ref` attestation whose `old_heads` are not all in the closure of `new_heads`. |
|
| 711 | + | Server policy is the trust policy (§14.5) plus server configuration for per-reference rules: protected patterns that additionally require CI status, forbid conflicted heads, forbid multiple heads, or forbid force (non-fast-forward) updates. A force update is a `ref` attestation whose `old_state` heads are not all in the closure of `new_state`. |
|
| 705 | 712 | ||
| 706 | 713 | ## 21. Garbage collection |
|
| 707 | 714 | ||
| … | 87 unchanged lines |
||
| 795 | 802 | ||
| 796 | 803 | --- |
|
| 797 | 804 | ||
| 798 | - | *Changelog.* Draft 0.4b (2026-09-26): §5.5 records the encoding conventions for variants, references, views, signatures, and secret key files; §13.1 adds the signature directory. |
|
| 805 | + | *Changelog.* Draft 0.5 (2026-09-26): bookmarks and tags point at a `state` object naming the heads, so attestations, views, and references carry one ObjectRef per bookmark however many heads it has; repository format 2 records this, and format-1 repositories migrate on open. |
|
| 806 | + | ||
| 807 | + | Draft 0.4b (2026-09-26): §5.5 records the encoding conventions for variants, references, views, signatures, and secret key files; §13.1 adds the signature directory. |
|
| 799 | 808 | ||
| 800 | 809 | Draft 0.4a (2026-09-26): §13.2 pins the FastCDC gear table, rolling hash, and masks so boundaries are identical across implementations. |
|
| 801 | 810 | ||
| … | 5 unchanged lines |