Add a new git/hooks role

This will be needed to migrate Dist Git from puppet to ansible.
This commit is contained in:
Mathieu Bridon 2014-07-04 17:59:44 +02:00 committed by Kevin Fenzi
parent b121d21d56
commit fed72f7ba1
6 changed files with 1400 additions and 0 deletions

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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",
)

View file

@ -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()

View file

@ -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