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>
This commit is contained in:
2026-04-24 23:36:10 +02:00
parent 921d84aa67
commit e9653cff2e
6 changed files with 65 additions and 4 deletions

View File

@@ -1,3 +1,7 @@
## [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.0"
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,6 +124,10 @@ 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:
@@ -128,6 +136,11 @@ def report_state() -> None:
print()
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}")

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

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")]