OXYGENHUB

bookmark: show a bookmark's heads; bookmark() in revsets bf3a67819b59

oxy bookmark show [NAME] lists each head, newest first, with its id,
hash, author, time and subject (--json too); bookmark list -v does it
for every bookmark, and bookmark list now names the newest head's
subject rather than the first. bookmark(NAME) in a revset is a local
or remote bookmark's heads and nothing else. log -r's help and the
guide say a bookmark name is a revset for its heads.

closes #73
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-05 18:32 UTC
Signature
✔ signed by Andy McCall
Hash
bf3a67819b59931bd91f38620a3f3fc26febe7af53c32ec2b1dfb03d19e67984
Depends on
14edbaaf5291 2c9ddac3c387 47ce8b8e2d4b 61e3bce7ea5b 75167672ebcd 7993d465675b 7b9809e67c3d 8ad9dfe16303 94084087a876 982864068a59 c6aa5bd9d1c8 d42369abf180 ee06a6c557b7 fbda43e7d8ed fce44ae9ad29
Depended on by
42ccf504aa50 72484195899a
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
16 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified crates/oxy-core/src/repo/revset.rs +17 −0
…
462 unchanged lines
463 463
                let kind = self.text_arg(name, args, 0)?.to_string();
464 464
                self.attested(within, &kind)
465 465
            }
466 +
            // A bookmark's heads by name: what a bare bookmark symbol means,
467 +
            // without falling through to tags and change prefixes.
468 +
            "bookmark" => {
469 +
                self.arity(name, args, 1)?;
470 +
                let b = self.text_arg(name, args, 0)?;
471 +
                let view = self.repo.view()?;
472 +
                let st = match view.bookmarks.get(b) {
473 +
                    Some(st) => *st,
474 +
                    None => match remote_bookmark(&view, b) {
475 +
                        Some((_, _, st)) => st,
476 +
                        None => return Err(err(format!("bookmark({b}): no such bookmark"))),
477 +
                    },
478 +
                };
479 +
                let heads = self.repo.store().get_state(&st)?.heads;
480 +
                self.ensure(&heads)?;
481 +
                Ok(heads.into_iter().collect())
482 +
            }
466 483
            "built" => {
467 484
                self.arity(name, args, 1)?;
468 485
                let job = self.text_arg(name, args, 0)?.to_string();
…
277 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +27 −0
…
7736 unchanged lines
7737 7737
    app.submodule_add(&lib_url, "libs/lib", None).unwrap();
7738 7738
    assert!(app.unpinned_modules().unwrap().is_empty(), "added now");
7739 7739
}
7740 +
7741 +
#[test]
7742 +
fn the_bookmark_revset_function_names_only_bookmarks() {
7743 +
    // oxygen#73: bookmark(NAME) is a bookmark's heads, never a tag or prefix.
7744 +
    hermetic();
7745 +
    let dir = tempfile::tempdir().unwrap();
7746 +
    let repo = Repo::init(dir.path()).unwrap();
7747 +
    write(dir.path(), "a.txt", b"a\n");
7748 +
    let a = repo.commit("A", &[]).unwrap();
7749 +
    write(dir.path(), "b.txt", b"b\n");
7750 +
    let b = repo.commit("B", &[]).unwrap();
7751 +
    let mut heads = repo.revset("bookmark(main)").unwrap().hashes;
7752 +
    heads.sort();
7753 +
    let mut want = repo.bookmark_heads().unwrap();
7754 +
    want.sort();
7755 +
    assert_eq!(heads, want);
7756 +
    assert!(heads.contains(&b));
7757 +
    assert_eq!(repo.revset("main").unwrap().hashes.len(), heads.len());
7758 +
    // A change prefix is a symbol, not a bookmark.
7759 +
    let prefix = repo.store().get_change_meta(&a).unwrap().change_id.short(8);
7760 +
    assert!(repo.revset(&prefix).is_ok());
7761 +
    let e = repo
7762 +
        .revset(&format!("bookmark({prefix})"))
7763 +
        .unwrap_err()
7764 +
        .to_string();
7765 +
    assert!(e.contains("no such bookmark"), "{e}");
7766 +
}
modified docs/guide.md +3 −0
…
117 unchanged lines
118 118
oxy bookmark switch main
119 119
oxy merge feature                  # main now holds both; working copy follows if clean
120 120
oxy bookmark list                  # ● current, heads, ↑n/↓n against origin
121 +
oxy bookmark show feature          # each head, newest first (list -v: every bookmark's)
121 122
oxy push -b feature                # publish a bookmark other than the current one
122 123
oxy bookmark set feature 658990aa  # move it; oxy bookmark delete feature to drop it
123 124
```
124 125
125 126
`oxy checkout feature` materialises a bookmark without switching to it, and
126 127
a bookmark name works wherever a change is expected when it has one head.
128 +
In a revset it means the bookmark's heads (`oxy log -r feature`);
129 +
`bookmark(feature)` means only the bookmark, never a tag or change prefix.
127 130
Each workspace remembers its own bookmark, so two directories can sit on
128 131
two bookmarks of one repository.
129 132
…
520 unchanged lines
modified docs/revset_query_language.md +1 −0
…
48 unchanged lines
49 49
| Symbol | Means |
50 50
|---|---|
51 51
| `main`, `v0.3.1` | the heads of that bookmark or tag |
52 +
| `bookmark(main)`, `bookmark(origin/main)` | the heads of that bookmark only; an error if there is none (a bare name falls through to tags and change prefixes) |
52 53
| `origin/main` | the heads of a remote-tracking bookmark |
53 54
| `8c5ea12a` | the change with that ChangeId prefix |
54 55
| `#fc68459c` | the change with that hash prefix |
…
108 unchanged lines
modified src/cli/mod.rs +21 −3
…
156 unchanged lines
157 157
    },
