Rewrite git-publish as a Python script, and add it to the blog's version control.
[blog.git] / posts / Git / git-publish.py
diff --git a/posts/Git/git-publish.py b/posts/Git/git-publish.py
new file mode 100755 (executable)
index 0000000..10ec076
--- /dev/null
@@ -0,0 +1,195 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2010-2011 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this program.  If not, see
+# <http://www.gnu.org/licenses/>.
+
+"""Publish a local Git repository on a remote host.
+
+This script wraps my usual workflow so I don't have to remember it ;).
+"""
+
+import logging as _logging
+import os as _os
+import os.path as _os_path
+import shutil as _shutil
+import subprocess as _subprocess
+import tempfile as _tempfile
+import urlparse as _urlparse
+
+
+__version__ = '0.2'
+
+_LOG = _logging.getLogger('git-publish')
+_LOG.addHandler(_logging.StreamHandler())
+_LOG.setLevel(_logging.WARNING)
+
+PUBLIC_BRANCH_NAME = 'public'
+
+
+def parse_remote(remote):
+    """
+
+    >>> parse_remote('ssh://example.com/~/')
+    (None, 'example.com', 22, '~')
+    >>> parse_remote('ssh://jdoe@example.com:2222/a/b/c')
+    ('jdoe', 'example.com', 2222, '/a/b/c')
+    """
+    _LOG.debug("parse {} using Git's URL syntax".format(remote))
+    p = _urlparse.urlparse(remote)
+    assert p.scheme == 'ssh', p.scheme
+    assert p.params == '', p.params
+    assert p.query == '', p.query
+    assert p.fragment == '', p.fragment
+
+    _LOG.debug('extract username, host, and port from {}'.format(p.netloc))
+    userhost_port = p.netloc.split(':', 1)
+    if len(userhost_port) > 2:
+        _LOG.error("more than one ':' in netloc: {}".format(p.netloc))
+        raise ValueError(p.netloc)
+    elif len(userhost_port) == 2:
+        port = int(userhost_port[1])
+    else:
+        port = 22
+    userhost = userhost_port[0]
+
+    _LOG.debug('extract username and host from {}'.format(userhost))
+    user_host = userhost.split('@', 1)
+    if len(user_host) > 2:
+        _LOG.error("more than one '@' in netloc: {}".format(p.netloc))
+        raise ValueError(userhost)
+    elif len(user_host) == 2:
+        user,host = user_host
+    else:
+        user = None
+        host = user_host[0]
+
+    _LOG.debug('extract path from {}'.format(p.path))
+    basedir = p.path.rstrip('/')
+    if basedir.startswith('/~'):
+        basedir = basedir[1:]  # allow user expansion on the remote host
+
+    _LOG.debug('user: {}, host: {}, port: {}, basedir: {}'.format(
+            user, host, port, basedir))
+    return (user, host, port, basedir)
+
+def touch(path):
+    with open(path, 'a') as f:
+        pass
+
+def git(args, repo=None):
+    """
+
+    >>> print(git(['help']))  # doctest: +ELLIPSIS
+    usage: git ...
+    """
+    if repo is None:
+        repo='.'
+    _LOG.debug('{}: git {}'.format(repo, args))
+    output = _subprocess.check_output(['git'] + args, cwd=repo)
+    _LOG.debug(output)
+    return output
+
+def has_remote(repo, remote):
+    for line in git(repo=repo, args=['remote']).splitlines():
+        if line == remote:
+            return True
+    return False
+
+def make_bare_local_checkout(repo):
+    bare = _tempfile.mkdtemp(prefix='git-publish-')
+    _LOG.debug('make a bare local checkout of {} in {}'.format(repo, bare))
+    git(args=['clone', '--bare', repo, bare])
+    _LOG.debug('locally configure the bare checkout')
+    _shutil.copy(_os_path.join(repo, '.git', 'description'), bare)
+    touch(_os_path.join(bare, 'git-daemon-export-ok'))
+    _shutil.move(_os_path.join(bare, 'hooks', 'post-update.sample'),
+                 _os_path.join(bare, 'hooks', 'post-update'))
+    git(repo=bare, args=['--bare', 'update-server-info'])
+    return bare
+
+def recursive_copy(source, user, host, port, path):
+    source = source.rstrip('/') + '/'
+    path = path.rstrip('/') + '/'
+    target = '{}@{}:{}'.format(user, host, path)
+    _LOG.debug('copy {} to {}'.format(source, target))
+    _subprocess.check_call(
+        ['rsync', '-az', '--delete', '--rsh', 'ssh -p{:d}'.format(port),
+         source, target])
+
+def add_remote(repo, remote, user, host, port=22, path=None):
+    url = 'ssh://{}@{}:{:d}/{}'.format(user, host, port, path)
+    _LOG.debug('add {} remote to {} pointing to {}'.format(remote, repo, url))
+    git(repo=repo, args=['remote', 'add', remote, url])
+    git(repo=repo, args=['fetch', remote])
+    git(repo=repo, args=[
+            'branch', '--set-upstream', 'master', '{}/master'.format(remote)])
+
+def publish(repo, host, basedir='.', port=22, user=None, name=None):
+    if user is None:
+        user = _os.getlogin()
+    repo = _os_path.abspath(_os_path.expanduser(repo))
+    if name is None:
+        name = _os_path.basename(repo)
+    target_dir = _os_path.join(basedir, '{}.git'.format(name))
+    _LOG.info('publishing {} at {}@{}:{:d}/{}'.format(
+            repo, user, host, port, target_dir))
+    if has_remote(repo=repo, remote=PUBLIC_BRANCH_NAME):
+        _LOG.info('{} already published'.format(name))
+        return
+    bare = make_bare_local_checkout(repo)
+    recursive_copy(
+        source=bare, user=user, host=host, port=port, path=target_dir)
+    _LOG.debug('cleanup {}'.format(bare))
+    _shutil.rmtree(bare)
+    add_remote(
+        repo=repo, remote=PUBLIC_BRANCH_NAME, user=user, host=host, port=port,
+        path=target_dir)
+
+
+if __name__ == '__main__':
+    from argparse import ArgumentParser
+    import sys
+
+    parser = ArgumentParser(description=__doc__, version=__version__)
+    parser.add_argument(
+        '-V', '--verbose', default=0, action='count',
+        help='increment verbosity')
+    parser.add_argument(
+        '-r', '--remote',
+        help=("the remote target.  Use Git's SSH URL, e.g. "
+              'ssh://user@host:port/~/path/to/base'))
+    parser.add_argument(
+        '-n', '--name',
+        help=('override the name of the new remote repository (defaults to '
+              'the local dirname + .git)'))
+    parser.add_argument(
+        'repo', default='.', nargs='?',
+        help='local Git repository to publish ({default})')
+    args = parser.parse_args()
+
+    if args.verbose >= 2:
+        _LOG.setLevel(_logging.DEBUG)
+    elif args.verbose >= 1:
+        _LOG.setLevel(_logging.INFO)
+
+    if args.remote is None:
+        _LOG.error('--remote argument is required.')
+        sys.exit(1)
+
+    user,host,port,basedir = parse_remote(remote=args.remote)
+    publish(
+        repo=args.repo, user=user, host=host, port=port, basedir=basedir,
+        name=args.name)