Files
Kevin Veen-Birkenbach 62523ba6b0 feat: add git-setup-remotes and git-sign-push CLIs
Ship the two maintainer workflow helpers as installable Python CLIs so
any fork-based OSS project can reuse them without vendoring shell
scripts:

  * git-setup-remotes configures origin/fork/main-tracking/pushDefault
    for a fork-based clone. URLs are parameterized (--canonical / --fork
    or CANONICAL_URL / FORK_URL) so the same binary bootstraps any
    maintainer's repo. Idempotent.
  * git-sign-push GPG-signs every unpushed commit on the current branch
    and pushes, resolving the target remote from remote.pushDefault
    (falling back to origin) for branches without upstream.

Both refuse to run when CLAUDE_CODE/CLAUDECODE is set, since the Claude
sandbox blocks .git/config writes and access to ~/.gnupg: failing fast
beats failing late.

Other additions:
  * Makefile: install / install-dev / test / lint / clean targets.
  * .github/workflows/test.yml: pytest + ruff matrix for py 3.10/11/12.
  * MIRRORS: github, git.veen.world:2201, code.infinito.nexus:2201, pypi.
  * LICENSE switched to MIT; README records the extraction origin
    (s.infinito.nexus/code) and adds author + license sections.
  * Tests cover sandbox-refusal guards and the fork-URL resolution
    preference order (CLI arg > env var > existing fork remote > origin
    when not canonical).
2026-04-24 20:33:38 +02:00

122 lines
3.7 KiB
Python

"""`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 <remote> <branch>`.
"""
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())