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:
@@ -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
|
## [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.0"
|
||||||
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
|
||||||
|
|||||||
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")]
|
||||||
Reference in New Issue
Block a user