From 51d0709fb8f35d32e27880c59d190c86456ef6b7 Mon Sep 17 00:00:00 2001 From: dsc Date: Tue, 21 Feb 2012 13:12:14 -0800 Subject: [PATCH] Initial commit. --- README.md | 5 + hooks/git-commit-notifier | 543 ++++++++++++++++++++++++++++++ hooks/git-post-receive-email | 763 ++++++++++++++++++++++++++++++++++++++++++ hooks/post-commit | 1 + 4 files changed, 1312 insertions(+), 0 deletions(-) create mode 100644 README.md create mode 100755 hooks/git-commit-notifier create mode 100755 hooks/git-post-receive-email create mode 120000 hooks/post-commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd77f9b --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# kraken-ui-hooks + +AHOY METAPROGRAMMING! + +This repo on git.less.ly has a `post-commit` hook that copies the contents of the `hooks` directory to `kraken-ui.git/hooks`, thus updating its hooks. diff --git a/hooks/git-commit-notifier b/hooks/git-commit-notifier new file mode 100755 index 0000000..6a48ce9 --- /dev/null +++ b/hooks/git-commit-notifier @@ -0,0 +1,543 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +# 2011-10-31 - This now takes a --subjectformat argument. -otto + +import optparse +import os +import shutil +import socket +import sys +import subprocess +import tempfile +import time + +Name = "git-notifier" +Version = "0.3" +CacheFile = ".%s.dat" % Name +Separator = "\n>---------------------------------------------------------------\n" +NoDiff = "[nodiff]" +NoMail = "[nomail]" + +gitolite = "GL_USER" in os.environ +whoami = os.environ["LOGNAME"] +sender = os.environ["GL_USER"] if gitolite else whoami + +Options = [ + # Name, argument, default, help, + ("allchanges", True, set(), "branches for which *all* changes are to be reported"), + ("debug", False, False, "enable debug output"), + ("diff", True, None, "mail out diffs between two revisions"), + ("emailprefix", True, "[git]", "Subject prefix for mails"), + ("hostname", True, socket.gethostname(), "host where the repository is hosted"), + ("log", True, "%s.log" % Name, "set log output"), + ("mailinglist", True, whoami, "destination address for mails"), + ("manual", True, None, "notifiy for a manually given set of revisions"), + ("maxdiffsize", True, 50, "limit the size of diffs in mails (KB)"), + ("noupdate", False, False, "do not update the state file"), + ("repouri", True, None, "full URI for the repository"), + ("sender", True, sender, "sender address for mails"), + ("link", True, None, "Link to insert into mail, %s will be replaced with revision"), + ("updateonly", False, False, "update state file only, no mails"), + ("users", True, None, "location of a user-to-email mapping file"), + ("subjectformat", True, "--pretty=format:%s (%h)", "Option to pass to git show to print out subject of a commit email.") +] + +class State(object): + def __init__(self): + self.clear() + + def clear(self): + self.heads = {} + self.tags = {} + self.revs = set() + + self.reported = set() # Revs reported this run so far. + + def writeTo(self, file): + if os.path.exists(CacheFile): + try: + shutil.move(CacheFile, CacheFile + ".bak") + except IOError: + pass + + out = open(file, "w") + for (head, ref) in self.heads.items(): + print >>out, "head", head, ref + for (tag, ref) in self.tags.items(): + print >>out, "tag", tag, ref + for rev in self.revs: + print >>out, "rev", rev + + def readFrom(self, file): + self.clear() + + for line in open(file): + + line = line.strip() + if not line or line.startswith("#"): + continue + + m = line.split() + + if len(m) == 3: + (type, key, val) = (m[0], m[1], m[2]) + else: + # No heads. + (type, key, val) = (m[0], m[1], "") + + if type == "head": + self.heads[key] = val + elif type == "tag": + self.tags[key] = val + elif type == "rev": + self.revs.add(key) + else: + error("unknown type %s in cache file" % type) + +class GitConfig(object): + def __init__(self, args): + self.parseArgs(args) + self.maxdiffsize *= 1024 # KBytes to bytes. + + if self.allchanges and not isinstance(self.allchanges, set): + self.allchanges = set([head.strip() for head in self.allchanges.split(",")]) + if not self.debug: + self.log = open(self.log, "a") + else: + self.log = sys.stderr + + if not self.users and "GL_ADMINDIR" in os.environ: + users = os.path.join(os.environ["GL_ADMINDIR"], "conf/sender.cfg") + if os.path.exists(users): + self.users = users + + self.readUsers() + + def parseArgs(self, args): + parser = optparse.OptionParser(version=Version) + for (name, arg, default, help) in Options: + defval = self._git_config(name, default) + + if isinstance(default, int): + defval = int(defval) + if not arg: + defval = bool(defval) + if not arg: + action = "store_true" if not default else "store_false" + parser.add_option("--%s" % name, action=action, dest=name, default=defval, help=help) + else: + type = "string" if not isinstance(default, int) else "int" + parser.add_option("--%s" % name, action="store", type=type, default=defval, dest=name, help=help) + + (options, args) = parser.parse_args(args) + if len(args) != 0: + parser.error("incorrect number of arguments") + + for (name, arg, default, help) in Options: + self.__dict__[name] = options.__dict__[name] + + def readUsers(self): + if self.users and os.path.exists(self.users): + for line in open(self.users): + line = line.strip() + if not line or line.startswith("#"): + continue + + m = line.split() + + if self.sender == m[0]: + self.sender = " ".join(m[1:]) + break + + def _git_config(self, key, default): + cfg = git(["config hooks.%s" % key]) + return cfg[0] if cfg else default + + +def log(msg): + print >>Config.log, "%s - %s" % (time.asctime(), msg) + +def error(msg): + log("Error: %s" % msg) + sys.exit(1) + + +def git(args, stdout_to=subprocess.PIPE, all=False): + if isinstance(args, tuple) or isinstance(args, list): + args = " ".join(args) + + try: + if Config.debug: + print >>sys.stderr, "> git " + args + except NameError: + # Config may not be defined yet. + pass + + try: + child = subprocess.Popen("git " + args, shell=True, stdin=None, stdout=stdout_to, stderr=subprocess.PIPE) + (stdout, stderr) = child.communicate() + except OSError, e: + error("cannot start git: %s" % str(e)) + + if child.returncode != 0 and stderr: + msg = ": %s" % stderr if stderr else "" + error("git child failed with exit code %d%s" % (child.returncode, msg)) + + if stdout_to != subprocess.PIPE: + return [] + + if not all: + return [line.strip() for line in stdout.split("\n") if line] + else: + return stdout.split("\n") + +def getHeads(state): + for (rev, head) in [head.split() for head in git("show-ref --heads")]: + if head.startswith("refs/heads/"): + head = head[11:] + + state.heads[head] = rev + +def getTags(state): + for (rev, tag) in [head.split() for head in git("show-ref --tags")]: + # We are only interested in annotaged tags. + type = git("cat-file -t %s" % rev)[0] + + if type == "tag": + if tag.startswith("refs/tags/"): + tag= tag[10:] + + state.tags[tag] = rev + +def getReachableRefs(state): + for rev in git(["rev-list"] + state.heads.keys() + state.tags.keys()): + state.revs.add(rev) + +def getCurrent(): + state = State() + getHeads(state) + getTags(state) + getReachableRefs(state) + + return state + + +Tmps = [] + +def makeTmp(): + global Tmps + (fd, fname) = tempfile.mkstemp(prefix="%s-" % Name, suffix=".tmp") + Tmps += [fname] + return (os.fdopen(fd, "w"), fname) + +def deleteTmps(): + for tmp in Tmps: + os.unlink(tmp) + +def mailTag(key, value): + return "%-11s: %s" % (key, value) + +def generateMailHeader(subject): + repo = Config.repouri + if not repo: + if gitolite: + # Gitolite version. + repo = "ssh://%s@%s/%s" % (whoami, Config.hostname, os.path.basename(os.getcwd())) + else: + # Standard version. + repo = "ssh://%s/%s" % (Config.hostname, os.path.basename(os.getcwd())) + if repo.endswith(".git"): + repo = repo[0:-4] + + (out, fname) = makeTmp() + print >>out, """From: %s +To: %s +Subject: %s %s +X-Git-Repository: %s +X-Mailer: %s %s + +%s + +""" % (Config.sender, Config.mailinglist, Config.emailprefix, subject, repo, + Name, Version, mailTag("Repository", repo)), + return (out, fname) + + +def sendMail(out, fname): + out.close() + + if Config.debug: + for line in open(fname): + print " |", line, + print "" + else: + stdin = subprocess.Popen("/usr/sbin/sendmail -t", shell=True, stdin=subprocess.PIPE).stdin + for line in open(fname): + print >>stdin, line, + stdin.close() + + # Wait a bit in case we're going to send more mails. Otherwise, the mails + # get sent back-to-back and are likely to end up with identical timestamps, + # which may then make them appear to have arrived in the wrong order. + if not Config.debug: + time.sleep(2) + +def entryAdded(key, value, rev): + log("New %s %s" % (key, value)) + + (out, fname) = generateMailHeader("%s '%s' created" % (key, value)) + + print >>out, mailTag("New %s" % key, value) + print >>out, mailTag("Referencing", rev) + + sendMail(out, fname) + +def entryDeleted(key, value): + log("Deleted %s %s" % (key, value)) + + (out, fname) = generateMailHeader("%s '%s' deleted" % (key, value)) + + print >>out, mailTag("Deleted %s" % key, value) + + sendMail(out, fname) + +# Sends a mail for a notification consistent of two parts: (1) the output of a +# show command, and (2) the output of a diff command. +def sendChangeMail(subject, heads, show_cmd, diff_cmd): + + (out, fname) = generateMailHeader(subject) + + multi = "es" if len(heads) > 1 else "" + heads = ",".join(heads) + + print >>out, mailTag("On branch%s" % multi, heads) + + if Config.link: + url = Config.link.replace("%s", rev) + print >>out, "" + print >>out, mailTag("Link", url) + + footer = "" + show = git(show_cmd) + + for line in show: + if NoDiff in line: + break + + if NoMail in line: + return + + else: + (tmp, tname) = makeTmp() + diff = git(diff_cmd, stdout_to=tmp) + tmp.close() + + size = os.path.getsize(tname) + + if size > Config.maxdiffsize: + footer = "\nDiff suppressed because of size. To see it, use:\n\n git %s" % diff_cmd + tname = None + + print >>out, Separator + + for line in git(show_cmd, all=True): + if line == "---": + print >>out, Separator + else: + print >>out, line + + print >>out, Separator + + if tname: + for line in open(tname): + print >>out, line, + + print >>out, footer + + if Config.debug: + print >>out, "-- " + print >>out, "debug: show_cmd = git %s" % show_cmd + print >>out, "debug: diff_cmd = git %s" % diff_cmd + + sendMail(out, fname) + +# Sends notification for a specific revision. +def commit(current, rev): + if rev in current.reported: + # Already reported in this run of the script. + log("New revision %s, but already reported this time" % rev) + + log("New revision %s" % rev) + current.reported.add(rev) + + heads = [head.split()[-1] for head in git("branch --contains=%s" % rev)] + + if len(set(heads) - Config.allchanges) == 0: + # We have reported full diffs for all already. + return + + subject = git("show '%s' -s %s" % (Config.subjectformat, rev)) + subject = "%s: %s" % (",".join(heads), subject[0]) + + show_cmd = "show -s --no-color --find-copies-harder --pretty=medium %s" % rev + diff_cmd = "diff --patch-with-stat --no-color --find-copies-harder --ignore-space-at-eol ^%s~1 %s " % (rev, rev) + + sendChangeMail(subject, heads, show_cmd, diff_cmd) + +# Sends a diff between two revisions. +def diff(head, first, last): + log("Diffing %s..%s" % (first, last)) + + subject = git("show '%s' -s %s" % (Config.subjectformat, last)) + subject = "%s diff: %s" % (head, subject[0]) + + heads = [head] + + show_cmd = "show -s --no-color --find-copies-harder --pretty=medium %s" % last + diff_cmd = "diff --patch-with-stat -m --no-color --find-copies-harder --ignore-space-at-eol %s %s" % (first, last) + + sendChangeMail(subject, heads, show_cmd, diff_cmd) + +# Sends pair-wise diffs for a path of revisions. +def diffPath(head, revs): + last = None + + for rev in revs: + if last: + diff(head, last, rev) + last = rev + +# Sends a commit notifications for a set of revisions, independent of whether +# they already have been reported. +def reportPath(current, revs): + for rev in revs: + commit(current, rev) + +# Sends a summary mail for a set of revisions. +def headMoved(head, path): + log("Head moved: %s -> %s" % (head, path[-1])) + + subject = git("show '%s' -s %s" % (Config.subjectformat, path[-1])) + + (out, fname) = generateMailHeader("%s's head updated: %s" % (head, subject[0])) + + print >>out, "Branch '%s' now includes:" % head + print >>out, "" + + for rev in path: + print >>out, " ", git("show -s --pretty=oneline --abbrev-commit %s" % rev)[0] + + sendMail(out, fname) + +Config = GitConfig(sys.argv[1:]) + +log("Running for %s" % os.getcwd()) + +if Config.debug: + for (name, arg, default, help) in Options: + print >>sys.stderr, "[Option %s: %s]" % (name, Config.__dict__[name]) + +cache = State() + +if os.path.exists(CacheFile): + cache.readFrom(CacheFile) + report = (not Config.updateonly) +else: + log("Initial run. Not generating any mails, just recording current state.") + report = False + +current = getCurrent() + +if Config.diff: + # Manual diff mode. The argument must be of the form "[old-rev..]new-rev". + path = [rev.strip() for rev in Config.diff.split("..")] + if len(path) == 1: + path = ("%s~2" % path[0], path[0]) # sic! ~2. + else: + path = ("%s~1" % path[0], path[1]) + + revs = git(["rev-list", "--reverse", "--first-parent", "--date-order", path[1], "^%s" % path[0]]) + diffPath("", revs) + + sys.exit(0) + +if Config.manual: + # Manual report mode. The argument must be of the form "[old-rev..]new-rev". + path = [rev.strip() for rev in Config.manual.split("..")] + if len(path) == 1: + path = ("%s~1" % path[0], path[0]) + + revs = git(["rev-list", "--reverse", "--date-order", path[1], "^%s" % path[0]]) + reportPath(current, revs) + + sys.exit(0) + +if report: + # Check for changes to the set of heads. + old = set(cache.heads.keys()) + new = set(current.heads.keys()) + + for head in (new - old): + entryAdded("branch", head, current.heads[head]) + + for head in (old - new): + entryDeleted("branch", head) + + stable_heads = new & old + Config.allchanges = Config.allchanges & stable_heads + + # Check tags. + old = set(cache.tags.keys()) + new = set(current.tags.keys()) + + for tag in (new - old): + entryAdded("tag", tag, current.tags[tag]) + + for tag in (old - new): + entryDeleted("tag", tag) + + # Do complete reports for the heads we want to see everything for. + for head in Config.allchanges: + old_rev = cache.heads[head] + new_rev = current.heads[head] + + revs = git(["rev-list", "--reverse", "--first-parent", "--date-order", new_rev, "^%s~1" % old_rev]) + diffPath(head, revs) + + # Check for unreported commits. + + # Sort updates by time. + def _key(rev): + ts = git("show -s '--pretty=format:%%ct' %s" % rev) + return int(ts[0]) + + old = set(cache.revs) + new = set(current.revs) + + new_revs = new - old + + for rev in sorted(new_revs, key=_key): + commit(current, rev) + + # See if heads have moved to include already reported revisions. + for head in stable_heads: + + if head in Config.allchanges: + # Already done complete diffs. + continue + + old_rev = cache.heads[head] + new_rev = current.heads[head] + + path = git(["rev-list", "--reverse", "--date-order", new_rev, "^%s" % old_rev]) + + if len(set(path) - new_revs): + headMoved(head, path) + +if not Config.noupdate: + current.writeTo(CacheFile) + +deleteTmps() + diff --git a/hooks/git-post-receive-email b/hooks/git-post-receive-email new file mode 100755 index 0000000..be8335a --- /dev/null +++ b/hooks/git-post-receive-email @@ -0,0 +1,763 @@ +#!/bin/sh +# +# Copyright (c) 2007 Andy Parkins +# +# An example hook script to mail out commit update information. This hook +# sends emails listing new revisions to the repository introduced by the +# change being reported. The rule is that (for branch updates) each commit +# will appear on one email and one email only. +# +# This hook is stored in the contrib/hooks directory. Your distribution +# will have put this somewhere standard. You should make this script +# executable then link to it in the repository you would like to use it in. +# For example, on debian the hook is stored in +# /usr/share/git-core/contrib/hooks/post-receive-email: +# +# chmod a+x post-receive-email +# cd /path/to/your/repository.git +# ln -sf /usr/share/git-core/contrib/hooks/post-receive-email hooks/post-receive +# +# This hook script assumes it is enabled on the central repository of a +# project, with all users pushing only to it and not between each other. It +# will still work if you don't operate in that style, but it would become +# possible for the email to be from someone other than the person doing the +# push. +# +# To help with debugging and use on pre-v1.5.1 git servers, this script will +# also obey the interface of hooks/update, taking its arguments on the +# command line. Unfortunately, hooks/update is called once for each ref. +# To avoid firing one email per ref, this script just prints its output to +# the screen when used in this mode. The output can then be redirected if +# wanted. +# +# Config +# ------ +# hooks.mailinglist +# This is the list that all pushes will go to; leave it blank to not send +# emails for every ref update. +# hooks.announcelist +# This is the list that all pushes of annotated tags will go to. Leave it +# blank to default to the mailinglist field. The announce emails lists +# the short log summary of the changes since the last annotated tag. +# hooks.envelopesender +# If set then the -f option is passed to sendmail to allow the envelope +# sender address to be set +# hooks.emailprefix +# All emails have their subjects prefixed with this prefix, or "[SCM]" +# if emailprefix is unset, to aid filtering +# hooks.showrev +# The shell command used to format each revision in the email, with +# "%s" replaced with the commit id. Defaults to "git rev-list -1 +# --pretty %s", displaying the commit id, author, date and log +# message. To list full patches separated by a blank line, you +# could set this to "git show -C %s; echo". +# To list a gitweb/cgit URL *and* a full patch for each change set, use this: +# "t=%s; printf 'http://.../?id=%%s' \$t; echo;echo; git show -C \$t; echo" +# Be careful if "..." contains things that will be expanded by shell "eval" +# or printf. +# hooks.emailmaxlines +# The maximum number of lines that should be included in the generated +# email body. If not specified, there is no limit. +# Lines beyond the limit are suppressed and counted, and a final +# line is added indicating the number of suppressed lines. +# hooks.diffopts +# Alternate options for the git diff-tree invocation that shows changes. +# Default is "--stat --summary --find-copies-harder". Add -p to those +# options to include a unified diff of changes in addition to the usual +# summary output. +# +# Notes +# ----- +# All emails include the headers "X-Git-Refname", "X-Git-Oldrev", +# "X-Git-Newrev", and "X-Git-Reftype" to enable fine tuned filtering and +# give information for debugging. +# + +# ---------------------------- Functions + +# +# Function to prepare for email generation. This decides what type +# of update this is and whether an email should even be generated. +# +prep_for_email() +{ + # --- Arguments + oldrev=$(git rev-parse $1) + newrev=$(git rev-parse $2) + refname="$3" + maxlines=$4 + + # --- Interpret + # 0000->1234 (create) + # 1234->2345 (update) + # 2345->0000 (delete) + if expr "$oldrev" : '0*$' >/dev/null + then + change_type="create" + else + if expr "$newrev" : '0*$' >/dev/null + then + change_type="delete" + else + change_type="update" + fi + fi + + # --- Get the revision types + newrev_type=$(git cat-file -t $newrev 2> /dev/null) + oldrev_type=$(git cat-file -t "$oldrev" 2> /dev/null) + case "$change_type" in + create|update) + rev="$newrev" + rev_type="$newrev_type" + ;; + delete) + rev="$oldrev" + rev_type="$oldrev_type" + ;; + esac + + # The revision type tells us what type the commit is, combined with + # the location of the ref we can decide between + # - working branch + # - tracking branch + # - unannoted tag + # - annotated tag + case "$refname","$rev_type" in + refs/tags/*,commit) + # un-annotated tag + refname_type="tag" + short_refname=${refname##refs/tags/} + ;; + refs/tags/*,tag) + # annotated tag + refname_type="annotated tag" + short_refname=${refname##refs/tags/} + # change recipients + if [ -n "$announcerecipients" ]; then + recipients="$announcerecipients" + fi + ;; + refs/heads/*,commit) + # branch + refname_type="branch" + short_refname=${refname##refs/heads/} + ;; + refs/remotes/*,commit) + # tracking branch + refname_type="tracking branch" + short_refname=${refname##refs/remotes/} + echo >&2 "*** Push-update of tracking branch, $refname" + echo >&2 "*** - no email generated." + return 1 + ;; + *) + # Anything else (is there anything else?) + echo >&2 "*** Unknown type of update to $refname ($rev_type)" + echo >&2 "*** - no email generated" + return 1 + ;; + esac + + # Check if we've got anyone to send to + if [ -z "$recipients" ]; then + case "$refname_type" in + "annotated tag") + config_name="hooks.announcelist" + ;; + *) + config_name="hooks.mailinglist" + ;; + esac + echo >&2 "*** $config_name is not set so no email will be sent" + echo >&2 "*** for $refname update $oldrev->$newrev" + return 1 + fi + + return 0 +} + +# +# Top level email generation function. This calls the appropriate +# body-generation routine after outputting the common header. +# +# Note this function doesn't actually generate any email output, that is +# taken care of by the functions it calls: +# - generate_email_header +# - generate_create_XXXX_email +# - generate_update_XXXX_email +# - generate_delete_XXXX_email +# - generate_email_footer +# +# Note also that this function cannot 'exit' from the script; when this +# function is running (in hook script mode), the send_mail() function +# is already executing in another process, connected via a pipe, and +# if this function exits without, whatever has been generated to that +# point will be sent as an email... even if nothing has been generated. +# +generate_email() +{ + # Email parameters + # The email subject will contain the best description of the ref + # that we can build from the parameters + describe=$(git describe $rev 2>/dev/null) + if [ -z "$describe" ]; then + describe=$rev + fi + + generate_email_header + + # Call the correct body generation function + fn_name=general + case "$refname_type" in + "tracking branch"|branch) + fn_name=branch + ;; + "annotated tag") + fn_name=atag + ;; + esac + + if [ -z "$maxlines" ]; then + generate_${change_type}_${fn_name}_email + else + generate_${change_type}_${fn_name}_email | limit_lines $maxlines + fi + + generate_email_footer +} + +generate_email_header() +{ + # --- Email (all stdout will be the email) + # Generate header + cat <<-EOF + To: $recipients + Subject: ${emailprefix}$projectdesc $refname_type $short_refname ${change_type}d. $describe + X-Git-Refname: $refname + X-Git-Reftype: $refname_type + X-Git-Oldrev: $oldrev + X-Git-Newrev: $newrev + + The $refname_type, $short_refname has been ${change_type}d + EOF +} + +generate_email_footer() +{ + SPACE=" " + cat <<-EOF + + + --${SPACE} + $projectdesc + EOF +} + +# --------------- Branches + +# +# Called for the creation of a branch +# +generate_create_branch_email() +{ + # This is a new branch and so oldrev is not valid + echo " at $newrev ($newrev_type)" + echo "" + + echo $LOGBEGIN + show_new_revisions + echo $LOGEND +} + +# +# Called for the change of a pre-existing branch +# +generate_update_branch_email() +{ + # Consider this: + # 1 --- 2 --- O --- X --- 3 --- 4 --- N + # + # O is $oldrev for $refname + # N is $newrev for $refname + # X is a revision pointed to by some other ref, for which we may + # assume that an email has already been generated. + # In this case we want to issue an email containing only revisions + # 3, 4, and N. Given (almost) by + # + # git rev-list N ^O --not --all + # + # The reason for the "almost", is that the "--not --all" will take + # precedence over the "N", and effectively will translate to + # + # git rev-list N ^O ^X ^N + # + # So, we need to build up the list more carefully. git rev-parse + # will generate a list of revs that may be fed into git rev-list. + # We can get it to make the "--not --all" part and then filter out + # the "^N" with: + # + # git rev-parse --not --all | grep -v N + # + # Then, using the --stdin switch to git rev-list we have effectively + # manufactured + # + # git rev-list N ^O ^X + # + # This leaves a problem when someone else updates the repository + # while this script is running. Their new value of the ref we're + # working on would be included in the "--not --all" output; and as + # our $newrev would be an ancestor of that commit, it would exclude + # all of our commits. What we really want is to exclude the current + # value of $refname from the --not list, rather than N itself. So: + # + # git rev-parse --not --all | grep -v $(git rev-parse $refname) + # + # Get's us to something pretty safe (apart from the small time + # between refname being read, and git rev-parse running - for that, + # I give up) + # + # + # Next problem, consider this: + # * --- B --- * --- O ($oldrev) + # \ + # * --- X --- * --- N ($newrev) + # + # That is to say, there is no guarantee that oldrev is a strict + # subset of newrev (it would have required a --force, but that's + # allowed). So, we can't simply say rev-list $oldrev..$newrev. + # Instead we find the common base of the two revs and list from + # there. + # + # As above, we need to take into account the presence of X; if + # another branch is already in the repository and points at some of + # the revisions that we are about to output - we don't want them. + # The solution is as before: git rev-parse output filtered. + # + # Finally, tags: 1 --- 2 --- O --- T --- 3 --- 4 --- N + # + # Tags pushed into the repository generate nice shortlog emails that + # summarise the commits between them and the previous tag. However, + # those emails don't include the full commit messages that we output + # for a branch update. Therefore we still want to output revisions + # that have been output on a tag email. + # + # Luckily, git rev-parse includes just the tool. Instead of using + # "--all" we use "--branches"; this has the added benefit that + # "remotes/" will be ignored as well. + + # List all of the revisions that were removed by this update, in a + # fast-forward update, this list will be empty, because rev-list O + # ^N is empty. For a non-fast-forward, O ^N is the list of removed + # revisions + fast_forward="" + rev="" + for rev in $(git rev-list $newrev..$oldrev) + do + revtype=$(git cat-file -t "$rev") + echo " discards $rev ($revtype)" + done + if [ -z "$rev" ]; then + fast_forward=1 + fi + + # List all the revisions from baserev to newrev in a kind of + # "table-of-contents"; note this list can include revisions that + # have already had notification emails and is present to show the + # full detail of the change from rolling back the old revision to + # the base revision and then forward to the new revision + for rev in $(git rev-list $oldrev..$newrev) + do + revtype=$(git cat-file -t "$rev") + shortrev=$(git log -n 1 --pretty="format:%h" "$rev") + author=$(git log -n 1 --pretty="format:%an" "$rev") + + # word wrap the subject line + subject=$(git log -n 1 --pretty="format:%s" "$rev" | fold -w 64 -s) + # seperate out the 1st subject line so we can align each line + subject_line_1=$(echo "${subject}" | sed -n '1p') + subject_remaining_lines=$(echo "${subject}" | sed '1d') + + printf " via $revtype $shortrev %20s: %s\n" "$author" "$subject_line_1" + echo "${subject_remaining_lines}" | while read line; do + printf "%43s %s\n" ' ' "${line}"; + done + done + + if [ "$fast_forward" ]; then + shortrev=$(git log -n 1 --pretty="format:%h" "$oldrev") + author=$(git log -n 1 --pretty="format:%an" "$oldrev") + + # word wrap the subject line + subject=$(git log -n 1 --pretty="format:%s" "$oldrev" | fold -w 64 -s) + # seperate out the 1st subject line so we can align each line + subject_line_1=$(echo "${subject}" | sed -n '1p') + subject_remaining_lines=$(echo "${subject}" | sed '1d') + + printf " from $oldrev_type $shortrev %20s: %s\n" "$author" "$subject_line_1" + echo "${subject_remaining_lines}" | while read line; do + printf "%43s %s\n" ' ' "${line}"; + done + else + # 1. Existing revisions were removed. In this case newrev + # is a subset of oldrev - this is the reverse of a + # fast-forward, a rewind + # 2. New revisions were added on top of an old revision, + # this is a rewind and addition. + + # (1) certainly happened, (2) possibly. When (2) hasn't + # happened, we set a flag to indicate that no log printout + # is required. + + echo "" + + # Find the common ancestor of the old and new revisions and + # compare it with newrev + baserev=$(git merge-base $oldrev $newrev) + rewind_only="" + if [ "$baserev" = "$newrev" ]; then + echo "This update discarded existing revisions and left the branch pointing at" + echo "a previous point in the repository history." + echo "" + echo " * -- * -- N ($newrev)" + echo " \\" + echo " O -- O -- O ($oldrev)" + echo "" + echo "The removed revisions are not necessarilly gone - if another reference" + echo "still refers to them they will stay in the repository." + rewind_only=1 + else + echo "This update added new revisions after undoing existing revisions. That is" + echo "to say, the old revision is not a strict subset of the new revision. This" + echo "situation occurs when you --force push a change and generate a repository" + echo "containing something like this:" + echo "" + echo " * -- * -- B -- O -- O -- O ($oldrev)" + echo " \\" + echo " N -- N -- N ($newrev)" + echo "" + echo "When this happens we assume that you've already had alert emails for all" + echo "of the O revisions, and so we here report only the revisions in the N" + echo "branch from the common base, B." + fi + fi + + # The diffstat is shown from the old revision to the new revision. + # This is to show the truth of what happened in this change. + # There's no point showing the stat from the base to the new + # revision because the base is effectively a random revision at this + # point - the user will be interested in what this revision changed + # - including the undoing of previous revisions in the case of + # non-fast-forward updates. + echo "" + echo "Summary of changes:" + git diff-tree $diffopts $oldrev..$newrev + + + echo "" + if [ -z "$rewind_only" ]; then + echo $LOGBEGIN + show_new_revisions + + # XXX: Need a way of detecting whether git rev-list actually + # outputted anything, so that we can issue a "no new + # revisions added by this update" message + + echo $LOGEND + else + echo "No new revisions were added by this update." + fi +} + +# +# Called for the deletion of a branch +# +generate_delete_branch_email() +{ + echo " was $oldrev" + echo "" + echo $LOGEND + git show -s --pretty=oneline $oldrev + echo $LOGEND +} + +# --------------- Annotated tags + +# +# Called for the creation of an annotated tag +# +generate_create_atag_email() +{ + echo " at $newrev ($newrev_type)" + + generate_atag_email +} + +# +# Called for the update of an annotated tag (this is probably a rare event +# and may not even be allowed) +# +generate_update_atag_email() +{ + echo " to $newrev ($newrev_type)" + echo " from $oldrev (which is now obsolete)" + + generate_atag_email +} + +# +# Called when an annotated tag is created or changed +# +generate_atag_email() +{ + # Use git for-each-ref to pull out the individual fields from the + # tag + eval $(git for-each-ref --shell --format=' + tagobject=%(*objectname) + tagtype=%(*objecttype) + tagger=%(taggername) + tagged=%(taggerdate)' $refname + ) + + echo " tagging $tagobject ($tagtype)" + case "$tagtype" in + commit) + + # If the tagged object is a commit, then we assume this is a + # release, and so we calculate which tag this tag is + # replacing + prevtag=$(git describe --abbrev=0 $newrev^ 2>/dev/null) + + if [ -n "$prevtag" ]; then + echo " replaces $prevtag" + fi + ;; + *) + echo " length $(git cat-file -s $tagobject) bytes" + ;; + esac + echo " tagged by $tagger" + echo " on $tagged" + + echo "" + echo $LOGBEGIN + + # Show the content of the tag message; this might contain a change + # log or release notes so is worth displaying. + git cat-file tag $newrev | sed -e '1,/^$/d' + + echo "" + case "$tagtype" in + commit) + # Only commit tags make sense to have rev-list operations + # performed on them + if [ -n "$prevtag" ]; then + # Show changes since the previous release + git rev-list --pretty=short "$prevtag..$newrev" | git shortlog + else + # No previous tag, show all the changes since time + # began + git rev-list --pretty=short $newrev | git shortlog + fi + ;; + *) + # XXX: Is there anything useful we can do for non-commit + # objects? + ;; + esac + + echo $LOGEND +} + +# +# Called for the deletion of an annotated tag +# +generate_delete_atag_email() +{ + echo " was $oldrev" + echo "" + echo $LOGEND + git show -s --pretty=oneline $oldrev + echo $LOGEND +} + +# --------------- General references + +# +# Called when any other type of reference is created (most likely a +# non-annotated tag) +# +generate_create_general_email() +{ + echo " at $newrev ($newrev_type)" + + generate_general_email +} + +# +# Called when any other type of reference is updated (most likely a +# non-annotated tag) +# +generate_update_general_email() +{ + echo " to $newrev ($newrev_type)" + echo " from $oldrev" + + generate_general_email +} + +# +# Called for creation or update of any other type of reference +# +generate_general_email() +{ + # Unannotated tags are more about marking a point than releasing a + # version; therefore we don't do the shortlog summary that we do for + # annotated tags above - we simply show that the point has been + # marked, and print the log message for the marked point for + # reference purposes + # + # Note this section also catches any other reference type (although + # there aren't any) and deals with them in the same way. + + echo "" + if [ "$newrev_type" = "commit" ]; then + echo $LOGBEGIN + git show --no-color --root -s --pretty=medium $newrev + echo $LOGEND + else + # What can we do here? The tag marks an object that is not + # a commit, so there is no log for us to display. It's + # probably not wise to output git cat-file as it could be a + # binary blob. We'll just say how big it is + echo "$newrev is a $newrev_type, and is $(git cat-file -s $newrev) bytes long." + fi +} + +# +# Called for the deletion of any other type of reference +# +generate_delete_general_email() +{ + echo " was $oldrev" + echo "" + echo $LOGEND + git show -s --pretty=oneline $oldrev + echo $LOGEND +} + + +# --------------- Miscellaneous utilities + +# +# Show new revisions as the user would like to see them in the email. +# +show_new_revisions() +{ + # This shows all log entries that are not already covered by + # another ref - i.e. commits that are now accessible from this + # ref that were previously not accessible + # (see generate_update_branch_email for the explanation of this + # command) + + # Revision range passed to rev-list differs for new vs. updated + # branches. + if [ "$change_type" = create ] + then + # Show all revisions exclusive to this (new) branch. + revspec=$newrev + else + # Branch update; show revisions not part of $oldrev. + revspec=$oldrev..$newrev + fi + + other_branches=$(git for-each-ref --format='%(refname)' refs/heads/ | + grep -F -v $refname) + git rev-parse --not $other_branches | + if [ -z "$custom_showrev" ] + then + git rev-list --pretty --stdin $revspec + else + git rev-list --stdin $revspec | + while read onerev + do + eval $(printf "$custom_showrev" $onerev) + done + fi +} + + +limit_lines() +{ + lines=0 + skipped=0 + while IFS="" read -r line; do + lines=$((lines + 1)) + if [ $lines -gt $1 ]; then + skipped=$((skipped + 1)) + else + printf "%s\n" "$line" + fi + done + if [ $skipped -ne 0 ]; then + echo "... $skipped lines suppressed ..." + fi +} + + +send_mail() +{ + if [ -n "$envelopesender" ]; then + /usr/sbin/sendmail -t -f "$envelopesender" + else + /usr/sbin/sendmail -t + fi +} + +# ---------------------------- main() + +# --- Constants +LOGBEGIN="- Log -----------------------------------------------------------------" +LOGEND="-----------------------------------------------------------------------" + +# --- Config +# Set GIT_DIR either from the working directory, or from the environment +# variable. +GIT_DIR=$(git rev-parse --git-dir 2>/dev/null) +if [ -z "$GIT_DIR" ]; then + echo >&2 "fatal: post-receive: GIT_DIR not set" + exit 1 +fi + +projectdesc=$(sed -ne '1p' "$GIT_DIR/description" 2>/dev/null) +# Check if the description is unchanged from it's default, and shorten it to +# a more manageable length if it is +if expr "$projectdesc" : "Unnamed repository.*$" >/dev/null +then + projectdesc="UNNAMED PROJECT" +fi + +recipients=$(git config hooks.mailinglist) +announcerecipients=$(git config hooks.announcelist) +envelopesender=$(git config hooks.envelopesender) +emailprefix=$(git config hooks.emailprefix || echo '[SCM] ') +custom_showrev=$(git config hooks.showrev) +maxlines=$(git config hooks.emailmaxlines) +diffopts=$(git config hooks.diffopts) +: ${diffopts:="--stat --summary --find-copies-harder"} + +# --- Main loop +# Allow dual mode: run from the command line just like the update hook, or +# if no arguments are given then run as a hook script +if [ -n "$1" -a -n "$2" -a -n "$3" ]; then + # Output to the terminal in command line mode - if someone wanted to + # resend an email; they could redirect the output to sendmail + # themselves + prep_for_email $1 $2 $3 && PAGER= generate_email +else + while read oldrev newrev refname + do + prep_for_email $oldrev $newrev $refname || continue + generate_email $maxlines | send_mail + done +fi \ No newline at end of file diff --git a/hooks/post-commit b/hooks/post-commit new file mode 120000 index 0000000..ca8a679 --- /dev/null +++ b/hooks/post-commit @@ -0,0 +1 @@ +git-post-receive-email \ No newline at end of file -- 1.7.0.4