Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a21fb1a908 | ||
|
|
e9653cff2e |
@@ -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
|
## [1.0.0] - 2026-04-24
|
||||||
|
|
||||||
* Official Release🚀
|
* Official Release🚀
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Configures a clone for a fork-based workflow and is idempotent.
|
|||||||
- `fork` points at the maintainer's personal fork.
|
- `fork` points at the maintainer's personal fork.
|
||||||
- `main` tracks `origin/main`.
|
- `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.
|
- `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:
|
Usage:
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "git-maintainer-tools"
|
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."
|
description = "Small CLIs (git-setup-remotes, git-sign-push) for fork-based OSS maintainer workflows."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ Target state after a successful run:
|
|||||||
* `remote.pushDefault` = `fork`, `push.default` = `current` so every
|
* `remote.pushDefault` = `fork`, `push.default` = `current` so every
|
||||||
`git push` without args (and new-branch pushes via sign-push) lands
|
`git push` without args (and new-branch pushes via sign-push) lands
|
||||||
on the fork, not on the canonical repo.
|
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
|
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
|
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:
|
def apply_push_config() -> None:
|
||||||
g.run_git("config", "remote.pushDefault", "fork", capture=False)
|
g.run_git("config", "remote.pushDefault", "fork", capture=False)
|
||||||
g.run_git("config", "push.default", "current", 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:
|
def report_state() -> None:
|
||||||
print("Remotes:")
|
print("Remotes:")
|
||||||
print(g.git_out("remote", "-v"))
|
print(g.git_out("remote", "-v"))
|
||||||
print()
|
print()
|
||||||
print(f"remote.pushDefault = {g.git_out('config', '--get', 'remote.pushDefault')}")
|
print(f"remote.pushDefault = {g.git_out('config', '--get', 'remote.pushDefault')}")
|
||||||
print(f"push.default = {g.git_out('config', '--get', 'push.default')}")
|
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}")
|
main_u = g.abbrev_ref("main@{u}")
|
||||||
if 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:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
|||||||
@@ -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
|
push target is resolved from `remote.pushDefault` (falling back to
|
||||||
`origin`), so the fork-based workflow from `git-setup-remotes` routes
|
`origin`), so the fork-based workflow from `git-setup-remotes` routes
|
||||||
new branches to the personal fork rather than the canonical repo.
|
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
|
from __future__ import annotations
|
||||||
@@ -71,12 +77,18 @@ def resign(base: str) -> None:
|
|||||||
`GIT_SEQUENCE_EDITOR=:` turns the interactive rebase TODO-list editor
|
`GIT_SEQUENCE_EDITOR=:` turns the interactive rebase TODO-list editor
|
||||||
into a no-op so the rebase just replays the existing commits without
|
into a no-op so the rebase just replays the existing commits without
|
||||||
reordering or squashing. `-S` signs each replayed commit.
|
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 = os.environ.copy()
|
||||||
env["GIT_SEQUENCE_EDITOR"] = ":"
|
env["GIT_SEQUENCE_EDITOR"] = ":"
|
||||||
import subprocess
|
import subprocess
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
["git", "rebase", "--rebase-merges", "-S", base],
|
["git", "rebase", "--rebase-merges", "--force-rebase", "-S", base],
|
||||||
env=env,
|
env=env,
|
||||||
)
|
)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
|
|||||||
37
tests/test_apply_push_config.py
Normal file
37
tests/test_apply_push_config.py
Normal 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
28
tests/test_resign.py
Normal 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"
|
||||||
Reference in New Issue
Block a user