Skip to content

Commit 1f52eca

Browse files
committed
feat(bookmark): add bump command to advance bookmarks to latest descendants
Enables automatic bookmark advancement without manual targeting, streamlining workflow after making several commits
1 parent 22900c9 commit 1f52eca

File tree

3 files changed

+362
-0
lines changed

3 files changed

+362
-0
lines changed

cli/src/commands/bookmark/bump.rs

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// Copyright 2020-2023 The Jujutsu Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
use itertools::Itertools as _;
16+
use jj_lib::object_id::ObjectId as _;
17+
use jj_lib::op_store::RefTarget;
18+
use jj_lib::repo::Repo as _;
19+
use jj_lib::str_util::StringPattern;
20+
21+
use super::find_local_bookmarks;
22+
use super::is_fast_forward;
23+
use crate::cli_util::CommandHelper;
24+
use crate::cli_util::RevisionArg;
25+
use crate::command_error::CommandError;
26+
use crate::command_error::user_error;
27+
use crate::command_error::user_error_with_hint;
28+
use crate::revset_util;
29+
use crate::ui::Ui;
30+
31+
/// Move a bookmark to the latest non-empty descendant
32+
///
33+
/// Finds the closest bookmark on the specified revision or any of its ancestors,
34+
/// then moves that bookmark forward to the topologically latest non-empty descendant.
35+
///
36+
/// This is useful for advancing a bookmark after making several commits, without
37+
/// having to manually specify the bookmark name or target revision.
38+
///
39+
/// If multiple bookmarks exist on the same commit, the alphabetically first one
40+
/// is selected.
41+
///
42+
/// Example: After creating commits on top of a bookmarked commit, move the bookmark
43+
/// forward to the latest non-empty commit:
44+
///
45+
/// ```shell
46+
/// $ jj bookmark bump
47+
/// ```
48+
#[derive(clap::Args, Clone, Debug)]
49+
pub struct BookmarkBumpArgs {
50+
/// Revision to start searching for bookmarks from (searches ancestors too)
51+
#[arg(
52+
long, short,
53+
value_name = "REVSET",
54+
default_value = "@"
55+
)]
56+
from: RevisionArg,
57+
58+
/// Allow moving the bookmark backwards or sideways
59+
#[arg(long, short = 'B')]
60+
allow_backwards: bool,
61+
}
62+
63+
pub fn cmd_bookmark_bump(
64+
ui: &mut Ui,
65+
command: &CommandHelper,
66+
args: &BookmarkBumpArgs,
67+
) -> Result<(), CommandError> {
68+
let mut workspace_command = command.workspace_helper(ui)?;
69+
let repo = workspace_command.repo();
70+
let from_commit = workspace_command.resolve_single_rev(ui, &args.from)?;
71+
72+
let (bookmark_name, bookmark_commit) = {
73+
let ancestors_expression = workspace_command
74+
.parse_revset(ui, &RevisionArg::from(format!("::{}", from_commit.id().hex())))?;
75+
let store = repo.store();
76+
let ancestors = ancestors_expression.evaluate()?;
77+
78+
let ancestor_ids: Vec<_> = ancestors
79+
.iter()
80+
.try_collect()?;
81+
82+
let pattern = StringPattern::everything();
83+
let all_bookmarks: Vec<_> = repo
84+
.view()
85+
.local_bookmarks_matching(&pattern)
86+
.collect();
87+
88+
let mut bookmark_on_ancestor = None;
89+
for ancestor_id in &ancestor_ids {
90+
let mut bookmarks_here: Vec<_> = all_bookmarks
91+
.iter()
92+
.filter(|(_, target)| {
93+
target
94+
.added_ids()
95+
.any(|id| id == ancestor_id)
96+
})
97+
.collect();
98+
99+
if !bookmarks_here.is_empty() {
100+
bookmarks_here.sort_by_key(|(name, _)| *name);
101+
102+
if bookmarks_here.len() > 1 {
103+
writeln!(
104+
ui.warning_default(),
105+
"Multiple bookmarks found on revision {}: {}",
106+
ancestor_id.hex(),
107+
bookmarks_here
108+
.iter()
109+
.map(|(name, _)| name.as_symbol())
110+
.join(", ")
111+
)?;
112+
writeln!(
113+
ui.hint_default(),
114+
"Using bookmark: {}",
115+
bookmarks_here[0].0.as_symbol()
116+
)?;
117+
}
118+
119+
let (name, _) = bookmarks_here[0];
120+
let name_string = name.as_symbol().to_string();
121+
bookmark_on_ancestor = Some((name_string, store.get_commit(ancestor_id)?));
122+
break;
123+
}
124+
}
125+
126+
bookmark_on_ancestor.ok_or_else(|| {
127+
user_error(format!(
128+
"No bookmarks found on {} or its ancestors",
129+
from_commit.id().hex()
130+
))
131+
})?
132+
};
133+
134+
let target_commit = {
135+
let revset_expression = workspace_command
136+
.parse_revset(ui, &RevisionArg::from(format!("heads({}+ & ~empty())", bookmark_commit.id().hex())))?;
137+
let store = repo.store();
138+
let descendants = revset_expression.evaluate()?;
139+
140+
let commit_ids: Vec<_> = descendants
141+
.iter()
142+
.try_collect()?;
143+
144+
commit_ids
145+
.into_iter()
146+
.map(|id| store.get_commit(&id))
147+
.collect::<Result<Vec<_>, _>>()?
148+
.into_iter()
149+
.next()
150+
.ok_or_else(|| {
151+
user_error(format!(
152+
"No non-empty descendants found for revision {}",
153+
bookmark_commit.id().hex()
154+
))
155+
})?
156+
};
157+
158+
if !args.allow_backwards {
159+
let matches = find_local_bookmarks(repo.view(), &[StringPattern::exact(bookmark_name.clone())])?;
160+
if let Some((name, _old_target)) = matches
161+
.into_iter()
162+
.find(|(_, old_target)| {
163+
!is_fast_forward(repo.as_ref(), old_target, target_commit.id())
164+
})
165+
{
166+
return Err(user_error_with_hint(
167+
format!(
168+
"Refusing to move bookmark backwards or sideways: {}",
169+
name.as_symbol()
170+
),
171+
"Use --allow-backwards to allow it.",
172+
));
173+
}
174+
}
175+
176+
let bookmark_ref_name = revset_util::parse_bookmark_name(&bookmark_name)
177+
.map_err(|e| user_error(format!("Failed to parse bookmark name '{}': {}", bookmark_name, e)))?;
178+
179+
let mut tx = workspace_command.start_transaction();
180+
tx.repo_mut()
181+
.set_local_bookmark_target(&bookmark_ref_name, RefTarget::normal(target_commit.id().clone()));
182+
183+
if let Some(mut formatter) = ui.status_formatter() {
184+
write!(formatter, "Moved bookmark {} to ", bookmark_name)?;
185+
tx.write_commit_summary(formatter.as_mut(), &target_commit)?;
186+
writeln!(formatter)?;
187+
}
188+
189+
tx.finish(
190+
ui,
191+
format!(
192+
"point bookmark {} to commit {}",
193+
bookmark_name,
194+
target_commit.id().hex()
195+
),
196+
)?;
197+
Ok(())
198+
}

