Repository: mkdocs/mkdocs

Issue:
Warn users if they are using an old/different version
pip has a nice feature where they warn users if they are using an old version and prompt them to update. I think it would be neat to add something like this. The code they use for it is here: https://github.com/pypa/pip/blob/7.0.3/pip/utils/outdated.py#L95



Discussion:
I actually think this is becoming more important. Otherwise multiple users could end up deploying the same docs with different MkDocs versions.

For example, Alice is working on Project X and installed MkDocs six months ago. Then Bob joins the project and installs MkDocs today when he wants to deploy the documentation, he gets a more recent version than Alice did. Now when they deploy the documentation, the deployed docs will switch between the old and new version - possibly adding or removing bugs and features.

Given that we include the MkDocs version in the git [commit message](https://github.com/mkdocs/mkdocs/commit/4af6066d62bbbaf8b82ea289bbc87bc62c63e4ae) we could use that information to warn about using older versions than a previous deploy.

Ah, so you are suggesting that the `gh-deploy` command check the `gh-pages` branch for the latest commit, extract the MkDocs version from the commit message, do a version comparison with the currently installed version of MkDocs, and issue a warning if they don't match? That might work.

What happens when doth Alice and Bob upgrade? Should they still get the warning the first time they deploy? Or should the warning only issue for Alice before she upgrades? In order words, should we allow newer versions to deploy silently but warn on older versions, or should we always warn on mismatched versions? This is what is not clear to me.

> In order words, should we allow newer versions to deploy silently but warn on older versions, or should we always warn on mismatched versions? This is what is not clear to me.

Good question. I am not sure. I am not sure I even like the idea in general. I guess we should allow newer versions - otherwise that could be annoying. Although, if it is just a warning, that would be fine.

So, how about:
- If it is an older version, error with a flag to do it anyway.
- It it is a newer version, display a warning/info message "The previous deploy was done with MkDocs version X you are deploying with version Y"
- If it is the same version, don't do anything.

That works for me. I like the "info" message on a newer version.

One problem with the proposal is that the "message" is configurable. Yes, the default message includes the MkDocs version, but users can override that with anything they want. I suppose this could be configured to only work if a version string is found in the message (if the previous commit does not include a version, then skip the check). Then we could warn users that if they want the check to be performed then their custom messages needs to conform to some pattern. We already call `message.format()` on the message passing in the `sha` and `version`, so that could all be addressed via documentation.
For the record, a few ways to get the most recent message are:

```
git log --format=%s -n 1
git show -s --format=%s HEAD

git log --format=%B -n 1
git show -s --format=%B HEAD
```

With `format=%B` the entire message is returned, while `format=%s` returns the first line only. Apparently `%B` is a more recent addition and not available on older git versions. As for `show` vs. `log`, `show` only ever references one object while `log` returns a list which is limited to the first item with `-n 1`.

Note that `git rev-list --format=%s --max-count=1 HEAD` (as well as its `%B` counterpart) also works, but `rev-list` includes the commit SHA, whereas `log` and `show` only output the message by itself.

To reference a branch other than the branch of the working tree, use `refs/heads/<branchname>`. Using only  `<branchname>` could cause a conflict if another object has the same name (perhaps a tag or directory?). Prefixing with `refs/heads` bypasses those other objects. See [gitrevisions][1]) for details. Being able to access a branch other than the current branch also appears to remove the option of using the `log` command. Therefore, the correct command appears to be:

```
git show -s --format=%s refs/heads/gh-pages
```

[1]: https://www.git-scm.com/docs/gitrevisions

Resolving patch:
diff --git a/mkdocs/__main__.py b/mkdocs/__main__.py
--- a/mkdocs/__main__.py
+++ b/mkdocs/__main__.py
@@ -89,6 +89,7 @@ def common_options(f):
 remote_name_help = ("The remote name to commit to for Github Pages. This "
                     "overrides the value specified in config")
 force_help = "Force the push to the repository."
+ignore_version_help = "Ignore check that build is not being deployed with an older version of MkDocs."
 
 pgk_dir = os.path.dirname(os.path.abspath(__file__))
 
@@ -172,8 +173,9 @@ def build_command(clean, config_file, strict, theme, theme_dir, site_dir):
 @click.option('-b', '--remote-branch', help=remote_branch_help)
 @click.option('-r', '--remote-name', help=remote_name_help)
 @click.option('--force', is_flag=True, help=force_help)
+@click.option('--ignore-version', is_flag=True, help=ignore_version_help)
 @common_options
