summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMathieu Bridon <bochecha@fedoraproject.org>2014-07-04 17:59:44 +0200
committerKevin Fenzi <kevin@scrye.com>2014-08-26 18:28:25 +0000
commitfed72f7ba11ee89520f289498bd85aa23b87e69a (patch)
treeb39c9f21f78eff928038af12ec10137b2a11d851
parentb121d21d569d3cddf0a95183b24afb4be6eba690 (diff)
downloadansible-fed72f7ba11ee89520f289498bd85aa23b87e69a.tar.gz
ansible-fed72f7ba11ee89520f289498bd85aa23b87e69a.tar.xz
ansible-fed72f7ba11ee89520f289498bd85aa23b87e69a.zip
Add a new git/hooks role
This will be needed to migrate Dist Git from puppet to ansible.
-rw-r--r--roles/git/hooks/files/git.py211
-rw-r--r--roles/git/hooks/files/gnome-post-receive-email941
-rw-r--r--roles/git/hooks/files/post-received-chained8
-rw-r--r--roles/git/hooks/files/post-received-fedmsg65
-rw-r--r--roles/git/hooks/files/util.py153
-rw-r--r--roles/git/hooks/tasks/main.yml22
6 files changed, 1400 insertions, 0 deletions
diff --git a/roles/git/hooks/files/git.py b/roles/git/hooks/files/git.py
new file mode 100644
index 000000000..72adff1f7
--- /dev/null
+++ b/roles/git/hooks/files/git.py
@@ -0,0 +1,211 @@
+# Utility functions for git
+#
+# Copyright (C) 2008 Owen Taylor
+# Copyright (C) 2009 Red Hat, Inc
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, If not, see
+# http://www.gnu.org/licenses/.
+#
+# (These are adapted from git-bz)
+
+import os
+import re
+from subprocess import Popen, PIPE
+import sys
+
+from util import die
+
+# Clone of subprocess.CalledProcessError (not in Python 2.4)
+class CalledProcessError(Exception):
+ def __init__(self, returncode, cmd):
+ self.returncode = returncode
+ self.cmd = cmd
+
+ def __str__(self):
+ return "Command '%s' returned non-zero exit status %d" % (self.cmd, self.returncode)
+
+NULL_REVISION = "0000000000000000000000000000000000000000"
+
+# Run a git command
+# Non-keyword arguments are passed verbatim as command line arguments
+# Keyword arguments are turned into command line options
+# <name>=True => --<name>
+# <name>='<str>' => --<name>=<str>
+# Special keyword arguments:
+# _quiet: Discard all output even if an error occurs
+# _interactive: Don't capture stdout and stderr
+# _input=<str>: Feed <str> to stdinin of the command
+# _outfile=<file): Use <file> as the output file descriptor
+# _split_lines: Return an array with one string per returned line
+#
+def git_run(command, *args, **kwargs):
+ to_run = ['git', command.replace("_", "-")]
+
+ interactive = False
+ quiet = False
+ input = None
+ interactive = False
+ outfile = None
+ do_split_lines = False
+ for (k,v) in kwargs.iteritems():
+ if k == '_quiet':
+ quiet = True
+ elif k == '_interactive':
+ interactive = True
+ elif k == '_input':
+ input = v
+ elif k == '_outfile':
+ outfile = v
+ elif k == '_split_lines':
+ do_split_lines = True
+ elif v is True:
+ if len(k) == 1:
+ to_run.append("-" + k)
+ else:
+ to_run.append("--" + k.replace("_", "-"))
+ else:
+ to_run.append("--" + k.replace("_", "-") + "=" + v)
+
+ to_run.extend(args)
+
+ if outfile:
+ stdout = outfile
+ else:
+ if interactive:
+ stdout = None
+ else:
+ stdout = PIPE
+
+ if interactive:
+ stderr = None
+ else:
+ stderr = PIPE
+
+ if input != None:
+ stdin = PIPE
+ else:
+ stdin = None
+
+ process = Popen(to_run,
+ stdout=stdout, stderr=stderr, stdin=stdin)
+ output, error = process.communicate(input)
+ if process.returncode != 0:
+ if not quiet and not interactive:
+ print >>sys.stderr, error,
+ print output,
+ raise CalledProcessError(process.returncode, " ".join(to_run))
+
+ if interactive or outfile:
+ return None
+ else:
+ if do_split_lines:
+ return output.strip().splitlines()
+ else:
+ return output.strip()
+
+# Wrapper to allow us to do git.<command>(...) instead of git_run()
+class Git:
+ def __getattr__(self, command):
+ def f(*args, **kwargs):
+ return git_run(command, *args, **kwargs)
+ return f
+
+git = Git()
+
+class GitCommit:
+ def __init__(self, id, subject):
+ self.id = id
+ self.subject = subject
+
+# Takes argument like 'git.rev_list()' and returns a list of commit objects
+def rev_list_commits(*args, **kwargs):
+ kwargs_copy = dict(kwargs)
+ kwargs_copy['pretty'] = 'format:%s'
+ kwargs_copy['_split_lines'] = True
+ lines = git.rev_list(*args, **kwargs_copy)
+ if (len(lines) % 2 != 0):
+ raise RuntimeException("git rev-list didn't return an even number of lines")
+
+ result = []
+ for i in xrange(0, len(lines), 2):
+ m = re.match("commit\s+([A-Fa-f0-9]+)", lines[i])
+ if not m:
+ raise RuntimeException("Can't parse commit it '%s'", lines[i])
+ commit_id = m.group(1)
+ subject = lines[i + 1]
+ result.append(GitCommit(commit_id, subject))
+
+ return result
+
+# Loads a single commit object by ID
+def load_commit(commit_id):
+ return rev_list_commits(commit_id + "^!")[0]
+
+# Return True if the commit has multiple parents
+def commit_is_merge(commit):
+ if isinstance(commit, basestring):
+ commit = load_commit(commit)
+
+ parent_count = 0
+ for line in git.cat_file("commit", commit.id, _split_lines=True):
+ if line == "":
+ break
+ if line.startswith("parent "):
+ parent_count += 1
+
+ return parent_count > 1
+
+# Return a short one-line summary of the commit
+def commit_oneline(commit):
+ if isinstance(commit, basestring):
+ commit = load_commit(commit)
+
+ return commit.id[0:7]+"... " + commit.subject[0:59]
+
+# Return the directory name with .git stripped as a short identifier
+# for the module
+def get_module_name():
+ try:
+ git_dir = git.rev_parse(git_dir=True, _quiet=True)
+ except CalledProcessError:
+ die("GIT_DIR not set")
+
+ # Use the directory name with .git stripped as a short identifier
+ absdir = os.path.abspath(git_dir)
+ if absdir.endswith(os.sep + '.git'):
+ absdir = os.path.dirname(absdir)
+ projectshort = os.path.basename(absdir)
+ if projectshort.endswith(".git"):
+ projectshort = projectshort[:-4]
+
+ return projectshort
+
+# Return the project description or '' if it is 'Unnamed repository;'
+def get_project_description():
+ try:
+ git_dir = git.rev_parse(git_dir=True, _quiet=True)
+ except CalledProcessError:
+ die("GIT_DIR not set")
+
+ projectdesc = ''
+ description = os.path.join(git_dir, 'description')
+ if os.path.exists(description):
+ try:
+ projectdesc = open(description).read().strip()
+ except:
+ pass
+ if projectdesc.startswith('Unnamed repository;'):
+ projectdesc = ''
+
+ return projectdesc
diff --git a/roles/git/hooks/files/gnome-post-receive-email b/roles/git/hooks/files/gnome-post-receive-email
new file mode 100644
index 000000000..4c5cd619b
--- /dev/null
+++ b/roles/git/hooks/files/gnome-post-receive-email
@@ -0,0 +1,941 @@
+#!/usr/bin/python
+#
+# gnome-post-receive-email - Post receive email hook for the GNOME Git repository
+#
+# Copyright (C) 2008 Owen Taylor
+# Copyright (C) 2009 Red Hat, Inc
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, If not, see
+# http://www.gnu.org/licenses/.
+#
+# About
+# =====
+# This script is used to generate mail to commits-list@gnome.org when change
+# are pushed to the GNOME git repository. It accepts input in the form of
+# a Git post-receive hook, and generates appropriate emails.
+#
+# The attempt here is to provide a maximimally useful and robust output
+# with as little clutter as possible.
+#
+
+import re
+import os
+import pwd
+import sys
+from email.header import Header
+from socket import gethostname
+
+from kitchen.text.converters import to_bytes, to_unicode
+from kitchen.text.misc import byte_string_valid_encoding
+
+script_path = os.path.realpath(os.path.abspath(sys.argv[0]))
+script_dir = os.path.dirname(script_path)
+
+sys.path.insert(0, script_dir)
+
+from git import *
+from util import die, strip_string as s, start_email, end_email
+
+# When we put a git subject into the Subject: line, where to truncate
+SUBJECT_MAX_SUBJECT_CHARS = 100
+
+CREATE = 0
+UPDATE = 1
+DELETE = 2
+INVALID_TAG = 3
+
+# Short name for project
+projectshort = None
+
+# Project description
+projectdesc = None
+
+# Human readable name for user, might be None
+user_fullname = None
+
+# Who gets the emails
+recipients = None
+
+# What domain the emails are from
+maildomain = None
+
+# short diff output only
+mailshortdiff = False
+
+# map of ref_name => Change object; this is used when computing whether
+# we've previously generated a detailed diff for a commit in the push
+all_changes = {}
+processed_changes = {}
+
+class RefChange(object):
+ def __init__(self, refname, oldrev, newrev):
+ self.refname = refname
+ self.oldrev = oldrev
+ self.newrev = newrev
+
+ if oldrev == None and newrev != None:
+ self.change_type = CREATE
+ elif oldrev != None and newrev == None:
+ self.change_type = DELETE
+ elif oldrev != None and newrev != None:
+ self.change_type = UPDATE
+ else:
+ self.change_type = INVALID_TAG
+
+ m = re.match(r"refs/[^/]*/(.*)", refname)
+ if m:
+ self.short_refname = m.group(1)
+ else:
+ self.short_refname = refname
+
+ # Do any setup before sending email. The __init__ function should generally
+ # just record the parameters passed in and not do git work. (The main reason
+ # for the split is to let the prepare stage do different things based on
+ # whether other ref updates have been processed or not.)
+ def prepare(self):
+ pass
+
+ # Whether we should generate the normal 'main' email. For simple branch
+ # updates we only generate 'extra' emails
+ def get_needs_main_email(self):
+ return True
+
+ # The XXX in [projectname/XXX], usually a branch
+ def get_project_extra(self):
+ return None
+
+ # Return the subject for the main email, without the leading [projectname]
+ def get_subject(self):
+ raise NotImplementedError()
+
+ # Write the body of the main email to the given file object
+ def generate_body(self, out):
+ raise NotImplementedError()
+
+ def generate_header(self, out, subject, include_revs=True, oldrev=None, newrev=None):
+ user = os.environ['USER']
+ if user_fullname:
+ from_address = "%s <%s@%s>" % (user_fullname, user, maildomain)
+ else:
+ from_address = "%s@%s" % (user, maildomain)
+
+ if not byte_string_valid_encoding(to_bytes(subject), 'ascii'):
+ # non-ascii chars
+ subject = Header(to_bytes(to_unicode(subject)), 'utf-8').encode()
+
+ print >>out, s("""
+To: %(recipients)s
+From: %(from_address)s
+Subject: %(subject)s
+MIME-Version: 1.0
+Content-Transfer-Encoding: 8bit
+Content-Type: text/plain; charset="utf-8"
+Keywords: %(projectshort)s
+X-Project: %(projectdesc)s
+X-Git-Refname: %(refname)s
+""") % {
+ 'recipients': to_bytes(recipients, errors='strict'),
+ 'from_address': to_bytes(from_address, errors='strict'),
+ 'subject': subject,
+ 'projectshort': to_bytes(projectshort),
+ 'projectdesc': to_bytes(projectdesc),
+ 'refname': to_bytes(self.refname)
+ }
+
+ if include_revs:
+ if oldrev:
+ oldrev = oldrev
+ else:
+ oldrev = NULL_REVISION
+ if newrev:
+ newrev = newrev
+ else:
+ newrev = NULL_REVISION
+
+ print >>out, s("""
+X-Git-Oldrev: %(oldrev)s
+X-Git-Newrev: %(newrev)s
+""") % {
+ 'oldrev': to_bytes(oldrev),
+ 'newrev': to_bytes(newrev),
+ }
+
+ # Trailing newline to signal the end of the header
+ print >>out
+
+ def send_main_email(self):
+ if not self.get_needs_main_email():
+ return
+
+ extra = self.get_project_extra()
+ if extra:
+ extra = "/" + extra
+ else:
+ extra = ""
+ subject = "[" + projectshort + extra + "] " + self.get_subject()
+
+ email_out = start_email()
+
+ self.generate_header(email_out, subject, include_revs=True, oldrev=self.oldrev, newrev=self.newrev)
+ self.generate_body(email_out)
+
+ end_email()
+
+ # Allow multiple emails to be sent - used for branch updates
+ def send_extra_emails(self):
+ pass
+
+ def send_emails(self):
+ self.send_main_email()
+ self.send_extra_emails()
+
+# ========================
+
+# Common baseclass for BranchCreation and BranchUpdate (but not BranchDeletion)
+class BranchChange(RefChange):
+ def __init__(self, *args):
+ RefChange.__init__(self, *args)
+
+ def prepare(self):
+ # We need to figure out what commits are referenced in this commit thta
+ # weren't previously referenced in the repository by another branch.
+ # "Previously" here means either before this push, or by branch updates
+ # we've already done in this push. These are the commits we'll send
+ # out individual mails for.
+ #
+ # Note that "Before this push" can't be gotten exactly right since an
+ # push is only atomic per-branch and there is no locking across branches.
+ # But new commits will always show up in a cover mail in any case; even
+ # someone who maliciously is trying to fool us can't hide all trace.
+
+ # Ordering matters here, so we can't rely on kwargs
+ branches = git.rev_parse('--symbolic-full-name', '--branches', _split_lines=True)
+ detailed_commit_args = [ self.newrev ]
+
+ for branch in branches:
+ if branch == self.refname:
+ # For this branch, exclude commits before 'oldrev'
+ if self.change_type != CREATE:
+ detailed_commit_args.append("^" + self.oldrev)
+ elif branch in all_changes and not branch in processed_changes:
+ # For branches that were updated in this push but we haven't processed
+ # yet, exclude commits before their old revisions
+ detailed_commit_args.append("^" + all_changes[branch].oldrev)
+ else:
+ # Exclude commits that are ancestors of all other branches
+ detailed_commit_args.append("^" + branch)
+
+ detailed_commits = git.rev_list(*detailed_commit_args).splitlines()
+
+ self.detailed_commits = set()
+ for id in detailed_commits:
+ self.detailed_commits.add(id)
+
+ # Find the commits that were added and removed, reverse() to get
+ # chronological order
+ if self.change_type == CREATE:
+ # If someone creates a branch of GTK+, we don't want to list (or even walk through)
+ # all 30,000 commits in the history as "new commits" on the branch. So we start
+ # the commit listing from the first commit we are going to send a mail out about.
+ #
+ # This does mean that if someone creates a branch, merges it, and then pushes
+ # both the branch and what was merged into at once, then the resulting mails will
+ # be a bit strange (depending on ordering) - the mail for the creation of the
+ # branch may look like it was created in the finished state because all the commits
+ # have been already mailed out for the other branch. I don't think this is a big
+ # problem, and the best way to fix it would be to sort the ref updates so that the
+ # branch creation was processed first.
+ #
+ if len(detailed_commits) > 0:
+ # Verify parent of first detailed commit is valid. On initial push, it is not.
+ parent = detailed_commits[-1] + "^"
+ try:
+ validref = git.rev_parse(parent, _quiet=True)
+ except CalledProcessError, error:
+ self.added_commits = []
+ else:
+ self.added_commits = rev_list_commits(parent + ".." + self.newrev)
+ self.added_commits.reverse()
+ else:
+ self.added_commits = []
+ self.removed_commits = []
+ else:
+ self.added_commits = rev_list_commits(self.oldrev + ".." + self.newrev)
+ self.added_commits.reverse()
+ self.removed_commits = rev_list_commits(self.newrev + ".." + self.oldrev)
+ self.removed_commits.reverse()
+
+ # In some cases we'll send a cover email that describes the overall
+ # change to the branch before ending individual mails for commits. In other
+ # cases, we just send the individual emails. We generate a cover mail:
+ #
+ # - If it's a branch creation
+ # - If it's not a fast forward
+ # - If there are any merge commits
+ # - If there are any commits we won't send separately (already in repo)
+
+ have_merge_commits = False
+ for commit in self.added_commits:
+ if commit_is_merge(commit):
+ have_merge_commits = True
+
+ self.needs_cover_email = (self.change_type == CREATE or
+ len(self.removed_commits) > 0 or
+ have_merge_commits or
+ len(self.detailed_commits) < len(self.added_commits))
+
+ def get_needs_main_email(self):
+ return self.needs_cover_email
+
+ # A prefix for the cover letter summary with the number of added commits
+ def get_count_string(self):
+ if len(self.added_commits) > 1:
+ return "(%d commits) " % len(self.added_commits)
+ else:
+ return ""
+
+ # Generate a short listing for a series of commits
+ # show_details - whether we should mark commit where we aren't going to send
+ # a detailed email. (Set the False when listing removed commits)
+ def generate_commit_summary(self, out, commits, show_details=True):
+ detail_note = False
+ for commit in commits:
+ if show_details and not commit.id in self.detailed_commits:
+ detail = " (*)"
+ detail_note = True
+ else:
+ detail = ""
+ print >>out, " %s%s" % (to_bytes(commit_oneline(commit)), to_bytes(detail))
+
+ if detail_note:
+ print >>out
+ print >>out, "(*) This commit already existed in another branch; no separate mail sent"
+
+ def send_extra_emails(self):
+ total = len(self.added_commits)
+
+ for i, commit in enumerate(self.added_commits):
+ if not commit.id in self.detailed_commits:
+ continue
+
+ email_out = start_email()
+
+ if self.short_refname == 'master':
+ branch = ""
+ else:
+ branch = "/" + self.short_refname
+
+ total = len(self.added_commits)
+ if total > 1 and self.needs_cover_email:
+ count_string = ": %(index)s/%(total)s" % {
+ 'index' : i + 1,
+ 'total' : total
+ }
+ else:
+ count_string = ""
+
+ subject = "[%(projectshort)s%(branch)s%(count_string)s] %(subject)s" % {
+ 'projectshort' : projectshort,
+ 'branch' : branch,
+ 'count_string' : count_string,
+ 'subject' : commit.subject[0:SUBJECT_MAX_SUBJECT_CHARS]
+ }
+
+ # If there is a cover email, it has the X-Git-OldRev/X-Git-NewRev in it
+ # for the total branch update. Without a cover email, we are conceptually
+ # breaking up the update into individual updates for each commit
+ if self.needs_cover_email:
+ self.generate_header(email_out, subject, include_revs=False)
+ else:
+ parent = git.rev_parse(commit.id + "^")
+ self.generate_header(email_out, subject,
+ include_revs=True,
+ oldrev=parent, newrev=commit.id)
+
+ email_out.flush()
+ git.show(commit.id, M=True, stat=True, _outfile=email_out)
+ email_out.flush()
+ if not mailshortdiff:
+ git.show(commit.id, p=True, M=True, diff_filter="ACMRTUXB", pretty="format:---", _outfile=email_out)
+ end_email()
+
+class BranchCreation(BranchChange):
+ def get_subject(self):
+ return self.get_count_string() + "Created branch " + self.short_refname
+
+ def generate_body(self, out):
+ if len(self.added_commits) > 0:
+ print >>out, s("""
+The branch '%(short_refname)s' was created.
+
+Summary of new commits:
+
+""") % {
+ 'short_refname': to_bytes(self.short_refname),
+ }
+
+ self.generate_commit_summary(out, self.added_commits)
+ else:
+ print >>out, s("""
+The branch '%(short_refname)s' was created pointing to:
+
+ %(commit_oneline)s
+
+""") % {
+ 'short_refname': to_bytes(self.short_refname),
+ 'commit_oneline': to_bytes(commit_oneline(self.newrev))
+ }
+
+class BranchUpdate(BranchChange):
+ def get_project_extra(self):
+ if len(self.removed_commits) > 0:
+ # In the non-fast-forward-case, the branch name is in the subject
+ return None
+ else:
+ if self.short_refname == 'master':
+ # Not saying 'master' all over the place reduces clutter
+ return None
+ else:
+ return self.short_refname
+
+ def get_subject(self):
+ if len(self.removed_commits) > 0:
+ return self.get_count_string() + "Non-fast-forward update to branch " + self.short_refname
+ else:
+ # We want something for useful for the subject than "Updates to branch spiffy-stuff".
+ # The common case where we have a cover-letter for a fast-forward branch
+ # update is a merge. So we try to get:
+ #
+ # [myproject/spiffy-stuff] (18 commits) ...Merge branch master
+ #
+ last_commit = self.added_commits[-1]
+ if len(self.added_commits) > 1:
+ return self.get_count_string() + "..." + last_commit.subject[0:SUBJECT_MAX_SUBJECT_CHARS]
+ else:
+ # The ... indicates we are only showing one of many, don't need it for a single commit
+ return last_commit.subject[0:SUBJECT_MAX_SUBJECT_CHARS]
+
+ def generate_body_normal(self, out):
+ print >>out, s("""
+Summary of changes:
+
+""")
+
+ self.generate_commit_summary(out, self.added_commits)
+
+ def generate_body_non_fast_forward(self, out):
+ print >>out, s("""
+The branch '%(short_refname)s' was changed in a way that was not a fast-forward update.
+NOTE: This may cause problems for people pulling from the branch. For more information,
+please see:
+
+ http://live.gnome.org/Git/Help/NonFastForward
+
+Commits removed from the branch:
+
+""") % {
+ 'short_refname': to_bytes(self.short_refname),
+ }
+
+ self.generate_commit_summary(out, self.removed_commits, show_details=False)
+
+ print >>out, s("""
+
+Commits added to the branch:
+
+""")
+ self.generate_commit_summary(out, self.added_commits)
+
+ def generate_body(self, out):
+ if len(self.removed_commits) == 0:
+ self.generate_body_normal(out)
+ else:
+ self.generate_body_non_fast_forward(out)
+
+class BranchDeletion(RefChange):
+ def get_subject(self):
+ return "Deleted branch " + self.short_refname
+
+ def generate_body(self, out):
+ print >>out, s("""
+The branch '%(short_refname)s' was deleted.
+""") % {
+ 'short_refname': to_bytes(self.short_refname),
+ }
+
+# ========================
+
+class AnnotatedTagChange(RefChange):
+ def __init__(self, *args):
+ RefChange.__init__(self, *args)
+
+ def prepare(self):
+ # Resolve tag to commit
+ if self.oldrev:
+ self.old_commit_id = git.rev_parse(self.oldrev + "^{commit}")
+
+ if self.newrev:
+ self.parse_tag_object(self.newrev)
+ else:
+ self.parse_tag_object(self.oldrev)
+
+ # Parse information out of the tag object
+ def parse_tag_object(self, revision):
+ message_lines = []
+ in_message = False
+
+ # A bit of paranoia if we fail at parsing; better to make the failure
+ # visible than just silently skip Tagger:/Date:.
+ self.tagger = "unknown <unknown@example.com>"
+ self.date = "at an unknown time"
+
+ self.have_signature = False
+ for line in git.cat_file(revision, p=True, _split_lines=True):
+ if in_message:
+ # Nobody is going to verify the signature by extracting it
+ # from the email, so strip it, and remember that we saw it
+ # by saying 'signed tag'
+ if re.match(r'-----BEGIN PGP SIGNATURE-----', line):
+ self.have_signature = True
+ break
+ message_lines.append(line)
+ else:
+ if line.strip() == "":
+ in_message = True
+ continue
+ # I don't know what a more robust rule is for dividing the
+ # name and date, other than maybe looking explicitly for a
+ # RFC 822 date. This seems to work pretty well
+ m = re.match(r"tagger\s+([^>]*>)\s*(.*)", line)
+ if m:
+ self.tagger = m.group(1)
+ self.date = m.group(2)
+ continue
+ self.message = "\n".join([" " + line for line in message_lines])
+
+ # Outputs information about the new tag
+ def generate_tag_info(self, out):
+
+ print >>out, s("""
+Tagger: %(tagger)s
+Date: %(date)s
+
+%(message)s
+
+""") % {
+ 'tagger': to_bytes(self.tagger),
+ 'date': to_bytes(self.date),
+ 'message': to_bytes(self.message),
+ }
+
+ # We take the creation of an annotated tag as being a "mini-release-announcement"
+ # and show a 'git shortlog' of the changes since the last tag that was an
+ # ancestor of the new tag.
+ last_tag = None
+ try:
+ # A bit of a hack to get that previous tag
+ last_tag = git.describe(self.newrev+"^", abbrev='0', _quiet=True)
+ except CalledProcessError:
+ # Assume that this means no older tag
+ pass
+
+ if last_tag:
+ revision_range = last_tag + ".." + self.newrev
+ print >>out, s("""
+Changes since the last tag '%(last_tag)s':
+
+""") % {
+ 'last_tag': to_bytes(last_tag)
+ }
+ else:
+ revision_range = self.newrev
+ print >>out, s("""
+Changes:
+
+""")
+ out.write(to_bytes(git.shortlog(revision_range)))
+ out.write("\n")
+
+ def get_tag_type(self):
+ if self.have_signature:
+ return 'signed tag'
+ else:
+ return 'unsigned tag'
+
+class AnnotatedTagCreation(AnnotatedTagChange):
+ def get_subject(self):
+ return "Created tag " + self.short_refname
+
+ def generate_body(self, out):
+ print >>out, s("""
+The %(tag_type)s '%(short_refname)s' was created.
+
+""") % {
+ 'tag_type': to_bytes(self.get_tag_type()),
+ 'short_refname': to_bytes(self.short_refname),
+ }
+ self.generate_tag_info(out)
+
+class AnnotatedTagDeletion(AnnotatedTagChange):
+ def get_subject(self):
+ return "Deleted tag " + self.short_refname
+
+ def generate_body(self, out):
+ print >>out, s("""
+The %(tag_type)s '%(short_refname)s' was deleted. It previously pointed to:
+
+ %(old_commit_oneline)s
+""") % {
+ 'tag_type': to_bytes(self.get_tag_type()),
+ 'short_refname': to_bytes(self.short_refname),
+ 'old_commit_oneline': to_bytes(commit_oneline(self.old_commit_id)),
+ }
+
+class AnnotatedTagUpdate(AnnotatedTagChange):
+ def get_subject(self):
+ return "Updated tag " + self.short_refname
+
+ def generate_body(self, out):
+ print >>out, s("""
+The tag '%(short_refname)s' was replaced with a new tag. It previously
+pointed to:
+
+ %(old_commit_oneline)s
+
+NOTE: People pulling from the repository will not get the new tag.
+For more information, please see:
+
+ http://live.gnome.org/Git/Help/TagUpdates
+
+New tag information:
+
+""") % {
+ 'short_refname': to_bytes(self.short_refname),
+ 'old_commit_oneline': to_bytes(commit_oneline(self.old_commit_id)),
+ }
+ self.generate_tag_info(out)
+
+# ========================
+
+class LightweightTagCreation(RefChange):
+ def get_subject(self):
+ return "Created tag " + self.short_refname
+
+ def generate_body(self, out):
+ print >>out, s("""
+The lightweight tag '%(short_refname)s' was created pointing to:
+
+ %(commit_oneline)s
+""") % {
+ 'short_refname': to_bytes(self.short_refname),
+ 'commit_oneline': to_bytes(commit_oneline(self.newrev))
+ }
+
+class LightweightTagDeletion(RefChange):
+ def get_subject(self):
+ return "Deleted tag " + self.short_refname
+
+ def generate_body(self, out):
+ print >>out, s("""
+The lighweight tag '%(short_refname)s' was deleted. It previously pointed to:
+
+ %(commit_oneline)s
+""") % {
+ 'short_refname': to_bytes(self.short_refname),
+ 'commit_oneline': to_bytes(commit_oneline(self.oldrev)),
+ }
+
+class LightweightTagUpdate(RefChange):
+ def get_subject(self):
+ return "Updated tag " + self.short_refname
+
+ def generate_body(self, out):
+ print >>out, s("""
+The lightweight tag '%(short_refname)s' was updated to point to:
+
+ %(commit_oneline)s
+
+It previously pointed to:
+
+ %(old_commit_oneline)s
+
+NOTE: People pulling from the repository will not get the new tag.
+For more information, please see:
+
+ http://live.gnome.org/Git/Help/TagUpdates
+""") % {
+ 'short_refname': to_bytes(self.short_refname),
+ 'commit_oneline': to_bytes(commit_oneline(self.newrev)),
+ 'old_commit_oneline': to_bytes(commit_oneline(self.oldrev)),
+ }
+
+# ========================
+
+class InvalidRefDeletion(RefChange):
+ def get_subject(self):
+ return "Deleted invalid ref " + self.refname
+
+ def generate_body(self, out):
+ print >>out, s("""
+The ref '%(refname)s' was deleted. It previously pointed nowhere.
+""") % {
+ 'refname': to_bytes(self.refname),
+ }
+
+# ========================
+
+class MiscChange(RefChange):
+ def __init__(self, refname, oldrev, newrev, message):
+ RefChange.__init__(self, refname, oldrev, newrev)
+ self.message = message
+
+class MiscCreation(MiscChange):
+ def get_subject(self):
+ return "Unexpected: Created " + self.refname
+
+ def generate_body(self, out):
+ print >>out, s("""
+The ref '%(refname)s' was created pointing to:
+
+ %(newrev)s
+
+This is unexpected because:
+
+ %(message)s
+""") % {
+ 'refname': to_bytes(self.refname),
+ 'newrev': to_bytes(self.newrev),
+ 'message': to_bytes(self.message),
+ }
+
+class MiscDeletion(MiscChange):
+ def get_subject(self):
+ return "Unexpected: Deleted " + self.refname
+
+ def generate_body(self, out):
+ print >>out, s("""
+The ref '%(refname)s' was deleted. It previously pointed to:
+
+ %(oldrev)s
+
+This is unexpected because:
+
+ %(message)s
+""") % {
+ 'refname': to_bytes(self.refname),
+ 'oldrev': to_bytes(self.oldrev),
+ 'message': to_bytes(self.message),
+ }
+
+class MiscUpdate(MiscChange):
+ def get_subject(self):
+ return "Unexpected: Updated " + self.refname
+
+ def generate_body(self, out):
+ print >>out, s("""
+The ref '%(refname)s' was updated from:
+
+ %(newrev)s
+
+To:
+
+ %(oldrev)s
+
+This is unexpected because:
+
+ %(message)s
+""") % {
+ 'refname': to_bytes(self.refname),
+ 'oldrev': to_bytes(self.oldrev),
+ 'newrev': to_bytes(self.newrev),
+ 'message': to_bytes(self.message),
+ }
+
+# ========================
+
+def make_change(oldrev, newrev, refname):
+ refname = refname
+
+ # Canonicalize
+ oldrev = git.rev_parse(oldrev)
+ newrev = git.rev_parse(newrev)
+
+ # Replacing the null revision with None makes it easier for us to test
+ # in subsequent code
+
+ if re.match(r'^0+$', oldrev):
+ oldrev = None
+ else:
+ oldrev = oldrev
+
+ if re.match(r'^0+$', newrev):
+ newrev = None
+ else:
+ newrev = newrev
+
+ # Figure out what we are doing to the ref
+
+ if oldrev == None and newrev != None:
+ change_type = CREATE
+ target = newrev
+ elif oldrev != None and newrev == None:
+ change_type = DELETE
+ target = oldrev
+ elif oldrev != None and newrev != None:
+ change_type = UPDATE
+ target = newrev
+ else:
+ return InvalidRefDeletion(refname, oldrev, newrev)
+
+ object_type = git.cat_file(target, t=True)
+
+ # And then create the right type of change object
+
+ # Closing the arguments like this simplifies the following code
+ def make(cls, *args):
+ return cls(refname, oldrev, newrev, *args)
+
+ def make_misc_change(message):
+ if change_type == CREATE:
+ return make(MiscCreation, message)
+ elif change_type == DELETE:
+ return make(MiscDeletion, message)
+ else:
+ return make(MiscUpdate, message)
+
+ if re.match(r'^refs/tags/.*$', refname):
+ if object_type == 'commit':
+ if change_type == CREATE:
+ return make(LightweightTagCreation)
+ elif change_type == DELETE:
+ return make(LightweightTagDeletion)
+ else:
+ return make(LightweightTagUpdate)
+ elif object_type == 'tag':
+ if change_type == CREATE:
+ return make(AnnotatedTagCreation)
+ elif change_type == DELETE:
+ return make(AnnotatedTagDeletion)
+ else:
+ return make(AnnotatedTagUpdate)
+ else:
+ return make_misc_change("%s is not a commit or tag object" % target)
+ elif re.match(r'^refs/heads/.*$', refname):
+ if object_type == 'commit':
+ if change_type == CREATE:
+ return make(BranchCreation)
+ elif change_type == DELETE:
+ return make(BranchDeletion)
+ else:
+ return make(BranchUpdate)
+ else:
+ return make_misc_change("%s is not a commit object" % target)
+ elif re.match(r'^refs/remotes/.*$', refname):
+ return make_misc_change("'%s' is a tracking branch and doesn't belong on the server" % refname)
+ else:
+ return make_misc_change("'%s' is not in refs/heads/ or refs/tags/" % refname)
+
+def main():
+ global projectshort
+ global projectdesc
+ global user_fullname
+ global recipients
+ global maildomain
+ global mailshortdiff
+
+ # No emails for a repository in the process of being imported
+ git_dir = git.rev_parse(git_dir=True, _quiet=True)
+ if os.path.exists(os.path.join(git_dir, 'pending')):
+ return
+
+ projectshort = get_module_name()
+ projectdesc = get_project_description()
+
+
+ try:
+ mailshortdiff=git.config("hooks.mailshortdiff", _quiet=True)
+ except CalledProcessError:
+ pass
+
+ if isinstance(mailshortdiff, str) and mailshortdiff.lower() in ('true', 'yes', 'on', '1'):
+ mailshortdiff = True
+ else:
+ mailshortdiff = False
+
+ try:
+ recipients=git.config("hooks.mailinglist", _quiet=True)
+ except CalledProcessError:
+ pass
+
+ if not recipients:
+ die("hooks.mailinglist is not set")
+
+ # Get the domain name to use in the From header
+ try:
+ maildomain = git.config("hooks.maildomain", _quiet=True)
+ except CalledProcessError:
+ pass
+
+ if not maildomain:
+ try:
+ hostname = gethostname()
+ maildomain = '.'.join(hostname.split('.')[1:])
+ except:
+ pass
+ if not maildomain or '.' not in maildomain:
+ maildomain = 'localhost.localdomain'
+
+ # Figure out a human-readable username
+ try:
+ entry = pwd.getpwuid(os.getuid())
+ gecos = entry.pw_gecos
+ except:
+ gecos = None
+
+ if gecos != None:
+ # Typical GNOME account have John Doe <john.doe@example.com> for the GECOS.
+ # Comma-separated fields are also possible
+ m = re.match("([^,<]+)", gecos)
+ if m:
+ fullname = m.group(1).strip()
+ if fullname != "":
+ try:
+ user_fullname = unicode(fullname, 'ascii')
+ except UnicodeDecodeError:
+ user_fullname = Header(fullname, 'utf-8').encode()
+
+ changes = []
+
+ if len(sys.argv) > 1:
+ # For testing purposes, allow passing in a ref update on the command line
+ if len(sys.argv) != 4:
+ die("Usage: generate-commit-mail OLDREV NEWREV REFNAME")
+ changes.append(make_change(sys.argv[1], sys.argv[2], sys.argv[3]))
+ else:
+ for line in sys.stdin:
+ items = line.strip().split()
+ if len(items) != 3:
+ die("Input line has unexpected number of items")
+ changes.append(make_change(items[0], items[1], items[2]))
+
+ for change in changes:
+ all_changes[change.refname] = change
+
+ for change in changes:
+ change.prepare()
+ change.send_emails()
+ processed_changes[change.refname] = change
+
+if __name__ == '__main__':
+ main()
diff --git a/roles/git/hooks/files/post-received-chained b/roles/git/hooks/files/post-received-chained
new file mode 100644
index 000000000..b5c6e2311
--- /dev/null
+++ b/roles/git/hooks/files/post-received-chained
@@ -0,0 +1,8 @@
+#!/bin/bash
+# Redirect stdin to each of the post-receive hooks in place.
+
+# You need to explicitly add your hook to the following list
+# for it to be invoked.
+pee \
+ $GIT_DIR/hooks/post-receive-chained.d/post-receive-email \
+ $GIT_DIR/hooks/post-receive-chained.d/post-receive-fedmsg
diff --git a/roles/git/hooks/files/post-received-fedmsg b/roles/git/hooks/files/post-received-fedmsg
new file mode 100644
index 000000000..7bc9a140d
--- /dev/null
+++ b/roles/git/hooks/files/post-received-fedmsg
@@ -0,0 +1,65 @@
+#!/usr/bin/env python
+
+import getpass
+import git
+import os
+import sys
+
+import fedmsg
+import fedmsg.config
+
+# Read in all the rev information git-receive-pack hands us.
+lines = [line.split() for line in sys.stdin.readlines()]
+
+# Use $GIT_DIR to determine where this repo is.
+abspath = os.path.abspath(os.environ['GIT_DIR'])
+repo_name = '.'.join(abspath.split(os.path.sep)[-1].split('.')[:-1])
+
+username = getpass.getuser()
+
+repo = git.repo.Repo(abspath)
+def _build_commit(rev):
+ old, rev, branch = rev
+ branch = '/'.join(branch.split('/')[2:])
+ commit = repo.rev_parse(rev=rev)
+
+ # We just don't handle these
+ if isinstance(commit, git.TagObject):
+ return None
+
+ return dict(
+ name=commit.author.name,
+ email=commit.author.email,
+ username=username,
+ summary=commit.summary,
+ message=commit.message,
+ stats=dict(
+ files=commit.stats.files,
+ total=commit.stats.total,
+ ),
+ rev=rev,
+ path=abspath,
+ repo=repo_name,
+ branch=branch,
+ agent=os.getlogin(),
+ )
+
+commits = map(_build_commit, lines)
+
+print "Emitting a message to the fedmsg bus."
+config = fedmsg.config.load_config([], None)
+config['active'] = True
+config['endpoints']['relay_inbound'] = config['relay_inbound']
+fedmsg.init(name='relay_inbound', cert_prefix='scm', **config)
+
+for commit in commits:
+
+ if commit is None:
+ continue
+
+ fedmsg.publish(
+ # Expect this to change to just "receive" in the future.
+ topic="receive",
+ msg=dict(commit=commit),
+ modname="git",
+ )
diff --git a/roles/git/hooks/files/util.py b/roles/git/hooks/files/util.py
new file mode 100644
index 000000000..f35019634
--- /dev/null
+++ b/roles/git/hooks/files/util.py
@@ -0,0 +1,153 @@
+# General Utility Functions used in our Git scripts
+#
+# Copyright (C) 2008 Owen Taylor
+# Copyright (C) 2009 Red Hat, Inc
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, If not, see
+# http://www.gnu.org/licenses/.
+
+import os
+import sys
+from subprocess import Popen
+import tempfile
+import time
+
+def die(message):
+ print >>sys.stderr, message
+ sys.exit(1)
+
+# This cleans up our generation code by allowing us to use the same indentation
+# for the first line and subsequent line of a multi-line string
+def strip_string(str):
+ start = 0
+ end = len(str)
+ if len(str) > 0 and str[0] == '\n':
+ start += 1
+ if len(str) > 1 and str[end - 1] == '\n':
+ end -= 1
+
+ return str[start:end]
+
+# How long to wait between mails (in seconds); the idea of waiting
+# is to try to make the sequence of mails we send out in order
+# actually get delivered in order. The waiting is done in a forked
+# subprocess and doesn't stall completion of the main script.
+EMAIL_DELAY = 5
+
+# Some line that can never appear in any email we send out
+EMAIL_BOUNDARY="---@@@--- gnome-git-email ---@@@---\n"
+
+# Run in subprocess
+def _do_send_emails(email_in):
+ email_files = []
+ current_file = None
+ last_line = None
+
+ # Read emails from the input pipe and write each to a file
+ for line in email_in:
+ if current_file is None:
+ current_file, filename = tempfile.mkstemp(suffix=".mail", prefix="gnome-post-receive-email-")
+ email_files.append(filename)
+
+ if line == EMAIL_BOUNDARY:
+ # Strip the last line if blank; see comment when writing
+ # the email boundary for rationale
+ if last_line.strip() != "":
+ os.write(current_file, last_line)
+ last_line = None
+ os.close(current_file)
+ current_file = None
+ else:
+ if last_line is not None:
+ os.write(current_file, last_line)
+ last_line = line
+
+ if current_file is not None:
+ if last_line is not None:
+ os.write(current_file, last_line)
+ os.close(current_file)
+
+ # We're done interacting with the parent process, the rest happens
+ # asynchronously; send out the emails one by one and remove the
+ # temporary files
+ for i, filename in enumerate(email_files):
+ if i != 0:
+ time.sleep(EMAIL_DELAY)
+
+ f = open(filename, "r")
+ process = Popen(["/usr/sbin/sendmail", "-t"],
+ stdout=None, stderr=None, stdin=f)
+ process.wait()
+ f.close()
+
+ os.remove(filename)
+
+email_file = None
+
+# Start a new outgoing email; returns a file object that the
+# email should be written to. Call end_email() when done
+def start_email():
+ global email_file
+ if email_file is None:
+ email_pipe = os.pipe()
+ pid = os.fork()
+ if pid == 0:
+ # The child
+
+ os.close(email_pipe[1])
+ email_in = os.fdopen(email_pipe[0])
+
+ # Redirect stdin/stdout/stderr to/from /dev/null
+ devnullin = os.open("/dev/null", os.O_RDONLY)
+ os.close(0)
+ os.dup2(devnullin, 0)
+
+ devnullout = os.open("/dev/null", os.O_WRONLY)
+ os.close(1)
+ os.dup2(devnullout, 1)
+ os.close(2)
+ os.dup2(devnullout, 2)
+ os.close(devnullout)
+
+ # Fork again to daemonize
+ if os.fork() > 0:
+ sys.exit(0)
+
+ try:
+ _do_send_emails(email_in)
+ except Exception:
+ import syslog
+ import traceback
+
+ syslog.openlog(os.path.basename(sys.argv[0]))
+ syslog.syslog(syslog.LOG_ERR, "Unexpected exception sending mail")
+ for line in traceback.format_exc().strip().split("\n"):
+ syslog.syslog(syslog.LOG_ERR, line)
+
+ sys.exit(0)
+
+ email_file = os.fdopen(email_pipe[1], "w")
+ else:
+ # The email might not end with a newline, so add one. We'll
+ # strip the last line, if blank, when emails, so the net effect
+ # is to add a newline to messages without one
+ email_file.write("\n")
+ email_file.write(EMAIL_BOUNDARY)
+
+ return email_file
+
+# Finish an email started with start_email
+def end_email():
+ global email_file
+ email_file.flush()
diff --git a/roles/git/hooks/tasks/main.yml b/roles/git/hooks/tasks/main.yml
new file mode 100644
index 000000000..6cf6b9d0f
--- /dev/null
+++ b/roles/git/hooks/tasks/main.yml
@@ -0,0 +1,22 @@
+---
+# tasklist for setting up git mail hooks
+
+- name: install needed packages
+ yum: pkg={{item}} state=present
+ with_items:
+ - git
+ - moreutils
+
+# This requires the fedmsg/base role
+- name: install the git hooks
+ copy: src={{item}} dest=/usr/share/git-core mode=0755
+ with_items:
+ - post-receive-fedmsg
+ - post-receive-chained
+
+- name: install the git mail hooks
+ copy: src={{item}} dest=/usr/share/git-core/mail-hooks mode=0755
+ with_items:
+ - util.py
+ - git.py
+ - gnome-post-receive-email