diff --git a/docs/revup.md b/docs/revup.md index 8f39658..0208455 100644 --- a/docs/revup.md +++ b/docs/revup.md @@ -98,6 +98,18 @@ expressions that match all possible base branches. Used to determine which branches are supported by base branch detection. See manpage of git-for-each-ref/fnmatch(3) for more info on glob syntax. +**--gpg-sign, --no-gpg-sign** +: Sign commits that revup creates internally (cherry-picks, synthetic +commits, and virtual diff targets) with GPG or SSH. This is required +for repositories that enforce verified-signature rules on pushed +branches. If not specified, the value of `git config commit.gpgSign` +is used. Signing uses the key configured by `user.signingKey` and +the format from `gpg.format`. + +Note: `git commit-tree` (the plumbing command revup uses to create +commits) intentionally ignores `commit.gpgSign`, which is why revup +must read it explicitly and pass `-S` itself. + # REVUP COMMANDS Revup is comprised of several sub-commands. diff --git a/revup/git.py b/revup/git.py index 6c4d88c..e45a314 100644 --- a/revup/git.py +++ b/revup/git.py @@ -135,6 +135,7 @@ async def make_git( base_branch_globs: str = "", keep_temp: bool = False, editor: str = "", + gpg_sign: Optional[bool] = None, ) -> "Git": if not git_path: git_path = get_default_git() @@ -157,6 +158,12 @@ async def get_editor() -> str: ret = os.environ.get("GIT_EDITOR", os.environ.get("EDITOR", "nano")) return ret + async def get_gpg_sign() -> bool: + if gpg_sign is not None: + return gpg_sign + val = await git_ctx.git_stdout("config", "--get", "commit.gpgSign", raiseonerror=False) + return val.strip().lower() in ("true", "yes", "on", "1") + ( repo_root, git_dir, @@ -172,6 +179,7 @@ async def get_editor() -> str: get_editor(), git_ctx.is_branch_or_commit(f"{remote_name}/{main_branch}"), ) + gpg_sign_enabled = await get_gpg_sign() if git_version: version_arr = [int(v) for v in git_version.split(".")] @@ -190,6 +198,7 @@ async def get_editor() -> str: git_ctx.email = email.lower() git_ctx.author = git_ctx.email.split("@")[0] git_ctx.editor = editor + git_ctx.gpg_sign = gpg_sign_enabled if not main_exists: if main_branch in COMMON_MAIN_BRANCHES: git_ctx.main_branch = COMMON_MAIN_BRANCHES[1 - COMMON_MAIN_BRANCHES.index(main_branch)] @@ -230,6 +239,7 @@ class Git: email: str author: str editor: str + gpg_sign: bool def __init__( self, @@ -245,6 +255,7 @@ def __init__( self.remote_name = remote_name self.keep_temp = keep_temp self.main_branch = main_branch + self.gpg_sign = False self.base_branch_globs = base_branch_globs.strip().splitlines() self.temp_dir = tempfile.TemporaryDirectory( # pylint: disable=consider-using-with prefix="revup_" @@ -580,6 +591,10 @@ async def commit_tree(self, commit_info: CommitHeader) -> GitCommitHash: "-m", commit_info.commit_msg, ] + if self.gpg_sign: + # git commit-tree (a plumbing command) intentionally ignores + # commit.gpgSign, so we pass -S explicitly when signing is enabled. + commit_tree_args.insert(2, "-S") for p in commit_info.parents: commit_tree_args.extend(["-p", p]) ret = await self.git_stdout(*commit_tree_args, env=git_env) diff --git a/revup/revup.py b/revup/revup.py index 21c5bf3..266d1f6 100755 --- a/revup/revup.py +++ b/revup/revup.py @@ -62,6 +62,7 @@ def make_toplevel_parser() -> RevupArgParser: revup_parser.add_argument("--main-branch", default="main") revup_parser.add_argument("--base-branch-globs", default="") revup_parser.add_argument("--git-version", default="2.43.0") + revup_parser.add_argument("--gpg-sign", action="store_true", default=None) for s in ShellType: revup_parser.add_argument( f"--prompt-completion-{s.value}", default="", help=argparse.SUPPRESS @@ -107,6 +108,7 @@ async def get_git(args: argparse.Namespace) -> git.Git: args.base_branch_globs, args.keep_temp, args.editor, + args.gpg_sign, ) return git_ctx diff --git a/tests/test_gpg.py b/tests/test_gpg.py new file mode 100644 index 0000000..7f80383 --- /dev/null +++ b/tests/test_gpg.py @@ -0,0 +1,81 @@ +from git_env import GitTestEnvironment, async_test + +from revup import git + + +async def _set_gpg_config(env, value): + await env.sh.sh(env._git_path, "config", "commit.gpgSign", value) + + +async def _make_git_in_env(env, gpg_sign=None): + return await git.make_git( + env.sh, + git_path=env._git_path, + remote_name="origin", + main_branch="main", + editor="true", + gpg_sign=gpg_sign, + ) + + +class TestMakeGitGpgSign: + @async_test + async def test_reads_from_git_config_true(self): + async with GitTestEnvironment() as env: + await _set_gpg_config(env, "true") + git_ctx = await _make_git_in_env(env) + assert git_ctx.gpg_sign is True + + @async_test + async def test_reads_from_git_config_false(self): + async with GitTestEnvironment() as env: + await _set_gpg_config(env, "false") + git_ctx = await _make_git_in_env(env) + assert git_ctx.gpg_sign is False + + @async_test + async def test_cli_override_true_when_config_unset(self): + async with GitTestEnvironment() as env: + git_ctx = await _make_git_in_env(env, gpg_sign=True) + assert git_ctx.gpg_sign is True + + @async_test + async def test_cli_override_false_wins_over_config(self): + async with GitTestEnvironment() as env: + await _set_gpg_config(env, "true") + git_ctx = await _make_git_in_env(env, gpg_sign=False) + assert git_ctx.gpg_sign is False + + +class TestCommitTreeSigning: + async def _get_real_commit_header(self, env): + commit_hash = await env.commit("base", {"a.txt": "hello"}) + raw = await env.git_ctx.rev_list(commit_hash, max_revs=1, header=True) + return git.parse_rev_list(raw)[0] + + async def _capture_commit_tree_args(self, env, gpg_sign, mocker): + commit_info = await self._get_real_commit_header(env) + env.git_ctx.gpg_sign = gpg_sign + + async def fake_git_stdout(*args, **kwargs): + return "fakecommithash" + + spy = mocker.patch.object(env.git_ctx, "git_stdout", side_effect=fake_git_stdout) + + result = await env.git_ctx.commit_tree(commit_info) + assert result == "fakecommithash" + return spy.call_args.args + + @async_test + async def test_dash_s_present_when_gpg_sign_enabled(self, mocker): + async with GitTestEnvironment() as env: + args = await self._capture_commit_tree_args(env, True, mocker) + assert "commit-tree" in args + assert "-S" in args + + @async_test + async def test_dash_s_absent_when_gpg_sign_disabled(self, mocker): + async with GitTestEnvironment() as env: + args = await self._capture_commit_tree_args(env, False, mocker) + assert "commit-tree" in args + assert "-S" not in args