#!/usr/bin/env python3
# -*- mode: Python; -*-
#
# repotool - query and manipulate multiple repository types in a uniform way.
#
# Rather non-idiomatic Python because it was translated from shell.
#
# This code runs under both Python 2 and Python 3: preserve this property!
#
# SPDX-License-Identifier: BSD-2-Clause
from __future__ import print_function

import sys, os, stat, getopt, subprocess, re, shutil

try:
    input = raw_input
except NameError:
    pass

template = """\
# Makefile for %(project)s conversion using reposurgeon
#
# Steps to using this:
# 1. Make sure reposurgeon and repotool are on your $PATH.
# 2. (Skip this step if you're starting from a stream file.) For svn, set
#    REMOTE_URL to point at the remote repository you want to convert.
#    If the repository is already in a DVCS such as hg or git,
#    set REMOTE_URL to either the normal cloning URL (starting with hg://,
#    git://, etc.) or to the path of a local clone.
# 3. For cvs, set CVS_HOST to the repo hostname and CVS_MODULE to the module,
#    then uncomment the line that builds REMOTE_URL 
#    Note: for CVS hosts other than Sourceforge or Savannah you will need to 
#    include the path to the CVS modules directory after the hostname.
# 4. Set any required read options, such as --user-ignores or --nobranch,
#    by setting READ_OPTIONS.
# 5. Run 'make stubmap' to create a stub author map.
# 6. Run 'make' to build a converted repository.
#
# The reason both first- and second-stage stream files are generated is that,
# especially with Subversion, making the first-stage stream file is often
# painfully slow. By splitting the process, we lower the overhead of
# experiments with the lift script.
#
# For a production-quality conversion you will need to edit the map
# file and the lift script.  During the process you can set EXTRAS to
# name extra metadata such as a comments message-box.
#
# Afterwards, you can use the headcompare and tagscompare productions
# to check your work.
#

EXTRAS = 
REMOTE_URL = svn://svn.debian.org/%(project)s
#REMOTE_URL = https://%(project)s.googlecode.com/svn/
CVS_HOST = %(project)s.cvs.sourceforge.net
#CVS_HOST = cvs.savannah.gnu.org
CVS_MODULE = %(project)s
#REMOTE_URL = cvs://$(CVS_HOST)/%(project)s\\#$(CVS_MODULE)
READ_OPTIONS =
VERBOSITY = "set progress"
REPOSURGEON = reposurgeon
LOGFILE = conversion.log

# Configuration ends here

.PHONY: local-clobber remote-clobber gitk gc compare clean dist stubmap
# Tell make not to auto-remove tag directories, because it only tries rm 
# and hence fails
.PRECIOUS: %(project)s-%%-checkout %(project)s-%%-%(target_vcs)s

default: %(project)s-%(target_vcs)s

# Build the converted repo from the second-stage fast-import stream
%(project)s-%(target_vcs)s: %(project)s.fi
	rm -fr %(project)s-%(target_vcs)s; $(REPOSURGEON) $(VERBOSITY) 'read <%(project)s.fi' 'prefer %(target_vcs)s' 'rebuild %(project)s-%(target_vcs)s'

# Build the second-stage fast-import stream from the first-stage stream dump
%(project)s.fi: %(project)s.%(source_vcs)s %(project)s.opts %(project)s.lift %(project)s.map $(EXTRAS)
	$(REPOSURGEON) $(VERBOSITY) 'logfile $(LOGFILE)' 'script %(project)s.opts' "read $(READ_OPTIONS) <%(project)s.%(source_vcs)s" 'authors read <%(project)s.map' 'sourcetype %(source_vcs)s' 'prefer git' 'script %(project)s.lift' 'legacy write >%(project)s.fo' 'write >%(project)s.fi'

# Build the first-stage stream dump from the local mirror
%(project)s.%(source_vcs)s: %(project)s-mirror
	(cd %(project)s-mirror/ >/dev/null; repotool export) >%(project)s.%(source_vcs)s

# Build a local mirror of the remote repository
%(project)s-mirror:
	repotool mirror $(REMOTE_URL) %(project)s-mirror

# Make a local checkout of the source mirror for inspection
%(project)s-checkout: %(project)s-mirror
	cd %(project)s-mirror >/dev/null; repotool checkout $(PWD)/%(project)s-checkout

# Make a local checkout of the source mirror for inspection at a specific tag
%(project)s-%%-checkout: %(project)s-mirror
	cd %(project)s-mirror >/dev/null; repotool checkout $(PWD)/%(project)s-$*-checkout $*

# Force rebuild of first-stage stream from the local mirror on the next make
local-clobber: clean
	rm -fr %(project)s.fi %(project)s-%(target_vcs)s *~ .rs* %(project)s-conversion.tar.gz %(project)s-*-%(target_vcs)s

# Force full rebuild from the remote repo on the next make.
remote-clobber: local-clobber
	rm -fr %(project)s.%(source_vcs)s %(project)s-mirror %(project)s-checkout %(project)s-*-checkout

# Get the (empty) state of the author mapping from the first-stage stream
stubmap: %(project)s.%(source_vcs)s
	$(REPOSURGEON) $(VERBOSITY) "read $(READ_OPTIONS) <%(project)s.%(source_vcs)s" 'authors write >%(project)s.map'

# Compare the histories of the unconverted and converted repositories at head
# and all tags.
EXCLUDE = -x CVS -x .%(source_vcs)s -x .%(target_vcs)s
EXCLUDE += -x .%(source_vcs)signore -x .%(target_vcs)signore
headcompare: %(project)s-mirror %(project)s-%(target_vcs)s
	repotool compare $(EXCLUDE) %(project)s-mirror %(project)s-%(target_vcs)s
tagscompare: %(project)s-mirror %(project)s-%(target_vcs)s
	repotool compare-tags $(EXCLUDE) %(project)s-mirror %(project)s-%(target_vcs)s
branchescompare: %(project)s-mirror %(project)s-%(target_vcs)s
	repotool compare-branches $(EXCLUDE) %(project)s-mirror %(project)s-%(target_vcs)s
allcompare: %(project)s-mirror %(project)s-%(target_vcs)s
	repotool compare-all $(EXCLUDE) %(project)s-mirror %(project)s-%(target_vcs)s

# General cleanup and utility
clean:
	rm -fr *~ .rs* %(project)s-conversion.tar.gz *.%(source_vcs)s *.fi *.fo

# Bundle up the conversion metadata for shipping
SOURCES = Makefile %(project)s.lift %(project)s.map $(EXTRAS)
%(project)s-conversion.tar.gz: $(SOURCES)
	tar --dereference --transform 's:^:%(project)s-conversion/:' -czvf %(project)s-conversion.tar.gz $(SOURCES)

dist: %(project)s-conversion.tar.gz
"""

