From 62523ba6b02aa6859ab845acc5ecc924ab9a941f Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 24 Apr 2026 20:33:38 +0200 Subject: [PATCH] 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). --- .github/FUNDING.yml | 7 + .github/workflows/test.yml | 37 +++++ .gitignore | 46 +++++- LICENSE | 22 ++- MIRRORS | 4 + Makefile | 29 ++++ README.md | 74 +++++++++- flake.nix | 11 -- pyproject.toml | 12 +- src/git_maintainer_tools/__init__.py | 4 + src/git_maintainer_tools/_git.py | 130 +++++++++++++++++ src/git_maintainer_tools/setup_remotes.py | 169 ++++++++++++++++++++++ src/git_maintainer_tools/sign_push.py | 121 ++++++++++++++++ tests/test_resolve_fork_url.py | 61 ++++++++ tests/test_sandbox_refusal.py | 38 +++++ 15 files changed, 744 insertions(+), 21 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/test.yml create mode 100644 MIRRORS create mode 100644 Makefile delete mode 100644 flake.nix create mode 100644 src/git_maintainer_tools/__init__.py create mode 100644 src/git_maintainer_tools/_git.py create mode 100644 src/git_maintainer_tools/setup_remotes.py create mode 100644 src/git_maintainer_tools/sign_push.py create mode 100644 tests/test_resolve_fork_url.py create mode 100644 tests/test_sandbox_refusal.py diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..e5118e4 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,7 @@ +github: kevinveenbirkenbach + +patreon: kevinveenbirkenbach + +buy_me_a_coffee: kevinveenbirkenbach + +custom: https://s.veen.world/paypaldonate diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4940a22 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,37 @@ +name: tests + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +permissions: + contents: read + +jobs: + test: + name: test (python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install with dev extras + run: make install-dev + + - name: Lint + run: make lint + + - name: Run tests + run: make test diff --git a/.gitignore b/.gitignore index cb54ccd..75f6027 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,45 @@ -.venv/ -dist/ -build/ +# Prevents unwanted files from being committed to version control. + +# Python bytecode __pycache__/ *.pyc +*.pyo +*.pyd + +# Virtual environments +.venv/ +venv/ +.venvs/ + +# Build artefacts +dist/ +build/ +*.egg-info/ +src/*.egg-info/ +pip-wheel-metadata/ + +# Test / lint caches +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +.coverage.* +htmlcov/ +.tox/ + +# Nix +result +.nix/ +.nix-dev-installed + +# Editor files +.vscode/ +.idea/ +*.swp + +# OS noise +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/LICENSE b/LICENSE index c30e6e6..7d80bdb 100644 --- a/LICENSE +++ b/LICENSE @@ -1 +1,21 @@ -All rights reserved by Kevin Veen-Birkenbach +MIT License + +Copyright (c) 2026 Kevin Veen-Birkenbach + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MIRRORS b/MIRRORS new file mode 100644 index 0000000..4a3a0da --- /dev/null +++ b/MIRRORS @@ -0,0 +1,4 @@ +git@github.com:kevinveenbirkenbach/git-maintainer-tools.git +ssh://git@git.veen.world:2201/kevinveenbirkenbach/git-maintainer-tools.git +ssh://git@code.infinito.nexus:2201/kevinveenbirkenbach/git-maintainer-tools.git +https://pypi.org/project/git-maintainer-tools/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1e7ff80 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +.PHONY: help install install-dev test lint clean + +PYTHON ?= python3 + +help: + @echo "Targets:" + @echo " install Install the package (editable, runtime deps only)" + @echo " install-dev Install the package with dev extras (pytest, ruff)" + @echo " test Run the pytest test suite" + @echo " lint Run ruff against the source tree" + @echo " clean Remove build artefacts and test/lint caches" + +install: + $(PYTHON) -m pip install -e . + +install-dev: + $(PYTHON) -m pip install -e '.[dev]' + +test: + PYTHONPATH=src $(PYTHON) -m pytest tests/ -v + +lint: + $(PYTHON) -m ruff check --no-cache src/ tests/ + +clean: + rm -rf build dist *.egg-info src/*.egg-info \ + .pytest_cache .ruff_cache \ + .venv \ + **/__pycache__ diff --git a/README.md b/README.md index f9e1403..302f7de 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,72 @@ -# git-maintainer-tools +# git-maintainer-tools 🧰 +[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate) -Homepage: https://github.com/kevinveenbirkenbach/git-maintainer-tools -## Author -Kevin Veen-Birkenbach +Small CLIs for a fork-based OSS maintainer workflow. + +Homepage: [github.com/kevinveenbirkenbach/git-maintainer-tools](https://github.com/kevinveenbirkenbach/git-maintainer-tools) + +Originally extracted from [s.infinito.nexus/code](https://s.infinito.nexus/code), where these helpers started as shell scripts under `scripts/git/` before being rewritten in Python and split out as a standalone tool. + +## Tools 🔧 + +### `git-setup-remotes` 🌐 + +Configures a clone for a fork-based workflow and is idempotent. + +- `origin` points at the canonical repository. +- `fork` points at the maintainer's personal fork. +- `main` tracks `origin/main`. +- `remote.pushDefault` = `fork`, `push.default` = `current` so every `git push` and every `git push -u` for a new branch lands on the fork, not on the canonical repo. + +Usage: + +```bash +git-setup-remotes \ + --canonical git@github.com:/.git \ + --fork git@github.com:/.git +``` + +Both URLs may be provided via environment variables instead (`CANONICAL_URL`, `FORK_URL`). If `--fork` / `FORK_URL` is not given, the tool reuses an existing `fork` remote or an existing `origin` that does not point at canonical (clone-from-fork case). + +### `git-sign-push` 🔐 + +GPG-signs every unpushed commit on the current branch and pushes. + +- Refuses to run inside the Claude sandbox (where `~/.gnupg` is unreadable) and when the working tree is dirty. +- For a branch with an upstream: `git push --force-with-lease` after any required re-sign. +- For a branch without upstream: `git push -u ` where `` is resolved from `remote.pushDefault` (fallback: `origin`). In a repo configured by `git-setup-remotes`, this means new branches land on the fork. + +Usage: + +```bash +git-sign-push +``` + +## Install 📦 + +From the repo checkout: + +```bash +pip install . +``` + +Or for development: + +```bash +pip install -e '.[dev]' +``` + +Both entry points are registered in `pyproject.toml` and will be on your `$PATH` after install. + +## Sandbox 🏜️ + +Both CLIs refuse to run when `CLAUDE_CODE` or `CLAUDECODE` is set in the environment, because the Claude sandbox blocks `.git/config` writes (for `git-setup-remotes`) and access to `~/.gnupg` (for `git-sign-push`). The tools MUST be run by the human operator outside the sandbox. + +## Author ✍️ + +Kevin Veen-Birkenbach, [veen.world](https://www.veen.world/) + +## License 📜 + +Licensed under the [MIT License](LICENSE). diff --git a/flake.nix b/flake.nix deleted file mode 100644 index 769a8ca..0000000 --- a/flake.nix +++ /dev/null @@ -1,11 +0,0 @@ -{ - description = "git-maintainer-tools"; - inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - outputs = { self, nixpkgs }: - let system = "x86_64-linux"; pkgs = import nixpkgs { inherit system; }; - in { - devShells.${system}.default = pkgs.mkShell { - packages = with pkgs; [ python312 python312Packages.pytest python312Packages.ruff ]; - }; - }; -} diff --git a/pyproject.toml b/pyproject.toml index e42a71c..fb3c6dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,17 +5,25 @@ build-backend = "setuptools.build_meta" [project] name = "git-maintainer-tools" version = "0.1.0" -description = "" +description = "Small CLIs (git-setup-remotes, git-sign-push) for fork-based OSS maintainer workflows." readme = "README.md" requires-python = ">=3.10" authors = [{ name = "Kevin Veen-Birkenbach", email = "kevin@veen.world" }] -license = { text = "All rights reserved by Kevin Veen-Birkenbach" } +license = { text = "MIT" } urls = { Homepage = "https://github.com/kevinveenbirkenbach/git-maintainer-tools" } dependencies = [] +[project.optional-dependencies] +dev = ["pytest>=7", "ruff>=0.4"] + +[project.scripts] +git-setup-remotes = "git_maintainer_tools.setup_remotes:main" +git-sign-push = "git_maintainer_tools.sign_push:main" + [tool.setuptools] package-dir = {"" = "src"} [tool.setuptools.packages.find] where = ["src"] +include = ["git_maintainer_tools*"] diff --git a/src/git_maintainer_tools/__init__.py b/src/git_maintainer_tools/__init__.py new file mode 100644 index 0000000..f989002 --- /dev/null +++ b/src/git_maintainer_tools/__init__.py @@ -0,0 +1,4 @@ +"""git-maintainer-tools: small CLIs for fork-based OSS maintainer workflows.""" + +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/src/git_maintainer_tools/_git.py b/src/git_maintainer_tools/_git.py new file mode 100644 index 0000000..55c0ad5 --- /dev/null +++ b/src/git_maintainer_tools/_git.py @@ -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 ` 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) diff --git a/src/git_maintainer_tools/setup_remotes.py b/src/git_maintainer_tools/setup_remotes.py new file mode 100644 index 0000000..ee84dea --- /dev/null +++ b/src/git_maintainer_tools/setup_remotes.py @@ -0,0 +1,169 @@ +"""`git-setup-remotes`: configure the fork-based remote layout. + +Target state after a successful run: + + * `origin` points at the canonical repository (the project's upstream). + * `fork` points at the maintainer's personal fork. + * `main` tracks `origin/main` so `git pull` takes from canonical. + * `remote.pushDefault` = `fork`, `push.default` = `current` so every + `git push` without args (and new-branch pushes via sign-push) lands + on the fork, not on the canonical repo. + +The tool is idempotent: re-running on a correctly configured repo is a +no-op. URLs are taken from CLI args or environment variables; there is +no hardcoded project-specific default so the same binary can bootstrap +any maintainer's repo. +""" + +from __future__ import annotations + +import argparse +import sys + +from . import _git as g + + +TOOL_NAME = "git-setup-remotes" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog=TOOL_NAME, + description=__doc__.strip().splitlines()[0], + ) + parser.add_argument( + "--canonical", + help=( + "URL of the canonical repository (assigned to `origin`). " + "Falls back to the $CANONICAL_URL environment variable." + ), + ) + parser.add_argument( + "--fork", + help=( + "URL of the maintainer's personal fork (assigned to `fork`). " + "Falls back to the $FORK_URL environment variable; if neither " + "is set, the current `fork` remote or a non-canonical `origin` " + "is used." + ), + ) + return parser + + +def resolve_fork_url(arg_value: str | None, canonical_url: str) -> str | None: + """Preference order: + 1. --fork CLI arg or $FORK_URL env var. + 2. Existing `fork` remote (already configured). + 3. Existing `origin` remote *if* it does not point at canonical + (clone-from-fork case: origin currently holds the fork URL). + """ + explicit = g.resolve_env_or_arg(arg_value, "FORK_URL", "--fork") + if explicit: + return explicit + fork = g.remote_url("fork") + if fork: + return fork + origin = g.remote_url("origin") + if origin and origin != canonical_url: + return origin + return None + + +def configure_origin(canonical_url: str) -> None: + """Ensure `origin` points at the canonical URL. + + If `origin` already points somewhere else (typically the fork), move + it aside. Use `git remote rename origin fork` when possible so that + per-branch tracking refs under `remotes/origin/*` migrate to + `remotes/fork/*` automatically. + """ + if not g.has_remote("origin"): + g.run_git("remote", "add", "origin", canonical_url, capture=False) + return + + current = g.remote_url("origin") + if current == canonical_url: + return + + if g.has_remote("fork"): + # Both `origin` (= fork) and `fork` already exist. Drop the stale + # `origin` entry and add canonical fresh. + g.run_git("remote", "remove", "origin", capture=False) + else: + g.run_git("remote", "rename", "origin", "fork", capture=False) + g.run_git("remote", "add", "origin", canonical_url, capture=False) + + +def cleanup_legacy_upstream(canonical_url: str) -> None: + """Drop a legacy `upstream` remote when it duplicates `origin`.""" + if g.has_remote("upstream") and g.remote_url("upstream") == canonical_url: + g.run_git("remote", "remove", "upstream", capture=False) + + +def configure_fork(fork_url: str) -> None: + if g.has_remote("fork"): + if g.remote_url("fork") != fork_url: + g.run_git("remote", "set-url", "fork", fork_url, capture=False) + else: + g.run_git("remote", "add", "fork", fork_url, capture=False) + + +def wire_main_tracking() -> None: + """Point `main` at `origin/main` if both exist.""" + if g.run_git("rev-parse", "--verify", "--quiet", "main", check=False).returncode != 0: + return + if g.run_git("rev-parse", "--verify", "--quiet", "origin/main", check=False).returncode != 0: + return + g.run_git("branch", "--set-upstream-to=origin/main", "main", capture=False) + + +def apply_push_config() -> None: + g.run_git("config", "remote.pushDefault", "fork", capture=False) + g.run_git("config", "push.default", "current", capture=False) + + +def report_state() -> None: + print("Remotes:") + print(g.git_out("remote", "-v")) + print() + print(f"remote.pushDefault = {g.git_out('config', '--get', 'remote.pushDefault')}") + print(f"push.default = {g.git_out('config', '--get', 'push.default')}") + main_u = g.abbrev_ref("main@{u}") + if main_u: + print(f"main tracks = {main_u}") + + +def main(argv: list[str] | None = None) -> int: + g.ensure_not_sandboxed(TOOL_NAME, "it writes .git/config") + + args = build_parser().parse_args(argv) + canonical_url = g.resolve_env_or_arg(args.canonical, "CANONICAL_URL", "--canonical") + if not canonical_url: + sys.stderr.write( + "ERROR: missing canonical URL. Pass --canonical URL or set CANONICAL_URL.\n" + ) + return 1 + + fork_url = resolve_fork_url(args.fork, canonical_url) + if not fork_url: + sys.stderr.write( + "ERROR: cannot determine the fork URL.\n" + " Pass --fork URL or set FORK_URL, and re-run.\n" + ) + return 1 + + configure_origin(canonical_url) + cleanup_legacy_upstream(canonical_url) + configure_fork(fork_url) + + g.run_git("fetch", "--quiet", "origin", capture=False) + g.run_git("fetch", "--quiet", "fork", check=False, capture=False) + + wire_main_tracking() + apply_push_config() + report_state() + return 0 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/src/git_maintainer_tools/sign_push.py b/src/git_maintainer_tools/sign_push.py new file mode 100644 index 0000000..d485fc9 --- /dev/null +++ b/src/git_maintainer_tools/sign_push.py @@ -0,0 +1,121 @@ +"""`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()) diff --git a/tests/test_resolve_fork_url.py b/tests/test_resolve_fork_url.py new file mode 100644 index 0000000..38cf9c3 --- /dev/null +++ b/tests/test_resolve_fork_url.py @@ -0,0 +1,61 @@ +"""Fork URL resolution logic for `git-setup-remotes`.""" + +from __future__ import annotations + +from git_maintainer_tools import setup_remotes + + +def test_cli_arg_wins(monkeypatch): + monkeypatch.delenv("FORK_URL", raising=False) + # Make helpers fail fast so we know we did not fall through. + monkeypatch.setattr(setup_remotes.g, "remote_url", lambda _: None) + assert ( + setup_remotes.resolve_fork_url("git@host:x/y.git", "git@host:canon/z.git") + == "git@host:x/y.git" + ) + + +def test_env_var_used_when_arg_missing(monkeypatch): + monkeypatch.setenv("FORK_URL", "git@host:env/y.git") + monkeypatch.setattr(setup_remotes.g, "remote_url", lambda _: None) + assert ( + setup_remotes.resolve_fork_url(None, "git@host:canon/z.git") + == "git@host:env/y.git" + ) + + +def test_existing_fork_remote_used(monkeypatch): + monkeypatch.delenv("FORK_URL", raising=False) + + def fake(name): + return "git@host:existing/fork.git" if name == "fork" else None + + monkeypatch.setattr(setup_remotes.g, "remote_url", fake) + assert ( + setup_remotes.resolve_fork_url(None, "git@host:canon/z.git") + == "git@host:existing/fork.git" + ) + + +def test_origin_reused_when_not_canonical(monkeypatch): + monkeypatch.delenv("FORK_URL", raising=False) + + def fake(name): + return {"origin": "git@host:user/fork.git"}.get(name) + + monkeypatch.setattr(setup_remotes.g, "remote_url", fake) + assert ( + setup_remotes.resolve_fork_url(None, "git@host:canon/z.git") + == "git@host:user/fork.git" + ) + + +def test_origin_ignored_when_canonical(monkeypatch): + monkeypatch.delenv("FORK_URL", raising=False) + canonical = "git@host:canon/z.git" + + def fake(name): + return {"origin": canonical}.get(name) + + monkeypatch.setattr(setup_remotes.g, "remote_url", fake) + assert setup_remotes.resolve_fork_url(None, canonical) is None diff --git a/tests/test_sandbox_refusal.py b/tests/test_sandbox_refusal.py new file mode 100644 index 0000000..e656dd4 --- /dev/null +++ b/tests/test_sandbox_refusal.py @@ -0,0 +1,38 @@ +"""Both CLIs must refuse to run inside the Claude sandbox.""" + +from __future__ import annotations + +import pytest + +from git_maintainer_tools import setup_remotes, sign_push + + +@pytest.mark.parametrize( + "module,env", + [ + (setup_remotes, "CLAUDE_CODE"), + (setup_remotes, "CLAUDECODE"), + (sign_push, "CLAUDE_CODE"), + (sign_push, "CLAUDECODE"), + ], +) +def test_refuses_in_sandbox(monkeypatch, capsys, module, env): + monkeypatch.setenv(env, "1") + with pytest.raises(SystemExit) as exc: + module.main([]) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert "must run outside the Claude sandbox" in err + + +def test_setup_remotes_missing_canonical(monkeypatch, capsys): + monkeypatch.delenv("CLAUDE_CODE", raising=False) + monkeypatch.delenv("CLAUDECODE", raising=False) + monkeypatch.delenv("CANONICAL_URL", raising=False) + # Guard against any ambient FORK_URL that would let the CLI get past + # the canonical check before hitting the canonical error path. + monkeypatch.delenv("FORK_URL", raising=False) + + rc = setup_remotes.main([]) + assert rc == 1 + assert "missing canonical URL" in capsys.readouterr().err