From 919e6a6434554d1a5ce32f857fda10ba2cb9d28f Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sun, 1 Nov 2026 00:49:07 +0100 Subject: [PATCH] feat(push): re-sign and lease every mirror pushurl pkgmgr push now signs unpushed commits like git-sign-push and pushes each pushurl with its own --force-with-lease: the fetch URL against the tracking ref, every mirror against its own fetched tip, each only after git cherry shows no commit exists only there. --rewrite allows a deliberate history rewrite. Without a repository selection it pushes the current checkout, including worktrees outside the base dir. pkgmgr release and release --retry sign before tagging and push the branch and tag through the same path. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/pkgmgr/actions/release/retry.py | 7 +- src/pkgmgr/actions/release/workflow.py | 10 +- src/pkgmgr/actions/repository/push.py | 246 +++++++++++++++++- src/pkgmgr/cli/proxy.py | 29 ++- src/pkgmgr/core/git/commands/__init__.py | 3 - src/pkgmgr/core/git/commands/push_args.py | 37 --- tests/integration/test_push_mirror_leases.py | 206 +++++++++++++++ .../unit/pkgmgr/actions/release/test_retry.py | 20 +- 8 files changed, 488 insertions(+), 70 deletions(-) delete mode 100644 src/pkgmgr/core/git/commands/push_args.py create mode 100644 tests/integration/test_push_mirror_leases.py diff --git a/src/pkgmgr/actions/release/retry.py b/src/pkgmgr/actions/release/retry.py index 29de831..88ab699 100644 --- a/src/pkgmgr/actions/release/retry.py +++ b/src/pkgmgr/actions/release/retry.py @@ -10,7 +10,7 @@ the side effects downstream are incomplete. `retry_release` re-runs the idempotent tail of that flow so a botched release can be re-pushed without touching code or recreating tags: - * `git push origin ` for the existing HEAD tag + * push the branch and the existing HEAD tag through `pkgmgr push`'s leases * re-align the floating `latest` tag if HEAD tag is the highest Publishing (PyPI etc.) stays the caller's responsibility — the publish @@ -23,7 +23,8 @@ from __future__ import annotations import os from pkgmgr.actions.publish.git_tags import head_semver_tags -from pkgmgr.core.git import GitRunError, run +from pkgmgr.actions.repository.push import push_branch +from pkgmgr.core.git import GitRunError from pkgmgr.core.git.queries import get_current_branch from pkgmgr.core.version.semver import SemVer @@ -50,7 +51,7 @@ def retry_release( tag = max(tags, key=SemVer.parse) print(f"Re-pushing existing tag: {tag}") - run(["push", "origin", branch, tag], preview=preview) + push_branch(".", [], refs=(tag,), preview=preview) try: if is_highest_version_tag(tag): diff --git a/src/pkgmgr/actions/release/workflow.py b/src/pkgmgr/actions/release/workflow.py index 319f733..e9bd2cc 100644 --- a/src/pkgmgr/actions/release/workflow.py +++ b/src/pkgmgr/actions/release/workflow.py @@ -4,7 +4,8 @@ import os import sys from pkgmgr.actions.branch import close_branch -from pkgmgr.core.git import GitRunError, run +from pkgmgr.actions.repository.push import push_branch, sign_unpushed +from pkgmgr.core.git import GitRunError from pkgmgr.core.git.commands import add, commit, tag_annotated from pkgmgr.core.git.queries import get_current_branch from pkgmgr.core.repository.paths import resolve_repo_paths @@ -136,8 +137,9 @@ def _release_impl( if preview: add(existing_files, preview=True) commit(commit_msg, all=True, preview=True) + sign_unpushed(".", preview=True) tag_annotated(new_tag, tag_msg, preview=True) - run(["push", "origin", branch, new_tag], preview=True) + push_branch(".", [], refs=(new_tag,), preview=True) if is_highest_version_tag(new_tag): update_latest_tag(new_tag, preview=True) @@ -157,10 +159,10 @@ def _release_impl( add(existing_files, preview=False) commit(commit_msg, all=True, preview=False) + sign_unpushed(".", preview=False) tag_annotated(new_tag, tag_msg, preview=False) - # Push branch and ONLY the newly created version tag in one command (no --tags) - run(["push", "origin", branch, new_tag], preview=False) + push_branch(".", [], refs=(new_tag,), preview=False) # Update 'latest' only if this is the highest version tag try: diff --git a/src/pkgmgr/actions/repository/push.py b/src/pkgmgr/actions/repository/push.py index db645ce..9266bfe 100644 --- a/src/pkgmgr/actions/repository/push.py +++ b/src/pkgmgr/actions/repository/push.py @@ -1,24 +1,249 @@ from __future__ import annotations +import os +import subprocess +import threading from typing import Any from pkgmgr.actions.repository._parallel import ( + RepoRef, resolve_repos, run_on_repos, ) -from pkgmgr.core.git.commands import GitPushArgsError, push_args +from pkgmgr.core.git import GitRunError, run Repository = dict[str, Any] +# Re-signing opens pinentry; parallel pushes must not ask for the passphrase at once. +_SIGN_LOCK = threading.Lock() -def _push_one(repo_dir: str, extra_args: list[str], preview: bool) -> tuple[bool, str]: + +class PushRefusedError(RuntimeError): + """Raised when a push would ship unsigned commits or overwrite a mirror.""" + + +def _try_git(repo_dir: str, *args: str) -> str | None: try: - push_args(extra_args, cwd=repo_dir, preview=preview) + return run(list(args), cwd=repo_dir) + except GitRunError: + return None + + +def _push_target(repo_dir: str) -> tuple[str, str]: + branch = run(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_dir) + if branch == "HEAD": + raise PushRefusedError(f"{repo_dir}: detached HEAD, nothing to push") + remote = ( + _try_git(repo_dir, "config", "--get", f"branch.{branch}.pushRemote") + or _try_git(repo_dir, "config", "--get", "remote.pushDefault") + or _try_git(repo_dir, "config", "--get", f"branch.{branch}.remote") + or "origin" + ) + return branch, remote + + +def _unpushed_signatures(repo_dir: str, remote: str) -> tuple[str, list[str]]: + for ref in ("@{u}", f"{remote}/main", f"{remote}/master"): + base = _try_git(repo_dir, "merge-base", "HEAD", ref) + if base: + return base, run( + ["log", "--format=%G?", f"{base}..HEAD"], cwd=repo_dir + ).split() + raise PushRefusedError( + f"{repo_dir}: no upstream and no {remote}/main or {remote}/master to sign against" + ) + + +def sign_unpushed(repo_dir: str, *, preview: bool = False) -> None: + """ + GPG-sign every unpushed commit that is not signed yet, like git-sign-push. + + Args: + repo_dir: checkout whose current branch is signed. + preview: print the fetch and rebase instead of running them. + """ + _branch, remote = _push_target(repo_dir) + run(["fetch", "--quiet", remote], cwd=repo_dir, preview=preview) + base, signatures = _unpushed_signatures(repo_dir, remote) + unsigned = sum(1 for s in signatures if s != "G") + if not unsigned: + return + if run(["status", "--porcelain", "--untracked-files=no"], cwd=repo_dir): + raise PushRefusedError(f"{repo_dir}: uncommitted changes, cannot re-sign") + + cmd = ["git", "rebase", "--rebase-merges", "--force-rebase", "-S", base] + print(f"[push] Signing {unsigned} of {len(signatures)} commit(s) in {repo_dir}") + if preview: + print(f"[PREVIEW] Would run in {repo_dir!r}: {' '.join(cmd)}") + return + with _SIGN_LOCK: + result = subprocess.run( + cmd, + cwd=repo_dir, + env={**os.environ, "GIT_SEQUENCE_EDITOR": ":"}, + check=False, + ) + if result.returncode != 0: + raise PushRefusedError( + f"{repo_dir}: re-sign rebase failed; resolve or `git rebase --abort`" + ) + + +def _lease( + repo_dir: str, + url: str, + branch_ref: str, + tracking: str | None, + keep: tuple[str, ...], + preview: bool, +) -> str: + if tracking is not None: + sha = _try_git(repo_dir, "rev-parse", "--verify", "--quiet", tracking) or "" + else: + listed = run(["ls-remote", url, branch_ref], cwd=repo_dir).splitlines() + sha = next( + (ln.split()[0] for ln in listed if ln.split()[1:] == [branch_ref]), "" + ) + if sha and not preview: + run(["fetch", "--quiet", "--no-tags", url, branch_ref], cwd=repo_dir) + if not sha or preview: + return sha + lost: set[str] | None = None + for tip in keep: + plus = { + line[2:] + for line in run(["cherry", tip, sha], cwd=repo_dir).splitlines() + if line.startswith("+") + } + lost = plus if lost is None else lost & plus + if lost: + raise PushRefusedError( + f"{url}: {len(lost)} commit(s) exist only there, not overwritten: " + f"{' '.join(s[:10] for s in sorted(lost))}; " + "pass --rewrite for a deliberate history rewrite" + ) + return sha + + +def push_branch( + repo_dir: str, + extra_args: list[str], + *, + refs: tuple[str, ...] = (), + rewrite: bool = False, + preview: bool = False, +) -> None: + """ + Push the current branch to every pushurl of its push remote, each with its own lease. + + The fetch URL is leased against the remote-tracking ref, every other + pushurl against its own branch tip; each only after `git cherry` shows it + holds no commit the new tip lacks. A mirror failure is reported after the + others were pushed; a canonical failure stops before any mirror. + + Args: + repo_dir: checkout whose current branch is pushed. + extra_args: flags appended to every `git push`. + refs: extra refs (e.g. a release tag) pushed to every URL. + rewrite: replace commits of the old canonical tip; the fetch URL skips + the cherry check, mirrors only refuse commits the old tip never had. + preview: print the pushes instead of running them. + """ + branch, remote = _push_target(repo_dir) + _base, signatures = _unpushed_signatures(repo_dir, remote) + if not preview and any(s != "G" for s in signatures): + raise PushRefusedError( + f"{repo_dir}: {sum(s != 'G' for s in signatures)} unsigned commit(s) " + "in the push range; run `pkgmgr push` to sign them" + ) + + head = run(["rev-parse", "HEAD"], cwd=repo_dir) + fetch_url = run(["remote", "get-url", remote], cwd=repo_dir) + push_urls = run( + ["remote", "get-url", "--push", "--all", remote], cwd=repo_dir + ).splitlines() + branch_ref = f"refs/heads/{branch}" + tracking = f"refs/remotes/{remote}/{branch}" + old_tip = _try_git(repo_dir, "rev-parse", "--verify", "--quiet", tracking) + if fetch_url not in push_urls: + print( + f"[push] WARN {remote}: fetch URL is no pushurl; every URL is leased " + "as a mirror, tracking ref and upstream stay untouched" + ) + + failed: list[str] = [] + for url in sorted(push_urls, key=lambda u: u != fetch_url): + if url == fetch_url: + keep: tuple[str, ...] = () if rewrite else (head,) + else: + keep = (head, old_tip) if rewrite and old_tip else (head,) + try: + expect = _lease( + repo_dir, + url, + branch_ref, + tracking if url == fetch_url else None, + keep, + preview, + ) + run( + [ + "push", + "--follow-tags", + f"--force-with-lease={branch_ref}:{expect}", + *extra_args, + url, + branch_ref, + *refs, + ], + cwd=repo_dir, + preview=preview, + ) + except (GitRunError, PushRefusedError) as exc: + if url == fetch_url: + raise + print(f"[push] FAILED {url}\n{exc}") + failed.append(url) + continue + print(f"[push] {url} <- {branch} {head[:10]}") + if url == fetch_url and not preview: + run(["update-ref", tracking, head], cwd=repo_dir) + if not _try_git(repo_dir, "rev-parse", "--abbrev-ref", "@{u}"): + run(["branch", f"--set-upstream-to={remote}/{branch}"], cwd=repo_dir) + + if failed: + raise PushRefusedError(f"{repo_dir}: push failed for {', '.join(failed)}") + + +def _push_one( + repo_dir: str, extra_args: list[str], rewrite: bool, preview: bool +) -> tuple[bool, str]: + try: + sign_unpushed(repo_dir, preview=preview) + push_branch(repo_dir, extra_args, rewrite=rewrite, preview=preview) return (True, "") - except GitPushArgsError as exc: + except (GitRunError, PushRefusedError) as exc: return (False, str(exc)) +def push_repo_dirs( + repos: list[RepoRef], + extra_args: list[str], + preview: bool, + jobs: int = 1, + rewrite: bool = False, +) -> None: + """ + Sign and push each ``(identifier, repo_dir)``, optionally in parallel. + """ + run_on_repos( + repos, + lambda rd: _push_one(rd, extra_args, rewrite, preview), + jobs=jobs, + op_name="push", + ) + + def push_in_parallel( selected_repos: list[Repository], repositories_base_dir: str, @@ -26,14 +251,15 @@ def push_in_parallel( extra_args: list[str], preview: bool, jobs: int = 1, + rewrite: bool = False, ) -> None: """ - Execute `git push` for each repository, optionally in parallel. + Sign and push each selected repository, optionally in parallel. """ - repos = resolve_repos(selected_repos, repositories_base_dir, all_repos) - run_on_repos( - repos, - lambda rd: _push_one(rd, extra_args, preview), + push_repo_dirs( + resolve_repos(selected_repos, repositories_base_dir, all_repos), + extra_args, + preview, jobs=jobs, - op_name="push", + rewrite=rewrite, ) diff --git a/src/pkgmgr/cli/proxy.py b/src/pkgmgr/cli/proxy.py index 72997b8..7ec831f 100644 --- a/src/pkgmgr/cli/proxy.py +++ b/src/pkgmgr/cli/proxy.py @@ -8,8 +8,9 @@ from typing import Any from pkgmgr.actions.proxy import exec_proxy_command from pkgmgr.actions.repository.clone import clone_repos from pkgmgr.actions.repository.pull import pull_with_verification -from pkgmgr.actions.repository.push import push_in_parallel +from pkgmgr.actions.repository.push import push_in_parallel, push_repo_dirs from pkgmgr.cli.context import CLIContext +from pkgmgr.core.git import GitRunError, run from pkgmgr.core.repository.dir import get_repo_dir from pkgmgr.core.repository.selected import get_selected_repos @@ -185,6 +186,16 @@ def register_proxy_commands( "(default: min(cpu_count, 8)). Use 1 for sequential." ), ) + if subcommand == "push": + parser.add_argument( + "--rewrite", + action="store_true", + default=False, + help=( + "Deliberate history rewrite: replace commits the remote " + "already has (amend, squash, drop); leases still apply." + ), + ) if subcommand == "clone": parser.add_argument( "--clone-mode", @@ -209,6 +220,21 @@ def maybe_handle_proxy(args: argparse.Namespace, ctx: CLIContext) -> bool: if args.command not in all_proxy_subcommands: return False + if args.command == "push" and not _proxy_has_explicit_selection(args): + try: + toplevel = run(["rev-parse", "--show-toplevel"]) + except GitRunError: + print("[ERROR] Current directory is not inside a git checkout.") + sys.exit(1) + push_repo_dirs( + [(toplevel, toplevel)], + args.extra_args, + args.preview, + jobs=args.jobs, + rewrite=args.rewrite, + ) + sys.exit(0) + # Default semantics: without explicit selection → repo of current folder. if _proxy_has_explicit_selection(args): selected = get_selected_repos(args, ctx.all_repositories) @@ -252,6 +278,7 @@ def maybe_handle_proxy(args: argparse.Namespace, ctx: CLIContext) -> bool: args.extra_args, args.preview, jobs=args.jobs, + rewrite=args.rewrite, ) else: exec_proxy_command( diff --git a/src/pkgmgr/core/git/commands/__init__.py b/src/pkgmgr/core/git/commands/__init__.py index d072886..d5bc9d5 100644 --- a/src/pkgmgr/core/git/commands/__init__.py +++ b/src/pkgmgr/core/git/commands/__init__.py @@ -19,7 +19,6 @@ from .pull import GitPullError, pull from .pull_args import GitPullArgsError, pull_args from .pull_ff_only import GitPullFfOnlyError, pull_ff_only from .push import GitPushError, push -from .push_args import GitPushArgsError, push_args from .push_upstream import GitPushUpstreamError, push_upstream from .set_remote_url import GitSetRemoteUrlError, set_remote_url from .tag_annotated import GitTagAnnotatedError, tag_annotated @@ -43,7 +42,6 @@ __all__ = [ "GitPullArgsError", "GitPullError", "GitPullFfOnlyError", - "GitPushArgsError", "GitPushError", "GitPushUpstreamError", "GitSetRemoteUrlError", @@ -67,7 +65,6 @@ __all__ = [ "pull_args", "pull_ff_only", "push", - "push_args", "push_upstream", "set_remote_url", "tag_annotated", diff --git a/src/pkgmgr/core/git/commands/push_args.py b/src/pkgmgr/core/git/commands/push_args.py deleted file mode 100644 index fb36daa..0000000 --- a/src/pkgmgr/core/git/commands/push_args.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -from ..errors import GitCommandError, GitRunError -from ..run import run - - -class GitPushArgsError(GitCommandError): - """Raised when `git push` with arbitrary args fails.""" - - -def push_args( - args: list[str] | None = None, - *, - cwd: str = ".", - preview: bool = False, -) -> None: - """ - Execute `git push` with caller-provided arguments. - - Examples: - [] -> git push - ["--force"] -> git push --force - ["origin", "main"] -> git push origin main - ["-u", "origin", "feature"] -> git push -u origin feature - """ - extra = args or [] - try: - run(["push", *extra], cwd=cwd, preview=preview) - except GitRunError as exc: - details = getattr(exc, "output", None) or getattr(exc, "stderr", None) or "" - raise GitPushArgsError( - ( - f"Failed to run `git push` with args={extra!r} " - f"in cwd={cwd!r}.\n{details}" - ).rstrip(), - cwd=cwd, - ) from exc diff --git a/tests/integration/test_push_mirror_leases.py b/tests/integration/test_push_mirror_leases.py new file mode 100644 index 0000000..f398779 --- /dev/null +++ b/tests/integration/test_push_mirror_leases.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import io +import os +import shutil +import subprocess +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import patch + +from pkgmgr.actions.release.retry import retry_release +from pkgmgr.actions.repository.push import ( + PushRefusedError, + push_branch, + sign_unpushed, +) +from pkgmgr.cli.context import CLIContext +from pkgmgr.cli.parser import create_parser +from pkgmgr.cli.proxy import maybe_handle_proxy + + +def git(cwd: str, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True, text=True + ).stdout.strip() + + +@unittest.skipUnless(shutil.which("ssh-keygen"), "ssh-keygen signs the commits") +class TestPushMirrorLeases(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + root = Path(os.path.realpath(self._tmp.name)) + self.root = str(root) + key = root / "key" + subprocess.run( + ["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], + check=True, + ) + (root / "allowed").write_text(f"t@example.org {(root / 'key.pub').read_text()}") + (root / "gitconfig").write_text( + "[user]\n\tname = t\n\temail = t@example.org\n" + f"\tsigningkey = {key}\n" + "[gpg]\n\tformat = ssh\n" + f'[gpg "ssh"]\n\tallowedSignersFile = {root / "allowed"}\n' + "[commit]\n\tgpgsign = false\n" + "[init]\n\tdefaultBranch = main\n" + ) + env = patch.dict( + os.environ, + {"GIT_CONFIG_GLOBAL": str(root / "gitconfig"), "GIT_CONFIG_NOSYSTEM": "1"}, + ) + env.start() + self.addCleanup(env.stop) + + self.canonical = str(root / "canonical.git") + self.mirror = str(root / "mirror.git") + self.repo = str(root / "repo") + git(self.root, "init", "-q", "--bare", self.canonical) + git(self.root, "init", "-q", "--bare", self.mirror) + git(self.root, "init", "-q", self.repo) + git(self.repo, "remote", "add", "origin", self.canonical) + git(self.repo, "remote", "set-url", "--add", "--push", "origin", self.canonical) + git(self.repo, "remote", "set-url", "--add", "--push", "origin", self.mirror) + self.init = self._commit("init") + git(self.repo, "push", "-q", self.canonical, "main") + git(self.repo, "push", "-q", self.mirror, "main") + git(self.repo, "fetch", "-q", "origin") + git(self.repo, "branch", "-q", "--set-upstream-to=origin/main") + + def _commit(self, name: str, cwd: str | None = None, *flags: str) -> str: + cwd = cwd or self.repo + Path(cwd, f"{name}.txt").write_text(name) + git(cwd, "add", f"{name}.txt") + git(cwd, "commit", "-q", *flags, "-m", name) + return git(cwd, "rev-parse", "HEAD") + + def _push(self, rewrite: bool = False) -> str: + with redirect_stdout(io.StringIO()) as out: + sign_unpushed(self.repo) + push_branch(self.repo, [], rewrite=rewrite) + return out.getvalue() + + def _tip(self, bare: str, ref: str = "refs/heads/main") -> str: + return subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", ref], + cwd=bare, + check=False, + capture_output=True, + text=True, + ).stdout.strip() + + def _assert_shipped_signed(self) -> None: + head = git(self.repo, "rev-parse", "HEAD") + self.assertEqual(self._tip(self.canonical), head) + self.assertEqual(self._tip(self.mirror), head) + signatures = git(self.repo, "log", "--format=%G?", f"{self.init}..HEAD") + self.assertEqual(set(signatures.split()), {"G"}) + + def test_mirror_equal_gets_the_signed_commit(self) -> None: + self._commit("feature") + + self._push() + + self._assert_shipped_signed() + + def test_mirror_behind_is_fast_forwarded(self) -> None: + self._commit("first", None, "-S") + git(self.repo, "push", "-q", self.canonical, "main") + git(self.repo, "fetch", "-q", "origin") + self._commit("second") + + self._push() + + self._assert_shipped_signed() + + def test_mirror_on_older_rewrite_is_replaced_after_resign(self) -> None: + unsigned = self._commit("feature") + git(self.repo, "push", "-q", self.mirror, "main") + + self._push() + + self._assert_shipped_signed() + self.assertNotEqual(self._tip(self.mirror), unsigned) + + def test_mirror_only_commit_is_refused_and_kept(self) -> None: + other = os.path.join(self.root, "other") + git(self.root, "clone", "-q", self.mirror, other) + foreign = self._commit("foreign", other) + git(other, "push", "-q", "origin", "main") + self._commit("feature") + + with self.assertRaises(PushRefusedError) as cm: + self._push() + with self.assertRaises(PushRefusedError): + self._push(rewrite=True) + + self.assertIn(self.mirror, str(cm.exception)) + self.assertEqual(self._tip(self.mirror), foreign) + self.assertEqual(self._tip(self.canonical), git(self.repo, "rev-parse", "HEAD")) + + def test_content_rewrite_needs_the_rewrite_flag(self) -> None: + self._commit("feature") + self._push() + shipped = git(self.repo, "rev-parse", "HEAD") + Path(self.repo, "feature.txt").write_text("changed") + git(self.repo, "commit", "-q", "-a", "--amend", "--no-edit") + + with self.assertRaises(PushRefusedError) as cm: + self._push() + + self.assertIn(self.canonical, str(cm.exception)) + self.assertEqual(self._tip(self.canonical), shipped) + self.assertEqual(self._tip(self.mirror), shipped) + + self._push(rewrite=True) + + self._assert_shipped_signed() + self.assertNotEqual(self._tip(self.mirror), shipped) + + def test_worktree_outside_base_dir_is_pushed(self) -> None: + worktree = os.path.join(self.root, "wt") + git(self.repo, "worktree", "add", "-q", "-b", "feature", worktree) + head_before = self._commit("feature", worktree) + ctx = CLIContext( + config_merged={}, + repositories_base_dir=os.path.join(self.root, "base"), + all_repositories=[], + binaries_dir=os.path.join(self.root, "bin"), + user_config_path=os.path.join(self.root, "config.yaml"), + ) + args = create_parser("pkgmgr").parse_args(["push", "-j", "1"]) + cwd = os.getcwd() + os.chdir(worktree) + self.addCleanup(os.chdir, cwd) + + with redirect_stdout(io.StringIO()), self.assertRaises(SystemExit) as cm: + maybe_handle_proxy(args, ctx) + + self.assertEqual(cm.exception.code, 0) + head = git(worktree, "rev-parse", "HEAD") + self.assertNotEqual(head, head_before) + self.assertEqual(self._tip(self.canonical, "refs/heads/feature"), head) + self.assertEqual(self._tip(self.mirror, "refs/heads/feature"), head) + self.assertEqual( + git(worktree, "rev-parse", "--abbrev-ref", "@{u}"), "origin/feature" + ) + + def test_release_retry_refuses_unsigned_range(self) -> None: + self._commit("release") + git(self.repo, "tag", "-a", "v1.0.0", "-m", "v1.0.0") + cwd = os.getcwd() + os.chdir(self.repo) + self.addCleanup(os.chdir, cwd) + + with redirect_stdout(io.StringIO()), self.assertRaises(PushRefusedError): + retry_release(pyproject_path=os.path.join(self.repo, "pyproject.toml")) + + self.assertEqual(self._tip(self.canonical), git(self.repo, "rev-parse", "@{u}")) + self.assertEqual(self._tip(self.canonical, "refs/tags/v1.0.0"), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/pkgmgr/actions/release/test_retry.py b/tests/unit/pkgmgr/actions/release/test_retry.py index c49e33c..b3bbecc 100644 --- a/tests/unit/pkgmgr/actions/release/test_retry.py +++ b/tests/unit/pkgmgr/actions/release/test_retry.py @@ -65,7 +65,7 @@ class TestReleaseRetry(unittest.TestCase): class TestRetryRelease(unittest.TestCase): @patch("pkgmgr.actions.release.retry.update_latest_tag") @patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=True) - @patch("pkgmgr.actions.release.retry.run") + @patch("pkgmgr.actions.release.retry.push_branch") @patch("pkgmgr.actions.release.retry.head_semver_tags") @patch( "pkgmgr.actions.release.retry.get_current_branch", @@ -75,7 +75,7 @@ class TestRetryRelease(unittest.TestCase): self, _mock_branch, mock_head_tags, - mock_run, + mock_push, _mock_highest, mock_update_latest, ) -> None: @@ -83,14 +83,12 @@ class TestRetryRelease(unittest.TestCase): retry_release(pyproject_path="pyproject.toml", preview=False) - mock_run.assert_called_once_with( - ["push", "origin", "release-fix", "v1.13.4"], preview=False - ) + mock_push.assert_called_once_with(".", [], refs=("v1.13.4",), preview=False) mock_update_latest.assert_called_once_with("v1.13.4", preview=False) @patch("pkgmgr.actions.release.retry.update_latest_tag") @patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=False) - @patch("pkgmgr.actions.release.retry.run") + @patch("pkgmgr.actions.release.retry.push_branch") @patch("pkgmgr.actions.release.retry.head_semver_tags") @patch( "pkgmgr.actions.release.retry.get_current_branch", @@ -100,7 +98,7 @@ class TestRetryRelease(unittest.TestCase): self, _mock_branch, mock_head_tags, - _mock_run, + _mock_push, _mock_highest, mock_update_latest, ) -> None: @@ -127,7 +125,7 @@ class TestRetryRelease(unittest.TestCase): @patch("pkgmgr.actions.release.retry.update_latest_tag") @patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=True) - @patch("pkgmgr.actions.release.retry.run") + @patch("pkgmgr.actions.release.retry.push_branch") @patch("pkgmgr.actions.release.retry.head_semver_tags") @patch( "pkgmgr.actions.release.retry.get_current_branch", @@ -137,7 +135,7 @@ class TestRetryRelease(unittest.TestCase): self, _mock_branch, mock_head_tags, - mock_run, + mock_push, _mock_highest, _mock_update_latest, ) -> None: @@ -145,9 +143,7 @@ class TestRetryRelease(unittest.TestCase): retry_release(pyproject_path="pyproject.toml", preview=False) - mock_run.assert_called_once_with( - ["push", "origin", "main", "v2.0.0"], preview=False - ) + mock_push.assert_called_once_with(".", [], refs=("v2.0.0",), preview=False) if __name__ == "__main__":