diff --git a/CHANGELOG.md b/CHANGELOG.md index cbeba42b75a..35fb7e0c0da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * The new `jj sign` command allows signing commits. +* The new `jj unsign` command allows unsigning commits. + ### Fixed bugs * Fixed diff selection by external tools with `jj split`/`commit -i FILESETS`. diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index ad7703a6c97..19e70bf373b 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -53,6 +53,7 @@ mod split; mod squash; mod status; mod tag; +mod unsign; mod unsquash; mod util; mod version; @@ -144,6 +145,7 @@ enum Command { Util(util::UtilCommand), /// Undo an operation (shortcut for `jj op undo`) Undo(operation::undo::OperationUndoArgs), + Unsign(unsign::UnsignArgs), // TODO: Delete `unsquash` in jj 0.28+ #[command(hide = true)] Unsquash(unsquash::UnsquashArgs), @@ -219,6 +221,7 @@ pub fn run_command(ui: &mut Ui, command_helper: &CommandHelper) -> Result<(), Co Command::Status(args) => status::cmd_status(ui, command_helper, args), Command::Tag(args) => tag::cmd_tag(ui, command_helper, args), Command::Undo(args) => operation::undo::cmd_op_undo(ui, command_helper, args), + Command::Unsign(args) => unsign::cmd_unsign(ui, command_helper, args), Command::Unsquash(args) => unsquash::cmd_unsquash(ui, command_helper, args), Command::Untrack(args) => { let cmd = renamed_cmd("untrack", "file untrack", file::untrack::cmd_file_untrack); diff --git a/cli/src/commands/unsign.rs b/cli/src/commands/unsign.rs new file mode 100644 index 00000000000..08a9ac401d3 --- /dev/null +++ b/cli/src/commands/unsign.rs @@ -0,0 +1,116 @@ +// Copyright 2023 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use clap_complete::ArgValueCandidates; +use indexmap::IndexSet; +use itertools::Itertools; +use jj_lib::commit::Commit; +use jj_lib::commit::CommitIteratorExt; +use jj_lib::signing::SignBehavior; + +use super::sign::check_commits_authored_by; +use crate::cli_util::CommandHelper; +use crate::cli_util::RevisionArg; +use crate::command_error::CommandError; +use crate::complete; +use crate::ui::Ui; + +/// Drop a cryptographic signature +#[derive(clap::Args, Clone, Debug)] +pub struct UnsignArgs { + /// What revision(s) to unsign + #[arg( + long, short, + default_value = "@", + value_name = "REVSETS", + add = ArgValueCandidates::new(complete::mutable_revisions), + )] + revisions: Vec, + /// Unsign a commit that is not authored by you. + #[arg(long, short)] + allow_not_mine: bool, +} + +pub fn cmd_unsign( + ui: &mut Ui, + command: &CommandHelper, + args: &UnsignArgs, +) -> Result<(), CommandError> { + let mut workspace_command = command.workspace_helper(ui)?; + + let commits: IndexSet = workspace_command + .parse_union_revsets(ui, &args.revisions)? + .evaluate_to_commits()? + .try_collect()?; + + if !args.allow_not_mine { + check_commits_authored_by( + &commits, + workspace_command.settings().user_email(), + "Use --allow-not-mine to unsign anyway.", + )?; + } + + workspace_command.check_rewritable(commits.iter().ids())?; + + let mut tx = workspace_command.start_transaction(); + + let mut unsigned_commits = vec![]; + tx.repo_mut().transform_descendants( + commits.iter().ids().cloned().collect_vec(), + |rewriter| { + if commits.contains(rewriter.old_commit()) { + let commit_builder = rewriter.reparent(); + let new_commit = commit_builder + .set_sign_behavior(SignBehavior::Drop) + .write()?; + unsigned_commits.push(new_commit); + } + Ok(()) + }, + )?; + + if let Some(mut formatter) = ui.status_formatter() { + match unsigned_commits.len() { + 0 => (), + 1 => { + write!(formatter, "Unsigned commit ")?; + tx.base_workspace_helper() + .write_commit_summary(formatter.as_mut(), &unsigned_commits[0])?; + writeln!(ui.status())?; + } + 2.. => { + let template = tx.base_workspace_helper().commit_summary_template(); + writeln!(formatter, "Unsigned the following commits:")?; + for commit in &unsigned_commits { + write!(formatter, " ")?; + template.format(commit, formatter.as_mut())?; + writeln!(formatter)?; + } + } + }; + } + let transaction_description = match unsigned_commits.len() { + 0 => "".to_string(), + 1 => format!("unsign commit {}", unsigned_commits[0].id()), + 2.. => format!( + "unsign commit {} and {} more", + unsigned_commits[0].id(), + unsigned_commits.len() - 1 + ), + }; + tx.finish(ui, transaction_description)?; + + Ok(()) +} diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index 2e98c06a89b..42f0a03a8ba 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -100,6 +100,7 @@ This document contains the help content for the `jj` command-line program. * [`jj util install-man-pages`↴](#jj-util-install-man-pages) * [`jj util markdown-help`↴](#jj-util-markdown-help) * [`jj undo`↴](#jj-undo) +* [`jj unsign`↴](#jj-unsign) * [`jj version`↴](#jj-version) * [`jj workspace`↴](#jj-workspace) * [`jj workspace add`↴](#jj-workspace-add) @@ -160,6 +161,7 @@ To get started, see the tutorial at https://jj-vcs.github.io/jj/latest/tutorial/ * `tag` — Manage tags * `util` — Infrequently used commands such as for generating shell completions * `undo` — Undo an operation (shortcut for `jj op undo`) +* `unsign` — Drop a cryptographic signature * `version` — Display version information * `workspace` — Commands for working with workspaces @@ -2416,6 +2418,21 @@ Undo an operation (shortcut for `jj op undo`) +## `jj unsign` + +Drop a cryptographic signature + +**Usage:** `jj unsign [OPTIONS]` + +###### **Options:** + +* `-r`, `--revisions ` — What revision(s) to unsign + + Default value: `@` +* `-a`, `--allow-not-mine` — Unsign a commit that is not authored by you + + + ## `jj version` Display version information diff --git a/cli/tests/runner.rs b/cli/tests/runner.rs index a6f33da2ec9..c3c8b7caa53 100644 --- a/cli/tests/runner.rs +++ b/cli/tests/runner.rs @@ -72,6 +72,7 @@ mod test_status_command; mod test_tag_command; mod test_templater; mod test_undo; +mod test_unsign_command; mod test_unsquash_command; mod test_util_command; mod test_working_copy; diff --git a/cli/tests/test_immutable_commits.rs b/cli/tests/test_immutable_commits.rs index 35ac4ec773c..e1138c6471f 100644 --- a/cli/tests/test_immutable_commits.rs +++ b/cli/tests/test_immutable_commits.rs @@ -354,4 +354,11 @@ fn test_rewrite_immutable_commands() { Hint: Could not modify commit: mzvwutvl bcab555f main | (conflict) merge Hint: Pass `--ignore-immutable` or configure the set of immutable commits via `revset-aliases.immutable_heads()`. "); + // unsign + let stderr = test_env.jj_cmd_failure(&repo_path, &["unsign", "-r=main"]); + insta::assert_snapshot!(stderr, @r" + Error: Commit bcab555fc80e is immutable + Hint: Could not modify commit: mzvwutvl bcab555f main | (conflict) merge + Hint: Pass `--ignore-immutable` or configure the set of immutable commits via `revset-aliases.immutable_heads()`. + "); } diff --git a/cli/tests/test_unsign_command.rs b/cli/tests/test_unsign_command.rs new file mode 100644 index 00000000000..d314f35fcb6 --- /dev/null +++ b/cli/tests/test_unsign_command.rs @@ -0,0 +1,150 @@ +// Copyright 2023 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::common::TestEnvironment; + +#[test] +fn test_unsign() { + let test_env = TestEnvironment::default(); + + test_env.add_config( + r#" +[signing] +sign-all = false +backend = "test" +"#, + ); + + 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, &["commit", "-m", "one"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "two"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "three"]); + + let template = r#"if(signature, + signature.status() ++ " " ++ signature.display(), + "no" + ) ++ " signature""#; + + let show_no_sig = test_env.jj_cmd_success(&repo_path, &["log", "-T", template, "-r", "all()"]); + insta::assert_snapshot!(show_no_sig, @r" + @ no signature + ○ no signature + ○ no signature + ○ no signature + ◆ no signature + "); + + test_env.jj_cmd_ok(&repo_path, &["sign", "-r", "..@-"]); + + let show_with_sig = + test_env.jj_cmd_success(&repo_path, &["log", "-T", template, "-r", "all()"]); + insta::assert_snapshot!(show_with_sig, @r" + @ no signature + ○ good test-display signature + ○ good test-display signature + ○ good test-display signature + ◆ no signature + "); + + let (_, stderr) = test_env.jj_cmd_ok(&repo_path, &["unsign", "-r", "..@-"]); + insta::assert_snapshot!(stderr, @r" + Unsigned the following commits: + qpvuntsm hidden afde6e4b (empty) one + rlvkpnrz hidden d49204af (empty) two + kkmpptxz hidden ea6d9b6d (empty) three + Rebased 1 descendant commits + Working copy now at: zsuskuln 4029f2fc (empty) (no description set) + Parent commit : kkmpptxz ea6d9b6d (empty) three + "); + + let show_with_sig = + test_env.jj_cmd_success(&repo_path, &["log", "-T", template, "-r", "all()"]); + insta::assert_snapshot!(show_with_sig, @r" + @ no signature + ○ no signature + ○ no signature + ○ no signature + ◆ no signature + "); +} + +#[test] +fn test_allow_not_mine() { + let test_env = TestEnvironment::default(); + + test_env.add_config( + r#" +[signing] +sign-all = false +backend = "test" +"#, + ); + + 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, &["commit", "-m", "init"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "one"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "two"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "three"]); + + let (_, stderr) = test_env.jj_cmd_ok(&repo_path, &["sign", "-r", "..@-"]); + insta::assert_snapshot!(stderr, @r" + Signed the following commits: + qpvuntsm hidden 59bce38c (empty) init + rlvkpnrz hidden 2ab1ce70 (empty) one + kkmpptxz hidden 00f3af95 (empty) two + zsuskuln hidden 648894e4 (empty) three + Rebased 1 descendant commits + Working copy now at: mzvwutvl 7349dba8 (empty) (no description set) + Parent commit : zsuskuln 648894e4 (empty) three + "); + test_env.jj_cmd_ok( + &repo_path, + &[ + "desc", + "--author", + "Someone Else ", + "--no-edit", + "..@-", + ], + ); + let stderr = test_env.jj_cmd_failure(&repo_path, &["unsign", "-r", "@-"]); + insta::assert_snapshot!(stderr, @r" + Error: Commit 88d29a30391f is not authored by you. + Hint: Use --allow-not-mine to unsign anyway. + "); + + let stderr = test_env.jj_cmd_failure(&repo_path, &["unsign", "-r", "..@-"]); + insta::assert_snapshot!(stderr, @r" + Error: The following commits are not authored by you: + 88d29a30391f + 78855514c988 + 8e11694d102e + 0180dfa7875d + Hint: Use --allow-not-mine to unsign anyway. + "); + + let (_, stderr) = test_env.jj_cmd_ok(&repo_path, &["unsign", "-r", "..@-", "--allow-not-mine"]); + insta::assert_snapshot!(stderr, @r" + Unsigned the following commits: + qpvuntsm hidden a6a855c8 (empty) init + rlvkpnrz hidden 99410553 (empty) one + kkmpptxz hidden bee2d845 (empty) two + zsuskuln hidden c0b9a9d9 (empty) three + Rebased 1 descendant commits + Working copy now at: mzvwutvl c7afb8c1 (empty) (no description set) + Parent commit : zsuskuln c0b9a9d9 (empty) three + "); +} diff --git a/docs/config.md b/docs/config.md index 5258a4b3296..9efd76b5729 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1097,7 +1097,8 @@ sign-on-push = true ### Manually signing commits -You can use [`jj sign`](./cli-reference.md#jj-sign) to manually sign commits. +You can use [`jj sign`](./cli-reference.md#jj-sign)/[`jj unsign`](./cli-reference.md#jj-unsign) +to sign/unsign commits manually. ## Commit Signature Verification