]> rtime.felk.cvut.cz Git - git-ftp-sync.git/commitdiff
Added git-ftp-sync
authorMichal Sojka <sojkam1@fel.cvut.cz>
Thu, 6 Mar 2008 10:11:00 +0000 (10:11 +0000)
committerMichal Sojka <sojkam1@fel.cvut.cz>
Tue, 23 Sep 2008 14:34:54 +0000 (16:34 +0200)
darcs-hash:20080306101108-f2ef6-e6346a2efdb298cc39c50237f1b7d45068511d11.gz

git-ftp-sync [new file with mode: 0755]

diff --git a/git-ftp-sync b/git-ftp-sync
new file mode 100755 (executable)
index 0000000..5d40c9d
--- /dev/null
@@ -0,0 +1,289 @@
+#!/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)
+    
+