OXYGENHUB

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