diff --git a/CHANGELOG.md b/CHANGELOG.md index 51e2725..ee5aa04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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🚀 diff --git a/README.md b/README.md index 302f7de..39d0dfa 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/pyproject.toml b/pyproject.toml index c096e7a..0312b9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/git_maintainer_tools/setup_remotes.py b/src/git_maintainer_tools/setup_remotes.py index ee84dea..c551233 100644 --- a/src/git_maintainer_tools/setup_remotes.py +++ b/src/git_maintainer_tools/setup_remotes.py @@ -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: diff --git a/src/git_maintainer_tools/sign_push.py b/src/git_maintainer_tools/sign_push.py index d485fc9..abadbb1 100644 --- a/src/git_maintainer_tools/sign_push.py +++ b/src/git_maintainer_tools/sign_push.py @@ -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..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 diff --git a/tests/test_apply_push_config.py b/tests/test_apply_push_config.py new file mode 100644 index 0000000..793241d --- /dev/null +++ b/tests/test_apply_push_config.py @@ -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")]