OXYGENHUB

Packed storage: zstd packs with sorted indexes, oxy gc, auto-pack at 4096 loose objects; README d02e545dc265

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 19:28 UTC
Signature
✔ signed by Andy McCall
Hash
d02e545dc265940fdff440a6459e9b42cfec48124827b5f3c613b95b53842c74
Depends on
1983686fb7a7 2b5ee371060e 5e388efbde57 648724d8e0de 750251b21a96 83e0677b07fe 982864068a59 e993a086c13f f4324e406906 fce44ae9ad29 ff0af1ee299d
Depended on by
91334b7fa574 e4595038502a 767ac6cd0545 7e0e8e5ad882 a67914bced23 b689779e9e74 54e2e6309aa3 37f1914b62a8 1f53d5b91eb5 da58bbbbc4d9 450d595070bb 648189388f1a 47ce8b8e2d4b 9ae4d15a0e4b f874dc8659c2 8e1194860efa 8d80356371c5 236f35c0baba b30a880fa409 ff1f62fee654 a17fc622ca43 25c6adfaa559 da652d6392d8 afaba5f1306d 75167672ebcd 728da0f92161 ee06a6c557b7 8f10a07a1dbe 2b872d074ddb 30ea9c1e70fa 21b2aec279ff 94084087a876 28fc23d7706a 6d3eefbce2f0 cdb5beba7f05 135212de5370 ba160f60219e f77698833f9f 614541c3cebd
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
36 atoms, 0 tree operations, 10 files

Browse the tree at this change

modified Cargo.lock +47 −0
…
290 unchanged lines
291 291
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
292 292
dependencies = [
293 293
 "find-msvc-tools",
294 +
 "jobserver",
295 +
 "libc",
294 296
 "shlex",
295 297
]
296 298
…
658 unchanged lines
955 957
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
956 958
957 959
[[package]]
960 +
name = "jobserver"
961 +
version = "0.1.35"
962 +
source = "registry+https://github.com/rust-lang/crates.io-index"
963 +
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
964 +
dependencies = [
965 +
 "getrandom 0.4.3",
966 +
 "libc",
967 +
]
968 +
969 +
[[package]]
958 970
name = "keccak"
959 971
version = "0.2.2"
960 972
source = "registry+https://github.com/rust-lang/crates.io-index"
…
199 unchanged lines
1160 1172
 "thiserror 1.0.69",
1161 1173
 "ureq",
1162 1174
 "webpki-roots 1.0.9",
1175 +
 "zstd",
