OXYGENHUB

bookmark set: fast-forward onto work built on the newest head 4afab467e35f

Moving a bookmark onto a change built on its newest head was refused
when the bookmark also held changes the new one does not depend on,
such as early independent changes every bookmark carries as heads
(feedback #37). When the new heads hold the bookmark's newest head and
add to it, those are kept, as --upto does, and the command says so. A
head the bookmark already holds is still refused without --upto or
--only.

closes #78
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-06 17:32 UTC
Signature
✔ signed by Andy McCall
Hash
4afab467e35f35023c84f531f79824cb10e31c242baa279de8b934177f8c84f1
Depends on
06d16dc2bc13 4fe7d6045860 c6aa5bd9d1c8 ee06a6c557b7 fbda43e7d8ed fce44ae9ad29
Depended on by
nothing yet
In bookmarks
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.10.0 v0.11.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
7 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified docs/guide.md +1 −1
…
94 unchanged lines
95 95
oxy merge feature                  # main now holds both; working copy follows if clean
96 96
oxy bookmark list                  # ● current, heads, ↑n/↓n against origin
97 97
oxy push -b feature                # publish a bookmark other than the current one
98 -
oxy bookmark set feature 658990aa  # move it; oxy bookmark delete feature to drop it
98 +
oxy bookmark set feature 658990aa  # move it (onto work built on it: kept heads stay); delete drops it
99 99
```
100 100
101 101
`oxy checkout feature` materialises a bookmark without switching to it, and
…
405 unchanged lines
modified src/cli/workspace.rs +32 −0
…
1651 unchanged lines
1652 1652
        .map(|b| b.heads)
1653 1653
        .ok_or_else(|| anyhow::anyhow!("no bookmark {name}"))?;
1654 1654
    let gap = repo.state_gap(&current, &named)?;
1655 +
    // A fast-forward: the new heads hold the bookmark's newest head, so
1656 +
    // what they leave out is older work nothing since has built on, such
1657 +
    // as an early change no later one depends on (oxygen#78). Keep it, as
1658 +
    // --upto does, and say so.
1659 +
    let newest = current
1660 +
        .iter()
1661 +
        .filter_map(|h| repo.store().get_change_meta(h).ok().map(|m| (m.hlc, *h)))
1662 +
        .max()
1663 +
        .map(|(_, h)| h);
1664 +
    // Ahead, too: a head the bookmark holds already (it would only drop
1665 +
    // the others) is not a move forward.
1666 +
    let ahead = repo
1667 +
        .load_state_history(&current)
1668 +
        .is_ok_and(|st| named.iter().any(|h| !st.contains(h)));
1669 +
    let forward = !only
1670 +
        && ahead
1671 +
        && !gap.earlier.is_empty()
1672 +
        && newest.is_some_and(|n| {
1673 +
            repo.load_state_history(&named)
1674 +
                .is_ok_and(|st| st.contains(&n))
1675 +
        });
1655 1676
    let heads = if upto {
1677 +
        gap.upto
1678 +
    } else if forward {
1679 +
        println!(
1680 +
            "  {} {}",
1681 +
            ui::note(),
1682 +
            ui::dim(format!(
1683 +
                "{} builds on {name}'s newest head; keeping {} it holds that {0} does not depend on",
1684 +
                changes.join(" "),
1685 +
                plural(gap.earlier.len(), "change")
1686 +
            ))
1687 +
        );
1656 1688
        gap.upto
1657 1689
    } else if only || gap.earlier.is_empty() {
1658 1690
        named
…
213 unchanged lines
modified tests/cli_json.rs +35 −1
…
448 unchanged lines
449 449
    // Moving a bookmark asks the same (metalgear, oxygen#42): main at b
450 450
    // alone would lose a, which it holds and was recorded before b.
451 451
    let (code, _, err) = oxy(&r, &home, &["bookmark", "set", "main", &b]);
452 -
    assert_ne!(code, 0);
452 +
    assert_ne!(code, 0, "b is a head main holds, not a move forward");
453 453
    assert!(
454 454
        err.contains("would lose 1 change") && err.contains("--upto"),
455 455
        "{err}"
…
10 unchanged lines
466 466
        .find(|x| x["name"] == "main")
467 467
        .unwrap();
468 468
    assert_eq!(main["heads"].as_array().unwrap().len(), 2, "a and b: {bms}");
469 +
470 +
    // A fast-forward (oxygen#78): y holds a and b; b2 is built on b, its
471 +
    // newest head. Moving y to b2 keeps a, which b2 does not depend on.
472 +
    assert_eq!(
473 +
        oxy(&r, &home, &["bookmark", "switch", "main", "--discard"]).0,
474 +
        0
475 +
    );
476 +
    assert_eq!(oxy(&r, &home, &["bookmark", "create", "y"]).0, 0);
477 +
    write(&r, "b.txt", "b2\n");
478 +
    assert_eq!(oxy(&r, &home, &["commit", "-m", "b2"]).0, 0);
479 +
    let log = json(&r, &home, &["log", "--json"]);
480 +
    let b2 = log["changes"]
481 +
        .as_array()
482 +
        .unwrap()
483 +
        .iter()
484 +
        .find(|c| c["message"] == "b2")
485 +
        .unwrap()["hash"]
486 +
        .as_str()
487 +
        .unwrap()[..12]
488 +
        .to_string();
489 +
    let (code, out, err) = oxy(&r, &home, &["bookmark", "set", "y", &format!("#{b2}")]);
490 +
    assert_eq!(code, 0, "{err}");
491 +
    assert!(
492 +
        String::from_utf8_lossy(&out).contains("builds on y's newest head"),
493 +
        "says why"
494 +
    );
495 +
    let bms = json(&r, &home, &["bookmark", "list", "--json"]);
496 +
    let y = bms
497 +
        .as_array()
498 +
        .unwrap()
499 +
        .iter()
500 +
        .find(|x| x["name"] == "y")
501 +
        .unwrap();
502 +
    assert_eq!(y["heads"].as_array().unwrap().len(), 2, "a and b2: {bms}");
469 503
}