gallery.py: Keyword text argument the directory-header crumbs
[blog.git] / posts / Git / git-publish.py
1 #!/usr/bin/env python
2 #
3 # Copyright (C) 2010-2011 W. Trevor King <wking@drexel.edu>
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Lesser General Public License as
7 # published by the Free Software Foundation, either version 3 of the
8 # License, or (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful, but
11 # WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13 # Lesser General Public License for more details.
14 #
15 # You should have received a copy of the GNU Lesser General Public
16 # License along with this program.  If not, see
17 # <http://www.gnu.org/licenses/>.
18
19 """Publish a local Git repository on a remote host.
20
21 This script wraps my usual workflow so I don't have to remember it ;).
22 """
23
24 import logging as _logging
25 import os as _os
26 import os.path as _os_path
27 import shutil as _shutil
28 import subprocess as _subprocess
29 import tempfile as _tempfile
30 try:  # Python 3
31     import urllib.parse as _urllib_parse
32 except ImportError:  # Python 2
33     import urlparse as _urllib_parse
34
35
36 __version__ = '0.3'
37
38 _LOG = _logging.getLogger('git-publish')
39 _LOG.addHandler(_logging.StreamHandler())
40 _LOG.setLevel(_logging.WARNING)
41
42 PUBLIC_BRANCH_NAME = 'public'
43
44
45 def parse_remote(remote):
46     """
47
48     >>> parse_remote('ssh://example.com/~/')
49     (None, 'example.com', 22, '~')
50     >>> parse_remote('ssh://jdoe@example.com:2222/a/b/c')
51     ('jdoe', 'example.com', 2222, '/a/b/c')
52     """
53     _LOG.debug("parse {} using Git's URL syntax".format(remote))
54     p = _urllib_parse.urlparse(remote)
55     assert p.scheme == 'ssh', p.scheme
56     assert p.params == '', p.params
57     assert p.query == '', p.query
58     assert p.fragment == '', p.fragment
59
60     _LOG.debug('extract username, host, and port from {}'.format(p.netloc))
61     userhost_port = p.netloc.split(':', 1)
62     if len(userhost_port) > 2:
63         _LOG.error("more than one ':' in netloc: {}".format(p.netloc))
64         raise ValueError(p.netloc)
65     elif len(userhost_port) == 2:
66         port = int(userhost_port[1])
67     else:
68         port = 22
69     userhost = userhost_port[0]
70
71     _LOG.debug('extract username and host from {}'.format(userhost))
72     user_host = userhost.split('@', 1)
73     if len(user_host) > 2:
74         _LOG.error("more than one '@' in netloc: {}".format(p.netloc))
75         raise ValueError(userhost)
76     elif len(user_host) == 2:
77         user,host = user_host
78     else:
79         user = None
80         host = user_host[0]
81
82     _LOG.debug('extract path from {}'.format(p.path))
83     basedir = p.path.rstrip('/')
84     if basedir.startswith('/~'):
85         basedir = basedir[1:]  # allow user expansion on the remote host
86
87     _LOG.debug('user: {}, host: {}, port: {}, basedir: {}'.format(
88             user, host, port, basedir))
89     return (user, host, port, basedir)
90
91 def touch(path):
92     with open(path, 'a') as f:
93         pass
94
95 def git(args, repo=None):
96     """
97
98     >>> print(git(['help']))  # doctest: +ELLIPSIS
99     usage: git ...
100     """
101     if repo is None:
102         repo='.'
103     _LOG.debug('{}: git {}'.format(repo, args))
104     output = _subprocess.check_output(['git'] + args, cwd=repo)
105     _LOG.debug(output)
106     return output
107
108 def has_remote(repo, remote):
109     for line in git(repo=repo, args=['remote']).splitlines():
110         if line == remote:
111             return True
112     return False
113
114 def make_bare_local_checkout(repo):
115     bare = _tempfile.mkdtemp(prefix='git-publish-')
116     _LOG.debug('make a bare local checkout of {} in {}'.format(repo, bare))
117     git(args=['clone', '--bare', repo, bare])
118     _LOG.debug('locally configure the bare checkout')
119     _shutil.copy(_os_path.join(repo, '.git', 'description'), bare)
120     touch(_os_path.join(bare, 'git-daemon-export-ok'))
121     _shutil.move(_os_path.join(bare, 'hooks', 'post-update.sample'),
122                  _os_path.join(bare, 'hooks', 'post-update'))
123     git(repo=bare, args=['--bare', 'update-server-info'])
124     _os.chmod(bare, 0o0755)
125     return bare
126
127 def recursive_copy(source, user, host, port, path):
128     source = source.rstrip('/') + '/'
129     path = path.rstrip('/') + '/'
130     target = '{}@{}:{}'.format(user, host, path)
131     _LOG.debug('copy {} to {}'.format(source, target))
132     _subprocess.check_call(
133         ['rsync', '-az', '--delete', '--rsh', 'ssh -p{:d}'.format(port),
134          source, target])
135
136 def add_remote(repo, remote, user, host, port=22, path=None):
137     url = 'ssh://{}@{}:{:d}/{}'.format(user, host, port, path)
138     _LOG.debug('add {} remote to {} pointing to {}'.format(remote, repo, url))
139     git(repo=repo, args=['remote', 'add', remote, url])
140     git(repo=repo, args=['fetch', remote])
141     git(repo=repo, args=[
142             'branch', '--set-upstream', 'master', '{}/master'.format(remote)])
143
144 def publish(repo, host, basedir='.', port=22, user=None, name=None):
145     if user is None:
146         user = _os.getlogin()
147     repo = _os_path.abspath(_os_path.expanduser(repo))
148     if name is None:
149         name = _os_path.basename(repo)
150     target_dir = _os_path.join(basedir, '{}.git'.format(name))
151     _LOG.info('publishing {} at {}@{}:{:d}/{}'.format(
152             repo, user, host, port, target_dir))
153     if has_remote(repo=repo, remote=PUBLIC_BRANCH_NAME):
154         _LOG.info('{} already published'.format(name))
155         return
156     bare = make_bare_local_checkout(repo)
157     recursive_copy(
158         source=bare, user=user, host=host, port=port, path=target_dir)
159     _LOG.debug('cleanup {}'.format(bare))
160     _shutil.rmtree(bare)
161     add_remote(
162         repo=repo, remote=PUBLIC_BRANCH_NAME, user=user, host=host, port=port,
163         path=target_dir)
164
165
166 if __name__ == '__main__':
167     from argparse import ArgumentParser
168     import sys
169
170     parser = ArgumentParser(description=__doc__, version=__version__)
171     parser.add_argument(
172         '-V', '--verbose', default=0, action='count',
173         help='increment verbosity')
174     parser.add_argument(
175         '-r', '--remote',
176         help=("the remote target.  Use Git's SSH URL, e.g. "
177               'ssh://user@host:port/~/path/to/base'))
178     parser.add_argument(
179         '-n', '--name',
180         help=('override the name of the new remote repository (defaults to '
181               'the local dirname + .git)'))
182     parser.add_argument(
183         'repo', default='.', nargs='?',
184         help='local Git repository to publish (default: %(default)s)')
185     args = parser.parse_args()
186
187     if args.verbose >= 2:
188         _LOG.setLevel(_logging.DEBUG)
189     elif args.verbose >= 1:
190         _LOG.setLevel(_logging.INFO)
191
192     if args.remote is None:
193         _LOG.error('--remote argument is required.')
194         sys.exit(1)
195
196     user,host,port,basedir = parse_remote(remote=args.remote)
197     publish(
198         repo=args.repo, user=user, host=host, port=port, basedir=basedir,
199         name=args.name)