"""`git-sign-push`: GPG-sign every unpushed commit on the current branch and push. Intended to replace a direct `git push` from inside an agent sandbox so that only the operator's GPG key (in `~/.gnupg`, unreadable from the sandbox) ever signs shipped commits. For a branch with no upstream, the push target is resolved from `remote.pushDefault` (falling back to `origin`), so the fork-based workflow from `git-setup-remotes` routes new branches to the personal fork rather than the canonical repo. """ from __future__ import annotations import os import sys from . import _git as g TOOL_NAME = "git-sign-push" DEFAULT_PUSH_REMOTE = "origin" FALLBACK_BASE_REFS = ("origin/main", "origin/master") def resolve_push_remote() -> str: """Return the configured push default, falling back to `origin`.""" proc = g.run_git( "config", "--default", DEFAULT_PUSH_REMOTE, "--get", "remote.pushDefault", check=False, ) if proc.returncode == 0: value = (proc.stdout or "").strip() if value: return value return DEFAULT_PUSH_REMOTE def resolve_base_commit() -> tuple[str, bool]: """Return (base_commit, needs_upstream). `needs_upstream` is True when the current branch has no upstream, which means the eventual push must be `git push -u `. """ upstream = g.abbrev_ref("@{u}") if upstream: return g.git_out("merge-base", "HEAD", upstream), False base_ref = g.first_existing_rev(FALLBACK_BASE_REFS) if not base_ref: sys.stderr.write( "ERROR: could not determine base commit (no upstream, no " "origin/main|master).\n" ) sys.exit(1) return g.git_out("merge-base", "HEAD", base_ref), True def count_unsigned(base: str) -> tuple[int, int]: """Return (total_commits_in_range, unsigned_count).""" total = g.rev_count(f"{base}..HEAD") if total == 0: return 0, 0 signed_status = g.git_out("log", "--format=%G?", f"{base}..HEAD").splitlines() unsigned = sum(1 for s in signed_status if s != "G") return total, unsigned def resign(base: str) -> None: """Rewrite every commit in base..HEAD with a GPG signature. `GIT_SEQUENCE_EDITOR=:` turns the interactive rebase TODO-list editor into a no-op so the rebase just replays the existing commits without reordering or squashing. `-S` signs each replayed commit. """ env = os.environ.copy() env["GIT_SEQUENCE_EDITOR"] = ":" import subprocess proc = subprocess.run( ["git", "rebase", "--rebase-merges", "-S", base], env=env, ) if proc.returncode != 0: sys.stderr.write("ERROR: GPG re-sign rebase failed.\n") sys.exit(proc.returncode) def push(push_remote: str, branch: str, needs_upstream: bool) -> None: if needs_upstream: g.run_git("push", "-u", push_remote, branch, capture=False) else: g.run_git("push", "--force-with-lease", capture=False) def main(argv: list[str] | None = None) -> int: g.ensure_not_sandboxed( TOOL_NAME, "gpg-agent/pinentry need access to ~/.gnupg" ) g.ensure_clean_tree() branch = g.current_branch() g.run_git("fetch", "--quiet", "origin", capture=False) base, needs_upstream = resolve_base_commit() total, unsigned = count_unsigned(base) if total == 0: print("Nothing to sign or push.") return 0 if unsigned > 0: print(f"Signing {unsigned} of {total} commit(s) in {base}..HEAD") resign(base) else: print(f"All {total} commit(s) already GPG-signed; skipping re-sign.") push_remote = resolve_push_remote() push(push_remote, branch, needs_upstream) return 0 if __name__ == "__main__": # pragma: no cover sys.exit(main())