cli/src/commands/bookmark/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
mod bump;
1516
mod create;
1617
mod delete;
1718
mod forget;
@@ -32,6 +33,8 @@ use jj_lib::repo::Repo;
3233
use jj_lib::str_util::StringPattern;
3334
use jj_lib::view::View;
3435

36+
use self::bump::BookmarkBumpArgs;
37+
use self::bump::cmd_bookmark_bump;
3538
use self::create::BookmarkCreateArgs;
3639
use self::create::cmd_bookmark_create;
3740
use self::delete::BookmarkDeleteArgs;
@@ -84,6 +87,8 @@ pub enum BookmarkCommand {
8487
#[command(visible_alias("t"))]
8588
Track(BookmarkTrackArgs),
8689
Untrack(BookmarkUntrackArgs),
90+
#[command(visible_alias("b"))]
91+
Bump(BookmarkBumpArgs),
8792
}
8893

8994
pub fn cmd_bookmark(
@@ -92,6 +97,7 @@ pub fn cmd_bookmark(
9297
subcommand: &BookmarkCommand,
9398
) -> Result<(), CommandError> {
9499
match subcommand {
100+
BookmarkCommand::Bump(args) => cmd_bookmark_bump(ui, command, args),
95101
BookmarkCommand::Create(args) => cmd_bookmark_create(ui, command, args),
96102
BookmarkCommand::Delete(args) => cmd_bookmark_delete(ui, command, args),
97103
BookmarkCommand::Forget(args) => cmd_bookmark_forget(ui, command, args),

cli/tests/test_bookmark_command.rs

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2358,3 +2358,161 @@ fn get_bookmark_output(work_dir: &TestWorkDir) -> CommandOutput {
23582358
// --quiet to suppress deleted bookmarks hint
23592359
work_dir.run_jj(["bookmark", "list", "--all-remotes", "--quiet"])
23602360
}
2361+
2362+
#[test]
2363+
fn test_bookmark_bump() {
2364+
let test_env = TestEnvironment::default();
2365+
test_env.run_jj_in(".", ["git", "init", "repo"]).success();
2366+
let work_dir = test_env.work_dir("repo");
2367+
2368+
std::fs::write(work_dir.root().join("file1"), "content1").unwrap();
2369+
work_dir.run_jj(["describe", "-m=first"]).success();
2370+
work_dir.run_jj(["bookmark", "create", "foo"]).success();
2371+
work_dir.run_jj(["new", "-m=second"]).success();
2372+
std::fs::write(work_dir.root().join("file2"), "content2").unwrap();
2373+
work_dir.run_jj(["new", "-m=third"]).success();
2374+
std::fs::write(work_dir.root().join("file3"), "content3").unwrap();
2375+
2376+
let output = work_dir.run_jj(["bookmark", "bump", "--from=description(first)"]);
2377+
insta::assert_snapshot!(output, @r###"
2378+
------- stderr -------
2379+
Moved bookmark foo to zsuskuln 22dbdd9a foo | second
2380+
[EOF]
2381+
"###);
2382+
}
2383+
2384+
#[test]
2385+
fn test_bookmark_bump_no_bookmark() {
2386+
let test_env = TestEnvironment::default();
2387+
test_env.run_jj_in(".", ["git", "init", "repo"]).success();
2388+
let work_dir = test_env.work_dir("repo");
2389+
2390+
work_dir.run_jj(["describe", "-m=first"]).success();
2391+
2392+
let output = work_dir.run_jj(["bookmark", "bump", "--from=@"]);
2393+
insta::assert_snapshot!(output, @r###"
2394+
------- stderr -------
2395+
Error: No bookmarks found on 68a505386f936fff6d718f55005e77ea72589bc1 or its ancestors
2396+
[EOF]
2397+
[exit status: 1]
2398+
"###);
2399+
}
2400+
2401+
#[test]
2402+
fn test_bookmark_bump_no_descendants() {
2403+
let test_env = TestEnvironment::default();
2404+
test_env.run_jj_in(".", ["git", "init", "repo"]).success();
2405+
let work_dir = test_env.work_dir("repo");
2406+
2407+
work_dir.run_jj(["describe", "-m=first"]).success();
2408+
work_dir.run_jj(["bookmark", "create", "foo"]).success();
2409+
2410+
let output = work_dir.run_jj(["bookmark", "bump"]);
2411+
insta::assert_snapshot!(output, @r###"
2412+
------- stderr -------
2413+
Error: No non-empty descendants found for revision 68a505386f936fff6d718f55005e77ea72589bc1
2414+
[EOF]
2415+
[exit status: 1]
2416+
"###);
2417+
}
2418+
2419+
#[test]
2420+
fn test_bookmark_bump_multiple_bookmarks() {
2421+
let test_env = TestEnvironment::default();
2422+
test_env.run_jj_in(".", ["git", "init", "repo"]).success();
2423+
let work_dir = test_env.work_dir("repo");
2424+
2425+
std::fs::write(work_dir.root().join("file1"), "content1").unwrap();
2426+
work_dir.run_jj(["describe", "-m=first"]).success();
2427+
work_dir
2428+
.run_jj(["bookmark", "create", "foo", "bar"])
2429+
.success();
2430+
work_dir.run_jj(["new", "-m=second"]).success();
2431+
std::fs::write(work_dir.root().join("file2"), "content2").unwrap();
2432+
2433+
let output = work_dir.run_jj(["bookmark", "bump", "--from=description(first)"]);
2434+
insta::assert_snapshot!(output, @r###"
2435+
------- stderr -------
2436+
Warning: Multiple bookmarks found on revision c38d5fac53d0539d9caa10495b207732ef170052: bar, foo
2437+
Hint: Using bookmark: bar
2438+
Moved bookmark bar to zsuskuln 22dbdd9a bar | second
2439+
[EOF]
2440+
"###);
2441+
}
2442+
2443+
#[test]
2444+
fn test_bookmark_bump_backwards() {
2445+
let test_env = TestEnvironment::default();
2446+
test_env.run_jj_in(".", ["git", "init", "repo"]).success();
2447+
let work_dir = test_env.work_dir("repo");
2448+
2449+
std::fs::write(work_dir.root().join("file1"), "content1").unwrap();
2450+
work_dir.run_jj(["describe", "-m=first"]).success();
2451+
work_dir.run_jj(["new", "-m=second"]).success();
2452+
std::fs::write(work_dir.root().join("file2"), "content2").unwrap();
2453+
work_dir.run_jj(["bookmark", "create", "foo"]).success();
2454+
work_dir.run_jj(["new", "root()", "-m=third"]).success();
2455+
std::fs::write(work_dir.root().join("file3"), "content3").unwrap();
2456+
2457+
let output = work_dir.run_jj(["bookmark", "bump", "--from=description(second)"]);
2458+
insta::assert_snapshot!(output, @r###"
2459+
------- stderr -------
2460+
Error: No non-empty descendants found for revision 63351e1b9362cbf9d47547d2e87a5e3bb8bcab3c
2461+
[EOF]
2462+
[exit status: 1]
2463+
"###);
2464+
2465+
let output = work_dir.run_jj(["bookmark", "bump", "--from=description(second)", "--allow-backwards"]);
2466+
insta::assert_snapshot!(output, @r###"
2467+
------- stderr -------
2468+
Error: No non-empty descendants found for revision 63351e1b9362cbf9d47547d2e87a5e3bb8bcab3c
2469+
[EOF]
2470+
[exit status: 1]
2471+
"###);
2472+
}
2473+
2474+
#[test]
2475+
fn test_bookmark_bump_finds_ancestor_bookmark() {
2476+
let test_env = TestEnvironment::default();
2477+
test_env.run_jj_in(".", ["git", "init", "repo"]).success();
2478+
let work_dir = test_env.work_dir("repo");
2479+
2480+
std::fs::write(work_dir.root().join("file1"), "content1").unwrap();
2481+
work_dir.run_jj(["describe", "-m=first"]).success();
2482+
work_dir.run_jj(["bookmark", "create", "foo"]).success();
2483+
work_dir.run_jj(["new", "-m=second"]).success();
2484+
std::fs::write(work_dir.root().join("file2"), "content2").unwrap();
2485+
work_dir.run_jj(["new", "-m=third"]).success();
2486+
std::fs::write(work_dir.root().join("file3"), "content3").unwrap();
2487+
2488+
let output = work_dir.run_jj(["bookmark", "bump"]);
2489+
insta::assert_snapshot!(output, @r###"
2490+
------- stderr -------
2491+
Moved bookmark foo to zsuskuln 22dbdd9a foo | second
2492+
[EOF]
2493+
"###);
2494+
}
2495+
2496+
#[test]
2497+
fn test_bookmark_bump_from_parent_with_bookmark() {
2498+
let test_env = TestEnvironment::default();
2499+
test_env.run_jj_in(".", ["git", "init", "repo"]).success();
2500+
let work_dir = test_env.work_dir("repo");
2501+
2502+
std::fs::write(work_dir.root().join("file1"), "content1").unwrap();
2503+
work_dir.run_jj(["describe", "-m=base"]).success();
2504+
work_dir.run_jj(["new", "-m=parent"]).success();
2505+
std::fs::write(work_dir.root().join("file2"), "content2").unwrap();
2506+
work_dir.run_jj(["bookmark", "create", "feature"]).success();
2507+
work_dir.run_jj(["new", "-m=child1"]).success();
2508+
std::fs::write(work_dir.root().join("file3"), "content3").unwrap();
2509+
work_dir.run_jj(["new", "-m=child2"]).success();
2510+
std::fs::write(work_dir.root().join("file4"), "content4").unwrap();
2511+
2512+
let output = work_dir.run_jj(["bookmark", "bump", "--from=@"]);
2513+
insta::assert_snapshot!(output, @r###"
2514+
------- stderr -------
2515+
Moved bookmark feature to mzvwutvl 48fcc57d feature | child1
2516+
[EOF]
2517+
"###);
2518+
}

0 commit comments

Comments
 (0)