OXYGENHUB

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