OXYGENHUB

oxy merge takes a remote's bookmark 733ea16a9701

Metal Gear could log oxygenhub/diffusion/rstar-api but not merge
oxygenhub/develop ('no bookmark'): merge looked only at the local
bookmarks, so merging what a remote has meant switching to it, pulling
and switching back. It now takes a remote's tracking bookmark too, as
revsets do (as last fetched), and says where it looked when neither
exists.

Closes #117
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key c5deb596c1b1
When
· 2026-10-18 16:16 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
733ea16a97015ecb5ff67ee729582786169fbcd577e3e27cfc9392d23e2daae7
Depends on
14edbaaf5291 1c2bd9abdd4f 4fe7d6045860 75167672ebcd 7b9809e67c3d 86e682c242c6 982864068a59 d42369abf180 e266b7d4065c fce44ae9ad29 ff0af1ee299d
Depended on by
nothing yet
In bookmarks
doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
9 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified crates/oxy-core/src/repo/bookmarks.rs +10 −4
…
447 unchanged lines
448 448
    }
449 449
450 450
    /// Union the heads of bookmark `name` into the current bookmark (spec
451 -
    /// §10.1). A working copy without changes follows.
451 +
    /// §10.1): a bookmark here, or a remote's as its tracking bookmark
452 +
    /// holds it (`origin/develop`, oxygen#117), so merging what a remote
453 +
    /// has needs no switch. A working copy without changes follows.
452 454
    pub fn merge(&self, name: &str) -> Result<MergeReport> {
453 455
        let _wc = self.lock_working_copy()?;
454 456
        let (_, op) = self.head_op()?;
455 457
        if name == self.config.bookmark {
456 458
            return fail(format!("{name} is the current bookmark"));
457 459
        }
458 -
        let Some(st) = op.view.bookmarks.get(name) else {
459 -
            return fail(format!("no bookmark {name}"));
460 +
        let here = op.view.bookmarks.get(name).copied();
461 +
        let Some(state) = here.or_else(|| remote_bookmark(&op.view, name).map(|(_, _, st)| st))
462 +
        else {
463 +
            return fail(format!(
464 +
                "no bookmark {name}, here or at a remote (oxy bookmark list --all lists them)"
465 +
            ));
460 466
        };
461 -
        let other = self.store.get_state(st)?.heads;
467 +
        let other = self.store.get_state(&state)?.heads;
462 468
        self.merge_heads(other, &format!("oxy merge {name}"))
463 469
    }
464 470
…
83 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +16 −0
…
5803 unchanged lines
5804 5804
    write(&root.join("a"), "m.txt", b"m\n");
5805 5805
    let m = a.commit("m", &[]).unwrap();
5806 5806
    assert!(a.push("origin", false).unwrap()[0].ok);
5807 +
    // oxygen#117: a remote's bookmark merges without switching to it.
5808 +
    let f = Repo::clone(&url, &root.join("f")).unwrap();
5809 +
    assert!(!f.view().unwrap().bookmarks.contains_key("develop"));
5810 +
    let merged = f.merge("origin/develop").unwrap();
5811 +
    assert!(merged.changed, "{merged:?}");
5812 +
    assert!(
5813 +
        f.load_state_indexed(&f.bookmark_heads().unwrap())
5814 +
            .unwrap()
5815 +
            .contains(&d1),
5816 +
        "main holds develop's change"
5817 +
    );
5818 +
    assert!(f
5819 +
        .merge("origin/nowhere")
5820 +
        .unwrap_err()
5821 +
        .to_string()
5822 +
        .contains("no bookmark origin/nowhere"));
5807 5823
    let r = c.pull("origin").unwrap();
5808 5824
    assert!(r.changed && r.checked_out, "{r:?}");
5809 5825
    assert_eq!(r.heads, vec![d2]);
…
712 unchanged lines
modified docs/spec.md +1 −1
…
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); 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.
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 (NAME is a bookmark here or a remote's tracking bookmark, `origin/develop`, as last fetched: oxygen#117), 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
…
613 unchanged lines
modified src/cli/mod.rs +2 −1
…
327 unchanged lines
328 328
        #[command(subcommand)]
329 329
        cmd: BookmarkCmd,
330 330
    },
331 -
    /// Union another bookmark's heads into the current one (no merge change)
331 +
    /// Union another bookmark's heads into the current one (no merge change):
332 +
    /// a bookmark here, or a remote's (`origin/develop`) as last fetched
332 333
    Merge {
333 334
        /// The bookmark whose heads to add
334 335
        bookmark: String,
…
954 unchanged lines