Never move a subrepo's bookmark backwards from its parent 1a3b62032ed9
On metalgear, the rstar-api subrepo had a new, pushed change on its bookmark that the parent had not pinned yet. The parent's bookmark switch --carry checked the subrepo out at the old pin and set its bookmark there, so the new files vanished and the bookmark fell behind; only the earlier push let pull recover it. A parent checkout still brings a nested working copy to the pin, but moves the nested bookmark only forward; when the bookmark has changes the pin lacks it stays, and the checkout says so. The pin is read from the nested working copy's base, so the parent does not then see the subrepo as changed. --carry leaves a subrepo whose pin is carried work as it is, and lists it among the carried paths. closes #38
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 21:28 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
1a3b62032ed99e6cc94ebb0e463b9983a5a96b1dadbd44783e922ee962cdc1e7- Depends on
- 1c2bd9abdd4f 4dee277c327a 4fe7d6045860 61e3bce7ea5b 6caacfff1d4f 6f8aeb996558 75167672ebcd 7b9809e67c3d 86fe29fd62ea 982864068a59 fce44ae9ad29 ff0af1ee299d
- Depended on by
- 44f1ebe7aa5b c5ce0367eddf da868d5d2646
- 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
- 25 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified crates/oxy-core/src/repo/bookmarks.rs +21 −1
| … | 291 unchanged lines |
||
| 292 | 292 | if differ.len() > 10 { ", …" } else { "" } |
|
| 293 | 293 | )); |
|
| 294 | 294 | } |
|
| 295 | + | // A subrepo whose pin is carried work keeps its own state: checking |
|
| 296 | + | // it out at the target's pin would roll it back (oxygen#38). |
|
| 297 | + | let keep: BTreeSet<String> = touched |
|
| 298 | + | .iter() |
|
| 299 | + | .filter(|p| { |
|
| 300 | + | snap.base_snapshot |
|
| 301 | + | .files |
|
| 302 | + | .get(*p) |
|
| 303 | + | .or_else(|| target_state.files.get(*p)) |
|
| 304 | + | .is_some_and(|f| f.kind == NodeKind::Subrepo) |
|
| 305 | + | }) |
|
| 306 | + | .cloned() |
|
| 307 | + | .collect(); |
|
| 295 | 308 | // The work as it is on disk: content and mode, or absent. |
|
| 296 | 309 | let mut carried: Vec<(String, Carried)> = Vec::new(); |
|
| 297 | 310 | for p in &touched { |
|
| 311 | + | if keep.contains(p) { |
|
| 312 | + | carried.push((p.clone(), Carried::Subrepo)); |
|
| 313 | + | continue; |
|
| 314 | + | } |
|
| 298 | 315 | let full = self.root.join(p); |
|
| 299 | 316 | let item = match fs::symlink_metadata(&full) { |
|
| 300 | 317 | Ok(m) if m.file_type().is_symlink() => { |
|
| … | 5 unchanged lines |
||
| 306 | 323 | }; |
|
| 307 | 324 | carried.push((p.clone(), item)); |
|
| 308 | 325 | } |
|
| 309 | - | let report = self.checkout(heads, true, command)?; |
|
| 326 | + | let report = self.checkout_keeping(heads, true, command, &keep)?; |
|
| 310 | 327 | for (p, item) in &carried { |
|
| 311 | 328 | match item { |
|
| 312 | 329 | Carried::Symlink(target) => self.write_symlink(p, target)?, |
|
| … | 1 unchanged lines |
||
| 314 | 331 | Carried::Absent => { |
|
| 315 | 332 | let _ = fs::remove_file(self.root.join(p)); |
|
| 316 | 333 | } |
|
| 334 | + | Carried::Subrepo => {} |
|
| 317 | 335 | } |
|
| 318 | 336 | } |
|
| 319 | 337 | self.drop_manifest(); |
|
| … | 62 unchanged lines |
||
| 382 | 400 | ||
| 383 | 401 | /// A path's state on disk, taken along by `bookmark switch --carry`. |
|
| 384 | 402 | enum Carried { |
|
| 403 | + | /// A subrepo whose pin is carried: left as it is. |
|
| 404 | + | Subrepo, |
|
| 385 | 405 | Symlink(Vec<u8>), |
|
| 386 | 406 | /// Executable bit and content. |
|
| 387 | 407 | File(bool, Vec<u8>), |
|
| … | 2 unchanged lines |
modified crates/oxy-core/src/repo/checkout.rs +22 −6
| … | 20 unchanged lines |
||
| 21 | 21 | discard: bool, |
|
| 22 | 22 | command: &str, |
|
| 23 | 23 | ) -> Result<CheckoutReport> { |
|
| 24 | + | self.checkout_keeping(heads, discard, command, &BTreeSet::new()) |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | /// [`Repo::checkout`], leaving the nested repositories at |
|
| 28 | + | /// `keep_subrepos` as they are (their pins are carried work). |
|
| 29 | + | pub(crate) fn checkout_keeping( |
|
| 30 | + | &self, |
|
| 31 | + | heads: &[ChangeHash], |
|
| 32 | + | discard: bool, |
|
| 33 | + | command: &str, |
|
| 34 | + | keep_subrepos: &BTreeSet<String>, |
|
| 35 | + | ) -> Result<CheckoutReport> { |
|
| 24 | 36 | let _wc = self.lock_working_copy()?; |
|
| 25 | 37 | let snap = self.snapshot(command)?; |
|
| 26 | 38 | if !discard && (!snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty()) { |
|
| … | 73 unchanged lines |
||
| 100 | 112 | let bytes = self.read_blob(blob)?; |
|
| 101 | 113 | if f.kind == NodeKind::Subrepo { |
|
| 102 | 114 | let pin = crate::subrepo::Pin::decode(&bytes)?; |
|
| 103 | - | match self.sync_subrepo(path, &pin, false)? { |
|
| 104 | - | SubrepoSync::Updated => report.written += 1, |
|
| 105 | - | SubrepoSync::UpToDate => {} |
|
| 106 | - | SubrepoSync::Missing => report.notes.push(format!("{path}: nested repository not present; run oxy submodule update --init")), |
|
| 107 | - | SubrepoSync::Dirty => report.notes.push(format!("{path}: nested repository has uncommitted changes; left as is")), |
|
| 108 | - | SubrepoSync::Failed(e) => report.notes.push(format!("{path}: {e}")), |
|
| 115 | + | if keep_subrepos.contains(path) { |
|
| 116 | + | continue; |
|
| 117 | + | } |
|
| 118 | + | let sync = self.sync_subrepo(path, &pin, false)?; |
|
| 119 | + | if matches!( |
|
| 120 | + | sync, |
|
| 121 | + | SubrepoSync::Updated | SubrepoSync::KeptBookmark { .. } |
|
| 122 | + | ) { |
|
| 123 | + | report.written += 1; |
|
| 109 | 124 | } |
|
| 125 | + | report.notes.extend(sync.note(path)); |
|
| 110 | 126 | continue; |
|
| 111 | 127 | } |
|
| 112 | 128 | if f.kind == NodeKind::Symlink { |
|
| … | 158 unchanged lines |
modified crates/oxy-core/src/repo/subrepos.rs +42 −5
| … | 12 unchanged lines |
||
| 13 | 13 | UpToDate, |
|
| 14 | 14 | Missing, |
|
| 15 | 15 | Dirty, |
|
| 16 | + | /// The working copy was brought to the pin, but the nested repository's |
|
| 17 | + | /// bookmark has changes the pin lacks, so it was left where it is: a |
|
| 18 | + | /// bookmark is never moved backwards (spec §7.8). |
|
| 19 | + | KeptBookmark { |
|
| 20 | + | bookmark: String, |
|
| 21 | + | newer: usize, |
|
| 22 | + | }, |
|
| 16 | 23 | Failed(String), |
|
| 17 | 24 | } |
|
| 18 | 25 | ||
| 26 | + | impl SubrepoSync { |
|
| 27 | + | /// A note for a checkout report, when there is something to say. |
|
| 28 | + | pub fn note(&self, path: &str) -> Option<String> { |
|
| 29 | + | match self { |
|
| 30 | + | SubrepoSync::Updated | SubrepoSync::UpToDate => None, |
|
| 31 | + | SubrepoSync::Missing => Some(format!( |
|
| 32 | + | "{path}: nested repository not present; run oxy submodule update --init" |
|
| 33 | + | )), |
|
| 34 | + | SubrepoSync::Dirty => Some(format!( |
|
| 35 | + | "{path}: nested repository has uncommitted changes; left as is" |
|
| 36 | + | )), |
|
| 37 | + | SubrepoSync::KeptBookmark { bookmark, newer } => Some(format!( |
|
| 38 | + | "{path}: checked out at the pin; its bookmark {bookmark} keeps {newer} newer change{} (oxy bookmark switch {bookmark} there to return to them)", |
|
| 39 | + | if *newer == 1 { "" } else { "s" } |
|
| 40 | + | )), |
|
| 41 | + | SubrepoSync::Failed(e) => Some(format!("{path}: {e}")), |
|
| 42 | + | } |
|
| 43 | + | } |
|
| 44 | + | } |
|
| 45 | + | ||
| 19 | 46 | /// One subrepo as `oxy submodule status` shows it. |
|
| 20 | 47 | #[derive(Clone, Debug)] |
|
| 21 | 48 | pub struct SubrepoInfo { |
|
| … | 27 unchanged lines |
||
| 49 | 76 | Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) |
|
| 50 | 77 | } |
|
| 51 | 78 | ||
| 52 | - | /// The pin a nested repository at `dir` is at: its current bookmark's |
|
| 53 | - | /// heads for an Oxygen repository, `HEAD` for a Git one. |
|
| 79 | + | /// The pin a nested repository at `dir` is at: the base of its working |
|
| 80 | + | /// copy for an Oxygen repository (which is its bookmark's heads unless a |
|
| 81 | + | /// parent checked out an older pin), `HEAD` for a Git one. |
|
| 54 | 82 | pub(crate) fn read_pin(dir: &Path, bookmark_hint: Option<&str>) -> Result<Pin> { |
|
| 55 | 83 | if dir.join(OXY_DIR).exists() { |
|
| 56 | 84 | let nested = Repo::open(dir)?; |
|
| 57 | 85 | return Ok(Pin::Oxy { |
|
| 58 | 86 | repo: nested.id()?, |
|
| 59 | - | heads: nested.bookmark_heads()?, |
|
| 87 | + | heads: nested.working_copy()?.1.deps, |
|
| 60 | 88 | bookmark: nested.config.bookmark.clone(), |
|
| 61 | 89 | }); |
|
| 62 | 90 | } |
|
| … | 116 unchanged lines |
||
| 179 | 207 | } |
|
| 180 | 208 | match nested.checkout(heads, false, "oxy submodule update") { |
|
| 181 | 209 | Ok(_) => { |
|
| 182 | - | let _ = nested.bookmark_set(&nested.config.bookmark, heads); |
|
| 183 | - | Ok(SubrepoSync::Updated) |
|
| 210 | + | // Its bookmark follows only forward: work it has |
|
| 211 | + | // beyond the pin stays on it (metalgear, oxygen#38). |
|
| 212 | + | let bookmark = nested.config.bookmark.clone(); |
|
| 213 | + | let at = nested.bookmark_heads().unwrap_or_default(); |
|
| 214 | + | let newer = nested.state_gap(&at, heads).map(|g| g.lacks).unwrap_or(0); |
|
| 215 | + | if newer == 0 { |
|
| 216 | + | let _ = nested.bookmark_set(&bookmark, heads); |
|
| 217 | + | Ok(SubrepoSync::Updated) |
|
| 218 | + | } else { |
|
| 219 | + | Ok(SubrepoSync::KeptBookmark { bookmark, newer }) |
|
| 220 | + | } |
|
| 184 | 221 | } |
|
| 185 | 222 | Err(e) => Ok(SubrepoSync::Failed(e.to_string())), |
|
| 186 | 223 | } |
|
| … | 188 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +54 −9
| … | 3363 unchanged lines |
||
| 3364 | 3364 | vec![("libs/lib", ChangeStatus::Modified)] |
|
| 3365 | 3365 | ); |
|
| 3366 | 3366 | let a2 = app.commit("bump lib", &[]).unwrap(); |
|
| 3367 | - | // Checking out the older app state moves the nested repository back. |
|
| 3367 | + | // Checking out the older app state brings the nested working copy back |
|
| 3368 | + | // to the older pin, but never moves its bookmark backwards (oxygen#38). |
|
| 3368 | 3369 | let r = app.checkout(&[a1], false, "checkout").unwrap(); |
|
| 3369 | - | assert!(r.notes.is_empty(), "{:?}", r.notes); |
|
| 3370 | + | assert!( |
|
| 3371 | + | r.notes.len() == 1 && r.notes[0].contains("keeps 1 newer change"), |
|
| 3372 | + | "{:?}", |
|
| 3373 | + | r.notes |
|
| 3374 | + | ); |
|
| 3370 | 3375 | assert_eq!(fs::read(app_dir.join("libs/lib/lib.txt")).unwrap(), b"v1\n"); |
|
| 3371 | - | assert_eq!( |
|
| 3372 | - | Repo::open(&app_dir.join("libs/lib")) |
|
| 3373 | - | .unwrap() |
|
| 3374 | - | .bookmark_heads() |
|
| 3375 | - | .unwrap(), |
|
| 3376 | - | vec![l1] |
|
| 3377 | - | ); |
|
| 3376 | + | let nested = Repo::open(&app_dir.join("libs/lib")).unwrap(); |
|
| 3377 | + | assert_eq!(nested.bookmark_heads().unwrap(), vec![l2]); |
|
| 3378 | + | assert_eq!(nested.working_copy().unwrap().1.deps, vec![l1]); |
|
| 3378 | 3379 | assert!(!app.snapshot("status").unwrap().changed); |
|
| 3379 | 3380 | // A dirty nested repository is left alone and reported. |
|
| 3380 | 3381 | write(&app_dir.join("libs/lib"), "scratch.txt", b"wip\n"); |
|
| … | 2284 unchanged lines |
||
| 5665 | 5666 | assert!(s.wc.edits.is_empty() && s.wc.tree_ops.is_empty()); |
|
| 5666 | 5667 | assert_eq!(s.wc.deps, repo.bookmark_heads().unwrap()); |
|
| 5667 | 5668 | assert!(root.join("many/f299.txt").exists()); |
|
| 5669 | + | } |
|
| 5670 | + | ||
| 5671 | + | /// Metalgear, 2026-09-29 (oxygen#38): a parent's `bookmark switch --carry` |
|
| 5672 | + | /// put a subrepo that was ahead of its pin back at the pin and moved the |
|
| 5673 | + | /// subrepo's own bookmark backwards. |
|
| 5674 | + | #[test] |
|
| 5675 | + | fn a_parent_switch_with_carry_leaves_a_subrepo_ahead_of_its_pin_alone() { |
|
| 5676 | + | hermetic(); |
|
| 5677 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 5678 | + | let root = dir.path(); |
|
| 5679 | + | Repo::init(&root.join("lib-origin")).unwrap(); |
|
| 5680 | + | let lib_url = root.join("lib-origin").to_string_lossy().to_string(); |
|
| 5681 | + | let lib = Repo::clone(&lib_url, &root.join("lib")).unwrap(); |
|
| 5682 | + | write(&root.join("lib"), "lib.txt", b"v1\n"); |
|
| 5683 | + | lib.commit("lib v1", &[]).unwrap(); |
|
| 5684 | + | assert!(lib.push("origin", false).unwrap()[0].ok); |
|
| 5685 | + | ||
| 5686 | + | let app_dir = root.join("app"); |
|
| 5687 | + | let mut app = Repo::init(&app_dir).unwrap(); |
|
| 5688 | + | write(&app_dir, "main.txt", b"app\n"); |
|
| 5689 | + | app.submodule_add(&lib_url, "libs/lib", None).unwrap(); |
|
| 5690 | + | app.commit("app with lib", &[]).unwrap(); |
|
| 5691 | + | app.bookmark_create("feature", None).unwrap(); |
|
| 5692 | + | ||
| 5693 | + | // New work in the subrepo, not yet pinned by the parent. |
|
| 5694 | + | let nested_dir = app_dir.join("libs/lib"); |
|
| 5695 | + | let nested = Repo::open(&nested_dir).unwrap(); |
|
| 5696 | + | write(&nested_dir, "new.txt", b"new\n"); |
|
| 5697 | + | let l2 = nested.commit("lib: new file", &[]).unwrap(); |
|
| 5698 | + | drop(nested); |
|
| 5699 | + | ||
| 5700 | + | let (_, carried) = app.bookmark_switch_carry("feature").unwrap(); |
|
| 5701 | + | assert!(carried.contains(&"libs/lib".to_string()), "{carried:?}"); |
|
| 5702 | + | let nested = Repo::open(&nested_dir).unwrap(); |
|
| 5703 | + | assert!( |
|
| 5704 | + | nested.bookmark_heads().unwrap().contains(&l2), |
|
| 5705 | + | "its bookmark kept its work" |
|
| 5706 | + | ); |
|
| 5707 | + | assert!(nested_dir.join("new.txt").exists(), "and its files"); |
|
| 5708 | + | let s = app.snapshot("status").unwrap(); |
|
| 5709 | + | assert_eq!( |
|
| 5710 | + | statuses(&s), |
|
| 5711 | + | vec![("libs/lib".to_string(), ChangeStatus::Modified)] |
|
| 5712 | + | ); |
|
| 5668 | 5713 | } |
modified docs/spec.md +2 −2
| … | 306 unchanged lines |
||
| 307 | 307 | ||
| 308 | 308 | The mapping from path to remote lives in `.oxymodules` at the repository root, a tracked file of `[path]` sections with `url` and optional `bookmark` (Oxygen) or `branch` (Git) keys, edited by `oxy submodule add|remove` and readable by hand. A pin without a `.oxymodules` entry is checked out only when the nested repository is already present. |
|
| 309 | 309 | ||
| 310 | - | In the working copy a subrepo is a directory holding its own `.oxy/` (or `.git/`). The snapshot MUST NOT descend into it; it reads the nested repository's current bookmark heads (or Git `HEAD`) as the pin, so committing the parent records whatever the nested repository is at, as in Git. A nested repository that is neither registered in `.oxymodules` nor tracked as a subrepo node is left alone entirely: not walked, not added. A tracked subrepo whose directory is absent keeps its recorded pin, as a Git gitlink does. `oxy checkout` of the parent MUST move an existing nested repository to the pinned state (`oxy checkout` of those heads, or `git checkout --detach <sha>`), fetching from the recorded remote when objects are missing, MUST leave an absent nested repository absent until `oxy submodule update --init` clones it, and MUST never delete a nested repository, even when the target state has no node for it. A nested working copy with uncommitted changes is never overwritten: the checkout reports it and continues. |
|
| 310 | + | In the working copy a subrepo is a directory holding its own `.oxy/` (or `.git/`). The snapshot MUST NOT descend into it; it reads the base of the nested repository's working copy (its bookmark's heads unless a parent checked out an older pin; or Git `HEAD`) as the pin, so committing the parent records whatever the nested repository is at, as in Git. A nested repository that is neither registered in `.oxymodules` nor tracked as a subrepo node is left alone entirely: not walked, not added. A tracked subrepo whose directory is absent keeps its recorded pin, as a Git gitlink does. `oxy checkout` of the parent MUST move an existing nested repository to the pinned state (`oxy checkout` of those heads, or `git checkout --detach <sha>`), fetching from the recorded remote when objects are missing. It moves the nested repository's bookmark only forward: when the bookmark has changes the pin lacks, the bookmark stays where it is and the checkout says so, as Git leaves a submodule's branch alone. A checkout that carries work (`--carry`) leaves a subrepo whose pin is part of that work as it is. It MUST leave an absent nested repository absent until `oxy submodule update --init` clones it, and MUST never delete a nested repository, even when the target state has no node for it. A nested working copy with uncommitted changes is never overwritten: the checkout reports it and continues. |
|
| 311 | 311 | ||
| 312 | 312 | `oxy submodule add URL PATH [--bookmark NAME]` clones URL into PATH (an Oxygen remote when URL answers as one, otherwise Git), records `.oxymodules`, and the next snapshot adds the node pinned at the clone's heads. `oxy submodule update [--init] [PATH..]` brings nested repositories to their pins. `oxy submodule status` lists each subrepo with its pin and whether the nested repository matches it; one registered but not committed yet is listed as added, with the pin the next commit records (`committed: false` in `--json`). `oxy submodule remove PATH` drops the `.oxymodules` entry and records that the node is to go with the next snapshot; the directory is left in place and thereafter ignored as unregistered. |
|
| 313 | 313 | ||
| … | 660 unchanged lines |
||
| 974 | 974 | ||
| 975 | 975 | --- |
|
| 976 | 976 | ||
| 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). |
|
| 977 | + | *Changelog.* Draft 0.8r (2026-09-29): a parent checkout never moves a subrepo's bookmark backwards, and the pin is read from the nested working copy (§7.8); `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/sync.rs +5 −0
| … | 585 unchanged lines |
||
| 586 | 586 | ui::glyph("⚠", "!") |
|
| 587 | 587 | )) |
|
| 588 | 588 | } |
|
| 589 | + | KeptBookmark { bookmark, newer } => ui::warn(format!( |
|
| 590 | + | "{} checked out at the pin; its bookmark {bookmark} keeps {} the pin lacks", |
|
| 591 | + | ui::glyph("⚠", "!"), |
|
| 592 | + | plural(newer, "newer change") |
|
| 593 | + | )), |
|
| 589 | 594 | Failed(e) => { |
|
| 590 | 595 | code = 1; |
|
| 591 | 596 | ui::err(format!("{} {e}", ui::glyph("✖", "x"))) |
|
| … | 121 unchanged lines |