git_template_additions = """\

#
# The following productions are git-specific
#

# Browse the generated git repository
gitk: %(project)s-git
	cd %(project)s-git; gitk --all

# Run a garbage-collect on the generated git repository.  Import doesn't.
# This repack call is the active part of gc --aggressive.  This call is
# tuned for very large repositories.
gc: %(project)s-git
	cd %(project)s-git; time git -c pack.threads=1 repack -AdF --window=1250 --depth=250
"""

verbose = False
quiet = True

def croak(msg):
    sys.stderr.write("repotool: " + msg + "\n")
    sys.exit(1)

def complain(msg):
    if not quiet:
        sys.stderr.write("repotool: " + msg + "\n")

def do_or_die(dcmd, legend=""):
    "Either execute a command or raise a fatal exception."
    if legend:
        legend = " "  + legend
    if verbose:
        sys.stdout.write("repotool: executing '%s'%s\n" % (dcmd, legend))
    try:
        retcode = subprocess.call(dcmd, shell=True)
        if retcode < 0:
            croak("child '%s' was terminated by signal %d." % (dcmd, -retcode))
        elif retcode != 0:
            croak("child '%s' returned %d." % (dcmd, retcode))
    except (OSError, IOError) as e:
        croak("execution of %s%s failed: %s" % (dcmd, legend, e))

def capture_or_die(dcmd, legend=""):
    "Either execute a command and capture its output or die."
    if legend:
        legend = " "  + legend
    if verbose:
        sys.stdout.write("repotool: executing '%s'%s\n" % (dcmd, legend))
    try:
        out = subprocess.check_output(dcmd, shell=True).decode('ascii')
        if verbose:
            sys.stdout.write("repotool: returning %s\n" % repr(out))
        return out
    except subprocess.CalledProcessError as e:
        if e.returncode < 0:
            croak("child was terminated by signal %d." % -e.returncode)
        elif e.returncode != 0:
            croak("child returned %d." % e.returncode)

