diff --git a/CHANGELOG.md b/CHANGELOG.md index e726807e3e..cf0a62a93e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,9 +59,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `branches()`/`remote_branches()`/`author()`/`committer()`/`description()` revsets now support glob matching. -* `jj branch delete`/`forget` now support [string pattern - syntax](docs/revsets.md#string-patterns). The `--glob` option is deprecated in - favor of `glob:` pattern. +* `jj branch delete`/`forget`, and `jj git push --branch` now support [string + pattern syntax](docs/revsets.md#string-patterns). The `--glob` option is + deprecated in favor of `glob:` pattern. ### Fixed bugs diff --git a/cli/src/commands/git.rs b/cli/src/commands/git.rs index 872228f95f..3576ee6573 100644 --- a/cli/src/commands/git.rs +++ b/cli/src/commands/git.rs @@ -24,6 +24,7 @@ use jj_lib::revset::{self, RevsetExpression, RevsetIteratorExt as _}; use jj_lib::settings::{ConfigResultExt as _, UserSettings}; use jj_lib::store::Store; use jj_lib::str_util::StringPattern; +use jj_lib::view::View; use jj_lib::workspace::Workspace; use maplit::hashset; @@ -141,8 +142,12 @@ pub struct GitPushArgs { #[arg(long)] remote: Option, /// Push only this branch (can be repeated) - #[arg(long, short)] - branch: Vec, + /// + /// 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(long, short, value_parser = parse_string_pattern)] + branch: Vec, /// Push all branches (including deleted branches) #[arg(long)] all: bool, @@ -716,19 +721,11 @@ fn cmd_git_push( tx_description = format!("push all deleted branches to git remote {remote}"); } else { let mut seen_branches = hashset! {}; - for branch_name in &args.branch { - if !seen_branches.insert(branch_name.clone()) { - continue; - } - let targets = TrackingRefPair { - local_target: repo.view().get_local_branch(branch_name), - remote_ref: repo.view().get_remote_branch(branch_name, &remote), - }; + let branches_by_name = + find_branches_to_push(repo.view(), &args.branch, &remote, &mut seen_branches)?; + for (branch_name, targets) in branches_by_name { match classify_branch_update(branch_name, &remote, targets) { - Ok(Some(update)) => branch_updates.push((branch_name.clone(), update)), - Ok(None) if targets.local_target.is_absent() => { - return Err(user_error(format!("Branch {branch_name} doesn't exist"))); - } + Ok(Some(update)) => branch_updates.push((branch_name.to_owned(), update)), Ok(None) => writeln!( ui.stderr(), "Branch {branch_name}@{remote} already matches {branch_name}", @@ -1047,6 +1044,39 @@ fn classify_branch_update( } } +fn find_branches_to_push<'a>( + view: &'a View, + branch_patterns: &[StringPattern], + remote_name: &str, + seen_branches: &mut HashSet, +) -> Result)>, CommandError> { + let mut matching_branches = vec![]; + let mut unmatched_patterns = vec![]; + for pattern in branch_patterns { + let mut matches = view + .local_remote_branches_matching(pattern, remote_name) + .filter(|(_, targets)| { + // If the remote exists but is not tracking, the absent local shouldn't + // be considered a deleted branch. + targets.local_target.is_present() || targets.remote_ref.is_tracking() + }) + .peekable(); + if matches.peek().is_none() { + unmatched_patterns.push(pattern); + } + matching_branches + .extend(matches.filter(|&(name, _)| seen_branches.insert(name.to_owned()))); + } + match &unmatched_patterns[..] { + [] => Ok(matching_branches), + [pattern] if pattern.is_exact() => Err(user_error(format!("No such branch: {pattern}"))), + patterns => Err(user_error(format!( + "No matching branches for patterns: {}", + patterns.iter().join(", ") + ))), + } +} + fn cmd_git_import( ui: &mut Ui, command: &CommandHelper, diff --git a/cli/tests/test_git_push.rs b/cli/tests/test_git_push.rs index f87e6ac7d8..688dfb3568 100644 --- a/cli/tests/test_git_push.rs +++ b/cli/tests/test_git_push.rs @@ -318,7 +318,7 @@ fn test_git_push_multiple() { "-b=branch1", "-b=my-branch", "-b=branch1", - "-b=my-branch", + "-b=glob:my-*", "--dry-run", ], ); @@ -329,6 +329,32 @@ fn test_git_push_multiple() { Add branch my-branch to 15dcdaa4f12f Dry-run requested, not pushing. "###); + // Dry run with glob pattern + let (stdout, stderr) = test_env.jj_cmd_ok( + &workspace_root, + &["git", "push", "-b=glob:branch?", "--dry-run"], + ); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Branch changes to push to origin: + Delete branch branch1 from 45a3aa29e907 + Force branch branch2 from 8476341eb395 to 15dcdaa4f12f + Dry-run requested, not pushing. + "###); + + // Unmatched branch name is error + let stderr = test_env.jj_cmd_failure(&workspace_root, &["git", "push", "-b=foo"]); + insta::assert_snapshot!(stderr, @r###" + Error: No such branch: foo + "###); + let stderr = test_env.jj_cmd_failure( + &workspace_root, + &["git", "push", "-b=foo", "-b=glob:?branch"], + ); + insta::assert_snapshot!(stderr, @r###" + Error: No matching branches for patterns: foo, ?branch + "###); + let (stdout, stderr) = test_env.jj_cmd_ok(&workspace_root, &["git", "push", "--all"]); insta::assert_snapshot!(stdout, @""); insta::assert_snapshot!(stderr, @r###" @@ -740,7 +766,7 @@ fn test_git_push_deleted_untracked() { "###); let stderr = test_env.jj_cmd_failure(&workspace_root, &["git", "push", "--branch=branch1"]); insta::assert_snapshot!(stderr, @r###" - Error: Branch branch1 doesn't exist + Error: No such branch: branch1 "###); } diff --git a/lib/src/view.rs b/lib/src/view.rs index f49592714a..b97bef485f 100644 --- a/lib/src/view.rs +++ b/lib/src/view.rs @@ -240,6 +240,33 @@ impl View { }) } + /// Iterates local/remote branch `(name, remote_ref)`s of the specified + /// remote, matching the given branch name pattern. Entries are sorted by + /// `name`. + pub fn local_remote_branches_matching<'a: 'b, 'b>( + &'a self, + branch_pattern: &'b StringPattern, + remote_name: &str, + ) -> impl Iterator)> + 'b { + // Change remote_name to StringPattern if needed, but merge-join adapter won't + // be usable. + let maybe_remote_view = self.data.remote_views.get(remote_name); + refs::iter_named_local_remote_refs( + branch_pattern.filter_btree_map(&self.data.local_branches), + maybe_remote_view + .map(|remote_view| branch_pattern.filter_btree_map(&remote_view.branches)) + .into_iter() + .flatten(), + ) + .map(|(name, (local_target, remote_ref))| { + let targets = TrackingRefPair { + local_target, + remote_ref, + }; + (name.as_ref(), targets) + }) + } + pub fn remove_remote(&mut self, remote_name: &str) { self.data.remote_views.remove(remote_name); }