OXYGENHUB

Refuse a bookmark at one head that leaves earlier work out 4fe7d6045860

A state is its heads and what they depend on, and a change depends only
on what it builds on. On metalgear, a bookmark created at one of three
independent heads held that change and an early tree, and switching to
it silently wrote the older files.

bookmark create NAME CHANGE now refuses when the current state has
changes recorded before CHANGE that it does not contain, listing them;
--upto takes everything of the current state up to CHANGE (what a Git
branch would hold) and --only just CHANGE and its dependencies.
bookmark switch says how many changes the new state lacks of the old.
The guide explains states for people coming from Git.

closes #37
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 21:23 UTC
Signature
✔ signed by Andy McCall
Hash
4fe7d6045860322e1cd8b25ce58bce21220d9f0500a8648b7f59dac577683236
Depends on
314db9b4f3f8 6a81dbab471d 6caacfff1d4f 75167672ebcd 7b9809e67c3d 86e682c242c6 982864068a59 c6aa5bd9d1c8 d42369abf180 ee06a6c557b7 fbda43e7d8ed fc1a466a1e66 fce44ae9ad29 ff0af1ee299d
Depended on by
733ea16a9701 b9bf7eec1f2d 4afab467e35f 14edbaaf5291 06d16dc2bc13 d3cd7ef95da6 7d9dab131937 1a3b62032ed9
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
19 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified crates/oxy-core/src/repo/bookmarks.rs +53 −0
…
35 unchanged lines
36 36
        && name != "policy"
37 37
}
38 38
39 +
/// What one state lacks of another: see [`Repo::state_gap`].
40 +
#[derive(Debug, Clone, PartialEq, Eq)]
41 +
pub struct StateGap {
42 +
    /// Changes of the first state recorded before the latest head of the
43 +
    /// second but not in it, oldest first: what Git's ancestry would have
44 +
    /// included.
45 +
    pub earlier: Vec<ChangeHash>,
46 +
    /// How many changes of the first state the second lacks altogether.
47 +
    pub lacks: usize,
48 +
    /// The heads of the second state together with `earlier`: everything
49 +
    /// of the first state up to the second's latest head.
50 +
    pub upto: Vec<ChangeHash>,
51 +
}
52 +
39 53
impl Repo {
40 54
    fn workspace_bookmark_path(&self) -> PathBuf {
41 55
        self.oxy
…
56 unchanged lines
98 112
        );
99 113
        self.record_op(&[op_ref], &format!("oxy bookmark create {name}"), view)?;
100 114
        Ok(heads)
115 +
    }
116 +
117 +
    /// What a state at `at` lacks of the state at `from` (spec §10.1). A
118 +
    /// state is its heads and what they depend on, not everything recorded
119 +
    /// before them, so a bookmark created at one change of a state with
120 +
    /// several heads holds much less than someone used to Git expects.
121 +
    pub fn state_gap(&self, from: &[ChangeHash], at: &[ChangeHash]) -> Result<StateGap> {
122 +
        let mut all = from.to_vec();
123 +
        all.extend_from_slice(at);
124 +
        let st = self.load_state_history(&all)?;
125 +
        let with_closure = |hs: &[ChangeHash]| {
126 +
            let mut s: HashSet<ChangeHash> = hs.iter().copied().collect();
127 +
            for h in hs {
128 +
                s.extend(st.closure(h).iter().copied());
129 +
            }
130 +
            s
131 +
        };
132 +
        let from_set = with_closure(from);
133 +
        let at_set = with_closure(at);
134 +
        let key = |h: &ChangeHash| st.get(h).map(|m| (m.hlc, *h));
135 +
        let last = at.iter().filter_map(key).max();
136 +
        let missing: Vec<ChangeHash> = from_set
137 +
            .iter()
138 +
            .filter(|h| !at_set.contains(h))
139 +
            .copied()
140 +
            .collect();
141 +
        let mut earlier: Vec<ChangeHash> = missing
142 +
            .iter()
143 +
            .filter(|h| matches!((key(h), last), (Some(k), Some(l)) if k < l))
144 +
            .copied()
145 +
            .collect();
146 +
        earlier.sort_by_key(key);
147 +
        let mut candidates = at.to_vec();
148 +
        candidates.extend_from_slice(&earlier);
149 +
        Ok(StateGap {
150 +
            lacks: missing.len(),
151 +
            upto: st.maximal(&candidates),
152 +
            earlier,
153 +
        })
101 154
    }
102 155
103 156
    /// Point an existing bookmark at `heads`.
