diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d6dfdc2c1..fb8b1d755a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -134,6 +134,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * Conditional configuration now supports `--when.command` to change configuration based on subcommand. +* 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 ddaac96e04..19e70bf373 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -46,12 +46,14 @@ mod restore; mod root; mod run; mod show; +mod sign; mod simplify_parents; mod sparse; mod split; mod squash; mod status; mod tag; +mod unsign; mod unsquash; mod util; mod version; @@ -130,6 +132,7 @@ enum Command { // TODO: Flesh out. Run(run::RunArgs), Show(show::ShowArgs), + Sign(sign::SignArgs), SimplifyParents(simplify_parents::SimplifyParentsArgs), #[command(subcommand)] Sparse(sparse::SparseCommand), @@ -142,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), @@ -210,12 +214,14 @@ pub fn run_command(ui: &mut Ui, command_helper: &CommandHelper) -> Result<(), Co simplify_parents::cmd_simplify_parents(ui, command_helper, args) } Command::Show(args) => show::cmd_show(ui, command_helper, args), + Command::Sign(args) => sign::cmd_sign(ui, command_helper, args), Command::Sparse(args) => sparse::cmd_sparse(ui, command_helper, args), Command::Split(args) => split::cmd_split(ui, command_helper, args), Command::Squash(args) => squash::cmd_squash(ui, command_helper, args), 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/sign.rs b/cli/src/commands/sign.rs new file mode 100644 index 0000000000..af73ec7374 --- /dev/null +++ b/cli/src/commands/sign.rs @@ -0,0 +1,142 @@ +// 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 crate::cli_util::CommandHelper; +use crate::cli_util::RevisionArg; +use crate::command_error::CommandError; +use crate::complete; +use crate::ui::Ui; + +/// Cryptographically sign a revision +#[derive(clap::Args, Clone, Debug)] +pub struct SignArgs { + /// What key to use, depends on the configured signing backend. + #[arg()] + key: Option, + /// What revision(s) to sign + #[arg( + long, short, + value_name = "REVSETS", + add = ArgValueCandidates::new(complete::mutable_revisions), + )] + revisions: Vec, +} + +pub fn cmd_sign(ui: &mut Ui, command: &CommandHelper, args: &SignArgs) -> 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()?; + + workspace_command.check_rewritable(commits.iter().ids())?; + + let mut tx = workspace_command.start_transaction(); + + let mut signed_commits = vec![]; + let mut foreign_commits = vec![]; + tx.repo_mut().transform_descendants( + commits.iter().ids().cloned().collect_vec(), + |rewriter| { + let authored_by_me = + rewriter.old_commit().author().email == command.settings().user_email(); + if rewriter.old_commit().is_signed() && authored_by_me { + // Don't resign commits, which are already signed by me. For people using + // hardware devices for signatures, resigning commits is + // cumbersome. + return Ok(()); + } + + if commits.contains(rewriter.old_commit()) { + let commit_builder = rewriter.reparent(); + let new_commit = commit_builder + .set_sign_key(args.key.clone()) + .set_sign_behavior(SignBehavior::Own) + .write()?; + signed_commits.push(new_commit.clone()); + + if !authored_by_me { + foreign_commits.push(new_commit); + } + } else { + rewriter.reparent().write()?; + } + + Ok(()) + }, + )?; + + if let Some(mut formatter) = ui.status_formatter() { + match &*foreign_commits { + [] => {} + [commit] => { + write!(ui.warning_default(), "Signed 1 commit not authored by you")?; + tx.base_workspace_helper() + .write_commit_summary(formatter.as_mut(), commit)?; + writeln!(ui.status())?; + } + commits => { + let template = tx.base_workspace_helper().commit_summary_template(); + writeln!( + ui.warning_default(), + "Signed {} commits not authored by you", + commits.len() + )?; + for commit in commits { + write!(formatter, " ")?; + template.format(commit, formatter.as_mut())?; + writeln!(formatter)?; + } + } + }; + + match &*signed_commits { + [] => {} + [commit] => { + write!(formatter, "Signed 1 commit ")?; + tx.base_workspace_helper() + .write_commit_summary(formatter.as_mut(), commit)?; + writeln!(ui.status())?; + } + commits => { + let template = tx.base_workspace_helper().commit_summary_template(); + writeln!(formatter, "Signed {} commits:", commits.len())?; + for commit in commits { + write!(formatter, " ")?; + template.format(commit, formatter.as_mut())?; + writeln!(formatter)?; + } + } + }; + } + let transaction_description = match &*signed_commits { + [] => "".to_string(), + [commit] => format!("sign commit {}", commit.id()), + commits => format!( + "sign commit {} and {} more", + commits[0].id(), + commits.len() - 1 + ), + }; + tx.finish(ui, transaction_description)?; + Ok(()) +} diff --git a/cli/src/commands/unsign.rs b/cli/src/commands/unsign.rs new file mode 100644 index 0000000000..3dcc8ebb5f --- /dev/null +++ b/cli/src/commands/unsign.rs @@ -0,0 +1,103 @@ +// 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 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, + value_name = "REVSETS", + add = ArgValueCandidates::new(complete::mutable_revisions), + )] + revisions: Vec, +} + +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()?; + + 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 { + [] => {} + [commit] => { + write!(formatter, "Unsigned commit ")?; + tx.base_workspace_helper() + .write_commit_summary(formatter.as_mut(), commit)?; + writeln!(ui.status())?; + } + commits => { + let template = tx.base_workspace_helper().commit_summary_template(); + writeln!(formatter, "Unsigned the following commits:")?; + for commit in commits { + write!(formatter, " ")?; + template.format(commit, formatter.as_mut())?; + writeln!(formatter)?; + } + } + }; + } + let transaction_description = match &*unsigned_commits { + [] => "".to_string(), + [commit] => format!("unsign commit {}", commit.id()), + commits => format!( + "unsign commit {} and {} more", + commits[0].id(), + 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 e88b72670c..db06a96126 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -1,6 +1,7 @@ --- source: cli/tests/test_generate_md_cli_help.rs description: "AUTO-GENERATED FILE, DO NOT EDIT. This cli reference is generated by a test as an `insta` snapshot. MkDocs includes this snapshot from docs/cli-reference.md." +snapshot_kind: text --- @@ -79,6 +80,7 @@ This document contains the help content for the `jj` command-line program. * [`jj restore`↴](#jj-restore) * [`jj root`↴](#jj-root) * [`jj show`↴](#jj-show) +* [`jj sign`↴](#jj-sign) * [`jj simplify-parents`↴](#jj-simplify-parents) * [`jj sparse`↴](#jj-sparse) * [`jj sparse edit`↴](#jj-sparse-edit) @@ -98,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) @@ -149,6 +152,7 @@ To get started, see the tutorial at https://jj-vcs.github.io/jj/latest/tutorial/ * `restore` — Restore paths from another revision * `root` — Show the current workspace root directory * `show` — Show commit description and changes in a revision +* `sign` — Cryptographically sign a revision * `simplify-parents` — Simplify parent edges for the specified revision(s) * `sparse` — Manage which paths from the working-copy commit are present in the working copy * `split` — Split a revision in two @@ -157,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 @@ -2032,6 +2037,22 @@ Show commit description and changes in a revision +## `jj sign` + +Cryptographically sign a revision + +**Usage:** `jj sign [OPTIONS] [KEY]` + +###### **Arguments:** + +* `` — What key to use, depends on the configured signing backend + +###### **Options:** + +* `-r`, `--revisions ` — What revision(s) to sign + + + ## `jj simplify-parents` Simplify parent edges for the specified revision(s). @@ -2402,6 +2423,18 @@ 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 + + + ## `jj version` Display version information diff --git a/cli/tests/runner.rs b/cli/tests/runner.rs index a8312038a7..c3c8b7caa5 100644 --- a/cli/tests/runner.rs +++ b/cli/tests/runner.rs @@ -63,6 +63,7 @@ mod test_revset_output; mod test_root; mod test_shell_completion; mod test_show_command; +mod test_sign_command; mod test_simplify_parents_command; mod test_sparse_command; mod test_split_command; @@ -71,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 6b52312673..e1138c6471 100644 --- a/cli/tests/test_immutable_commits.rs +++ b/cli/tests/test_immutable_commits.rs @@ -347,4 +347,18 @@ 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()`. "###); + // sign + let stderr = test_env.jj_cmd_failure(&repo_path, &["sign", "-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()`. + "); + // 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_sign_command.rs b/cli/tests/test_sign_command.rs new file mode 100644 index 0000000000..c9ea9ed57f --- /dev/null +++ b/cli/tests/test_sign_command.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 crate::common::TestEnvironment; + +#[test] +fn test_sign() { + 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, + separate(" ", signature.status(), signature.display()), + "no" + ) ++ " signature""#; + + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "-T", template, "-r", "all()"]); + insta::assert_snapshot!(stdout, @r" + @ no signature + ○ no signature + ○ no signature + ○ no signature + ◆ no signature + "); + + let (_, stderr) = test_env.jj_cmd_ok(&repo_path, &["sign", "-r", "..@-"]); + insta::assert_snapshot!(stderr, @r" + Signed 3 commits: + qpvuntsm hidden 8174ec98 (empty) one + rlvkpnrz hidden 6500b275 (empty) two + kkmpptxz hidden bcfaa4c3 (empty) three + Rebased 1 descendant commits + Working copy now at: zsuskuln eeb8c985 (empty) (no description set) + Parent commit : kkmpptxz bcfaa4c3 (empty) three + "); + + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "-T", template, "-r", "all()"]); + insta::assert_snapshot!(stdout, @r" + @ no signature + ○ good test-display signature + ○ good test-display signature + ○ good test-display signature + ◆ no signature + "); + + // Don't resign commits, which are already signed by me. + let (_, stderr) = test_env.jj_cmd_ok(&repo_path, &["sign", "-r", "..@-"]); + insta::assert_snapshot!(stderr, @r"Nothing changed."); +} + +#[test] +fn test_warn_about_signing_commits_not_authored_by_me() { + 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"]); + + test_env.jj_cmd_ok( + &repo_path, + &[ + "desc", + "--author", + "Someone Else ", + "--no-edit", + "..@-", + ], + ); + let (_, stderr) = test_env.jj_cmd_ok(&repo_path, &["sign", "-r", "..@-"]); + insta::assert_snapshot!(stderr, @r" + Warning: Signed 3 commits not authored by you + qpvuntsm hidden 82f99921 (empty) one + rlvkpnrz hidden 715131ae (empty) two + kkmpptxz hidden 60618621 (empty) three + Signed 3 commits: + qpvuntsm hidden 82f99921 (empty) one + rlvkpnrz hidden 715131ae (empty) two + kkmpptxz hidden 60618621 (empty) three + Rebased 1 descendant commits + Working copy now at: zsuskuln 5a1d05b3 (empty) (no description set) + Parent commit : kkmpptxz 60618621 (empty) three + "); +} diff --git a/cli/tests/test_unsign_command.rs b/cli/tests/test_unsign_command.rs new file mode 100644 index 0000000000..aea11b7ea8 --- /dev/null +++ b/cli/tests/test_unsign_command.rs @@ -0,0 +1,87 @@ +// 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] +#[should_panic] +fn test_warn_about_unsigning_commits_not_authored_by_me() { + todo!() +} diff --git a/docs/config.md b/docs/config.md index 6438539fbe..e243b7aaad 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1121,6 +1121,10 @@ key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGj+J6N6SO+4P8dOZqfR1oiay2yxhhHnagH52 sign-on-push = true ``` +### Manually signing 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