Skip to content

feat(ci): refuse to push a branch that is behind origin/main - #2056

Merged
Smana merged 1 commit into
mainfrom
worktree-pre-push-rebase-guard
Sep 17, 2026
Merged

Smana merged 1 commit into
mainfrom
worktree-pre-push-rebase-guard

Conversation

@Smana

@Smana Smana commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Summary

AGENTS.md says "rebase onto origin/main before every review, push and PR", and sync-branch
does it — but only if an agent chooses to run it. This puts the same rule below the agent, so it
holds for every agent and for a human typing git push.

A branch behind the base produces a diff that no longer describes what will merge: reviewers read
the wrong merge base, CI passes against code nobody will ship, and conflicts surface at merge time.
That happened twice while writing this PR — main moved under it both times, and the guard caught
the second one on itself.

flowchart TD
    P["git push"] --> D{"pushing main,<br/>a tag, or a deletion?"}
    D -->|yes| A["allow"]
    D -->|no| F["fetch origin/main"]
    F -->|fetch failed| W["warn + allow<br/>(offline work isn't blocked)"]
    F --> M{"is origin/main an<br/>ancestor of HEAD?"}
    M -->|yes| A2["allow"]
    M -->|no| R["refuse — name the count,<br/>print the rebase command"]

    classDef ok fill:#1f3d2f,stroke:#4ad98a,color:#fff
    classDef no fill:#4a1f1f,stroke:#d94a4a,color:#fff
    classDef neutral fill:#1e3a5f,stroke:#4a90d9,color:#fff
    class A,A2 ok
    class R no
    class W,P,F,D,M neutral
Loading

Why pre-commit's pre-push stage, not .githooks + core.hooksPath

core.hooksPath was the obvious approach and it is a trap here: it makes git ignore
.git/hooks/ entirely, which is exactly where pre-commit installs. Wiring the rebase guard that way
would have silently disabled the pre-commit hook — trading one guard for another, with no error.

The repo already runs pre-commit, and CI already runs it, so the pre-push stage is free integration.

I judged this below the ADR bar — it is a wiring detail inside a tool the repo already uses, not a
cross-cutting technology choice. Flagging it here rather than leaving it unsaid, per the rule in
AGENTS.md. Say the word if you'd rather have the record.

Two config lines that are load-bearing

Line Without it
default_install_hook_types: [pre-commit, pre-push] pre-commit install installs only the pre-commit hook, so the guard silently never runs for anyone who had already installed
default_stages: [pre-commit] a hook with no stages runs at every stage — installing a pre-push hook made all 14 existing hooks (terraform validate, tflint, detect-secrets) fire on every push. Measured: 15 hooks at push time before this line, 1 after

Escape hatches

SKIP=check-rebased git push    # skip this hook only
git push --no-verify           # skip every pre-push hook

Both are legitimate — a WIP branch nobody will review, or a commit added to someone else's branch
that you must not rebase.

Verification

Exercised by replaying the stdin git actually feeds a pre-push hook, not just by calling the script
with hand-set variables — the first round of testing did the latter and gave false confidence
(see the gap below).

Case Result
behind by 2 refuses, exit 1, names the count and the fix
up to date allows
branch deletion (zero sha) allows
pushing main, as a full ref allows, before the staleness check
pushing a tag allows, before the staleness check
fetch fails (offline) warns and allows
shellcheck clean
CI unaffected No hook with id check-rebased in stage pre-commit
live blocked this PR's own push, then passed after git rebase origin/main

Known gap, and why it's accepted

pre-commit's pre-push stage only runs when the push carries commits not already on a remote
(git rev-list <local> --not --remotes); when that set is empty it returns before running any hook,
and always_run: true does not override it. So a branch with no unique commits is unchecked — found
by actually pushing one at origin/main~2, which sailed straight through.

Accepted rather than worked around: such a push carries no work to review, which is the only thing a
stale merge base can misrepresent. Every real feature branch has commits of its own, and a rebase
gives them new SHAs, so the ordinary push and the post-rebase force-push are both covered. Recorded
in the script header so the next reader doesn't assume wider coverage than exists.

Note for you

I ran pre-commit install locally while testing, which wired pre-push into the shared gitdir —
so it applies to your other worktrees too. They're unaffected until this merges: their
.pre-commit-config.yaml has no pre-push hook, so the installed hook finds nothing to run.

A branch behind the base produces a diff that no longer describes what will
merge: reviewers read the wrong merge base, CI passes against code nobody will
ship, and conflicts surface at merge time. The sync-branch skill asks an agent
to rebase first; this is the same rule below the agent, so it holds for every
agent and for a human typing `git push`.

Runs from pre-commit's pre-push stage, NOT from core.hooksPath. Setting
core.hooksPath would make git ignore .git/hooks/ entirely, silently disabling
the pre-commit hook already installed there — trading one guard for another.

Two properties in .pre-commit-config.yaml are load-bearing:

- `default_install_hook_types` — `pre-commit install` alone installs only the
  pre-commit hook, so without this the guard would silently never run for
  anyone who had already installed. Verified: a plain install now wires both.
- `default_stages: [pre-commit]` — a hook with no `stages` runs at EVERY stage,
  so installing a pre-push hook made all 14 existing hooks (terraform validate,
  tflint, detect-secrets) fire on every push too. Measured 15 hooks at push
  time before this line, 1 after.

The fetch before comparing is the point: a local origin/main last updated hours
ago reports "up to date" on a branch that is not, which is the exact failure
being guarded against.

Verified end to end by replaying the stdin git feeds a pre-push hook, not just
by calling the script:

  behind by 2      -> refuses, exit 1, names the count and the fix
  up to date       -> allows
  branch deletion  -> allows (zero sha)
  pushing main     -> allows, as a full ref, before the staleness check
  pushing a tag    -> allows, before the staleness check
  fetch fails      -> warns and allows, so offline work is not blocked
  shellcheck       -> clean
  CI unaffected    -> "No hook with id check-rebased in stage pre-commit"

Known gap, documented in the script: pre-commit's pre-push stage only runs when
the push carries commits not already on a remote, and `always_run` does not
override that. A branch with no unique commits is therefore unchecked — found
by actually pushing one, which sailed through. Accepted rather than worked
around: such a push carries no work to review, and both the ordinary push and
the post-rebase force-push have commits of their own.
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Rendered manifest diff — this PR vs main (desired state)

No changes to the rendered desired state. ✅

@Smana
Smana merged commit 74a119c into main Sep 17, 2026
11 checks passed
@Smana
Smana deleted the worktree-pre-push-rebase-guard branch September 17, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant