Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 65 additions & 11 deletions cli/src/commands/git/push.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Comment on lines +235 to +237

@PhilipMetzger PhilipMetzger Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I don't think we should do it this way, if we're doing it at all

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do believe a flag like this should exist for the purpose of shell scripts, AI agents, or other places where interaction must be avoided without modifying the user configs. This particular flag is inspired by apt install, which has -y/--yes/--assume-yes. Do you have a different design in mind?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have a different design in mind?

If this feature is opt-in only such a flag is unnecessary, as I said if we're doing it all it should be something which we slowly roll out.


/// Git push options
#[arg(long, short)]
option: Vec<String>,
Expand Down Expand Up @@ -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) {
Expand All @@ -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)?;
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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?;
Expand All @@ -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(),
Expand All @@ -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<bool, CommandError> {
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,
Expand Down
5 changes: 5 additions & 0 deletions cli/src/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand Down
1 change: 1 addition & 0 deletions cli/tests/cli-reference@.md.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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 <OPTION>` — Git push options


Expand Down
1 change: 1 addition & 0 deletions cli/tests/test_bookmark_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@ fn test_bookmark_rename() {
let output = work_dir.run_jj(["git", "push", "--bookmark", "bremote2"]);
insta::assert_snapshot!(output, @"
------- stderr -------
Hint: Pushing non-interactively; set `git.confirm-before-push` to `always`, `never`, or `auto` to disable this message.
Changes to push to origin:
bookmark: bremote2 [add to 1e76d54fcfce]
[EOF]
Expand Down
1 change: 1 addition & 0 deletions cli/tests/test_config_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1381,6 +1381,7 @@ fn test_config_get_yields_values_consistent_with_schema_defaults() -> TestResult
"ui.merge-editor" => insta::assert_snapshot!(schema_default, @r#"":builtin""#),
"git.fetch" => insta::assert_snapshot!(schema_default, @r#""origin""#),
"git.push" => insta::assert_snapshot!(schema_default, @r#""origin""#),
"git.confirm-before-push" => insta::assert_snapshot!(schema_default, @r#""never""#),

// When no `short-prefixes` revset is explicitly configured, the revset for `log` is
// used instead, even if that has a value different from the default. The schema
Expand Down
8 changes: 8 additions & 0 deletions cli/tests/test_git_private_commits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ fn test_git_private_commits_block_pushing() {
let output = work_dir.run_jj(["git", "push", "--all"]);
insta::assert_snapshot!(output, @"
------- stderr -------
Hint: Pushing non-interactively; set `git.confirm-before-push` to `always`, `never`, or `auto` to disable this message.
Changes to push to origin:
bookmark: main [move forward from 95cc152cd086 to 469f044473ed]
tag: alpha [move sideways from 95cc152cd086 to 69b30fdbc569]
Expand Down Expand Up @@ -203,6 +204,7 @@ fn test_git_private_commits_can_be_overridden() {
let output = work_dir.run_jj(["git", "push", "--all", "--allow-private"]);
insta::assert_snapshot!(output, @"
------- stderr -------
Hint: Pushing non-interactively; set `git.confirm-before-push` to `always`, `never`, or `auto` to disable this message.
Changes to push to origin:
bookmark: main [move forward from 95cc152cd086 to 7f665ca27d4e]
Warning: The working-copy commit became immutable; a new commit has been created on top of it.
Expand All @@ -228,6 +230,7 @@ fn test_git_private_commits_are_not_checked_if_immutable() {
let output = work_dir.run_jj(["git", "push", "--all"]);
insta::assert_snapshot!(output, @"
------- stderr -------
Hint: Pushing non-interactively; set `git.confirm-before-push` to `always`, `never`, or `auto` to disable this message.
Changes to push to origin:
bookmark: main [move forward from 95cc152cd086 to 7f665ca27d4e]
Warning: The working-copy commit became immutable; a new commit has been created on top of it.
Expand Down Expand Up @@ -291,6 +294,7 @@ fn test_git_private_commits_descending_from_commits_pushed_do_not_block_pushing(
Warning: Won't push bookmark wip2: commit c3ad06b3e0ea is private
yostqsxw c3ad06b3 wip1 wip2 | (empty) private 1
Hint: Configured git.private-commits: 'description('private*')'
Hint: Pushing non-interactively; set `git.confirm-before-push` to `always`, `never`, or `auto` to disable this message.
Changes to push to origin:
bookmark: main [move forward from 95cc152cd086 to f0291dea729d]
[EOF]
Expand Down Expand Up @@ -319,6 +323,7 @@ fn test_git_private_commits_already_on_the_remote_do_not_block_push() {
let output = work_dir.run_jj(["git", "push", "-b=main", "-b=bookmark1"]);
insta::assert_snapshot!(output, @"
------- stderr -------
Hint: Pushing non-interactively; set `git.confirm-before-push` to `always`, `never`, or `auto` to disable this message.
Changes to push to origin:
bookmark: bookmark1 [add to 95cc152cd086]
bookmark: main [move forward from 95cc152cd086 to 03bc2bf271e0]
Expand All @@ -337,6 +342,7 @@ fn test_git_private_commits_already_on_the_remote_do_not_block_push() {
let output = work_dir.run_jj(["git", "push", "--all"]);
insta::assert_snapshot!(output, @"
------- stderr -------
Hint: Pushing non-interactively; set `git.confirm-before-push` to `always`, `never`, or `auto` to disable this message.
Changes to push to origin:
bookmark: bookmark1 [move forward from 95cc152cd086 to 03bc2bf271e0]
[EOF]
Expand All @@ -353,6 +359,7 @@ fn test_git_private_commits_already_on_the_remote_do_not_block_push() {
let output = work_dir.run_jj(["git", "push", "-b=bookmark2"]);
insta::assert_snapshot!(output, @"
------- stderr -------
Hint: Pushing non-interactively; set `git.confirm-before-push` to `always`, `never`, or `auto` to disable this message.
Changes to push to origin:
bookmark: bookmark2 [add to 987ee765174d]
[EOF]
Expand All @@ -377,6 +384,7 @@ fn test_git_private_commits_are_evaluated_separately_for_each_remote() {
let output = work_dir.run_jj(["git", "push", "-b=main"]);
insta::assert_snapshot!(output, @"
------- stderr -------
Hint: Pushing non-interactively; set `git.confirm-before-push` to `always`, `never`, or `auto` to disable this message.
Changes to push to origin:
bookmark: main [move forward from 95cc152cd086 to efa3666d00e4]
[EOF]
Expand Down
Loading