Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

cli: branch: add "move" command that can update branches by revset or name #3895

Merged
merged 2 commits into from
Jun 18, 2024
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* `jj file` now groups commands for working with files.

* New command `jj branch move` let you update branches by name pattern or source
revision.

### Fixed bugs

## [0.18.0] - 2024-06-05
Expand Down
137 changes: 129 additions & 8 deletions cli/src/commands/branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use std::io::Write as _;

use clap::builder::NonEmptyStringValueParser;
use itertools::Itertools;
use jj_lib::backend::CommitId;
use jj_lib::git;
use jj_lib::object_id::ObjectId;
use jj_lib::op_store::{RefTarget, RemoteRef};
Expand Down Expand Up @@ -45,6 +46,8 @@ pub enum BranchCommand {
Forget(BranchForgetArgs),
#[command(visible_alias("l"))]
List(BranchListArgs),
#[command(visible_alias("m"))]
Move(BranchMoveArgs),
#[command(visible_alias("r"))]
Rename(BranchRenameArgs),
#[command(visible_alias("s"))]
Expand Down Expand Up @@ -182,6 +185,41 @@ pub struct BranchSetArgs {
pub names: Vec<String>,
}

/// Move existing branches to target revision
///
/// If branch names are given, the specified branches will be updated to point
/// to the target revision.
///
/// If `--from` options are given, branches currently pointing to the specified
/// revisions will be updated. The branches can also be filtered by names.
///
/// Example: pull up the nearest branches to the working-copy parent
///
/// $ jj branch move --from 'heads(::@- & branches())' --to @-
#[derive(clap::Args, Clone, Debug)]
#[command(group(clap::ArgGroup::new("source").multiple(true).required(true)))]
pub struct BranchMoveArgs {
/// Move branches from the given revisions
#[arg(long, group = "source", value_name = "REVISIONS")]
from: Vec<RevisionArg>,

/// Move branches to this revision
#[arg(long, default_value = "@", value_name = "REVISION")]
to: RevisionArg,

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

/// Move branches matching the given name patterns
///
/// By default, the specified name matches exactly. Use `glob:` prefix to
/// select branches by wildcard pattern. For details, see
/// https://github.com/martinvonz/jj/blob/main/docs/revsets.md#string-patterns.
#[arg(group = "source", value_parser = StringPattern::parse)]
names: Vec<StringPattern>,
}

/// Start tracking given remote branches
///
/// A tracking remote branch will be imported as a local branch of the same
Expand Down Expand Up @@ -233,6 +271,7 @@ pub fn cmd_branch(
BranchCommand::Create(sub_args) => cmd_branch_create(ui, command, sub_args),
BranchCommand::Rename(sub_args) => cmd_branch_rename(ui, command, sub_args),
BranchCommand::Set(sub_args) => cmd_branch_set(ui, command, sub_args),
BranchCommand::Move(sub_args) => cmd_branch_move(ui, command, sub_args),
BranchCommand::Delete(sub_args) => cmd_branch_delete(ui, command, sub_args),
BranchCommand::Forget(sub_args) => cmd_branch_forget(ui, command, sub_args),
BranchCommand::Track(sub_args) => cmd_branch_track(ui, command, sub_args),
Expand Down Expand Up @@ -349,13 +388,6 @@ fn cmd_branch_set(
let target_commit =
workspace_command.resolve_single_rev(args.revision.as_ref().unwrap_or(&RevisionArg::AT))?;
let repo = workspace_command.repo().as_ref();
let is_fast_forward = |old_target: &RefTarget| {
// Strictly speaking, "all" old targets should be ancestors, but we allow
// conflict resolution by setting branch to "any" of the old target descendants.
old_target
.added_ids()
.any(|old| repo.index().is_ancestor(old, target_commit.id()))
};
let branch_names = &args.names;
for name in branch_names {
let old_target = repo.view().get_local_branch(name);
Expand All @@ -365,7 +397,7 @@ fn cmd_branch_set(
"Use `jj branch create` to create it.",
));
}
if !args.allow_backwards && !is_fast_forward(old_target) {
if !args.allow_backwards && !is_fast_forward(repo, old_target, target_commit.id()) {
return Err(user_error_with_hint(
format!("Refusing to move branch backwards or sideways: {name}"),
"Use --allow-backwards to allow it.",
Expand Down Expand Up @@ -397,6 +429,83 @@ fn cmd_branch_set(
Ok(())
}

fn cmd_branch_move(
ui: &mut Ui,
command: &CommandHelper,
args: &BranchMoveArgs,
) -> Result<(), CommandError> {
let mut workspace_command = command.workspace_helper(ui)?;
let repo = workspace_command.repo().as_ref();
let view = repo.view();

let branch_names = {
let is_source_commit = if !args.from.is_empty() {
workspace_command
.parse_union_revsets(&args.from)?
.evaluate()?
.containing_fn()
} else {
Box::new(|_: &CommitId| true)
};
if !args.names.is_empty() {
find_branches_with(&args.names, |pattern| {
view.local_branches_matching(pattern)
.filter(|(_, target)| target.added_ids().any(&is_source_commit))
.map(|(name, _)| name.to_owned())
})?
} else {
view.local_branches()
.filter(|(_, target)| target.added_ids().any(&is_source_commit))
.map(|(name, _)| name.to_owned())
.collect()
}
};
let target_commit = workspace_command.resolve_single_rev(&args.to)?;

if branch_names.is_empty() {
writeln!(ui.status(), "No branches to update.")?;
return Ok(());
}
if branch_names.len() > 1 {
writeln!(
ui.warning_default(),
"Updating multiple branches: {}",
yuja marked this conversation as resolved.
Show resolved Hide resolved
branch_names.join(", "),
)?;
if args.names.is_empty() {
writeln!(ui.hint_default(), "Specify branch by name to update one.")?;
}
}

if !args.allow_backwards {
if let Some(name) = branch_names
.iter()
.find(|name| !is_fast_forward(repo, view.get_local_branch(name), target_commit.id()))
{
return Err(user_error_with_hint(
format!("Refusing to move branch backwards or sideways: {name}"),
"Use --allow-backwards to allow it.",
));
}
}

let mut tx = workspace_command.start_transaction();
for name in &branch_names {
tx.mut_repo()
.set_local_branch_target(name, RefTarget::normal(target_commit.id().clone()));
}
tx.finish(
ui,
format!(
"point {} to commit {}",
make_branch_term(&branch_names),
target_commit.id().hex()
),
)?;

Ok(())
}

fn find_local_branches(
view: &View,
name_patterns: &[StringPattern],
Expand Down Expand Up @@ -783,3 +892,15 @@ fn cmd_branch_list(

Ok(())
}

fn is_fast_forward(repo: &dyn Repo, old_target: &RefTarget, new_target_id: &CommitId) -> bool {
if old_target.is_present() {
// Strictly speaking, "all" old targets should be ancestors, but we allow
// conflict resolution by setting branch to "any" of the old target descendants.
old_target
.added_ids()
.any(|old| repo.index().is_ancestor(old, new_target_id))
} else {
true
}
}
32 changes: 32 additions & 0 deletions cli/tests/[email protected]
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ This document contains the help content for the `jj` command-line program.
* [`jj branch delete`↴](#jj-branch-delete)
* [`jj branch forget`↴](#jj-branch-forget)
* [`jj branch list`↴](#jj-branch-list)
* [`jj branch move`↴](#jj-branch-move)
* [`jj branch rename`↴](#jj-branch-rename)
* [`jj branch set`↴](#jj-branch-set)
* [`jj branch track`↴](#jj-branch-track)
Expand Down Expand Up @@ -236,6 +237,7 @@ For information about branches, see https://github.com/martinvonz/jj/blob/main/d
* `delete` — Delete an existing branch and propagate the deletion to remotes on the next push
* `forget` — Forget everything about a branch, including its local and remote targets
* `list` — List branches and their targets
* `move` — Move existing branches to target revision
* `rename` — Rename `old` branch name to `new` branch name
* `set` — Update an existing branch to point to a certain commit
* `track` — Start tracking given remote branches
Expand Down Expand Up @@ -321,6 +323,36 @@ For information about branches, see https://github.com/martinvonz/jj/blob/main/d



## `jj branch move`

Move existing branches to target revision

If branch names are given, the specified branches will be updated to point to the target revision.

If `--from` options are given, branches currently pointing to the specified revisions will be updated. The branches can also be filtered by names.

Example: pull up the nearest branches to the working-copy parent

$ jj branch move --from 'heads(::@- & branches())' --to @-

**Usage:** `jj branch move [OPTIONS] <--from <REVISIONS>|NAMES>`

###### **Arguments:**

* `<NAMES>` — Move branches matching the given name patterns

By default, the specified name matches exactly. Use `glob:` prefix to select branches by wildcard pattern. For details, see https://github.com/martinvonz/jj/blob/main/docs/revsets.md#string-patterns.

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

* `--from <REVISIONS>` — Move branches from the given revisions
* `--to <REVISION>` — Move branches to this revision

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



## `jj branch rename`

Rename `old` branch name to `new` branch name.
Expand Down
122 changes: 122 additions & 0 deletions cli/tests/test_branch_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ fn test_branch_move() {
Error: No such branch: foo
Hint: Use `jj branch create` to create it.
"###);
let stderr = test_env.jj_cmd_failure(&repo_path, &["branch", "move", "foo"]);
insta::assert_snapshot!(stderr, @r###"
Error: No such branch: foo
"###);

let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["branch", "create", "foo"]);
insta::assert_snapshot!(stderr, @"");
Expand All @@ -126,6 +130,124 @@ fn test_branch_move() {
&["branch", "set", "-r@-", "--allow-backwards", "foo"],
);
insta::assert_snapshot!(stderr, @"");

let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["branch", "move", "foo"]);
insta::assert_snapshot!(stderr, @"");

let stderr = test_env.jj_cmd_failure(&repo_path, &["branch", "move", "--to=@-", "foo"]);
insta::assert_snapshot!(stderr, @r###"
Error: Refusing to move branch backwards or sideways: foo
Hint: Use --allow-backwards to allow it.
"###);

let (_stdout, stderr) = test_env.jj_cmd_ok(
&repo_path,
&["branch", "move", "--to=@-", "--allow-backwards", "foo"],
);
insta::assert_snapshot!(stderr, @"");
}

#[test]
fn test_branch_move_matching() {
let test_env = TestEnvironment::default();
test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]);
let repo_path = test_env.env_root().join("repo");

test_env.jj_cmd_ok(&repo_path, &["branch", "create", "a1", "a2"]);
test_env.jj_cmd_ok(&repo_path, &["new", "-mhead1"]);
test_env.jj_cmd_ok(&repo_path, &["new", "root()"]);
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "b1"]);
test_env.jj_cmd_ok(&repo_path, &["new"]);
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "c1"]);
test_env.jj_cmd_ok(&repo_path, &["new", "-mhead2"]);
insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###"
@ a2781dd9ee37
◉ c1 f4f38657a3dd
◉ b1 f652c32197cf
│ ◉ 6b5e840ea72b
│ ◉ a1 a2 230dd059e1b0
├─╯
◉ 000000000000
"###);

// The default could be considered "--from=all() glob:*", but is disabled
let stderr = test_env.jj_cmd_cli_error(&repo_path, &["branch", "move"]);
insta::assert_snapshot!(stderr, @r###"
error: the following required arguments were not provided:
<--from <REVISIONS>|NAMES>

Usage: jj branch move <--from <REVISIONS>|NAMES>

For more information, try '--help'.
"###);

// No branches pointing to the source revisions
let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["branch", "move", "--from=none()"]);
insta::assert_snapshot!(stderr, @r###"
No branches to update.
"###);

// No matching branches within the source revisions
let stderr = test_env.jj_cmd_failure(&repo_path, &["branch", "move", "--from=::@", "glob:a?"]);
insta::assert_snapshot!(stderr, @r###"
Error: No matching branches for patterns: a?
"###);

// Noop move
let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["branch", "move", "--to=a1", "a2"]);
insta::assert_snapshot!(stderr, @r###"
Nothing changed.
"###);

// Move from multiple revisions
let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["branch", "move", "--from=::@"]);
insta::assert_snapshot!(stderr, @r###"
Warning: Updating multiple branches: b1, c1
Hint: Specify branch by name to update one.
"###);
insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###"
@ b1 c1 a2781dd9ee37
◉ f4f38657a3dd
◉ f652c32197cf
│ ◉ 6b5e840ea72b
│ ◉ a1 a2 230dd059e1b0
├─╯
◉ 000000000000
"###);
test_env.jj_cmd_ok(&repo_path, &["undo"]);

// Try to move multiple branches, but one of them isn't fast-forward
let stderr = test_env.jj_cmd_failure(&repo_path, &["branch", "move", "glob:?1"]);
insta::assert_snapshot!(stderr, @r###"
Warning: Updating multiple branches: a1, b1, c1
Error: Refusing to move branch backwards or sideways: a1
Hint: Use --allow-backwards to allow it.
"###);
insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###"
@ a2781dd9ee37
◉ c1 f4f38657a3dd
◉ b1 f652c32197cf
│ ◉ 6b5e840ea72b
│ ◉ a1 a2 230dd059e1b0
├─╯
◉ 000000000000
"###);

// Select by revision and name
let (_stdout, stderr) = test_env.jj_cmd_ok(
&repo_path,
&["branch", "move", "--from=::a1+", "--to=a1+", "glob:?1"],
);
insta::assert_snapshot!(stderr, @"");
insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###"
@ a2781dd9ee37
◉ c1 f4f38657a3dd
◉ b1 f652c32197cf
│ ◉ a1 6b5e840ea72b
│ ◉ a2 230dd059e1b0
├─╯
◉ 000000000000
"###);
}

#[test]
Expand Down