What is Hooks?
A hook script is a program triggered by some repository event, such as the creation of a new revision or the modification of an unversioned property. Each hook is handed enough information to tell what that event is, what target(s) it’s operating on, and the username of the person who triggered the event.
Server hooks are scripts that run automatically every time a particular event occurs in the StarTeam repository. Server hooks allow the user to trigger customizable actions at key points in the development life cycle.
The subversion version control system has a wonderfully handy feature called hooks. Hooks are essentially scripts that are triggered by a version control event (such as a commits, or revision property changes).
Subversion Hooks are located in your repository directory (so if you have multiple repositories you have to setup hooks for each one) in a directory called hooks, perhaps something like this: /home/svn/projectName/hooks.
What is SVN Hooks?
Subversion repositories provide a number of event hooks which are essentially opportunities for administrators to extend Subversion’s functionality at key moments of key operations. Repository hooks are implemented as programs executed by Subversion itself at those key moments—before and after a commit, before and after a user locks a file, and so on.
Subversion repositories provide a number of event hooks which are essentially opportunities for administrators to extend Subversion’s functionality at key moments of key operations.
How to setup SVN SVN Hooks
Types of SVN Hooks
- start-commit – Notification of the beginning of a commit.
- pre-commit – Notification just prior to commit completion.
- post-commit – Notification of a successful commit.
- pre-revprop-change – Notification of a revision property change attempt.
- post-revprop-change – Notification of a successful revision property change.
- pre-lock – Notification of a path lock attempt.
- post-lock – Notification of a successful path lock.
- pre-unlock – Notification of a path unlock attempt.
- post-unlock – Notification of a successful path unlock.
What is pre-commit hook in SVN?
A pre-commit hook is a feature available in the Subversion version control system that allows code to be validated before it is committed to the repository. The PHP_CodeSniffer pre-commit hook allows you to check code for coding standard errors and stop the commit process if errors are found.
Where are SVN hooks stored?.
Subversion Hooks are located in your repository directory (so if you have multiple repositories you have to setup hooks for each one) in a directory called hooks , perhaps something like this: /home/svn/projectName/hooks .
How To setup an SVN Pre-Commit Hook?
- Find the hooks directory for your repo.
- There should be a pre-commit. tmpl file there – rename it to pre-commit (no extension)
- Restart the SVN server.
How to write SVN Hooks Script?
You can write your hook using C or C++ if you like. Most people use Perl or Python.
The main thing is that svnlook should be used in your hook script and not svn. svnlook is faster and safer than svn. In fact, in pre-commit scripts, you have to use svnlook since you don’t have a repository revision.
Use Cases of SVN Hooks?
Reference
- https://svnbook.red-bean.com/en/1.8/svn.ref.reposhooks.html
- http://svn.apache.org/repos/asf/subversion/trunk/tools/hook-scripts/
- http://svn.apache.org/repos/asf/subversion/trunk/contrib/hook-scripts/
- http://svn.code.sf.net/p/tortoisesvn/code/trunk/contrib/hook-scripts/
Some Example SVN Hooks Code
#!/usr/bin/php | |
<?php | |
define('SVNLOOK', '/usr/bin/svnlook'); | |
// 1 = TXN, 2 = REPO | |
$command = SVNLOOK." changed -t {$_SERVER['argv'][1]} {$_SERVER['argv'][2]} | awk '{print \$2}' | grep -i \\.php\$"; | |
$handle = popen($command, 'r'); | |
if ($handle === false) { | |
echo 'ERROR: Could not execute "'.$command.'"'.PHP_EOL.PHP_EOL; | |
exit(2); | |
} | |
$contents = stream_get_contents($handle); | |
fclose($handle); | |
$filesWithBOM = array(); | |
// Ausgabe in einzelne Zeilen aufspalten, -1 == no limit | |
foreach (preg_split('/\v/', $contents, -1, PREG_SPLIT_NO_EMPTY) as $path) { | |
$command = SVNLOOK." cat -t {$_SERVER['argv'][1]} {$_SERVER['argv'][2]} ".$path." | grep -l \$'\\xEF\\xBB\\xBF'"; | |
system($command, $ret); | |
if ($ret != 0) $filesWithBOM[] = $path; | |
} | |
if (empty($filesWithBOM)) exit(0); | |
echo "The following files are saved with UTF-8 BOM encoding, which is not allowed:", PHP_EOL; | |
foreach ($filesWithBOM as $file) { | |
echo $file, PHP_EOL; | |
} | |
exit(1); |
# This is a sample configuration file for commit-access-control.pl. | |
# | |
# $Id$ | |
# | |
# This file uses the Windows ini style, where the file consists of a | |
# number of sections, each section starts with a unique section name | |
# in square brackets. Parameters in each section are specified as | |
# Name = Value. Any spaces around the equal sign will be ignored. If | |
# there are multiple sections with exactly the same section name, then | |
# the parameters in those sections will be added together to produce | |
# one section with cumulative parameters. | |
# | |
# The commit-access-control.pl script reads these sections in order, | |
# so later sections may overwrite permissions granted or removed in | |
# previous sections. | |
# | |
# Each section has three valid parameters. Any other parameters are | |
# ignored. | |
# access = (read-only|read-write) | |
# | |
# This parameter is a required parameter. Valid values are | |
# `read-only' and `read-write'. | |
# | |
# The access rights to apply to modified files and directories | |
# that match the `match' regular expression described later on. | |
# | |
# match = PERL_REGEX | |
# | |
# This parameter is a required parameter and its value is a Perl | |
# regular expression. | |
# | |
# To help users that automatically write regular expressions that | |
# match the beginning of absolute paths using ^/, the script | |
# removes the / character because subversion paths, while they | |
# start at the root level, do not begin with a /. | |
# | |
# users = username1 [username2 [username3 [username4 ...]]] | |
# or | |
# users = username1 [username2] | |
# users = username3 username4 | |
# | |
# This parameter is optional. The usernames listed here must be | |
# exact usernames. There is no regular expression matching for | |
# usernames. You may specify all the usernames that apply on one | |
# line or split the names up on multiple lines. | |
# | |
# The access rights from `access' are applied to ALL modified | |
# paths that match the `match' regular expression only if NO | |
# usernames are specified in the section or if one of the listed | |
# usernames matches the author of the commit. | |
# | |
# By default, because you're using commit-access-control.pl in the | |
# first place to protect your repository, the script sets the | |
# permissions to all files and directories in the repository to | |
# read-only, so if you want to open up portions of the repository, | |
# you'll need to edit this file. | |
# | |
# NOTE: NEVER GIVE DIFFERENT SECTIONS THE SAME SECTION NAME, OTHERWISE | |
# THE PARAMETERS FOR THOSE SECTIONS WILL BE MERGED TOGETHER INTO ONE | |
# SECTION AND YOUR SECURITY MAY BE COMPROMISED. | |
[Make everything read-only for all users] | |
match = .* | |
access = read-only | |
[Make project1 read-write for users Jane and Joe] | |
match = ^(branches|tags|trunk)/project1 | |
users = jane joe | |
access = read-write | |
[However, we don't trust Joe with project1's Makefile] | |
match = ^(branches|tags|trunk)/project1/Makefile | |
users = joe | |
access = read-only |
#!/usr/bin/env perl | |
# ==================================================================== | |
# commit-access-control.pl: check if the user that submitted the | |
# transaction TXN-NAME has the appropriate rights to perform the | |
# commit in repository REPOS using the permissions listed in the | |
# configuration file CONF_FILE. | |
# | |
# $HeadURL$ | |
# $LastChangedDate$ | |
# $LastChangedBy$ | |
# $LastChangedRevision$ | |
# | |
# Usage: commit-access-control.pl REPOS TXN-NAME CONF_FILE | |
# | |
# ==================================================================== | |
# Licensed to the Apache Software Foundation (ASF) under one | |
# or more contributor license agreements. See the NOTICE file | |
# distributed with this work for additional information | |
# regarding copyright ownership. The ASF licenses this file | |
# to you under the Apache License, Version 2.0 (the | |
# "License"); you may not use this file except in compliance | |
# with the License. You may obtain a copy of the License at | |
# | |
# http://www.apache.org/licenses/LICENSE-2.0 | |
# | |
# Unless required by applicable law or agreed to in writing, | |
# software distributed under the License is distributed on an | |
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
# KIND, either express or implied. See the License for the | |
# specific language governing permissions and limitations | |
# under the License. | |
# ==================================================================== | |
# Turn on warnings the best way depending on the Perl version. | |
BEGIN { | |
if ( $] >= 5.006_000) | |
{ require warnings; import warnings; } | |
else | |
{ $^W = 1; } | |
} | |
use strict; | |
use Carp; | |
use Config::IniFiles 2.27; | |
###################################################################### | |
# Configuration section. | |
# Svnlook path. | |
my $svnlook = "@SVN_BINDIR@/svnlook"; | |
# Since the path to svnlook depends upon the local installation | |
# preferences, check that the required program exists to insure that | |
# the administrator has set up the script properly. | |
{ | |
my $ok = 1; | |
foreach my $program ($svnlook) | |
{ | |
if (-e $program) | |
{ | |
unless (-x $program) | |
{ | |
warn "$0: required program `$program' is not executable, ", | |
"edit $0.\n"; | |
$ok = 0; | |
} | |
} | |
else | |
{ | |
warn "$0: required program `$program' does not exist, edit $0.\n"; | |
$ok = 0; | |
} | |
} | |
exit 1 unless $ok; | |
} | |
###################################################################### | |
# Initial setup/command-line handling. | |
&usage unless @ARGV == 3; | |
my $repos = shift; | |
my $txn = shift; | |
my $cfg_filename = shift; | |
unless (-e $repos) | |
{ | |
&usage("$0: repository directory `$repos' does not exist."); | |
} | |
unless (-d $repos) | |
{ | |
&usage("$0: repository directory `$repos' is not a directory."); | |
} | |
unless (-e $cfg_filename) | |
{ | |
&usage("$0: configuration file `$cfg_filename' does not exist."); | |
} | |
unless (-r $cfg_filename) | |
{ | |
&usage("$0: configuration file `$cfg_filename' is not readable."); | |
} | |
# Define two constant subroutines to stand for read-only or read-write | |
# access to the repository. | |
sub ACCESS_READ_ONLY () { 'read-only' } | |
sub ACCESS_READ_WRITE () { 'read-write' } | |
###################################################################### | |
# Load the configuration file and validate it. | |
my $cfg = Config::IniFiles->new(-file => $cfg_filename); | |
unless ($cfg) | |
{ | |
die "$0: error in loading configuration file `$cfg_filename'", | |
@Config::IniFiles::errors ? ":\n@Config::IniFiles::errors\n" | |
: ".\n"; | |
} | |
# Go through each section of the configuration file, validate that | |
# each section has the required parameters and complain about unknown | |
# parameters. Compile any regular expressions. | |
my @sections = $cfg->Sections; | |
{ | |
my $ok = 1; | |
foreach my $section (@sections) | |
{ | |
# First check for any unknown parameters. | |
foreach my $param ($cfg->Parameters($section)) | |
{ | |
next if $param eq 'match'; | |
next if $param eq 'users'; | |
next if $param eq 'access'; | |
warn "$0: config file `$cfg_filename' section `$section' parameter ", | |
"`$param' is being ignored.\n"; | |
$cfg->delval($section, $param); | |
} | |
my $access = $cfg->val($section, 'access'); | |
if (defined $access) | |
{ | |
unless ($access eq ACCESS_READ_ONLY or $access eq ACCESS_READ_WRITE) | |
{ | |
warn "$0: config file `$cfg_filename' section `$section' sets ", | |
"`access' to illegal value `$access'.\n"; | |
$ok = 0; | |
} | |
} | |
else | |
{ | |
warn "$0: config file `$cfg_filename' section `$section' does ", | |
"not set `access' parameter.\n"; | |
$ok = 0; | |
} | |
my $match_regex = $cfg->val($section, 'match'); | |
if (defined $match_regex) | |
{ | |
# To help users that automatically write regular expressions | |
# that match the beginning of absolute paths using ^/, | |
# remove the / character because subversion paths, while | |
# they start at the root level, do not begin with a /. | |
$match_regex =~ s#^\^/#^#; | |
my $match_re; | |
eval { $match_re = qr/$match_regex/ }; | |
if ($@) | |
{ | |
warn "$0: config file `$cfg_filename' section `$section' ", | |
"`match' regex `$match_regex' does not compile:\n$@\n"; | |
$ok = 0; | |
} | |
else | |
{ | |
$cfg->newval($section, 'match_re', $match_re); | |
} | |
} | |
else | |
{ | |
warn "$0: config file `$cfg_filename' section `$section' does ", | |
"not set `match' parameter.\n"; | |
$ok = 0; | |
} | |
} | |
exit 1 unless $ok; | |
} | |
###################################################################### | |
# Harvest data using svnlook. | |
# Change into /tmp so that svnlook diff can create its .svnlook | |
# directory. | |
my $tmp_dir = '/tmp'; | |
chdir($tmp_dir) | |
or die "$0: cannot chdir `$tmp_dir': $!\n"; | |
# Get the author from svnlook. | |
my @svnlooklines = &read_from_process($svnlook, 'author', $repos, '-t', $txn); | |
my $author = shift @svnlooklines; | |
unless (length $author) | |
{ | |
die "$0: txn `$txn' has no author.\n"; | |
} | |
# Figure out what directories have changed using svnlook.. | |
my @dirs_changed = &read_from_process($svnlook, 'dirs-changed', $repos, | |
'-t', $txn); | |
# Lose the trailing slash in the directory names if one exists, except | |
# in the case of '/'. | |
my $rootchanged = 0; | |
for (my $i=0; $i<@dirs_changed; ++$i) | |
{ | |
if ($dirs_changed[$i] eq '/') | |
{ | |
$rootchanged = 1; | |
} | |
else | |
{ | |
$dirs_changed[$i] =~ s#^(.+)[/\\]$#$1#; | |
} | |
} | |
# Figure out what files have changed using svnlook. | |
my @files_changed; | |
foreach my $line (&read_from_process($svnlook, 'changed', $repos, '-t', $txn)) | |
{ | |
# Split the line up into the modification code and path, ignoring | |
# property modifications. | |
if ($line =~ /^.. (.*)$/) | |
{ | |
push(@files_changed, $1); | |
} | |
} | |
# Create the list of all modified paths. | |
my @changed = (@dirs_changed, @files_changed); | |
# There should always be at least one changed path. If there are | |
# none, then there maybe something fishy going on, so just exit now | |
# indicating that the commit should not proceed. | |
unless (@changed) | |
{ | |
die "$0: no changed paths found in txn `$txn'.\n"; | |
} | |
###################################################################### | |
# Populate the permissions table. | |
# Set a hash keeping track of the access rights to each path. Because | |
# this is an access control script, set the default permissions to | |
# read-only. | |
my %permissions; | |
foreach my $path (@changed) | |
{ | |
$permissions{$path} = ACCESS_READ_ONLY; | |
} | |
foreach my $section (@sections) | |
{ | |
# Decide if this section should be used. It should be used if | |
# there are no users listed at all for this section, or if there | |
# are users listed and the author is one of them. | |
my $use_this_section; | |
# If there are any users listed, then check if the author of this | |
# commit is listed in the list. If not, then delete the section, | |
# because it won't apply. | |
# | |
# The configuration file can list users like this on multiple | |
# lines: | |
# users = joe@mysite.com betty@mysite.com | |
# users = bob@yoursite.com | |
# Because of the way Config::IniFiles works, check if there are | |
# any users at all with the scalar return from val() and if there, | |
# then get the array value to get all users. | |
my $users = $cfg->val($section, 'users'); | |
if (defined $users and length $users) | |
{ | |
my $match_user = 0; | |
foreach my $entry ($cfg->val($section, 'users')) | |
{ | |
unless ($match_user) | |
{ | |
foreach my $user (split(' ', $entry)) | |
{ | |
if ($author eq $user) | |
{ | |
$match_user = 1; | |
last; | |
} | |
} | |
} | |
} | |
$use_this_section = $match_user; | |
} | |
else | |
{ | |
$use_this_section = 1; | |
} | |
next unless $use_this_section; | |
# Go through each modified path and match it to the regular | |
# expression and set the access right if the regular expression | |
# matches. | |
my $access = $cfg->val($section, 'access'); | |
my $match_re = $cfg->val($section, 'match_re'); | |
foreach my $path (@changed) | |
{ | |
$permissions{$path} = $access if $path =~ $match_re; | |
} | |
} | |
# Go through all the modified paths and see if any permissions are | |
# read-only. If so, then fail the commit. | |
my @failed_paths; | |
foreach my $path (@changed) | |
{ | |
if ($permissions{$path} ne ACCESS_READ_WRITE) | |
{ | |
push(@failed_paths, $path); | |
} | |
} | |
if (@failed_paths) | |
{ | |
warn "$0: user `$author' does not have permission to commit to ", | |
@failed_paths > 1 ? "these paths:\n " : "this path:\n ", | |
join("\n ", @failed_paths), "\n"; | |
exit 1; | |
} | |
else | |
{ | |
exit 0; | |
} | |
sub usage | |
{ | |
warn "@_\n" if @_; | |
die "usage: $0 REPOS TXN-NAME CONF_FILE\n"; | |
} | |
sub safe_read_from_pipe | |
{ | |
unless (@_) | |
{ | |
croak "$0: safe_read_from_pipe passed no arguments.\n"; | |
} | |
print "Running @_\n"; | |
my $pid = open(SAFE_READ, '-|'); | |
unless (defined $pid) | |
{ | |
die "$0: cannot fork: $!\n"; | |
} | |
unless ($pid) | |
{ | |
open(STDERR, ">&STDOUT") | |
or die "$0: cannot dup STDOUT: $!\n"; | |
exec(@_) | |
or die "$0: cannot exec `@_': $!\n"; | |
} | |
my @output; | |
while (<SAFE_READ>) | |
{ | |
chomp; | |
push(@output, $_); | |
} | |
close(SAFE_READ); | |
my $result = $?; | |
my $exit = $result >> 8; | |
my $signal = $result & 127; | |
my $cd = $result & 128 ? "with core dump" : ""; | |
if ($signal or $cd) | |
{ | |
warn "$0: pipe from `@_' failed $cd: exit=$exit signal=$signal\n"; | |
} | |
if (wantarray) | |
{ | |
return ($result, @output); | |
} | |
else | |
{ | |
return $result; | |
} | |
} | |
sub read_from_process | |
{ | |
unless (@_) | |
{ | |
croak "$0: read_from_process passed no arguments.\n"; | |
} | |
my ($status, @output) = &safe_read_from_pipe(@_); | |
if ($status) | |
{ | |
if (@output) | |
{ | |
die "$0: `@_' failed with this output:\n", join("\n", @output), "\n"; | |
} | |
else | |
{ | |
die "$0: `@_' failed with no output.\n"; | |
} | |
} | |
else | |
{ | |
return @output; | |
} | |
} |
#!/usr/bin/env python | |
# | |
# | |
# Licensed to the Apache Software Foundation (ASF) under one | |
# or more contributor license agreements. See the NOTICE file | |
# distributed with this work for additional information | |
# regarding copyright ownership. The ASF licenses this file | |
# to you under the Apache License, Version 2.0 (the | |
# "License"); you may not use this file except in compliance | |
# with the License. You may obtain a copy of the License at | |
# | |
# http://www.apache.org/licenses/LICENSE-2.0 | |
# | |
# Unless required by applicable law or agreed to in writing, | |
# software distributed under the License is distributed on an | |
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
# KIND, either express or implied. See the License for the | |
# specific language governing permissions and limitations | |
# under the License. | |
# | |
# | |
'''control-chars.py: Subversion repository hook script that rejects filenames | |
which contain control characters. Expects to be called like a pre-commit hook: | |
control-chars.py <REPOS-PATH> <TXN-NAME> | |
Latest version should be available at | |
http://svn.apache.org/repos/asf/subversion/trunk/tools/hook-scripts/ | |
See validate-files.py for more generic validations.''' | |
import sys | |
import re | |
import posixpath | |
import svn | |
import svn.fs | |
import svn.repos | |
import svn.core | |
# Can't hurt to disallow chr(0), though the C API will never pass one anyway. | |
control_chars = set( [chr(i) for i in range(32)] ) | |
control_chars.add(chr(127)) | |
def check_node(node, path): | |
"check NODE for control characters. PATH is used for error messages" | |
if node.action == 'A': | |
if any((c in control_chars) for c in node.name): | |
sys.stderr.write("'%s' contains a control character" % path) | |
return 3 | |
def walk_tree(node, path, callback): | |
"Walk NODE" | |
if not node: | |
return 0 | |
ret_val = callback(node, path) | |
if ret_val > 0: | |
return ret_val | |
node = node.child | |
if not node: | |
return 0 | |
while node: | |
full_path = posixpath.join(path, node.name) | |
ret_val = walk_tree(node, full_path, callback) | |
# If we ran into an error just return up the stack all the way | |
if ret_val > 0: | |
return ret_val | |
node = node.sibling | |
return 0 | |
def usage(): | |
sys.stderr.write("Invalid arguments, expects to be called like a pre-commit hook.") | |
def main(ignored_pool, argv): | |
if len(argv) < 3: | |
usage() | |
return 2 | |
repos_path = svn.core.svn_path_canonicalize(argv[1]) | |
txn_name = argv[2] | |
if not repos_path or not txn_name: | |
usage() | |
return 2 | |
repos = svn.repos.svn_repos_open(repos_path) | |
fs = svn.repos.svn_repos_fs(repos) | |
txn = svn.fs.svn_fs_open_txn(fs, txn_name) | |
txn_root = svn.fs.svn_fs_txn_root(txn) | |
base_rev = svn.fs.svn_fs_txn_base_revision(txn) | |
if base_rev is None or base_rev <= svn.core.SVN_INVALID_REVNUM: | |
sys.stderr.write("Transaction '%s' is not based on a revision" % txn_name) | |
return 2 | |
base_root = svn.fs.svn_fs_revision_root(fs, base_rev) | |
editor, editor_baton = svn.repos.svn_repos_node_editor(repos, base_root, | |
txn_root) | |
try: | |
svn.repos.svn_repos_replay2(txn_root, "", svn.core.SVN_INVALID_REVNUM, | |
False, editor, editor_baton, None, None) | |
except svn.core.SubversionException as e: | |
# If we get a file not found error then some file has a newline in it and | |
# fsfs's own transaction is now corrupted. | |
if e.apr_err == svn.core.SVN_ERR_FS_NOT_FOUND: | |
match = re.search("path '(.*?)'", e.message) | |
if not match: | |
sys.stderr.write(repr(e)) | |
return 2 | |
path = match.group(1) | |
sys.stderr.write("Path name that contains '%s' has a newline." % path) | |
return 3 | |
# fs corrupt error probably means that there is probably both | |
# file and file\n in the transaction. However, we can't really determine | |
# which files since the transaction is broken. Even if we didn't reject | |
# this it would not be able to be committed. This just gives a better | |
# error message. | |
elif e.apr_err == svn.core.SVN_ERR_FS_CORRUPT: | |
sys.stderr.write("Some path contains a newline causing: %s" % repr(e)) | |
return 3 | |
else: | |
sys.stderr.write(repr(e)) | |
return 2 | |
tree = svn.repos.svn_repos_node_from_baton(editor_baton) | |
return walk_tree(tree, "/", check_node) | |
if __name__ == '__main__': | |
sys.exit(svn.core.run_app(main, sys.argv)) |
#!/usr/bin/env python2 | |
# Licensed under the same terms as Subversion: the Apache License, Version 2.0 | |
# | |
# pre-commit hook script for Subversion CVE-2017-9800 | |
# | |
# This prevents commits that set svn:externals containing suspicions | |
# svn+ssh:// URLs. | |
# | |
# With this script installed a commit like the one below should fail: | |
# | |
# svnmucc -mm propset svn:externals 'svn+ssh://-localhost/X X' REPOSITORY-URL | |
import sys, locale, urllib, urlparse, curses.ascii | |
from svn import wc, repos, fs | |
# A simple whitelist to ensure these are not suspicious: | |
# user@server | |
# [::1]:22 | |
# server-name | |
# server_name | |
# 127.0.0.1 | |
# with an extra restriction that a leading '-' is suspicious. | |
def suspicious_host(host): | |
if host[0] == '-': | |
return True | |
for char in host: | |
if not curses.ascii.isalnum(char) and not char in ':.-_[]@': | |
return True | |
return False | |
native = locale.getlocale()[1] | |
if not native: native = 'ascii' | |
repos_handle = repos.open(sys.argv[1].decode(native).encode('utf-8')) | |
fs_handle = repos.fs(repos_handle) | |
txn_handle = fs.open_txn(fs_handle, sys.argv[2].decode(native).encode('utf-8')) | |
txn_root = fs.txn_root(txn_handle) | |
rev_root = fs.revision_root(fs_handle, fs.txn_root_base_revision(txn_root)) | |
for path, change in fs.paths_changed2(txn_root).iteritems(): | |
if change.prop_mod: | |
# The new value, if any | |
txn_prop = fs.node_prop(txn_root, path, "svn:externals") | |
if not txn_prop: | |
continue | |
# The old value, if any | |
rev_prop = None | |
if change.change_kind == fs.path_change_modify: | |
rev_prop = fs.node_prop(rev_root, path, "svn:externals") | |
elif change.change_kind == fs.path_change_add and change.copyfrom_path: | |
copy_root = fs.revision_root(fs_handle, change.copyfrom_rev) | |
rev_prop = fs.node_prop(copy_root, change.copyfrom_path, | |
"svn:externals") | |
if txn_prop != rev_prop: | |
error_path = path.decode('utf-8').encode(native, 'replace') | |
externals = [] | |
try: | |
externals = wc.parse_externals_description2(path, txn_prop) | |
except: | |
sys.stderr.write("Commit blocked due to parse failure " | |
"on svn:externals for %s\n" % error_path) | |
sys.exit(1) | |
for external in externals: | |
parsed = urlparse.urlparse(urllib.unquote(external.url)) | |
if (parsed and parsed.scheme[:4] == "svn+" | |
and suspicious_host(parsed.netloc)): | |
sys.stderr.write("Commit blocked due to suspicious URL " | |
"containing %r in svn:externals " | |
"for %s\n" % (parsed.netloc, error_path)) | |
sys.exit(1) |
#!/usr/bin/env python | |
# | |
# | |
# Licensed to the Apache Software Foundation (ASF) under one | |
# or more contributor license agreements. See the NOTICE file | |
# distributed with this work for additional information | |
# regarding copyright ownership. The ASF licenses this file | |
# to you under the Apache License, Version 2.0 (the | |
# "License"); you may not use this file except in compliance | |
# with the License. You may obtain a copy of the License at | |
# | |
# http://www.apache.org/licenses/LICENSE-2.0 | |
# | |
# Unless required by applicable law or agreed to in writing, | |
# software distributed under the License is distributed on an | |
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
# KIND, either express or implied. See the License for the | |
# specific language governing permissions and limitations | |
# under the License. | |
# | |
# | |
# log-police.py: Ensure that log messages end with a single newline. | |
# See usage() function for details, or just run with no arguments. | |
import os | |
import sys | |
import getopt | |
try: | |
my_getopt = getopt.gnu_getopt | |
except AttributeError: | |
my_getopt = getopt.getopt | |
import svn | |
import svn.fs | |
import svn.repos | |
import svn.core | |
def fix_log_message(log_message): | |
"""Return a fixed version of LOG_MESSAGE. By default, this just | |
means ensuring that the result ends with exactly one newline and no | |
other whitespace. But if you want to do other kinds of fixups, this | |
function is the place to implement them -- all log message fixing in | |
this script happens here.""" | |
return log_message.rstrip() + "\n" | |
def fix_txn(fs, txn_name): | |
"Fix up the log message for txn TXN_NAME in FS. See fix_log_message()." | |
txn = svn.fs.svn_fs_open_txn(fs, txn_name) | |
log_message = svn.fs.svn_fs_txn_prop(txn, "svn:log") | |
if log_message is not None: | |
new_message = fix_log_message(log_message) | |
if new_message != log_message: | |
svn.fs.svn_fs_change_txn_prop(txn, "svn:log", new_message) | |
def fix_rev(fs, revnum): | |
"Fix up the log message for revision REVNUM in FS. See fix_log_message()." | |
log_message = svn.fs.svn_fs_revision_prop(fs, revnum, 'svn:log') | |
if log_message is not None: | |
new_message = fix_log_message(log_message) | |
if new_message != log_message: | |
svn.fs.svn_fs_change_rev_prop(fs, revnum, "svn:log", new_message) | |
def usage_and_exit(error_msg=None): | |
"""Write usage information and exit. If ERROR_MSG is provide, that | |
error message is printed first (to stderr), the usage info goes to | |
stderr, and the script exits with a non-zero status. Otherwise, | |
usage info goes to stdout and the script exits with a zero status.""" | |
import os.path | |
stream = error_msg and sys.stderr or sys.stdout | |
if error_msg: | |
stream.write("ERROR: %s\n\n" % error_msg) | |
stream.write("USAGE: %s [-t TXN_NAME | -r REV_NUM | --all-revs] REPOS\n" | |
% (os.path.basename(sys.argv[0]))) | |
stream.write(""" | |
Ensure that log messages end with exactly one newline and no other | |
whitespace characters. Use as a pre-commit hook by passing '-t TXN_NAME'; | |
fix up a single revision by passing '-r REV_NUM'; fix up all revisions by | |
passing '--all-revs'. (When used as a pre-commit hook, may modify the | |
svn:log property on the txn.) | |
""") | |
sys.exit(error_msg and 1 or 0) | |
def main(ignored_pool, argv): | |
repos_path = None | |
txn_name = None | |
rev_name = None | |
all_revs = False | |
try: | |
opts, args = my_getopt(argv[1:], 't:r:h?', ["help", "all-revs"]) | |
except: | |
usage_and_exit("problem processing arguments / options.") | |
for opt, value in opts: | |
if opt == '--help' or opt == '-h' or opt == '-?': | |
usage_and_exit() | |
elif opt == '-t': | |
txn_name = value | |
elif opt == '-r': | |
rev_name = value | |
elif opt == '--all-revs': | |
all_revs = True | |
else: | |
usage_and_exit("unknown option '%s'." % opt) | |
if txn_name is not None and rev_name is not None: | |
usage_and_exit("cannot pass both -t and -r.") | |
if txn_name is not None and all_revs: | |
usage_and_exit("cannot pass --all-revs with -t.") | |
if rev_name is not None and all_revs: | |
usage_and_exit("cannot pass --all-revs with -r.") | |
if rev_name is None and txn_name is None and not all_revs: | |
usage_and_exit("must provide exactly one of -r, -t, or --all-revs.") | |
if len(args) != 1: | |
usage_and_exit("only one argument allowed (the repository).") | |
repos_path = svn.core.svn_path_canonicalize(args[0]) | |
# A non-bindings version of this could be implemented by calling out | |
# to 'svnlook getlog' and 'svnadmin setlog'. However, using the | |
# bindings results in much simpler code. | |
fs = svn.repos.svn_repos_fs(svn.repos.svn_repos_open(repos_path)) | |
if txn_name is not None: | |
fix_txn(fs, txn_name) | |
elif rev_name is not None: | |
fix_rev(fs, int(rev_name)) | |
elif all_revs: | |
# Do it such that if we're running on a live repository, we'll | |
# catch up even with commits that came in after we started. | |
last_youngest = 0 | |
while True: | |
youngest = svn.fs.svn_fs_youngest_rev(fs) | |
if youngest >= last_youngest: | |
for this_rev in range(last_youngest, youngest + 1): | |
fix_rev(fs, this_rev) | |
last_youngest = youngest + 1 | |
else: | |
break | |
if __name__ == '__main__': | |
sys.exit(svn.core.run_app(main, sys.argv)) |
# | |
# mailer.conf: example configuration file for mailer.py | |
# | |
# $Id$ | |
[general] | |
# The [general].diff option is now DEPRECATED. | |
# Instead use [defaults].diff . | |
# | |
# One delivery method must be chosen. mailer.py will prefer using the | |
# "mail_command" option. If that option is empty or commented out, | |
# then it checks whether the "smtp_hostname" option has been | |
# specified. If neither option is set, then the commit message is | |
# delivered to stdout. | |
# | |
# This command will be invoked with destination addresses on the command | |
# line, and the message piped into it. | |
#mail_command = /usr/sbin/sendmail | |
# This option specifies the hostname for delivery via SMTP. | |
#smtp_hostname = localhost | |
# This option specifies the TCP port number to connect for SMTP. | |
# If it is not specified, 25 is used for SMTP and 465 is used for | |
# SMTP-Over-SSL by default. | |
#smtp_port = 25 | |
# Username and password for SMTP servers requiring authorisation. | |
#smtp_username = example | |
#smtp_password = example | |
# This option specifies whether to use SSL from the beginning of the SMTP | |
# connection. | |
#smtp_ssl = yes | |
# -------------------------------------------------------------------------- | |
# | |
# CONFIGURATION GROUPS | |
# | |
# Any sections other than [general], [defaults], [maps] and sections | |
# referred to within [maps] are considered to be user-defined groups | |
# which override values in the [defaults] section. | |
# These groups are selected using the following three options: | |
# | |
# for_repos | |
# for_paths | |
# search_logmsg | |
# | |
# Each option specifies a regular expression. for_repos is matched | |
# against the absolute path to the repository the mailer is operating | |
# against. for_paths is matched against *every* path (files and | |
# dirs) that was modified during the commit. | |
# | |
# The options specified in the [defaults] section are always selected. The | |
# presence of a non-matching for_repos has no relevance. Note that you may | |
# still use a for_repos value to extract useful information (more on this | |
# later). Any user-defined groups without a for_repos, or which contains | |
# a matching for_repos, will be selected for potential use. | |
# | |
# The subset of user-defined groups identified by the repository are further | |
# refined based on the for_paths option. A group is selected if at least | |
# one path(*) in the commit matches the for_paths regular expression. Note | |
# that the paths are relative to the root of the repository and do not | |
# have a leading slash. | |
# | |
# (*) Actually, each path will select just one group. Thus, it is possible | |
# that one group will match against all paths, while another group matches | |
# none of the paths, even though its for_paths would have selected some of | |
# the paths in the commit. | |
# | |
# search_logmsg specifies a regular expression to match against the | |
# log message. If the regular expression does not match the log | |
# message, the group is not matched; if the regular expression matches | |
# once, the group is used. If there are multiple matches, each | |
# successful match generates another group-match (this is useful if | |
# "named groups" are used). If search_logmsg is not used, no log | |
# message filtering is performed. | |
# | |
# Groups are matched in no particular order. Do not depend upon their | |
# order within this configuration file. The values from [defaults] will | |
# be used if no group is matched or an option in a group does not override | |
# the corresponding value from [defaults]. | |
# | |
# Generally, a commit email is generated for each group that has been | |
# selected. The script will try to minimize mails, so it may be possible | |
# that a single message will be generated to multiple recipients. In | |
# addition, it is possible for multiple messages per group to be generated, | |
# based on the various substitutions that are performed (see the following | |
# section). | |
# | |
# | |
# SUBSTITUTIONS | |
# | |
# The regular expressions can use the "named group" syntax to extract | |
# interesting pieces of the repository or commit path. These named values | |
# can then be substituted in the option values during mail generation. | |
# | |
# For example, let's say that you have a repository with a top-level | |
# directory named "clients", with several client projects underneath: | |
# | |
# REPOS/ | |
# clients/ | |
# gsvn/ | |
# rapidsvn/ | |
# winsvn/ | |
# | |
# The client name can be extracted with a regular expression like: | |
# | |
# for_paths = clients/(?P<client>[^/]*)($|/) | |
# | |
# The substitution is performed using Python's dict-based string | |
# interpolation syntax: | |
# | |
# to_addr = commits@%(client)s.tigris.org | |
# | |
# The %(NAME)s syntax will substitute whatever value for NAME was captured | |
# in the for_repos and for_paths regular expressions. The set of names | |
# available is obtained from the following set of regular expressions: | |
# | |
# [defaults].for_repos (if present) | |
# [GROUP].for_repos (if present in the user-defined group "GROUP") | |
# [GROUP].for_paths (if present in the user-defined group "GROUP") | |
# | |
# The names from the regexes later in the list override the earlier names. | |
# If none of the groups match, but a for_paths is present in [defaults], | |
# then its extracted names will be available. | |
# | |
# Further suppose you want to match bug-ids in log messages: | |
# | |
# search_logmsg = (?P<bugid>(ProjA|ProjB)#\d) | |
# | |
# The bugids would be of the form ProjA#123 and ProjB#456. In this | |
# case, each time the regular expression matches, another match group | |
# will be generated. Thus, if you use: | |
# | |
# commit_subject_prefix = %(bugid)s: | |
# | |
# Then, a log message such as "Fixes ProjA#123 and ProjB#234" would | |
# match both bug-ids, and two emails would be generated - one with | |
# subject "ProjA#123: <...>" and "ProjB#234: <...>". | |
# | |
# Note that each unique set of names for substitution will generate an | |
# email. In the above example, if a commit modified files in all three | |
# client subdirectories, then an email will be sent to all three commits@ | |
# mailing lists on tigris.org. | |
# | |
# The substitution variable "author" is provided by default, and is set | |
# to the author name passed to mailer.py for revprop changes or the | |
# author defined for a revision; if neither is available, then it is | |
# set to "no_author". Thus, you might define a line like: | |
# | |
# from_addr = %(author)s@example.com | |
# | |
# The substitution variable "repos_basename" is provided, and is set to | |
# the directory name of the repository. This can be useful to set | |
# a custom subject that can be re-used in multiple repositories: | |
# | |
# commit_subject_prefix = [svn-%(repos_basename)s] | |
# | |
# For example if the repository is at /path/to/repo/project-x then | |
# the subject of commit emails will be prefixed with [svn-project-x] | |
# | |
# | |
# SUMMARY | |
# | |
# While mailer.py will work to minimize the number of mail messages | |
# generated, a single commit can potentially generate a large number | |
# of variants of a commit message. The criteria for generating messages | |
# is based on: | |
# | |
# groups selected by for_repos | |
# groups selected by for_paths | |
# unique sets of parameters extracted by the above regular expressions | |
# | |
[defaults] | |
# This is not passed to the shell, so do not use shell metacharacters. | |
# The command is split around whitespace, so if you want to include | |
# whitespace in the command, then ### something ###. | |
diff = /usr/bin/diff -u -L %(label_from)s -L %(label_to)s %(from)s %(to)s | |
# The default prefix for the Subject: header for commits. | |
commit_subject_prefix = | |
# The default prefix for the Subject: header for propchanges. | |
propchange_subject_prefix = | |
# The default prefix for the Subject: header for locks. | |
lock_subject_prefix = | |
# The default prefix for the Subject: header for unlocks. | |
unlock_subject_prefix = | |
# The default From: address for messages. If the from_addr is not | |
# specified or it is specified but there is no text after the `=', | |
# then the revision's author is used as the from address. If the | |
# revision author is not specified, such as when a commit is done | |
# without requiring authentication and authorization, then the string | |
# 'no_author' is used. You can specify a default from_addr here and | |
# if you want to have a particular for_repos group use the author as | |
# the from address, you can use "from_addr =". | |
from_addr = invalid@example.com | |
# The default To: addresses for message. One or more addresses, | |
# separated by whitespace (no commas). | |
# NOTE: If you want to use a different character for separating the | |
# addresses put it in front of the addresses included in square | |
# brackets '[ ]'. | |
to_addr = invalid@example.com | |
# If this is set, then a Reply-To: will be inserted into the message. | |
reply_to = | |
# Specify which types of repository changes mailer.py will create | |
# diffs for. Valid options are any combination of | |
# 'add copy modify delete', or 'none' to never create diffs. | |
# If the generate_diffs option is empty, the selection is controlled | |
# by the deprecated options suppress_deletes and suppress_adds. | |
# Note that this only affects the display of diffs - all changes are | |
# mentioned in the summary of changed paths at the top of the message, | |
# regardless of this option's value. | |
# Meaning of the possible values: | |
# add: generates diffs for all added paths | |
# copy: generates diffs for all copied paths | |
# which were not changed after copying | |
# modify: generates diffs for all modified paths, including paths that were | |
# copied and modified afterwards (within the same commit) | |
# delete: generates diffs for all removed paths | |
generate_diffs = add copy modify | |
# Commit URL construction. This adds a URL to the top of the message | |
# that can lead the reader to a Trac, ViewVC or other view of the | |
# commit as a whole. | |
# | |
# The available substitution variable is: rev | |
#commit_url = http://diffs.server.com/trac/software/changeset/%(rev)s | |
# Diff URL construction. For the configured diff URL types, the diff | |
# section (which follows the message header) will include the URL | |
# relevant to the change type, even if actual diff generation for that | |
# change type is disabled (per the generate_diffs option). | |
# | |
# Available substitution variables are: path, base_path, rev, base_rev | |
#diff_add_url = | |
#diff_copy_url = | |
#diff_modify_url = http://diffs.server.com/?p1=%(base_path)s&p2=%(path)s | |
#diff_delete_url = | |
# When set to "yes", the mailer will suppress the creation of a diff which | |
# deletes all the lines in the file. If this is set to anything else, or | |
# is simply commented out, then the diff will be inserted. Note that the | |
# deletion is always mentioned in the message header, regardless of this | |
# option's value. | |
### DEPRECATED (if generate_diffs is not empty, this option is ignored) | |
#suppress_deletes = yes | |
# When set to "yes", the mailer will suppress the creation of a diff which | |
# adds all the lines in the file. If this is set to anything else, or | |
# is simply commented out, then the diff will be inserted. Note that the | |
# addition is always mentioned in the message header, regardless of this | |
# option's value. | |
### DEPRECATED (if generate_diffs is not empty, this option is ignored) | |
#suppress_adds = yes | |
# A revision is reported on if any of its changed paths match the | |
# for_paths option. If only some of the changed paths of a revision | |
# match, this variable controls the behaviour for the non-matching | |
# paths. Possible values are: | |
# | |
# yes: (Default) Show in both summary and diffs. | |
# summary: Show the changed paths in the summary, but omit the diffs. | |
# no: Show nothing more than a note saying "and changes in other areas" | |
# | |
show_nonmatching_paths = yes | |
# Subject line length limit. The generated subject line will be truncated | |
# and terminated with "...", to remain within the specified maximum length. | |
# Set to 0 to turn off. | |
#truncate_subject = 200 | |
# -------------------------------------------------------------------------- | |
[maps] | |
# | |
# This section can be used define rewrite mappings for option values. It | |
# is typically used for computing from/to addresses, but can actually be | |
# used to remap values for any option in this file. | |
# | |
# The mappings are global for the entire configuration file. There is | |
# no group-specific mapping capability. For each mapping that you want | |
# to perform, you will provide the name of the option (e.g. from_addr) | |
# and a specification of how to perform those mappings. These declarations | |
# are made here in the [maps] section. | |
# | |
# When an option is accessed, the value is loaded from the configuration | |
# file and all %(NAME)s substitutions are performed. The resulting value | |
# is then passed through the map. If a map entry is not available for | |
# the value, then it will be used unchanged. | |
# | |
# NOTES: - Avoid using map substitution names which differ only in case. | |
# Unexpected results may occur. | |
# - A colon ':' is also considered as separator between option and | |
# value (keep this in mind when trying to map a file path under | |
# windows). | |
# | |
# The format to declare a map is: | |
# | |
# option_name_to_remap = mapping_specification | |
# | |
# At the moment, there is only one type of mapping specification: | |
# | |
# mapping_specification = '[' sectionname ']' | |
# | |
# This will use the given section to map values. The option names in | |
# the section are the input values, and the option values are the result. | |
# | |
# | |
# EXAMPLE: | |
# | |
# We have two projects using two repositories. The name of the repos | |
# does not easily map to their commit mailing lists, so we will use | |
# a mapping to go from a project name (extracted from the repository | |
# path) to their commit list. The committers also need a special | |
# mapping to derive their email address from their repository username. | |
# | |
# [projects] | |
# for_repos = .*/(?P<project>.*) | |
# from_addr = %(author)s | |
# to_addr = %(project)s | |
# | |
# [maps] | |
# from_addr = [authors] | |
# to_addr = [mailing-lists] | |
# | |
# [authors] | |
# john = jconnor@example.com | |
# sarah = sconnor@example.com | |
# | |
# [mailing-lists] | |
# t600 = spottable-commits@example.com | |
# tx = hotness-commits@example.com | |
# | |
# -------------------------------------------------------------------------- | |
# | |
# [example-group] | |
# # send notifications if any web pages are changed | |
# for_paths = .*\.html | |
# # set a custom prefix | |
# commit_subject_prefix = [commit] | |
# propchange_subject_prefix = [propchange] | |
# # override the default, sending these elsewhere | |
# to_addr = www-commits@example.com | |
# # use the revision author as the from address | |
# from_addr = | |
# # use a custom diff program for this group | |
# diff = /usr/bin/my-diff -u -L %(label_from)s -L %(label_to)s %(from)s %(to)s | |
# | |
# [another-example] | |
# # commits to personal repositories should go to that person | |
# for_repos = /home/(?P<who>[^/]*)/repos | |
# to_addr = %(who)s@example.com | |
# | |
# [issuetracker] | |
# search_logmsg = (?P<bugid>(?P<project>projecta|projectb|projectc)#\d+) | |
# # (or, use a mapping if the bug-id to email address is not this trivial) | |
# to_addr = %(project)s-tracker@example.com | |
# commit_subject_prefix = %(bugid)s: | |
# propchange_subject_prefix = %(bugid)s: |
#!/usr/bin/env python | |
# -*- coding: utf-8 -*- | |
# | |
# | |
# Licensed to the Apache Software Foundation (ASF) under one | |
# or more contributor license agreements. See the NOTICE file | |
# distributed with this work for additional information | |
# regarding copyright ownership. The ASF licenses this file | |
# to you under the Apache License, Version 2.0 (the | |
# "License"); you may not use this file except in compliance | |
# with the License. You may obtain a copy of the License at | |
# | |
# http://www.apache.org/licenses/LICENSE-2.0 | |
# | |
# Unless required by applicable law or agreed to in writing, | |
# software distributed under the License is distributed on an | |
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
# KIND, either express or implied. See the License for the | |
# specific language governing permissions and limitations | |
# under the License. | |
# | |
# | |
# mailer.py: send email describing a commit | |
# | |
# $HeadURL$ | |
# $LastChangedDate$ | |
# $LastChangedBy$ | |
# $LastChangedRevision$ | |
# | |
# USAGE: mailer.py commit REPOS REVISION [CONFIG-FILE] | |
# mailer.py propchange REPOS REVISION AUTHOR REVPROPNAME [CONFIG-FILE] | |
# mailer.py propchange2 REPOS REVISION AUTHOR REVPROPNAME ACTION \ | |
# [CONFIG-FILE] | |
# mailer.py lock REPOS AUTHOR [CONFIG-FILE] | |
# mailer.py unlock REPOS AUTHOR [CONFIG-FILE] | |
# | |
# Using CONFIG-FILE, deliver an email describing the changes between | |
# REV and REV-1 for the repository REPOS. | |
# | |
# ACTION was added as a fifth argument to the post-revprop-change hook | |
# in Subversion 1.2.0. Its value is one of 'A', 'M' or 'D' to indicate | |
# if the property was added, modified or deleted, respectively. | |
# | |
# See _MIN_SVN_VERSION below for which version of Subversion's Python | |
# bindings are required by this version of mailer.py. | |
import os | |
import sys | |
if sys.hexversion >= 0x3000000: | |
PY3 = True | |
import configparser | |
from urllib.parse import quote as _url_quote | |
else: | |
PY3 = False | |
import ConfigParser as configparser | |
from urllib import quote as _url_quote | |
import time | |
import subprocess | |
from io import BytesIO | |
import smtplib | |
import re | |
import tempfile | |
import codecs | |
# Minimal version of Subversion's bindings required | |
_MIN_SVN_VERSION = [1, 5, 0] | |
# Import the Subversion Python bindings, making sure they meet our | |
# minimum version requirements. | |
import svn.fs | |
import svn.delta | |
import svn.repos | |
import svn.core | |
if _MIN_SVN_VERSION > [svn.core.SVN_VER_MAJOR, | |
svn.core.SVN_VER_MINOR, | |
svn.core.SVN_VER_PATCH]: | |
sys.stderr.write( | |
"You need version %s or better of the Subversion Python bindings.\n" \ | |
% ".".join([str(x) for x in _MIN_SVN_VERSION])) | |
sys.exit(1) | |
# Absorb difference between Python 2 and Python >= 3 | |
if PY3: | |
def to_bytes(x): | |
return x.encode('utf-8') | |
def to_str(x): | |
return x.decode('utf-8') | |
# We never use sys.stdin nor sys.stdout TextIOwrapper. | |
_stdin = sys.stdin.buffer | |
_stdout = sys.stdout.buffer | |
else: | |
# Python 2 | |
def to_bytes(x): | |
return x | |
def to_str(x): | |
return x | |
_stdin = sys.stdin | |
_stdout = sys.stdout | |
SEPARATOR = '=' * 78 | |
def main(pool, cmd, config_fname, repos_dir, cmd_args): | |
### TODO: Sanity check the incoming args | |
if cmd == 'commit': | |
revision = int(cmd_args[0]) | |
repos = Repository(repos_dir, revision, pool) | |
cfg = Config(config_fname, repos, | |
{'author': repos.author, | |
'repos_basename': os.path.basename(repos.repos_dir) | |
}) | |
messenger = Commit(pool, cfg, repos) | |
elif cmd == 'propchange' or cmd == 'propchange2': | |
revision = int(cmd_args[0]) | |
author = cmd_args[1] | |
propname = cmd_args[2] | |
if cmd == 'propchange2' and cmd_args[3]: | |
action = cmd_args[3] | |
else: | |
action = 'A' | |
repos = Repository(repos_dir, revision, pool) | |
# Override the repos revision author with the author of the propchange | |
repos.author = author | |
cfg = Config(config_fname, repos, | |
{'author': author, | |
'repos_basename': os.path.basename(repos.repos_dir) | |
}) | |
messenger = PropChange(pool, cfg, repos, author, propname, action) | |
elif cmd == 'lock' or cmd == 'unlock': | |
author = cmd_args[0] | |
repos = Repository(repos_dir, 0, pool) ### any old revision will do | |
# Override the repos revision author with the author of the lock/unlock | |
repos.author = author | |
cfg = Config(config_fname, repos, | |
{'author': author, | |
'repos_basename': os.path.basename(repos.repos_dir) | |
}) | |
messenger = Lock(pool, cfg, repos, author, cmd == 'lock') | |
else: | |
raise UnknownSubcommand(cmd) | |
return messenger.generate() | |
def remove_leading_slashes(path): | |
while path and path[0:1] == b'/': | |
path = path[1:] | |
return path | |
class OutputBase: | |
"Abstract base class to formalize the interface of output methods" | |
def __init__(self, cfg, repos, prefix_param): | |
self.cfg = cfg | |
self.repos = repos | |
self.prefix_param = prefix_param | |
self._CHUNKSIZE = 128 * 1024 | |
# This is a public member variable. This must be assigned a suitable | |
# piece of descriptive text before make_subject() is called. | |
self.subject = "" | |
def make_subject(self, group, params): | |
prefix = self.cfg.get(self.prefix_param, group, params) | |
if prefix: | |
subject = prefix + ' ' + self.subject | |
else: | |
subject = self.subject | |
try: | |
truncate_subject = int( | |
self.cfg.get('truncate_subject', group, params)) | |
except ValueError: | |
truncate_subject = 0 | |
# truncate subject as UTF-8 string. | |
# Note: there still exists an issue on combining characters. | |
if truncate_subject: | |
bsubject = to_bytes(subject) | |
if len(bsubject) > truncate_subject: | |
idx = truncate_subject - 2 | |
while b'\x80' <= bsubject[idx-1:idx] <= b'\xbf': | |
idx -= 1 | |
subject = to_str(bsubject[:idx-1]) + "..." | |
return subject | |
def start(self, group, params): | |
"""Override this method. | |
Begin writing an output representation. GROUP is the name of the | |
configuration file group which is causing this output to be produced. | |
PARAMS is a dictionary of any named subexpressions of regular expressions | |
defined in the configuration file, plus the key 'author' contains the | |
author of the action being reported.""" | |
raise NotImplementedError | |
def finish(self): | |
"""Override this method. | |
Flush any cached information and finish writing the output | |
representation.""" | |
raise NotImplementedError | |
def write_binary(self, output): | |
"""Override this method. | |
Append the binary data OUTPUT to the output representation.""" | |
raise NotImplementedError | |
def write(self, output): | |
"""Append the literal text string OUTPUT to the output representation.""" | |
return self.write_binary(to_bytes(output)) | |
def run(self, cmd): | |
"""Override this method, if the default implementation is not sufficient. | |
Execute CMD, writing the stdout produced to the output representation.""" | |
# By default we choose to incorporate child stderr into the output | |
pipe_ob = subprocess.Popen(cmd, stdout=subprocess.PIPE, | |
stderr=subprocess.STDOUT, | |
close_fds=sys.platform != "win32") | |
buf = pipe_ob.stdout.read(self._CHUNKSIZE) | |
while buf: | |
self.write_binary(buf) | |
buf = pipe_ob.stdout.read(self._CHUNKSIZE) | |
# wait on the child so we don't end up with a billion zombies | |
pipe_ob.wait() | |
class MailedOutput(OutputBase): | |
def __init__(self, cfg, repos, prefix_param): | |
OutputBase.__init__(self, cfg, repos, prefix_param) | |
def start(self, group, params): | |
# whitespace (or another character) separated list of addresses | |
# which must be split into a clean list | |
to_addr_in = self.cfg.get('to_addr', group, params) | |
# if list of addresses starts with '[.]' | |
# use the character between the square brackets as split char | |
# else use whitespaces | |
if len(to_addr_in) >= 3 and to_addr_in[0] == '[' \ | |
and to_addr_in[2] == ']': | |
self.to_addrs = \ | |
[_f for _f in to_addr_in[3:].split(to_addr_in[1]) if _f] | |
else: | |
self.to_addrs = [_f for _f in to_addr_in.split() if _f] | |
self.from_addr = self.cfg.get('from_addr', group, params) \ | |
or self.repos.author or 'no_author' | |
# if the from_addr (also) starts with '[.]' (may happen if one | |
# map is used for both to_addr and from_addr) remove '[.]' | |
if len(self.from_addr) >= 3 and self.from_addr[0] == '[' \ | |
and self.from_addr[2] == ']': | |
self.from_addr = self.from_addr[3:] | |
self.reply_to = self.cfg.get('reply_to', group, params) | |
# if the reply_to (also) starts with '[.]' (may happen if one | |
# map is used for both to_addr and reply_to) remove '[.]' | |
if len(self.reply_to) >= 3 and self.reply_to[0] == '[' \ | |
and self.reply_to[2] == ']': | |
self.reply_to = self.reply_to[3:] | |
def _rfc2047_encode(self, hdr): | |
# Return the result of splitting HDR into tokens (on space | |
# characters), encoding (per RFC2047) each token as necessary, and | |
# slapping 'em back to together again. | |
from email.header import Header | |
def _maybe_encode_header(hdr_token): | |
try: | |
hdr_token.encode('ascii') | |
return hdr_token | |
except UnicodeError: | |
return Header(hdr_token, 'utf-8').encode() | |
return ' '.join(map(_maybe_encode_header, hdr.split())) | |
def mail_headers(self, group, params): | |
from email import utils | |
subject = self._rfc2047_encode(self.make_subject(group, params)) | |
from_hdr = self._rfc2047_encode(self.from_addr) | |
to_hdr = self._rfc2047_encode(', '.join(self.to_addrs)) | |
hdrs = 'From: %s\n' \ | |
'To: %s\n' \ | |
'Subject: %s\n' \ | |
'Date: %s\n' \ | |
'Message-ID: %s\n' \ | |
'MIME-Version: 1.0\n' \ | |
'Content-Type: text/plain; charset=UTF-8\n' \ | |
'Content-Transfer-Encoding: 8bit\n' \ | |
'X-Svn-Commit-Project: %s\n' \ | |
'X-Svn-Commit-Author: %s\n' \ | |
'X-Svn-Commit-Revision: %d\n' \ | |
'X-Svn-Commit-Repository: %s\n' \ | |
% (from_hdr, to_hdr, subject, | |
utils.formatdate(), utils.make_msgid(), group, | |
self.repos.author or 'no_author', self.repos.rev, | |
os.path.basename(self.repos.repos_dir)) | |
if self.reply_to: | |
hdrs = '%sReply-To: %s\n' % (hdrs, self.reply_to) | |
return hdrs + '\n' | |
class SMTPOutput(MailedOutput): | |
"Deliver a mail message to an MTA using SMTP." | |
def start(self, group, params): | |
MailedOutput.start(self, group, params) | |
self.buffer = BytesIO() | |
self.write_binary = self.buffer.write | |
self.write(self.mail_headers(group, params)) | |
def finish(self): | |
""" | |
Send email via SMTP or SMTP_SSL, logging in if username is | |
specified. | |
Errors such as invalid recipient, which affect a particular email, | |
are reported to stderr and raise MessageSendFailure. If the caller | |
has other emails to send, it may continue doing so. | |
Errors caused by bad configuration, such as login failures, for | |
which too many occurrences could lead to SMTP server lockout, are | |
reported to stderr and re-raised. These should be considered fatal | |
(to minimize the chances of said lockout). | |
""" | |
if self.cfg.is_set('general.smtp_port'): | |
smtp_port = self.cfg.general.smtp_port | |
else: | |
smtp_port = 0 | |
try: | |
if self.cfg.is_set('general.smtp_ssl') and self.cfg.general.smtp_ssl == 'yes': | |
server = smtplib.SMTP_SSL(self.cfg.general.smtp_hostname, smtp_port) | |
else: | |
server = smtplib.SMTP(self.cfg.general.smtp_hostname, smtp_port) | |
except Exception as detail: | |
sys.stderr.write("mailer.py: Failed to instantiate SMTP object: %s\n" % (detail,)) | |
# Any error to instantiate is fatal | |
raise | |
try: | |
if self.cfg.is_set('general.smtp_username'): | |
try: | |
server.login(self.cfg.general.smtp_username, | |
self.cfg.general.smtp_password) | |
except smtplib.SMTPException as detail: | |
sys.stderr.write("mailer.py: SMTP login failed with username %s and/or password: %s\n" | |
% (self.cfg.general.smtp_username, detail,)) | |
# Any error at login is fatal | |
raise | |
server.sendmail(self.from_addr, self.to_addrs, self.buffer.getvalue()) | |
### TODO: 'raise .. from' is Python 3+. When we convert this | |
### script to Python 3, uncomment 'from detail' below | |
### (2 instances): | |
except smtplib.SMTPRecipientsRefused as detail: | |
sys.stderr.write("mailer.py: SMTP recipient(s) refused: %s: %s\n" | |
% (self.to_addrs, detail,)) | |
raise MessageSendFailure ### from detail | |
except smtplib.SMTPSenderRefused as detail: | |
sys.stderr.write("mailer.py: SMTP sender refused: %s: %s\n" | |
% (self.from_addr, detail,)) | |
raise MessageSendFailure ### from detail | |
except smtplib.SMTPException as detail: | |
# All other errors are fatal; this includes: | |
# SMTPHeloError, SMTPDataError, SMTPNotSupportedError | |
sys.stderr.write("mailer.py: SMTP error occurred: %s\n" % (detail,)) | |
raise | |
finally: | |
try: | |
server.quit() | |
except smtplib.SMTPException as detail: | |
sys.stderr.write("mailer.py: Error occurred during SMTP session cleanup: %s\n" | |
% (detail,)) | |
class StandardOutput(OutputBase): | |
"Print the commit message to stdout." | |
def __init__(self, cfg, repos, prefix_param): | |
OutputBase.__init__(self, cfg, repos, prefix_param) | |
self.write_binary = _stdout.write | |
def start(self, group, params): | |
self.write("Group: " + (group or "defaults") + "\n") | |
self.write("Subject: " + self.make_subject(group, params) + "\n\n") | |
def finish(self): | |
pass | |
if (PY3 and (codecs.lookup(sys.stdout.encoding) != codecs.lookup('utf-8'))): | |
def write(self, output): | |
"""Write text as *default* encoding string""" | |
return self.write_binary(output.encode(sys.stdout.encoding, | |
'backslashreplace')) | |
class PipeOutput(MailedOutput): | |
"Deliver a mail message to an MTA via a pipe." | |
def __init__(self, cfg, repos, prefix_param): | |
MailedOutput.__init__(self, cfg, repos, prefix_param) | |
# figure out the command for delivery | |
self.cmd = cfg.general.mail_command.split() | |
def start(self, group, params): | |
MailedOutput.start(self, group, params) | |
### gotta fix this. this is pretty specific to sendmail and qmail's | |
### mailwrapper program. should be able to use option param substitution | |
cmd = self.cmd + [ '-f', self.from_addr ] + self.to_addrs | |
# construct the pipe for talking to the mailer | |
self.pipe = subprocess.Popen(cmd, stdin=subprocess.PIPE, | |
close_fds=sys.platform != "win32") | |
self.write_binary = self.pipe.stdin.write | |
# start writing out the mail message | |
self.write(self.mail_headers(group, params)) | |
def finish(self): | |
# signal that we're done sending content | |
self.pipe.stdin.close() | |
# wait to avoid zombies | |
self.pipe.wait() | |
class Messenger: | |
def __init__(self, pool, cfg, repos, prefix_param): | |
self.pool = pool | |
self.cfg = cfg | |
self.repos = repos | |
if cfg.is_set('general.mail_command'): | |
cls = PipeOutput | |
elif cfg.is_set('general.smtp_hostname'): | |
cls = SMTPOutput | |
else: | |
cls = StandardOutput | |
self.output = cls(cfg, repos, prefix_param) | |
class Commit(Messenger): | |
def __init__(self, pool, cfg, repos): | |
Messenger.__init__(self, pool, cfg, repos, 'commit_subject_prefix') | |
# get all the changes and sort by path | |
editor = svn.repos.ChangeCollector(repos.fs_ptr, repos.root_this, \ | |
self.pool) | |
e_ptr, e_baton = svn.delta.make_editor(editor, self.pool) | |
svn.repos.replay2(repos.root_this, "", svn.core.SVN_INVALID_REVNUM, 1, e_ptr, e_baton, None, self.pool) | |
self.changelist = sorted(editor.get_changes().items()) | |
log = to_str(repos.get_rev_prop(svn.core.SVN_PROP_REVISION_LOG) or b'') | |
# collect the set of groups and the unique sets of params for the options | |
self.groups = { } | |
for path, change in self.changelist: | |
for (group, params) in self.cfg.which_groups(path, log): | |
# turn the params into a hashable object and stash it away | |
param_list = sorted(params.items()) | |
# collect the set of paths belonging to this group | |
if (group, tuple(param_list)) in self.groups: | |
old_param, paths = self.groups[group, tuple(param_list)] | |
else: | |
paths = { } | |
paths[path] = None | |
self.groups[group, tuple(param_list)] = (params, paths) | |
# figure out the changed directories | |
dirs = { } | |
for path, change in self.changelist: | |
path = to_str(path) | |
if change.item_kind == svn.core.svn_node_dir: | |
dirs[path] = None | |
else: | |
idx = path.rfind('/') | |
if idx == -1: | |
dirs[''] = None | |
else: | |
dirs[path[:idx]] = None | |
dirlist = list(dirs.keys()) | |
commondir, dirlist = get_commondir(dirlist) | |
# compose the basic subject line. later, we can prefix it. | |
dirlist.sort() | |
dirlist = ' '.join(dirlist) | |
if commondir: | |
self.output.subject = 'r%d - in %s: %s' % (repos.rev, commondir, dirlist) | |
else: | |
self.output.subject = 'r%d - %s' % (repos.rev, dirlist) | |
def generate(self): | |
"Generate email for the various groups and option-params." | |
### the groups need to be further compressed. if the headers and | |
### body are the same across groups, then we can have multiple To: | |
### addresses. SMTPOutput holds the entire message body in memory, | |
### so if the body doesn't change, then it can be sent N times | |
### rather than rebuilding it each time. | |
subpool = svn.core.svn_pool_create(self.pool) | |
ret = 0 | |
# build a renderer, tied to our output stream | |
renderer = TextCommitRenderer(self.output) | |
for (group, param_tuple), (params, paths) in sorted(self.groups.items()): | |
try: | |
self.output.start(group, params) | |
# generate the content for this group and set of params | |
generate_content(renderer, self.cfg, self.repos, self.changelist, | |
group, params, paths, subpool) | |
self.output.finish() | |
except MessageSendFailure: | |
ret = 1 | |
svn.core.svn_pool_clear(subpool) | |
svn.core.svn_pool_destroy(subpool) | |
return ret | |
class PropChange(Messenger): | |
def __init__(self, pool, cfg, repos, author, propname, action): | |
Messenger.__init__(self, pool, cfg, repos, 'propchange_subject_prefix') | |
self.author = author | |
self.propname = propname | |
self.action = action | |
# collect the set of groups and the unique sets of params for the options | |
self.groups = { } | |
for (group, params) in self.cfg.which_groups('', None): | |
# turn the params into a hashable object and stash it away | |
param_list = sorted(params.items()) | |
self.groups[group, tuple(param_list)] = params | |
self.output.subject = 'r%d - %s' % (repos.rev, propname) | |
def generate(self): | |
actions = { 'A': 'added', 'M': 'modified', 'D': 'deleted' } | |
ret = 0 | |
for (group, param_tuple), params in self.groups.items(): | |
try: | |
self.output.start(group, params) | |
self.output.write('Author: %s\n' | |
'Revision: %s\n' | |
'Property Name: %s\n' | |
'Action: %s\n' | |
'\n' | |
% (self.author, self.repos.rev, self.propname, | |
actions.get(self.action, 'Unknown (\'%s\')' \ | |
% self.action))) | |
if self.action == 'A' or self.action not in actions: | |
self.output.write('Property value:\n') | |
propvalue = self.repos.get_rev_prop(self.propname) | |
self.output.write(propvalue) | |
elif self.action == 'M': | |
self.output.write('Property diff:\n') | |
tempfile1 = tempfile.NamedTemporaryFile() | |
tempfile1.write(_stdin.read()) | |
tempfile1.flush() | |
tempfile2 = tempfile.NamedTemporaryFile() | |
tempfile2.write(self.repos.get_rev_prop(self.propname)) | |
tempfile2.flush() | |
self.output.run(self.cfg.get_diff_cmd(group, { | |
'label_from' : 'old property value', | |
'label_to' : 'new property value', | |
'from' : tempfile1.name, | |
'to' : tempfile2.name, | |
})) | |
self.output.finish() | |
except MessageSendFailure: | |
ret = 1 | |
return ret | |
def get_commondir(dirlist): | |
"""Figure out the common portion/parent (commondir) of all the paths | |
in DIRLIST and return a tuple consisting of commondir, dirlist. If | |
a commondir is found, the dirlist returned is rooted in that | |
commondir. If no commondir is found, dirlist is returned unchanged, | |
and commondir is the empty string.""" | |
if len(dirlist) < 2 or '/' in dirlist: | |
commondir = '' | |
newdirs = dirlist | |
else: | |
common = dirlist[0].split('/') | |
for j in range(1, len(dirlist)): | |
d = dirlist[j] | |
parts = d.split('/') | |
for i in range(len(common)): | |
if i == len(parts) or common[i] != parts[i]: | |
del common[i:] | |
break | |
commondir = '/'.join(common) | |
if commondir: | |
# strip the common portion from each directory | |
l = len(commondir) + 1 | |
newdirs = [ ] | |
for d in dirlist: | |
if d == commondir: | |
newdirs.append('.') | |
else: | |
newdirs.append(d[l:]) | |
else: | |
# nothing in common, so reset the list of directories | |
newdirs = dirlist | |
return commondir, newdirs | |
class Lock(Messenger): | |
def __init__(self, pool, cfg, repos, author, do_lock): | |
self.author = author | |
self.do_lock = do_lock | |
Messenger.__init__(self, pool, cfg, repos, | |
(do_lock and 'lock_subject_prefix' | |
or 'unlock_subject_prefix')) | |
# read all the locked paths from STDIN and strip off the trailing newlines | |
self.dirlist = [to_str(x).rstrip() for x in _stdin.readlines()] | |
# collect the set of groups and the unique sets of params for the options | |
self.groups = { } | |
for path in self.dirlist: | |
for (group, params) in self.cfg.which_groups(path, None): | |
# turn the params into a hashable object and stash it away | |
param_list = sorted(params.items()) | |
# collect the set of paths belonging to this group | |
if (group, tuple(param_list)) in self.groups: | |
old_param, paths = self.groups[group, tuple(param_list)] | |
else: | |
paths = { } | |
paths[path] = None | |
self.groups[group, tuple(param_list)] = (params, paths) | |
commondir, dirlist = get_commondir(self.dirlist) | |
# compose the basic subject line. later, we can prefix it. | |
dirlist.sort() | |
dirlist = ' '.join(dirlist) | |
if commondir: | |
self.output.subject = '%s: %s' % (commondir, dirlist) | |
else: | |
self.output.subject = '%s' % (dirlist) | |
# The lock comment is the same for all paths, so we can just pull | |
# the comment for the first path in the dirlist and cache it. | |
self.lock = svn.fs.svn_fs_get_lock(self.repos.fs_ptr, | |
to_bytes(self.dirlist[0]), | |
self.pool) | |
def generate(self): | |
ret = 0 | |
for (group, param_tuple), (params, paths) in sorted(self.groups.items()): | |
try: | |
self.output.start(group, params) | |
self.output.write('Author: %s\n' | |
'%s paths:\n' % | |
(self.author, self.do_lock and 'Locked' or 'Unlocked')) | |
self.dirlist.sort() | |
for dir in self.dirlist: | |
self.output.write(' %s\n\n' % dir) | |
if self.do_lock: | |
self.output.write('Comment:\n%s\n' % (self.lock.comment or '')) | |
self.output.finish() | |
except MessageSendFailure: | |
ret = 1 | |
return ret | |
class DiffSelections: | |
def __init__(self, cfg, group, params): | |
self.add = False | |
self.copy = False | |
self.delete = False | |
self.modify = False | |
gen_diffs = cfg.get('generate_diffs', group, params) | |
### Do a little dance for deprecated options. Note that even if you | |
### don't have an option anywhere in your configuration file, it | |
### still gets returned as non-None. | |
if len(gen_diffs): | |
list = gen_diffs.split(" ") | |
for item in list: | |
if item == 'add': | |
self.add = True | |
if item == 'copy': | |
self.copy = True | |
if item == 'delete': | |
self.delete = True | |
if item == 'modify': | |
self.modify = True | |
else: | |
self.add = True | |
self.copy = True | |
self.delete = True | |
self.modify = True | |
### These options are deprecated | |
suppress = cfg.get('suppress_deletes', group, params) | |
if suppress == 'yes': | |
self.delete = False | |
suppress = cfg.get('suppress_adds', group, params) | |
if suppress == 'yes': | |
self.add = False | |
class DiffURLSelections: | |
def __init__(self, cfg, group, params): | |
self.cfg = cfg | |
self.group = group | |
self.params = params | |
def _get_url(self, action, repos_rev, change): | |
# The parameters for the URLs generation need to be placed in the | |
# parameters for the configuration module, otherwise we may get | |
# KeyError exceptions. | |
params = self.params.copy() | |
params['path'] = _url_quote(change.path) if change.path else None | |
params['base_path'] = (_url_quote(change.base_path) | |
if change.base_path else None) | |
params['rev'] = repos_rev | |
params['base_rev'] = change.base_rev | |
return self.cfg.get("diff_%s_url" % action, self.group, params) | |
def get_add_url(self, repos_rev, change): | |
return self._get_url('add', repos_rev, change) | |
def get_copy_url(self, repos_rev, change): | |
return self._get_url('copy', repos_rev, change) | |
def get_delete_url(self, repos_rev, change): | |
return self._get_url('delete', repos_rev, change) | |
def get_modify_url(self, repos_rev, change): | |
return self._get_url('modify', repos_rev, change) | |
def generate_content(renderer, cfg, repos, changelist, group, params, paths, | |
pool): | |
svndate = repos.get_rev_prop(svn.core.SVN_PROP_REVISION_DATE) | |
### pick a different date format? | |
date = time.ctime(svn.core.secs_from_timestr(svndate, pool)) | |
diffsels = DiffSelections(cfg, group, params) | |
diffurls = DiffURLSelections(cfg, group, params) | |
show_nonmatching_paths = cfg.get('show_nonmatching_paths', group, params) \ | |
or 'yes' | |
params_with_rev = params.copy() | |
params_with_rev['rev'] = repos.rev | |
commit_url = cfg.get('commit_url', group, params_with_rev) | |
# figure out the lists of changes outside the selected path-space | |
other_added_data = other_replaced_data = other_deleted_data = \ | |
other_modified_data = [ ] | |
if len(paths) != len(changelist) and show_nonmatching_paths != 'no': | |
other_added_data = generate_list('A', changelist, paths, False) | |
other_replaced_data = generate_list('R', changelist, paths, False) | |
other_deleted_data = generate_list('D', changelist, paths, False) | |
other_modified_data = generate_list('M', changelist, paths, False) | |
if len(paths) != len(changelist) and show_nonmatching_paths == 'yes': | |
other_diffs = DiffGenerator(changelist, paths, False, cfg, repos, date, | |
group, params, diffsels, diffurls, pool) | |
else: | |
other_diffs = None | |
data = _data( | |
author=repos.author, | |
date=date, | |
rev=repos.rev, | |
log=to_str(repos.get_rev_prop(svn.core.SVN_PROP_REVISION_LOG) or b''), | |
commit_url=commit_url, | |
added_data=generate_list('A', changelist, paths, True), | |
replaced_data=generate_list('R', changelist, paths, True), | |
deleted_data=generate_list('D', changelist, paths, True), | |
modified_data=generate_list('M', changelist, paths, True), | |
show_nonmatching_paths=show_nonmatching_paths, | |
other_added_data=other_added_data, | |
other_replaced_data=other_replaced_data, | |
other_deleted_data=other_deleted_data, | |
other_modified_data=other_modified_data, | |
diffs=DiffGenerator(changelist, paths, True, cfg, repos, date, group, | |
params, diffsels, diffurls, pool), | |
other_diffs=other_diffs, | |
) | |
renderer.render(data) | |
def generate_list(changekind, changelist, paths, in_paths): | |
if changekind == 'A': | |
selection = lambda change: change.action == svn.repos.CHANGE_ACTION_ADD | |
elif changekind == 'R': | |
selection = lambda change: change.action == svn.repos.CHANGE_ACTION_REPLACE | |
elif changekind == 'D': | |
selection = lambda change: change.action == svn.repos.CHANGE_ACTION_DELETE | |
elif changekind == 'M': | |
selection = lambda change: change.action == svn.repos.CHANGE_ACTION_MODIFY | |
items = [ ] | |
for path, change in changelist: | |
if selection(change) and (path in paths) == in_paths: | |
item = _data( | |
path=path, | |
is_dir=change.item_kind == svn.core.svn_node_dir, | |
props_changed=change.prop_changes, | |
text_changed=change.text_changed, | |
copied=(change.action == svn.repos.CHANGE_ACTION_ADD \ | |
or change.action == svn.repos.CHANGE_ACTION_REPLACE) \ | |
and change.base_path, | |
base_path=remove_leading_slashes(change.base_path), | |
base_rev=change.base_rev, | |
) | |
items.append(item) | |
return items | |
class DiffGenerator: | |
"This is a generator-like object returning DiffContent objects." | |
def __init__(self, changelist, paths, in_paths, cfg, repos, date, group, | |
params, diffsels, diffurls, pool): | |
self.changelist = changelist | |
self.paths = paths | |
self.in_paths = in_paths | |
self.cfg = cfg | |
self.repos = repos | |
self.date = date | |
self.group = group | |
self.params = params | |
self.diffsels = diffsels | |
self.diffurls = diffurls | |
self.pool = pool | |
self.diff = self.diff_url = None | |
self.idx = 0 | |
def __nonzero__(self): | |
# we always have some items | |
return True | |
def __getitem__(self, idx): | |
while True: | |
if self.idx == len(self.changelist): | |
raise IndexError | |
path, change = self.changelist[self.idx] | |
self.idx = self.idx + 1 | |
diff = diff_url = None | |
kind = None | |
label1 = None | |
label2 = None | |
src_fname = None | |
dst_fname = None | |
binary = None | |
singular = None | |
content = None | |
# just skip directories. they have no diffs. | |
if change.item_kind == svn.core.svn_node_dir: | |
continue | |
# is this change in (or out of) the set of matched paths? | |
if (path in self.paths) != self.in_paths: | |
continue | |
if change.base_rev != -1: | |
svndate = self.repos.get_rev_prop(svn.core.SVN_PROP_REVISION_DATE, | |
change.base_rev) | |
### pick a different date format? | |
base_date = time.ctime(svn.core.secs_from_timestr(svndate, self.pool)) | |
else: | |
base_date = '' | |
# figure out if/how to generate a diff | |
base_path_bytes = remove_leading_slashes(change.base_path) | |
base_path = (to_str(base_path_bytes) | |
if base_path_bytes is not None else None) | |
if change.action == svn.repos.CHANGE_ACTION_DELETE: | |
# it was delete. | |
kind = 'D' | |
# get the diff url, if any is specified | |
diff_url = self.diffurls.get_delete_url(self.repos.rev, change) | |
# show the diff? | |
if self.diffsels.delete: | |
diff = svn.fs.FileDiff(self.repos.get_root(change.base_rev), | |
base_path_bytes, None, None, self.pool) | |
label1 = '%s\t%s\t(r%s)' % (base_path, self.date, change.base_rev) | |
label2 = '/dev/null\t00:00:00 1970\t(deleted)' | |
singular = True | |
elif change.action == svn.repos.CHANGE_ACTION_ADD \ | |
or change.action == svn.repos.CHANGE_ACTION_REPLACE: | |
if base_path and (change.base_rev != -1): | |
# any diff of interest? | |
if change.text_changed: | |
# this file was copied and modified. | |
kind = 'W' | |
# get the diff url, if any is specified | |
diff_url = self.diffurls.get_copy_url(self.repos.rev, change) | |
# show the diff? | |
if self.diffsels.modify: | |
diff = svn.fs.FileDiff(self.repos.get_root(change.base_rev), | |
base_path_bytes, | |
self.repos.root_this, change.path, | |
self.pool) | |
label1 = ('%s\t%s\t(r%s, copy source)' | |
% (base_path, base_date, change.base_rev)) | |
label2 = ('%s\t%s\t(r%s)' | |
% (to_str(change.path), self.date, self.repos.rev)) | |
singular = False | |
else: | |
# this file was copied. | |
kind = 'C' | |
if self.diffsels.copy: | |
diff = svn.fs.FileDiff(None, None, self.repos.root_this, | |
change.path, self.pool) | |
label1 = ('/dev/null\t00:00:00 1970\t' | |
'(empty, because file is newly added)') | |
label2 = ('%s\t%s\t(r%s, copy of r%s, %s)' | |
% (to_str(change.path), | |
self.date, self.repos.rev, change.base_rev, | |
base_path)) | |
singular = False | |
else: | |
# the file was added. | |
kind = 'A' | |
# get the diff url, if any is specified | |
diff_url = self.diffurls.get_add_url(self.repos.rev, change) | |
# show the diff? | |
if self.diffsels.add: | |
diff = svn.fs.FileDiff(None, None, self.repos.root_this, | |
change.path, self.pool) | |
label1 = '/dev/null\t00:00:00 1970\t' \ | |
'(empty, because file is newly added)' | |
label2 = '%s\t%s\t(r%s)' \ | |
% (to_str(change.path), self.date, self.repos.rev) | |
singular = True | |
elif not change.text_changed: | |
# the text didn't change, so nothing to show. | |
continue | |
else: | |
# a simple modification. | |
kind = 'M' | |
# get the diff url, if any is specified | |
diff_url = self.diffurls.get_modify_url(self.repos.rev, change) | |
# show the diff? | |
if self.diffsels.modify: | |
diff = svn.fs.FileDiff(self.repos.get_root(change.base_rev), | |
base_path, | |
self.repos.root_this, change.path, | |
self.pool) | |
label1 = '%s\t%s\t(r%s)' \ | |
% (base_path, base_date, change.base_rev) | |
label2 = '%s\t%s\t(r%s)' \ | |
% (to_str(change.path), self.date, self.repos.rev) | |
singular = False | |
if diff: | |
binary = diff.either_binary() | |
if binary: | |
content = src_fname = dst_fname = None | |
else: | |
src_fname, dst_fname = diff.get_files() | |
try: | |
content = DiffContent(self.cfg.get_diff_cmd(self.group, { | |
'label_from' : label1, | |
'label_to' : label2, | |
'from' : src_fname, | |
'to' : dst_fname, | |
})) | |
except OSError: | |
# diff command does not exist, try difflib.unified_diff() | |
content = DifflibDiffContent(label1, label2, src_fname, dst_fname) | |
# return a data item for this diff | |
return _data( | |
path=change.path, | |
base_path=base_path_bytes, | |
base_rev=change.base_rev, | |
diff=diff, | |
diff_url=diff_url, | |
kind=kind, | |
label_from=label1, | |
label_to=label2, | |
from_fname=src_fname, | |
to_fname=dst_fname, | |
binary=binary, | |
singular=singular, | |
content=content, | |
) | |
def _classify_diff_line(line, seen_change): | |
# classify the type of line. | |
first = line[:1] | |
ltype = '' | |
if first == '@': | |
seen_change = True | |
ltype = 'H' | |
elif first == '-': | |
if seen_change: | |
ltype = 'D' | |
else: | |
ltype = 'F' | |
elif first == '+': | |
if seen_change: | |
ltype = 'A' | |
else: | |
ltype = 'T' | |
elif first == ' ': | |
ltype = 'C' | |
else: | |
ltype = 'U' | |
if line[-2] == '\r': | |
line=line[0:-2] + '\n' # remove carriage return | |
return line, ltype, seen_change | |
class DiffContent: | |
"This is a generator-like object returning annotated lines of a diff." | |
def __init__(self, cmd): | |
self.seen_change = False | |
# By default we choose to incorporate child stderr into the output | |
self.pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE, | |
stderr=subprocess.STDOUT, | |
close_fds=sys.platform != "win32") | |
def __nonzero__(self): | |
# we always have some items | |
return True | |
def __getitem__(self, idx): | |
if self.pipe is None: | |
raise IndexError | |
line = self.pipe.stdout.readline() | |
if not line: | |
# wait on the child so we don't end up with a billion zombies | |
self.pipe.wait() | |
self.pipe = None | |
raise IndexError | |
line, ltype, self.seen_change = _classify_diff_line(line, self.seen_change) | |
return _data( | |
raw=line, | |
text=line[1:-1], # remove indicator and newline | |
type=ltype, | |
) | |
class DifflibDiffContent(): | |
"This is a generator-like object returning annotated lines of a diff." | |
def __init__(self, label_from, label_to, from_file, to_file): | |
import difflib | |
self.seen_change = False | |
fromlines = open(from_file, 'U').readlines() | |
tolines = open(to_file, 'U').readlines() | |
self.diff = difflib.unified_diff(fromlines, tolines, | |
label_from, label_to) | |
def __nonzero__(self): | |
# we always have some items | |
return True | |
def __getitem__(self, idx): | |
try: | |
line = self.diff.next() | |
except StopIteration: | |
raise IndexError | |
line, ltype, self.seen_change = _classify_diff_line(line, self.seen_change) | |
return _data( | |
raw=line, | |
text=line[1:-1], # remove indicator and newline | |
type=ltype, | |
) | |
class TextCommitRenderer: | |
"This class will render the commit mail in plain text." | |
def __init__(self, output): | |
self.output = output | |
def render(self, data): | |
"Render the commit defined by 'data'." | |
w = self.output.write | |
w('Author: %s\nDate: %s\nNew Revision: %s\n' % (data.author, | |
data.date, | |
data.rev)) | |
if data.commit_url: | |
w('URL: %s\n\n' % data.commit_url) | |
else: | |
w('\n') | |
w('Log:\n%s\n\n' % data.log.strip()) | |
# print summary sections | |
self._render_list('Added', data.added_data) | |
self._render_list('Replaced', data.replaced_data) | |
self._render_list('Deleted', data.deleted_data) | |
self._render_list('Modified', data.modified_data) | |
if data.other_added_data or data.other_replaced_data \ | |
or data.other_deleted_data or data.other_modified_data: | |
if data.show_nonmatching_paths: | |
w('\nChanges in other areas also in this revision:\n') | |
self._render_list('Added', data.other_added_data) | |
self._render_list('Replaced', data.other_replaced_data) | |
self._render_list('Deleted', data.other_deleted_data) | |
self._render_list('Modified', data.other_modified_data) | |
else: | |
w('and changes in other areas\n') | |
self._render_diffs(data.diffs, '') | |
if data.other_diffs: | |
self._render_diffs(data.other_diffs, | |
'\nDiffs of changes in other areas also' | |
' in this revision:\n') | |
def _render_list(self, header, data_list): | |
if not data_list: | |
return | |
w = self.output.write | |
w(header + ':\n') | |
for d in data_list: | |
if d.is_dir: | |
is_dir = '/' | |
else: | |
is_dir = '' | |
if d.props_changed: | |
if d.text_changed: | |
props = ' (contents, props changed)' | |
else: | |
props = ' (props changed)' | |
else: | |
props = '' | |
w(' %s%s%s\n' % (to_str(d.path), is_dir, props)) | |
if d.copied: | |
if is_dir: | |
text = '' | |
elif d.text_changed: | |
text = ', changed' | |
else: | |
text = ' unchanged' | |
w(' - copied%s from r%d, %s%s\n' | |
% (text, d.base_rev, to_str(d.base_path), is_dir)) | |
def _render_diffs(self, diffs, section_header): | |
"""Render diffs. Write the SECTION_HEADER if there are actually | |
any diffs to render.""" | |
if not diffs: | |
return | |
w = self.output.write | |
section_header_printed = False | |
for diff in diffs: | |
if not diff.diff and not diff.diff_url: | |
continue | |
if not section_header_printed: | |
w(section_header) | |
section_header_printed = True | |
if diff.kind == 'D': | |
w('\nDeleted: %s\n' % to_str(diff.base_path)) | |
elif diff.kind == 'A': | |
w('\nAdded: %s\n' % to_str(diff.path)) | |
elif diff.kind == 'C': | |
w('\nCopied: %s (from r%d, %s)\n' | |
% (to_str(diff.path), diff.base_rev, | |
to_str(diff.base_path))) | |
elif diff.kind == 'W': | |
w('\nCopied and modified: %s (from r%d, %s)\n' | |
% (to_str(diff.path), diff.base_rev, | |
to_str(diff.base_path))) | |
else: | |
# kind == 'M' | |
w('\nModified: %s\n' % to_str(diff.path)) | |
if diff.diff_url: | |
w('URL: %s\n' % diff.diff_url) | |
if not diff.diff: | |
continue | |
w(SEPARATOR + '\n') | |
if diff.binary: | |
if diff.singular: | |
w('Binary file. No diff available.\n') | |
else: | |
w('Binary file (source and/or target). No diff available.\n') | |
continue | |
wb = self.output.write_binary | |
for line in diff.content: | |
wb(line.raw) | |
class Repository: | |
"Hold roots and other information about the repository." | |
def __init__(self, repos_dir, rev, pool): | |
self.repos_dir = repos_dir | |
self.rev = rev | |
self.pool = pool | |
self.repos_ptr = svn.repos.open(repos_dir, pool) | |
self.fs_ptr = svn.repos.fs(self.repos_ptr) | |
self.roots = { } | |
self.root_this = self.get_root(rev) | |
self.author = self.get_rev_prop(svn.core.SVN_PROP_REVISION_AUTHOR) | |
if self.author is not None: | |
self.author = to_str(self.author) | |
def get_rev_prop(self, propname, rev = None): | |
if not rev: | |
rev = self.rev | |
return svn.fs.revision_prop(self.fs_ptr, rev, propname, self.pool) | |
def get_root(self, rev): | |
try: | |
return self.roots[rev] | |
except KeyError: | |
pass | |
root = self.roots[rev] = svn.fs.revision_root(self.fs_ptr, rev, self.pool) | |
return root | |
class Config: | |
# The predefined configuration sections. These are omitted from the | |
# set of groups. | |
_predefined = ('general', 'defaults', 'maps') | |
def __init__(self, fname, repos, global_params): | |
cp = configparser.ConfigParser() | |
cp.read(fname) | |
# record the (non-default) groups that we find | |
self._groups = [ ] | |
for section in cp.sections(): | |
if not hasattr(self, section): | |
section_ob = _sub_section() | |
setattr(self, section, section_ob) | |
if section not in self._predefined: | |
self._groups.append(section) | |
else: | |
section_ob = getattr(self, section) | |
for option in cp.options(section): | |
# get the raw value -- we use the same format for *our* interpolation | |
value = cp.get(section, option, raw=1) | |
setattr(section_ob, option, value) | |
# be compatible with old format config files | |
if hasattr(self.general, 'diff') and not hasattr(self.defaults, 'diff'): | |
self.defaults.diff = self.general.diff | |
if not hasattr(self, 'maps'): | |
self.maps = _sub_section() | |
# these params are always available, although they may be overridden | |
self._global_params = global_params.copy() | |
# prepare maps. this may remove sections from consideration as a group. | |
self._prep_maps() | |
# process all the group sections. | |
self._prep_groups(repos) | |
def is_set(self, option): | |
"""Return None if the option is not set; otherwise, its value is returned. | |
The option is specified as a dotted symbol, such as 'general.mail_command' | |
""" | |
ob = self | |
for part in option.split('.'): | |
if not hasattr(ob, part): | |
return None | |
ob = getattr(ob, part) | |
return ob | |
def get(self, option, group, params): | |
"Get a config value with appropriate substitutions and value mapping." | |
# find the right value | |
value = None | |
if group: | |
sub = getattr(self, group) | |
value = getattr(sub, option, None) | |
if value is None: | |
value = getattr(self.defaults, option, '') | |
# parameterize it | |
if params is not None: | |
value = value % params | |
# apply any mapper | |
mapper = getattr(self.maps, option, None) | |
if mapper is not None: | |
value = mapper(value) | |
# Apply any parameters that may now be available for | |
# substitution that were not before the mapping. | |
if value is not None and params is not None: | |
value = value % params | |
return value | |
def get_diff_cmd(self, group, args): | |
"Get a diff command as a list of argv elements." | |
### do some better splitting to enable quoting of spaces | |
diff_cmd = self.get('diff', group, None).split() | |
cmd = [ ] | |
for part in diff_cmd: | |
cmd.append(part % args) | |
return cmd | |
def _prep_maps(self): | |
"Rewrite the [maps] options into callables that look up values." | |
mapsections = [] | |
for optname, mapvalue in vars(self.maps).items(): | |
if mapvalue[:1] == '[': | |
# a section is acting as a mapping | |
sectname = mapvalue[1:-1] | |
if not hasattr(self, sectname): | |
raise UnknownMappingSection(sectname) | |
# construct a lambda to look up the given value as an option name, | |
# and return the option's value. if the option is not present, | |
# then just return the value unchanged. | |
setattr(self.maps, optname, | |
lambda value, | |
sect=getattr(self, sectname): getattr(sect, | |
value.lower(), | |
value)) | |
# mark for removal when all optnames are done | |
if sectname not in mapsections: | |
mapsections.append(sectname) | |
# elif test for other mapper types. possible examples: | |
# dbm:filename.db | |
# file:two-column-file.txt | |
# ldap:some-query-spec | |
# just craft a mapper function and insert it appropriately | |
else: | |
raise UnknownMappingSpec(mapvalue) | |
# remove each mapping section from consideration as a group | |
for sectname in mapsections: | |
self._groups.remove(sectname) | |
def _prep_groups(self, repos): | |
self._group_re = [ ] | |
repos_dir = os.path.abspath(repos.repos_dir) | |
# compute the default repository-based parameters. start with some | |
# basic parameters, then bring in the regex-based params. | |
self._default_params = self._global_params | |
try: | |
match = re.match(self.defaults.for_repos, repos_dir) | |
if match: | |
self._default_params = self._default_params.copy() | |
self._default_params.update(match.groupdict()) | |
except AttributeError: | |
# there is no self.defaults.for_repos | |
pass | |
# select the groups that apply to this repository | |
for group in self._groups: | |
sub = getattr(self, group) | |
params = self._default_params | |
if hasattr(sub, 'for_repos'): | |
match = re.match(sub.for_repos, repos_dir) | |
if not match: | |
continue | |
params = params.copy() | |
params.update(match.groupdict()) | |
# if a matching rule hasn't been given, then use the empty string | |
# as it will match all paths | |
for_paths = getattr(sub, 'for_paths', '') | |
exclude_paths = getattr(sub, 'exclude_paths', None) | |
if exclude_paths: | |
exclude_paths_re = re.compile(exclude_paths) | |
else: | |
exclude_paths_re = None | |
# check search_logmsg re | |
search_logmsg = getattr(sub, 'search_logmsg', None) | |
if search_logmsg is not None: | |
search_logmsg_re = re.compile(search_logmsg) | |
else: | |
search_logmsg_re = None | |
self._group_re.append((group, | |
re.compile(for_paths), | |
exclude_paths_re, | |
params, | |
search_logmsg_re)) | |
# after all the groups are done, add in the default group | |
try: | |
self._group_re.append((None, | |
re.compile(self.defaults.for_paths), | |
None, | |
self._default_params, | |
None)) | |
except AttributeError: | |
# there is no self.defaults.for_paths | |
pass | |
def which_groups(self, path, logmsg): | |
"Return the path's associated groups." | |
groups = [] | |
for group, pattern, exclude_pattern, repos_params, search_logmsg_re in self._group_re: | |
match = pattern.match(to_str(path)) | |
if match: | |
if exclude_pattern and exclude_pattern.match(to_str(path)): | |
continue | |
params = repos_params.copy() | |
params.update(match.groupdict()) | |
if search_logmsg_re is None: | |
groups.append((group, params)) | |
else: | |
if logmsg is None: | |
logmsg = '' | |
for match in search_logmsg_re.finditer(logmsg): | |
# Add captured variables to (a copy of) params | |
msg_params = params.copy() | |
msg_params.update(match.groupdict()) | |
groups.append((group, msg_params)) | |
if not groups: | |
groups.append((None, self._default_params)) | |
return groups | |
class _sub_section: | |
pass | |
class _data: | |
"Helper class to define an attribute-based hunk o' data." | |
def __init__(self, **kw): | |
vars(self).update(kw) | |
class MissingConfig(Exception): | |
pass | |
class UnknownMappingSection(Exception): | |
pass | |
class UnknownMappingSpec(Exception): | |
pass | |
class UnknownSubcommand(Exception): | |
pass | |
class MessageSendFailure(Exception): | |
pass | |
if __name__ == '__main__': | |
def usage(): | |
scriptname = os.path.basename(sys.argv[0]) | |
sys.stderr.write( | |
"""USAGE: %s commit REPOS REVISION [CONFIG-FILE] | |
%s propchange REPOS REVISION AUTHOR REVPROPNAME [CONFIG-FILE] | |
%s propchange2 REPOS REVISION AUTHOR REVPROPNAME ACTION [CONFIG-FILE] | |
%s lock REPOS AUTHOR [CONFIG-FILE] | |
%s unlock REPOS AUTHOR [CONFIG-FILE] | |
If no CONFIG-FILE is provided, the script will first search for a mailer.conf | |
file in REPOS/conf/. Failing that, it will search the directory in which | |
the script itself resides. | |
ACTION was added as a fifth argument to the post-revprop-change hook | |
in Subversion 1.2.0. Its value is one of 'A', 'M' or 'D' to indicate | |
if the property was added, modified or deleted, respectively. | |
""" % (scriptname, scriptname, scriptname, scriptname, scriptname)) | |
sys.exit(1) | |
# Command list: subcommand -> number of arguments expected (not including | |
# the repository directory and config-file) | |
cmd_list = {'commit' : 1, | |
'propchange' : 3, | |
'propchange2': 4, | |
'lock' : 1, | |
'unlock' : 1, | |
} | |
config_fname = None | |
argc = len(sys.argv) | |
if argc < 3: | |
usage() | |
cmd = sys.argv[1] | |
repos_dir = to_str(svn.core.svn_path_canonicalize(to_bytes(sys.argv[2]))) | |
try: | |
expected_args = cmd_list[cmd] | |
except KeyError: | |
usage() | |
if argc < (expected_args + 3): | |
usage() | |
elif argc > expected_args + 4: | |
usage() | |
elif argc == (expected_args + 4): | |
config_fname = sys.argv[expected_args + 3] | |
# Settle on a config file location, and open it. | |
if config_fname is None: | |
# Default to REPOS-DIR/conf/mailer.conf. | |
config_fname = os.path.join(repos_dir, 'conf', 'mailer.conf') | |
if not os.path.exists(config_fname): | |
# Okay. Look for 'mailer.conf' as a sibling of this script. | |
config_fname = os.path.join(os.path.dirname(sys.argv[0]), 'mailer.conf') | |
if not os.path.exists(config_fname): | |
raise MissingConfig(config_fname) | |
ret = svn.core.run_app(main, cmd, config_fname, repos_dir, | |
sys.argv[3:3+expected_args]) | |
sys.exit(1 if ret else 0) | |
# ------------------------------------------------------------------------ | |
# TODO | |
# | |
# * add configuration options | |
# - each group defines delivery info: | |
# o whether to set Reply-To and/or Mail-Followup-To | |
# (btw: it is legal do set Reply-To since this is the originator of the | |
# mail; i.e. different from MLMs that munge it) | |
# - each group defines content construction: | |
# o max size of diff before trimming | |
# o max size of entire commit message before truncation | |
# - per-repository configuration | |
# o extra config living in repos | |
# o optional, non-mail log file | |
# o look up authors (username -> email; for the From: header) in a | |
# file(s) or DBM | |
# * get rid of global functions that should properly be class methods |
#!/usr/bin/php | |
<?php | |
define('SVNLOOK', '/usr/bin/svnlook'); | |
// Get list of files in this transaction, but only consider sql files in the 'SQL' folder. | |
// 1 = TXN, 2 = REPO | |
$command = SVNLOOK." changed -t {$_SERVER['argv'][1]} {$_SERVER['argv'][2]} | awk '{print \$2}' | grep /SQL/ | grep -i \\.sql\$"; | |
// Wenn der Befehl eben nicht geht, dann SQL-Dateien per Hand raussuchen. | |
$handle = popen($command, 'r'); | |
if ($handle === false) { | |
echo 'ERROR: Could not execute "'.$command.'"'.PHP_EOL.PHP_EOL; | |
exit(2); | |
} | |
$contents = stream_get_contents($handle); | |
fclose($handle); | |
$neededRollbacks = array(); | |
$neededMigrations = array(); | |
// Ausgabe in einzelne Zeilen aufspalten, -1 == no limit | |
foreach (preg_split('/\v/', $contents, -1, PREG_SPLIT_NO_EMPTY) as $path) { | |
$matches = array(); | |
if (preg_match("/migration_([0-9]+)/", $path, $matches)) { | |
$neededRollbacks[$matches[1]] = $path; | |
} else if (preg_match("/rollback_([0-9]+)/", $path, $matches)) { | |
$neededMigrations[$matches[1]] = $path; | |
} | |
} | |
// Wir müssen jetzt vergleichen, ob es zu jeder migration_<num>.sql auch eine rollback_<num>.sql gibt. | |
$missingMigrations = array_diff_key($neededRollbacks, $neededMigrations); | |
$missingRollbacks = array_diff_key($neededMigrations, $neededRollbacks); | |
if (empty($missingMigrations) && empty($missingRollbacks)) { | |
exit(0); | |
} | |
echo "Each migration sql file must have a corresponding rollback sql file and vice versa:", PHP_EOL, PHP_EOL; | |
foreach ($missingMigrations as $rev => $file) { | |
echo $file, PHP_EOL; | |
} | |
foreach ($missingRollbacks as $rev => $file) { | |
echo $file, PHP_EOL; | |
} | |
echo PHP_EOL; | |
echo "If you absolutely cannot provide a rollback file, please insert @NO ROLLBACK@ into your commit comment.", PHP_EOL; | |
echo "This will allow your commit to pass.", PHP_EOL; | |
echo "See https://wiki.th-mittelhessen.de/index.php/Datenbankmigration for more information.", PHP_EOL; | |
exit(1); |
#!/usr/bin/env python | |
# | |
# | |
# Licensed to the Apache Software Foundation (ASF) under one | |
# or more contributor license agreements. See the NOTICE file | |
# distributed with this work for additional information | |
# regarding copyright ownership. The ASF licenses this file | |
# to you under the Apache License, Version 2.0 (the | |
# "License"); you may not use this file except in compliance | |
# with the License. You may obtain a copy of the License at | |
# | |
# http://www.apache.org/licenses/LICENSE-2.0 | |
# | |
# Unless required by applicable law or agreed to in writing, | |
# software distributed under the License is distributed on an | |
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
# KIND, either express or implied. See the License for the | |
# specific language governing permissions and limitations | |
# under the License. | |
# | |
# | |
import sys | |
import os | |
from svn import repos, fs, core | |
def duplicate_ephemeral_txnprops(repos_path, txn_name): | |
fs_ptr = repos.fs(repos.open(repos_path)) | |
txn_t = fs.open_txn(fs_ptr, txn_name) | |
for name, value in fs.txn_proplist(txn_t).items(): | |
if name.startswith(core.SVN_PROP_TXN_PREFIX): | |
name = core.SVN_PROP_REVISION_PREFIX + \ | |
name[len(core.SVN_PROP_TXN_PREFIX):] | |
fs.change_txn_prop(txn_t, name, value) | |
def usage_and_exit(errmsg=None): | |
stream = errmsg and sys.stderr or sys.stdout | |
stream.write("""\ | |
Usage: | |
persist-ephemeral-txnprops.py REPOS_PATH TXN_NAME | |
Duplicate ephemeral transaction properties so that the information | |
they carry may persist as properties of the revision created once the | |
transaction is committed. This is intended to be used as a Subversion | |
pre-commit hook script. | |
REPOS_PATH is the on-disk path of the repository whose transaction | |
properties are being examined/modified. TXN_NAME is the name of the | |
transaction. | |
Ephemeral transaction properties, whose names all begin with the | |
prefix "%s", will be copied to new properties which use the | |
prefix "%s" instead. | |
""" % (core.SVN_PROP_TXN_PREFIX, core.SVN_PROP_REVISION_PREFIX)) | |
if errmsg: | |
stream.write("ERROR: " + errmsg + "\n") | |
sys.exit(errmsg and 1 or 0) | |
def main(): | |
argc = len(sys.argv) | |
if argc != 3: | |
usage_and_exit("Incorrect number of arguments.") | |
repos_path = sys.argv[1] | |
txn_name = sys.argv[2] | |
duplicate_ephemeral_txnprops(repos_path, txn_name) | |
if __name__ == "__main__": | |
main() |
#!/usr/bin/php | |
<?php | |
/** | |
* A commit hook for SVN. | |
* | |
* PHP version 5 | |
* | |
* @category PHP | |
* @package PHP_CodeSniffer | |
* @author Jack Bates <ms419@freezone.co.uk> | |
* @author Greg Sherwood <gsherwood@squiz.net> | |
* @copyright 2006 Squiz Pty Ltd (ABN 77 084 670 600) | |
* @license http://matrix.squiz.net/developer/tools/php_cs/licence BSD Licence | |
* @version CVS: $Id: phpcs-svn-pre-commit,v 1.5 2009/01/14 02:44:18 squiz Exp $ | |
* @link http://pear.php.net/package/PHP_CodeSniffer | |
*/ | |
if (is_file(dirname(__FILE__).'/../CodeSniffer/CLI.php') === true) { | |
include_once dirname(__FILE__).'/../CodeSniffer/CLI.php'; | |
} else { | |
include_once 'PHP/CodeSniffer/CLI.php'; | |
} | |
define('PHP_CODESNIFFER_SVNLOOK', '/usr/bin/svnlook'); | |
/** | |
* A class to process command line options. | |
* | |
* @category PHP | |
* @package PHP_CodeSniffer | |
* @author Jack Bates <ms419@freezone.co.uk> | |
* @author Greg Sherwood <gsherwood@squiz.net> | |
* @copyright 2006 Squiz Pty Ltd (ABN 77 084 670 600) | |
* @license http://matrix.squiz.net/developer/tools/php_cs/licence BSD Licence | |
* @version Release: @package_version@ | |
* @link http://pear.php.net/package/PHP_CodeSniffer | |
*/ | |
class PHP_CodeSniffer_SVN_Hook extends PHP_CodeSniffer_CLI | |
{ | |
/** | |
* Get a list of default values for all possible command line arguments. | |
* | |
* @return array | |
*/ | |
public function getDefaults() | |
{ | |
$defaults = parent::getDefaults(); | |
$defaults['svnArgs'] = array(); | |
return $defaults; | |
}//end getDefaults() | |
/** | |
* Processes an unknown command line argument. | |
* | |
* All unkown args are sent to SVN commands. | |
* | |
* @param string $arg The command line argument. | |
* @param int $pos The position of the argument on the command line. | |
* @param array $values An array of values determined from CLI args. | |
* | |
* @return array The updated CLI values. | |
* @see getCommandLineValues() | |
*/ | |
public function processUnknownArgument($arg, $pos, $values) | |
{ | |
$values['svnArgs'][] = $arg; | |
return $values; | |
}//end processUnknownArgument() | |
/** | |
* Runs PHP_CodeSniffer over files are directories. | |
* | |
* @param array $values An array of values determined from CLI args. | |
* | |
* @return int The number of error and warning messages shown. | |
* @see getCommandLineValues() | |
*/ | |
public function process($values=array()) | |
{ | |
if (empty($values) === true) { | |
$values = parent::getCommandLineValues(); | |
} | |
// Get list of files in this transaction. | |
$command = PHP_CODESNIFFER_SVNLOOK.' changed '.implode(' ', $values['svnArgs']); | |
$handle = popen($command, 'r'); | |
if ($handle === false) { | |
echo 'ERROR: Could not execute "'.$command.'"'.PHP_EOL.PHP_EOL; | |
exit(2); | |
} | |
$contents = stream_get_contents($handle); | |
fclose($handle); | |
// Do not check deleted paths. | |
$contents = preg_replace('/^D.*/m', null, $contents); | |
// Drop the four characters representing the action which precede the path on | |
// each line. | |
$contents = preg_replace('/^.{4}/m', null, $contents); | |
$values['standard'] = $this->validateStandard($values['standard']); | |
if (PHP_CodeSniffer::isInstalledStandard($values['standard']) === false) { | |
// They didn't select a valid coding standard, so help them | |
// out by letting them know which standards are installed. | |
echo 'ERROR: the "'.$values['standard'].'" coding standard is not installed. '; | |
$this->printInstalledStandards(); | |
exit(2); | |
} | |
$phpcs = new PHP_CodeSniffer($values['verbosity'], $values['tabWidth']); | |
// Set file extensions if they were specified. Otherwise, | |
// let PHP_CodeSniffer decide on the defaults. | |
if (empty($values['extensions']) === false) { | |
$phpcs->setAllowedFileExtensions($values['extensions']); | |
} | |
// Set ignore patterns if they were specified. | |
if (empty($values['ignored']) === false) { | |
$phpcs->setIgnorePatterns($values['ignored']); | |
} | |
// Initialize PHP_CodeSniffer listeners but don't process any files. | |
$phpcs->process(array(), $values['standard'], $values['sniffs']); | |
foreach (preg_split('/\v/', $contents, -1, PREG_SPLIT_NO_EMPTY) as $path) { | |
// No need to process folders as each changed file is checked. | |
if (substr($path, -1) === '/') { | |
continue; | |
} | |
// Do not check non-php files | |
if (substr($path, -4) !== ".php") { | |
continue; | |
} | |
// Get the contents of each file, as it would be after this transaction. | |
$command = PHP_CODESNIFFER_SVNLOOK.' cat '.implode(' ', $values['svnArgs']).' '.$path; | |
$handle = popen($command, 'r'); | |
if ($handle === false) { | |
echo 'ERROR: Could not execute "'.$command.'"'.PHP_EOL.PHP_EOL; | |
exit(2); | |
} | |
$contents = stream_get_contents($handle); | |
fclose($handle); | |
$phpcs->processFile($path, $contents); | |
}//end foreach | |
return parent::printErrorReport( | |
$phpcs, | |
$values['reports'], | |
$values['showSources'], | |
$values['reportFile'], | |
$values['reportWidth'] | |
); | |
}//end process() | |
/** | |
* Prints out the usage information for this script. | |
* | |
* @return void | |
*/ | |
public function printUsage() | |
{ | |
parent::printUsage(); | |
echo PHP_EOL; | |
echo ' Each additional argument is passed to the `svnlook changed ...`'.PHP_EOL; | |
echo ' and `svnlook cat ...` commands. The report is printed on standard output,'.PHP_EOL; | |
echo ' however Subversion displays only standard error to the user, so in a'.PHP_EOL; | |
echo ' pre-commit hook, this script should be invoked as follows:'.PHP_EOL; | |
echo PHP_EOL; | |
echo ' '.basename($_SERVER['argv'][0]).' ... "$REPOS" -t "$TXN" >&2 || exit 1'.PHP_EOL; | |
}//end printUsage() | |
}//end class | |
$phpcs = new PHP_CodeSniffer_SVN_Hook(); | |
$phpcs->checkRequirements(); | |
$numErrors = $phpcs->process(); | |
if ($numErrors !== 0) { | |
exit(1); | |
} | |
?> |
#!/bin/sh | |
# PRE-COMMIT HOOK | |
# | |
# The pre-commit hook is invoked before a Subversion txn is | |
# committed. Subversion runs this hook by invoking a program | |
# (script, executable, binary, etc.) named 'pre-commit' (for which | |
# this file is a template), with the following ordered arguments: | |
# | |
# [1] REPOS-PATH (the path to this repository) | |
# [2] TXN-NAME (the name of the txn about to be committed) | |
# | |
# The default working directory for the invocation is undefined, so | |
# the program should set one explicitly if it cares. | |
# | |
# If the hook program exits with success, the txn is committed; but | |
# if it exits with failure (non-zero), the txn is aborted, no commit | |
# takes place, and STDERR is returned to the client. The hook | |
# program can use the 'svnlook' utility to help it examine the txn. | |
# | |
# On a Unix system, the normal procedure is to have 'pre-commit' | |
# invoke other programs to do the real work, though it may do the | |
# work itself too. | |
# | |
# *** NOTE: THE HOOK PROGRAM MUST NOT MODIFY THE TXN, EXCEPT *** | |
# *** FOR REVISION PROPERTIES (like svn:log or svn:author). *** | |
# | |
# This is why we recommend using the read-only 'svnlook' utility. | |
# In the future, Subversion may enforce the rule that pre-commit | |
# hooks should not modify the versioned data in txns, or else come | |
# up with a mechanism to make it safe to do so (by informing the | |
# committing client of the changes). However, right now neither | |
# mechanism is implemented, so hook writers just have to be careful. | |
# | |
# Note that 'pre-commit' must be executable by the user(s) who will | |
# invoke it (typically the user httpd runs as), and that user must | |
# have filesystem-level permission to access the repository. | |
# | |
# On a Windows system, you should name the hook program | |
# 'pre-commit.bat' or 'pre-commit.exe', | |
# but the basic idea is the same. | |
# | |
# The hook program typically does not inherit the environment of | |
# its parent process. For example, a common problem is for the | |
# PATH environment variable to not be set to its usual value, so | |
# that subprograms fail to launch unless invoked via absolute path. | |
# If you're having unexpected problems with a hook program, the | |
# culprit may be unusual (or missing) environment variables. | |
# | |
# Here is an example hook script, for a Unix /bin/sh interpreter. | |
# For more examples and pre-written hooks, see those in | |
# the Subversion repository at | |
# http://svn.collab.net/repos/svn/trunk/tools/hook-scripts/ and | |
# http://svn.collab.net/repos/svn/trunk/contrib/hook-scripts/ | |
REPOS="$1" | |
TXN="$2" | |
# Make sure that the log message contains some text. | |
SVNLOOK=/usr/bin/svnlook | |
$SVNLOOK log -t "$TXN" "$REPOS" | grep "[a-zA-Z0-9]" > /dev/null || exit 1 | |
# Check that a migration file has corresponding rollback file. | |
# This check can be overridden with magic words. | |
$SVNLOOK log -t "$TXN" "$REPOS" | grep "@NO ROLLBACK@" > /dev/null | |
if [ $? -eq 1 ] | |
then | |
/usr/share/subversion/hook-scripts/migration-check.php "$TXN" "$REPOS" >&2 || exit 1 | |
fi | |
# Check if file uses UTF-8 byte order mark and abort commit if BOM is present | |
/usr/share/subversion/hook-scripts/bom-check.php "$TXN" "$REPOS" >&2 || exit 1 | |
#echo "Sorry, gerade kann man nicht committen. :o" 1>&2 | |
#exit 1 | |
# Check that the author of this commit has the rights to perform | |
# the commit on the files and directories being modified. | |
#/usr/share/subversion/hook-scripts/commit-access-control.pl "$REPOS" "$TXN" "$REPOS"/commit-access-control.cfg || exit 1 | |
PHP="/usr/bin/php" | |
AWK="/usr/bin/awk" | |
GREP="/bin/egrep" | |
SED="/bin/sed" | |
CHANGED=`$SVNLOOK changed -t "$TXN" "$REPOS" | $AWK '{print $2}' | $GREP \\.php$` | |
for FILE in $CHANGED | |
do | |
MESSAGE=`$SVNLOOK cat -t "$TXN" "$REPOS" "$FILE" | $PHP -l` | |
if [ $? -ne 0 ] | |
then | |
echo 1>&2 | |
echo "***********************************" 1>&2 | |
echo "PHP error in: $FILE:" 1>&2 | |
echo `echo "$MESSAGE" | $SED "s| -| $FILE|g"` 1>&2 | |
echo "***********************************" 1>&2 | |
exit 1 | |
fi | |
done | |
# --ignore=\.txt$,\.TXT$,\.jpg$,\.JPG$,\.js$,\.css$,\.html$,\.mkf$,\.phar$,\.sql$,\.ttf$,\.png&,\.phtml$,\.gif$,\.GIF$,\.svg$,\.SVG$,\.xml$,\.dtd$,\.db$,\.xsd$ | |
/usr/share/php/PHP/scripts/phpcs-svn-pre-commit --ignore=web/common/classes/jpgraph,web/common/classes/geshi,web/common/classes/tcpdf,web/xpweb/lib,web/phpids/classes/phpids_lib -t "$TXN" "$REPOS" >&2 || exit 1 | |
# All checks passed, so allow the commit. | |
exit 0 |
I’m a DevOps/SRE/DevSecOps/Cloud Expert passionate about sharing knowledge and experiences. I am working at Cotocus. I blog tech insights at DevOps School, travel stories at Holiday Landmark, stock market tips at Stocks Mantra, health and fitness guidance at My Medic Plus, product reviews at I reviewed , and SEO strategies at Wizbrand.
Please find my social handles as below;
Rajesh Kumar Personal Website
Rajesh Kumar at YOUTUBE
Rajesh Kumar at INSTAGRAM
Rajesh Kumar at X
Rajesh Kumar at FACEBOOK
Rajesh Kumar at LINKEDIN
Rajesh Kumar at PINTEREST
Rajesh Kumar at QUORA
Rajesh Kumar at WIZBRAND