1163 1176
]
1164 1177
1165 1178
[[package]]
…
42 unchanged lines
1208 1221
]
1209 1222
1210 1223
[[package]]
1224 +
name = "pkg-config"
1225 +
version = "0.3.34"
1226 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1227 +
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
1228 +
1229 +
[[package]]
1211 1230
name = "potential_utf"
1212 1231
version = "0.1.6"
1213 1232
source = "registry+https://github.com/rust-lang/crates.io-index"
…
974 unchanged lines
2188 2207
version = "1.0.23"
2189 2208
source = "registry+https://github.com/rust-lang/crates.io-index"
2190 2209
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
2210 +
2211 +
[[package]]
2212 +
name = "zstd"
2213 +
version = "0.13.3"
2214 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2215 +
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
2216 +
dependencies = [
2217 +
 "zstd-safe",
2218 +
]
2219 +
2220 +
[[package]]
2221 +
name = "zstd-safe"
2222 +
version = "7.3.0"
2223 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2224 +
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
2225 +
dependencies = [
2226 +
 "zstd-sys",
2227 +
]
2228 +
2229 +
[[package]]
2230 +
name = "zstd-sys"
2231 +
version = "2.1.0+zstd.1.5.7"
2232 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2233 +
checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0"
2234 +
dependencies = [
2235 +
 "cc",
2236 +
 "pkg-config",
2237 +
]
modified README.md +69 −2
1 1
# Oxygen
2 2
3 -
A distributed, commutative version control system written in Rust for large repositories. See docs/spec.md.
3 +
Oxygen (`oxy`) is a distributed, commutative version control system written in Rust for large repositories. See [docs/spec.md](docs/spec.md) for the specification.
4 +
5 +
It is self-hosting: this directory is an Oxygen repository, and the project's own history is kept with `oxy`, not with Git.
6 +
7 +
## What is different
8 +
9 +
- **History is a set of changes, not a chain of snapshots.** A change records what it did to identified lines and tree nodes and which earlier changes it depends on. Changes that do not depend on each other commute, so there are no merge commits and rebasing independent work rewrites nothing.
10 +
- **Conflicts are data.** A conflicted state can be committed, pushed and pulled. Resolving one is an ordinary edit followed by a commit.
11 +
- **Every change has a stable ID and a content hash.** The ID survives rewrites; the hash makes history tamper-evident.
12 +
- **Everything is signed.** Composite Ed25519 + ML-DSA-65 signatures on changes and on every bookmark update, verified against a trust policy that lives in the repository.
13 +
- **Every operation is undoable** through an operation log.
14 +
- **Large files are chunked** with content-defined chunking into a BLAKE3-addressed store.
15 +
16 +
## Status
17 +
18 +
Early, but usable for its own development. Implemented: the object model, the change graph with conflict detection and resolution, the working copy (`status`, `commit`, `checkout`, `diff`, `undo`), identities and signatures, remotes over local paths and HTTP(S) with `clone`, `fetch`, `pull`, `push`, the reference server `oxy serve`, the trust policy, and packed storage.
19 +
20 +
Not yet: checkpoints for very long histories, history editing (`rebase`, `split`, `edit`), multiple workspaces, garbage collection with retention, and the Thin client profile for constrained machines.
21 +
22 +
## Building
23 +
24 +
```
25 +
cargo build --release
26 +
cargo test --workspace
27 +
```
28 +
29 +
The binary is `target/release/oxy`.
4 30
5 -
Cloned and pushed back over the local transport.
31 +
## Quick start
32 +
33 +
```
34 +
oxy identity create --name "Your Name" --email [email protected]
35 +
oxy config --user core.sign change      # sign every commit
36 +
oxy init                                 # in a project directory
37 +
oxy status
38 +
oxy commit -m "First change"
39 +
oxy log
40 +
```
41 +
42 +
Collaborating:
43 +
44 +
```
45 +
oxy serve --root /srv/oxy --bind 0.0.0.0:8890 --tls-cert cert.pem --tls-key key.pem --token SECRET
46 +
oxy clone https://host:8890/project
47 +
oxy config remote.origin.token SECRET
48 +
oxy push
49 +
oxy pull
50 +
```
51 +
52 +
Trust policy, so a server accepts only changes from keys you have granted:
53 +
54 +
```
55 +
oxy policy init                          # your key becomes the root
56 +
oxy identity export                      # a contributor sends you this file
57 +
oxy policy grant alice.identity
58 +
oxy push                                 # publishes the policy
59 +
```
60 +
61 +
## Layout
62 +
63 +
| Path | Contents |
64 +
|---|---|
65 +
| `crates/oxy-core` | Library: encoding, hashing, objects, the change graph, storage, identities, policy, protocol, repository operations |
66 +
| `crates/oxy-server` | Reference HTTP(S) server for the wire protocol |
67 +
| `src/cli` | The `oxy` command-line interface |
68 +
| `docs/spec.md` | The specification |
69 +
70 +
## Licence
71 +
72 +
MIT or Apache-2.0, at your option.
modified crates/oxy-core/Cargo.toml +1 −0
…
17 unchanged lines
18 18
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
19 19
rustls-pemfile = "2"
20 20
webpki-roots = "1"
21 +
zstd = "0.13"
21 22
22 23
[dev-dependencies]
23 24
tempfile = "3"
modified crates/oxy-core/src/repo/mod.rs +17 −0
…
27 unchanged lines
28 28
pub const FORMAT_LINE: &str = "oxy repository format 2\n";
29 29
const FORMAT1_LINE: &str = "oxy repository format 1\n";
30 30
const BINARY_SNIFF: usize = 8 * 1024;
31 +
/// Loose objects tolerated before a commit packs them (spec §13.4).
32 +
pub const AUTOPACK_THRESHOLD: usize = 4096;
31 33
32 34
#[derive(Debug, thiserror::Error)]
33 35
pub enum RepoError {
…
185 unchanged lines
219 221
        Ok(())
220 222
    }
221 223
224 +
    /// Pack every loose object (spec §13.4). Pruning unreachable objects
225 +
    /// waits for operation-log retention, since every operation keeps its
226 +
    /// states reachable for now.
227 +
    pub fn gc(&self) -> Result<crate::store::PackReport> {
228 +
        Ok(self.store.pack_loose()?)
229 +
    }
230 +
231 +
    /// Pack automatically once many loose objects have accumulated.
232 +
    pub(crate) fn maybe_autopack(&self) -> Result<()> {
233 +
        if self.store.loose_count()? > AUTOPACK_THRESHOLD {
234 +
            self.store.pack_loose()?;
235 +
        }
236 +
        Ok(())
237 +
    }