class directory_context:
    def __init__(self, target):
        self.target = target
        self.source = None
    def __enter__(self):
        if verbose:
            sys.stdout.write("repotool: in %s...\n" % self.target)
        self.source = os.getcwd()
        if os.path.isdir(self.target):
            os.chdir(self.target)
        else:
            enclosing = os.path.dirname(self.target)
            if enclosing:
                os.chdir(enclosing)
    def __exit__(self, extype, value_unused, traceback_unused):
        os.chdir(self.source)

def vcstype(d):
    "What repository type in this directory?"
    if os.path.isdir(os.path.join(d, "CVSROOT")):
        return "cvs"
    elif [p for p in os.listdir(d) if p.endswith(",v")]:
        return "cvs"
    elif os.path.isdir(os.path.join(d, "CVS")):
        return "cvs-checkout"
    elif os.path.isdir(os.path.join(d, "locks")):
        return "svn"
    elif os.path.isdir(os.path.join(d, ".svn")):
        return "svn-checkout"
    elif os.path.isdir(os.path.join(d, ".git")):
        return "git"
    elif os.path.isdir(os.path.join(d, ".bzr")):
        return "bzr"
    elif os.path.isdir(os.path.join(d, ".hg")):
        return "hg"
    elif os.path.isdir(os.path.join(d, "_darcs")):
        return "darcs"
    elif os.path.isdir(os.path.join(d, ".bk")):
        return "bk"
    else:
        croak("%s does not look like a repository of known type." % os.path.abspath(d))

def is_dvcs_or_checkout(d="."):
    "Is this a DVCS or checkout where we can compare files?"
    return vcstype(d) not in ("cvs", "svn")

def vcsignores():
    "Return ignorable directories."
    return [".svn",
            "CVS", ".cvsignore",
            ".git", ".gitignore",
            ".hg", ".hgignore",
            ".bzr", ".bzrignore",
            ".bk", ".bkignore",
            "_darcs"]

def initialize(args):
    "Initialize project-conversion machinery."
    if len(args) < 1:
        croak("initialize requires a project name.")
    project = args.pop(0) 
    if not args:
        source_vcs = input("repotool: what VCS do you want to convert from? ")
    else:
        source_vcs = args.pop(0)
    if source_vcs not in ("cvs", "svn", "git", "bzr", "hg", "darcs", "bk"):
        croak("unknown source VCS type %s" % source_vcs)
    if not args:
        target_vcs = input("repotool: what VCS do you want to convert to? ")
    else:
        target_vcs = args.pop(0)
    if target_vcs not in ("cvs", "svn", "git", "bzr", "hg", "darcs", "bk"):
        croak("unknown target VCS type %s" % target_vcs)
    if os.path.exists("Makefile"):
        complain("a Makefile already exists here.")    
    else:
        print("repotool: generating Makefile, some variables in it need to be set.")
        with open("Makefile", "w") as wp:
            wp.write(template % locals())
            if target_vcs == "git":
                wp.write(git_template_additions % locals())
    if os.path.exists(project + ".opts"):
        complain("a project options file already exists here.")
    else:
        print("repotool: generating a stub options file.")
        with open(project + ".opts", "w") as wp:
            wp.write("# Pre-read options for reposurgeon go here.\n")
    if os.path.exists(project + ".lift"):
        complain("a project lift file already exists here.")
    else:
        print("repotool: generating a stub options file.")
        with open(project + ".lift", "w") as wp:
            wp.write("# Lift commands for %s\n" % project)

def export():
    "Export from the current working diretory to standard output."
    m = {
        "cvs": r"find . -name \*,v | cvs-fast-export -q --reposurgeon",
        "svn": "svnadmin -q dump .",
        "git": "git fast-export --all",
        "bzr": "bzr fast-export --no-plain .",
        "hg": "reposurgeon 'read .' 'prefer git' 'write -'",
        "darcs": "darcs fastconvert export",
        "bk": "bk fast-export -q",
        }
    vcs = vcstype(".")
    e = m.get(vcs)
    if e is None:
        croak("can't export from directory of type %s." % vcs)
    else:
        do_or_die(e, " export command")

