def test_origin_deleted(local, remote):
    """Test scenario where the remote repo is unavailable (e.g. repo deleted from GitHub).

    :param local: conftest fixture.
    :param remote: conftest fixture.
    """
    local.join('README').write('Changed by local.')
    remote.remove()

    with pytest.raises(GitError) as exc:
        commit_and_push(str(local), 'origin', Versions(REMOTES))
    assert 'Could not read from remote repository' in exc.value.output
def test_nothing_significant_to_commit(caplog, local, subdirs):
    """Test ignoring of always-changing generated Sphinx files.

    :param caplog: pytest extension fixture.
    :param local: conftest fixture.
    :param bool subdirs: Test these files from sub directories.
    """
    local.ensure('sub' if subdirs else '', '.doctrees', 'file.bin').write('data')
    local.ensure('sub' if subdirs else '', 'searchindex.js').write('data')
    old_sha = pytest.run(local, ['git', 'rev-parse', 'HEAD']).strip()
    actual = commit_and_push(str(local), 'origin', Versions(REMOTES))
    assert actual is True
    sha = pytest.run(local, ['git', 'rev-parse', 'HEAD']).strip()
    assert sha != old_sha
    pytest.run(local, ['git', 'diff-index', '--quiet', 'HEAD', '--'])  # Exit 0 if nothing changed.
    records = [(r.levelname, r.message) for r in caplog.records]
    assert ('INFO', 'No changes to commit.') not in records
    assert ('INFO', 'No significant changes to commit.') not in records

    local.ensure('sub' if subdirs else '', '.doctrees', 'file.bin').write('changed')
    local.ensure('sub' if subdirs else '', 'searchindex.js').write('changed')
    old_sha = sha
    records_seek = len(caplog.records)
    actual = commit_and_push(str(local), 'origin', Versions(REMOTES))
    assert actual is True
    sha = pytest.run(local, ['git', 'rev-parse', 'HEAD']).strip()
    assert sha == old_sha
    with pytest.raises(CalledProcessError):
        pytest.run(local, ['git', 'diff-index', '--quiet', 'HEAD', '--'])
    records = [(r.levelname, r.message) for r in caplog.records][records_seek:]
    assert ('INFO', 'No changes to commit.') not in records
    assert ('INFO', 'No significant changes to commit.') in records

    local.join('README').write('changed')  # Should cause other two to be committed.
    old_sha = sha
    records_seek = len(caplog.records)
    actual = commit_and_push(str(local), 'origin', Versions(REMOTES))
    assert actual is True
    sha = pytest.run(local, ['git', 'rev-parse', 'HEAD']).strip()
    assert sha != old_sha
    pytest.run(local, ['git', 'diff-index', '--quiet', 'HEAD', '--'])  # Exit 0 if nothing changed.
    records = [(r.levelname, r.message) for r in caplog.records][records_seek:]
    assert ('INFO', 'No changes to commit.') not in records
    assert ('INFO', 'No significant changes to commit.') not in records
def test_branch_deleted(local):
    """Test scenario where branch is deleted by someone.

    :param local: conftest fixture.
    """
    pytest.run(local, ['git', 'checkout', 'feature'])
    pytest.run(local, ['git', 'push', 'origin', '--delete', 'feature'])
    local.join('README').write('Changed by local.')

    # Run.
    actual = commit_and_push(str(local), 'origin', Versions(REMOTES))
    assert actual is True
    pytest.run(local, ['git', 'diff-index', '--quiet', 'HEAD', '--'])  # Exit 0 if nothing changed.
    assert local.join('README').read() == 'Changed by local.'
def test_nothing_to_commit(caplog, local, exclude):
    """Test with no changes to commit.

    :param caplog: pytest extension fixture.
    :param local: conftest fixture.
    :param bool exclude: Test with exclude support (aka files staged for deletion). Else clean repo.
    """
    if exclude:
        contents = local.join('README').read()
        pytest.run(local, ['git', 'rm', 'README'])  # Stages removal of README.
        local.join('README').write(contents)  # Unstaged restore.
    old_sha = pytest.run(local, ['git', 'rev-parse', 'HEAD']).strip()

    actual = commit_and_push(str(local), 'origin', Versions(REMOTES))
    assert actual is True
    sha = pytest.run(local, ['git', 'rev-parse', 'HEAD']).strip()
    assert sha == old_sha

    records = [(r.levelname, r.message) for r in caplog.records]
    assert ('INFO', 'No changes to commit.') in records