158 158
    /// List changes reachable from the bookmark and the working copy
159 159
    Log {
160 -
        /// Show the changes a revset selects: `main..feature & author(andy)`,
161 -
        /// `touches("src/**") & date(-7d..)` (see docs/revset_query_language.md)
160 +
        /// Show the changes a revset selects: `main` (a bookmark name means its
161 +
        /// heads; `bookmark(main)` only ever the bookmark), `main..feature &
162 +
        /// author(andy)`, `touches("src/**") & date(-7d..)` (see
163 +
        /// docs/revset_query_language.md)
162 164
        #[arg(short = 'r', long, value_name = "REVSET", conflicts_with_all = ["path", "lines"])]
163 165
        revset: Option<String>,
164 166
        /// Show at most this many changes
…
454 unchanged lines
619 621
        /// Also list remotes' bookmarks that have no local bookmark
620 622
        #[arg(long, short)]
621 623
        all: bool,
624 +
        /// Each head of every bookmark, as `bookmark show` prints them
625 +
        #[arg(long, short)]
626 +
        verbose: bool,
622 627
        /// Machine-readable output: one JSON document on stdout
623 628
        #[arg(long)]
624 629
        json: bool,
625 630
    },
631 +
    /// A bookmark's heads: each change nothing else on it depends on,
632 +
    /// newest first. A bookmark name is also a revset for its heads
633 +
    /// (`oxy log -r NAME`, or `bookmark(NAME)` to mean only the bookmark)
634 +
    Show {
635 +
        /// The bookmark, or REMOTE/NAME (default: the current one)
636 +
        name: Option<String>,
637 +
        /// Machine-readable output: one JSON document on stdout
638 +
        #[arg(long)]
639 +
        json: bool,
640 +
    },
626 641
    /// Make NAME this workspace's bookmark and check it out. Uncommitted work stops it; --carry takes the work along, --discard drops it
627 642
    Switch {
628 643
        /// The bookmark to work on; one only a remote has (NAME or REMOTE/NAME) is created from it
…
554 unchanged lines
1183 1198
                only,
1184 1199
            } => workspace::bookmark_set(&name, &changes, upto, only),
1185 1200
            BookmarkCmd::Delete { name } => workspace::bookmark_delete(&name),
1186 -
            BookmarkCmd::List { all, json } => workspace::bookmark_list(all, json),
1201 +
            BookmarkCmd::List { all, verbose, json } => {
1202 +
                workspace::bookmark_list(all, verbose, json)
1203 +
            }
1204 +
            BookmarkCmd::Show { name, json } => workspace::bookmark_show(name.as_deref(), json),