def mirror(operand, mirrordir):
    "Refresh a local mirror directory from its remote repository"
    pwd = os.getcwd()
    if re.match("svn://|svn\+ssh://|https://|http://", operand) \
       or (operand.startswith("file://") and os.path.isdir(os.path.join(operand[7:], "locks"))):
        if mirrordir:
            locald = mirrordir
        else:
            locald = os.path.basename(operand) + "-mirror"
        do_or_die("svnadmin create " + locald)
        with open(locald + "/hooks/pre-revprop-change", "w") as wp:
            wp.write("#!/bin/sh\nexit 0;\n")
        try:
            os.remove(locald + "/hooks/post-revprop-change")
        except OSError:
            pass
        # Note: The --allow-non-empty and --steal-lock options permit
        # this to operate on a Subversion repository you have pulled
        # in with rsync (which is very much faster than mirrorng via
        # SVN protocol), but they disable some safety checking.  Be
        # very sure you have not made any local changes to the repo
        # since rsyncing, or havoc will ensue.
        do_or_die("chmod a+x %s/hooks/pre-revprop-change" % locald)
        do_or_die("svnsync init --allow-non-empty file://%(pwd)s/%(locald)s %(operand)s" % locals())
        do_or_die("svnsync synchronize --steal-lock file://%(pwd)s/%(locald)s" % locals())
    elif os.path.isdir(operand + "/locks"):
        do_or_die("svnsync --steal-lock synchronize file://%(pwd)s/%(operand)s" % locals())
    elif operand.startswith("cvs://"):
        if mirrordir:
            locald = mirrordir
        else:
            locald = re.sub("^.*#", os.path.basename(operand))
        os.mkdir(locald)
        do_or_die("cvssync -c -o %(locald)s %(operand)s" % locals())
        with open(locald + "/.cvssync", "w") as wp:
            wp.write(operand)
    elif os.path.exists(operand + "/.cvssync"):
        with open(operand + "/.cvssync") as rp:
            do_or_die("cvssync -c -o " + operand + " " + rp.read())
    elif operand.startswith("git://"):
        if mirrord:
            locald = mirrordir
        else:
            locald = re.sub("^.*#", os.path.basename(operand))
        do_or_die("git clone %s %s" % (operand, locald))
    elif os.path.isdir(operand + "/.git"):
        with directory_context(operand):
            do_or_die("git pull")
        do_or_die("git clone %(operand)s %(mirrordir)s" % locals())
    elif operand.startswith("hg://"):
        if mirrord:
           locald = mirrordir
        else:
            locald = re.sub("^.*#", os.path.basename(operand))
        do_or_die("hg clone %(operand)s %(locald)s" % locals()) 
    elif os.path.isdir(operand + "/.hg"):
        with directory_context(operand):
            do_or_die("hg update")
        do_or_die("hg clone $operand $mirrordir" % locals())
    else:
        croak("%s does not look like a repository mirror." % operand)

def tags():
    "List tags from the current working directory to standard output."
    m = {
        # CVS code will screw up if any tag is not common to all files
        "cvs": "module=`ls -1 | grep -v CVSROOT`; \
                cvs -Q -d:local:${PWD} rlog -h $module 2>&1 \
                | awk -F'[.:]' '/^\t/&&$(NF-1)!=0{print $1}' |awk '{print $1}' | sort -u",
        "svn": "svn ls 'file://%s/tags' | sed 's|/$||'" % os.getcwd(),
        "svn-checkout": "ls tags 2>/dev/null || exit 0",
        "git": "git tag -l",
        "bzr": "bzr tags",
        "hg": "hg tags --quiet",
        "darcs": "darcs show tags",
        "bk": "bk tags | sed -n 's/ *TAG: *//p'",
        }
    vcs = vcstype(".")
    e = m.get(vcs)
    if e is None:
        croak("can't list tags from directory of type %s." % vcs)
    else:
        do_or_die(e, " tag-list command")

