A repository kept on a store: oxy init --store d069701a4997
oxy init --store URL (storage.url, set at init) keeps the objects and the operation head on dir://, S3, GCS or Azure behind a cache in .oxy/cache of storage.cache bytes (2 GiB by default); .oxy/objects is then scratch only. Repo::init_on, init_bare_on and init_new take the store. Tested over dir:// (reopened with the cache dropped) and measured against the real services: init 0.4-0.8 s, a commit 0.5-0.9 s, a cold open 0.5-0.6 s. refs #68
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-05 01:13 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
d069701a4997c24716b412a12b1e828a3c578aebc2ddc3276f16def9a3901bde- Depends on
- 454236d1108d 47ce8b8e2d4b 4e88e774777a 6d3eefbce2f0 75167672ebcd 8d80356371c5 8e1194860efa 8fd78eb52ad2 982864068a59 9f9e72b93e4e a70dce536476 b3c5a59a875a c07406bcc237 d42369abf180 dd21fd82687d e02bf0cd0533 fce44ae9ad29
- Depended on by
- 771661aa50f4 8b3c9b2ba502 42ccf504aa50 80677b7a3513 7026bd7dc5b8
- In bookmarks
- 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.10.0 v0.11.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
- 27 atoms, 0 tree operations, 9 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **A repository kept on a store (#68).** `oxy init --store URL` keeps a repository's objects and operation log on `dir://`, S3, GCS or Azure, behind a local cache of `storage.cache` bytes (2 GiB by default) that can be deleted at any time. Checked against the real services: a commit takes 0.5 to 0.9 seconds, and reopening with no cache about half a second. |
|
| 6 | + | ||
| 5 | 7 | - **A caching store, and the operation head on a store (#68).** `strata::CachingStore` puts a local directory in front of any store: reads are kept, writes go to the remote first, the cache stays under a size limit by removing what was used least recently, and references always come from the remote. A repository kept on a store other than this disk's directory now holds its operation head there, moved by compare-and-swap with merge and retry. |
|
| 6 | 8 | ||
| 7 | 9 | - **A large merge's file no longer imports empty (#71).** When a merge rendered its files in batches, a file whose graph the import had already built came out empty, so the merge recorded the whole file as inserted again and conflicted with itself: VS Code's history failed verification from 2016 on. Built graphs are now copied. The 10 412-commit reproducer imports and verifies every commit. |
|
| … | 108 unchanged lines |
modified crates/oxy-core/src/repo/config.rs +22 −0
| … | 41 unchanged lines |
||
| 42 | 42 | pub semantic_deps: bool, |
|
| 43 | 43 | /// `revset.<name>`: a saved revset, used as `name()`. |
|
| 44 | 44 | pub revsets: BTreeMap<String, String>, |
|
| 45 | + | /// `storage.url`: the store the repository's objects and operation |
|
| 46 | + | /// head are kept on (`dir://`, `s3://`, …) instead of `.oxy/objects` |
|
| 47 | + | /// (oxygen#68). |
|
| 48 | + | pub storage_url: Option<String>, |
|
| 49 | + | /// `storage.cache`: the most the local cache in front of that store |
|
| 50 | + | /// holds, in bytes. |
|
| 51 | + | pub storage_cache: u64, |
|
| 45 | 52 | } |
|
| 46 | 53 | ||
| 47 | 54 | impl Config { |
|
| … | 20 unchanged lines |
||
| 68 | 75 | sparse_paths: Vec::new(), |
|
| 69 | 76 | semantic_deps: false, |
|
| 70 | 77 | revsets: BTreeMap::new(), |
|
| 78 | + | storage_url: None, |
|
| 79 | + | storage_cache: DEFAULT_STORAGE_CACHE, |
|
| 71 | 80 | } |
|
| 72 | 81 | } |
|
| 73 | 82 | ||
| … | 65 unchanged lines |
||
| 139 | 148 | Some(parse_size(value)?) |
|
| 140 | 149 | } |
|
| 141 | 150 | } |
|
| 151 | + | "storage.url" => { |
|
| 152 | + | self.storage_url = (!value.is_empty()).then(|| value.to_string()); |
|
| 153 | + | } |
|
| 154 | + | "storage.cache" => { |
|
| 155 | + | self.storage_cache = if value.is_empty() { |
|
| 156 | + | DEFAULT_STORAGE_CACHE |
|
| 157 | + | } else { |
|
| 158 | + | parse_size(value)? |
|
| 159 | + | } |
|
| 160 | + | } |
|
| 142 | 161 | "hooks.semantic-deps" => self.semantic_deps = matches!(value, "true" | "1" | "yes"), |
|
| 143 | 162 | "filter.paths" => { |
|
| 144 | 163 | self.sparse_paths = value |
|
| … | 87 unchanged lines |
||
| 232 | 251 | } |
|
| 233 | 252 | ||
| 234 | 253 | /// A byte count with an optional `K`, `M`, or `G` suffix (binary units). |
|
| 254 | + | /// `storage.cache` when unset: 2 GiB. |
|
| 255 | + | pub const DEFAULT_STORAGE_CACHE: u64 = 2 << 30; |
|
| 256 | + | ||
| 235 | 257 | pub fn parse_size(v: &str) -> Result<u64> { |
|
| 236 | 258 | let v = v.trim(); |
|
| 237 | 259 | let (num, mult) = match v.chars().last() { |
|
| … | 47 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +66 −6
| … | 67 unchanged lines |
||
| 68 | 68 | /// processes keep moving it. |
|
| 69 | 69 | const HEAD_TRIES: usize = 8; |
|
| 70 | 70 | ||
| 71 | + | /// The object store of the repository directory `oxy`: `.oxy/objects`, |
|
| 72 | + | /// or the store `storage.url` names behind a cache of `storage.cache` |
|
| 73 | + | /// bytes in `.oxy/cache` (oxygen#68). `.oxy/objects` is then only |
|
| 74 | + | /// scratch space: packs being written, and derived caches. |
|
| 75 | + | fn open_objects(oxy: &Path, config: &Config) -> Result<Store> { |
|
| 76 | + | let Some(url) = &config.storage_url else { |
|
| 77 | + | return Ok(Store::open(oxy.join("objects"))?); |
|
| 78 | + | }; |
|
| 79 | + | let options = crate::protocol::RemoteOptions { |
|
| 80 | + | cache_dir: Some(oxy.join("cache")), |
|
| 81 | + | ..Default::default() |
|
| 82 | + | }; |
|
| 83 | + | let remote = crate::protocol::open_store(url, &options) |
|
| 84 | + | .ok_or_else(|| { |
|
| 85 | + | RepoError::Msg(format!( |
|
| 86 | + | "storage.url {url}: this oxy cannot open that kind of store (dir://, or s3://, gs:// and az:// in builds with cloud stores)" |
|
| 87 | + | )) |
|
| 88 | + | })? |
|
| 89 | + | .map_err(|e| RepoError::Msg(format!("open the repository's store {url}: {e}")))?; |
|
| 90 | + | let cached = crate::strata::CachingStore::open( |
|
| 91 | + | std::sync::Arc::from(remote), |
|
| 92 | + | oxy.join("cache"), |
|
| 93 | + | config.storage_cache, |
|
| 94 | + | ) |
|
| 95 | + | .map_err(|e| RepoError::Msg(format!("open the cache for {url}: {e}")))?; |
|
| 96 | + | Ok(Store::open_on( |
|
| 97 | + | std::sync::Arc::new(cached), |
|
| 98 | + | oxy.join("objects"), |
|
| 99 | + | )?) |
|
| 100 | + | } |
|
| 101 | + | ||
| 71 | 102 | fn fail<T>(s: impl Into<String>) -> Result<T> { |
|
| 72 | 103 | Err(RepoError::Msg(s.into())) |
|
| 73 | 104 | } |
|
| … | 229 unchanged lines |
||
| 303 | 334 | /// Initialize a repository with a given repository ID (clones share the |
|
| 304 | 335 | /// ID of their origin, spec §14.4). |
|
| 305 | 336 | pub fn init_with_id(root: &Path, id: [u8; 16]) -> Result<Repo> { |
|
| 337 | + | Self::init_on(root, id, None) |
|
| 338 | + | } |
|
| 339 | + | ||
| 340 | + | /// A new repository with a fresh id, bare or not, on `storage` or in |
|
| 341 | + | /// `.oxy/objects` (`oxy init [--bare] [--store URL]`). |
|
| 342 | + | pub fn init_new(root: &Path, bare: bool, storage: Option<&str>) -> Result<Repo> { |
|
| 343 | + | let mut id = [0u8; 16]; |
|
| 344 | + | rand::rngs::OsRng.fill_bytes(&mut id); |
|
| 345 | + | if bare { |
|
| 346 | + | Self::init_bare_on(root, id, storage) |
|
| 347 | + | } else { |
|
| 348 | + | Self::init_on(root, id, storage) |
|
| 349 | + | } |
|
| 350 | + | } |
|
| 351 | + | ||
| 352 | + | /// Initialize a repository whose objects and operation head are kept |
|
| 353 | + | /// on the store at `storage` (`storage.url`), or in `.oxy/objects`. |
|
| 354 | + | pub fn init_on(root: &Path, id: [u8; 16], storage: Option<&str>) -> Result<Repo> { |
|
| 306 | 355 | fs::create_dir_all(root)?; |
|
| 307 | 356 | let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); |
|
| 308 | 357 | let oxy = root.join(OXY_DIR); |
|
| … | 3 unchanged lines |
||
| 312 | 361 | fs::create_dir_all(oxy.join("ops"))?; |
|
| 313 | 362 | write_atomic(&oxy.join("format"), FORMAT_LINE.as_bytes())?; |
|
| 314 | 363 | write_atomic(&oxy.join("id"), hex::encode(id).as_bytes())?; |
|
| 315 | - | let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?; |
|
| 364 | + | let mut config = Config::load(Some(&default_user_config()), &oxy.join("config"))?; |
|
| 365 | + | if let Some(url) = storage { |
|
| 366 | + | config.set("storage.url", url)?; |
|
| 367 | + | } |
|
| 316 | 368 | config.save(&oxy.join("config"))?; |
|
| 317 | 369 | let workspace = WorkspaceId::random(); |
|
| 318 | 370 | write_atomic(&oxy.join("workspace"), workspace.to_hex().as_bytes())?; |
|
| 319 | - | let store = Store::open(oxy.join("objects"))?; |
|
| 371 | + | let store = open_objects(&oxy, &config)?; |
|
| 320 | 372 | let repo = Repo { |
|
| 321 | 373 | last_change: Default::default(), |
|
| 322 | 374 | at_op: None, |
|
| … | 37 unchanged lines |
||
| 360 | 412 | } |
|
| 361 | 413 | ||
| 362 | 414 | pub fn init_bare_with_id(root: &Path, id: [u8; 16]) -> Result<Repo> { |
|
| 415 | + | Self::init_bare_on(root, id, None) |
|
| 416 | + | } |
|
| 417 | + | ||
| 418 | + | /// [`Repo::init_on`] for a bare repository. |
|
| 419 | + | pub fn init_bare_on(root: &Path, id: [u8; 16], storage: Option<&str>) -> Result<Repo> { |
|
| 363 | 420 | fs::create_dir_all(root)?; |
|
| 364 | 421 | let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); |
|
| 365 | 422 | let oxy = root.join(OXY_DIR); |
|
| … | 4 unchanged lines |
||
| 370 | 427 | write_atomic(&oxy.join("format"), FORMAT_LINE.as_bytes())?; |
|
| 371 | 428 | write_atomic(&oxy.join("id"), hex::encode(id).as_bytes())?; |
|
| 372 | 429 | write_atomic(&oxy.join(BARE_MARKER), b"")?; |
|
| 373 | - | let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?; |
|
| 430 | + | let mut config = Config::load(Some(&default_user_config()), &oxy.join("config"))?; |
|
| 431 | + | if let Some(url) = storage { |
|
| 432 | + | config.set("storage.url", url)?; |
|
| 433 | + | } |
|
| 374 | 434 | config.save(&oxy.join("config"))?; |
|
| 375 | - | let store = Store::open(oxy.join("objects"))?; |
|
| 435 | + | let store = open_objects(&oxy, &config)?; |
|
| 376 | 436 | let repo = Repo { |
|
| 377 | 437 | last_change: Default::default(), |
|
| 378 | 438 | at_op: None, |
|
| … | 71 unchanged lines |
||
| 450 | 510 | return fail(format!("unsupported repository format: {}", format.trim())); |
|
| 451 | 511 | } |
|
| 452 | 512 | let config = Config::load(Some(&default_user_config()), &shared.join("config"))?; |
|
| 453 | - | let store = Store::open(shared.join("objects"))?; |
|
| 513 | + | let store = open_objects(&shared, &config)?; |
|
| 454 | 514 | let mut repo = Repo { |
|
| 455 | 515 | last_change: Default::default(), |
|
| 456 | 516 | at_op: None, |
|
| … | 26 unchanged lines |
||
| 483 | 543 | WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim()) |
|
| 484 | 544 | .ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))? |
|
| 485 | 545 | }; |
|
| 486 | - | let store = Store::open(oxy.join("objects"))?; |
|
| 546 | + | let store = open_objects(&oxy, &config)?; |
|
| 487 | 547 | let mut repo = Repo { |
|
| 488 | 548 | last_change: Default::default(), |
|
| 489 | 549 | at_op: None, |
|
| … | 1737 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +68 −0
| … | 7591 unchanged lines |
||
| 7592 | 7592 | ); |
|
| 7593 | 7593 | crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = None); |
|
| 7594 | 7594 | } |
|
| 7595 | + | ||
| 7596 | + | /// oxygen#68: a repository kept on a store (`storage.url`) holds its |
|
| 7597 | + | /// objects and operation head there, with `.oxy/cache` in front: it works |
|
| 7598 | + | /// as one on disk does, reopens, and survives losing its cache. |
|
| 7599 | + | #[test] |
|
| 7600 | + | fn a_repository_runs_on_a_store_with_a_cache_in_front() { |
|
| 7601 | + | hermetic(); |
|
| 7602 | + | if std::env::var_os("OXY_STORE_BACKEND").is_some() { |
|
| 7603 | + | return; |
|
| 7604 | + | } |
|
| 7605 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 7606 | + | let (root, bucket) = (dir.path().join("work"), dir.path().join("bucket")); |
|
| 7607 | + | let url = format!("dir://{}", bucket.display()); |
|
| 7608 | + | let repo = Repo::init_on(&root, [7; 16], Some(&url)).unwrap(); |
|
| 7609 | + | assert_eq!(repo.config.storage_url.as_deref(), Some(url.as_str())); |
|
| 7610 | + | write(&root, "a.txt", b"one\n"); |
|
| 7611 | + | let h1 = repo.commit("First", &[]).unwrap(); |
|
| 7612 | + | write(&root, "a.txt", b"one\ntwo\n"); |
|
| 7613 | + | let h2 = repo.commit("Second", &[]).unwrap(); |
|
| 7614 | + | assert!( |
|
| 7615 | + | !root.join(".oxy/ops/head").exists(), |
|
| 7616 | + | "the head is in the store" |
|
| 7617 | + | ); |
|
| 7618 | + | assert!(bucket.join("ops/head").is_file()); |
|
| 7619 | + | assert!(root.join(".oxy/cache").is_dir()); |
|
| 7620 | + | drop(repo); |
|
| 7621 | + | ||
| 7622 | + | // Reopened, and again with no cache at all: everything comes from the store. |
|
| 7623 | + | for drop_cache in [false, true] { |
|
| 7624 | + | if drop_cache { |
|
| 7625 | + | std::fs::remove_dir_all(root.join(".oxy/cache")).unwrap(); |
|
| 7626 | + | } |
|
| 7627 | + | let repo = Repo::open(&root).unwrap(); |
|
| 7628 | + | let log: Vec<String> = repo |
|
| 7629 | + | .op_log() |
|
| 7630 | + | .unwrap() |
|
| 7631 | + | .into_iter() |
|
| 7632 | + | .map(|(_, op)| op.command) |
|
| 7633 | + | .collect(); |
|
| 7634 | + | assert!(log.len() >= 3, "{log:?}"); |
|
| 7635 | + | assert_eq!(repo.store().get_change(&h1).unwrap().message, "First"); |
|
| 7636 | + | let heads = repo |
|
| 7637 | + | .store() |
|
| 7638 | + | .get_state(&repo.view().unwrap().bookmarks["main"]) |
|
| 7639 | + | .unwrap() |
|
| 7640 | + | .heads; |
|
| 7641 | + | assert_eq!(heads, vec![h2]); |
|
| 7642 | + | assert_eq!( |
|
| 7643 | + | repo.load_state(&heads).unwrap().materialize().files.len(), |
|
| 7644 | + | 1 |
|
| 7645 | + | ); |
|
| 7646 | + | } |
|
| 7647 | + | ||
| 7648 | + | // A second repository cannot start on the same store. |
|
| 7649 | + | let err = Repo::init_on(&dir.path().join("other"), [8; 16], Some(&url)) |
|
| 7650 | + | .err() |
|
| 7651 | + | .unwrap() |
|
| 7652 | + | .to_string(); |
|
| 7653 | + | assert!( |
|
| 7654 | + | err.contains("already holds a repository's operation log"), |
|
| 7655 | + | "{err}" |
|
| 7656 | + | ); |
|
| 7657 | + | let err = Repo::init_on(&dir.path().join("third"), [9; 16], Some("nope://x")) |
|
| 7658 | + | .err() |
|
| 7659 | + | .unwrap() |
|
| 7660 | + | .to_string(); |
|
| 7661 | + | assert!(err.contains("cannot open that kind of store"), "{err}"); |
|
| 7662 | + | } |
modified crates/oxy-strata/tests/cloud.rs +57 −0
| … | 141 unchanged lines |
||
| 142 | 142 | } |
|
| 143 | 143 | } |
|
| 144 | 144 | ||
| 145 | + | /// oxygen#68: a small repository kept on each store (`storage.url`), with |
|
| 146 | + | /// the local cache in front: commits, a reopen with the cache dropped, and |
|
| 147 | + | /// the time each step took. Then every key the test wrote is removed. |
|
| 148 | + | #[test] |
|
| 149 | + | fn a_repository_runs_on_every_configured_store() { |
|
| 150 | + | for (url, base_store) in each_store() { |
|
| 151 | + | remove_stale(&base_store); |
|
| 152 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 153 | + | let root = dir.path(); |
|
| 154 | + | std::fs::create_dir_all(root.join("home")).unwrap(); |
|
| 155 | + | std::env::set_var("OXY_HOME", root.join("home")); |
|
| 156 | + | let (base, query) = match url.split_once('?') { |
|
| 157 | + | Some((b, q)) => (b.to_string(), format!("?{q}")), |
|
| 158 | + | None => (url.clone(), String::new()), |
|
| 159 | + | }; |
|
| 160 | + | let store_url = format!("{base}/repo-{}{query}", std::process::id()); |
|
| 161 | + | let plain = format!("{base}/repo-{}", std::process::id()); |
|
| 162 | + | let store = CloudStore::open(&store_url).unwrap(); |
|
| 163 | + | let t = std::time::Instant::now(); |
|
| 164 | + | let repo = Repo::init_new(&root.join("w"), false, Some(&store_url)) |
|
| 165 | + | .unwrap_or_else(|e| panic!("{plain}: init: {e}")); |
|
| 166 | + | let init_ms = t.elapsed().as_millis(); |
|
| 167 | + | let t = std::time::Instant::now(); |
|
| 168 | + | let mut last = None; |
|
| 169 | + | for i in 0..5 { |
|
| 170 | + | write( |
|
| 171 | + | root, |
|
| 172 | + | "w/f.txt", |
|
| 173 | + | format!("line {i}\n").repeat(i + 1).as_bytes(), |
|
| 174 | + | ); |
|
| 175 | + | last = Some(repo.commit(&format!("commit {i}"), &[]).unwrap()); |
|
| 176 | + | } |
|
| 177 | + | let commit_ms = t.elapsed().as_millis() / 5; |
|
| 178 | + | drop(repo); |
|
| 179 | + | std::fs::remove_dir_all(root.join("w/.oxy/cache")).unwrap(); |
|
| 180 | + | let t = std::time::Instant::now(); |
|
| 181 | + | let repo = Repo::open(&root.join("w")).unwrap(); |
|
| 182 | + | let heads = repo.bookmark_heads().unwrap(); |
|
| 183 | + | let state = repo.load_state(&heads).unwrap().materialize(); |
|
| 184 | + | let cold_ms = t.elapsed().as_millis(); |
|
| 185 | + | assert_eq!(heads, vec![last.unwrap()], "{plain}"); |
|
| 186 | + | assert_eq!(state.files.len(), 1); |
|
| 187 | + | assert!( |
|
| 188 | + | store.read_ref("ops/head").unwrap().is_some(), |
|
| 189 | + | "{plain}: the head is in the store" |
|
| 190 | + | ); |
|
| 191 | + | let keys = store.list("").unwrap(); |
|
| 192 | + | let total: u64 = keys.iter().map(|e| e.size).sum(); |
|
| 193 | + | eprintln!( |
|
| 194 | + | "{plain}: init {init_ms} ms, commit {commit_ms} ms each, cold open and read {cold_ms} ms; {} keys, {total} bytes", |
|
| 195 | + | keys.len() |
|
| 196 | + | ); |
|
| 197 | + | remove_all(&store, ""); |
|
| 198 | + | assert!(store.list("").unwrap().is_empty(), "{plain}: cleanup"); |
|
| 199 | + | } |
|
| 200 | + | } |
|
| 201 | + | ||
| 145 | 202 | fn write(root: &Path, rel: &str, content: &[u8]) { |
|
| 146 | 203 | let p = root.join(rel); |
|
| 147 | 204 | std::fs::create_dir_all(p.parent().unwrap()).unwrap(); |
|
| … | 2 unchanged lines |
modified docs/stores.md +18 −0
| … | 130 unchanged lines |
||
| 131 | 131 | the limit. An object larger than an eighth of the limit is not kept: |
|
| 132 | 132 | ranges of it are read from the remote each time. References are never |
|
| 133 | 133 | cached, so compare-and-swap is always the remote's. |
|
| 134 | + | - **A repository on a store** (B2, 2026-10-05): `oxy init --store URL` |
|
| 135 | + | (or `storage.url` in `.oxy/config`, set at init) keeps the objects and |
|
| 136 | + | the operation head on that store, behind a cache in `.oxy/cache` of |
|
| 137 | + | `storage.cache` bytes (2 GiB by default). `.oxy/objects` then holds |
|
| 138 | + | only scratch: packs being written and derived caches. The cache can be |
|
| 139 | + | deleted at any time. A second repository cannot start on a store that |
|
| 140 | + | already holds one. Measured from the workstation against the real |
|
| 141 | + | services with five small commits (`a_repository_runs_on_every_configured_store`): |
|
| 134 | 142 | ||
| 143 | + | | Store | init | each commit | open with no cache, read the files | |
|
| 144 | + | |---|---|---|---| |
|
| 145 | + | | S3 | 441 ms | 703 ms | 481 ms | |
|
| 146 | + | | GCS | 769 ms | 857 ms | 570 ms | |
|
| 147 | + | | Azure | 664 ms | 511 ms | 639 ms | |
|
| 148 | + | ||
| 149 | + | A commit writes its objects one request each and moves the head, so |
|
| 150 | + | its cost is round trips, not bytes; batching a commit's objects into |
|
| 151 | + | one pack is the next step for speed. |
|
| 152 | + | ||
| 135 | 153 | ## 4. Layout in the bucket (as built, 2026-10-03) |
|
| 136 | 154 | ||
| 137 | 155 | ``` |
|
| … | 140 unchanged lines |
modified docs/strata.md +22 −0
| … | 119 unchanged lines |
||
| 120 | 120 | later (`--now` removes them at once), so a clone that was listing them at |
|
| 121 | 121 | the time still completes. |
|
| 122 | 122 | ||
| 123 | + | ## A repository kept on a store |
|
| 124 | + | ||
| 125 | + | A store can also hold a repository itself rather than a copy of it. |
|
| 126 | + | Start one with `--store`: |
|
| 127 | + | ||
| 128 | + | ```sh |
|
| 129 | + | oxy init myproject --store s3://my-bucket/repos/myproject?profile=work |
|
| 130 | + | ``` |
|
| 131 | + | ||
| 132 | + | - **Where things live:** the objects and the operation log are in the |
|
| 133 | + | store. `.oxy/cache` keeps the objects this machine has used, up to |
|
| 134 | + | `storage.cache` (2 GiB unless you set it, for example |
|
| 135 | + | `oxy config storage.cache 10G`). |
|
| 136 | + | - **The cache:** you can delete `.oxy/cache` at any time. Objects come |
|
| 137 | + | back from the store as they are needed. |
|
| 138 | + | - **One repository per prefix:** `oxy init --store` refuses a prefix |
|
| 139 | + | that already holds a repository's operation log. |
|
| 140 | + | - **Speed:** each command that records something makes a few requests to |
|
| 141 | + | the store, so a commit takes about half a second to a second. |
|
| 142 | + | - **Choose at init:** set `storage.url` only when the repository is |
|
| 143 | + | created. Changing it later does not move the objects. |
|
| 144 | + | ||
| 123 | 145 | ## What a store holds |
|
| 124 | 146 | ||
| 125 | 147 | ``` |
|
| … | 37 unchanged lines |
modified src/cli/mod.rs +6 −1
| … | 43 unchanged lines |
||
| 44 | 44 | /// No working copy: an object store and references only, for hosting |
|
| 45 | 45 | #[arg(long)] |
|
| 46 | 46 | bare: bool, |
|
| 47 | + | /// Keep the objects and the operation log on this store |
|
| 48 | + | /// (dir://PATH, s3://BUCKET/PREFIX, gs://…, az://…) instead of |
|
| 49 | + | /// in .oxy, with a local cache in front (storage.cache, 2 GiB) |
|
| 50 | + | #[arg(long, value_name = "URL")] |
|
| 51 | + | store: Option<String>, |
|
| 47 | 52 | }, |
|
| 48 | 53 | /// Clone a repository from a local path or an http(s) URL |
|
| 49 | 54 | Clone { |
|
| … | 990 unchanged lines |
||
| 1040 | 1045 | Ok(0) |
|
| 1041 | 1046 | } |
|
| 1042 | 1047 | Cmd::Man { dir } => man(dir), |
|
| 1043 | - | Cmd::Init { path, bare } => workspace::init(path, bare), |
|
| 1048 | + | Cmd::Init { path, bare, store } => workspace::init(path, bare, store.as_deref()), |
|
| 1044 | 1049 | Cmd::Status { json } => workspace::status(json), |
|
| 1045 | 1050 | Cmd::Prompt { |
|
| 1046 | 1051 | format, |
|
| … | 332 unchanged lines |
modified src/cli/workspace.rs +15 −6
| … | 13 unchanged lines |
||
| 14 | 14 | use std::io::Write; |
|
| 15 | 15 | use std::path::PathBuf; |
|
| 16 | 16 | ||
| 17 | - | pub fn init(path: Option<PathBuf>, bare: bool) -> Result<i32> { |
|
| 17 | + | pub fn init(path: Option<PathBuf>, bare: bool, store: Option<&str>) -> Result<i32> { |
|
| 18 | 18 | let path = path.unwrap_or(std::env::current_dir()?); |
|
| 19 | - | let repo = if bare { |
|
| 20 | - | Repo::init_bare(&path)? |
|
| 21 | - | } else { |
|
| 22 | - | Repo::init(&path)? |
|
| 23 | - | }; |
|
| 19 | + | let repo = Repo::init_new(&path, bare, store)?; |
|
| 24 | 20 | println!( |
|
| 25 | 21 | "{} {}oxy repository in {}", |
|
| 26 | 22 | ui::done("Initialized"), |
|
| … | 4 unchanged lines |
||
| 31 | 27 | "{}", |
|
| 32 | 28 | ui::kv("bookmark", ui::bookmark(&repo.config.bookmark)) |
|
| 33 | 29 | ); |
|
| 30 | + | if let Some(url) = &repo.config.storage_url { |
|
| 31 | + | println!( |
|
| 32 | + | "{}", |
|
| 33 | + | ui::kv( |
|
| 34 | + | "store", |
|
| 35 | + | format!( |
|
| 36 | + | "{url} (cache in {}, up to {} MiB)", |
|
| 37 | + | ui::path(repo.root().join(".oxy/cache").display()), |
|
| 38 | + | repo.config.storage_cache >> 20 |
|
| 39 | + | ) |
|
| 40 | + | ) |
|
| 41 | + | ); |
|
| 42 | + | } |
|
| 34 | 43 | println!( |
|
| 35 | 44 | "{}", |
|
| 36 | 45 | ui::kv( |
|
| … | 2523 unchanged lines |