]> rtime.felk.cvut.cz Git - git-ftp-sync.git/blob - git-ftp-sync
New directories are created recursively on target
[git-ftp-sync.git] / git-ftp-sync
1 #!/usr/bin/env python
2
3 # Script for mirroring (a part of) git repository to an FTP site. Only
4 # changed files are copied to FTP. Works with bare repositories.
5 #
6 # Author: Michal Sojka <sojkam1@fel.cvut.cz>
7 # License: GPL
8
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
13 # or
14 #     /usr/local/bin/git-ftp-sync -d /var/www/projectweb -r www
15
16 # The first comamnd line mirrors the content of www directory in
17 # repository to FTP site at ftp.example.com as user "username" with
18 # password "secret".
19
20
21 import sys
22 from ftplib import FTP
23 import ftplib
24 from optparse import OptionParser
25 from subprocess import Popen, PIPE, call
26 import re
27 import os
28 from os.path import dirname, normpath
29
30 try:
31     defpass = os.environ["PASSWORD"]
32     del os.environ["PASSWORD"]
33 except KeyError:
34     defpass = ""
35
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.")
47
48 class RepoChange:
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
52         self.repo_path = path
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)
58         self.oldrev = oldrev
59         self.newrev = newrev
60         
61     def isDir(self):
62         return self.repo_path[-1] == "/"
63     
64     def inDir(self, dir):
65         """Dir: empty string or / closed directory name(s), without starting . or /"""
66         return self.repo_path.startswith(dir)
67
68 class Syncer:
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             if (normpath(dirname(change.dest_path)) != normpath(dest_root)):
77                 retcode = 1
78                 if (change.oldrev):         # If there is previous revision, check for it
79                     retcode = call("git ls-tree %s %s|grep -q ." % (change.oldrev, dirname(change.repo_path)), shell=True)
80                 if (retcode != 0):
81                     dirs = normpath(dirname(change.dest_path)).split("/")
82                     #print "YYYYYYYYYYYYY",dirname(change.dest_path)
83                     destdir = ""
84                     for i in dirs:
85                         if (i==""): i="/"
86                         destdir = os.path.join(destdir, i);
87                         #print "XXXXXXXXXXXXX",destdir
88                         #self.mkd(dirname(change.dest_path))
89                         self.mkd(destdir)
90                 
91             # Upload the file
92             print "%s: UPLOAD"%self.__class__.__name__, change.dest_path
93             pipe = Popen("git cat-file blob %s:%s" % (change.newrev, change.repo_path),
94                          stdout=PIPE, shell=True)
95             self._storbinary(pipe.stdout, change.dest_path)
96         elif change.type[0] == "D":
97             if change.isDir():
98                 # Never happens in git
99                 self.rmd(change.dest_path)
100             else:
101                 print "%s: DEL   "%self.__class__.__name__, change.dest_path
102                 self._delete(change.dest_path)
103         else:
104             perror("Unknown change: %s %s" % (change.type, change.dest_path))
105             sys.exit(1)
106
107     def mkd(self, path):
108         print "%s: MKD   "%self.__class__.__name__, path
109         self._mkd(path)
110
111     def rmd(self, path):
112         print "%s: RMD   "%self.__class__.__name__, path
113         self._rmd(path);
114         
115     def delete_empty_directories(self, changes, revision, dest_root):
116         dirs = {}
117         for change in changes:
118             if change.type[0] in ["D", "R"]:
119                 dirs[dirname(change.repo_path)] = dirname(change.dest_path)
120         for d in dirs.keys():
121             retcode = call("git ls-tree %s %s|grep -q ." % (revision, d), shell=True)
122             if (retcode != 0 and normpath(dirs[d]) != normpath(dest_root)):
123                 self.rmd(dirs[d])
124             
125     def close(self):
126         pass
127
128 class FTPSync(Syncer):
129     def __init__(self, ftp):
130         self.ftp = ftp
131
132     def close(self):
133         self.ftp.close()
134
135     def _mkd(self, path):
136         try:
137             self.ftp.mkd(path)
138         except ftplib.error_perm, detail:
139             perror("FTP warning: %s %s" % (detail, path))
140
141     def _storbinary(self, string, path):
142         #print >> sys.stderr, self.dest_path
143         self.ftp.storbinary("STOR %s"%path, string)
144
145     def _rmd(self, path):
146         try:
147             # FIXME: this should be recursive deletion
148             self.ftp.rmd(path)
149         except ftplib.error_perm, detail:
150             perror("FTP warning: %s %s" % (detail, path))
151
152     def _delete(self, path):
153         try:
154             #print >> sys.stderr, path
155             self.ftp.delete(path)
156         except ftplib.error_perm, detail:
157             perror("FTP warning: %s %s" % (detail, path))
158
159
160 class LocalSync(Syncer):
161     def _mkd(self, path):
162         try:
163             os.mkdir(path)
164         except OSError, detail:
165             print "warning:", detail
166             
167     def _storbinary(self, file, path):
168         f = open(path, 'wb')
169         s = file.read(10000)
170         while s:
171             f.write(s)
172             s = file.read(10000)
173         f.close()
174
175     def _rmd(self, path):
176         """ Delete everything reachable from the directory named in 'self.dest_path',
177         assuming there are no symbolic links."""
178         for root, dirs, files in os.walk(path, topdown=False):
179             for name in files:
180                 os.remove(os.path.join(root, name))
181             for name in dirs:
182                 os.rmdir(os.path.join(root, name))
183         os.rmdir(path)
184
185     def _delete(self, path):
186         os.remove(path)
187         
188 def selftest():
189     commands = """
190     set -x -e
191     commit_push() { git commit -q -m "$1"; git push $REPO master; }
192     mkdir repo.git
193     cd repo.git
194     git --bare init
195     REPO=$PWD
196     cd ..
197     mkdir www
198     mkdir working
199     cd working
200     git init
201     mkdir www
202
203     # Commit something to www directory in repo without the activated hook
204     mkdir www/dir
205     mkdir www/dir/dir2
206     echo 'Bye' > www/dir/dir2/first.html
207     git add www/dir/dir2/first.html
208     commit_push 'Added first.html'
209     
210     # Activate the hook and commit a non-mirrored file
211     echo "%s" > $REPO/hooks/post-receive
212     chmod +x $REPO/hooks/post-receive
213     echo 'abc' > non-mirrored-file
214     git add non-mirrored-file
215     commit_push 'Added non-mirrored-file'
216
217     # Check that the first commit appears in www even if the hook was
218     # activated later (only for local sync)
219     grep -q PASSWORD $REPO/hooks/post-receive || test -f ../www/dir/dir2/first.html
220     
221     cd www
222     echo 'Hello' > index.html
223     git add index.html
224     commit_push 'Added index.html'
225     mkdir subdir
226     echo 'asdf' > subdir/subfile.html
227     git add subdir/subfile.html
228     commit_push 'Added subdir'
229
230     git mv subdir subdir2
231     commit_push 'Renamed directory'
232
233     git mv subdir2/subfile.html subdir2/renamed.html
234     commit_push 'Renamed file'
235
236     git rm -r subdir2
237     commit_push 'Removed subdir2'
238     git mv index.html index2.html
239     commit_push 'Renamed file index->index2'
240     cd ..
241     git rm www/index2.html
242     commit_push 'Removed index2'
243     git rm www/dir/dir2/first.html
244     commit_push 'Removed first.html'
245     cd $REPO/..
246     rm -rf working repo.git www    
247     """
248     selfpath = sys.argv[0]
249
250     print "Checking local sync"
251     hook = "#!/bin/sh\n%s -d %s/www/ -r www" % (selfpath, os.getcwd())
252     ret = os.system(commands % hook)
253     if ret: return
254
255     print "\n\nChecking FTP sync (server: localhost, user: test, password: env. variable PASSWORD)"
256     password = defpass
257     hook = "#!/bin/sh\nexport PASSWORD=%(password)s\n%(selfpath)s -r www -H localhost -u test" % locals()
258     ret = os.system(commands % hook)
259     if ret: return
260
261     print
262     print "Selftest succeeded!"
263
264 def perror(str):    
265     print >>sys.stderr, "git-ftp-sync: %s"%str
266
267
268 def update_ref(hash):
269     remote = "ftp"
270     branch = "master"
271     if (not os.path.exists("refs/remotes/"+remote)):
272         os.makedirs("refs/remotes/"+remote)
273     file("refs/remotes/"+remote+"/"+branch, "w").write(newrev+"\n")
274
275 def add_to_change_list(changes, git_command, oldrev, newrev):
276     # Read changes
277     gitdiff = Popen(git_command,
278                     stdout=PIPE, stderr=PIPE, close_fds=True, shell=True)
279     change_re = re.compile("(\S+)\s+(.*)$");
280     for line in gitdiff.stdout:
281         #print line,
282         m = change_re.match(line)
283         if (m):
284             change = RepoChange(m.group(1), m.group(2), oldrev, newrev, options)
285             if change.inDir(options.repodir):
286                 changes.append(change)
287
288 # Parse command line options
289 (options, args) = opt_parser.parse_args()
290
291 if len(args) > 0 and args[0] == "selftest":
292     selftest()
293     sys.exit(0)
294
295 options.repodir = normpath(options.repodir).lstrip(".")
296 if options.repodir: options.repodir+="/"
297
298 changes = list()
299
300 # Read the changes
301 if 'GIT_DIR' in os.environ:
302     # Invocation from hook
303     for line in sys.stdin:
304         (oldrev, newrev, refname) = line.split()
305         if refname == "refs/heads/master":
306             try:
307                 oldrev=file("refs/remotes/ftp/master").readline().strip();
308                 git_command = "/usr/bin/git diff --name-status %s %s"%(oldrev, newrev)
309             except IOError:
310                 # We are run for the first time, so (A)dd all files in the repo.
311                 oldrev = None;
312                 git_command = r"git ls-tree -r --name-only %s | sed -e 's/\(.*\)/A \1/'"%(newrev);
313
314         add_to_change_list(changes, git_command, oldrev, newrev)
315 else:
316     # Manual invocation
317     newrev = "HEAD"
318     oldrev = None;
319     git_command = r"git ls-tree -r --name-only HEAD | sed -e 's/\(.*\)/A \1/'";
320     add_to_change_list(changes, git_command, oldrev, newrev)
321         
322 if not changes:
323     perror("No changes to sync")
324     sys.exit(0)
325
326 # Apply changes
327 try:
328     if options.host:
329         syncer = FTPSync(FTP(options.host, options.user, options.password))
330     else:
331         syncer = LocalSync()
332
333     for change in changes:
334         syncer.sync(change, options.dir)
335     syncer.delete_empty_directories(changes, newrev, options.dir)
336
337     syncer.close()
338
339 except ftplib.all_errors, detail:
340     perror("FTP synchronization error: %s" % detail);
341     perror("I will try it next time again");
342     sys.exit(1)
343
344 # If succeessfull, update remote ref
345 update_ref(newrev)