def branches():
    "List branches from the current working directory to standard output."
    m = {
        "cvs": "module=`ls -1 | grep -v CVSROOT`; \
                cvs -Q -d:local:${PWD} rlog -h $module 2>&1 \
                 | awk -F'[.:]' '/^\t/&&$(NF-1)==0{print $1}' | awk '{print $1}' | sort -u",
        "svn": "svn ls 'file://%s/branches' | sed 's|/$||'" % os.getcwd(),
        "svn-checkout": "ls branches 2>/dev/null || exit 0",
        "git": "git branch -q --list 2>&1 | cut -c 3- | egrep -v 'detached|^master$' || exit 0",
        "bzr": "bzr branches | cut -c 3-",
        "hg": "hg branches --template '{branch}\n' | grep -v '^default$'",
        }
    vcs = vcstype(".")
    e = m.get(vcs)
    if e is None:
        croak("can't list branches from directory of type %s." % vcs)
    else:
        do_or_die(e, " tag-list command")

def checkout(args):
    "Check out a specified revision, branch, or tag (defaulting to tip of trunk)."
    # This code is complicated because it deals with distinct cases:
    # 1. DVCS - make outdir a symlink to the repo, then check out the
    #    version we want.
    # 2. Non-DVCS, master directory: True checkout to remote directory,
    #    add path qualifier if needed.
    # 3. Non-DVCS, checkout directory: make outdir a symlink to the checkout,
    #    then update to revision needed and add a qualifier  
    # This can be very slow under Subversion, we want to avoid it if at all possible.
    # Other cases should error out.
    if verbose:
        print("checkout: %s" % args)
    (options, arguments) = getopt.getopt(args, "b:c:r:t:",
                                        ["nobranch", "accept-missing"])
    outdir = "."
    branch = ""
    tag = ""
    revision = ""
    nobranch = False
    accept_missing = False
    for (opt, val) in options:
        if opt == '-b':
            branch = val
        elif opt == '--nobranch':
            nobranch = True
        elif opt == '--accept-missing':
            accept_missing = True
        elif opt == '-c':
            os.chdir(val)
        elif opt == '-r':
            revision = val
        elif opt == '-t':
            tag = val
    if nobranch: branch == "" # nobranch will also prevent the automatic switch to "trunk"
    outdir = arguments[0]
    if outdir[0] != os.sep:
        croak("checkout requires absolute target path")
    outdir = os.path.realpath(outdir)
    pwd = os.getcwd()
    vcs = vcstype(".")
    if vcs == "cvs":
        module = capture_or_die("ls -1 | grep -v CVSROOT", " listing modules")
        if revision:
            revision = "-r " + revision
        # By choosing -kb we get binary files right, but won't
        # suppress any expanded keywords that might be lurking
        # in masters.
        do_or_die("cvs -Q -d:local:%(pwd)s co -P %(branch)s %(tag)s %(revision)s -d %(outdir)s -kb %(module)s" % locals())
        return outdir
    elif vcs == "cvs-checkout":
        do_or_die("cvs -Q -d:local:%(pwd)s co -P %(branch)s %(tag)s %(revision)s -kb" % locals())
        return outdir
    elif vcs == "svn":
        if revision:
            revision = "-r " + revision
        do_or_die("svn co -q %(revision)s file://%(pwd)s %(outdir)s" % locals())
        if nobranch:
            pass # flat repository
        elif tag:
            outdir = os.path.join(outdir, "tags", tag)
        elif branch in ("", "master", "trunk"):
            outdir = os.path.join(outdir, "trunk")
        elif branch:
            outdir = os.path.join(outdir, "branches", branch)
        return outdir
    elif vcs == "svn-checkout":
        if revision:
            revision = "-r " + revision
            # Potentially dangerous assumption: User made a full checkout
            # of HEAD and the update operation (which is hideously slow on
            # large repositories) only needs to be done if an explicit revision
            # was supplied.
            do_or_die("svn up -q " + revision)
        relpath = ""
        if nobranch:
            pass # flat repository
        elif tag and (accept_missing or os.path.isdir("tags")):
            relpath = os.path.join("tags", tag)
        elif branch in ("", "master", "trunk") and os.path.isdir("trunk"):
            relpath = "trunk"
        elif branch and os.path.isdir(os.path.join("branches", branch)):
            relpath = os.path.join("branches", branch)
        elif branch and os.path.isdir(branch):
            complain("branch '%s' found at the root which is non-standard" % branch)
            relpath = branch
        elif branch in ("master", "trunk") and accept_missing:
            relpath = "trunk"
        elif branch and accept_missing:
            relpath = os.path.join("branches", branch)
        else:
            croak("invalid branch or tag")
        if os.path.exists(outdir):
            if os.path.islink(outdir):
                os.remove(outdir)
            else:
                croak("can't checkout to existing %s" % outdir)
        os.symlink(os.path.join(pwd, relpath), outdir)
        return outdir
    elif vcs == "git":
        # Only one revision should be given to git checkout
        # Use the passed-in arguments, in some order of specificity.
        handle_missing = False
        if not revision:
            revision = tag or branch or "master"
            handle_missing = accept_missing and \
                (capture_or_die(
                        "git rev-parse --verify -q %s >/dev/null || echo no" % revision))
        if handle_missing:
            path = pwd + ".git/this/path/does/not/exist"
        else:
            do_or_die("git checkout --quiet %s" % revision)
            path = pwd
        if os.path.exists(outdir):
            if os.path.islink(outdir):
                os.remove(outdir)
        os.symlink(path, outdir)
        return outdir
    elif vcs == "bzr":
        croak("checkout is not yet supported in bzr.")
    elif vcs == "hg":
        spec = ""
        if revision:
            spec = "-r " + revision
        elif tag:
            spec = "-r " + tag
        elif branch:
            spec = "-r " + branch
        do_or_die("hg update -q %(spec)s" % locals())
        if outdir == '.':
            return os.getcwd()
        elif os.path.exists(outdir):
            if os.path.islink(outdir):
                os.remove(outdir)
        os.symlink(pwd, outdir)
        return outdir
    elif vcs == "darcs":
        croak("checkout is not yet supported for darcs.")
    else:
        croak("checkout not supported for this repository type.")

