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 | + | } |