2 Commits
main ... v1.1.1

Author SHA1 Message Date
a21fb1a908 fix(sign-push): force rebase so -S actually re-signs the tip
`git rebase <base>` is a no-op when HEAD is already a descendant of
<base>, 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 <noreply@anthropic.com>
2026-04-24 23:57:10 +02:00
e9653cff2e feat(setup-remotes): pin branch.main.pushRemote to origin
Direct pushes on the canonical branch now target upstream instead of
the personal fork whose branch-protection rules can diverge. Feature
branches still fall back to the fork via remote.pushDefault.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 23:36:10 +02:00
7 changed files with 104 additions and 5 deletions

View File

@@ -1,3 +1,11 @@
## [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 <base>` 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.
## [1.0.0] - 2026-04-24
* Official Release🚀

View File

@@ -18,6 +18,7 @@ Configures a clone for a fork-based workflow and is idempotent.
- `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.
- `branch.main.pushRemote` = `origin` so a direct push on the canonical branch targets upstream, not the personal fork (whose branch-protection rules can diverge).
Usage:

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "git-maintainer-tools"
version = "1.0.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"

View File

@@ -8,6 +8,10 @@ Target state after a successful run:
* `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.
* `branch.main.pushRemote` = `origin` so a direct `git push` on the
canonical branch never accidentally targets the personal fork (whose
branch-protection rules diverge from upstream). Feature branches
still fall back to the fork via `remote.pushDefault`.
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
@@ -120,17 +124,26 @@ def wire_main_tracking() -> None:
def apply_push_config() -> None:
g.run_git("config", "remote.pushDefault", "fork", capture=False)
g.run_git("config", "push.default", "current", capture=False)
# Override the global push default for `main` only: direct pushes on
# the canonical branch go to `origin`, not the fork. Feature branches
# stay on `fork` via `remote.pushDefault`.
g.run_git("config", "branch.main.pushRemote", "origin", 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')}")
print(f"remote.pushDefault = {g.git_out('config', '--get', 'remote.pushDefault')}")
print(f"push.default = {g.git_out('config', '--get', 'push.default')}")
main_push = g.run_git(
"config", "--get", "branch.main.pushRemote", check=False
)
if main_push.returncode == 0:
print(f"branch.main.pushRemote = {(main_push.stdout or '').strip()}")
main_u = g.abbrev_ref("main@{u}")
if main_u:
print(f"main tracks = {main_u}")
print(f"main tracks = {main_u}")
def main(argv: list[str] | None = None) -> int:

View File

@@ -6,6 +6,12 @@ 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.
For a branch that already has an upstream, this tool runs
`git push --force-with-lease` without an explicit remote. Git then
honours `branch.<name>.pushRemote` over `remote.pushDefault`, which is
how `git-setup-remotes` pins `main` to `origin` while other branches
still land on the fork.
"""
from __future__ import annotations
@@ -71,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 <base>` 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:

View File

@@ -0,0 +1,37 @@
"""Verify `apply_push_config` writes the expected git-config keys."""
from __future__ import annotations
from git_maintainer_tools import setup_remotes
def test_apply_push_config_writes_all_three_keys(monkeypatch):
calls: list[tuple[str, ...]] = []
def fake_run_git(*args, **kwargs):
calls.append(args)
monkeypatch.setattr(setup_remotes.g, "run_git", fake_run_git)
setup_remotes.apply_push_config()
config_sets = [a for a in calls if a[:1] == ("config",)]
assert ("config", "remote.pushDefault", "fork") in config_sets
assert ("config", "push.default", "current") in config_sets
assert ("config", "branch.main.pushRemote", "origin") in config_sets
def test_main_push_override_is_origin_not_fork(monkeypatch):
"""Regression: main must pin to origin so pushes never hit the fork."""
calls: list[tuple[str, ...]] = []
def fake_run_git(*args, **kwargs):
calls.append(args)
monkeypatch.setattr(setup_remotes.g, "run_git", fake_run_git)
setup_remotes.apply_push_config()
main_overrides = [
a for a in calls
if a[:2] == ("config", "branch.main.pushRemote")
]
assert main_overrides == [("config", "branch.main.pushRemote", "origin")]

28
tests/test_resign.py Normal file
View File

@@ -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 <base>` 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"