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) <noreply@anthropic.com>
This commit is contained in:
1 parent
4a4bf137ac
commit
919e6a6434
8 files changed
+488
-70
No files matched your search
@@ -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 <branch> <tag>` 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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
+28
-1
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user