#!/usr/bin/env python # # Copyright (C) 2010-2011 W. Trevor King # # 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 # . """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: %(default)s)') 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)