diff --git a/CHANGELOG.md b/CHANGELOG.md index e441901d400..0d172aaa869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cli/src/commands/bookmark/mod.rs b/cli/src/commands/bookmark/mod.rs index 8288e9da5b0..c30d0b103a9 100644 --- a/cli/src/commands/bookmark/mod.rs +++ b/cli/src/commands/bookmark/mod.rs @@ -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; @@ -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; @@ -84,6 +87,7 @@ pub enum BookmarkCommand { #[command(visible_alias("t"))] Track(BookmarkTrackArgs), Untrack(BookmarkUntrackArgs), + Tug(BookmarkTugArgs), } pub fn cmd_bookmark( @@ -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), diff --git a/cli/src/commands/bookmark/tug.rs b/cli/src/commands/bookmark/tug.rs new file mode 100644 index 00000000000..0d092bfc476 --- /dev/null +++ b/cli/src/commands/bookmark/tug.rs @@ -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::, _>>()? + .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(()) +} diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index bde5525ae9b..767a6d36fac 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -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." --- @@ -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) @@ -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 @@ -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 ` — 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] diff --git a/cli/tests/test_bookmark_command.rs b/cli/tests/test_bookmark_command.rs index dd16f6d5e8e..4c12b8f5a5f 100644 --- a/cli/tests/test_bookmark_command.rs +++ b/cli/tests/test_bookmark_command.rs @@ -2358,3 +2358,166 @@ fn get_bookmark_output(work_dir: &TestWorkDir) -> CommandOutput { // --quiet to suppress deleted bookmarks hint work_dir.run_jj(["bookmark", "list", "--all-remotes", "--quiet"]) } + +#[test] +fn test_bookmark_tug() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + std::fs::write(work_dir.root().join("file1"), "content1").unwrap(); + work_dir.run_jj(["describe", "-m=first"]).success(); + work_dir.run_jj(["bookmark", "create", "foo"]).success(); + work_dir.run_jj(["new", "-m=second"]).success(); + std::fs::write(work_dir.root().join("file2"), "content2").unwrap(); + work_dir.run_jj(["new", "-m=third"]).success(); + std::fs::write(work_dir.root().join("file3"), "content3").unwrap(); + + let output = work_dir.run_jj(["bookmark", "tug", "--from=description(first)"]); + insta::assert_snapshot!(output, @r###" + ------- stderr ------- + Moved bookmark foo to zsuskuln 22dbdd9a foo | second + [EOF] + "###); +} + +#[test] +fn test_bookmark_tug_no_bookmark() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + work_dir.run_jj(["describe", "-m=first"]).success(); + + let output = work_dir.run_jj(["bookmark", "tug", "--from=@"]); + insta::assert_snapshot!(output, @r###" + ------- stderr ------- + Error: No bookmarks found on 68a505386f936fff6d718f55005e77ea72589bc1 or its ancestors + [EOF] + [exit status: 1] + "###); +} + +#[test] +fn test_bookmark_tug_no_descendants() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + work_dir.run_jj(["describe", "-m=first"]).success(); + work_dir.run_jj(["bookmark", "create", "foo"]).success(); + + let output = work_dir.run_jj(["bookmark", "tug"]); + insta::assert_snapshot!(output, @r###" + ------- stderr ------- + Error: No non-empty descendants found for revision 68a505386f936fff6d718f55005e77ea72589bc1 + [EOF] + [exit status: 1] + "###); +} + +#[test] +fn test_bookmark_tug_multiple_bookmarks() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + std::fs::write(work_dir.root().join("file1"), "content1").unwrap(); + work_dir.run_jj(["describe", "-m=first"]).success(); + work_dir + .run_jj(["bookmark", "create", "foo", "bar"]) + .success(); + work_dir.run_jj(["new", "-m=second"]).success(); + std::fs::write(work_dir.root().join("file2"), "content2").unwrap(); + + let output = work_dir.run_jj(["bookmark", "tug", "--from=description(first)"]); + insta::assert_snapshot!(output, @r###" + ------- stderr ------- + Warning: Multiple bookmarks found on revision c38d5fac53d0539d9caa10495b207732ef170052: bar, foo + Hint: Using bookmark: bar + Moved bookmark bar to zsuskuln 22dbdd9a bar | second + [EOF] + "###); +} + +#[test] +fn test_bookmark_tug_backwards() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + std::fs::write(work_dir.root().join("file1"), "content1").unwrap(); + work_dir.run_jj(["describe", "-m=first"]).success(); + work_dir.run_jj(["new", "-m=second"]).success(); + std::fs::write(work_dir.root().join("file2"), "content2").unwrap(); + work_dir.run_jj(["bookmark", "create", "foo"]).success(); + work_dir.run_jj(["new", "root()", "-m=third"]).success(); + std::fs::write(work_dir.root().join("file3"), "content3").unwrap(); + + let output = work_dir.run_jj(["bookmark", "tug", "--from=description(second)"]); + insta::assert_snapshot!(output, @r###" + ------- stderr ------- + Error: No non-empty descendants found for revision 63351e1b9362cbf9d47547d2e87a5e3bb8bcab3c + [EOF] + [exit status: 1] + "###); + + let output = work_dir.run_jj([ + "bookmark", + "tug", + "--from=description(second)", + "--allow-backwards", + ]); + insta::assert_snapshot!(output, @r###" + ------- stderr ------- + Error: No non-empty descendants found for revision 63351e1b9362cbf9d47547d2e87a5e3bb8bcab3c + [EOF] + [exit status: 1] + "###); +} + +#[test] +fn test_bookmark_tug_finds_ancestor_bookmark() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + std::fs::write(work_dir.root().join("file1"), "content1").unwrap(); + work_dir.run_jj(["describe", "-m=first"]).success(); + work_dir.run_jj(["bookmark", "create", "foo"]).success(); + work_dir.run_jj(["new", "-m=second"]).success(); + std::fs::write(work_dir.root().join("file2"), "content2").unwrap(); + work_dir.run_jj(["new", "-m=third"]).success(); + std::fs::write(work_dir.root().join("file3"), "content3").unwrap(); + + let output = work_dir.run_jj(["bookmark", "tug"]); + insta::assert_snapshot!(output, @r###" + ------- stderr ------- + Moved bookmark foo to zsuskuln 22dbdd9a foo | second + [EOF] + "###); +} + +#[test] +fn test_bookmark_tug_from_parent_with_bookmark() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + std::fs::write(work_dir.root().join("file1"), "content1").unwrap(); + work_dir.run_jj(["describe", "-m=base"]).success(); + work_dir.run_jj(["new", "-m=parent"]).success(); + std::fs::write(work_dir.root().join("file2"), "content2").unwrap(); + work_dir.run_jj(["bookmark", "create", "feature"]).success(); + work_dir.run_jj(["new", "-m=child1"]).success(); + std::fs::write(work_dir.root().join("file3"), "content3").unwrap(); + work_dir.run_jj(["new", "-m=child2"]).success(); + std::fs::write(work_dir.root().join("file4"), "content4").unwrap(); + + let output = work_dir.run_jj(["bookmark", "tug", "--from=@"]); + insta::assert_snapshot!(output, @r###" + ------- stderr ------- + Moved bookmark feature to mzvwutvl 48fcc57d feature | child1 + [EOF] + "###); +}