238 +
222 239
    /// The repository ID (spec §14.4). Repositories made before IDs existed get one on first use.
223 240
    pub fn id(&self) -> Result<[u8; 16]> {
224 241
        let path = self.oxy.join("id");
…
335 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +1 −0
…
175 unchanged lines
176 176
        view.bookmarks.insert(self.config.bookmark.clone(), self.store.put_state(&StateObject::new(new_heads))?);
177 177
        view.workspaces.insert(self.workspace, wc_hash);
178 178
        self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?;
179 +
        self.maybe_autopack()?;
179 180
        Ok(hash)
180 181
    }
181 182
}
…
375 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +37 −0
…
748 unchanged lines
749 749
    assert!(c.current_policy().unwrap().unwrap().1.policy.roots.contains(&key_a));
750 750
    assert!(!c.snapshot("status").unwrap().changed);
751 751
}
752 +
753 +
#[test]
754 +
fn gc_packs_objects_and_history_still_works() {
755 +
    hermetic();
756 +
    let dir = tempfile::tempdir().unwrap();
757 +
    let root = dir.path();
758 +
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
759 +
    repo.create_identity("Ada", "a@b", IdentityScope::User).unwrap();
760 +
    repo.set_user_config("core.sign", "change").unwrap();
761 +
    write(root, "a.txt", b"one\n");
762 +
    let h1 = repo.commit("one", &[]).unwrap();
763 +
    write(root, "a.txt", b"two\n");
764 +
    write(root, "big.bin", &vec![0u8; 100_000]);
765 +
    let h2 = repo.commit("two", &[]).unwrap();
766 +
    let before = repo.store().list().unwrap();
767 +
    assert!(repo.store().loose_count().unwrap() > 0);
768 +
    let r = repo.gc().unwrap();
769 +
    assert_eq!(r.objects, before.len());
770 +
    assert_eq!(repo.store().loose_count().unwrap(), 0);
771 +
    assert_eq!(repo.store().list().unwrap(), before);
772 +
    // Reopen: reads come from the pack; signatures, states, ops all resolve.
773 +
    let repo = Repo::open(root).unwrap().with_home(root.join("home"));
774 +
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]);
775 +
    assert!(matches!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { .. }));
776 +
    assert_eq!(repo.log().unwrap().len(), 3);
777 +
    repo.checkout(&[h1], false, "checkout").unwrap();
778 +
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n");
779 +
    repo.checkout(&[h2], false, "checkout").unwrap();
780 +
    assert_eq!(fs::read(root.join("big.bin")).unwrap().len(), 100_000);
781 +
    assert!(!repo.snapshot("status").unwrap().changed);
782 +
    // New work after packing goes loose and commits fine.
783 +
    write(root, "a.txt", b"three\n");
784 +
    repo.commit("three", &[]).unwrap();
785 +
    assert!(repo.store().loose_count().unwrap() > 0);
786 +
    assert_eq!(repo.gc().unwrap().objects, repo.store().list().unwrap().len() - before.len());
787 +
    assert_eq!(repo.store().pack_count(), 2);
