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
#!/bin/sh
# trusty-mpm-push-guard: v3 (#2867)
#
# 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 OVERWRITE AN EXISTING remote branch with the
# history of a branch of a DIFFERENT name. One rule, applied identically
# whether HEAD is attached or detached:
#
# updating an existing branch requires source name == destination name
#
# 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 it can only
# CREATE, never update.
# Three 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;
# * 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.
# 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 exemption above is keyed on CREATE-vs-UPDATE, which 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 cross-branch push attached AND detached, refuses
# the `remote.<name>.push` detached-bare-push exploit above, permits the
# same-name push and the create in both states, 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
# Empty when HEAD is detached.
head_branch= || head_branch=""
refused=0
refused_branch=""
rescue_sha=""
# `|| [ -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
refused=1
if [; then
refused_branch=
else
rescue_sha=
fi
done
if [; then
if [; then
else
fi
fi