#!/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 from urlparse import urlparse 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("-U", "--url", dest="url", default="", help="Destination URL (available protocols: sftp, ftp, file). Depricates -H, -u and -d.") 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("-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("-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, destroot=None): 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 destroot: # something -> prefix/something path = destroot+"/"+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 if (normpath(dirname(change.dest_path)) != normpath(dest_root)): retcode = 1 if (change.oldrev): # If there is previous revision, check for it retcode = call("git ls-tree %s %s|grep -q ." % (change.oldrev, dirname(change.repo_path)), shell=True) if (retcode != 0): dirs = normpath(dirname(change.dest_path)).split("/") #print "YYYYYYYYYYYYY",dirname(change.dest_path) destdir = "" for i in dirs: if (i==""): i="/" destdir = os.path.join(destdir, i); #print "XXXXXXXXXXXXX",destdir #self.mkd(dirname(change.dest_path)) self.mkd(destdir) # 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 SFTPSync(Syncer): def __init__(self, url): import paramiko # get host key, if we know one hostkeytype = None hostkey = None try: host_keys = paramiko.util.load_host_keys(os.path.expanduser('~/.ssh/known_hosts')) except IOError: try: # try ~/ssh/ too, because windows can't have a folder named ~/.ssh/ host_keys = paramiko.util.load_host_keys(os.path.expanduser('~/ssh/known_hosts')) except IOError: print '*** Unable to open host keys file' host_keys = {} if host_keys.has_key(url.hostname): hostkeytype = host_keys[url.hostname].keys()[0] hostkey = host_keys[url.hostname][hostkeytype] print 'Using host key of type %s' % hostkeytype # now, connect and use paramiko Transport to negotiate SSH2 across the connection port = 22 if url.port: port = url.port self.t = paramiko.Transport((url.hostname, port)) password = url.password if not password: password = options.password self.t.connect(username=url.username, password=password, hostkey=hostkey) self.sftp = paramiko.SFTPClient.from_transport(self.t) def close(self): self.sftp.close() self.t.close() def _mkd(self, path): try: self.sftp.mkdir(path) except IOError, detail: print "sftp warning:", detail def _storbinary(self, file, path): remote = self.sftp.open(path, 'w') s = file.read(10000) while s: remote.write(s) s = file.read(10000) remote.close() def _rmd(self, path): """ Delete everything reachable from the directory named in 'self.dest_path', assuming there are no symbolic links.""" self.sftp.rmdir(path) # FIXME: this should be recursive deletion def _delete(self, path): self.sftp.remove(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 mkdir www/dir mkdir www/dir/dir2 echo 'Bye' > www/dir/dir2/first.html git add www/dir/dir2/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/dir/dir2/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/dir/dir2/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 "\n\nChecking SFTP sync (server: localhost, user: test, password: env. variable PASSWORD)" password = defpass hook = """#!/bin/sh\nexport PASSWORD=%(password)s\n%(selfpath)s -r www --url sftp://test@localhost""" % 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): cmd = "git update-ref refs/remotes/ftp/master %s"%(hash) #print "Runnging", cmd os.system(cmd) def add_to_change_list(changes, git_command, oldrev, newrev, destroot): # Read changes ##print "Running: ", git_command 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, destroot) 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+="/" if options.host: url = urlparse("ftp://"+options.user+"@"+options.host+"/"+options.dir) elif options.dir and not options.url: url = urlparse("file:///"+options.dir) else: url = urlparse(options.url) changes = list() # Read the changes if 'GIT_DIR' in os.environ: # Invocation from hook for line in sys.stdin: (oldrev, newrev, refname) = line.split() if refname == "refs/heads/master": try: oldrev=os.popen('git show-ref --hash refs/remotes/ftp/master').read().strip() if not oldrev: raise IOError, "No ref" # Simulate failure if the branch doesn't exist git_command = "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. oldrev = None git_command = r"git ls-tree -r --name-only %s | sed -e 's/\(.*\)/A \1/'"%(newrev); add_to_change_list(changes, git_command, oldrev, newrev, url.path) else: # Manual invocation newrev = "HEAD" oldrev = None; git_command = r"git ls-tree -r --name-only HEAD | sed -e 's/\(.*\)/A \1/'"; add_to_change_list(changes, git_command, oldrev, newrev) if not changes: perror("No changes to sync") sys.exit(0) # Apply changes try: if url.scheme == "ftp": syncer = FTPSync(FTP(options.host, options.user, options.password)) elif url.scheme == "file": syncer = LocalSync() elif url.scheme == "sftp": syncer = SFTPSync(url) 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) except: raise # If succeessfull, update remote ref update_ref(newrev)