788 +
}
modified crates/oxy-core/src/store.rs +286 −26
1 -
//! Loose object store (spec §13.1): one file per object under
2 -
//! `objects/loose/<hh>/<rest>`, written atomically and verified on read.
3 -
//! Packs come later.
1 +
//! Object store (spec §13.1, §13.3, §13.4): loose objects, one file per
2 +
//! object under `objects/loose/<hh>/<rest>`, and packs, `pack-<id>.pack`
3 +
//! with a sorted `pack-<id>.idx`, holding zstd-compressed objects. Reads
4 +
//! check loose objects first, then every pack; every read re-hashes.
5 +
//!
6 +
//! Pack file: `OXYPACK1` then entries of
7 +
//! `type u8 | flags u8 (bit 0: zstd) | stored_len u32 | raw_len u32 | ref 32 | body`.
8 +
//! Index file: `OXYIDX01` then `count u32` then sorted entries of
9 +
//! `ref 32 | offset u64 | stored_len u32 | raw_len u32 | type u8 | flags u8`.
4 10
5 11
use crate::attest::Attest;
6 12
use crate::hash::{hash_object, header, ObjectRef, ObjectType};
7 13
use crate::identity::{Identity, Signature};
8 14
use crate::objects::{Change, ObjectError, StateObject};
9 15
use rand::RngCore;
16 +
use std::cell::RefCell;
10 17
use std::fs;
11 -
use std::io::{self, Write};
18 +
use std::io::{self, Read, Seek, SeekFrom, Write};
12 19
use std::path::{Path, PathBuf};
13 20
21 +
const PACK_MAGIC: &[u8; 8] = b"OXYPACK1";
22 +
const IDX_MAGIC: &[u8; 8] = b"OXYIDX01";
23 +
const IDX_ENTRY: usize = 32 + 8 + 4 + 4 + 1 + 1;
24 +
const FLAG_ZSTD: u8 = 1;
25 +
const ZSTD_LEVEL: i32 = 3;
26 +
27 +
#[derive(Clone, Copy, Debug)]
28 +
struct PackEntry {
29 +
    offset: u64,
30 +
    stored_len: u32,
31 +
    raw_len: u32,
32 +
    ty: ObjectType,
33 +
    flags: u8,
34 +
}
35 +
36 +
/// One pack's index, fully in memory and sorted by ObjectRef.
37 +
#[derive(Debug)]
38 +
struct PackIndex {
39 +
    path: PathBuf,
40 +
    refs: Vec<ObjectRef>,
41 +
    entries: Vec<PackEntry>,
42 +
}
43 +
44 +
impl PackIndex {
45 +
    fn load(idx_path: &Path, pack_path: &Path) -> Result<PackIndex> {
46 +
        let data = fs::read(idx_path)?;
47 +
        let bad = |m: &str| StoreError::Corrupt(ObjectRef::ZERO, format!("{}: {m}", idx_path.display()));
48 +
        if data.len() < 12 || &data[..8] != IDX_MAGIC {
49 +
            return Err(bad("bad index header"));
50 +
        }
51 +
        let count = u32::from_le_bytes(data[8..12].try_into().unwrap()) as usize;
52 +
        if data.len() != 12 + count * IDX_ENTRY {
53 +
            return Err(bad("index length mismatch"));
54 +
        }
55 +
        let mut refs = Vec::with_capacity(count);
56 +
        let mut entries = Vec::with_capacity(count);
57 +
        for i in 0..count {
58 +
            let e = &data[12 + i * IDX_ENTRY..12 + (i + 1) * IDX_ENTRY];
59 +
            let r = ObjectRef::from_slice(&e[..32]).unwrap();
60 +
            if let Some(prev) = refs.last() {
61 +
                if r <= *prev {
62 +
                    return Err(bad("index not sorted"));
63 +
                }
64 +
            }
65 +
            refs.push(r);
66 +
            entries.push(PackEntry {
67 +
                offset: u64::from_le_bytes(e[32..40].try_into().unwrap()),
68 +
                stored_len: u32::from_le_bytes(e[40..44].try_into().unwrap()),
69 +
                raw_len: u32::from_le_bytes(e[44..48].try_into().unwrap()),
70 +
                ty: ObjectType::ALL.get(e[48] as usize).copied().ok_or_else(|| bad("unknown object type"))?,
71 +
                flags: e[49],
72 +
            });
73 +
        }
74 +
        Ok(PackIndex { path: pack_path.to_path_buf(), refs, entries })
75 +
    }
76 +
77 +
    fn find(&self, r: &ObjectRef) -> Option<PackEntry> {
78 +
        self.refs.binary_search(r).ok().map(|i| self.entries[i])
79 +
    }
80 +
81 +
    fn read(&self, r: &ObjectRef, e: PackEntry) -> Result<(ObjectType, Vec<u8>)> {
82 +
        let mut f = fs::File::open(&self.path)?;
83 +
        f.seek(SeekFrom::Start(e.offset))?;
84 +
        let mut stored = vec![0u8; e.stored_len as usize];
85 +
        f.read_exact(&mut stored)?;
86 +
        let body = if e.flags & FLAG_ZSTD != 0 {
87 +
            zstd::bulk::decompress(&stored, e.raw_len as usize).map_err(|err| StoreError::Corrupt(*r, format!("zstd: {err}")))?
88 +
        } else {
89 +
            stored
90 +
        };
91 +
        if body.len() != e.raw_len as usize || hash_object(e.ty, &body) != *r {
92 +
            return Err(StoreError::Corrupt(*r, "packed object does not match its reference".into()));
93 +
        }
94 +
        Ok((e.ty, body))
95 +
    }
96 +
}
97 +
98 +
fn type_index(ty: ObjectType) -> u8 {
99 +
    ObjectType::ALL.iter().position(|t| *t == ty).unwrap() as u8
100 +
}
101 +
102 +
/// What `pack_loose` did.
103 +
#[derive(Debug, Clone, Default, PartialEq, Eq)]
104 +
pub struct PackReport {
105 +
    pub objects: usize,
106 +
    pub raw_bytes: u64,
107 +
    pub packed_bytes: u64,
108 +
}
109 +
14 110
#[derive(Debug, thiserror::Error)]
15 111
pub enum StoreError {
16 112
    #[error("io: {0}")]
…
10 unchanged lines
27 123
28 124
pub type Result<T> = std::result::Result<T, StoreError>;
29 125
30 -
#[derive(Debug, Clone)]
126 +
#[derive(Debug)]
31 127
pub struct Store {
32 128
    dir: PathBuf,
129 +
    packs: RefCell<Vec<PackIndex>>,
33 130
}
34 131
35 132
impl Store {
36 133
    pub fn open(dir: impl Into<PathBuf>) -> io::Result<Store> {
37 134
        let dir = dir.into();
38 135
        fs::create_dir_all(dir.join("loose"))?;
39 -
        Ok(Store { dir })
136 +
        let store = Store { dir, packs: RefCell::new(Vec::new()) };
137 +
        store.reload_packs().map_err(|e| io::Error::other(e.to_string()))?;
138 +
        Ok(store)
139 +
    }
140 +
141 +
    fn reload_packs(&self) -> Result<()> {
142 +
        let mut packs = Vec::new();
143 +
        let mut names: Vec<PathBuf> = fs::read_dir(&self.dir)?
144 +
            .filter_map(|e| e.ok().map(|e| e.path()))
145 +
            .filter(|p| p.extension().is_some_and(|x| x == "idx") && p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("pack-")))
146 +
            .collect();
147 +
        names.sort();
148 +
        for idx in names {
149 +
            let pack = idx.with_extension("pack");
150 +
            if pack.is_file() {
151 +
                packs.push(PackIndex::load(&idx, &pack)?);
152 +
            }
153 +
        }
154 +
        *self.packs.borrow_mut() = packs;
155 +
        Ok(())
156 +
    }
157 +
158 +
    pub fn pack_count(&self) -> usize {
159 +
        self.packs.borrow().len()
160 +
    }
161 +
162 +
    fn find_packed(&self, r: &ObjectRef) -> Option<(usize, PackEntry)> {
163 +
        self.packs.borrow().iter().enumerate().find_map(|(i, p)| p.find(r).map(|e| (i, e)))
164 +
    }
165 +
166 +
    /// Loose objects on disk, with their paths.
167 +
    fn loose_objects(&self) -> io::Result<Vec<(ObjectRef, PathBuf)>> {
168 +
        let mut out = Vec::new();
169 +
        let loose = self.dir.join("loose");
170 +
        for d in fs::read_dir(&loose)? {
171 +
            let d = d?;
172 +
            if !d.file_type()?.is_dir() {
173 +
                continue;
174 +
            }
175 +
            let prefix = d.file_name().to_string_lossy().to_string();
176 +
            for f in fs::read_dir(d.path())? {
177 +
                let f = f?;
178 +
                let name = f.file_name().to_string_lossy().to_string();
179 +
                if let Some(r) = ObjectRef::from_hex(&format!("{prefix}{name}")) {
180 +
                    out.push((r, f.path()));
181 +
                }
182 +
            }
183 +
        }
184 +
        out.sort();
185 +
        Ok(out)
186 +
    }
187 +
188 +
    pub fn loose_count(&self) -> io::Result<usize> {
189 +
        Ok(self.loose_objects()?.len())
190 +
    }
191 +
192 +
    /// Move every loose object into a new pack (spec §13.4). Loose files are
193 +
    /// deleted only after the pack and its index are durable.
194 +
    pub fn pack_loose(&self) -> Result<PackReport> {
195 +
        let loose = self.loose_objects()?;
196 +
        if loose.is_empty() {
197 +
            return Ok(PackReport::default());
198 +
        }
199 +
        let mut nonce = [0u8; 8];
200 +
        rand::rngs::OsRng.fill_bytes(&mut nonce);
201 +
        let id = hex::encode(nonce);
202 +
        let pack_path = self.dir.join(format!("pack-{id}.pack"));
203 +
        let idx_path = self.dir.join(format!("pack-{id}.idx"));
204 +
        let tmp_pack = self.dir.join(format!("tmp-{id}.pack"));
205 +
        let tmp_idx = self.dir.join(format!("tmp-{id}.idx"));
206 +
        let mut report = PackReport::default();
207 +
        let mut index: Vec<(ObjectRef, PackEntry)> = Vec::with_capacity(loose.len());
208 +
        {
209 +
            let mut f = io::BufWriter::new(fs::File::create(&tmp_pack)?);
210 +
            f.write_all(PACK_MAGIC)?;
211 +
            let mut offset = PACK_MAGIC.len() as u64;
212 +
            for (r, _) in &loose {
213 +
                let (ty, body) = self.get(r)?;
214 +
                let compressed = zstd::bulk::compress(&body, ZSTD_LEVEL).map_err(|e| StoreError::Corrupt(*r, format!("zstd: {e}")))?;
215 +
                let (flags, stored) = if compressed.len() < body.len() { (FLAG_ZSTD, compressed) } else { (0, body.clone()) };
216 +
                let header_len = 1 + 1 + 4 + 4 + 32;
217 +
                f.write_all(&[type_index(ty), flags])?;
218 +
                f.write_all(&(stored.len() as u32).to_le_bytes())?;
219 +
                f.write_all(&(body.len() as u32).to_le_bytes())?;
220 +
                f.write_all(&r.0)?;
221 +
                f.write_all(&stored)?;
222 +
                index.push((*r, PackEntry { offset: offset + header_len, stored_len: stored.len() as u32, raw_len: body.len() as u32, ty, flags }));
223 +
                offset += header_len + stored.len() as u64;
224 +
                report.objects += 1;
225 +
                report.raw_bytes += body.len() as u64;
226 +
                report.packed_bytes += stored.len() as u64;
227 +
            }
228 +
            f.flush()?;
229 +
            f.get_ref().sync_all()?;
230 +
        }
231 +
        {
232 +
            let mut f = io::BufWriter::new(fs::File::create(&tmp_idx)?);
233 +
            f.write_all(IDX_MAGIC)?;
234 +
            f.write_all(&(index.len() as u32).to_le_bytes())?;
235 +
            for (r, e) in &index {
236 +
                f.write_all(&r.0)?;
237 +
                f.write_all(&e.offset.to_le_bytes())?;
238 +
                f.write_all(&e.stored_len.to_le_bytes())?;
239 +
                f.write_all(&e.raw_len.to_le_bytes())?;
240 +
                f.write_all(&[type_index(e.ty), e.flags])?;
241 +
            }
242 +
            f.flush()?;
243 +
            f.get_ref().sync_all()?;
244 +
        }
245 +
        fs::rename(&tmp_pack, &pack_path)?;
246 +
        fs::rename(&tmp_idx, &idx_path)?;
247 +
        self.reload_packs()?;
248 +
        for (_, path) in &loose {
249 +
            fs::remove_file(path)?;
250 +
            if let Some(dir) = path.parent() {
251 +
                let _ = fs::remove_dir(dir);
252 +
            }
253 +
        }
254 +
        Ok(report)
40 255
    }
41 256
42 257
    pub fn dir(&self) -> &Path {
…
6 unchanged lines
49 264
    }
50 265
51 266
    pub fn has(&self, r: &ObjectRef) -> bool {
52 -
        self.path_for(r).is_file()
267 +
        self.path_for(r).is_file() || self.find_packed(r).is_some()
53 268
    }
54 269
55 270
    /// Store `body` as an object of type `ty`; idempotent.
56 271
    pub fn put(&self, ty: ObjectType, body: &[u8]) -> Result<ObjectRef> {
57 272
        let r = hash_object(ty, body);
58 273
        let path = self.path_for(&r);
59 -
        if path.is_file() {
274 +
        if path.is_file() || self.find_packed(&r).is_some() {
60 275
            return Ok(r);
61 276
        }
62 277
        fs::create_dir_all(path.parent().unwrap())?;
…
10 unchanged lines
73 288
        Ok(r)
74 289
    }
75 290
76 -
    /// Read and verify an object.
291 +
    /// Read and verify an object, loose or packed.
77 292
    pub fn get(&self, r: &ObjectRef) -> Result<(ObjectType, Vec<u8>)> {
78 293
        let path = self.path_for(r);
79 294
        let data = match fs::read(&path) {
80 295
            Ok(d) => d,
81 -
            Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(StoreError::NotFound(*r)),
296 +
            Err(e) if e.kind() == io::ErrorKind::NotFound => {
297 +
                return match self.find_packed(r) {
298 +
                    Some((i, e)) => self.packs.borrow()[i].read(r, e),
299 +
                    None => Err(StoreError::NotFound(*r)),
300 +
                };
301 +
            }
82 302
            Err(e) => return Err(e.into()),
83 303
        };
84 304
        let nul = data.iter().position(|&b| b == 0).ok_or_else(|| StoreError::Corrupt(*r, "no header".into()))?;
…
123 unchanged lines
208 428
        Ok(())
209 429
    }
210 430
211 -
    /// Every object reference in the store (slow; for gc and tests).
431 +
    /// Every object reference in the store, loose and packed (slow; for gc and tests).
212 432
    pub fn list(&self) -> io::Result<Vec<ObjectRef>> {
213 -
        let mut out = Vec::new();
214 -
        let loose = self.dir.join("loose");
215 -
        for d in fs::read_dir(&loose)? {
216 -
            let d = d?;
217 -
            if !d.file_type()?.is_dir() {
218 -
                continue;
219 -
            }
220 -
            let prefix = d.file_name().to_string_lossy().to_string();
221 -
            for f in fs::read_dir(d.path())? {
222 -
                let f = f?;
223 -
                let name = f.file_name().to_string_lossy().to_string();
224 -
                if let Some(r) = ObjectRef::from_hex(&format!("{prefix}{name}")) {
225 -
                    out.push(r);
226 -
                }
227 -
            }
433 +
        let mut out: Vec<ObjectRef> = self.loose_objects()?.into_iter().map(|(r, _)| r).collect();
434 +
        for p in self.packs.borrow().iter() {
435 +
            out.extend(p.refs.iter().copied());
228 436
        }
229 437
        out.sort();
438 +
        out.dedup();
230 439
        Ok(out)
231 440
    }
232 441
}
…
24 unchanged lines
257 466
    }
258 467
259 468
    #[test]
469 +
    fn packing_keeps_every_object_readable() {
470 +
        let dir = tempfile::tempdir().unwrap();
471 +
        let s = Store::open(dir.path()).unwrap();
472 +
        let mut refs = Vec::new();
473 +
        for i in 0..300u32 {
474 +
            let body = format!("object {i} ").repeat(1 + (i % 7) as usize);
475 +
            refs.push((s.put(ObjectType::Chunk, body.as_bytes()).unwrap(), body));
476 +
        }
477 +
        let c = crate::objects::tests::sample_change();
478 +
        let cr = s.put_change(&c).unwrap();
479 +
        assert_eq!(s.loose_count().unwrap(), 301);
480 +
        let report = s.pack_loose().unwrap();
481 +
        assert_eq!(report.objects, 301);
482 +
        assert!(report.packed_bytes < report.raw_bytes, "{report:?}");
483 +
        assert_eq!(s.loose_count().unwrap(), 0);
484 +
        assert_eq!(s.pack_count(), 1);
485 +
        for (r, body) in &refs {
486 +
            assert!(s.has(r));
487 +
            assert_eq!(s.get(r).unwrap(), (ObjectType::Chunk, body.as_bytes().to_vec()));
488 +
        }
489 +
        assert_eq!(s.get_change(&cr).unwrap(), c);
490 +
        assert_eq!(s.list().unwrap().len(), 301);
491 +
        // Re-putting a packed object is a no-op; new objects go loose and a
492 +
        // second pack coexists with the first.
493 +
        assert_eq!(s.put(ObjectType::Chunk, refs[0].1.as_bytes()).unwrap(), refs[0].0);
494 +
        assert_eq!(s.loose_count().unwrap(), 0);
495 +
        let extra = s.put(ObjectType::Chunk, b"after packing").unwrap();
496 +
        s.pack_loose().unwrap();
497 +
        assert_eq!(s.pack_count(), 2);
498 +
        assert_eq!(s.get(&extra).unwrap().1, b"after packing");
499 +
        // A reopened store sees the packs.
500 +
        let s2 = Store::open(dir.path()).unwrap();
501 +
        assert_eq!(s2.pack_count(), 2);
502 +
        assert_eq!(s2.get(&refs[5].0).unwrap().1, refs[5].1.as_bytes());
503 +
        assert!(matches!(s2.get(&ObjectRef([9; 32])), Err(StoreError::NotFound(_))));
504 +
        // Corrupting an object's body inside the pack is detected on read.
505 +
        let target = refs[5].0;
506 +
        let (pack_path, offset) = {
507 +
            let packs = s2.packs.borrow();
508 +
            let (i, e) = s2.find_packed(&target).unwrap();
509 +
            (packs[i].path.clone(), e.offset as usize)
510 +
        };
511 +
        let mut data = fs::read(&pack_path).unwrap();
512 +
        data[offset] ^= 0xff;
513 +
        fs::write(&pack_path, data).unwrap();
514 +
        let s3 = Store::open(dir.path()).unwrap();
515 +
        assert!(matches!(s3.get(&target), Err(StoreError::Corrupt(..))));
516 +
        assert_eq!(s3.get(&refs[6].0).unwrap().1, refs[6].1.as_bytes(), "other objects are unaffected");
517 +
    }
518 +
519 +
    #[test]
260 520
    fn signatures_are_stored_beside_objects() {
261 521
        use crate::identity::{Domain, SecretKey};
262 522
        let dir = tempfile::tempdir().unwrap();
…
25 unchanged lines
modified docs/spec.md +14 −2
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.6 |
5 +
| **Status** | Draft 0.6a |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
498 unchanged lines
507 507
508 508
`oxy gc` (and an automatic trigger when loose object count exceeds 4096) writes loose objects into a new pack and merges packs when there are more than 16. Pack indexes MUST support lookup by ObjectRef prefix and MUST be memory-mappable. A repository with two million objects MUST be openable with under 64 MiB of resident index memory on the Full profile.
509 509
510 +
The pack and index formats are fixed so that tools can read them without the implementation:
511 +
512 +
```
513 +
pack:  "OXYPACK1" || entry*
514 +
entry: type u8 || flags u8 || stored_len u32le || raw_len u32le || ref bytes(32) || body[stored_len]
515 +
index: "OXYIDX01" || count u32le || ( ref bytes(32) || offset u64le || stored_len u32le || raw_len u32le || type u8 || flags u8 )*
516 +
```
517 +
518 +
`type` indexes the object types in the order of §5.3 (chunk 0, blob 1, change 2, checkpoint 3, op 4, attest 5, identity 6, policy 7, state 8). Flag bit 0 means the body is zstd-compressed and `raw_len` is its decompressed size; otherwise the body is stored raw and the two lengths are equal. `offset` is the position of the body, not of the entry. Index entries are sorted by `ref`. A reader MUST re-hash every object it reads from a pack and reject a mismatch.
519 +
510 520
### 13.5 Lazy objects
511 521
512 522
A blob or chunk may be **absent** in a partial repository (§17). The object store records absent refs it has been told about with the remote that promised them, and fetches on first read. Changes and checkpoints are never lazy: a repository that has a change has all of its inline data.
…
311 unchanged lines
824 834
825 835
---
826 836
827 -
*Changelog.* Draft 0.6 (2026-09-26): trust policy propagation over the wire, the `policy` signing domain, per-remote downgrade protection, the bootstrap rule for unverified remotes, and the rule that policies govern only changes added after them.
837 +
*Changelog.* Draft 0.6a (2026-09-26): §13.4 fixes the pack and index file formats.
838 +
839 +
Draft 0.6 (2026-09-26): trust policy propagation over the wire, the `policy` signing domain, per-remote downgrade protection, the bootstrap rule for unverified remotes, and the rule that policies govern only changes added after them.
828 840
829 841
Draft 0.5b (2026-09-26): §19 defines the user configuration file's keys, the precedence of repository over user over environment, and the key directory layout.
830 842
…
13 unchanged lines
modified src/cli/mod.rs +3 −0
…
49 unchanged lines
50 50
    Resolve,
51 51
    /// Undo the last operation
52 52
    Undo,
53 +
    /// Pack loose objects into a compressed pack
54 +
    Gc,
53 55
    /// List operations
54 56
    #[command(name = "op")]
55 57
    Op {
…
120 unchanged lines
176 178
        Cmd::Checkout { changes, discard } => workspace::checkout(&changes, discard),
177 179
        Cmd::Resolve => workspace::resolve(),
178 180
        Cmd::Undo => workspace::undo(),
181 +
        Cmd::Gc => workspace::gc(),
179 182
        Cmd::Op { cmd: OpCmd::Log } => workspace::op_log(),
180 183
        Cmd::Clone { url, dest } => sync::clone(&url, dest),
181 184
        Cmd::Remote { cmd } => match cmd {
…
23 unchanged lines
modified src/cli/workspace.rs +18 −0
…
182 unchanged lines
183 183
    }
184 184
    Ok(0)
185 185
}
186 +
187 +
pub fn gc() -> Result<i32> {
188 +
    let repo = open()?;
189 +
    let r = repo.gc()?;
190 +
    if r.objects == 0 {
191 +
        println!("Nothing to pack; {} pack(s) present.", repo.store().pack_count());
192 +
    } else {
193 +
        println!(
194 +
            "Packed {} into one pack: {} bytes -> {} bytes ({:.0}%); {} pack(s) present.",
195 +
            plural(r.objects, "object"),
196 +
            r.raw_bytes,
197 +
            r.packed_bytes,
198 +
            100.0 * r.packed_bytes as f64 / r.raw_bytes.max(1) as f64,
199 +
            repo.store().pack_count()
200 +
        );
201 +
    }
202 +
    Ok(0)
203 +
}