…
233 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +1 −1
…
81 unchanged lines
82 82
mod tests;
83 83
mod workspace;
84 84
85 -
pub use bookmarks::{BookmarkInfo, MergeReport};
85 +
pub use bookmarks::{BookmarkInfo, MergeReport, StateGap};
86 86
pub use checkout::CheckoutReport;
87 87
pub use config::{parse_size, read_config_file, Config};
88 88
pub use doctor::{Check, DoctorReport, Health};
…
1559 unchanged lines
modified docs/guide.md +17 −0
…
102 unchanged lines
103 103
Each workspace remembers its own bookmark, so two directories can sit on
104 104
two bookmarks of one repository.
105 105
106 +
**A state is its heads, not a history.** If you are coming from Git, this
107 +
is the one thing to know. A bookmark holds its heads and what they depend
108 +
on, and a change depends only on the changes it builds on (the files it
109 +
touched), not on everything recorded before it. So on a bookmark with
110 +
several heads, a new bookmark at one change can be a much older-looking
111 +
tree. `oxy bookmark create NAME CHANGE` refuses when that would leave out
112 +
earlier work and lists what it would lack. Then pick one:
113 +
114 +
```
115 +
oxy bookmark create logo 745307b4 --upto  # everything here up to that change, as a Git branch would be
116 +
oxy bookmark create logo 745307b4 --only  # just that change and what it depends on
117 +
oxy bookmark create logo                  # the whole current state, usually what you want
118 +
```
119 +
120 +
`oxy bookmark switch` says when the bookmark you switch to lacks changes
121 +
that the one you left has.
122 +
106 123
### A develop / feature flow
107 124
108 125
Names with `/` work everywhere, pushing included:
…
375 unchanged lines
modified docs/spec.md +2 −2
…
425 unchanged lines
426 426
State { version [0]: 1, heads [1]: [ChangeHash] }   // sorted, unique; may be empty
427 427
```
428 428
429 -
A repository holds any number of bookmarks. Each workspace has a **current bookmark** (`oxy bookmark switch NAME`, recorded per workspace) that its working copy sits on and that `commit`, `log`, `push`, and `pull` act on by default. Switching refuses uncommitted work unless told what to do with it: `--discard` drops it, and `--carry` takes it along when every path it touches is the same in NAME's state as in the working copy's base (so the same edits apply there), refusing and naming the paths otherwise. `oxy bookmark create feat && oxy bookmark switch feat --carry` moves work started on one bookmark onto a new one. `oxy bookmark create NAME [CHANGE..]` names a new state (default: the current working copy's base heads), `set NAME CHANGE..` moves one, `delete NAME` removes it (its changes stay until unreachable, §21), `list` shows all with their heads and the remote-tracking difference, and `oxy merge NAME` unions the named bookmark's heads into the current one, which is the whole of merging in this model: no merge change, conflicts reified (§9); a working copy without changes follows the merge, whichever state it sat on. `oxy checkout NAME` materializes a bookmark without switching to it; with uncommitted work it refuses, naming each changed path and how it changed (a *removed* path is in the working copy's base but not on disk), unless `--discard` or `--carry` (as for switching) says what to do with the work. Bookmark names, then tag names, are resolved before change prefixes wherever a change is expected (a bookmark or tag names a change when its state has one head). For builds, `oxy describe` without `-m` names the working copy as `git describe` does: its base state's tag (`v1.2`), else the nearest tag whose state the base contains with the number of changes it adds and the newest head's ChangeId (`v1.2-3-8c5ea12a`), else the bookmark and that ChangeId (`develop-8c5ea12a`), with `-dirty` when the working copy has uncommitted changes; `--json` gives the parts.
429 +
A repository holds any number of bookmarks. Each workspace has a **current bookmark** (`oxy bookmark switch NAME`, recorded per workspace) that its working copy sits on and that `commit`, `log`, `push`, and `pull` act on by default. Switching refuses uncommitted work unless told what to do with it: `--discard` drops it, and `--carry` takes it along when every path it touches is the same in NAME's state as in the working copy's base (so the same edits apply there), refusing and naming the paths otherwise. `oxy bookmark create feat && oxy bookmark switch feat --carry` moves work started on one bookmark onto a new one. `oxy bookmark create NAME [CHANGE..]` names a new state (default: the current working copy's base heads); given CHANGE.., it refuses when the current state has changes recorded before the latest of them that they do not contain, since a state holds its heads' dependency closure and not everything recorded before them, unless `--upto` (the current state's changes up to CHANGE.., as Git ancestry would have it) or `--only` (exactly CHANGE.. and their dependencies) says which is meant, and `bookmark switch` reports how many changes the new state lacks of the old; `set NAME CHANGE..` moves one, `delete NAME` removes it (its changes stay until unreachable, §21), `list` shows all with their heads and the remote-tracking difference, and `oxy merge NAME` unions the named bookmark's heads into the current one, which is the whole of merging in this model: no merge change, conflicts reified (§9); a working copy without changes follows the merge, whichever state it sat on. `oxy checkout NAME` materializes a bookmark without switching to it; with uncommitted work it refuses, naming each changed path and how it changed (a *removed* path is in the working copy's base but not on disk), unless `--discard` or `--carry` (as for switching) says what to do with the work. Bookmark names, then tag names, are resolved before change prefixes wherever a change is expected (a bookmark or tag names a change when its state has one head). For builds, `oxy describe` without `-m` names the working copy as `git describe` does: its base state's tag (`v1.2`), else the nearest tag whose state the base contains with the number of changes it adds and the newest head's ChangeId (`v1.2-3-8c5ea12a`), else the bookmark and that ChangeId (`develop-8c5ea12a`), with `-dirty` when the working copy has uncommitted changes; `--json` gives the parts.
430 430
431 431
Every reference, attestation, view, and checkpoint therefore names a state by one ObjectRef however many heads it has. Multiple heads are normal in this model: every independent change is a head until something depends on it, so a busy bookmark may have dozens. That is not a conflict in the §9 sense; `oxy log` shows the heads as parallel, and a state object keeps the cost of naming them constant for everything that refers to the bookmark. The empty state, `State { heads: [] }`, is what a new bookmark points at.
432 432
…
541 unchanged lines
974 974
975 975
---
976 976
977 -
*Changelog.* Draft 0.8r (2026-09-29): the working-copy lock covers every command that writes the working copy; a workspace changed by two concurrent operations is an error (§12.1). Draft 0.8q (2026-09-29): change format version 2 stores large inserts as text blobs, so a change does not grow with the files it adds (§6.1, §7.4); no object may exceed 1 GiB (§13.2). Draft 0.8p (2026-09-29): Git import derives new NodeIds from the commit, making imports deterministic (§23). Draft 0.8o (2026-09-29): execution receipts, the `receipt` object and attestation (§14.6). Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11).
977 +
*Changelog.* Draft 0.8r (2026-09-29): `bookmark create --upto/--only` (§10.1); the working-copy lock covers every command that writes the working copy; a workspace changed by two concurrent operations is an error (§12.1). Draft 0.8q (2026-09-29): change format version 2 stores large inserts as text blobs, so a change does not grow with the files it adds (§6.1, §7.4); no object may exceed 1 GiB (§13.2). Draft 0.8p (2026-09-29): Git import derives new NodeIds from the commit, making imports deterministic (§23). Draft 0.8o (2026-09-29): execution receipts, the `receipt` object and attestation (§14.6). Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11).
978 978
979 979
Draft 0.8i (2026-09-28): portability rules for exec bits and path names (§7.7); found by verifying a real Git import commit by commit: deletions depend on the inserters of the lines right after them, and replacements on the change that set their old blob (§8.1); replace records are superseded causally, so a reverted binary keeps its blob (§9.3); merges resolve opaque conflicts to the commit's blob, and `oxy git verify` (§23). Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1); `oxy doctor` (§21.2). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands.
980 980
…
61 unchanged lines
modified src/cli/mod.rs +17 −2
…
516 unchanged lines
517 517
    Create {
518 518
        /// Name of the new bookmark
519 519
        name: String,
520 -
        /// Its heads (default: the working copy's base)
520 +
        /// Its heads (default: the working copy's base). A state is its
521 +
        /// heads and what they depend on: a change alone may leave out
522 +
        /// earlier changes of the current state, which is refused unless
523 +
        /// --upto or --only says which is meant
521 524
        changes: Vec<String>,
525 +
        /// Everything of the current state up to CHANGE..., as a Git
526 +
        /// branch at a commit would hold
527 +
        #[arg(long, conflicts_with = "only")]
528 +
        upto: bool,
529 +
        /// Just CHANGE... and what they depend on
530 +
        #[arg(long)]
531 +
        only: bool,
522 532
    },
523 533
    /// Point an existing bookmark at CHANGE...
524 534
    Set {
…
499 unchanged lines
1024 1034
            WorkspaceCmd::Forget { workspace } => workspace::workspace_forget(&workspace),
1025 1035
        },
1026 1036
        Cmd::Bookmark { cmd } => match cmd {
1027 -
            BookmarkCmd::Create { name, changes } => workspace::bookmark_create(&name, &changes),
1037 +
            BookmarkCmd::Create {
1038 +
                name,
1039 +
                changes,
1040 +
                upto,
1041 +
                only,
1042 +
            } => workspace::bookmark_create(&name, &changes, upto, only),
1028 1043
            BookmarkCmd::Set { name, changes } => workspace::bookmark_set(&name, &changes),
1029 1044
            BookmarkCmd::Delete { name } => workspace::bookmark_delete(&name),
1030 1045
            BookmarkCmd::List { json } => workspace::bookmark_list(json),
…
175 unchanged lines
modified src/cli/workspace.rs +63 −2
…
1578 unchanged lines
1579 1579
    Ok(heads)
1580 1580
}
1581 1581
1582 -
pub fn bookmark_create(name: &str, changes: &[String]) -> Result<i32> {
1582 +
pub fn bookmark_create(name: &str, changes: &[String], upto: bool, only: bool) -> Result<i32> {
1583 1583
    let repo = open()?;
1584 1584
    repo.snapshot_status("oxy bookmark create")?;
1585 1585
    let heads = if changes.is_empty() {
1586 1586
        None
1587 1587
    } else {
1588 -
        Some(heads_from(&repo, changes)?)
1588 +
        let named = heads_from(&repo, changes)?;
1589 +
        let (_, wc) = repo.working_copy()?;
1590 +
        let gap = repo.state_gap(&wc.deps, &named)?;
1591 +
        if upto {
1592 +
            Some(gap.upto)
1593 +
        } else if only || gap.earlier.is_empty() {
1594 +
            Some(named)
1595 +
        } else {
1596 +
            anyhow::bail!(
1597 +
                "{name} at {} would lack {} of the current state recorded before it:\n{}\n\
1598 +
                 A state is its heads and what they depend on, not everything recorded before them.\n\
1599 +
                 Pass --upto for everything of the current state up to {0} (as a Git branch would hold),\n\
1600 +
                 or --only for just {0} and what it depends on.",
1601 +
                changes.join(" "),
1602 +
                plural(gap.earlier.len(), "change"),
1603 +
                listed_changes(&repo, &gap.earlier)
1604 +
            );
1605 +
        }
1589 1606
    };
1590 1607
    let heads = repo.bookmark_create(name, heads.as_deref())?;
1591 1608
    println!(
…
11 unchanged lines
1603 1620
    Ok(0)
1604 1621
}
1605 1622
1623 +
/// Up to five changes, newest last, as `  #hash subject` lines.
1624 +
fn listed_changes(repo: &Repo, changes: &[oxy_core::hash::ChangeHash]) -> String {
1625 +
    let shown = changes.len().min(5);
1626 +
    let mut lines: Vec<String> = changes[changes.len() - shown..]
1627 +
        .iter()
1628 +
        .map(|h| {
1629 +
            let subject = repo
1630 +
                .store()
1631 +
                .get_change(h)
1632 +
                .map(|c| subject_of(&c.message).to_string())
1633 +
                .unwrap_or_default();
1634 +
            format!("  #{} {subject}", h.short(12))
1635 +
        })
1636 +
        .collect();
1637 +
    if changes.len() > shown {
1638 +
        lines.insert(0, format!("  … and {} earlier", changes.len() - shown));
1639 +
    }
1640 +
    lines.join("\n")
1641 +
}
1642 +
1606 1643
pub fn bookmark_set(name: &str, changes: &[String]) -> Result<i32> {
1607 1644
    let repo = open()?;
1608 1645
    repo.snapshot_status("oxy bookmark set")?;
…
71 unchanged lines
1680 1717
1681 1718
pub fn bookmark_switch(name: &str, discard: bool, carry: bool) -> Result<i32> {
1682 1719
    let mut repo = open()?;
1720 +
    let from = repo.config.bookmark.clone();
1721 +
    let before = repo
1722 +
        .working_copy()
1723 +
        .map(|(_, wc)| wc.deps)
1724 +
        .unwrap_or_default();
1683 1725
    let sp = ui::spinner(&format!("Switching to {name}…"));
1684 1726
    let (r, carried) = if carry {
1685 1727
        repo.bookmark_switch_carry(name)?
…
18 unchanged lines
1704 1746
            ui::caution(),
1705 1747
            ui::warn("this state has conflicts; see oxy status")
1706 1748
        );
1749 +
    }
1750 +
    // Say when the new state leaves out work the old one had: a state is
1751 +
    // its heads and what they depend on, which can be far less than a Git
1752 +
    // user expects.
1753 +
    let after = repo
1754 +
        .working_copy()
1755 +
        .map(|(_, wc)| wc.deps)
1756 +
        .unwrap_or_default();
1757 +
    if let Ok(gap) = repo.state_gap(&before, &after) {
1758 +
        if gap.lacks > 0 {
1759 +
            println!(
1760 +
                "  {} {}",
1761 +
                ui::note(),
1762 +
                ui::dim(format!(
1763 +
                    "{name} lacks {} that {from} has",
1764 +
                    plural(gap.lacks, "change")
1765 +
                ))
1766 +
            );
1767 +
        }
1707 1768
    }
1708 1769
    Ok(0)
1709 1770
}
…
78 unchanged lines
modified tests/cli_json.rs +69 −0
…
376 unchanged lines
377 377
    assert_ne!(code, 0);
