#!/usr/bin/env python # Script for mirroring (a part of) git repository to an 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 of 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: perror("Unknown change: %s %s" % (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: perror("FTP warning: %s %s" % (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: perror("FTP warning: %s %s" % (detail, path)) def _delete(self, path): try: #print >> sys.stderr, path self.ftp.delete(path) except ftplib.error_perm, detail: perror("FTP warning: %s %s" % (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 REPO=$PWD cd .. mkdir www mkdir working cd working git init mkdir www # Commit something to www directory in repo without the activated hook echo 'Bye' > www/first.html git add www/first.html commit_push 'Added first.html' # Activate the hook and commit a non-mirrored file echo "%s" > $REPO/hooks/post-receive chmod +x $REPO/hooks/post-receive echo 'abc' > non-mirrored-file git add non-mirrored-file commit_push 'Added non-mirrored-file' # Check that the first commit appears in www even if the hook was # activated later (only for local sync) grep -q PASSWORD $REPO/hooks/post-receive || test -f ../www/first.html 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' git rm www/first.html commit_push 'Removed first.html' 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 perror(str): print >>sys.stderr, "git-ftp-sync: %s"%str 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 add_to_change_list(changes, git_command): # Read changes gitdiff = Popen(git_command, 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: oldrev=file("refs/remotes/ftp/master").readline().strip(); git_command = "/usr/bin/git diff --name-status %s %s"%(oldrev, newrev) except IOError: # We are run for the first time, so (A)dd all files in the repo. git_command = r"git ls-tree -r --name-only %s | sed -e 's/\(.*\)/A \1/'"%(newrev); add_to_change_list(changes, git_command) if not changes: perror("No changes to sync") 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: perror("FTP synchronization error: %s" % detail); perror("I will try it next time again"); sys.exit(1) # If succeessfull, update remote ref update_ref(newrev)