From 47242037313efd68787ad2196da3fe09c7edae4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= Date: Tue, 25 Apr 2017 19:30:49 +0200 Subject: [PATCH] initial commit, origins explained in README --- README | 5 + git.py | 211 ++++++++ gnome-pre-receive | 59 +++ post-receive | 26 + post-receive-email | 896 ++++++++++++++++++++++++++++++++++ post-receive-notify-updates | 117 +++++ pre-receive-check-maintainers | 72 +++ pre-receive-check-po | 141 ++++++ pre-receive-check-policy | 259 ++++++++++ util.py | 166 +++++++ 10 files changed, 1952 insertions(+) create mode 100644 README create mode 100644 git.py create mode 100755 gnome-pre-receive create mode 100755 post-receive create mode 100755 post-receive-email create mode 100755 post-receive-notify-updates create mode 100755 pre-receive-check-maintainers create mode 100755 pre-receive-check-po create mode 100755 pre-receive-check-policy create mode 100644 util.py diff --git a/README b/README new file mode 100644 index 0000000..17d9ed8 --- /dev/null +++ b/README @@ -0,0 +1,5 @@ +This comes from the ancient GNOME gitadmin-bin that can't be found anywhere, +that was later copied without history to the current GNOME sysadmin-bin module. + +This module is an older history-less fork, with a few entr'ouvert specific +changes from the start. diff --git a/git.py b/git.py new file mode 100644 index 0000000..72adff1 --- /dev/null +++ b/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 +# =True => -- +# ='' => --= +# Special keyword arguments: +# _quiet: Discard all output even if an error occurs +# _interactive: Don't capture stdout and stderr +# _input=: Feed to stdinin of the command +# _outfile= 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.(...) 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/gnome-pre-receive b/gnome-pre-receive new file mode 100755 index 0000000..7f70ed2 --- /dev/null +++ b/gnome-pre-receive @@ -0,0 +1,59 @@ +#!/bin/bash +# +# Standard GNOME pre-receive hook. +# +# The "pre-receive" hook is invoked just before receive-pack starts to +# update refs on the remote repository. Its exit status determines the +# success or failure of the update. + +BINDIR=/usr/local/bin/git-bin + +# If the committing user has a homedir with a .gitconfig in it, it we +# don't want that to affect our operation. (Should this just be handled +# in run-git-or-special-cmd?) +GIT_DIR=$(git rev-parse --git-dir 2>/dev/null) +GIT_CONFIG="${GIT_DIR}/config" +export GIT_CONFIG + +# Use the directory name with .git stripped as a short identifier +absdir=$(cd $GIT_DIR && pwd) +projectshort=$(basename ${absdir%.git}) + +# Make sure that the user used --exec=import for importing repositories, +# and not otherwise. This forces people to be aware of the 'pending' +# state. +if [ -e "$GIT_DIR/pending" -a -z "$GNOME_GIT_IMPORT" ] ; then + cat <&2 +--- +$projectshort is still in the process of being imported. To import +into $projectshort use 'git push --exec=import'. If you are done +importing, do: + + ssh $USER@git.gnome.org finish-import $projectshort + +--- +EOF + exit 1 +fi + +if [ \! -e "$GIT_DIR/pending" -a -n "$GNOME_GIT_IMPORT" ] ; then + cat <&2 +--- +$projectshort is no longer in the process of being imported. You +can push to $projectshort normally. If you accidentally ran +finish-import too early, please contact gitmaster@gnome.org +for assistance. +---- +EOF + exit 1 +fi + +while read oldrev newrev refname; do + # Unlike the gnome-post-receive script, where we play fancy games + # with 'tee', we invoke the different pre-receive hooks separately + # for each ref that is updated. This keeps things simple and + # reliable and none of the scripts need all the refs at once. + + $BINDIR/pre-receive-check-policy $oldrev $newrev $refname || exit 1 + #$BINDIR/pre-receive-check-po $oldrev $newrev $refname || exit 1 +done diff --git a/post-receive b/post-receive new file mode 100755 index 0000000..5ddbee2 --- /dev/null +++ b/post-receive @@ -0,0 +1,26 @@ +#!/bin/bash +# +# Standard GNOME post-receive hook. +# +# The "post-receive" script is run after receive-pack has accepted a pack +# and the repository has been updated. It is passed arguments in through +# stdin in the form +# +# For example: +# aa453216d1b3e49e7f6f98441fa56946ddcd6a20 68f7abf4e6f922807889f52bc043ecd31b79f814 refs/heads/master +# +# git-config options affecting the operation of this script: +# hook.emailprefix - should always be empty +# hooks.mailinglist - should always be svn-commits-list@gnome.org + +BINDIR=/usr/local/bin/git-bin + +# If the committing user has a homedir with a .gitconfig in it, it we +# don't want that to affect our operation. (Should this just be handled +# in run-git-or-special-cmd?) +GIT_DIR=$(git rev-parse --git-dir 2>/dev/null) +GIT_CONFIG="${GIT_DIR}/config" +export GIT_CONFIG + +tee >($BINDIR/post-receive-notify-updates 1>&2) \ + | $BINDIR/post-receive-email 1>&2 diff --git a/post-receive-email b/post-receive-email new file mode 100755 index 0000000..30c79d7 --- /dev/null +++ b/post-receive-email @@ -0,0 +1,896 @@ +#! /usr/bin/env python +# +# post-receive-email - Post receive email hook for the Labs Git repository +# +# Copyright (C) 2008 Owen Taylor +# Copyright (C) 2009 Red Hat, Inc +# Copyright (C) 2009 Frederic Peters +# +# 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 the project list when change +# are pushed to the project 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 import Header + +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 + +# Human readable name for user, might be None +user_fullname = None + +# Who gets the emails +recipients = None + +# 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 + self.cc = set() + + 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 NotImplemenetedError() + + # Write the body of the main email to the given file object + def generate_body(self, out): + raise NotImplemenetedError() + + def generate_header(self, out, subject, include_revs=True, oldrev=None, newrev=None, cc=None): + user = os.environ['USER'] + if user_fullname: + from_address = "%s <%s@entrouvert.com>" % (user_fullname, user) + else: + from_address = "%s@entrouvert.com" % (user) + + if cc is None: + cc = self.cc + + print >>out, s(""" +To: %(recipients)s +Cc: %(cc)s +Content-Type: text/plain; charset=utf-8 +From: %(from_address)s +Subject: %(subject)s +Keywords: %(projectshort)s +X-Git-Refname: %(refname)s +""") % { + 'recipients': recipients, + 'cc': ','.join(cc), + 'from_address': from_address, + 'subject': subject, + 'projectshort': projectshort, + 'refname': 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': oldrev, + 'newrev': 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: + 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, " " + commit_oneline(commit) + 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, cc=[]) + 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() + git.show(commit.id, p=True, M=True, diff_filter="ACMRTUXB", pretty="format:---", _outfile=email_out) + end_email() + +class BranchCreation(BranchChange): + def __init__(self, *args): + BranchChange.__init__(self, *args) + + 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': 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': self.short_refname, + 'commit_oneline': 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': 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': 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 " + 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': self.tagger, + 'date': self.date, + 'message': 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': last_tag + } + else: + revision_range = self.newrev + print >>out, s(""" +Changes: + +""") + out.write(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': self.get_tag_type(), + 'short_refname': 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': self.get_tag_type(), + 'short_refname': self.short_refname, + 'old_commit_oneline': 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': self.short_refname, + 'old_commit_oneline': 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': self.short_refname, + 'commit_oneline': 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': self.short_refname, + 'commit_oneline': 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': self.short_refname, + 'commit_oneline': commit_oneline(self.newrev), + 'old_commit_oneline': 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': 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': self.refname, + 'newrev': self.newrev, + 'message': 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': self.refname, + 'oldrev': self.oldrev, + 'message': 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': self.refname, + 'oldrev': self.oldrev, + 'newrev': self.newrev, + 'message': 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 user_fullname + global recipients + + # 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() + + try: + recipients=git.config("hooks.mailinglist", _quiet=True) + except CalledProcessError: + pass + + if not recipients: + sys.exit(0) + + # 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 for the GECOS. + # Comma-separated fields are also possible + m = re.match("([^,<]+)", gecos) + if m: + fullname = m.group(1).strip() + if fullname != "": + user_fullname = fullname + + 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/post-receive-notify-updates b/post-receive-notify-updates new file mode 100755 index 0000000..0769668 --- /dev/null +++ b/post-receive-notify-updates @@ -0,0 +1,117 @@ +#!/usr/bin/python +# +# post-receive-notify-updates +# +# Copyright (C) 2008 Owen Taylor +# Copyright (C) 2009 Red Hat, Inc +# Copyright (C) 2009 Frederic Peters +# +# 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 send out notification mails on branch updates; these +# notification mails are typically used to trigger automated rebuilds +# + +import re +import os +import pwd +import sys + +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 + +# These addresses always get notified +ALL_MODULE_RECIPIENTS = [] + +BRANCH_RE = re.compile(r'^refs/heads/(.*)$') +def get_branch_name(refname): + m = BRANCH_RE.match(refname) + if m: + return m.group(1) + +SPLIT_RE = re.compile("\s*,\s*") + +def main(): + module_name = get_module_name() + + try: + recipients_s = git.config("hooks.update-recipients", _quiet=True) + except CalledProcessError: + recipients_s = "" + + recipients_s = recipients_s.strip() + if recipients_s == "": + recipients = [] + else: + recipients = SPLIT_RE.split(recipients_s) + + for recipient in ALL_MODULE_RECIPIENTS: + if not recipient in recipients: + recipients.append(recipient) + + 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: post-receive-notify-updates OLDREV NEWREV REFNAME") + branch_name = get_branch_name(sys.argv[3]) + if branch_name is not None: + changes.append((branch_name, 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") + branch_name = get_branch_name(items[2]) + if branch_name is not None: + changes.append((branch_name, items[0], items[1], items[2])) + + if len(changes) == 0: + # Nothing to mail about + return + + branches = [branch_name for branch_name, _, _, _ in changes] + subject = module_name + " " + " ".join(branches) + + if recipients: + out = start_email() + + print >>out, s(""" +To: %(recipients)s +From: noreply@entrouvert.org +Subject: %(subject)s +""") % { + 'recipients': ", ".join(recipients), + 'subject': subject + } + + # Trailing newline to signal the end of the header + print >>out + + for _, oldrev, newrev, refname in changes: + print >>out, oldrev, newrev, refname + + end_email() + +if __name__ == '__main__': + main() diff --git a/pre-receive-check-maintainers b/pre-receive-check-maintainers new file mode 100755 index 0000000..441e071 --- /dev/null +++ b/pre-receive-check-maintainers @@ -0,0 +1,72 @@ +#!/bin/bash + +# This checks to see if the module has maintainer information. Maintainer +# information can be provided one of two ways: +# +# - A .doap file +# - A MAINTAINERS file (deprecated) + +BINDIR=/home/admin/gitadmin-bin + +GIT_DIR=$(git rev-parse --git-dir 2>/dev/null) + +# Use the directory name with .git stripped as a short identifier +absdir=$(cd $GIT_DIR && pwd) +projectshort=$(basename ${absdir%.git}) + +check_maintainers() { + oldrev=$1 + newrev=$2 + refname=$3 + + branchname=${refname#refs/heads/} + if [ "$branchname" = "$refname" ] ; then + # not a branch update + return 0 + fi + + if [ "$branchname" != "master" ] ; then + # maintainer info only required on the master branch + return 0 + fi + + if expr $oldrev : "^0\+$" > /dev/null 2>&1; then + # Don't require maintainer info for initial imports; keeps things simple + return 0 + fi + + if expr $newrev : "^0\+$" > /dev/null 2>&1; then + # Branch deletion; (shouldn't really happen for the master branch) + return 0 + fi + + if ! git diff-tree --name-only -r $oldrev $newrev | grep -q -v '\(LINGUAS\|ChangeLog\|.po\)$' ; then + # Looks like something a translator would do, exempt it from the check + return 0 + fi + + if git cat-file -e $newrev:$projectshort.doap 2>/dev/null ; then + # There's a DOAP file. For performance reasons and to allow having fairly + # strict validation without being annoying, we only validate the DOAP file + # if it changed + if git diff-tree --name-only -r $oldrev $newrev | grep -q $projectshort.doap ; then + if ! git cat-file blob $newrev:$projectshort.doap | $BINDIR/validate-doap $projectshort ; then + return 1 + fi + fi + else + # See if there is a old-style MAINTAINERS file + if ! git cat-file blob $newrev:MAINTAINERS 2>/dev/null | /bin/grep -q "^Userid:" ; then + echo "A valid $projectshort.doap file is required. See http://live.gnome.org/MaintainersCorner#maintainers" >&2 + return 1 + fi + fi +} + +if [ $# = 3 ] ; then + check_maintainers $@ || exit 1 +else + while read oldrev newrev refname; do + check_maintainers $oldrev $newrev $refname || exit 1 + done +fi diff --git a/pre-receive-check-po b/pre-receive-check-po new file mode 100755 index 0000000..71db5d8 --- /dev/null +++ b/pre-receive-check-po @@ -0,0 +1,141 @@ +#!/bin/bash + +check_po() { + rev=$1 + path=$2 + mode=$3 + + case "$path" in + *.po) + ;; + *) + return + ;; + esac + + basename=`basename $path` + + # Perform extensive tests. This could be disabled if GNOME .po + # files used newer features than those available on git.gnome.org. + dash_c="-c" + + # Parse the file and check for errors + result=`git cat-file blob "$rev:$path" | msgfmt $dash_c -o /dev/null - 2>&1` + if [ $? -gt 0 ]; then + cat <&2 +--- +The following translation (.po) file appears to be invalid.$branch_message + +$path + +The results of the validation follow. Please correct the errors on the line numbers mentioned and try to push again. + +$result + +To check this locally before attempting to push again, you can use the following command: + +msgfmt $dash_c $basename + +After making fixes, modify your commit to include them, by doing: + +git add $basename +git commit --amend + +If you have any further problems or questions, please contact the GNOME Translation Project mailing list . Thank you. +--- +EOF + exit 1 + fi + + # Check for the absence of an executable flag + if expr "$mode" : ".*\([1357]..\|[1357].\|[1357]\)$" > /dev/null 2>& 1; then + cat <&2 +--- +The following translation file appears to have its executable flag set.$branch_message + +$path + +Translation files should not be executable. Please remove the flag and try to push again. The following commands may help: + +chmod a-x $basename +git add $basename +git commit --amend + +If you have any further problems or questions, please contact the GNOME Translation Project mailing list . Thank you. +--- +EOF + exit 1 + fi +} + +check_pos() { + oldrev=$1 + newrev=$2 + refname=$3 + + branchname=${refname#refs/heads/} + if [ "$branchname" = "$refname" ] ; then + # not a branch update + return + fi + + branch_message= + if [ "x$branchname" != "master" ] ; then + branch_message=" (When updating branch '$branchname'.)" + fi + + if expr $newrev : "^0\+$" > /dev/null 2>&1; then + # Branch deletion, nothing to check + return 0 + fi + + if expr $oldrev : "^0\+$" > /dev/null 2>&1; then + # Branch creation + git ls-tree -r $newrev | ( + while read mode objtype sha path ; do + if [ $objtype = blob ] ; then + check_po $newrev $path $mode + fi + done + ) + else + # Branch update + git diff-tree -r $oldrev $newrev | ( + while read srcmode destmode srcsha destsha status srcpath destpath ; do + if [ $status = 'D' ] ; then + continue # deleted + fi + + # destpath only present for copies/renames + if [ x"$destpath" = x ] ; then + destpath=$srcpath + fi + + # Strip colon from the source mode + srcmode=${srcmode#:} + + check_po $newrev $destpath $destmode + done + ) + fi + +} + +# Use a newer version of the gettext tools than the ones shipped with RHEL 5 +PATH=/usr/libexec/gettext17:$PATH + +GIT_DIR=$(git rev-parse --git-dir 2>/dev/null) + +# Don't check .po's during import; we don't want to enforce correct +# .po files for some ancient historical branch +if [ -e $GIT_DIR/pending ] ; then + exit 0 +fi + +if [ $# = 3 ] ; then + check_pos $@ || exit 1 +else + while read oldrev newrev refname; do + check_pos $oldrev $newrev $refname || exit 1 + done +fi diff --git a/pre-receive-check-policy b/pre-receive-check-policy new file mode 100755 index 0000000..6771741 --- /dev/null +++ b/pre-receive-check-policy @@ -0,0 +1,259 @@ +#!/bin/bash + +# This script checks gnome.org policy about how people are supposed to +# use git; the intent of the policy is to keep people from shooting +# themselve in the foot. +# +# Eventually, we'd like to have an ability to override policy; one way +# it could work is that if you did 'git push --exec=force' and you +# were a member of the right group, then run-git-or-special-cmd +# would set an environment variable that this script would interpret. + +# Used in some of the messages +server=git.labs.libre-entreprise.org + +GIT_DIR=$(git rev-parse --git-dir 2>/dev/null) + +in_import() { + test -e "$GIT_DIR/pending" +} + +forced() { + test -n "$GNOME_GIT_FORCE" +} + +check_commit() { + commit=$1 + + email="$(git log $commit -1 --pretty=format:%ae)" + case "$email" in + *localhost.localdomain|*\(none\)) + if ! in_import && ! forced ; then + cat <&2 +--- +The commits you are trying to push contain the author email +address '$email'. Please configure your +username and email address. See: + + http://live.gnome.org/Git/Help/AuthorEmail + +For instructions about how to do this and how to fix your +existing commits. +--- +EOF + exit 1 + fi + ;; + esac + + subject="$(git log $commit -1 --pretty=format:%s)" + if expr "$subject" : ".*Merge branch.*of.*\(git\|ssh\):" > /dev/null 2>&1; then + if ! in_import && ! forced ; then + cat <&2 +--- +The commit: + +EOF + git log $commit -1 >&2 + cat <&2 + +Looks like it was produced by typing 'git pull' without the --rebase +option when you had local changes. Running 'git pull --rebase' now +will fix the problem. Then please try, 'git push' again. Please see: + + http://live.gnome.org/Git/Help/ExtraMergeCommits +--- +EOF + exit 1 + fi + fi +} + +check_ref_update() { + oldrev=$1 + newrev=$2 + refname=$3 + + change_type=update + if expr $oldrev : "^0\+$" > /dev/null 2>&1; then + change_type=create + fi + + if expr $newrev : "^0\+$" > /dev/null 2>&1; then + if [ x$change_type = xcreate ] ; then + # Deleting an invalid ref, allow + return 0 + fi + change_type=delete + fi + + case $refname in + refs/heads/*) + # Branch update + branchname=${refname#refs/heads/} + + range= + case $change_type in + create) + range="$newrev" + ;; + delete) + # We really don't like to allow deleting any branch, but + # people need to do it to clean up accidentally pushed + # branches. Deleting master, however, has no purpose other + # than getting around the no-fast-forward restrictions + if [ "x$branchname" = xmaster ] ; then + cat <&2 +--- +You are trying to delete the branch 'master'. +--- +EOF + exit 1 + fi + ;; + update) + range="$oldrev..$newrev" + if [ `git merge-base $oldrev $newrev` != $oldrev ] && ! forced ; then + # Non-fast-forward update. Right now we have + # receive.denyNonFastforwards in the git configs for + # our repositories anyways, but catching it here would + # allow overriding without having to change the config + # temporarily. + cat <&2 +--- +You are trying to update the branch '$branchname' in a way that is not +a fast-forward update. Please see: + + http://live.gnome.org/Git/Help/NonFastForward +--- +EOF + exit 1 + fi + ;; + esac + + # For new commits introduced with this branch update, we want to run some + # checks to catch common mistakes. + # + # Expression here is same as in post-receive-notify-cia; we take + # all the branches in the repo, as "^/ref/heads/branchname", other than the + # branch we are actualy committing to, and exclude commits already on those + # branches from the list of commits between $oldrev and $newrev. + + if [ -n "$range" ] ; then + for merged in $(git rev-parse --symbolic-full-name --not --branches | \ + egrep -v "^\^$refname$" | \ + git rev-list --reverse --stdin "$range"); do + check_commit $merged + done + fi + ;; + refs/tags/*) + # Tag update + tagname=${refname#refs/tags/} + + case $change_type in + create) + object_type=`git cat-file -t $newrev` + case $object_type in + commit) + # Lightweight tag; we allow an import containing these + # tags, but forbid them in general + if ! in_import && ! forced ; then + cat <&2 +--- +You are trying to push the lightweight tag '$tagname'. You should use +a signed tag instead. See: + + http://live.gnome.org/Git/Help/LightweightTags +--- +EOF + exit 1 + fi + ;; + tag) + # Annotated tag + ;; + *) + # git is happy to allow tagging random objects, we aren't + cat <&2 +--- +You are trying to push the tag '$tagname', which points to an object +of type $object_type. (It should point to a commit or tag object.) +--- +EOF + exit 1 + ;; + esac + ;; + delete) + # Deleting a tag is probably someone trying to work-around + # not being able to update a tag. Disallowing lightweight + # tags will cut down on accidentally pushing tags called 'list' + # or whatever. During import we allow the user to clean up + # accidentally pushed tags. + if ! in_import && ! forced ; then + cat <&2 +--- +You are trying to delete the tag '$tagname'. + + http://live.gnome.org/Git/Help/TagUpdates +--- +EOF + exit 1 + fi + ;; + update) + if ! forced ; then + cat <&2 +--- +You are trying to replace the tag '$tagname' with a new tag. Please see: + + http://live.gnome.org/Git/Help/TagUpdates +--- +EOF + exit 1 + fi + ;; + esac + ;; + refs/remotes/*) + # Remote tracking branch + cat <&2 +--- +You are trying to push the remote tracking branch: + + $refname + +to $server. +--- +EOF + exit 1 + ;; + *) + # Something else + cat <&2 +--- +You are trying to push the ref: + + $refname + +to $server. This isn't a branch or tag. +--- +EOF + exit 1 + ;; + esac + + return 0 +} + +if [ $# = 3 ] ; then + check_ref_update $@ +else + while read oldrev newrev refname; do + check_ref_update $oldrev $newrev $refname + done +fi + +exit 0 diff --git a/util.py b/util.py new file mode 100644 index 0000000..988279d --- /dev/null +++ b/util.py @@ -0,0 +1,166 @@ +# 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 +import smtplib +from subprocess import Popen +import tempfile +import time +import email + +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="---@@@--- labs-git-email ---@@@---\n" + +def tomail(x): + if '<' in x: + x = x[x.index('<')+1:-1] + return x + +# 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="labs-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 + server = smtplib.SMTP('localhost') + for i, filename in enumerate(email_files): + if i != 0: + time.sleep(EMAIL_DELAY) + + f = open(filename) + msgstr = f.read() + message = email.message_from_string(msgstr) + fromaddr = tomail(message['From']) + toaddrs = [tomail(x.strip()) for x in message['To'].split(',')] + server.sendmail(fromaddr, toaddrs, msgstr) + + os.remove(filename) + f.close() + server.quit() + +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) + + if False: + 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: + raise + 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()