378 378
    assert!(err.contains("does not verify"), "{err}");
379 379
}
380 +
381 +
/// Metalgear, 2026-09-29 (oxygen#37): a bookmark created at one of three
382 +
/// independent heads held only that change and what it depends on, and
383 +
/// switching to it silently wrote a much older tree.
384 +
#[test]
385 +
fn a_bookmark_at_one_head_of_several_is_refused_unless_asked_for() {
386 +
    let tmp = tempfile::tempdir().unwrap();
387 +
    let home = tmp.path().join("home");
388 +
    std::fs::create_dir_all(&home).unwrap();
389 +
    let r = tmp.path().join("repo");
390 +
    assert_eq!(oxy(tmp.path(), &home, &["init", r.to_str().unwrap()]).0, 0);
391 +
    for f in ["a", "b", "c"] {
392 +
        write(&r, &format!("{f}.txt"), &format!("{f}\n"));
393 +
        assert_eq!(oxy(&r, &home, &["commit", "-m", f]).0, 0);
394 +
    }
395 +
    let bms = json(&r, &home, &["bookmark", "list", "--json"]);
396 +
    assert_eq!(
397 +
        bms[0]["heads"].as_array().unwrap().len(),
398 +
        3,
399 +
        "independent: {bms}"
400 +
    );
401 +
    let log = json(&r, &home, &["log", "--json"]);
402 +
    let hash = |m: &str| {
403 +
        let c = log["changes"]
404 +
            .as_array()
405 +
            .unwrap()
406 +
            .iter()
407 +
            .find(|c| c["message"] == m)
408 +
            .unwrap();
409 +
        format!("#{}", &c["hash"].as_str().unwrap()[..12])
410 +
    };
411 +
    let b = hash("b");
412 +
413 +
    let (code, _, err) = oxy(&r, &home, &["bookmark", "create", "x", &b]);
414 +
    assert_ne!(code, 0);
415 +
    assert!(
416 +
        err.contains("would lack 1 change") && err.contains("--upto") && err.contains("--only"),
417 +
        "{err}"
418 +
    );
419 +
420 +
    assert_eq!(
421 +
        oxy(&r, &home, &["bookmark", "create", "upto", &b, "--upto"]).0,
422 +
        0
423 +
    );
424 +
    assert_eq!(
425 +
        oxy(&r, &home, &["bookmark", "create", "only", &b, "--only"]).0,
426 +
        0
427 +
    );
428 +
    let bms = json(&r, &home, &["bookmark", "list", "--json"]);
429 +
    let heads = |n: &str| {
430 +
        bms.as_array()
431 +
            .unwrap()
432 +
            .iter()
433 +
            .find(|x| x["name"] == n)
434 +
            .unwrap()["heads"]
435 +
            .as_array()
436 +
            .unwrap()
437 +
            .len()
438 +
    };
439 +
    assert_eq!(heads("upto"), 2, "a and b: {bms}");
440 +
    assert_eq!(heads("only"), 1);
441 +
442 +
    // Switching to the smaller state says what it leaves out.
443 +
    let (code, out, err) = oxy(&r, &home, &["bookmark", "switch", "only"]);
444 +
    assert_eq!(code, 0, "{err}");
445 +
    let out = String::from_utf8_lossy(&out);
446 +
    assert!(out.contains("only lacks 2 changes that main has"), "{out}");
447 +
    assert!(!r.join("a.txt").exists() && r.join("b.txt").exists());
448 +
}