Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement jj sign command #4747

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
3 changes: 3 additions & 0 deletions cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ mod restore;
mod root;
mod run;
mod show;
mod sign;
mod simplify_parents;
mod sparse;
mod split;
Expand Down Expand Up @@ -128,6 +129,7 @@ enum Command {
// TODO: Flesh out.
Run(run::RunArgs),
Show(show::ShowArgs),
Sign(sign::SignArgs),
SimplifyParents(simplify_parents::SimplifyParentsArgs),
#[command(subcommand)]
Sparse(sparse::SparseCommand),
Expand Down Expand Up @@ -207,6 +209,7 @@ 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),
Expand Down
118 changes: 118 additions & 0 deletions cli/src/commands/sign.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Copyright 2023 The Jujutsu Authors
Copy link
Member

Choose a reason for hiding this comment

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

nit: maybe 2024 or 2025? or maybe this work was started in 2023 so it should be 2023?

Copy link
Contributor Author

@pylbrecht pylbrecht Jan 8, 2025

Choose a reason for hiding this comment

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

I jj duplicated the commit from #3142, so I suppose the work started in 2023.

//
// 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 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::user_error;
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<String>,
/// What revision(s) to sign
#[arg(
long, short,
default_value = "@",
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: it might be better to make the default configurable. I think jj sign will be used in the context where we do jj fix && jj sign .. && jj git push.

(can be addressed separately)

value_name = "REVSETS",
add = ArgValueCandidates::new(complete::mutable_revisions),
)]
revisions: Vec<RevisionArg>,
/// Sign a commit that is not authored by you or was already signed.
#[arg(long, short)]
force: bool,
Comment on lines +42 to +44
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: --force tends to mean arbitrary things. Maybe --allow-not-mine or something is better?

/// Drop the signature, explicitly "un-signing" the commit.
#[arg(long, short = 'D', conflicts_with = "force")]
drop: bool,
Comment on lines +45 to +47
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: just dropping idea: jj unsign or jj signature drop.
(It's unlikely we'll add more sub commands, so namespaced command might be overkill.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I like jj unsign.
Splitting off the drop from jj sign would make cmd_sign() a little simpler, as well.

}

pub fn cmd_sign(ui: &mut Ui, command: &CommandHelper, args: &SignArgs) -> Result<(), CommandError> {
let mut workspace_command = command.workspace_helper(ui)?;

let commits: Vec<Commit> = workspace_command
.parse_union_revsets(ui, &args.revisions)?
.evaluate_to_commits()?
.try_collect()?;

let commit_ids = commits.iter().ids().collect_vec();
workspace_command.check_rewritable(commit_ids)?;

for commit in &commits {
if !args.force {
if !args.drop && commit.is_signed() {
return Err(user_error(
"Commit is already signed, use --force to sign anyway",
));
}
if commit.author().email != command.settings().user_email() {
return Err(user_error(
"Commit is not authored by you, use --force to sign anyway",
));
}
Comment on lines +63 to +72
Copy link
Contributor

Choose a reason for hiding this comment

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

Now that this version can work with multiple commits, those checks should show the offending one.
Or ones, actually, so they should eagerly collect lists of bad commits and print them all at once.

Copy link
Contributor

@necauqua necauqua Jan 6, 2025

Choose a reason for hiding this comment

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

nit: Also if !args.force can be moved outside of the loop

}
}

let mut tx = workspace_command.start_transaction();

let behavior = if args.drop {
SignBehavior::Drop
} else if args.force {
SignBehavior::Force
} else {
SignBehavior::Own
};
let mut signed_commits = vec![];
tx.repo_mut().transform_descendants(
commits.iter().ids().cloned().collect_vec(),
Copy link
Contributor

Choose a reason for hiding this comment

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

Use commit_ids here?

Hmm, if check_rewritable consumes the vector that sounds meh, it should accept &[CommitId].
If it really does need to consume the vector for some reason ignore this comment then 🤷
(Or maybe it accepts any iterable and you just need to borrow there, I haven't looked at the code in a year already)

|rewriter| {
if commits.contains(rewriter.old_commit()) {
necauqua marked this conversation as resolved.
Show resolved Hide resolved
let commit_builder = rewriter.rebase()?;
let new_commit = commit_builder
.set_sign_key(args.key.clone())
.set_sign_behavior(behavior)
.write()?;
signed_commits.push(new_commit);
}
Ok(())
Copy link
Contributor

Choose a reason for hiding this comment

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

maybe you'll need to do rewriter.reparent(). cmd_describe() does rewriter.rebase(), but I think .reparent() works there, too.

},
)?;

tx.finish(ui, format!("signed {} commits", signed_commits.len()))?;

let Some(mut formatter) = ui.status_formatter() else {
return Ok(());
};
let template = workspace_command.commit_summary_template();
for commit in &signed_commits {
if args.drop {
write!(formatter, "Signature was dropped: ")?;
} else {
write!(formatter, "Commit was signed: ")?;
}
Comment on lines +108 to +112
Copy link
Member

Choose a reason for hiding this comment

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

I would expect these messages to depend on the commit's previous signature, especially "Signature was dropped" is confusing for commits that were not signed to start with.

Copy link
Contributor

Choose a reason for hiding this comment

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

This made sense with a singular commit, since pre-check would short-circuit the situation where args.drop=true and the commit was not signed

template.format(commit, formatter.as_mut())?;
writeln!(formatter)?;
}

Ok(())
}
23 changes: 23 additions & 0 deletions cli/tests/[email protected]
Original file line number Diff line number Diff line change
@@ -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
---
<!-- BEGIN MARKDOWN-->

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -149,6 +151,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
Expand Down Expand Up @@ -1982,6 +1985,26 @@ Show commit description and changes in a revision



## `jj sign`

Cryptographically sign a revision

**Usage:** `jj sign [OPTIONS] [KEY]`

###### **Arguments:**

* `<KEY>` — What key to use, depends on the configured signing backend

###### **Options:**

* `-r`, `--revisions <REVSETS>` — What revision(s) to sign

Default value: `@`
* `-f`, `--force` — Sign a commit that is not authored by you or was already signed
* `-D`, `--drop` — Drop the signature, explicitly "un-signing" the commit



## `jj simplify-parents`

Simplify parent edges for the specified revision(s).
Expand Down
1 change: 1 addition & 0 deletions cli/tests/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
170 changes: 170 additions & 0 deletions cli/tests/test_sign_command.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// 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", "init"]);

let template = r#"if(signature,
signature.status() ++ " " ++ signature.display(),
"no"
) ++ " signature""#;

