OXYGENHUB

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