Repository: streamlit/streamlit

Issue:
Using git below version 2.7 requires remote (e.g. GitHub) credentials on every run (for private repos)
# Summary

When Streamlit uses Git to detect branch/remote information. It uses `GitPython` package to 

# Steps to reproduce

What are the steps we should take to reproduce the bug:

1. Install an old version of git (< 2.7)
2. Create/run a streamlit script on a private repo

## Expected behavior:

No credentials should be asked for.

## Actual behavior:

You should see credentials being asked for (and asked on every rerun).

## Is this a regression?

yes - because we never required gitpython in the past.

# Debug info

- Streamlit version: 0.68+

# Additional information

See https://discuss.streamlit.io/t/running-locally-streamlit-prompts-to-login-to-my-git-repo-how-can-i-disable/6751/19
Issue is brought up to https://github.com/gitpython-developers/GitPython/blob/master/git/remote.py#L528

Can also just log that git is pretty old.



Discussion:
Austin Chen commented: * Don't require a password
* Tell the user to upgrade git
** Only for local repo
* Specify a minimum git version in our docs
* Abhi to sync with Austin on messaging
Please add an option to disable git requests altogether. I am forcing page reload when new data arrives (`session.request_rerun`), about once a second, and see

```
2020-11-16 14:15:14 frap git.cmd[279370] DEBUG Popen(['git', 'rev-parse', '--show-toplevel'], cwd=/some/path, universal_newlines=False, shell=None, istream=None)
2020-11-16 14:15:14 frap git.cmd[279370] DEBUG Popen(['git', 'remote', 'get-url', '--all', 'origin'], cwd=/some/path, universal_newlines=False, shell=None, istream=None)
```

at every reload. This means spawning two processes every second.

Resolving patch:
diff --git a/lib/streamlit/bootstrap.py b/lib/streamlit/bootstrap.py
--- a/lib/streamlit/bootstrap.py
+++ b/lib/streamlit/bootstrap.py
@@ -18,6 +18,7 @@
 
 import click
 import tornado.ioloop
+from streamlit.git_util import GitRepo, MIN_GIT_VERSION
 
 from streamlit import config
 from streamlit import net_util
@@ -132,6 +133,7 @@ def _fix_sys_argv(script_path, args):
 
 
 def _on_server_start(server):
+    _maybe_print_old_git_warning(server.script_path)
     _print_url(server.is_running_hello)
 
     def maybe_open_browser():
@@ -225,6 +227,33 @@ def _print_url(is_running_hello):
         click.secho("")
 
 
+def _maybe_print_old_git_warning(script_path: str) -> None:
+    """If our script is running in a Git repo, and we're running a very old
+    Git version, print a warning that Git integration will be unavailable.
+    """
+    repo = GitRepo(script_path)
+    if (
+        not repo.is_valid()
+        and repo.git_version is not None
+        and repo.git_version < MIN_GIT_VERSION
+    ):
+        git_version_string = ".".join(str(val) for val in repo.git_version)
+        min_version_string = ".".join(str(val) for val in MIN_GIT_VERSION)
+        click.secho("")
+        click.secho("  Git integration is disabled.", fg="yellow", bold=True)
+        click.secho("")
+        click.secho(
+            f"  Streamlit requires Git {min_version_string} or later, "
+            f"but you have {git_version_string}.",
+            fg="yellow",
+        )
+        click.secho(
+            "  Git is used by Streamlit Sharing (https://streamlit.io/sharing).",
+            fg="yellow",
+        )
+        click.secho("  To enable this feature, please update Git.", fg="yellow")
+
+
 def run(script_path, command_line, args):
     """Run a script in a separate thread and start a server for the app.
 
diff --git a/lib/streamlit/git_util.py b/lib/streamlit/git_util.py
--- a/lib/streamlit/git_util.py
+++ b/lib/streamlit/git_util.py
@@ -1,18 +1,32 @@
-import git  # type: ignore[import]
 import os
 import re
+from typing import Optional, Tuple
+
+import git  # type: ignore[import]
 
 # Github has two URLs, one that is https and one that is ssh
 GITHUB_HTTP_URL = r"^https://(www\.)?github.com/(.+)/(.+).git$"
 GITHUB_SSH_URL = r"^git@github.com:(.+)/(.+).git$"
 
+# We don't support git < 2.7, because we can't get repo info without
+# talking to the remote server, which results in the user being prompted
+# for credentials.
+MIN_GIT_VERSION = (2, 7, 0)
+
 
 class GitRepo:
     def __init__(self, path):
+        # If we have a valid repo, git_version will be a tuple of 3+ ints:
+        # (major, minor, patch, possible_additional_patch_number)
+        self.git_version = None  # type: Optional[Tuple[int, ...]]
+
         try:
             self.repo = git.Repo(path, search_parent_directories=True)
-            git_root = self.repo.git.rev_parse("--show-toplevel")
-            self.module = os.path.relpath(path, git_root)
+            self.git_version = self.repo.git.version_info
+            if self.git_version >= MIN_GIT_VERSION:
+                git_root = self.repo.git.rev_parse("--show-toplevel")
+                self.module = os.path.relpath(path, git_root)
+
         except:
             # The git repo must be invalid for the following reasons:
             #  * No .git folder
@@ -20,14 +34,24 @@ def __init__(self, path):
             #  * Path is invalid
             self.repo = None
 
-    def is_valid(self):
-        return self.repo is not None
+    def is_valid(self) -> bool:
+        """True if there's a git repo here, and git.version >= MIN_GIT_VERSION."""
+        return (
+            self.repo is not None
+            and self.git_version is not None
+            and self.git_version >= MIN_GIT_VERSION
+        )
 
     @property
     def tracking_branch(self):
