--- /dev/null
+#!/usr/bin/env python
+
+# Script for mirroring (a part of) git repository to a FTP site. Only
+# changed files are copied to FTP. Works with bare repositories.
+#
+# Author: Michal Sojka <sojkam1@fel.cvut.cz>
+# License: GPL
+
+# Typical usage in hooks/post-receive (or pre-receive if you want push
+# command to fail in case of error during mirroring):
+# export PASSWORD=secret
+# /usr/local/bin/git-ftp-sync -H ftp.example.com -u username -r www
+# or
+# /usr/local/bin/git-ftp-sync -d /var/www/projectweb -r www
+
+# The first comamnd line mirrors the content og www directory in
+# repository to FTP site at ftp.example.com as user "username" with
+# password "secret".
+
+
+import sys
+from ftplib import FTP
+import ftplib
+from optparse import OptionParser
+from subprocess import Popen, PIPE, call
+import re
+import os
+from os.path import dirname, normpath
+
+try:
+ defpass = os.environ["PASSWORD"]
+ del os.environ["PASSWORD"]
+except KeyError:
+ defpass = ""
+
+opt_parser = OptionParser(usage="usage: %prog [options] repository revision")
+opt_parser.add_option("-H", "--host", dest="host",
+ help="FTP server address. If not specified update files localy without FTP.")
+opt_parser.add_option("-u", "--user", dest="user",
+ help="FTP login name")
+opt_parser.add_option("-p", "--pass", dest="password", default=defpass,
+ help="FTP password (defaults to environment variable PASSWORD)")
+opt_parser.add_option("-r", "--repodir", dest="repodir", default="",
+ help="Synchronize only this directory (and its subdirectories) from within a repository")
+opt_parser.add_option("-d", "--dir", dest="dir", default="",
+ help="An existing directory (on FTP site or localhost), where to store synchronized files.")
+
+class RepoChange:
+ """Represents one line in git diff --name-status"""
+ def __init__(self, type, path, oldrev, newrev, options):
+ self.type = type.strip() # one of ADMRCUX
+ self.repo_path = path
+ if path.startswith(options.repodir): # www/something -> something
+ path = path[len(options.repodir):]
+ if options.dir: # something -> prefix/something
+ path = options.dir+"/"+path
+ self.dest_path = normpath(path)
+ self.oldrev = oldrev
+ self.newrev = newrev
+
+ def isDir(self):
+ return self.repo_path[-1] == "/"
+
+ def inDir(self, dir):
+ """Dir: empty string or / closed directory name(s), without starting . or /"""
+ return self.repo_path.startswith(dir)
+
+class Syncer:
+ """Abstract class performing synchronization"""
+ def sync(self, change, dest_root):
+ if change.type[0] == 'A' and change.isDir():
+ # Never happens in git
+ self.mkd(change.dest_path)
+ elif change.type[0] in ["A", "C", "M"]:
+ # Check whether the target directory exists
+ retcode = call("git ls-tree %s %s|grep -q ." % (change.oldrev, dirname(change.repo_path)), shell=True)
+ if (retcode != 0 and normpath(dirname(change.dest_path)) != normpath(dest_root)):
+ self.mkd(dirname(change.dest_path))
+ # Upload the file
+ print "%s: UPLOAD"%self.__class__.__name__, change.dest_path
+ pipe = Popen("git cat-file blob %s:%s" % (change.newrev, change.repo_path),
+ stdout=PIPE, shell=True)
+ self._storbinary(pipe.stdout, change.dest_path)
+ elif change.type[0] == "D":
+ if change.isDir():
+ # Never happens in git
+ self.rmd(change.dest_path)
+ else:
+ print "%s: DEL "%self.__class__.__name__, change.dest_path
+ self._delete(change.dest_path)
+ else:
+ print >> sys.stderr, "Unknown change:", change.type, change.dest_path
+ sys.exit(1)
+
+ def mkd(self, path):
+ print "%s: MKD "%self.__class__.__name__, path
+ self._mkd(path)
+
+ def rmd(self, path):
+ print "%s: RMD "%self.__class__.__name__, path
+ self._rmd(path);
+
+ def delete_empty_directories(self, changes, revision, dest_root):
+ dirs = {}
+ for change in changes:
+ if change.type[0] in ["D", "R"]:
+ dirs[dirname(change.repo_path)] = dirname(change.dest_path)
+ for d in dirs.keys():
+ retcode = call("git ls-tree %s %s|grep -q ." % (revision, d), shell=True)
+ if (retcode != 0 and normpath(dirs[d]) != normpath(dest_root)):
+ self.rmd(dirs[d])
+
+ def close(self):
+ pass
+
+class FTPSync(Syncer):
+ def __init__(self, ftp):
+ self.ftp = ftp
+
+ def close(self):
+ self.ftp.close()
+
+ def _mkd(self, path):
+ try:
+ self.ftp.mkd(path)
+ except ftplib.error_perm, detail:
+ print >> sys.stderr, "FTP warning:", detail, path
+
+ def _storbinary(self, string, path):
+ #print >> sys.stderr, self.dest_path
+ self.ftp.storbinary("STOR %s"%path, string)
+
+ def _rmd(self, path):
+ try:
+ # FIXME: this should be recursive deletion
+ self.ftp.rmd(path)
+ except ftplib.error_perm, detail:
+ print >> sys.stderr, "FTP warning:", detail
+
+ def _delete(self, path):
+ try:
+ #print >> sys.stderr, path
+ self.ftp.delete(path)
+ except ftplib.error_perm, detail:
+ print >> sys.stderr, "FTP warning:", detail, self.dest_path
+
+
+class LocalSync(Syncer):
+ def _mkd(self, path):
+ try:
+ os.mkdir(path)
+ except OSError, detail:
+ print "warning:", detail
+
+ def _storbinary(self, file, path):
+ f = open(path, 'wb')
+ s = file.read(10000)
+ while s:
+ f.write(s)
+ s = file.read(10000)
+ f.close()
+
+ def _rmd(self, path):
+ """ Delete everything reachable from the directory named in 'self.dest_path',
+ assuming there are no symbolic links."""
+ for root, dirs, files in os.walk(path, topdown=False):
+ for name in files:
+ os.remove(os.path.join(root, name))
+ for name in dirs:
+ os.rmdir(os.path.join(root, name))
+ os.rmdir(path)
+
+ def _delete(self, path):
+ os.remove(path)
+
+def selftest():
+ commands = """
+ set -x -e
+ commit_push() { git commit -q -m "$1"; git push $REPO master; }
+ mkdir repo.git
+ cd repo.git
+ git --bare init
+ echo -e "%s" > hooks/pre-receive
+ chmod +x hooks/pre-receive
+ REPO=$PWD
+ cd ..
+ mkdir www
+ mkdir working
+ cd working
+ git init
+ echo 'abc' > non-mirrored-file
+ git add non-mirrored-file
+ commit_push 'Added non-mirrored-file'
+ mkdir www
+ cd www
+ echo 'Hello' > index.html
+ git add index.html
+ commit_push 'Added index.html'
+ mkdir subdir
+ echo 'asdf' > subdir/subfile.html
+ git add subdir/subfile.html
+ commit_push 'Added subdir'
+
+ git mv subdir subdir2
+ commit_push 'Renamed directory'
+
+ git mv subdir2/subfile.html subdir2/renamed.html
+ commit_push 'Renamed file'
+
+ git rm -r subdir2
+ commit_push 'Removed subdir2'
+ git mv index.html index2.html
+ commit_push 'Renamed file index->index2'
+ cd ..
+ git rm www/index2.html
+ commit_push 'Removed index2'
+ cd $REPO/..
+ rm -rf working repo.git www
+ """
+ selfpath = sys.argv[0]
+
+ print "Checking local sync"
+ hook = "#!/bin/sh\n%s -d %s/www/ -r www" % (selfpath, os.getcwd())
+ ret = os.system(commands % hook)
+ if ret: return
+
+ print "\n\nChecking FTP sync (server: localhost, user: test, password: env. variable PASSWORD)"
+ password = defpass
+ hook = "#!/bin/sh\nexport PASSWORD=%(password)s\n%(selfpath)s -r www -H localhost -u test" % locals()
+ ret = os.system(commands % hook)
+ if ret: return
+
+ print
+ print "Selftest succeeded!"
+
+def build_change_list(changes, oldrev, newrev):
+ # Read changes
+ gitdiff = Popen("/usr/bin/git diff --name-status %s %s"%(oldrev, newrev),
+ stdout=PIPE, stderr=PIPE, close_fds=True, shell=True)
+ change_re = re.compile("(\S+)\s+(.*)$");
+ for line in gitdiff.stdout:
+ #print line,
+ m = change_re.match(line)
+ if (m):
+ change = RepoChange(m.group(1), m.group(2), oldrev, newrev, options)
+ if change.inDir(options.repodir):
+ changes.append(change)
+
+
+
+# Parse command line options
+(options, args) = opt_parser.parse_args()
+
+if len(args) > 0 and args[0] == "selftest":
+ selftest()
+ sys.exit(0)
+
+options.repodir = normpath(options.repodir).lstrip(".")
+if options.repodir: options.repodir+="/"
+
+changes = list()
+
+# Read the changes
+for line in sys.stdin:
+ (oldrev, newrev, refname) = line.split()
+ if refname == "refs/heads/master":
+ build_change_list(changes, oldrev, newrev)
+
+if not changes:
+ sys.exit(0)
+
+# Apply changes
+try:
+ if options.host:
+ syncer = FTPSync(FTP(options.host, options.user, options.password))
+ else:
+ syncer = LocalSync()
+
+ for change in changes:
+ syncer.sync(change, options.dir)
+ syncer.delete_empty_directories(changes, newrev, options.dir)
+
+ syncer.close()
+
+except ftplib.all_errors, detail:
+ print >> sys.stderr, "FTP error: ", detail
+ sys.exit(1)
+
+