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.
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 of 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 perror("Unknown change: %s %s" % (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 perror("FTP warning: %s %s" % (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 perror("FTP warning: %s %s" % (detail, path))
140 def _delete(self, path):
142 #print >> sys.stderr, path
143 self.ftp.delete(path)
144 except ftplib.error_perm, detail:
145 perror("FTP warning: %s %s" % (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; }
191 # Commit something to www directory in repo without the activated hook
192 echo 'Bye' > www/first.html
193 git add www/first.html
194 commit_push 'Added first.html'
196 # Activate the hook and commit a non-mirrored file
197 echo "%s" > $REPO/hooks/post-receive
198 chmod +x $REPO/hooks/post-receive
199 echo 'abc' > non-mirrored-file
200 git add non-mirrored-file
201 commit_push 'Added non-mirrored-file'
203 # Check that the first commit appears in www even if the hook was
204 # activated later (only for local sync)
205 grep -q PASSWORD $REPO/hooks/post-receive || test -f ../www/first.html
208 echo 'Hello' > index.html
210 commit_push 'Added index.html'
212 echo 'asdf' > subdir/subfile.html
213 git add subdir/subfile.html
214 commit_push 'Added subdir'
216 git mv subdir subdir2
217 commit_push 'Renamed directory'
219 git mv subdir2/subfile.html subdir2/renamed.html
220 commit_push 'Renamed file'
223 commit_push 'Removed subdir2'
224 git mv index.html index2.html
225 commit_push 'Renamed file index->index2'
227 git rm www/index2.html
228 commit_push 'Removed index2'
229 git rm www/first.html
230 commit_push 'Removed first.html'
232 rm -rf working repo.git www
234 selfpath = sys.argv[0]
236 print "Checking local sync"
237 hook = "#!/bin/sh\n%s -d %s/www/ -r www" % (selfpath, os.getcwd())
238 ret = os.system(commands % hook)
241 print "\n\nChecking FTP sync (server: localhost, user: test, password: env. variable PASSWORD)"
243 hook = "#!/bin/sh\nexport PASSWORD=%(password)s\n%(selfpath)s -r www -H localhost -u test" % locals()
244 ret = os.system(commands % hook)
248 print "Selftest succeeded!"
251 print >>sys.stderr, "git-ftp-sync: %s"%str
254 def update_ref(hash):
257 if (not os.path.exists("refs/remotes/"+remote)):
258 os.makedirs("refs/remotes/"+remote)
259 file("refs/remotes/"+remote+"/"+branch, "w").write(newrev+"\n")
261 def add_to_change_list(changes, git_command):
263 gitdiff = Popen(git_command,
264 stdout=PIPE, stderr=PIPE, close_fds=True, shell=True)
265 change_re = re.compile("(\S+)\s+(.*)$");
266 for line in gitdiff.stdout:
268 m = change_re.match(line)
270 change = RepoChange(m.group(1), m.group(2), oldrev, newrev, options)
271 if change.inDir(options.repodir):
272 changes.append(change)
274 # Parse command line options
275 (options, args) = opt_parser.parse_args()
277 if len(args) > 0 and args[0] == "selftest":
281 options.repodir = normpath(options.repodir).lstrip(".")
282 if options.repodir: options.repodir+="/"
287 for line in sys.stdin:
288 (oldrev, newrev, refname) = line.split()
289 if refname == "refs/heads/master":
291 oldrev=file("refs/remotes/ftp/master").readline().strip();
292 git_command = "/usr/bin/git diff --name-status %s %s"%(oldrev, newrev)
294 # We are run for the first time, so (A)dd all files in the repo.
295 git_command = r"git ls-tree -r --name-only %s | sed -e 's/\(.*\)/A \1/'"%(newrev);
297 add_to_change_list(changes, git_command)
300 perror("No changes to sync")
306 syncer = FTPSync(FTP(options.host, options.user, options.password))
310 for change in changes:
311 syncer.sync(change, options.dir)
312 syncer.delete_empty_directories(changes, newrev, options.dir)
316 except ftplib.all_errors, detail:
317 perror("FTP synchronization error: %s" % detail);
318 perror("I will try it next time again");
321 # If succeessfull, update remote ref