let show_no_sig = test_env.jj_cmd_success(&repo_path, &["show", "-T", template, "-r", "@-"]);
insta::assert_snapshot!(show_no_sig, @"no signature");

let (_, stderr) = test_env.jj_cmd_ok(&repo_path, &["sign", "-r", "@-"]);
insta::assert_snapshot!(stderr, @r"
Rebased 1 descendant commits
Working copy now at: rlvkpnrz 1c141424 (empty) (no description set)
Parent commit : qpvuntsm a9cc7c27 (empty) init
Commit was signed: qpvuntsm a9cc7c27 (empty) init
");

let show_with_sig = test_env.jj_cmd_success(&repo_path, &["show", "-T", template, "-r", "@-"]);
insta::assert_snapshot!(show_with_sig, @r"good test-display signature");
}

#[test]
fn test_sig_drop() {
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"]);

let template = r#"if(signature,
signature.status() ++ " " ++ signature.display(),
"no"
) ++ " signature""#;

let show_no_sig = test_env.jj_cmd_success(&repo_path, &["show", "-T", template, "-r", "@-"]);
insta::assert_snapshot!(show_no_sig, @"no signature");

test_env.jj_cmd_ok(&repo_path, &["sign", "-r", "@-"]);

let show_with_sig = test_env.jj_cmd_success(&repo_path, &["show", "-T", template, "-r", "@-"]);
insta::assert_snapshot!(show_with_sig, @"good test-display signature");

let (_, stderr) = test_env.jj_cmd_ok(&repo_path, &["sign", "-r", "@-", "--drop"]);
insta::assert_snapshot!(stderr, @r"
Rebased 1 descendant commits
Working copy now at: rlvkpnrz be42f24a (empty) (no description set)
Parent commit : qpvuntsm 755eae8e (empty) init
Signature was dropped: qpvuntsm 755eae8e (empty) init
");

let show_with_sig = test_env.jj_cmd_success(&repo_path, &["show", "-T", template, "-r", "@-"]);
insta::assert_snapshot!(show_with_sig, @"no signature");
}

#[test]
fn test_commit_already_signed() {
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, &["sign", "-r", "@-"]);
let stderr = test_env.jj_cmd_failure(&repo_path, &["sign", "-r", "@-"]);
insta::assert_snapshot!(stderr, @"Error: Commit is already signed, use --force to sign anyway");
}

#[test]
fn test_force_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", "init"]);

test_env.jj_cmd_ok(&repo_path, &["sign", "-r", "@-"]);
let (_, stderr) = test_env.jj_cmd_ok(&repo_path, &["sign", "--force", "-r", "@-"]);
insta::assert_snapshot!(stderr, @r"
Rebased 1 descendant commits
Working copy now at: rlvkpnrz 1c141424 (empty) (no description set)
Parent commit : qpvuntsm a9cc7c27 (empty) init
Commit was signed: qpvuntsm a9cc7c27 (empty) init
");
}

#[test]
fn test_different_author() {
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,
&[
"desc",
"--author",
"Someone Else <[email protected]>",
"-m",
"init",
"@-",
],
);
let stderr = test_env.jj_cmd_failure(&repo_path, &["sign", "-r", "@-"]);
insta::assert_snapshot!(stderr, @"Error: Commit is not authored by you, use --force to sign anyway");
}
4 changes: 3 additions & 1 deletion lib/src/commit_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,9 @@ impl DetachedCommitBuilder {
}

pub fn set_sign_key(&mut self, sign_key: Option<String>) -> &mut Self {
self.sign_settings.key = sign_key;
if sign_key.is_some() {
self.sign_settings.key = sign_key;
}
self
}
Comment on lines 334 to 339
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: looking at the overall method now (my previous nit was a mechanical refactor to avoid an unnecessary clone) - set_sign_key does nothing if given option is None, which is a bit.. meh, idk - could change it to accept sign_key: String directly and make the callers do the if let Some(..) = ..?


Expand Down