aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/dot_config/git/hooks/executable_pre-push
blob: 286958b757c42aeaf6210f79cd1cda6673c27ae4 (plain) (blame)
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
#!/bin/sh
# Reject pushes that include commits which:
#   * lack a good signature, or
#   * have a committer different from this repo's user.name / user.email, or
#   * have an author that looks like a coding agent (Copilot, Claude,
#     Codex, ChatGPT, Cursor, Aider, Devin, ...) -- I want my name on
#     anything I push, even when an agent helped write it. Use
#     `git commit --amend --reset-author` after agent-assisted work.
#
# Activated via core.hooksPath in ~/.config/git/config so it applies to
# every repo unless that repo overrides hooksPath itself (this dotfiles
# repo does, pointing at .githooks/ which delegates here).
#
# Bypass for one push: git push --no-verify

set -eu

# shellcheck source=./_dispatch.sh
. "${0%/*}/_dispatch.sh"
# Buffer stdin so both the per-repo hook and our own loop below get the
# full ref list (git only feeds it once).
_stdin_buf=$(mktemp)
trap 'rm -f "$_stdin_buf"' EXIT INT TERM
cat >"$_stdin_buf"
dispatch_repo_hook pre-push "$@" <"$_stdin_buf"

zero=$(git hash-object --stdin </dev/null | tr '0-9a-f' '0')

expected_name=$(git config user.name || true)
expected_email=$(git config user.email || true)

if [ -z "$expected_name" ] || [ -z "$expected_email" ]; then
  printf 'pre-push: user.name or user.email is unset; refusing.\n' >&2
  exit 1
fi

# %G? signature status codes from git-log:
#   G  good signature
#   U  good, unknown validity (e.g. trust level not set)
#   X  good but expired signature
#   Y  good but key is expiring
#   R  good but key has been revoked
#   B  bad signature
#   E  signature cannot be checked (e.g. missing key)
#   N  no signature
# We accept G/U/X/Y and reject anything else.
ok='G U X Y'

# Case-insensitive substrings that disqualify an author. Matched against
# "<name> <email>" lowercased. Plain substring matching (index()) is
# used in the awk below to dodge regex-escaping pitfalls. Keep this list
# narrow on purpose: false positives are worse than misses (a slipped
# commit can be amended and force-pushed; a noisy hook gets disabled).
agent_subs='copilot claude codex chatgpt cursor aider devin [bot] @openai. @anthropic.'

fail=0
while read -r _local_ref local_sha remote_ref remote_sha; do
  # Branch deletion: nothing to verify on our side.
  [ "$local_sha" = "$zero" ] && continue

  if [ "$remote_sha" = "$zero" ]; then
    # New branch: verify the commits unique to this push, excluding
    # anything already reachable from any remote-tracking ref.
    set -- "$local_sha" --not --remotes
  else
    set -- "${remote_sha}..${local_sha}"
  fi

  # One pass: per-commit emit
  #   SHA \t %G? \t cn \t ce \t an \t ae \t co-authored-by-list \t subject
  # Co-authored-by trailers are joined with 0x1f (unit separator) which
  # cannot appear in identity strings. Tabs aren't valid in identity
  # fields either, so awk -F'\t' parses unambiguously.
  bad=$(git rev-list \
    --format='%H%x09%G?%x09%cn%x09%ce%x09%an%x09%ae%x09%(trailers:key=Co-authored-by,valueonly,unfold,separator=%x1f)%x09%s' \
    --no-commit-header "$@" |
    awk -F'\t' \
      -v ok="$ok" \
      -v en="$expected_name" \
      -v ee="$expected_email" \
      -v agent_subs="$agent_subs" '
      BEGIN {
        split(ok, a, " "); for (i in a) good[a[i]] = 1
        n_subs = split(agent_subs, subs, " ")
      }
      function is_agent(s,    i) {
        for (i = 1; i <= n_subs; i++) if (index(s, subs[i])) return 1
        return 0
      }
      {
        reasons = ""
        if (!($2 in good)) reasons = reasons " [sig=" $2 "]"
        if ($3 != en || $4 != ee) {
          reasons = reasons " [committer=" $3 " <" $4 ">]"
        }
        if (is_agent(tolower($5 " " $6))) {
          reasons = reasons " [agent-author=" $5 " <" $6 ">]"
        }
        if ($7 != "") {
          n_co = split($7, coauthors, "\037")
          for (i = 1; i <= n_co; i++) {
            if (coauthors[i] != "" && is_agent(tolower(coauthors[i]))) {
              reasons = reasons " [agent-coauthor=" coauthors[i] "]"
            }
          }
        }
        if (reasons != "") print $1 reasons "  " $8
      }
    ')

  if [ -n "$bad" ]; then
    if [ "$fail" -eq 0 ]; then
      printf '\nrefusing to push: bad commits found\n' >&2
      printf '(expected committer: %s <%s>)\n' \
        "$expected_name" "$expected_email" >&2
    fi
    printf '\non %s:\n%s\n' "$remote_ref" "$bad" >&2
    fail=1
  fi
done <"$_stdin_buf"

if [ "$fail" -ne 0 ]; then
  printf '\nfix sig + committer:\n' >&2
  printf '  git rebase --exec "git commit --amend --no-edit -S" <base>\n' >&2
  printf 'fix author (re-stamp yourself as author, keep agent out):\n' >&2
  printf '  git rebase --exec "git commit --amend --no-edit --reset-author -S" <base>\n' >&2
  printf 'bypass:\n  git push --no-verify\n\n' >&2
  exit 1
fi

exit 0