diff --git a/CHANGELOG.md b/CHANGELOG.md index 408c8a47efe..d290c98021f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). jj workspace can have its own Git HEAD. Existing repositories are migrated automatically. +* `jj git push` can now be configured to ask for confirmation before pushing + changes to a remote using `git.confirm-before-push`. This prompt can be + skipped using the `-y`/`--yes` flag. + ### Fixed bugs * The default pager flags now include `-K` (`--quit-on-intr`), so pressing diff --git a/cli/src/commands/git/push.rs b/cli/src/commands/git/push.rs index ac344565488..cb393b0b827 100644 --- a/cli/src/commands/git/push.rs +++ b/cli/src/commands/git/push.rs @@ -56,6 +56,7 @@ use jj_lib::revset::RevsetExpression; use jj_lib::revset::RevsetStreamExt as _; use jj_lib::revset::UserRevsetExpression; use jj_lib::rewrite::CommitRewriter; +use jj_lib::settings::UserSettings; use jj_lib::signing::SignBehavior; use jj_lib::str_util::StringExpression; use jj_lib::view::View; @@ -231,6 +232,10 @@ pub struct GitPushArgs { #[arg(long)] dry_run: bool, + /// Automatically answer all prompts with "yes" and run non-interactively + #[arg(long, short)] + yes: bool, + /// Git push options #[arg(long, short)] option: Vec, @@ -288,9 +293,8 @@ pub async fn cmd_git_push( let mut tx = workspace_command.start_transaction(); let view = tx.repo().view(); - let tx_description; let mut ref_updates = GitPushRefTargets::default(); - if args.all { + let tx_description = if args.all { let mut commits_validator = CommitsValidator::new(ui, tx.base_workspace_helper(), remote, args)?; for (name, targets) in view.local_remote_bookmarks(remote) { @@ -317,10 +321,10 @@ pub async fn cmd_git_push( Err(reason) => reason.print(ui)?, } } - tx_description = format!( + format!( "{TX_DESC_PUSH}all bookmarks/tags to git remote {remote}", remote = remote.as_symbol() - ); + ) } else if args.tracked { let mut commits_validator = CommitsValidator::new(ui, tx.base_workspace_helper(), remote, args)?; @@ -354,10 +358,10 @@ pub async fn cmd_git_push( Err(reason) => reason.print(ui)?, } } - tx_description = format!( + format!( "{TX_DESC_PUSH}all tracked bookmarks/tags to git remote {remote}", remote = remote.as_symbol() - ); + ) } else if args.deleted { // There shouldn't be new heads to push, but we run validation for consistency. let mut commits_validator = @@ -394,10 +398,10 @@ pub async fn cmd_git_push( Err(reason) => reason.print(ui)?, } } - tx_description = format!( + format!( "{TX_DESC_PUSH}all deleted bookmarks/tags to git remote {remote}", remote = remote.as_symbol() - ); + ) } else { let mut seen_bookmarks: HashSet<&RefName> = HashSet::new(); let mut seen_tags: HashSet<&RefName> = HashSet::new(); @@ -542,17 +546,24 @@ pub async fn cmd_git_push( } } - tx_description = format!( + format!( "{TX_DESC_PUSH}{names} to git remote {remote}", names = make_updates_term(&ref_updates), remote = remote.as_symbol() - ); - } + ) + }; + if ref_updates.bookmarks.is_empty() && ref_updates.tags.is_empty() { writeln!(ui.status(), "Nothing changed.")?; return Ok(()); } + let needs_confirm = !args.dry_run + && !args.yes + && needs_confirm_from_settings(ui, tx.settings(), "git.confirm-before-push", || { + ref_updates.bookmarks.len() + ref_updates.tags.len() > 1 + })?; + if !args.dry_run && tx.settings().get_bool("git.sign-on-push")? { let to_push_expr = ready_to_push_revset_expression(&tx, remote, &ref_updates); ref_updates = sign_commits_before_push(ui, &mut tx, to_push_expr, ref_updates).await?; @@ -572,6 +583,11 @@ pub async fn cmd_git_push( return Ok(()); } + if needs_confirm && !ui.prompt_yes_no("Continue?", Some(true))? { + writeln!(ui.status(), "Aborting; nothing was pushed.")?; + return Ok(()); + } + let git_settings = GitSettings::from_settings(tx.settings())?; let options = GitPushOptions { remote_push_options: args.option.clone(), @@ -597,6 +613,44 @@ pub async fn cmd_git_push( } } +#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PushConfirmChoice { + /// Always ask for confirmation before pushing + Always, + /// Never prompt the user before pushing a change + Never, + /// Only prompt if more than one bookmark/tag is about to be pushed + /// + /// If more than one bookmark or tag is moved in the same push, it is + /// possible that some of them were unintentional, so we should give the + /// user a chance to correct their mistake. + Auto, +} + +fn needs_confirm_from_settings( + ui: &Ui, + settings: &UserSettings, + key: &'static str, + auto_cb: impl FnOnce() -> bool, +) -> Result { + Ok(if let Some(choice) = settings.get(key).optional()? { + match choice { + PushConfirmChoice::Always => true, + PushConfirmChoice::Never => false, + PushConfirmChoice::Auto => auto_cb(), + } + } else { + writeln!( + ui.hint_default(), + "Pushing non-interactively; set `git.confirm-before-push` to `always`, `never`, or \ + `auto` to disable this message." + ) + .ok(); + false + }) +} + #[derive(Clone, Debug)] struct RejectedCommitReason { commit: Commit, diff --git a/cli/src/config-schema.json b/cli/src/config-schema.json index 4ff0790b875..0ca22ad7ece 100644 --- a/cli/src/config-schema.json +++ b/cli/src/config-schema.json @@ -558,6 +558,11 @@ "enum": ["sha1", "sha256"], "description": "Object hash algorithm used when initializing a new Git repository", "default": "sha1" + }, + "confirm-before-push": { + "enum": ["always", "never", "auto"], + "description": "When to prompt user before pushing changes to remote", + "default": "never" } } }, diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index d03d1c88b3b..563ec43867c 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -1854,6 +1854,7 @@ Before the command actually moves, creates, or deletes a remote bookmark, it mak Automatically tracks the bookmark if it is new. * `--dry-run` — Only display what will change on the remote +* `-y`, `--yes` — Automatically answer all prompts with "yes" and run non-interactively * `-o`, `--option