def nuke(linkpath):
    "Nuke  a comparison temporary directory."
    if os.path.islink(linkpath):
        os.remove(linkpath)
    else:
        try:
            shutil.rmtree(linkpath)
        except OSError:
            pass

def compare(args):
    "Compare two repositories at a specified revision, defaulting to mainline tip."
    if verbose:
        print("compare: %s" % args)
    (options, arguments) = getopt.getopt(args, "b:e:ir:st:ux:",
                                        ["nobranch", "accept-missing"])
    outdir = "."
    branch = ""
    tag = ""
    revision = ""
    checkout_args = []
    checkout1_args = []
    checkout2_args = []
    diff_args = []
    seeignores = False
    accept_missing = False
    if verbose:
        print("Options: %s" % options)
    for (opt, val) in options:
        if opt in ('-b', '-t'):
            checkout_args.append(opt)
            checkout_args.append(val)
        elif opt == '-r':
            vals = val.split(":", 1)
            if 1 <= len(vals) <= 2:
                if vals[0]:
                    checkout1_args.append(opt)
                    checkout1_args.append(vals[0])
                if vals[-1]:
                    checkout2_args.append(opt)
                    checkout2_args.append(vals[-1])
            else:
                croak("incorrect value for compare -r option.")
        elif opt == '--nobranch':
            checkout_args.append(opt)
        elif opt == '--accept-missing':
            accept_missing = True
            checkout_args.append(opt)
        elif opt in ('-q', '-s', '-u'):
            diff_args.append(opt)
        elif opt == '-x':
            diff_args.append(opt)
            diff_args.append(val)
        elif opt == '-i':
            seeignores = True
    checkout1_args = checkout_args + checkout1_args
    checkout2_args = checkout_args + checkout2_args
    if verbose:
        print("Checkout 1 arguments: %s" % checkout1_args)
        print("Checkout 2 arguments: %s" % checkout2_args)
    if len(arguments) != 2:
        croak("compare requires exactly two repository-name arguments.")
    args = args[:]
    target = args.pop()
    source = args.pop()
    if not os.path.isdir(source) or not os.path.isdir(target):
        croak("both repository directories must exist.")
    rsource = os.path.join(TMPDIR, "source")
    nuke(rsource)
    rtarget = os.path.join(TMPDIR, "target")
    nuke(rtarget)
    diffopts = []
    sourceignores = []
    tmpdir = os.getenv("TMPDIR") or "/tmp" 
    with directory_context(source):
        if is_dvcs_or_checkout() and not seeignores:
            sourceignores = vcsignores()
            for f in sourceignores:
                diffopts += ["-x", f]
        sourcedir = checkout(checkout1_args + [rsource])
        assert(sourcedir)
    targetignores = []
    with directory_context(target):
        if is_dvcs_or_checkout() and not seeignores:
            targetignores = vcsignores()
            for f in targetignores:
                diffopts += ["-x", f]
        targetdir = checkout(checkout2_args + [rtarget])
        assert(targetdir)
    diffopts += diff_args
    diffopts = " ".join(diffopts)
    if accept_missing:
        if not os.path.exists(sourcedir):
            # replace by empty directory
            nuke(sourcedir)
            os.mkdir(sourcedir)
        if not os.path.exists(targetdir):
            # replace by empty directory
            nuke(targetdir)
            os.mkdir(targetdir)
    # add missing empty directories in checkouts of VCSs that do not support them
    dirs_to_nuke = []
    if vcstype(source) not in ("git", "hg") and vcstype(target) in ("git", "hg"):
        with directory_context(sourcedir):
            for (dirpath, _, _) in os.walk("."):
                matching = os.path.join(targetdir, dirpath)
                if not os.path.exists(matching):
                    dirs_to_nuke.append(matching)
                    os.mkdir(matching)
    if vcstype(target) not in ("git", "hg") and vcstype(source) in ("git", "hg"):
        with directory_context(targetdir):
            for (dirpath, _, _) in os.walk("."):
                matching = os.path.join(sourcedir, dirpath)
                if not os.path.exists(matching):
                    dirs_to_nuke.append(matching)
                    os.mkdir(matching)
    with directory_context(tmpdir):
        # FIXME: use difflib here?
        silent_diff_errors = "2>/dev/null" # for dangling symlinks or similar
        if verbose:
            print("Comparing %s to %s" % (sourcedir, targetdir))
            silent_diff_errors = ""
        diff = capture_or_die("diff -r %(diffopts)s --ignore-matching-lines=' @(#) ' --ignore-matching-lines='$Id.*$' --ignore-matching-lines='$Header.*$' --ignore-matching-lines='$Log.*$' %(sourcedir)s %(targetdir)s %(silent_diff_errors)s || exit 0" % locals())
        def dirlist(top, excl=None):
            "Get list of all paths under specied top node, with optional exclusion."
            m = []
            trunc = len(top) + 1
            for (dirpath, dirnames, filenames) in os.walk(top):
                for f in filenames:
                    fullpath = os.path.join(dirpath, f)
                    if not excl or not any(x for x in excl if x in fullpath):
                        m.append(fullpath[trunc:])
            return set(m)
        common = dirlist(sourcedir,sourceignores) & dirlist(targetdir, targetignores)
        common = list(common)
        common.sort()
        try:
            for path in common:
                sstat = os.stat(os.path.join(sourcedir, path), follow_symlinks=False)[stat.ST_MODE]
                tstat = os.stat(os.path.join(targetdir, path), follow_symlinks=False)[stat.ST_MODE]
                if sstat != tstat:
                    diff += "{}: 0{:o} -> 0{:o}\n".format(path, sstat, tstat)
        except OSError:
            sys.stderr.write("repotool: can't find %s for permissions check.\n" % path)
    # cleanup in case the checkouts were a symlink to an existing worktree
    for d in reversed(dirs_to_nuke):
        os.rmdir(d)
    nuke(rsource)
    nuke(rtarget)
    if diff != "":
        croak("Non-empty diff:\n" + diff)

