From 91e59706fa2d1dd49bee45d3bc99b905e9970dbf Mon Sep 17 00:00:00 2001
From: Arksine <arksine.code@gmail.com>
Date: Sat, 13 Mar 2021 20:37:50 -0500
Subject: [PATCH] update_manager: track commits behind for git repos

Signed-off-by:  Eric Callahan <arksine.code@gmail.com>
---
 moonraker/plugins/update_manager.py | 68 ++++++++++++++++++++++++++++-
 1 file changed, 66 insertions(+), 2 deletions(-)

diff --git a/moonraker/plugins/update_manager.py b/moonraker/plugins/update_manager.py
index 2453ba2..0d70f6d 100644
--- a/moonraker/plugins/update_manager.py
+++ b/moonraker/plugins/update_manager.py
@@ -724,6 +724,9 @@ GIT_FETCH_ENV_VARS = {
     'GIT_HTTP_LOW_SPEED_LIMIT': "1000",
     'GIT_HTTP_LOW_SPEED_TIME ': "15"
 }
+GIT_MAX_LOG_CNT = 100
+GIT_LOG_FMT = \
+    "\"sha:%H%x1Dauthor:%an%x1Ddate:%ct%x1Dsubject:%s%x1Dmessage:%b%x1E\""
 
 class GitRepo:
     def __init__(self, cmd_helper, git_path, alias):
@@ -744,6 +747,7 @@ class GitRepo:
         self.branches = []
         self.dirty = False
         self.head_detached = False
+        self.commits_behind = []
 
         self.init_condition = None
         self.git_operation_lock = Lock()
@@ -804,6 +808,22 @@ class GitRepo:
                     tag_version = ver_match.group()
                 versions.append(tag_version)
             self.current_version, self.upstream_version = versions
+
+            # Get Commits Behind
+            self.commits_behind = []
+            cbh = await self.get_commits_behind()
+            if cbh:
+                tagged_commits = await self.get_tagged_commits()
+                debug_msg = '\n'.join([f"{k}: {v}" for k, v in
+                                       tagged_commits.items()])
+                logging.debug(f"Git Repo {self.alias}: Tagged Commits\n"
+                              f"{debug_msg}")
+                for i, commit in enumerate(cbh):
+                    tag = tagged_commits.get(commit['sha'], None)
+                    if i < 30 or tag is not None:
+                        commit['tag'] = tag
+                        self.commits_behind.append(commit)
+
             self.log_repo_info()
         except Exception:
             logging.exception(f"Git Repo {self.alias}: Initialization failure")
@@ -868,7 +888,8 @@ class GitRepo:
             f"Current Version: {self.current_version}\n"
             f"Upstream Version: {self.upstream_version}\n"
             f"Is Dirty: {self.dirty}\n"
-            f"Is Detached: {self.head_detached}")
+            f"Is Detached: {self.head_detached}\n"
+            f"Commits Behind: {len(self.commits_behind)}")
 
     def report_invalids(self, valid_origin):
         invalids = []
@@ -959,6 +980,48 @@ class GitRepo:
             await self.cmd_helper.run_cmd_with_response(
                 f"{self.git_cmd} checkout {branch} -q")
 
+    async def get_commits_behind(self):
+        self._verify_repo()
+        if self.is_current():
+            return []
+        async with self.git_operation_lock:
+            branch = f"{self.git_remote}/{self.git_branch}"
+            resp = await self.cmd_helper.run_cmd_with_response(
+                f"{self.git_cmd} log {self.current_commit}..{branch} "
+                f"--format={GIT_LOG_FMT} --max-count={GIT_MAX_LOG_CNT}")
+            commits_behind = []
+            for log_entry in resp.split('\x1E'):
+                log_entry = log_entry.strip()
+                if not log_entry:
+                    continue
+                log_items = [li.strip() for li in log_entry.split('\x1D')
+                             if li.strip()]
+                commits_behind.append(
+                    dict([li.split(':', 1) for li in log_items]))
+            return commits_behind
+
+    async def get_tagged_commits(self):
+        self._verify_repo()
+        async with self.git_operation_lock:
+            resp = await self.cmd_helper.run_cmd_with_response(
+                f"{self.git_cmd} show-ref --tags -d")
+            tagged_commits = {}
+            tags = [tag.strip() for tag in resp.split('\n') if tag.strip()]
+            for tag in tags:
+                sha, ref = tag.split(' ', 1)
+                ref = ref.split('/')[-1]
+                if ref[-3:] == "^{}":
+                    # Dereference this commit and overwrite any existing tag
+                    ref = ref[:-3]
+                    tagged_commits[ref] = sha
+                elif ref not in tagged_commits:
+                    # This could be a lightweight tag pointing to a commit.  If
+                    # it is an annotated tag it will be overwritten by the
+                    # dereferenced tag
+                    tagged_commits[ref] = sha
+            # Return tagged commits as SHA keys mapped to tag values
+            return {v: k for k, v in tagged_commits.items()}
+
     def get_repo_status(self):
         return {
             'remote_alias': self.git_remote,
@@ -969,7 +1032,8 @@ class GitRepo:
             'current_hash': self.current_commit,
             'remote_hash': self.upstream_commit,
             'is_dirty': self.dirty,
-            'detached': self.head_detached
+            'detached': self.head_detached,
+            'commits_behind': self.commits_behind
         }
 
     def get_version(self, upstream=False):