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).
122 lines
3.7 KiB
Python
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())
|