3 # Script for mirroring (a part of) git repository to a FTP site. Only
4 # changed files are copied to FTP. Works with bare repositories.
6 # Author: Michal Sojka <sojkam1@fel.cvut.cz>
9 # Typical usage in hooks/post-receive (or pre-receive if you want push
10 # command to fail in case of error during mirroring):
11 # export PASSWORD=secret
12 # /usr/local/bin/git-ftp-sync -H ftp.example.com -u username -r www
14 # /usr/local/bin/git-ftp-sync -d /var/www/projectweb -r www
16 # The first comamnd line mirrors the content og www directory in
17 # repository to FTP site at ftp.example.com as user "username" with
22 from ftplib import FTP
24 from optparse import OptionParser
25 from subprocess import Popen, PIPE, call
28 from os.path import dirname, normpath
31 defpass = os.environ["PASSWORD"]
32 del os.environ["PASSWORD"]
36 opt_parser = OptionParser(usage="usage: %prog [options] repository revision")
37 opt_parser.add_option("-H", "--host", dest="host",
38 help="FTP server address. If not specified update files localy without FTP.")
39 opt_parser.add_option("-u", "--user", dest="user",
40 help="FTP login name")
41 opt_parser.add_option("-p", "--pass", dest="password", default=defpass,
42 help="FTP password (defaults to environment variable PASSWORD)")
43 opt_parser.add_option("-r", "--repodir", dest="repodir", default="",
44 help="Synchronize only this directory (and its subdirectories) from within a repository")
45 opt_parser.add_option("-d", "--dir", dest="dir", default="",
46 help="An existing directory (on FTP site or localhost), where to store synchronized files.")
49 """Represents one line in git diff --name-status"""
50 def __init__(self, type, path, oldrev, newrev, options):
51 self.type = type.strip() # one of ADMRCUX
53 if path.startswith(options.repodir): # www/something -> something
54 path = path[len(options.repodir):]
55 if options.dir: # something -> prefix/something
56 path = options.dir+"/"+path
57 self.dest_path = normpath(path)
62 return self.repo_path[-1] == "/"
65 """Dir: empty string or / closed directory name(s), without starting . or /"""
66 return self.repo_path.startswith(dir)
69 """Abstract class performing synchronization"""
70 def sync(self, change, dest_root):
71 if change.type[0] == 'A' and change.isDir():
72 # Never happens in git
73 self.mkd(change.dest_path)
74 elif change.type[0] in ["A", "C", "M"]:
75 # Check whether the target directory exists
76 retcode = call("git ls-tree %s %s|grep -q ." % (change.oldrev, dirname(change.repo_path)), shell=True)
77 if (retcode != 0 and normpath(dirname(change.dest_path)) != normpath(dest_root)):
78 self.mkd(dirname(change.dest_path))
80 print "%s: UPLOAD"%self.__class__.__name__, change.dest_path
81 pipe = Popen("git cat-file blob %s:%s" % (change.newrev, change.repo_path),
82 stdout=PIPE, shell=True)
83 self._storbinary(pipe.stdout, change.dest_path)
84 elif change.type[0] == "D":
86 # Never happens in git
87 self.rmd(change.dest_path)
89 print "%s: DEL "%self.__class__.__name__, change.dest_path
90 self._delete(change.dest_path)
92 print >> sys.stderr, "Unknown change:", change.type, change.dest_path
96 print "%s: MKD "%self.__class__.__name__, path
100 print "%s: RMD "%self.__class__.__name__, path
103 def delete_empty_directories(self, changes, revision, dest_root):
105 for change in changes:
106 if change.type[0] in ["D", "R"]:
107 dirs[dirname(change.repo_path)] = dirname(change.dest_path)
108 for d in dirs.keys():
109 retcode = call("git ls-tree %s %s|grep -q ." % (revision, d), shell=True)
110 if (retcode != 0 and normpath(dirs[d]) != normpath(dest_root)):
116 class FTPSync(Syncer):
117 def __init__(self, ftp):
123 def _mkd(self, path):
126 except ftplib.error_perm, detail:
127 print >> sys.stderr, "FTP warning:", detail, path
129 def _storbinary(self, string, path):
130 #print >> sys.stderr, self.dest_path
131 self.ftp.storbinary("STOR %s"%path, string)
133 def _rmd(self, path):
135 # FIXME: this should be recursive deletion
137 except ftplib.error_perm, detail:
138 print >> sys.stderr, "FTP warning:", detail, path
140 def _delete(self, path):
142 #print >> sys.stderr, path
143 self.ftp.delete(path)
144 except ftplib.error_perm, detail:
145 print >> sys.stderr, "FTP warning:", detail, path
148 class LocalSync(Syncer):
149 def _mkd(self, path):
152 except OSError, detail:
153 print "warning:", detail
155 def _storbinary(self, file, path):
163 def _rmd(self, path):
164 """ Delete everything reachable from the directory named in 'self.dest_path',
165 assuming there are no symbolic links."""
166 for root, dirs, files in os.walk(path, topdown=False):
168 os.remove(os.path.join(root, name))
170 os.rmdir(os.path.join(root, name))
173 def _delete(self, path):
179 commit_push() { git commit -q -m "$1"; git push $REPO master; }
183 echo -e "%s" > hooks/pre-receive
184 chmod +x hooks/pre-receive
191 echo 'abc' > non-mirrored-file
192 git add non-mirrored-file
193 commit_push 'Added non-mirrored-file'
196 echo 'Hello' > index.html
198 commit_push 'Added index.html'
200 echo 'asdf' > subdir/subfile.html
201 git add subdir/subfile.html
202 commit_push 'Added subdir'
204 git mv subdir subdir2
205 commit_push 'Renamed directory'
207 git mv subdir2/subfile.html subdir2/renamed.html
208 commit_push 'Renamed file'
211 commit_push 'Removed subdir2'
212 git mv index.html index2.html
213 commit_push 'Renamed file index->index2'
215 git rm www/index2.html
216 commit_push 'Removed index2'
218 rm -rf working repo.git www
220 selfpath = sys.argv[0]
222 print "Checking local sync"
223 hook = "#!/bin/sh\n%s -d %s/www/ -r www" % (selfpath, os.getcwd())
224 ret = os.system(commands % hook)
227 print "\n\nChecking FTP sync (server: localhost, user: test, password: env. variable PASSWORD)"
229 hook = "#!/bin/sh\nexport PASSWORD=%(password)s\n%(selfpath)s -r www -H localhost -u test" % locals()
230 ret = os.system(commands % hook)
234 print "Selftest succeeded!"
236 def update_ref(hash):
239 if (not os.path.exists("refs/remotes/"+remote)):
240 os.makedirs("refs/remotes/"+remote)
241 file("refs/remotes/"+remote+"/"+branch, "w").write(newrev+"\n")
243 def build_change_list(changes, oldrev, newrev):
245 gitdiff = Popen("/usr/bin/git diff --name-status %s %s"%(oldrev, newrev),
246 stdout=PIPE, stderr=PIPE, close_fds=True, shell=True)
247 change_re = re.compile("(\S+)\s+(.*)$");
248 for line in gitdiff.stdout:
250 m = change_re.match(line)
252 change = RepoChange(m.group(1), m.group(2), oldrev, newrev, options)
253 if change.inDir(options.repodir):
254 changes.append(change)
258 # Parse command line options
259 (options, args) = opt_parser.parse_args()
261 if len(args) > 0 and args[0] == "selftest":
265 options.repodir = normpath(options.repodir).lstrip(".")
266 if options.repodir: options.repodir+="/"
271 for line in sys.stdin:
272 (oldrev, newrev, refname) = line.split()
273 if refname == "refs/heads/master":
275 oldref=file("refs/remotes/ftp/master").readline().strip();
278 build_change_list(changes, oldrev, newrev)
286 syncer = FTPSync(FTP(options.host, options.user, options.password))
290 for change in changes:
291 syncer.sync(change, options.dir)
292 syncer.delete_empty_directories(changes, newrev, options.dir)
296 except ftplib.all_errors, detail:
297 print >> sys.stderr, "FTP synchronization error: ", detail
298 print >> sys.stderr, "I will try it next time again"
301 # If succeessfull, update remote ref