fix(push): sign only an unsigned HEAD instead of rebasing the range
pkgmgr push re-signed every unpushed commit with rebase --rebase-merges -S, which rewrote history that mirrors or peers may already hold. It now amends only HEAD with -S (--no-edit --no-verify) and warns about older unsigned commits, which stay as they are; the push check refuses only an unsigned HEAD. The signing base prefers the branch's own remote-tracking ref. 🎯 Fix: 92 % 💥 Break: 3 % 💸 Cost: 100 % ⏱️ Time: 100 % 🛡️ Security: 100 % 🏆 Score: 89 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a324ed0b65
commit
fb6183838b
2 files changed
+81
-32
No files matched your search
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -42,51 +41,65 @@ def _push_target(repo_dir: str) -> tuple[str, str]:
|
|||||||
return branch, remote
|
return branch, remote
|
||||||
|
|
||||||
|
|
||||||
def _unpushed_signatures(repo_dir: str, remote: str) -> tuple[str, list[str]]:
|
def _unpushed_signatures(
|
||||||
for ref in ("@{u}", f"{remote}/main", f"{remote}/master"):
|
repo_dir: str, remote: str, branch: str
|
||||||
|
) -> tuple[str, dict[str, str]]:
|
||||||
|
for ref in (
|
||||||
|
f"refs/remotes/{remote}/{branch}",
|
||||||
|
"@{u}",
|
||||||
|
f"refs/remotes/{remote}/main",
|
||||||
|
f"refs/remotes/{remote}/master",
|
||||||
|
):
|
||||||
base = _try_git(repo_dir, "merge-base", "HEAD", ref)
|
base = _try_git(repo_dir, "merge-base", "HEAD", ref)
|
||||||
if base:
|
if base:
|
||||||
return base, run(
|
lines = run(
|
||||||
["log", "--format=%G?", f"{base}..HEAD"], cwd=repo_dir
|
["log", "--format=%H %G?", f"{base}..HEAD"], cwd=repo_dir
|
||||||
).split()
|
).splitlines()
|
||||||
|
return base, dict(line.split() for line in lines)
|
||||||
raise PushRefusedError(
|
raise PushRefusedError(
|
||||||
f"{repo_dir}: no upstream and no {remote}/main or {remote}/master to sign against"
|
f"{repo_dir}: no {remote}/{branch}, no upstream and no {remote}/main or "
|
||||||
|
f"{remote}/master to sign against"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def sign_unpushed(repo_dir: str, *, preview: bool = False) -> None:
|
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:
|
Args:
|
||||||
repo_dir: checkout whose current branch is signed.
|
repo_dir: checkout whose current branch is signed.
|
||||||
preview: print the fetch and rebase instead of running them.
|
preview: print the fetch and the amend instead of running them.
|
||||||
"""
|
"""
|
||||||
_branch, remote = _push_target(repo_dir)
|
branch, remote = _push_target(repo_dir)
|
||||||
run(["fetch", "--quiet", remote], cwd=repo_dir, preview=preview)
|
run(["fetch", "--quiet", remote], cwd=repo_dir, preview=preview)
|
||||||
base, signatures = _unpushed_signatures(repo_dir, remote)
|
base, signatures = _unpushed_signatures(repo_dir, remote, branch)
|
||||||
unsigned = sum(1 for s in signatures if s != "G")
|
head = run(["rev-parse", "HEAD"], cwd=repo_dir)
|
||||||
if not unsigned:
|
older = sum(1 for sha, s in signatures.items() if sha != head and s != "G")
|
||||||
|
if older:
|
||||||
|
print(
|
||||||
|
f"[push] WARN {older} older unsigned commit(s) in {base[:10]}..HEAD~ of "
|
||||||
|
f"{repo_dir} stay unsigned: signing them would rewrite history"
|
||||||
|
)
|
||||||
|
if signatures.get(head, "G") == "G":
|
||||||
return
|
return
|
||||||
if run(["status", "--porcelain", "--untracked-files=no"], cwd=repo_dir):
|
if run(["status", "--porcelain", "--untracked-files=no"], cwd=repo_dir):
|
||||||
raise PushRefusedError(f"{repo_dir}: uncommitted changes, cannot re-sign")
|
raise PushRefusedError(f"{repo_dir}: uncommitted changes, cannot sign HEAD")
|
||||||
|
|
||||||
cmd = ["git", "rebase", "--rebase-merges", "--force-rebase", "-S", base]
|
cmd = [
|
||||||
print(f"[push] Signing {unsigned} of {len(signatures)} commit(s) in {repo_dir}")
|
"git",
|
||||||
|
"commit",
|
||||||
|
"--amend",
|
||||||
|
"--no-edit",
|
||||||
|
"--no-verify",
|
||||||
|
"--allow-empty",
|
||||||
|
"-S",
|
||||||
|
]
|
||||||
|
print(f"[push] Signing HEAD {head[:10]} in {repo_dir}")
|
||||||
if preview:
|
if preview:
|
||||||
print(f"[PREVIEW] Would run in {repo_dir!r}: {' '.join(cmd)}")
|
print(f"[PREVIEW] Would run in {repo_dir!r}: {' '.join(cmd)}")
|
||||||
return
|
return
|
||||||
with _SIGN_LOCK:
|
with _SIGN_LOCK:
|
||||||
result = subprocess.run(
|
result = subprocess.run(cmd, cwd=repo_dir, check=False)
|
||||||
cmd,
|
|
||||||
cwd=repo_dir,
|
|
||||||
env={**os.environ, "GIT_SEQUENCE_EDITOR": ":"},
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise PushRefusedError(
|
raise PushRefusedError(f"{repo_dir}: signing HEAD {head[:10]} failed")
|
||||||
f"{repo_dir}: re-sign rebase failed; resolve or `git rebase --abort`"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _lease(
|
def _lease(
|
||||||
@@ -145,22 +158,21 @@ def push_branch(
|
|||||||
Args:
|
Args:
|
||||||
repo_dir: checkout whose current branch is pushed.
|
repo_dir: checkout whose current branch is pushed.
|
||||||
extra_args: flags appended to every `git push`.
|
extra_args: flags appended to every `git push`.
|
||||||
sign: refuse unsigned commits in the push range; False pushes them as they are.
|
sign: refuse an unpushed, unsigned HEAD; False pushes it as it is.
|
||||||
refs: extra refs (e.g. a release tag) pushed to every URL.
|
refs: extra refs (e.g. a release tag) pushed to every URL.
|
||||||
rewrite: replace commits of the old canonical tip; the fetch URL skips
|
rewrite: replace commits of the old canonical tip; the fetch URL skips
|
||||||
the cherry check, mirrors only refuse commits the old tip never had.
|
the cherry check, mirrors only refuse commits the old tip never had.
|
||||||
preview: print the pushes instead of running them.
|
preview: print the pushes instead of running them.
|
||||||
"""
|
"""
|
||||||
branch, remote = _push_target(repo_dir)
|
branch, remote = _push_target(repo_dir)
|
||||||
|
head = run(["rev-parse", "HEAD"], cwd=repo_dir)
|
||||||
if sign:
|
if sign:
|
||||||
_base, signatures = _unpushed_signatures(repo_dir, remote)
|
_base, signatures = _unpushed_signatures(repo_dir, remote, branch)
|
||||||
if not preview and any(s != "G" for s in signatures):
|
if not preview and signatures.get(head, "G") != "G":
|
||||||
raise PushRefusedError(
|
raise PushRefusedError(
|
||||||
f"{repo_dir}: {sum(s != 'G' for s in signatures)} unsigned commit(s) "
|
f"{repo_dir}: HEAD {head[:10]} is unsigned; run `pkgmgr push` to sign it"
|
||||||
"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)
|
fetch_url = run(["remote", "get-url", remote], cwd=repo_dir)
|
||||||
push_urls = run(
|
push_urls = run(
|
||||||
["remote", "get-url", "--push", "--all", remote], cwd=repo_dir
|
["remote", "get-url", "--push", "--all", remote], cwd=repo_dir
|
||||||
|
|||||||
@@ -203,6 +203,43 @@ class TestPushMirrorLeases(unittest.TestCase):
|
|||||||
self.assertEqual(self._tip(self.mirror), unsigned)
|
self.assertEqual(self._tip(self.mirror), unsigned)
|
||||||
self.assertEqual(git(self.repo, "log", "-1", "--format=%G?"), "N")
|
self.assertEqual(git(self.repo, "log", "-1", "--format=%G?"), "N")
|
||||||
|
|
||||||
|
def test_only_head_is_signed_and_older_unsigned_commits_stay(self) -> None:
|
||||||
|
older = self._commit("older")
|
||||||
|
self._commit("head")
|
||||||
|
|
||||||
|
out = self._push()
|
||||||
|
|
||||||
|
head = git(self.repo, "rev-parse", "HEAD")
|
||||||
|
self.assertEqual(git(self.repo, "rev-parse", "HEAD^"), older)
|
||||||
|
self.assertEqual(git(self.repo, "log", "-1", "--format=%G?", head), "G")
|
||||||
|
self.assertEqual(git(self.repo, "log", "-1", "--format=%G?", older), "N")
|
||||||
|
self.assertIn("WARN 1 older unsigned commit(s)", out)
|
||||||
|
self.assertEqual(self._tip(self.canonical), head)
|
||||||
|
self.assertEqual(self._tip(self.mirror), head)
|
||||||
|
|
||||||
|
def test_pushed_remote_branch_is_the_base_without_upstream(self) -> None:
|
||||||
|
git(self.repo, "checkout", "-q", "-b", "feature")
|
||||||
|
with patch.dict(os.environ, {"GIT_COMMITTER_DATE": "2001-01-01T00:00:00+0000"}):
|
||||||
|
pushed = self._commit("pushed", None, "-S")
|
||||||
|
git(self.repo, "push", "-q", "origin", "feature")
|
||||||
|
git(self.repo, "fetch", "-q", "origin")
|
||||||
|
self._commit("new")
|
||||||
|
upstream = subprocess.run(
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "@{u}"],
|
||||||
|
cwd=self.repo,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(upstream.returncode, 0)
|
||||||
|
|
||||||
|
out = self._push()
|
||||||
|
|
||||||
|
head = git(self.repo, "rev-parse", "HEAD")
|
||||||
|
self.assertEqual(git(self.repo, "rev-parse", "HEAD^"), pushed)
|
||||||
|
self.assertEqual(git(self.repo, "log", "-1", "--format=%G?", head), "G")
|
||||||
|
self.assertNotIn("WARN", out)
|
||||||
|
self.assertEqual(self._tip(self.canonical, "refs/heads/feature"), head)
|
||||||
|
|
||||||
def test_release_retry_refuses_unsigned_range(self) -> None:
|
def test_release_retry_refuses_unsigned_range(self) -> None:
|
||||||
self._commit("release")
|
self._commit("release")
|
||||||
git(self.repo, "tag", "-a", "v1.0.0", "-m", "v1.0.0")
|
git(self.repo, "tag", "-a", "v1.0.0", "-m", "v1.0.0")
|
||||||
|
|||||||
Reference in new issue
Block a user