#!/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 # 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, path def _delete(self, path): try: #print >> sys.stderr, path self.ftp.delete(path) except ftplib.error_perm, detail: print >> sys.stderr, "FTP warning:", detail, 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 update_ref(hash): remote = "ftp" branch = "master" if (not os.path.exists("refs/remotes/"+remote)): os.makedirs("refs/remotes/"+remote) file("refs/remotes/"+remote+"/"+branch, "w").write(newrev+"\n") 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": try: oldref=file("refs/remotes/ftp/master").readline().strip(); except IOError: pass 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 synchronization error: ", detail print >> sys.stderr, "I will try it next time again" sys.exit(1) # If succeessfull, update remote ref update_ref(newrev)