From a21fb1a90840c0bd8e2548fdf0ff45bdfb2c8d22 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 24 Apr 2026 23:57:10 +0200 Subject: [PATCH] fix(sign-push): force rebase so -S actually re-signs the tip `git rebase ` is a no-op when HEAD is already a descendant of , which is the normal shape for a local branch built on top of origin/main. Without `--force-rebase`, rebase short-circuits, `-S` never runs, and the unsigned commit gets pushed and rejected by required_signatures branch rules. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/git_maintainer_tools/sign_push.py | 8 +++++++- tests/test_resign.py | 28 +++++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 tests/test_resign.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ee5aa04..5d97dc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [1.1.1] - 2026-04-24 + +* `git-sign-push`: pass `--force-rebase` to the signing rebase so the tip commit actually gets re-signed when HEAD is already a descendant of the base (otherwise `git rebase ` is a no-op and the unsigned commit gets pushed). + ## [1.1.0] - 2026-04-24 * `git-setup-remotes` now pins `branch.main.pushRemote` to `origin` so direct pushes on the canonical branch never target the personal fork. diff --git a/pyproject.toml b/pyproject.toml index 0312b9a..2428f22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "git-maintainer-tools" -version = "1.1.0" +version = "1.1.1" description = "Small CLIs (git-setup-remotes, git-sign-push) for fork-based OSS maintainer workflows." readme = "README.md" requires-python = ">=3.10" diff --git a/src/git_maintainer_tools/sign_push.py b/src/git_maintainer_tools/sign_push.py index abadbb1..ed0299a 100644 --- a/src/git_maintainer_tools/sign_push.py +++ b/src/git_maintainer_tools/sign_push.py @@ -77,12 +77,18 @@ def resign(base: str) -> None: `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. + + `--force-rebase` is required: without it, `git rebase ` is a no-op + when HEAD is already a descendant of `base` (the normal shape for a + local branch built on top of `origin/main`). The rebase then never + replays any commits, `-S` signs nothing, and the still-unsigned tip + gets pushed and rejected by `required_signatures` branch rules. """ env = os.environ.copy() env["GIT_SEQUENCE_EDITOR"] = ":" import subprocess proc = subprocess.run( - ["git", "rebase", "--rebase-merges", "-S", base], + ["git", "rebase", "--rebase-merges", "--force-rebase", "-S", base], env=env, ) if proc.returncode != 0: diff --git a/tests/test_resign.py b/tests/test_resign.py new file mode 100644 index 0000000..0c03872 --- /dev/null +++ b/tests/test_resign.py @@ -0,0 +1,28 @@ +"""Guard the rebase invocation used to re-sign pending commits.""" + +from __future__ import annotations + +import subprocess +from unittest.mock import MagicMock + +from git_maintainer_tools import sign_push + + +def test_resign_passes_force_rebase(monkeypatch): + """`git rebase ` is a no-op when HEAD already descends from base. + + Without `--force-rebase`, `-S` would never replay any commit and the + tip would stay unsigned, so push-time `required_signatures` rules + reject it. This test pins the flag in place. + """ + fake_run = MagicMock(return_value=MagicMock(returncode=0)) + monkeypatch.setattr(subprocess, "run", fake_run) + + sign_push.resign("abc123") + + called_cmd = fake_run.call_args.args[0] + assert called_cmd[0] == "git" + assert "rebase" in called_cmd + assert "--force-rebase" in called_cmd + assert "-S" in called_cmd + assert called_cmd[-1] == "abc123"