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 if (normpath(dirname(change.dest_path)) != normpath(dest_root)):
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)
81 dirs = normpath(dirname(change.dest_path)).split("/")
82 #print "YYYYYYYYYYYYY",dirname(change.dest_path)
86 destdir = os.path.join(destdir, i);
87 #print "XXXXXXXXXXXXX",destdir
88 #self.mkd(dirname(change.dest_path))
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":
98 # Never happens in git
99 self.rmd(change.dest_path)
101 print "%s: DEL "%self.__class__.__name__, change.dest_path
102 self._delete(change.dest_path)
104 perror("Unknown change: %s %s" % (change.type, change.dest_path))
108 print "%s: MKD "%self.__class__.__name__, path
112 print "%s: RMD "%self.__class__.__name__, path
115 def delete_empty_directories(self, changes, revision, dest_root):
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)):
128 class FTPSync(Syncer):
129 def __init__(self, ftp):
135 def _mkd(self, path):
138 except ftplib.error_perm, detail:
139 perror("FTP warning: %s %s" % (detail, path))
141 def _storbinary(self, string, path):
142 #print >> sys.stderr, self.dest_path
143 self.ftp.storbinary("STOR %s"%path, string)
145 def _rmd(self, path):
147 # FIXME: this should be recursive deletion
149 except ftplib.error_perm, detail:
150 perror("FTP warning: %s %s" % (detail, path))
152 def _delete(self, path):
154 #print >> sys.stderr, path
155 self.ftp.delete(path)
156 except ftplib.error_perm, detail:
157 perror("FTP warning: %s %s" % (detail, path))
160 class LocalSync(Syncer):
161 def _mkd(self, path):
164 except OSError, detail:
165 print "warning:", detail
167 def _storbinary(self, file, path):
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):
180 os.remove(os.path.join(root, name))
182 os.rmdir(os.path.join(root, name))
185 def _delete(self, path):
191 commit_push() { git commit -q -m "$1"; git push $REPO master; }
203 # Commit something to www directory in repo without the activated hook
206 echo 'Bye' > www/dir/dir2/first.html
207 git add www/dir/dir2/first.html
208 commit_push 'Added first.html'
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'
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
222 echo 'Hello' > index.html
224 commit_push 'Added index.html'
226 echo 'asdf' > subdir/subfile.html
227 git add subdir/subfile.html
228 commit_push 'Added subdir'
230 git mv subdir subdir2
231 commit_push 'Renamed directory'
233 git mv subdir2/subfile.html subdir2/renamed.html
234 commit_push 'Renamed file'
237 commit_push 'Removed subdir2'
238 git mv index.html index2.html
239 commit_push 'Renamed file index->index2'
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'
246 rm -rf working repo.git www
248 selfpath = sys.argv[0]
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)
255 print "\n\nChecking FTP sync (server: localhost, user: test, password: env. variable PASSWORD)"
257 hook = "#!/bin/sh\nexport PASSWORD=%(password)s\n%(selfpath)s -r www -H localhost -u test" % locals()
258 ret = os.system(commands % hook)
262 print "Selftest succeeded!"
265 print >>sys.stderr, "git-ftp-sync: %s"%str
268 def update_ref(hash):
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")
275 def add_to_change_list(changes, git_command, oldrev, newrev):
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:
282 m = change_re.match(line)
284 change = RepoChange(m.group(1), m.group(2), oldrev, newrev, options)
285 if change.inDir(options.repodir):
286 changes.append(change)
288 # Parse command line options
289 (options, args) = opt_parser.parse_args()
291 if len(args) > 0 and args[0] == "selftest":
295 options.repodir = normpath(options.repodir).lstrip(".")
296 if options.repodir: options.repodir+="/"
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":
307 oldrev=file("refs/remotes/ftp/master").readline().strip();
308 git_command = "/usr/bin/git diff --name-status %s %s"%(oldrev, newrev)
310 # We are run for the first time, so (A)dd all files in the repo.
312 git_command = r"git ls-tree -r --name-only %s | sed -e 's/\(.*\)/A \1/'"%(newrev);
314 add_to_change_list(changes, git_command, oldrev, newrev)
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)
323 perror("No changes to sync")
329 syncer = FTPSync(FTP(options.host, options.user, options.password))
333 for change in changes:
334 syncer.sync(change, options.dir)
335 syncer.delete_empty_directories(changes, newrev, options.dir)
339 except ftplib.all_errors, detail:
340 perror("FTP synchronization error: %s" % detail);
341 perror("I will try it next time again");
344 # If succeessfull, update remote ref