1187 1205
            BookmarkCmd::Switch {
1188 1206
                name,
1189 1207
                discard,
…
194 unchanged lines
modified src/cli/workspace.rs +110 −12
…
2311 unchanged lines
2312 2312
    Ok(0)
2313 2313
}
2314 2314
2315 -
pub fn bookmark_list(all: bool, as_json: bool) -> Result<i32> {
2315 +
/// Heads with their metadata, newest first.
2316 +
fn heads_newest_first(
2317 +
    repo: &Repo,
2318 +
    heads: &[oxy_core::hash::ChangeHash],
2319 +
) -> Vec<(oxy_core::hash::ChangeHash, oxy_core::state::ChangeMeta)> {
2320 +
    let mut out: Vec<_> = heads
2321 +
        .iter()
2322 +
        .filter_map(|h| repo.store().get_change_meta(h).ok().map(|m| (*h, m)))
2323 +
        .collect();
2324 +
    out.sort_by_key(|(_, m)| std::cmp::Reverse(m.hlc));
2325 +
    out
2326 +
}
2327 +
2328 +
/// One head as a line: id, hash, author, when, subject.
2329 +
fn print_head(
2330 +
    repo: &Repo,
2331 +
    indent: &str,
2332 +
    h: &oxy_core::hash::ChangeHash,
2333 +
    m: &oxy_core::state::ChangeMeta,
2334 +
) {
2335 +
    println!(
2336 +
        "{indent}{} {} {}{}{}{}{}",
2337 +
        ui::id(m.change_id.short(8)),
2338 +
        ui::dim(format!("#{}", &h.to_hex()[..8])),
2339 +
        author_name(repo, m),
2340 +
        ui::sep(),
2341 +
        ui::dim(ui::ago(m.hlc.wall_ms)),
2342 +
        ui::sep(),
2343 +
        ui::subject(subject_of(&m.message))
2344 +
    );
2345 +
}
2346 +
2347 +
pub fn bookmark_show(name: Option<&str>, as_json: bool) -> Result<i32> {
2316 2348
    let repo = open()?;
2349 +
    let name = name.unwrap_or(&repo.config.bookmark).to_string();
2350 +
    let view = repo.view()?;
2351 +
    let st = match view.bookmarks.get(&name) {
2352 +
        Some(st) => *st,
2353 +
        None => match oxy_core::repo::remote_bookmark(&view, &name) {
2354 +
            Some((_, _, st)) => st,
2355 +
            None => anyhow::bail!("no bookmark {name}: oxy bookmark list --all lists them"),
2356 +
        },
2357 +
    };
2358 +
    let heads = repo.store().get_state(&st)?.heads;
2359 +
    let shown = heads_newest_first(&repo, &heads);
2360 +
    if as_json {
2361 +
        println!(
2362 +
            "{}",
2363 +
            json::obj([
2364 +
                ("name", json::s(&name)),
2365 +
                ("current", json::b(name == repo.config.bookmark)),
2366 +
                (
2367 +
                    "heads",
2368 +
                    json::arr(shown.iter().map(|(h, m)| {
2369 +
                        json::obj([
2370 +
                            ("hash", json::s(&h.to_hex())),
2371 +
                            ("id", json::s(&m.change_id.to_string())),
2372 +
                            ("author", json::s(&author_name(&repo, m))),
2373 +
                            ("time_ms", json::n(m.hlc.wall_ms)),
2374 +
                            ("subject", json::s(subject_of(&m.message))),
2375 +
                        ])
2376 +
                    }))
2377 +
                ),
2378 +
            ])
2379 +
        );
2380 +
        return Ok(0);
2381 +
    }
2382 +
    println!(
2383 +
        "{} {}{}{}",
2384 +
        ui::ok(ui::glyph("●", "*")),
2385 +
        ui::bookmark(&name),
2386 +
        ui::sep(),
2387 +
        ui::dim(plural(heads.len(), "head"))
2388 +
    );
2389 +
    for (h, m) in &shown {
2390 +
        print_head(&repo, "  ", h, m);
2391 +
    }
2392 +
    if heads.len() > 1 {
2393 +
        println!(
2394 +
            "  {}",
2395 +
            ui::dim("None depends on another: the bookmark is all of them and their dependencies.")
2396 +
        );
2397 +
    }
2398 +
    Ok(0)
2399 +
}
2400 +
2401 +
pub fn bookmark_list(all: bool, verbose: bool, as_json: bool) -> Result<i32> {
2402 +
    let repo = open()?;
2317 2403
    let remote_only = repo.remote_only_bookmarks()?;
2318 2404
    if as_json {
2319 2405
        let mut items: Vec<String> = repo
…
37 unchanged lines
2357 2443
        if let Some(n) = b.behind.filter(|n| *n > 0) {
2358 2444
            tail += &ui::warn(format!(" {}{n}", ui::glyph("↓", "-")));
2359 2445
        }
2360 -
        let head = b
2361 -
            .heads
2362 -
            .first()
2363 -
            .map(|h| {
2364 -
                repo.store()
2365 -
                    .get_change(h)
2366 -
                    .map(|c| ui::subject(subject_of(&c.message)))
2367 -
                    .unwrap_or_default()
2368 -
            })
2369 -
            .unwrap_or_else(|| ui::dim("(empty)"));
2446 +
        let shown = heads_newest_first(&repo, &b.heads);
2447 +
        // The newest head's subject, so a busy bookmark shows its latest work.
2448 +
        let head = match shown.first() {
2449 +
            Some(_) if verbose => String::new(),
2450 +
            Some((_, m)) if b.heads.len() > 1 => format!(
2451 +
                "{} {}",
2452 +
                ui::dim("newest"),
2453 +
                ui::subject(subject_of(&m.message))
2454 +
            ),
2455 +
            Some((_, m)) => ui::subject(subject_of(&m.message)),
2456 +
            None => ui::dim("(empty)"),
2457 +
        };
2458 +
        let head = if head.is_empty() {
2459 +
            head
2460 +
        } else {
2461 +
            format!("  {head}")
2462 +
        };
2370 2463
        println!(
2371 -
            "{mark} {}{}{}{tail}  {head}",
2464 +
            "{mark} {}{}{}{tail}{head}",
2372 2465
            ui::bookmark(&b.name),
2373 2466
            ui::sep(),
2374 2467
            ui::dim(plural(b.heads.len(), "head"))
2375 2468
        );
2469 +
        if verbose {
2470 +
            for (h, m) in &shown {
2471 +
                print_head(&repo, "    ", h, m);
2472 +
            }
2473 +
        }
2376 2474
    }
2377 2475
    if remote_only.is_empty() {
2378 2476
        return Ok(0);
…
190 unchanged lines