+        if not self.is_valid():
+            return None
         return self.repo.active_branch.tracking_branch()
 
     def get_tracking_branch_remote(self):
+        if not self.is_valid():
+            return None
+
         tracking_branch = self.tracking_branch
         if tracking_branch is None:
             return None
@@ -43,7 +67,7 @@ def is_github_repo(self):
 
         remote_info = self.get_tracking_branch_remote()
         if remote_info is None:
-            return None
+            return False
 
         remote, _branch = remote_info
 
diff --git a/lib/streamlit/server/server.py b/lib/streamlit/server/server.py
--- a/lib/streamlit/server/server.py
+++ b/lib/streamlit/server/server.py
@@ -211,16 +211,10 @@ def get_current(cls):
 
         return Server._singleton
 
-    def __init__(self, ioloop, script_path, command_line):
-        """Create the server. It won't be started yet.
-
-        Parameters
-        ----------
-        ioloop : tornado.ioloop.IOLoop
-        script_path : str
-        command_line : str
-
-        """
+    def __init__(
+        self, ioloop: tornado.ioloop.IOLoop, script_path: str, command_line: str
+    ):
+        """Create the server. It won't be started yet."""
         if Server._singleton is not None:
             raise RuntimeError("Server already initialized. Use .get_current() instead")
 
@@ -233,7 +227,7 @@ def __init__(self, ioloop, script_path, command_line):
         self._command_line = command_line
 
         # Mapping of ReportSession.id -> SessionInfo.
-        self._session_info_by_id = {}
+        self._session_info_by_id = {}  # type: Dict[str, SessionInfo]
 
         self._must_stop = threading.Event()
         self._state = None
@@ -244,6 +238,10 @@ def __init__(self, ioloop, script_path, command_line):
         self._report = None  # type: Optional[Report]
         self._preheated_session_id = None  # type: Optional[str]
 
+    @property
+    def script_path(self) -> str:
+        return self._script_path
+
     def on_files_updated(self, session_id):
         """Event handler for UploadedFileManager.on_file_added.
 


Test patch:
diff --git a/lib/tests/streamlit/bootstrap_test.py b/lib/tests/streamlit/bootstrap_test.py
--- a/lib/tests/streamlit/bootstrap_test.py
+++ b/lib/tests/streamlit/bootstrap_test.py
@@ -272,3 +272,14 @@ def test_print_urls_base_no_internal(self, mock_get_internal_ip):
         out = sys.stdout.getvalue()
         self.assertTrue("Local URL: http://localhost:8501/foo" in out)
         self.assertTrue("Network URL: http://internal-ip:8501/foo" not in out)
+
+    @patch("streamlit.bootstrap.GitRepo")
+    def test_print_old_git_warning(self, mock_git_repo):
+        mock_git_repo.return_value.is_valid.return_value = False
+        mock_git_repo.return_value.git_version = (1, 2, 3)
+
+        bootstrap._maybe_print_old_git_warning("script_path")
+        out = sys.stdout.getvalue()
+        self.assertTrue(
+            "Streamlit requires Git 2.7.0 or later, but you have 1.2.3." in out
+        )
diff --git a/lib/tests/streamlit/git_util_test.py b/lib/tests/streamlit/git_util_test.py
--- a/lib/tests/streamlit/git_util_test.py
+++ b/lib/tests/streamlit/git_util_test.py
@@ -14,7 +14,8 @@
 
 import re
 import unittest
-from unittest.mock import patch, MagicMock
+from unittest.mock import patch
+
 from git.exc import InvalidGitRepositoryError
 
 from streamlit.git_util import GITHUB_HTTP_URL, GITHUB_SSH_URL, GitRepo
@@ -55,8 +56,24 @@ def test_git_repo_invalid(self):
             repo = GitRepo(".")
             self.assertFalse(repo.is_valid())
 
+    def test_old_git_version(self):
+        """If the installed git is older than 2.7, certain repo operations
+        prompt the user for credentials. We don't want to do this, so
+        repo.is_valid() returns False for old gits.
+        """
+        with patch("git.repo.base.Repo.GitCommandWrapperType") as git_mock, patch(
+            "streamlit.git_util.os"
+        ):
+            git_mock.return_value.version_info = (1, 6, 4)  # An old git version
+            repo = GitRepo(".")
+            self.assertFalse(repo.is_valid())
+            self.assertEqual((1, 6, 4), repo.git_version)
+
     def test_git_repo_valid(self):
-        with patch("git.Repo") as mock, patch("os.path"):
-            mock.git.return_value = MagicMock()
+        with patch("git.repo.base.Repo.GitCommandWrapperType") as git_mock, patch(
+            "streamlit.git_util.os"
+        ):
+            git_mock.return_value.version_info = (2, 20, 3)  # A recent git version
             repo = GitRepo(".")
             self.assertTrue(repo.is_valid())
+            self.assertEqual((2, 20, 3), repo.git_version)