def test_changes(monkeypatch, local):
    """Test with changes to commit and push successfully.

    :param monkeypatch: pytest fixture.
    :param local: conftest fixture.
    """
    monkeypatch.setenv('LANG', 'en_US.UTF-8')
    monkeypatch.setenv('TRAVIS_BUILD_ID', '12345')
    monkeypatch.setenv('TRAVIS_BRANCH', 'master')
    old_sha = pytest.run(local, ['git', 'rev-parse', 'HEAD']).strip()
    local.ensure('new', 'new.txt')
    local.join('README').write('test\n', mode='a')

    actual = commit_and_push(str(local), 'origin', Versions(REMOTES))
    assert actual is True
    sha = pytest.run(local, ['git', 'rev-parse', 'HEAD']).strip()
    assert sha != old_sha
    pytest.run(local, ['git', 'diff-index', '--quiet', 'HEAD', '--'])  # Exit 0 if nothing changed.

    # Verify commit message.
    subject, body = pytest.run(local, ['git', 'log', '-n1', '--pretty=%B']).strip().split('\n', 2)[::2]
    assert subject == 'AUTO sphinxcontrib-versioning 20160722 0772e5ff32a'
    assert body == 'LANG: en_US.UTF-8\nTRAVIS_BRANCH: master\nTRAVIS_BUILD_ID: 12345'
def test_retryable_race(tmpdir, local, remote, collision):
    """Test race condition scenario where another CI build pushes changes first.

    :param tmpdir: pytest fixture.
    :param local: conftest fixture.
    :param remote: conftest fixture.
    :param bool collision: Have other repo make changes to the same file as this one.
    """
    local_other = tmpdir.ensure_dir('local_other')
    pytest.run(local_other, ['git', 'clone', remote, '.'])
    local_other.ensure('sub', 'ignored.txt').write('Added by other. Should be ignored by commit_and_push().')
    if collision:
        local_other.ensure('sub', 'added.txt').write('Added by other.')
    pytest.run(local_other, ['git', 'add', 'sub'])
    pytest.run(local_other, ['git', 'commit', '-m', 'Added by other.'])
    pytest.run(local_other, ['git', 'push', 'origin', 'master'])

    # Make unstaged changes and then run.
    local.ensure('sub', 'added.txt').write('Added by local.')
    actual = commit_and_push(str(local), 'origin', Versions(REMOTES))

    # Verify.
    assert actual is False
def push(ctx, config, rel_source, dest_branch, rel_dest, **options):
    """Build locally and then push to remote branch.

    First the build sub command is invoked which takes care of building all versions of your documentation in a
    temporary directory. If that succeeds then all built documents will be pushed to a remote branch.

    REL_SOURCE is the path to the docs directory relative to the git root. If the source directory has moved around
    between git tags you can specify additional directories.

    DEST_BRANCH is the branch name where generated docs will be committed to. The branch will then be pushed to remote.
    If there is a race condition with another job pushing to remote the docs will be re-generated and pushed again.

    REL_DEST is the path to the directory that will hold all generated docs for all versions relative to the git roof of
    DEST_BRANCH.

    To pass options to sphinx-build (run for every branch/tag) use a double hyphen
    (e.g. push docs gh-pages . -- -D setting=value).
    \f

    :param click.core.Context ctx: Click context.
    :param sphinxcontrib.versioning.lib.Config config: Runtime configuration.
    :param tuple rel_source: Possible relative paths (to git root) of Sphinx directory containing conf.py (e.g. docs).
    :param str dest_branch: Branch to clone and push to.
    :param str rel_dest: Relative path (to git root) to write generated docs to.
    :param dict options: Additional Click options.
    """
    if 'pre' in config:
        config.pop('pre')(rel_source)
        config.update({k: v for k, v in options.items() if v})
        if config.local_conf:
            config.update(read_local_conf(config.local_conf), ignore_set=True)
    if NO_EXECUTE:
        raise RuntimeError(config, rel_source, dest_branch, rel_dest)
    log = logging.getLogger(__name__)

    # Clone, build, push.
    for _ in range(PUSH_RETRIES):
        with TempDir() as temp_dir:
            log.info('Cloning %s into temporary directory...', dest_branch)
            try:
                clone(config.git_root, temp_dir, config.push_remote, dest_branch, rel_dest, config.grm_exclude)
            except GitError as exc:
                log.error(exc.message)
                log.error(exc.output)
                raise HandledError

            log.info('Building docs...')
            ctx.invoke(build, rel_source=rel_source, destination=os.path.join(temp_dir, rel_dest))
            versions = config.pop('versions')

            log.info('Attempting to push to branch %s on remote repository.', dest_branch)
            try:
                if commit_and_push(temp_dir, config.push_remote, versions):
                    return
            except GitError as exc:
                log.error(exc.message)
                log.error(exc.output)
                raise HandledError
        log.warning('Failed to push to remote repository. Retrying in %d seconds...', PUSH_SLEEP)
        time.sleep(PUSH_SLEEP)

    # Failed if this is reached.
    log.error('Ran out of retries, giving up.')
    raise HandledError