def compare_engine(singular, plural, lister, comparer, args):
    "Compare two repositories at all revisions implied by a specified command."
    (options, arguments) = getopt.getopt(args, "e:isux:")
    diff_args = []
    excludes = []
    seeignores = False
    for (opt, val) in options:
        if opt in ('-q', '-s', '-u'):
            diff_args.append(opt)
        elif opt == '-x':
            diff_args.append(opt)
            diff_args.append(val)
        elif opt == '-i':
            seeignores = True
        elif opt == '-e':
            excludes.append(val)
    if len(arguments) != 2:
        croak("compare requires exactly two repository-name arguments.")
    args = args[:]
    target = args.pop()
    source = args.pop()
    if not os.path.isdir(source) or not os.path.isdir(target):
        croak("both repository directories must exist.")
    with directory_context(source):
        sourcetags = capture_or_die(lister).strip().split()
    with directory_context(target):
        targettags = capture_or_die(lister).strip().split()
    common = set(sourcetags) & set(targettags)
    sourceonly = list(set(sourcetags) - common)
    for regexp in excludes:
        sourceonly = [x for x in sourceonly if re.compile(regexp).search(x) == None]
    sourceonly.sort()
    targetonly = list(set(targettags) - common)
    for regexp in excludes:
        targetonly = [x for x in targetonly if re.compile(regexp).search(x) == None]
    targetonly.sort()
    common = list(common)
    common.sort()
    compare_result = ""
    if sourceonly:
        compare_result += "----------------------------------------------------------------\n"
        compare_result += "%s only in source:\n" % plural
        for item in sourceonly:
            compare_result += item + "\n"
    if targetonly:
        compare_result += "----------------------------------------------------------------\n"
        compare_result += "%s only in target:\n" % plural
        for item in targetonly:
            compare_result += item + "\n"
    if compare_result != "":
        croak(compare_result)
    if common:
        for item in common:
            cmd = " ".join([comparer, item] + diff_args + [source, target])
            comparison = capture_or_die(cmd)

