]> rtime.felk.cvut.cz Git - git-ftp-sync.git/blob - git-ftp-sync
cd570ff0bf67fa00a7bd367e9854f4439916a3e4
[git-ftp-sync.git] / git-ftp-sync
1 #!/usr/bin/env python
2
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.
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 og 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             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))
79             # Upload the file
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":
85             if change.isDir():
86                 # Never happens in git
87                 self.rmd(change.dest_path)
88             else:
89                 print "%s: DEL   "%self.__class__.__name__, change.dest_path
90                 self._delete(change.dest_path)
91         else:
92             perror("Unknown change: %s %s" % (change.type, change.dest_path))
93             sys.exit(1)
94
95     def mkd(self, path):
96         print "%s: MKD   "%self.__class__.__name__, path
97         self._mkd(path)
98
99     def rmd(self, path):
100         print "%s: RMD   "%self.__class__.__name__, path
101         self._rmd(path);
102         
103     def delete_empty_directories(self, changes, revision, dest_root):
104         dirs = {}
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)):
111                 self.rmd(dirs[d])
112             
113     def close(self):
114         pass
115
116 class FTPSync(Syncer):
117     def __init__(self, ftp):
118         self.ftp = ftp
119
120     def close(self):
121         self.ftp.close()
122
123     def _mkd(self, path):
124         try:
125             self.ftp.mkd(path)
126         except ftplib.error_perm, detail:
127             perror("FTP warning: %s %s" % (detail, path))
128
129     def _storbinary(self, string, path):
130         #print >> sys.stderr, self.dest_path
131         self.ftp.storbinary("STOR %s"%path, string)
132
133     def _rmd(self, path):
134         try:
135             # FIXME: this should be recursive deletion
136             self.ftp.rmd(path)
137         except ftplib.error_perm, detail:
138             perror("FTP warning: %s %s" % (detail, path))
139
140     def _delete(self, path):
141         try:
142             #print >> sys.stderr, path
143             self.ftp.delete(path)
144         except ftplib.error_perm, detail:
145             perror("FTP warning: %s %s" % (detail, path))
146
147
148 class LocalSync(Syncer):
149     def _mkd(self, path):
150         try:
151             os.mkdir(path)
152         except OSError, detail:
153             print "warning:", detail
154             
155     def _storbinary(self, file, path):
156         f = open(path, 'wb')
157         s = file.read(10000)
158         while s:
159             f.write(s)
160             s = file.read(10000)
161         f.close()
162
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):
167             for name in files:
168                 os.remove(os.path.join(root, name))
169             for name in dirs:
170                 os.rmdir(os.path.join(root, name))
171         os.rmdir(path)
172
173     def _delete(self, path):
174         os.remove(path)
175         
176 def selftest():
177     commands = """
178     set -x -e
179     commit_push() { git commit -q -m "$1"; git push $REPO master; }
180     mkdir repo.git
181     cd repo.git
182     git --bare init
183     echo -e "%s" > hooks/pre-receive
184     chmod +x hooks/pre-receive
185     REPO=$PWD
186     cd ..
187     mkdir www
188     mkdir working
189     cd working
190     git init
191     echo 'abc' > non-mirrored-file
192     git add non-mirrored-file
193     commit_push 'Added non-mirrored-file'
194     mkdir www
195     cd www
196     echo 'Hello' > index.html
197     git add index.html
198     commit_push 'Added index.html'
199     mkdir subdir
200     echo 'asdf' > subdir/subfile.html
201     git add subdir/subfile.html
202     commit_push 'Added subdir'
203
204     git mv subdir subdir2
205     commit_push 'Renamed directory'
206
207     git mv subdir2/subfile.html subdir2/renamed.html
208     commit_push 'Renamed file'
209
210     git rm -r subdir2
211     commit_push 'Removed subdir2'
212     git mv index.html index2.html
213     commit_push 'Renamed file index->index2'
214     cd ..
215     git rm www/index2.html
216     commit_push 'Removed index2'
217     cd $REPO/..
218     rm -rf working repo.git www    
219     """
220     selfpath = sys.argv[0]
221
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)
225     if ret: return
226
227     print "\n\nChecking FTP sync (server: localhost, user: test, password: env. variable PASSWORD)"
228     password = defpass
229     hook = "#!/bin/sh\nexport PASSWORD=%(password)s\n%(selfpath)s -r www -H localhost -u test" % locals()
230     ret = os.system(commands % hook)
231     if ret: return
232
233     print
234     print "Selftest succeeded!"
235
236 def perror(str):    
237     print >>sys.stderr, "git-ftp-sync: %s"%str
238
239
240 def update_ref(hash):
241     remote = "ftp"
242     branch = "master"
243     if (not os.path.exists("refs/remotes/"+remote)):
244         os.makedirs("refs/remotes/"+remote)
245     file("refs/remotes/"+remote+"/"+branch, "w").write(newrev+"\n")
246
247 def build_change_list(changes, oldrev, newrev):
248     # Read changes
249     gitdiff = Popen("/usr/bin/git diff --name-status %s %s"%(oldrev, newrev),
250                     stdout=PIPE, stderr=PIPE, close_fds=True, shell=True)
251     change_re = re.compile("(\S+)\s+(.*)$");
252     for line in gitdiff.stdout:
253         #print line,
254         m = change_re.match(line)
255         if (m):
256             change = RepoChange(m.group(1), m.group(2), oldrev, newrev, options)
257             if change.inDir(options.repodir):
258                 changes.append(change)
259
260
261
262 # Parse command line options
263 (options, args) = opt_parser.parse_args()
264
265 if len(args) > 0 and args[0] == "selftest":
266     selftest()
267     sys.exit(0)
268
269 options.repodir = normpath(options.repodir).lstrip(".")
270 if options.repodir: options.repodir+="/"
271
272 changes = list()
273
274 # Read the changes
275 for line in sys.stdin:
276     (oldrev, newrev, refname) = line.split()
277     if refname == "refs/heads/master":
278         try:
279             oldrev=file("refs/remotes/ftp/master").readline().strip();
280         except IOError:
281             pass
282         build_change_list(changes, oldrev, newrev)
283
284 if not changes:
285     perror("No changes to sync")
286     sys.exit(0)
287
288 # Apply changes
289 try:
290     if options.host:
291         syncer = FTPSync(FTP(options.host, options.user, options.password))
292     else:
293         syncer = LocalSync()
294
295     for change in changes:
296         syncer.sync(change, options.dir)
297     syncer.delete_empty_directories(changes, newrev, options.dir)
298
299     syncer.close()
300
301 except ftplib.all_errors, detail:
302     perror("FTP synchronization error: %s" % detail);
303     perror("I will try it next time again");
304     sys.exit(1)
305
306 # If succeessfull, update remote ref
307 update_ref(newrev)