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.
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 og 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