#! /bin/sh # # Copyright 2013 Chris Torek # NOTE: BARELY TESTED # # This is a git pre-receive hook that implments various checks, # meant to be used in a --bare repo on a server that people # 'git push' to, via plain (non-gitolite) ssh. # # (For gitolite you need to change the USER= computation.) # # There are some global hooks.* variables: # # hooks.supermaintainers = # hooks.tags.annotated-only = # # People in the supermaintainers list are allowed to push anything. # # If annotated-only is set, all tags should be annotated # (supermaintainers get off with a warning, others get errors). # # Underneath this are: # # hooks.branch.$BRANCH.maintainers = # # This is a list of "branch maintainers", per-branch. They are # allowed to do anything to that branch (including create or delete it, # overriding more general create/delete allow/deny below). # # hooks.branch.$BRANCH.rewindable = # hooks.branch.$BRANCH.merge = # # The "rewindable" flag, if set, allows the branch to be "moved # backwards" (have commits that were previously reachable, become # unreachable). If false or not set, only the maintainers can do this. # # The "merge" list is a list of branches that may be merged into the # branch. For instance, if hooks.branches.devel.merge = featureX,featureY # anyone may merge the (current) head of branch featureX and/or # featureY into devel. If not present, only maintainers may merge # into the branch. If set to '*', anyone may merge into the branch. # # hooks.branches.create-allow = # hooks.branches.create-deny = # hooks.branches.delete-allow = # hooks.branches.delete-deny = # hooks.tags.create-allow = # hooks.tags.create-deny = # hooks.tags.delete-allow = # hooks.tags.delete-deny = # # These are lists of users who are allowed or denied the act of # creating or deleting branches and tags. If the "allow" list # exists at all, only those users are allowed. Otherwise, if the # "deny" list exists, those users are denied, and anyone else may # do the operation. fatal() { echo "$@" >&2 exit 1 } # choose debug command here debug=echo # debug=: # Set $GIT to the version of git to run. # : ${GIT:=git} (use this to import env $GIT) GIT=git # 40 0's NULL_SHA1="0000000000000000000000000000000000000000" # default = OK # on failing check, set this to nonzero to keep going # or exit immediately (nonzero) to fail without more checking STATUS=0 USER=$(id -un) || fatal "can't figure out who you are!" # OLD, NEW, REFNAME, and SHORTREF are "global variables", # as are OTYPE, NTYPE, and CHANGE. See bottom of script. # Given name or SHA1, get ref type (blob, commit, tag, tree) reftype() { $GIT cat-file -t $1 || echo badref } # Check to see if $USER is allowed to create/delete ($CHANGE) a branch # or tag ($REFTYPE). This does not include any special tag checks, # just the create/deny logic. check_generic_c_d() { local plural allow # Supermaintainers get away with everything as usual. user_is_super && return # This is a little odd, but a branch maintainer may # delete and then re-create the branch too. user_is_maint && return case $REFTYPE in branch) plural=branches;; tag) plural=tags;; *) fatal "internal error in check_generic_c_d: REFTYPE=$REFTYPE";; esac # If hooks.(branches|tags).(create|delete)-allow is set, # it's a list of user names who ARE allowed to create branches # or tags, and no one else is. if allow=$($GIT config --get hooks.$plural.$CHANGE-allow); then $debug "hooks.$plural.$CHANGE-allow = $allow" if ! user_in_list "$allow"; then echo \ "only allowed users may $CHANGE $plural; you ($USER) are not listed" STATUS=1 return fi $debug \ "hooks.$plural.$CHANGE-allow allows you ($USER) to $CHANGE $REFTYPE '$SHORTREF'" return # 'allow' overrides 'deny': don't look at deny list else $debug "hooks.$plural.$CHANGE-allow is unset... proceeding" fi # If hooks.(branches|tags).(create|delete)-deny is set, it's # a list of user names who are NOT allowed to create/delete. # If it's unset, user_in_list won't find the user in the # (empty) list. if user_in_list $($GIT config --get hooks.$plural.$CHANGE-deny); then echo \ "hooks.$plural.$CHANGE-deny says you ($USER) may not $CHANGE $plural" STATUS=1 return fi } # If hooks.tags.annotated-only is set, forbid new tags from # being lightweight tags. check_anno_tag() { local anno anno=$($GIT config --get --bool hooks.tags.annotated-only) if [ "$anno" = true -a $NTYPE != tag ]; then # tags should be annotated, but we'll let the # super-maintainer do anything (with a warning) if user_is_super; then anno=warning else anno=error STATUS=1 fi echo "$anno: tags must be annotated; '$SHORTREF' is not" fi } # Check to see if $USER is allowed to create/delete ($CHANGE) # a tag ($REFTYPE). check_tag_c_d() { # If hooks.tags.annotated-only is set (boolean), forbid # lightweight tags. if [ $CHANGE = create ]; then check_anno_tag fi check_generic_c_d } # Check tag update. Only the super-maintainer may update tags. check_tag_update() { if user_is_super; then # Still want to warn about non-annotated tags. check_anno_tag else echo "only the super-maintainer may move a tag" STATUS=1 fi } # Check to see if $USER is allowed to update branch $SHORTREF. check_branch_update() { local removed removed=$(git rev-list $NEW..$OLD) if [ "$removed" != "" ]; then $debug "removing some commits from branch '$SHORTREF'" check_branch_rm_commits elif $GIT merge-base --is-ancestor $OLD $NEW; then # "Ordinary" addition of commits. The new # ones are $OLD..$NEW. $debug "adding commits to branch '$SHORTREF'" check_branch_add_commits else $debug "removing some commits, adding others to '$SHORTREF'" # Remove check is the strictest, so that's all we need. check_branch_rm_commits fi } check_branch_add_commits() { local merges from b can_merge # Find out if any new commit is a merge commit. merges=$($GIT rev-list --min-parents=2 $OLD..$NEW) # If no merges, just clean history add, allow. [ "$merges" = "" ] && return # Allow maintainers and supermaintainers. user_is_super && return user_is_maint && return # If hooks.branch.$branch.merge is set, it's a list of # branches that anyone may merge, e.g., # hooks.branch.devel.merge = featureX, featureY # allows merges from featureX and/or featureY into devel. if ! can_merge=$($GIT config --get \ hooks.branch.$SHORTREF.merge); then echo \ "merge commit found, but hooks.branch.$SHORTREF.merge is not set, so only maintainers are allowed to merge into '$SHORTREF'" STATUS=1 return fi if [ "$can_merge" = "*" ]; then $debug "anyone is allowed to merge anything into '$SHORTREF'" return fi # $debug "allow anyone to merge $can_merge into '$SHORTREF'" # What branch(es) actually got merged-in? We can't be # 100% sure, especially if other branches are being updated # in the same push. But this may suffice. for rev in $merges; do from=$(merges_from $rev) set -- $from # $debug "$rev: check merge of $@ into '$SHORTREF'" for b do if [ "$b" = "-" ]; then echo \ "I can't figure out which branch(es) are being merged" echo "you'll need to ask a maintainer to push" STATUS=1 return fi if ! in_list $b "$can_merge"; then echo "merge '$b' into '$SHORTREF' denied:" echo "you ($USER) are not a maintainer" STATUS=1 else $debug "(allowing merge '$b' into '$SHORTREF')" fi done done } check_branch_rm_commits() { # If hooks.branch.$SHORTREF.rewindable is set, anyone may rewind # this branch. [ "$($GIT config --get --bool \ hooks.branch.$SHORTREF.rewindable)" = true ] && return # No: only the maintainers may remove commits. user_is_super && return user_is_maint && return echo "only maintainers may remove commits, and you ($USER) are not" echo "a maintainer: update of '$SHORTREF' denied" STATUS=1 } # $1 is a merge commit. Find all its parents, except the first # (which is "on" the branch being merged-into and hence not relevant). # For each remaining parent, find which branch(es) contain them. # If those branch heads *are* them, consider that the "source" of the merge. # # Note that this means if branches A and B both name commit 1234567, # and you merge commit 1234567 into branch master, this considers # the merge (with its one parent) to merge both branches A and B. # That may not be ideal, but it is what we get... merges_from() { local branches b src set -- $($GIT rev-list --parents --no-walk $1) shift 2 for i do # git branch --contains may print something like # * (no branch) # or # * (detached from blah) # if HEAD is detached. This should not happen in # a bare repo, but if it does, we'll add HEAD to # our list. branches=$($GIT branch --merged $i | sed -e 's/\* (.*)/HEAD/' -e 's/^[* ]//') set -- $branches src="" for b do if [ $($GIT rev-parse $b) = $i ]; then src="${src}${src:+ }$b" fi [ "$src" == "" ] && src="-" done echo $src done } # Execute a command. If it exits 0 (true) print "true", else print "false". tf() { $@ && echo true || echo false } # Is $USER a supermaintainer? Caches result. SUPERMAINTAINER=unknown user_is_super() { if [ $SUPERMAINTAINER = unknown ]; then SUPERMAINTAINER=$(tf user_in_list \ $($GIT config --get hooks.supermaintainers)) # $debug SUPERMAINTAINER=$SUPERMAINTAINER fi $SUPERMAINTAINER } # Is $USER a maintainer of $SHORTREF? # (Cache is complicated because $SHORTREF can change) MAINTAINER=unknown MAINTCACHE=none user_is_maint() { if [ $MAINTCACHE != $SHORTREF -o "$MAINTAINER" = unknown ]; then MAINTCACHE=$SHORTREF MAINTAINER=$(tf user_in_list \ $($GIT config --get hooks.branch.$SHORTREF.maintainers)) # $debug "MAINTAINER=$MAINTAINER for MAINTCACHE=$MAINTCACHE" fi $MAINTAINER } # Is $USER in a list of users obtained with "git config --get"? # (The list of users may be white-space separated or comma-separated.) user_in_list() { in_list "$USER" "$@" } # Is $1 in a list? in_list() { local match i match="$1" shift set -- $(echo "$@" | sed 's/,/ /g') for i do [ "$i" = "$match" ] && return 0 # i.e., "true" done return 1 # i.e., "false" } while read OLD NEW REFNAME; do case $OLD,$NEW in $NULL_SHA1,*) OTYPE=null; NTYPE=$(reftype $NEW); CHANGE=create;; *,$NULL_SHA1) OTYPE=$(reftype $OLD); NTYPE=null; CHANGE=delete;; *) OTYPE=$(reftype $OLD); NTYPE=$(reftype $NEW); CHANGE=update;; esac if [ $NTYPE == badref -o $OTYPE == badref ]; then exit 1; fi case $REFNAME in refs/heads/*) REFTYPE=branch SHORTREF=${REFNAME#refs/heads/} ;; refs/tags/*) REFTYPE=tag SHORTREF=${REFNAME#refs/tags/} ;; refs/notes/*) REFTYPE=note SHORTREF=${REFNAME#refs/notes/} ;; refs/remotes/*) REFTYPE=remote SHORTREF=${REFNAME#refs/remotes/} ;; *) REFTYPE=unknown SHORTREF=$REFNAME ;; esac # Could use: # check_$REFTYPE_$CHANGE # which will invoke check_branch_create, check_tag_update, etc # but that would prevent us from skipping some kinds of tests, # so let's do this instead: case $CHANGE,$REFTYPE in create,branch|delete,branch) check_generic_c_d ;; update,branch) check_branch_update ;; create,tag|delete,tag) check_tag_c_d ;; update,tag) check_tag_update ;; *,remote) # Can we get called for refs/remotes/* in post-receive? # Quietly allow, in case this occurs on fetch. $debug "debug: remote ref: allow $CHANGE of '$SHORTREF'" ;; *,note) # don't know how to check notes yet, we'll just allow $debug "debug: note ref: allow $CHANGE of '$SHORTREF'" ;; *) # Ref types we have not heard of. echo "allowing $CHANGE of '$REFNAME', hope that's OK" ;; esac done exit $STATUS