Snapshots: large new files left out and named; past working copies expire b689779e9e74
A new, untracked file over snapshot.max-new-file-size (default 100 MiB) is left out of the working-copy snapshot and named by status, with how to ignore it or raise the limit; tracked files and files already in the working copy are unaffected. Past operations keep their working copies reachable only within core.snapshot-retention (default 7 days, capped by core.op-retention), so gc prunes a file only snapshots held; gc --expire-snapshots keeps none but the current ones. undo and op restore to an expired working copy keep the files as they are. closes #98
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-11 11:06 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
b689779e9e7428f68bef6dfc047bf9fcb4a6ba516fd5e9b34c9ea067c830d974- Depends on
- 135212de5370 252e8cb23fbc 34fad4a92451 50a046e439e9 530a0fc650a2 62794ecc7f74 6a81dbab471d 6f8aeb996558 7505194116e7 75167672ebcd 76740263089c 8fd78eb52ad2 982864068a59 98a1620a3840 ba160f60219e c6aa5bd9d1c8 cc7a00565161 d02e545dc265 d4dbab97e948 d8d9367878a6 da868d5d2646 fc1a466a1e66 fce44ae9ad29 ff0af1ee299d
- Depended on by
- 767ac6cd0545 c86270b7c670 4294602ee66f ebb9210bad0c 31d3e932e97a 5d79b5c2de88 7fc365369758 aa4939e07a09 9b723f69b032 fe9586d93fb1
- 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.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
- 38 atoms, 0 tree operations, 10 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Large new files are not snapshotted unseen, and past snapshots expire (#98).** A 1 GB image left untracked in a working tree was taken in by the next `status` and kept by the operation log for good: deleting the file and running `gc --now` freed nothing. Now a new, untracked file over `snapshot.max-new-file-size` (default 100 MiB) is left out and named by `status`, with how to ignore it or raise the limit; tracked files and files already in the working copy are unaffected. Past working-copy snapshots keep their files only for `core.snapshot-retention` (default 7 days), so `gc` prunes what only they held, and `oxy gc --expire-snapshots --now` does it at once. `undo` and `op restore` to an operation whose working copy has expired keep the files as they are. |
|
| 6 | + | ||
| 5 | 7 | - **`oxy undo` undoes this workspace's last command (#99).** It undid the repository's newest operation, whichever workspace made it: in a repository with several workspaces an undo could take back another workspace's commit or merge, and it said only which operation it went back to. Operations now record their workspace, and undo takes back this workspace's last command, leaving other workspaces' work as it is. On a bookmark both share, only its own heads go; a change another workspace built on it, or the workspace it would take away, refuses the undo and names it. Undo now says which command it undid and what that changed, passes over pushes (the remote keeps what it was sent), and warns when an undone commit is already on a remote. `oxy amend` warns when it rewrites a change a remote already has, so the next push will need `--force`. |
|
| 6 | 8 | ||
| 7 | 9 | - **gc can keep what a host names (oxygenhub#126).** `Repo::gc_keeping` keeps extra blobs and their chunks beside what history reaches, so a forge's job artifacts, referenced only from its own database, survive garbage collection. |
|
| … | 170 unchanged lines |
modified crates/oxy-core/src/repo/config.rs +12 −0
| … | 37 unchanged lines |
||
| 38 | 38 | pub op_retention_days: u64, |
|
| 39 | 39 | /// `core.gc-grace`: days an unreachable object survives, protecting concurrent writers (spec §21). |
|
| 40 | 40 | pub gc_grace_days: u64, |
|
| 41 | + | /// `core.snapshot-retention`: days for which past operations keep their |
|
| 42 | + | /// working copies reachable (oxygen#98); each workspace's current one is |
|
| 43 | + | /// always kept. A file only ever in a working copy goes with it. |
|
| 44 | + | pub snapshot_retention_days: u64, |
|
| 45 | + | /// `snapshot.max-new-file-size`: a new, untracked file larger than this |
|
| 46 | + | /// is left out of working-copy snapshots and named instead (oxygen#98); |
|
| 47 | + | /// 0 takes in files of any size. |
|
| 48 | + | pub max_new_file: u64, |
|
| 41 | 49 | /// `core.checkpoint-interval`: changes between automatic checkpoints (spec §11). |
|
| 42 | 50 | pub checkpoint_interval: u64, |
|
| 43 | 51 | /// `push.batch-bytes`: about how many bytes of changes and their texts |
|
| … | 43 unchanged lines |
||
| 87 | 95 | git_claim: false, |
|
| 88 | 96 | op_retention_days: 90, |
|
| 89 | 97 | gc_grace_days: 14, |
|
| 98 | + | max_new_file: 100 << 20, |
|
| 99 | + | snapshot_retention_days: 7, |
|
| 90 | 100 | checkpoint_interval: 256, |
|
| 91 | 101 | push_batch_bytes: 256 << 20, |
|
| 92 | 102 | max_blob: None, |
|
| … | 66 unchanged lines |
||
| 159 | 169 | "auth.helper" => {} |
|
| 160 | 170 | "core.op-retention" => self.op_retention_days = parse_days(value)?, |
|
| 161 | 171 | "core.gc-grace" => self.gc_grace_days = parse_days(value)?, |
|
| 172 | + | "snapshot.max-new-file-size" => self.max_new_file = parse_size(value)?, |
|
| 173 | + | "core.snapshot-retention" => self.snapshot_retention_days = parse_days(value)?, |
|
| 162 | 174 | "filter.max-blob" => { |
|
| 163 | 175 | self.max_blob = if value.is_empty() { |
|
| 164 | 176 | None |
|
| … | 169 unchanged lines |
modified crates/oxy-core/src/repo/gc.rs +14 −6
| … | 1 unchanged lines |
||
| 2 | 2 | //! nothing reachable refers to. |
|
| 3 | 3 | //! |
|
| 4 | 4 | //! Roots: the current view, the views of every operation newer than |
|
| 5 | - | //! `core.op-retention`, reference attestations, and the policy; receipts |
|
| 5 | + | //! `core.op-retention` (their working copies only within the shorter |
|
| 6 | + | //! `core.snapshot-retention`), reference attestations, and the policy; receipts |
|
| 6 | 7 | //! live as long as the changes of their state. Operation |
|
| 7 | 8 | //! objects are always kept so the log stays walkable, but an operation older |
|
| 8 | 9 | //! than the retention window no longer keeps its states alive, so undoing |
|
| … | 74 unchanged lines |
||
| 83 | 84 | let mut attests: Vec<ObjectRef> = Vec::new(); |
|
| 84 | 85 | let mut policies: Vec<ObjectRef> = Vec::new(); |
|
| 85 | 86 | ||
| 86 | - | // Operations: always kept; views within retention are roots. |
|
| 87 | - | let cutoff = Hlc::now() |
|
| 88 | - | .wall_ms |
|
| 89 | - | .saturating_sub(self.config.op_retention_days * 86_400_000); |
|
| 87 | + | // Operations: always kept; views within retention are roots. Past |
|
| 88 | + | // working copies only within the shorter `core.snapshot-retention` |
|
| 89 | + | // (oxygen#98): a file only ever snapshotted, such as a build output |
|
| 90 | + | // a status took in, goes once they expire. |
|
| 91 | + | let now_ms = Hlc::now().wall_ms; |
|
| 92 | + | let cutoff = now_ms.saturating_sub(self.config.op_retention_days * 86_400_000); |
|
| 93 | + | let wc_cutoff = now_ms |
|
| 94 | + | .saturating_sub(self.config.snapshot_retention_days * 86_400_000) |
|
| 95 | + | .max(cutoff); |
|
| 90 | 96 | for (i, (r, op)) in self.op_log()?.into_iter().enumerate() { |
|
| 91 | 97 | keep.insert(r); |
|
| 92 | 98 | if i == 0 || op.hlc.wall_ms >= cutoff { |
|
| 93 | 99 | states.extend(op.view.bookmarks.values().copied()); |
|
| 94 | 100 | states.extend(op.view.remotes.values().flat_map(|b| b.values().copied())); |
|
| 95 | - | changes.extend(op.view.workspaces.values().copied()); |
|
| 96 | 101 | policies.extend(op.view.policy); |
|
| 102 | + | } |
|
| 103 | + | if i == 0 || (self.config.snapshot_retention_days > 0 && op.hlc.wall_ms >= wc_cutoff) { |
|
| 104 | + | changes.extend(op.view.workspaces.values().copied()); |
|
| 97 | 105 | } |
|
| 98 | 106 | } |
|
| 99 | 107 | // Checkpoints: the object, its payload blob, and the blob's chunks. |
|
| … | 105 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +42 −1
| … | 259 unchanged lines |
||
| 260 | 260 | /// The nested repositories the last walk left out, being neither |
|
| 261 | 261 | /// registered nor tracked subrepos (oxygen#43). |
|
| 262 | 262 | pub(crate) untracked_nested: std::cell::RefCell<Vec<String>>, |
|
| 263 | + | /// New untracked files the last walk left out for their size, with |
|
| 264 | + | /// their sizes (oxygen#98). |
|
| 265 | + | pub(crate) skipped_large: std::cell::RefCell<Vec<(String, u64)>>, |
|
| 263 | 266 | root: PathBuf, |
|
| 264 | 267 | oxy: PathBuf, |
|
| 265 | 268 | store: Store, |
|
| … | 113 unchanged lines |
||
| 379 | 382 | manifest_cache: Default::default(), |
|
| 380 | 383 | listing_cache: Default::default(), |
|
| 381 | 384 | untracked_nested: Default::default(), |
|
| 385 | + | skipped_large: Default::default(), |
|
| 382 | 386 | root, |
|
| 383 | 387 | oxy, |
|
| 384 | 388 | store, |
|
| … | 58 unchanged lines |
||
| 443 | 447 | manifest_cache: Default::default(), |
|
| 444 | 448 | listing_cache: Default::default(), |
|
| 445 | 449 | untracked_nested: Default::default(), |
|
| 450 | + | skipped_large: Default::default(), |
|
| 446 | 451 | root, |
|
| 447 | 452 | oxy, |
|
| 448 | 453 | store, |
|
| … | 72 unchanged lines |
||
| 521 | 526 | manifest_cache: Default::default(), |
|
| 522 | 527 | listing_cache: Default::default(), |
|
| 523 | 528 | untracked_nested: Default::default(), |
|
| 529 | + | skipped_large: Default::default(), |
|
| 524 | 530 | root: d.to_path_buf(), |
|
| 525 | 531 | oxy: shared, |
|
| 526 | 532 | store, |
|
| … | 27 unchanged lines |
||
| 554 | 560 | manifest_cache: Default::default(), |
|
| 555 | 561 | listing_cache: Default::default(), |
|
| 556 | 562 | untracked_nested: Default::default(), |
|
| 563 | + | skipped_large: Default::default(), |
|
| 557 | 564 | root: d.to_path_buf(), |
|
| 558 | 565 | oxy, |
|
| 559 | 566 | store, |
|
| … | 541 unchanged lines |
||
| 1101 | 1108 | self.drop_manifest(); |
|
| 1102 | 1109 | if let Some(then) = restored_wc { |
|
| 1103 | 1110 | if clean_now && Some(then) != wc_now { |
|
| 1104 | - | let wc = self.store.get_change(&then)?; |
|
| 1111 | + | // Past `core.snapshot-retention` the working copy may be |
|
| 1112 | + | // gone: the files then stay as they are. |
|
| 1113 | + | let Ok(wc) = self.store.get_change(&then) else { |
|
| 1114 | + | return Ok(Undone { |
|
| 1115 | + | command: target.command, |
|
| 1116 | + | changes, |
|
| 1117 | + | others_kept: others, |
|
| 1118 | + | pushed, |
|
| 1119 | + | }); |
|
| 1120 | + | }; |
|
| 1105 | 1121 | if wc.edits.is_empty() && wc.tree_ops.is_empty() { |
|
| 1106 | 1122 | let base = self.load_state_history(&wc.deps)?.maximal(&wc.deps); |
|
| 1107 | 1123 | // The files differ from the restored working copy by |
|
| … | 554 unchanged lines |
||
| 1662 | 1678 | /// working copy left out, not being subrepos (oxygen#43). |
|
| 1663 | 1679 | pub fn untracked_nested(&self) -> Vec<String> { |
|
| 1664 | 1680 | self.untracked_nested.borrow().clone() |
|
| 1681 | + | } |
|
| 1682 | + | ||
| 1683 | + | /// New untracked files the last snapshot left out for being larger than |
|
| 1684 | + | /// `snapshot.max-new-file-size`, with their sizes (oxygen#98). |
|
| 1685 | + | pub fn skipped_large(&self) -> Vec<(String, u64)> { |
|
| 1686 | + | self.skipped_large.borrow().clone() |
|
| 1665 | 1687 | } |
|
| 1666 | 1688 | ||
| 1667 | 1689 | /// Attributes from a materialized state's `.oxyattributes` files. |
|
| … | 353 unchanged lines |
||
| 2021 | 2043 | // written after it was snapshotted leaves it out again). |
|
| 2022 | 2044 | None => walk::list_knowing(&self.root, &in_scope, &|p: &str| tracked.contains_key(p))?, |
|
| 2023 | 2045 | }; |
|
| 2046 | + | // A new file larger than the limit is left out and named, not taken |
|
| 2047 | + | // in unseen (oxygen#98): a build's output or a downloaded image |
|
| 2048 | + | // would otherwise be stored by the next status, kept by the |
|
| 2049 | + | // operation log. Files tracked, or already in the working copy, stay. |
|
| 2050 | + | let limit = self.config.max_new_file; |
|
| 2051 | + | let mut skipped = Vec::new(); |
|
| 2052 | + | let files: Vec<_> = files |
|
| 2053 | + | .into_iter() |
|
| 2054 | + | .filter(|(rel, _, meta)| { |
|
| 2055 | + | let new = !tracked.contains_key(rel) |
|
| 2056 | + | && !cache.is_some_and(|m| m.entries.contains_key(rel)); |
|
| 2057 | + | if limit > 0 && new && meta.size > limit { |
|
| 2058 | + | skipped.push((rel.clone(), meta.size)); |
|
| 2059 | + | return false; |
|
| 2060 | + | } |
|
| 2061 | + | true |
|
| 2062 | + | }) |
|
| 2063 | + | .collect(); |
|
| 2064 | + | *self.skipped_large.borrow_mut() = skipped; |
|
| 2024 | 2065 | let keys = keys.filter(|k| { |
|
| 2025 | 2066 | let on_disk: HashSet<&str> = files.iter().map(|(r, _, _)| r.as_str()).collect(); |
|
| 2026 | 2067 | let attrs_unchanged = |
|
| … | 638 unchanged lines |
modified crates/oxy-core/src/repo/ops.rs +15 −5
| … | 193 unchanged lines |
||
| 194 | 194 | let mut view = head.view.clone(); |
|
| 195 | 195 | view.bookmarks = target.view.bookmarks.clone(); |
|
| 196 | 196 | self.record_op(&[head_ref], &command, view)?; |
|
| 197 | - | let base = match target.view.workspaces.get(&self.workspace) { |
|
| 198 | - | Some(w) => { |
|
| 199 | - | let wc = self.store.get_change(w)?; |
|
| 200 | - | self.load_state_history(&wc.deps)?.maximal(&wc.deps) |
|
| 197 | + | // A working copy past `core.snapshot-retention` may be gone: then |
|
| 198 | + | // the bookmark as it was. |
|
| 199 | + | let base = match target |
|
| 200 | + | .view |
|
| 201 | + | .workspaces |
|
| 202 | + | .get(&self.workspace) |
|
| 203 | + | .map(|w| self.store.get_change(w)) |
|
| 204 | + | { |
|
| 205 | + | Some(Ok(wc)) => self.load_state_history(&wc.deps)?.maximal(&wc.deps), |
|
| 206 | + | Some(Err(StoreError::NotFound(_))) | None => { |
|
| 207 | + | match target.view.bookmarks.get(&self.config.bookmark) { |
|
| 208 | + | Some(st) => self.store.get_state(st)?.heads, |
|
| 209 | + | None => self.bookmark_heads()?, |
|
| 210 | + | } |
|
| 201 | 211 | } |
|
| 202 | - | None => self.bookmark_heads()?, |
|
| 212 | + | Some(Err(e)) => return Err(e.into()), |
|
| 203 | 213 | }; |
|
| 204 | 214 | let checkout = self.checkout(&base, discard, &format!("{command} (checkout)"))?; |
|
| 205 | 215 | Ok(RestoreReport { |
|
| … | 85 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +8 −2
| … | 784 unchanged lines |
||
| 785 | 785 | )); |
|
| 786 | 786 | } |
|
| 787 | 787 | Ok(format!( |
|
| 788 | - | "{{\"bookmark\":{},\"working_copy\":{},\"heads\":{},\"clean\":{},\"conflicted\":{},\"paths\":{paths},\"conflicts\":{},\"untracked_nested\":{}}}", |
|
| 788 | + | "{{\"bookmark\":{},\"working_copy\":{},\"heads\":{},\"clean\":{},\"conflicted\":{},\"paths\":{paths},\"conflicts\":{},\"untracked_nested\":{},\"skipped_large\":{}}}", |
|
| 789 | 789 | json_str(&self.config.bookmark), |
|
| 790 | 790 | json_str(&snap.wc.change_id.to_hex()), |
|
| 791 | 791 | heads.len(), |
|
| 792 | 792 | snap.statuses.is_empty(), |
|
| 793 | 793 | base_snapshot.is_conflicted(), |
|
| 794 | 794 | json_array(conflicts), |
|
| 795 | - | json_array(snap.untracked_nested.iter().map(|p| json_str(p))) |
|
| 795 | + | json_array(snap.untracked_nested.iter().map(|p| json_str(p))), |
|
| 796 | + | json_array( |
|
| 797 | + | self.skipped_large |
|
| 798 | + | .borrow() |
|
| 799 | + | .iter() |
|
| 800 | + | .map(|(p, n)| format!("{{\"path\":{},\"size\":{n}}}", json_str(p))) |
|
| 801 | + | ) |
|
| 796 | 802 | )) |
|
| 797 | 803 | } |
|
| 798 | 804 | ||
| … | 2278 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +65 −0
| … | 8453 unchanged lines |
||
| 8454 | 8454 | assert_eq!(u.command, "oxy commit -m \"a\""); |
|
| 8455 | 8455 | assert_eq!(u.pushed, vec![("main".to_string(), "origin".to_string())]); |
|
| 8456 | 8456 | } |
|
| 8457 | + | ||
| 8458 | + | /// oxygen#98: a new untracked file over snapshot.max-new-file-size is left |
|
| 8459 | + | /// out of the snapshot and named; tracked files and files already in the |
|
| 8460 | + | /// working copy are not. |
|
| 8461 | + | #[test] |
|
| 8462 | + | fn large_new_files_are_left_out_of_snapshots() { |
|
| 8463 | + | hermetic(); |
|
| 8464 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 8465 | + | let root = dir.path(); |
|
| 8466 | + | let mut repo = Repo::init(root).unwrap(); |
|
| 8467 | + | repo.config.max_new_file = 1024; |
|
| 8468 | + | write(root, "small.txt", b"small\n"); |
|
| 8469 | + | write(root, "big.bin", &vec![0u8; 4096]); |
|
| 8470 | + | let s = repo.snapshot("oxy status").unwrap(); |
|
| 8471 | + | let paths: Vec<_> = s.statuses.iter().map(|p| p.path.as_str()).collect(); |
|
| 8472 | + | assert_eq!(paths, ["small.txt"]); |
|
| 8473 | + | assert_eq!(repo.skipped_large(), vec![("big.bin".to_string(), 4096)]); |
|
| 8474 | + | repo.commit("small", &[]).unwrap(); |
|
| 8475 | + | ||
| 8476 | + | // A tracked file may grow past the limit. |
|
| 8477 | + | write(root, "small.txt", &vec![b'x'; 4096]); |
|
| 8478 | + | let s = repo.snapshot("oxy status").unwrap(); |
|
| 8479 | + | assert!(s.statuses.iter().any(|p| p.path == "small.txt")); |
|
| 8480 | + | // With no limit the big file is taken in, and stays once it is. |
|
| 8481 | + | repo.config.max_new_file = 0; |
|
| 8482 | + | let s = repo.snapshot("oxy status").unwrap(); |
|
| 8483 | + | assert!(s.statuses.iter().any(|p| p.path == "big.bin")); |
|
| 8484 | + | repo.config.max_new_file = 1024; |
|
| 8485 | + | let s = repo.snapshot("oxy status").unwrap(); |
|
| 8486 | + | assert!( |
|
| 8487 | + | s.statuses.iter().any(|p| p.path == "big.bin"), |
|
| 8488 | + | "already in the working copy" |
|
| 8489 | + | ); |
|
| 8490 | + | assert!(repo.skipped_large().is_empty()); |
|
| 8491 | + | } |
|
| 8492 | + | ||
| 8493 | + | /// oxygen#98: a file only a past working-copy snapshot held is pruned once |
|
| 8494 | + | /// snapshots expire, and kept while they have not. |
|
| 8495 | + | #[test] |
|
| 8496 | + | fn gc_prunes_what_only_expired_snapshots_held() { |
|
| 8497 | + | hermetic(); |
|
| 8498 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 8499 | + | let root = dir.path(); |
|
| 8500 | + | let mut repo = Repo::init(root).unwrap(); |
|
| 8501 | + | repo.config.gc_grace_days = 0; |
|
| 8502 | + | write(root, "a.txt", b"a\n"); |
|
| 8503 | + | repo.commit("a", &[]).unwrap(); |
|
| 8504 | + | let image: Vec<u8> = (0..200_000u32).map(|i| (i % 7) as u8).collect(); |
|
| 8505 | + | write(root, "image.tar", &image); |
|
| 8506 | + | repo.snapshot("oxy status").unwrap(); |
|
| 8507 | + | std::fs::remove_file(root.join("image.tar")).unwrap(); |
|
| 8508 | + | repo.snapshot("oxy status").unwrap(); |
|
| 8509 | + | let blob = Repo::blob_ref(&image); |
|
| 8510 | + | assert!(repo.store().has(&blob)); |
|
| 8511 | + | repo.gc(true).unwrap(); |
|
| 8512 | + | assert!(repo.store().has(&blob), "a recent snapshot keeps it"); |
|
| 8513 | + | repo.config.snapshot_retention_days = 0; |
|
| 8514 | + | repo.gc(true).unwrap(); |
|
| 8515 | + | assert!(!repo.store().has(&blob), "an expired snapshot does not"); |
|
| 8516 | + | assert_eq!( |
|
| 8517 | + | repo.file_at("a.txt", None).unwrap(), |
|
| 8518 | + | b"a\n", |
|
| 8519 | + | "committed files stay" |
|
| 8520 | + | ); |
|
| 8521 | + | } |
modified docs/spec.md +3 −2
| … | 492 unchanged lines |
||
| 493 | 493 | The working copy is a real change with `kind = working-copy`. Before running any command that reads repository state, `oxy` snapshots the working copy: it diffs the file system against the working-copy change's materialized state and rewrites the working-copy change to include the difference. The change therefore always describes the working directory. To prevent operation log bloat, a command that snapshots the working copy MUST NOT record a new Operation (§12.1) if the filesystem diff was empty (i.e., the working-copy change's hash did not change). |
|
| 494 | 494 | ||
| 495 | 495 | - The working-copy change is unsigned and never pushed while `kind = working-copy`. |
|
| 496 | + | - A **new, untracked file larger than `snapshot.max-new-file-size`** (default 100 MiB; 0 for no limit) is left out of the snapshot and named by `oxy status` (and `status --json` as `skipped_large`), with how to ignore it or raise the limit (oxygen#98): a build's output or a downloaded image is not stored unseen by the next command. A tracked file, or one already in the working copy, is never left out for its size. |
|
| 496 | 497 | - `oxy commit` (`-m MSG`, or `-F FILE` with `-` for standard input) sets the message, sets `kind = normal`, and starts a fresh working-copy change on top of it. The change is signed immediately if `core.sign = "change"`; with the default `core.sign = "seal"` it is left unsigned locally and covered by a seal at push time (§14.4). |
|
| 497 | 498 | - `oxy new`, `oxy edit <change>`, and `oxy abandon` move which change the workspace edits. |
|
| 498 | 499 | - There is no staging area and no stash. A partial commit is a split of the working copy: `oxy commit PATH..` records only the named paths (and every edit of theirs) and leaves the rest as uncommitted work; `oxy commit --interactive` offers each hunk (one `Insert` or `Delete` edit on one file, or one tree operation) for inclusion, like `git add -p`; `oxy split` does the same to an existing change. A hunk that another selected hunk refers to is selected with it, so the committed change is always well-formed (§8.3). |
|
| … | 338 unchanged lines |
||
| 837 | 838 | ||
| 838 | 839 | ## 21. Garbage collection |
|
| 839 | 840 | ||
| 840 | - | An object is **reachable** if it is referenced, directly or transitively, from any bookmark, tag, policy, the current operation heads, any operation newer than `core.op-retention`, any workspace's working-copy change, or any checkpoint of a reachable state. `oxy gc` deletes unreachable objects that are older than `core.gc-grace` (default 14 days, to protect concurrent writers) and repacks. Tombstone atoms are never collected independently of their change. Divergent changes are reachable if any of their variants is. A change's text blobs and their chunks are reachable with it (§7.4). A seal is reachable if any change it covers is reachable, and reference attestations are reachable while they are the latest for their reference or are within `core.op-retention`. |
|
| 841 | + | An object is **reachable** if it is referenced, directly or transitively, from any bookmark, tag, policy, the current operation heads, any operation newer than `core.op-retention`, any workspace's current working-copy change, the working-copy changes of operations newer than `core.snapshot-retention` (default 7 days; `oxy gc --expire-snapshots` keeps none but the current ones, oxygen#98), or any checkpoint of a reachable state. A file that was only ever in a working copy, never committed, so goes once the snapshots that held it expire. `oxy gc` deletes unreachable objects that are older than `core.gc-grace` (default 14 days, to protect concurrent writers) and repacks. Tombstone atoms are never collected independently of their change. Divergent changes are reachable if any of their variants is. A change's text blobs and their chunks are reachable with it (§7.4). A seal is reachable if any change it covers is reachable, and reference attestations are reachable while they are the latest for their reference or are within `core.op-retention`. |
|
| 841 | 842 | ||
| 842 | 843 | ### 21.1 Integrity check |
|
| 843 | 844 | ||
| … | 57 unchanged lines |
||
| 901 | 902 | oxy seal [CHANGE].. | --proposal URL seal your unauthenticated and anonymous changes (§14.4), or a hub proposal's changes as your approval |
|
| 902 | 903 | oxy proposal show|land URL hub proposals from a client (hub spec §7) |
|
| 903 | 904 | oxy git import|export|fetch|push|verify (fetch and push go through a bare mirror under .oxy/git/) |
|
| 904 | - | oxy checkpoint oxy gc [--now] oxy config [--user] KEY VALUE |
|
| 905 | + | oxy checkpoint oxy gc [--now] [--expire-snapshots] oxy config [--user] KEY VALUE |
|
| 905 | 906 | oxy root oxy cat PATH [-r CHANGE] oxy version |
|
| 906 | 907 | oxy fsck [--json] check integrity (§21.1) |
|
| 907 | 908 | oxy doctor [--offline] [--json] check identity, config and remotes (§21.2) |
|
| … | 149 unchanged lines |
modified src/cli/mod.rs +9 −2
| … | 328 unchanged lines |
||
| 329 | 329 | /// Ignore the grace period (core.gc-grace) and prune immediately |
|
| 330 | 330 | #[arg(long)] |
|
| 331 | 331 | now: bool, |
|
| 332 | + | /// Keep no past working-copy snapshots, only each workspace's current one, so files only they held go (core.snapshot-retention, default 7 days) |
|
| 333 | + | #[arg(long)] |
|
| 334 | + | expire_snapshots: bool, |
|
| 332 | 335 | /// Instead: fold the bundles of a remote kept on a store into one, and remove the ones a gc superseded 7 days ago (or now, with --now) |
|
| 333 | 336 | #[arg(long, value_name = "NAME")] |
|
| 334 | 337 | remote: Option<String>, |
|
| … | 851 unchanged lines |
||
| 1186 | 1189 | } => workspace::checkout(&changes, discard, carry), |
|
| 1187 | 1190 | Cmd::Resolve { json } => workspace::resolve(json), |
|
| 1188 | 1191 | Cmd::Undo => workspace::undo(), |
|
| 1189 | - | Cmd::Gc { now, remote } => match remote { |
|
| 1192 | + | Cmd::Gc { |
|
| 1193 | + | now, |
|
| 1194 | + | expire_snapshots, |
|
| 1195 | + | remote, |
|
| 1196 | + | } => match remote { |
|
| 1190 | 1197 | Some(name) => workspace::gc_remote(&name, now), |
|
| 1191 | - | None => workspace::gc(now), |
|
| 1198 | + | None => workspace::gc(now, expire_snapshots), |
|
| 1192 | 1199 | }, |
|
| 1193 | 1200 | Cmd::Fsck { json } => workspace::fsck(json), |
|
| 1194 | 1201 | Cmd::Doctor { offline, json } => workspace::doctor(offline, json), |
|
| … | 259 unchanged lines |
modified src/cli/workspace.rs +41 −2
| … | 65 unchanged lines |
||
| 66 | 66 | } |
|
| 67 | 67 | } |
|
| 68 | 68 | ||
| 69 | + | /// New untracked files left out of the snapshot for their size (oxygen#98): |
|
| 70 | + | /// named, with how to keep them out quietly or take them in. |
|
| 71 | + | fn print_skipped_large(repo: &Repo) { |
|
| 72 | + | let skipped = repo.skipped_large(); |
|
| 73 | + | if skipped.is_empty() { |
|
| 74 | + | return; |
|
| 75 | + | } |
|
| 76 | + | let size = |n: u64| { |
|
| 77 | + | if n >= 1 << 30 { |
|
| 78 | + | format!("{:.1} GiB", n as f64 / (1u64 << 30) as f64) |
|
| 79 | + | } else { |
|
| 80 | + | format!("{:.0} MiB", n as f64 / (1u64 << 20) as f64) |
|
| 81 | + | } |
|
| 82 | + | }; |
|
| 83 | + | println!(); |
|
| 84 | + | println!( |
|
| 85 | + | "{} {}", |
|
| 86 | + | ui::section(ui::warn("Not tracked")), |
|
| 87 | + | ui::dim(format!("new files over {}", size(repo.config.max_new_file))) |
|
| 88 | + | ); |
|
| 89 | + | for (p, n) in &skipped { |
|
| 90 | + | println!(" {} {} {}", ui::caution(), ui::path(p), ui::dim(size(*n))); |
|
| 91 | + | } |
|
| 92 | + | println!( |
|
| 93 | + | " {} {}", |
|
| 94 | + | ui::note(), |
|
| 95 | + | ui::dim( |
|
| 96 | + | "to leave them out quietly, list them in .oxyignore; to track one, \ |
|
| 97 | + | raise snapshot.max-new-file-size (oxy config snapshot.max-new-file-size 2G)" |
|
| 98 | + | ) |
|
| 99 | + | ); |
|
| 100 | + | } |
|
| 101 | + | ||
| 69 | 102 | /// Folders holding a repository of their own, which oxy leaves out (oxygen#43): |
|
| 70 | 103 | /// named, with how to track them. |
|
| 71 | 104 | fn print_untracked_nested(repo: &Repo, nested: &[String]) { |
|
| … | 89 unchanged lines |
||
| 161 | 194 | print_behind(&repo); |
|
| 162 | 195 | print_statuses(&statuses); |
|
| 163 | 196 | print_untracked_nested(&repo, &repo.untracked_nested()); |
|
| 197 | + | print_skipped_large(&repo); |
|
| 164 | 198 | return Ok(0); |
|
| 165 | 199 | } |
|
| 166 | 200 | let sp = ui::spinner("Snapshotting the working copy…"); |
|
| … | 24 unchanged lines |
||
| 191 | 225 | print_behind(&repo); |
|
| 192 | 226 | print_statuses(&snap.statuses); |
|
| 193 | 227 | print_untracked_nested(&repo, &snap.untracked_nested); |
|
| 228 | + | print_skipped_large(&repo); |
|
| 194 | 229 | if !snap.base_snapshot.is_conflicted() { |
|
| 195 | 230 | return Ok(0); |
|
| 196 | 231 | } |
|
| … | 128 unchanged lines |
||
| 325 | 360 | ui::subject(subject_of(message)) |
|
| 326 | 361 | )); |
|
| 327 | 362 | print_untracked_nested(&repo, &repo.untracked_nested()); |
|
| 363 | + | print_skipped_large(&repo); |
|
| 328 | 364 | // Only a commit that took part of the working copy leaves anything. |
|
| 329 | 365 | if partial { |
|
| 330 | 366 | let rest = repo.snapshot_status("oxy commit")?; |
|
| … | 1287 unchanged lines |
||
| 1618 | 1654 | ); |
|
| 1619 | 1655 | } |
|
| 1620 | 1656 | ||
| 1621 | - | pub fn gc(now: bool) -> Result<i32> { |
|
| 1622 | - | let repo = open()?; |
|
| 1657 | + | pub fn gc(now: bool, expire_snapshots: bool) -> Result<i32> { |
|
| 1658 | + | let mut repo = open()?; |
|
| 1659 | + | if expire_snapshots { |
|
| 1660 | + | repo.config.snapshot_retention_days = 0; |
|
| 1661 | + | } |
|
| 1623 | 1662 | let sp = ui::spinner("Packing and pruning…"); |
|
| 1624 | 1663 | let r = repo.gc_with(now, &mut |phase| sp.set(&format!("{phase}…")))?; |
|
| 1625 | 1664 | sp.clear(); |
|
| … | 1236 unchanged lines |