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).
This commit is contained in:
130
src/git_maintainer_tools/_git.py
Normal file
130
src/git_maintainer_tools/_git.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Shared helpers for interacting with git and the Claude sandbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
|
||||
class GitError(RuntimeError):
|
||||
"""Raised when a git subprocess returns a non-zero exit status."""
|
||||
|
||||
|
||||
def run_git(
|
||||
*args: str,
|
||||
check: bool = True,
|
||||
capture: bool = True,
|
||||
quiet: bool = False,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run `git <args>` and return the completed-process handle.
|
||||
|
||||
`check=True` raises `GitError` on non-zero exit. `capture=True` routes
|
||||
stdout/stderr through pipes so callers can inspect output; setting it
|
||||
to False lets git write straight to the terminal (useful for
|
||||
long-running commands like `git push` or `git rebase`).
|
||||
"""
|
||||
cmd: list[str] = ["git", *args]
|
||||
kwargs: dict = {}
|
||||
if capture:
|
||||
kwargs["stdout"] = subprocess.PIPE
|
||||
kwargs["stderr"] = subprocess.PIPE
|
||||
kwargs["text"] = True
|
||||
proc = subprocess.run(cmd, **kwargs)
|
||||
if check and proc.returncode != 0:
|
||||
stderr = (proc.stderr or "") if capture else ""
|
||||
if not quiet:
|
||||
sys.stderr.write(stderr)
|
||||
raise GitError(
|
||||
f"git {' '.join(args)} exited {proc.returncode}: {stderr.strip()}"
|
||||
)
|
||||
return proc
|
||||
|
||||
|
||||
def git_out(*args: str) -> str:
|
||||
"""Run a git command that is expected to succeed and return stdout (stripped)."""
|
||||
return run_git(*args).stdout.strip()
|
||||
|
||||
|
||||
def has_remote(name: str) -> bool:
|
||||
try:
|
||||
run_git("remote", "get-url", name, quiet=True)
|
||||
return True
|
||||
except GitError:
|
||||
return False
|
||||
|
||||
|
||||
def remote_url(name: str) -> str | None:
|
||||
if not has_remote(name):
|
||||
return None
|
||||
return git_out("remote", "get-url", name)
|
||||
|
||||
|
||||
def ensure_not_sandboxed(tool_name: str, reason: str) -> None:
|
||||
"""Abort the CLI when invoked inside the Claude sandbox.
|
||||
|
||||
Claude Code exports `CLAUDE_CODE` / `CLAUDECODE` inside the sandbox.
|
||||
Writes to `.git/config` (and `~/.gnupg` access) are blocked there per
|
||||
the Git Safety Protocol, so running these tools inside would always
|
||||
fail late. Fail fast instead.
|
||||
"""
|
||||
if os.environ.get("CLAUDE_CODE") or os.environ.get("CLAUDECODE"):
|
||||
sys.stderr.write(
|
||||
f"ERROR: {tool_name} must run outside the Claude sandbox ({reason}).\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def ensure_clean_tree() -> None:
|
||||
if run_git("diff", "--quiet", check=False, capture=False).returncode != 0 \
|
||||
or run_git("diff", "--cached", "--quiet", check=False, capture=False).returncode != 0:
|
||||
sys.stderr.write(
|
||||
"ERROR: uncommitted changes present. Commit or stash before signing.\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def current_branch() -> str:
|
||||
branch = git_out("rev-parse", "--abbrev-ref", "HEAD")
|
||||
if branch == "HEAD":
|
||||
sys.stderr.write("ERROR: detached HEAD.\n")
|
||||
sys.exit(1)
|
||||
return branch
|
||||
|
||||
|
||||
def first_existing_rev(candidates: Iterable[str]) -> str | None:
|
||||
for cand in candidates:
|
||||
proc = run_git("rev-parse", "--verify", "--quiet", cand, check=False)
|
||||
if proc.returncode == 0:
|
||||
return cand
|
||||
return None
|
||||
|
||||
|
||||
def resolve_env_or_arg(
|
||||
arg_value: str | None, env_var: str, label: str
|
||||
) -> str | None:
|
||||
"""Return the first non-empty value from the CLI arg or the env var."""
|
||||
if arg_value:
|
||||
return arg_value
|
||||
value = os.environ.get(env_var, "").strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def abbrev_ref(rev: str) -> str | None:
|
||||
proc = run_git(
|
||||
"rev-parse", "--abbrev-ref", "--symbolic-full-name", rev, check=False
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
value = (proc.stdout or "").strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def rev_count(range_spec: str) -> int:
|
||||
return int(git_out("rev-list", "--count", range_spec))
|
||||
|
||||
|
||||
def format_cmd_preview(cmd: Sequence[str]) -> str:
|
||||
"""Render a command list for the help messages that tell the user what ran."""
|
||||
return " ".join(cmd)
|
||||
Reference in New Issue
Block a user