Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New features

* `jj bookmark tug` finds the closest bookmark on a revision or its ancestors
and moves it forward to the latest non-empty descendant. This is useful for
advancing a bookmark after making several commits without having to manually
specify the bookmark name or target revision.

### Fixed bugs

## [0.34.0] - 2025-10-01
Expand Down
5 changes: 5 additions & 0 deletions cli/src/commands/bookmark/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

mod tug;
mod create;
mod delete;
mod forget;
Expand All @@ -32,6 +33,8 @@ use jj_lib::repo::Repo;
use jj_lib::str_util::StringPattern;
use jj_lib::view::View;

use self::tug::BookmarkTugArgs;
use self::tug::cmd_bookmark_tug;
use self::create::BookmarkCreateArgs;
use self::create::cmd_bookmark_create;
use self::delete::BookmarkDeleteArgs;
Expand Down Expand Up @@ -84,6 +87,7 @@ pub enum BookmarkCommand {
#[command(visible_alias("t"))]
Track(BookmarkTrackArgs),
Untrack(BookmarkUntrackArgs),
Tug(BookmarkTugArgs),
}

pub fn cmd_bookmark(
Expand All @@ -92,6 +96,7 @@ pub fn cmd_bookmark(
subcommand: &BookmarkCommand,
) -> Result<(), CommandError> {
match subcommand {
BookmarkCommand::Tug(args) => cmd_bookmark_tug(ui, command, args),
BookmarkCommand::Create(args) => cmd_bookmark_create(ui, command, args),
BookmarkCommand::Delete(args) => cmd_bookmark_delete(ui, command, args),
BookmarkCommand::Forget(args) => cmd_bookmark_forget(ui, command, args),
Expand Down
192 changes: 192 additions & 0 deletions cli/src/commands/bookmark/tug.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// Copyright 2020-2023 The Jujutsu Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use itertools::Itertools as _;
use jj_lib::object_id::ObjectId as _;
use jj_lib::op_store::RefTarget;
use jj_lib::repo::Repo as _;
use jj_lib::str_util::StringPattern;

use super::find_local_bookmarks;
use super::is_fast_forward;
use crate::cli_util::CommandHelper;
use crate::cli_util::RevisionArg;
use crate::command_error::CommandError;
use crate::command_error::user_error;
use crate::command_error::user_error_with_hint;
use crate::revset_util;
use crate::ui::Ui;

/// Move a bookmark to the latest non-empty descendant
///
/// Finds the closest bookmark on the specified revision or any of its
/// ancestors, then moves that bookmark forward to the topologically latest
/// non-empty descendant.
///
/// This is useful for advancing a bookmark after making several commits,
/// without having to manually specify the bookmark name or target revision.
///
/// If multiple bookmarks exist on the same commit, the alphabetically first one
/// is selected.
///
/// Example: After creating commits on top of a bookmarked commit, move the
/// bookmark forward to the latest non-empty commit:
///
/// ```shell
/// $ jj bookmark tug
/// ```
#[derive(clap::Args, Clone, Debug)]
pub struct BookmarkTugArgs {
/// Revision to start searching for bookmarks from (searches ancestors too)
#[arg(long, short, value_name = "REVSET", default_value = "@")]
from: RevisionArg,

/// Allow moving the bookmark backwards or sideways
#[arg(long, short = 'B')]
allow_backwards: bool,
}

pub fn cmd_bookmark_tug(
ui: &mut Ui,
command: &CommandHelper,
args: &BookmarkTugArgs,
) -> Result<(), CommandError> {
let mut workspace_command = command.workspace_helper(ui)?;
let repo = workspace_command.repo();
let from_commit = workspace_command.resolve_single_rev(ui, &args.from)?;

let (bookmark_name, bookmark_commit) = {
let ancestors_expression = workspace_command.parse_revset(
ui,
&RevisionArg::from(format!("::{}", from_commit.id().hex())),
)?;
let store = repo.store();
let ancestors = ancestors_expression.evaluate()?;

let ancestor_ids: Vec<_> = ancestors.iter().try_collect()?;

let pattern = StringPattern::everything();
let all_bookmarks: Vec<_> = repo.view().local_bookmarks_matching(&pattern).collect();

let mut bookmark_on_ancestor = None;
for ancestor_id in &ancestor_ids {
let mut bookmarks_here: Vec<_> = all_bookmarks
.iter()
.filter(|(_, target)| target.added_ids().any(|id| id == ancestor_id))
.collect();

if !bookmarks_here.is_empty() {
bookmarks_here.sort_by_key(|(name, _)| *name);

if bookmarks_here.len() > 1 {
writeln!(
ui.warning_default(),
"Multiple bookmarks found on revision {}: {}",
ancestor_id.hex(),
bookmarks_here
.iter()
.map(|(name, _)| name.as_symbol())
.join(", ")
)?;
writeln!(
ui.hint_default(),
"Using bookmark: {}",
bookmarks_here[0].0.as_symbol()
)?;
}

let (name, _) = bookmarks_here[0];
let name_string = name.as_symbol().to_string();
bookmark_on_ancestor = Some((name_string, store.get_commit(ancestor_id)?));
break;
}
}

bookmark_on_ancestor.ok_or_else(|| {
user_error(format!(
"No bookmarks found on {} or its ancestors",
from_commit.id().hex()
))
})?
};

let target_commit = {
let revset_expression = workspace_command.parse_revset(
ui,
&RevisionArg::from(format!("heads({}+ & ~empty())", bookmark_commit.id().hex())),
)?;
let store = repo.store();
let descendants = revset_expression.evaluate()?;

let commit_ids: Vec<_> = descendants.iter().try_collect()?;

commit_ids
.into_iter()
.map(|id| store.get_commit(&id))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.next()
.ok_or_else(|| {
user_error(format!(
"No non-empty descendants found for revision {}",
bookmark_commit.id().hex()
))
})?
};

if !args.allow_backwards {
let matches =
find_local_bookmarks(repo.view(), &[StringPattern::exact(bookmark_name.clone())])?;
if let Some((name, _old_target)) = matches
.into_iter()
.find(|(_, old_target)| !is_fast_forward(repo.as_ref(), old_target, target_commit.id()))
{
return Err(user_error_with_hint(
format!(
"Refusing to move bookmark backwards or sideways: {}",
name.as_symbol()
),
"Use --allow-backwards to allow it.",
));
}
}

let bookmark_ref_name = revset_util::parse_bookmark_name(&bookmark_name).map_err(|e| {
user_error(format!(
"Failed to parse bookmark name '{bookmark_name}': {e}"
))
})?;

let mut tx = workspace_command.start_transaction();
tx.repo_mut().set_local_bookmark_target(
&bookmark_ref_name,
RefTarget::normal(target_commit.id().clone()),
);

if let Some(mut formatter) = ui.status_formatter() {
write!(formatter, "Moved bookmark {bookmark_name} to ")?;
tx.write_commit_summary(formatter.as_mut(), &target_commit)?;
writeln!(formatter)?;
}

tx.finish(
ui,
format!(
"point bookmark {} to commit {}",
bookmark_name,
target_commit.id().hex()
),
)?;
Ok(())
}
30 changes: 30 additions & 0 deletions cli/tests/[email protected]
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
source: cli/tests/test_generate_md_cli_help.rs
assertion_line: 45
description: "AUTO-GENERATED FILE, DO NOT EDIT. This cli reference is generated by a test as an `insta` snapshot. MkDocs includes this snapshot from docs/cli-reference.md."
---
<!-- BEGIN MARKDOWN-->
Expand All @@ -25,6 +26,7 @@ This document contains the help content for the `jj` command-line program.
* [`jj bookmark set`↴](#jj-bookmark-set)
* [`jj bookmark track`↴](#jj-bookmark-track)
* [`jj bookmark untrack`↴](#jj-bookmark-untrack)
* [`jj bookmark tug`↴](#jj-bookmark-tug)
* [`jj commit`↴](#jj-commit)
* [`jj config`↴](#jj-config)
* [`jj config edit`↴](#jj-config-edit)
Expand Down Expand Up @@ -336,6 +338,7 @@ See [`jj help -k bookmarks`] for more information.
* `set` — Create or update a bookmark to point to a certain commit
* `track` — Start tracking given remote bookmarks
* `untrack` — Stop tracking given remote bookmarks
* `tug` — Move a bookmark to the latest non-empty descendant



Expand Down Expand Up @@ -578,6 +581,33 @@ If you want to forget a local bookmark while also untracking the corresponding r



## `jj bookmark tug`

Move a bookmark to the latest non-empty descendant

Finds the closest bookmark on the specified revision or any of its ancestors, then moves that bookmark forward to the topologically latest non-empty descendant.

This is useful for advancing a bookmark after making several commits, without having to manually specify the bookmark name or target revision.

If multiple bookmarks exist on the same commit, the alphabetically first one is selected.

Example: After creating commits on top of a bookmarked commit, move the bookmark forward to the latest non-empty commit:

```shell $ jj bookmark tug ```

**Usage:** `jj bookmark tug [OPTIONS]`

**Command Alias:** `b`

###### **Options:**

* `-f`, `--from <REVSET>` — Revision to start searching for bookmarks from (searches ancestors too)

Default value: `@`
* `-B`, `--allow-backwards` — Allow moving the bookmark backwards or sideways



## `jj commit`

Update the description and create a new change on top [default alias: ci]
Expand Down
Loading
Loading