]> rtime.felk.cvut.cz Git - git-ftp-sync.git/blob - git-ftp-sync
cf5cf118ba978123c6a42e6c3779acd4cda234aa
[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             print >> sys.stderr, "Unknown change:", 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             print >> sys.stderr, "FTP warning:", 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             print >> sys.stderr, "FTP warning:", 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             print >> sys.stderr, "FTP warning:", 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 update_ref(hash):
237     remote = "ftp"
238     branch = "master"
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")
242
243 def build_change_list(changes, oldrev, newrev):
244     # Read changes
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:
249         #print line,
250         m = change_re.match(line)
251         if (m):
252             change = RepoChange(m.group(1), m.group(2), oldrev, newrev, options)
253             if change.inDir(options.repodir):
254                 changes.append(change)
255
256
257
258 # Parse command line options
259 (options, args) = opt_parser.parse_args()
260
261 if len(args) > 0 and args[0] == "selftest":
262     selftest()
263     sys.exit(0)
264
265 options.repodir = normpath(options.repodir).lstrip(".")
266 if options.repodir: options.repodir+="/"
267
268 changes = list()
269
270 # Read the changes
271 for line in sys.stdin:
272     (oldrev, newrev, refname) = line.split()
273     if refname == "refs/heads/master":
274         try:
275             oldref=file("refs/remotes/ftp/master").readline().strip();
276         except IOError:
277             pass
278         build_change_list(changes, oldrev, newrev)
279
280 if not changes:
281     sys.exit(0)
282
283 # Apply changes
284 try:
285     if options.host:
286         syncer = FTPSync(FTP(options.host, options.user, options.password))
287     else:
288         syncer = LocalSync()
289
290     for change in changes:
291         syncer.sync(change, options.dir)
292     syncer.delete_empty_directories(changes, newrev, options.dir)
293
294     syncer.close()
295
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"
299     sys.exit(1)
300
301 # If succeessfull, update remote ref
302 update_ref(newrev)