def compare_tags(args):
    "Compare two repos at all tags."
    vtoken = "-v" if verbose else ""
    repotool = os.path.realpath(sys.argv[0])
    compare_engine("Tag", "Tags", repotool + " tags",
                   "%s %s compare -t" % (repotool, vtoken), args)

def compare_branches(args):
    "Compare two repos at all branches."
    vtoken = "-v" if verbose else ""
    repotool = os.path.realpath(sys.argv[0])
    compare_engine("Branch", "Branches", repotool + " branches",
                   "%s %s compare -b" % (repotool, vtoken), args)

def compare_all(args):
    "Compare two repos on mainline and at all tags and branches."
    if "--nobranch" in args:
        if verbose:
            print("Comparing the complete repository...")
        compare(args)
        return
    if verbose:
        print("Comparing master...")
    # --accept-missing will compare against an empty directory if trunk does
    # not exist, which will thus fail the comparison if it exists on one side
    # but not the other, but will succeed if both repositories have no trunk
    compare(["--accept-missing", "-b", "master"] + args)
    if verbose:
        print("Comparing tags...")
    compare_tags(args)
    if verbose:
        print("Comparing branches...")
    compare_branches(args)
    if verbose:
        print("Done")

if __name__ == "__main__":
    (options, arguments) = getopt.getopt(sys.argv[1:], "vq")
    verbose = False
    quiet = False
    for (opt, val) in options:
        if opt == '-v':
            verbose = True
            quiet = False
        elif opt == '-q':
            quiet = True
            verbose = False

    if len(arguments) < 1:
        croak("requires an operation as first argument.")
    operation = arguments.pop(0)

    TMPDIR = os.getenv("TMPDIR") or "/tmp"

    if operation == "initialize":
        initialize(arguments)
    elif operation == "export":
        export()
    elif operation == "mirror":
        if len(arguments) == 0:
            croak("mirror [url] mirrordir")
        else:
            mirror(arguments[0], arguments[1] if len(arguments) > 1 else None)
    elif operation == "tags":
        tags()
    elif operation == "branches":
        branches()
    elif operation == "checkout":
        checkout(arguments)
    elif operation == "compare":
        compare(arguments)
    elif operation == "compare-tags":
        compare_tags(arguments)
    elif operation == "compare-branches":
        compare_branches(arguments)
    elif operation == "compare-all":
        compare_all(arguments)
    else:
        print("""
repotool commands:

initialize  - create Makefile and stub files for standard conversion workflow.
export - export a stream dump of the source repository
mirror [URL] localdir - create or update a mirror of the source repository
branches - list repository branch names
checkout [-r rev] [-t tag] [-b branch] - check out a working copy of the repo
compare [-r rev] [-t tag] [-b branch] - compare head content of two repositories
compare-tags - compare source and target repo content at all tags
compare-branches - compare source and target repo content at all branches
compare-all - compare repositories at head, all tags, and all branches
""")

# end
