git_command: refactor User-Agent settings

Convert the RepoUserAgent function into a UserAgent class.  This
makes it cleaner to hold internal state, and will make it easier
to add a separate git User-Agent, although we don't do it here.

We make the RepoSourceVersion independent of GitCommand so that
it can be called by the class (later).

Bug: https://crbug.com/gerrit/11144
Change-Id: Iab4e1f974b8733a36b243b2d03f5085a96effa19
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/239232
Reviewed-by: David Pursehouse <dpursehouse@collab.net>
Tested-by: Mike Frysinger <vapier@google.com>
diff --git a/git_command.py b/git_command.py
index 32dcde0..a4081f4 100644
--- a/git_command.py
+++ b/git_command.py
@@ -22,6 +22,7 @@
 from signal import SIGTERM
 
 from error import GitError
+from git_refs import HEAD
 import platform_utils
 from repo_trace import REPO_TRACE, IsTrace, Trace
 from wrapper import Wrapper
@@ -99,50 +100,72 @@
 git = _GitCall()
 
 
-_user_agent = None
+def RepoSourceVersion():
+  """Return the version of the repo.git tree."""
+  ver = getattr(RepoSourceVersion, 'version', None)
 
-def RepoUserAgent():
-  """Return a User-Agent string suitable for HTTP-like services.
+  # We avoid GitCommand so we don't run into circular deps -- GitCommand needs
+  # to initialize version info we provide.
+  if ver is None:
+    env = GitCommand._GetBasicEnv()
+
+    proj = os.path.dirname(os.path.abspath(__file__))
+    env[GIT_DIR] = os.path.join(proj, '.git')
+
+    p = subprocess.Popen([GIT, 'describe', HEAD], stdout=subprocess.PIPE,
+                         env=env)
+    if p.wait() == 0:
+      ver = p.stdout.read().strip().decode('utf-8')
+      if ver.startswith('v'):
+        ver = ver[1:]
+    else:
+      ver = 'unknown'
+    setattr(RepoSourceVersion, 'version', ver)
+
+  return ver
+
+
+class UserAgent(object):
+  """Mange User-Agent settings when talking to external services
 
   We follow the style as documented here:
   https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent
   """
-  global _user_agent
 
-  if _user_agent is None:
-    py_version = sys.version_info
+  _os = None
+  _repo_ua = None
 
-    os_name = sys.platform
-    if os_name == 'linux2':
-      os_name = 'Linux'
-    elif os_name == 'win32':
-      os_name = 'Win32'
-    elif os_name == 'cygwin':
-      os_name = 'Cygwin'
-    elif os_name == 'darwin':
-      os_name = 'Darwin'
+  @property
+  def os(self):
+    """The operating system name."""
+    if self._os is None:
+      os_name = sys.platform
+      if os_name.lower().startswith('linux'):
+        os_name = 'Linux'
+      elif os_name == 'win32':
+        os_name = 'Win32'
+      elif os_name == 'cygwin':
+        os_name = 'Cygwin'
+      elif os_name == 'darwin':
+        os_name = 'Darwin'
+      self._os = os_name
 
-    p = GitCommand(
-        None, ['describe', 'HEAD'],
-        cwd=os.path.dirname(__file__),
-        capture_stdout=True)
-    if p.Wait() == 0:
-      repo_version = p.stdout
-      if repo_version and repo_version[-1] == '\n':
-        repo_version = repo_version[0:-1]
-      if repo_version and repo_version[0] == 'v':
-        repo_version = repo_version[1:]
-    else:
-      repo_version = 'unknown'
+    return self._os
 
-    _user_agent = 'git-repo/%s (%s) git/%s Python/%d.%d.%d' % (
-        repo_version,
-        os_name,
-        git.version_tuple().full,
-        py_version.major, py_version.minor, py_version.micro)
+  @property
+  def repo(self):
+    """The UA when connecting directly from repo."""
+    if self._repo_ua is None:
+      py_version = sys.version_info
+      self._repo_ua = 'git-repo/%s (%s) git/%s Python/%d.%d.%d' % (
+          RepoSourceVersion(),
+          self.os,
+          git.version_tuple().full,
+          py_version.major, py_version.minor, py_version.micro)
 
-  return _user_agent
+    return self._repo_ua
 
+user_agent = UserAgent()
 
 def git_require(min_version, fail=False, msg=''):
   git_version = git.version_tuple()
@@ -171,17 +194,7 @@
                ssh_proxy = False,
                cwd = None,
                gitdir = None):
-    env = os.environ.copy()
-
-    for key in [REPO_TRACE,
-              GIT_DIR,
-              'GIT_ALTERNATE_OBJECT_DIRECTORIES',
-              'GIT_OBJECT_DIRECTORY',
-              'GIT_WORK_TREE',
-              'GIT_GRAFT_FILE',
-              'GIT_INDEX_FILE']:
-      if key in env:
-        del env[key]
+    env = self._GetBasicEnv()
 
     # If we are not capturing std* then need to print it.
     self.tee = {'stdout': not capture_stdout, 'stderr': not capture_stderr}
@@ -273,6 +286,23 @@
     self.process = p
     self.stdin = p.stdin
 
+  @staticmethod
+  def _GetBasicEnv():
+    """Return a basic env for running git under.
+
+    This is guaranteed to be side-effect free.
+    """
+    env = os.environ.copy()
+    for key in (REPO_TRACE,
+                GIT_DIR,
+                'GIT_ALTERNATE_OBJECT_DIRECTORIES',
+                'GIT_OBJECT_DIRECTORY',
+                'GIT_WORK_TREE',
+                'GIT_GRAFT_FILE',
+                'GIT_INDEX_FILE'):
+      env.pop(key, None)
+    return env
+
   def Wait(self):
     try:
       p = self.process
diff --git a/main.py b/main.py
index 0b19aeb..515cdf4 100755
--- a/main.py
+++ b/main.py
@@ -46,7 +46,7 @@
 from color import SetDefaultColoring
 import event_log
 from repo_trace import SetTrace
-from git_command import git, GitCommand, RepoUserAgent
+from git_command import git, GitCommand, user_agent
 from git_config import init_ssh, close_ssh
 from command import InteractiveCommand
 from command import MirrorSafeCommand
@@ -297,11 +297,11 @@
 
 class _UserAgentHandler(urllib.request.BaseHandler):
   def http_request(self, req):
-    req.add_header('User-Agent', RepoUserAgent())
+    req.add_header('User-Agent', user_agent.repo)
     return req
 
   def https_request(self, req):
-    req.add_header('User-Agent', RepoUserAgent())
+    req.add_header('User-Agent', user_agent.repo)
     return req
 
 def _AddPasswordFromUserInput(handler, msg, req):
diff --git a/tests/test_git_command.py b/tests/test_git_command.py
index 4d65d3c..5ceb0b3 100644
--- a/tests/test_git_command.py
+++ b/tests/test_git_command.py
@@ -50,12 +50,20 @@
     self.assertNotEqual('', ver.full)
 
 
-class RepoUserAgentUnitTest(unittest.TestCase):
-  """Tests the RepoUserAgent function."""
+class UserAgentUnitTest(unittest.TestCase):
+  """Tests the UserAgent function."""
 
-  def test_smoke(self):
-    """Make sure it returns something useful."""
-    ua = git_command.RepoUserAgent()
+  def test_smoke_os(self):
+    """Make sure UA OS setting returns something useful."""
+    os_name = git_command.user_agent.os
+    # We can't dive too deep because of OS/tool differences, but we can check
+    # the general form.
+    m = re.match(r'^[^ ]+$', os_name)
+    self.assertIsNotNone(m)
+
+  def test_smoke_repo(self):
+    """Make sure repo UA returns something useful."""
+    ua = git_command.user_agent.repo
     # We can't dive too deep because of OS/tool differences, but we can check
     # the general form.
     m = re.match(r'^git-repo/[^ ]+ ([^ ]+) git/[^ ]+ Python/[0-9.]+', ua)