1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
#!/bin/sh
# trusty-mpm-push-guard: v4 (#2867, #7704)
#
# Why: issue #2867 — an agent worktree whose local branch was configured with
# `branch.<local>.merge = refs/heads/<someone-else's-PR-branch>` ran a bare
# `git push` and clobbered the reviewed lineage of a PR branch it did not
# own. Tracking config is not the only way to arm that gun (an explicit
# `git push origin HEAD:other-branch` does it too), and the offending push
# may come from a process trusty-mpm never created. A pre-push hook is the
# only guard that fires regardless of WHO runs the push.
# What: refuses a push that would DESTROY lineage on an existing remote branch
# from a branch of a DIFFERENT name. One rule, applied identically whether
# HEAD is attached or detached:
#
# updating an existing branch requires either source name == destination
# name, or a FAST-FORWARD of that destination
#
# where the source name is the pushed `refs/heads/<name>`, or — when the
# source is anonymous (`HEAD`, a raw sha) — the checked-out branch name.
# An anonymous source from a DETACHED HEAD has no name, so only the
# fast-forward test can permit it onto an existing branch.
# Four shapes are deliberately OUT OF SCOPE and pass through untouched:
# * non-branch destinations (tags, notes, refs/pull) — cannot clobber a
# PR's branch lineage;
# * CREATING a branch that does not exist on the remote yet — git reports
# an all-zero <remote sha> for these. There is no lineage there to
# destroy, so a create can never be the #2867 damage, and `git push
# origin <sha>:refs/heads/<new-name>` is the standard way to rescue work
# out of a detached worktree;
# * FAST-FORWARDING an existing branch (#7704) — the destination's current
# tip is an ancestor of what is being pushed, so every commit the branch
# already has is still reachable afterwards. The push only ADDS, which
# is the routine "land another commit on the PR branch I am working"
# case and is not the #2867 damage. Proven from the <remote sha> git
# states on stdin, never from how the push was spelled: `--force` and
# `--force-with-lease` on a genuine fast-forward are still permitted,
# and a plain `git push` that is NOT one is still refused;
# * branch DELETES (`git push origin --delete X`, `git push origin :X`).
# NOT because they cannot happen by accident — they can, see the
# warning below — but because a deleted GitHub branch is RECOVERABLE
# (the PR retains its commits at `refs/pull/<N>/head`, and the branch
# can be re-pushed from any clone) whereas a force-clobbered lineage is
# not. Post-merge remote cleanup is routine, and the right layer to
# police deletion is branch protection on the server, not a hook the
# pusher can remove.
# The fast-forward test FAILS CLOSED: when the destination's tip cannot be
# resolved locally — even after fetching that one ref — the guard cannot
# prove the push is additive, so it refuses.
# Set TM_ALLOW_CROSS_BRANCH_PUSH=1 to perform a deliberate cross-branch push.
#
# WARNING — do NOT reason from "git refuses a bare push while detached". That
# `fatal: You are not currently on a branch` comes from `push.default`
# resolution ONLY, and git never consults `push.default` when a configured
# `remote.<name>.push` refspec supplies the destination. Proven against this
# very hook: `git config remote.origin.push '+HEAD:refs/heads/victim'`, then
# a BARE `git push` from a detached HEAD, clobbered `victim` with no refspec
# typed by anyone. The same mechanism makes a bare `git push` perform a
# DELETE (`remote.origin.push = ':refs/heads/victim'`). This is exactly why
# the exemptions above are keyed on facts git states in the <remote sha>
# field, and never on HEAD state or on how the push was spelled.
# Test: crates/trusty-mpm/tests/push_guard_hook.rs (real temp repos and a real
# bare remote: refuses the divergent cross-branch push attached AND detached,
# refuses the `remote.<name>.push` detached-bare-push exploit above, permits
# the same-name push, the create and the fast-forward in both states, refuses
# a rebased `--force-with-lease`, fails closed on an unresolvable destination
# tip, permits deletes and tags, permits with the override).
#
# Note: git aborts the ENTIRE push when a pre-push hook exits non-zero, so a
# multi-refspec push containing one refused ref sends none of them. That is
# git's hook contract, not a choice this script makes.
#
# stdin lines are: <local ref> <local sha> <remote ref> <remote sha>
# argv is: $1 = remote name, $2 = remote URL.
if [; then
fi
remote_name=
# Classify an update of an existing branch: `fast-forward`, `diverged`, or
# `unverifiable`. $1 = <remote sha> (the destination's current tip, as the
# remote reports it), $2 = <local sha> (what is being pushed — HEAD's sha for a
# `HEAD:<branch>` refspec), $3 = the destination ref, used only to fetch the
# tip when this clone has never seen it.
#
# Every git call redirects stdin from /dev/null: this runs inside the `while
# read` loop below, and a child that consumes the hook's stdin would silently
# swallow the remaining refspecs.
# Empty when HEAD is detached.
head_branch= || head_branch=""
refused=0
refused_branch=""
rescue_sha=""
saw_unverifiable=0
# `|| [ -n "$local_ref" ]` keeps a final line with no trailing newline from
# being silently dropped: POSIX `read` returns non-zero at EOF even when it
# read a partial line, and dropping it here would fail OPEN.
while || [; do
# Only branch destinations can clobber a PR lineage.
dest_branch=
src_branch=""
# A CREATE cannot destroy a lineage that does not exist yet. Git marks
# one with an all-zero <remote sha>. Checked BEFORE the name comparison
# so it applies identically to a named and an anonymous source, and to
# an attached and a detached HEAD — no asymmetry to reason about.
if [ && [; then
continue
fi
# #7704: a fast-forward ADDS to the destination without discarding any
# commit it already has, so it cannot be the #2867 damage even across
# names. Anything else — a rebase pushed with `--force-with-lease`
# included — rewrites commits the destination already has, which is the
# exact lineage this guard exists to protect.
verdict=
if [; then
continue
fi
if [; then
saw_unverifiable=1
fi
refused=1
if [; then
refused_branch=
else
rescue_sha=
fi
done
if [; then
if [; then
fi
if [; then
else
fi
fi