-def gh_deploy_command(config_file, clean, message, remote_branch, remote_name, force):
+def gh_deploy_command(config_file, clean, message, remote_branch, remote_name, force, ignore_version):
     """Deploy your documentation to GitHub Pages"""
     try:
         cfg = config.load_config(
@@ -182,7 +184,7 @@ def gh_deploy_command(config_file, clean, message, remote_branch, remote_name, f
             remote_name=remote_name
         )
         build.build(cfg, dirty=not clean)
-        gh_deploy.gh_deploy(cfg, message=message, force=force)
+        gh_deploy.gh_deploy(cfg, message=message, force=force, ignore_version=ignore_version)
     except exceptions.ConfigurationError as e:  # pragma: no cover
         # Avoid ugly, unhelpful traceback
         raise SystemExit('\n' + str(e))
diff --git a/mkdocs/commands/gh_deploy.py b/mkdocs/commands/gh_deploy.py
--- a/mkdocs/commands/gh_deploy.py
+++ b/mkdocs/commands/gh_deploy.py
@@ -2,6 +2,8 @@
 import logging
 import subprocess
 import os
+import re
+from pkg_resources import parse_version
 
 import mkdocs
 from mkdocs.utils import ghp_import
@@ -49,20 +51,49 @@ def _get_remote_url(remote_name):
     return host, path
 
 
-def gh_deploy(config, message=None, force=False):
+def _check_version(branch):
+
+    proc = subprocess.Popen(['git', 'show', '-s', '--format=%s', 'refs/heads/{}'.format(branch)],
+                            stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+
+    stdout, _ = proc.communicate()
+    msg = stdout.decode('utf-8').strip()
+    m = re.search(r'\d+(\.\d+)+', msg, re.X | re.I)
+    previousv = parse_version(m.group()) if m else None
+    currentv = parse_version(mkdocs.__version__)
+    if not previousv:
+        log.warn('Version check skipped: No version specificed in previous deployment.')
+    elif currentv > previousv:
+        log.info(
+            'Previous deployment was done with MkDocs version {}; '
+            'you are deploying with a newer version ({})'.format(previousv, currentv)
+        )
+    elif currentv < previousv:
+        log.error(
+            'Deployment terminated: Previous deployment was made with MkDocs version {}; '
+            'you are attempting to deploy with an older version ({}). Use --ignore-version '
+            'to deploy anyway.'.format(previousv, currentv)
+        )
+        raise SystemExit(1)
+
+
+def gh_deploy(config, message=None, force=False, ignore_version=False):
 
     if not _is_cwd_git_repo():
         log.error('Cannot deploy - this directory does not appear to be a git '
                   'repository')
 
+    remote_branch = config['remote_branch']
+    remote_name = config['remote_name']
+
+    if not ignore_version:
+        _check_version(remote_branch)
+
     if message is None:
         message = default_message
     sha = _get_current_sha(os.path.dirname(config.config_file_path))
     message = message.format(version=mkdocs.__version__, sha=sha)
 
-    remote_branch = config['remote_branch']
-    remote_name = config['remote_name']
-
     log.info("Copying '%s' to '%s' branch and pushing to GitHub.",
              config['site_dir'], config['remote_branch'])
 


Test patch:
diff --git a/mkdocs/tests/base.py b/mkdocs/tests/base.py
--- a/mkdocs/tests/base.py
+++ b/mkdocs/tests/base.py
@@ -2,9 +2,14 @@
 import textwrap
 import markdown
 import os
+import logging
+import collections
+import unittest
+
 
 from mkdocs import toc
 from mkdocs import config
+from mkdocs import utils
 
 
 def dedent(text):
@@ -37,3 +42,97 @@ def load_config(**cfg):
     errors_warnings = conf.validate()
     assert(errors_warnings == ([], [])), errors_warnings
     return conf
+
+
+# Backport unittest.TestCase.assertLogs for Python 2.7
+# see https://github.com/python/cpython/blob/3.6/Lib/unittest/case.py
+
+if not utils.PY3:
+    _LoggingWatcher = collections.namedtuple("_LoggingWatcher",
+                                             ["records", "output"])
+
+    class _CapturingHandler(logging.Handler):
+        """
+        A logging handler capturing all (raw and formatted) logging output.
+        """
+
+        def __init__(self):
+            logging.Handler.__init__(self)
+            self.watcher = _LoggingWatcher([], [])
+
+        def flush(self):
+            pass
+
+        def emit(self, record):
+            self.watcher.records.append(record)
+            msg = self.format(record)
+            self.watcher.output.append(msg)
+
+    class _AssertLogsContext(object):
+        """A context manager used to implement TestCase.assertLogs()."""
+
+        LOGGING_FORMAT = "%(levelname)s:%(name)s:%(message)s"
+
+        def __init__(self, test_case, logger_name, level):
+            self.test_case = test_case
+            self.logger_name = logger_name
+            if level:
+                self.level = logging._levelNames.get(level, level)
+            else:
+                self.level = logging.INFO
+            self.msg = None
+
+        def __enter__(self):
+            if isinstance(self.logger_name, logging.Logger):
+                logger = self.logger = self.logger_name
+            else:
+                logger = self.logger = logging.getLogger(self.logger_name)
+            formatter = logging.Formatter(self.LOGGING_FORMAT)
+            handler = _CapturingHandler()
+            handler.setFormatter(formatter)
+            self.watcher = handler.watcher
+            self.old_handlers = logger.handlers[:]
+            self.old_level = logger.level
+            self.old_propagate = logger.propagate
+            logger.handlers = [handler]
+            logger.setLevel(self.level)
+            logger.propagate = False
+            return handler.watcher
+
+        def __exit__(self, exc_type, exc_value, tb):
+            self.logger.handlers = self.old_handlers
+            self.logger.propagate = self.old_propagate
+            self.logger.setLevel(self.old_level)
+            if exc_type is not None:
+                # let unexpected exceptions pass through
+                return False
+            if len(self.watcher.records) == 0:
+                self._raiseFailure(
+                    "no logs of level {} or higher triggered on {}"
+                    .format(logging.getLevelName(self.level), self.logger.name))
+
+        def _raiseFailure(self, standardMsg):
+            msg = self.test_case._formatMessage(self.msg, standardMsg)
+            raise self.test_case.failureException(msg)
+
+    class LogTestCase(unittest.TestCase):
+        def assertLogs(self, logger=None, level=None):
+            """Fail unless a log message of level *level* or higher is emitted
+            on *logger_name* or its children.  If omitted, *level* defaults to
+            INFO and *logger* defaults to the root logger.
+            This method must be used as a context manager, and will yield
+            a recording object with two attributes: `output` and `records`.
+            At the end of the context manager, the `output` attribute will
+            be a list of the matching formatted log messages and the
+            `records` attribute will be a list of the corresponding LogRecord
+            objects.
+            Example::
+                with self.assertLogs('foo', level='INFO') as cm:
+                    logging.getLogger('foo').info('first message')
+                    logging.getLogger('foo.bar').error('second message')
+                self.assertEqual(cm.output, ['INFO:foo:first message',
+                                             'ERROR:foo.bar:second message'])
+            """
+            return _AssertLogsContext(self, logger, level)
+else:
+    LogTestCase = unittest.TestCase
diff --git a/mkdocs/tests/cli_tests.py b/mkdocs/tests/cli_tests.py
--- a/mkdocs/tests/cli_tests.py
+++ b/mkdocs/tests/cli_tests.py
@@ -346,6 +346,8 @@ def test_gh_deploy_defaults(self, mock_gh_deploy, mock_build, mock_load_config):
         self.assertEqual(g_kwargs['message'], None)
         self.assertTrue('force' in g_kwargs)
         self.assertEqual(g_kwargs['force'], False)
+        self.assertTrue('ignore_version' in g_kwargs)
+        self.assertEqual(g_kwargs['ignore_version'], False)
         self.assertEqual(mock_build.call_count, 1)
         b_args, b_kwargs = mock_build.call_args
         self.assertTrue('dirty' in b_kwargs)
@@ -471,3 +473,19 @@ def test_gh_deploy_force(self, mock_gh_deploy, mock_build, mock_load_config):
         self.assertEqual(g_kwargs['force'], True)
         self.assertEqual(mock_build.call_count, 1)
         self.assertEqual(mock_load_config.call_count, 1)
+
+    @mock.patch('mkdocs.config.load_config', autospec=True)
+    @mock.patch('mkdocs.commands.build.build', autospec=True)
+    @mock.patch('mkdocs.commands.gh_deploy.gh_deploy', autospec=True)
+    def test_gh_deploy_ognore_version(self, mock_gh_deploy, mock_build, mock_load_config):
+
+        result = self.runner.invoke(
+            cli.cli, ['gh-deploy', '--ignore-version'], catch_exceptions=False)
+
+        self.assertEqual(result.exit_code, 0)
+        self.assertEqual(mock_gh_deploy.call_count, 1)
+        g_args, g_kwargs = mock_gh_deploy.call_args
+        self.assertTrue('ignore_version' in g_kwargs)
+        self.assertEqual(g_kwargs['ignore_version'], True)
+        self.assertEqual(mock_build.call_count, 1)
+        self.assertEqual(mock_load_config.call_count, 1)
diff --git a/mkdocs/tests/gh_deploy_tests.py b/mkdocs/tests/gh_deploy_tests.py
--- a/mkdocs/tests/gh_deploy_tests.py
+++ b/mkdocs/tests/gh_deploy_tests.py
@@ -3,8 +3,9 @@
 import unittest
 import mock
 
-from mkdocs.tests.base import load_config
+from mkdocs.tests.base import load_config, LogTestCase
 from mkdocs.commands import gh_deploy
+from mkdocs import __version__
 
 
 class TestGitHubDeploy(unittest.TestCase):
@@ -99,6 +100,32 @@ def test_deploy_hostname(self, mock_import, get_remote, get_sha, is_repo):
         )
         gh_deploy.gh_deploy(config)
 
+    @mock.patch('mkdocs.commands.gh_deploy._is_cwd_git_repo', return_value=True)
+    @mock.patch('mkdocs.commands.gh_deploy._get_current_sha', return_value='shashas')
+    @mock.patch('mkdocs.commands.gh_deploy._get_remote_url', return_value=(None, None))
+    @mock.patch('mkdocs.commands.gh_deploy._check_version')
+    @mock.patch('mkdocs.commands.gh_deploy.ghp_import.ghp_import', return_value=(True, ''))
+    def test_deploy_ignore_version_default(self, mock_import, check_version, get_remote, get_sha, is_repo):
+
+        config = load_config(
+            remote_branch='test',
+        )
+        gh_deploy.gh_deploy(config)
+        check_version.assert_called_once()
+
+    @mock.patch('mkdocs.commands.gh_deploy._is_cwd_git_repo', return_value=True)
+    @mock.patch('mkdocs.commands.gh_deploy._get_current_sha', return_value='shashas')
+    @mock.patch('mkdocs.commands.gh_deploy._get_remote_url', return_value=(None, None))
+    @mock.patch('mkdocs.commands.gh_deploy._check_version')
+    @mock.patch('mkdocs.commands.gh_deploy.ghp_import.ghp_import', return_value=(True, ''))
+    def test_deploy_ignore_version(self, mock_import, check_version, get_remote, get_sha, is_repo):
+
+        config = load_config(
+            remote_branch='test',
+        )
+        gh_deploy.gh_deploy(config, ignore_version=True)
+        check_version.assert_not_called()
+
     @mock.patch('mkdocs.utils.ghp_import.ghp_import')
     @mock.patch('mkdocs.commands.gh_deploy.log')
     def test_deploy_error(self, mock_log, mock_import):
@@ -112,3 +139,43 @@ def test_deploy_error(self, mock_log, mock_import):
         self.assertRaises(SystemExit, gh_deploy.gh_deploy, config)
         mock_log.error.assert_called_once_with('Failed to deploy to GitHub with error: \n%s',
                                                error_string)
+
+
+class TestGitHubDeployLogs(LogTestCase):
+
+    @mock.patch('subprocess.Popen')
+    def test_mkdocs_newer(self, mock_popeno):
+
+        mock_popeno().communicate.return_value = (b'Deployed 12345678 with MkDocs version: 0.1.2\n', b'')
+
+        with self.assertLogs('mkdocs', level='INFO') as cm:
+            gh_deploy._check_version('gh-pages')
+        self.assertEqual(
+            cm.output, ['INFO:mkdocs.commands.gh_deploy:Previous deployment was done with MkDocs '
+                        'version 0.1.2; you are deploying with a newer version ({})'.format(__version__)]
+        )
+
+    @mock.patch('subprocess.Popen')
+    def test_mkdocs_older(self, mock_popeno):
+
+        mock_popeno().communicate.return_value = (b'Deployed 12345678 with MkDocs version: 10.1.2\n', b'')
+
+        with self.assertLogs('mkdocs', level='ERROR') as cm:
+            self.assertRaises(SystemExit, gh_deploy._check_version, 'gh-pages')
+        self.assertEqual(
+            cm.output, ['ERROR:mkdocs.commands.gh_deploy:Deployment terminated: Previous deployment was made with '
+                        'MkDocs version 10.1.2; you are attempting to deploy with an older version ({}). Use '
+                        '--ignore-version to deploy anyway.'.format(__version__)]
+        )
+
+    @mock.patch('subprocess.Popen')
+    def test_version_unknown(self, mock_popeno):
+
+        mock_popeno().communicate.return_value = (b'No version specified\n', b'')
+
+        with self.assertLogs('mkdocs', level='WARNING') as cm:
+            gh_deploy._check_version('gh-pages')
+        self.assertEqual(
+            cm.output,
+            ['WARNING:mkdocs.commands.gh_deploy:Version check skipped: No version specificed in previous deployment.']
+        )