Skip to content

Commit 6923119

Browse files
authored
Merge pull request #90 from ESMCI/issue-86-detached-sync-status
git: rework logic for determining in-sync and current ref name Rework two aspects of the logic for git repositories; both of these can change behavior in some cases when printing status: 1. Determining whether the local checkout is in-sync with the expected reference from the configuration file: Now we always convert the expected reference to a hash and compare that with the currently-checked-out hash. Previously, we sometimes did the comparison using names, e.g., just ensuring that you're on the right branch. 2. Determining the current ref name (e.g., the branch or tag currently checked out). Now we use a number of plumbing commands rather than relying on regex parsing of 'git branch -vv'. The previous regex parsing was fragile and hard to maintain, and was the source of a number of bugs. In addition, differences between git v. 1 and git v. 2 meant that the result was incorrect in some cases - particularly, in the case where we have "detached from foo" (which is the text that always appeared for a detached head in v 1, but in v 2 means we are no longer at foo). I have also overhauled the unit tests covering this functionality. Many tests were no longer needed with the new logic and so I have removed them. I have added some other tests covering the new functionality. User interface changes?: Yes - Subtle changes to status output, as described above - One particular change is: If we're on a tracking branch, `checkout_externals -S -v` will show the name of the local branch rather than the tracked branch. (This is more accurate, because we may not actually be at the head of the tracking branch.) Fixes: #86 (Status incorrectly reports in-sync when you have made commits in detached head state - fixed due to the change in (1)). Testing: test removed: many no-longer-relevant unit tests unit tests: pass system tests: pass manual testing: basic manual testing of checking out and running status, in the context of cesm and ctsm
2 parents 3b624cf + b11ad61 commit 6923119

File tree

4 files changed

+615
-591
lines changed

4 files changed

+615
-591
lines changed

manic/repository_git.py

Lines changed: 115 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
import copy
99
import os
10-
import re
1110

1211
from .global_constants import EMPTY_STR, LOCAL_PATH_INDICATOR
1312
from .global_constants import VERBOSITY_VERBOSE
@@ -37,15 +36,6 @@ class GitRepository(Repository):
3736
3837
"""
3938

40-
# match XYZ of '* (HEAD detached at {XYZ}):
41-
# e.g. * (HEAD detached at origin/feature-2)
42-
RE_DETACHED = re.compile(
43-
r'\* \((?:[\w]+[\s]+)?detached (?:at|from) ([\w\-./]+)\)')
44-
45-
# match tracking reference info, return XYZ from [XYZ]
46-
# e.g. [origin/master]
47-
RE_TRACKING = re.compile(r'\[([\w\-./]+)(?::[\s]+[\w\s,]+)?\]')
48-
4939
def __init__(self, component_name, repo):
5040
"""
5141
Parse repo (a <repo> XML element).
@@ -93,81 +83,42 @@ def _clone_repo(self, base_dir_path, repo_dir_name, verbosity):
9383
self._git_clone(self._url, repo_dir_name, verbosity)
9484
os.chdir(cwd)
9585

96-
def _current_ref_from_branch_command(self, git_output):
97-
"""Parse output of the 'git branch -vv' command to determine the current
98-
branch. The line starting with '*' is the current branch. It
99-
can be one of the following head states:
100-
101-
1. On local branch
102-
103-
feature2 36418b4 [origin/feature2] Work on feature2
104-
* feature3 36418b4 Work on feature2
105-
master 9b75494 [origin/master] Initialize repository.
106-
107-
2. Detached from sha
108-
109-
* (HEAD detached at 36418b4) 36418b4 Work on feature2
110-
feature2 36418b4 [origin/feature2] Work on feature2
111-
master 9b75494 [origin/master] Initialize repository.
112-
113-
3. Detached from remote branch
114-
115-
* (HEAD detached at origin/feature2) 36418b4 Work on feature2
116-
feature2 36418b4 [origin/feature2] Work on feature2
117-
feature3 36418b4 Work on feature2
118-
master 9b75494 [origin/master] Initialize repository.
119-
120-
4. Detached from tag
121-
122-
* (HEAD detached at clm4_5_18_r272) b837fc36 clm4_5_18_r272
123-
124-
5. On tracking branch. Note, may be may be ahead or behind remote.
125-
126-
* master 562bac9a [origin/master] more test junk
127-
128-
* master 408a8920 [origin/master: ahead 3] more junk
129-
130-
* master 408a8920 [origin/master: ahead 3, behind 2] more junk
131-
132-
* master 822d687d [origin/master: behind 3] more junk
133-
134-
NOTE: Parsing the output of the porcelain is probably not a
135-
great idea, but there doesn't appear to be a single plumbing
136-
command that will return the same info.
137-
138-
"""
139-
lines = git_output.splitlines()
140-
ref = ''
141-
for line in lines:
142-
if line.startswith('*'):
143-
ref = line
144-
break
145-
current_ref = EMPTY_STR
146-
if not ref:
147-
# not a git repo? some other error? we return so the
148-
# caller can handle.
149-
pass
150-
elif 'detached' in ref:
151-
match = self.RE_DETACHED.search(ref)
152-
try:
153-
current_ref = match.group(1)
154-
except BaseException:
155-
msg = 'DEV_ERROR: regex to detect detached head state failed!'
156-
msg += '\nref:\n{0}\ngit_output\n{1}\n'.format(ref, git_output)
157-
fatal_error(msg)
158-
elif '[' in ref:
159-
match = self.RE_TRACKING.search(ref)
160-
try:
161-
current_ref = match.group(1)
162-
except BaseException:
163-
msg = 'DEV_ERROR: regex to detect tracking branch failed.'
164-
msg += '\nref:\n{0}\ngit_output\n{1}\n'.format(ref, git_output)
165-
fatal_error(msg)
166-
else:
167-
# assumed local branch
168-
current_ref = ref.split()[1]
86+
def _current_ref(self):
87+
"""Determine the *name* associated with HEAD.
88+
89+
If we're on a branch, then returns the branch name; otherwise,
90+
if we're on a tag, then returns the tag name; otherwise, returns
91+
the current hash. Returns an empty string if no reference can be
92+
determined (e.g., if we're not actually in a git repository).
93+
"""
94+
ref_found = False
95+
96+
# If we're on a branch, then use that as the current ref
97+
branch_found, branch_name = self._git_current_branch()
98+
if branch_found:
99+
current_ref = branch_name
100+
ref_found = True
101+
102+
if not ref_found:
103+
# Otherwise, if we're exactly at a tag, use that as the
104+
# current ref
105+
tag_found, tag_name = self._git_current_tag()
106+
if tag_found:
107+
current_ref = tag_name
108+
ref_found = True
109+
110+
if not ref_found:
111+
# Otherwise, use current hash as the current ref
112+
hash_found, hash_name = self._git_current_hash()
113+
if hash_found:
114+
current_ref = hash_name
115+
ref_found = True
116+
117+
if not ref_found:
118+
# If we still can't find a ref, return empty string. This
119+
# can happen if we're not actually in a git repo
120+
current_ref = ''
169121

170-
current_ref = current_ref.strip()
171122
return current_ref
172123

173124
def _check_sync(self, stat, repo_dir_path):
@@ -194,12 +145,11 @@ def _check_sync(self, stat, repo_dir_path):
194145
self._check_sync_logic(stat, repo_dir_path)
195146

196147
def _check_sync_logic(self, stat, repo_dir_path):
197-
"""Isolate the complicated synce logic so it is not so deeply nested
198-
and a bit easier to understand.
199-
200-
Sync logic - only reporting on whether we are on the ref
201-
(branch, tag, hash) specified in the externals description.
148+
"""Compare the underlying hashes of the currently checkout ref and the
149+
expected ref.
202150
151+
Output: sets the sync_state as well as the current and
152+
expected ref in the input status object.
203153
204154
"""
205155
def compare_refs(current_ref, expected_ref):
@@ -215,8 +165,8 @@ def compare_refs(current_ref, expected_ref):
215165
cwd = os.getcwd()
216166
os.chdir(repo_dir_path)
217167

218-
git_output = self._git_branch_vv()
219-
current_ref = self._current_ref_from_branch_command(git_output)
168+
# get the full hash of the current commit
169+
_, current_ref = self._git_current_hash()
220170

221171
if self._branch:
222172
if self._url == LOCAL_PATH_INDICATOR:
@@ -230,22 +180,33 @@ def compare_refs(current_ref, expected_ref):
230180
else:
231181
expected_ref = "{0}/{1}".format(remote_name, self._branch)
232182
elif self._hash:
233-
# NOTE(bja, 2018-03) For comparison purposes, we could
234-
# determine which is longer and check that the short ref
235-
# is a substring of the long ref. But it is simpler to
236-
# just expand both to the full sha and do an exact
237-
# comparison.
238-
_, expected_ref = self._git_revparse_commit(self._hash)
239-
_, current_ref = self._git_revparse_commit(current_ref)
240-
else:
183+
expected_ref = self._hash
184+
elif self._tag:
241185
expected_ref = self._tag
186+
else:
187+
msg = 'In repo "{0}": none of branch, hash or tag are set'.format(
188+
self._name)
189+
fatal_error(msg)
190+
191+
# record the *names* of the current and expected branches
192+
stat.current_version = self._current_ref()
193+
stat.expected_version = copy.deepcopy(expected_ref)
242194

243-
stat.sync_state = compare_refs(current_ref, expected_ref)
244195
if current_ref == EMPTY_STR:
245196
stat.sync_state = ExternalStatus.UNKNOWN
246-
247-
stat.current_version = current_ref
248-
stat.expected_version = expected_ref
197+
else:
198+
# get the underlying hash of the expected ref
199+
revparse_status, expected_ref_hash = self._git_revparse_commit(
200+
expected_ref)
201+
if revparse_status:
202+
# We failed to get the hash associated with
203+
# expected_ref. Maybe we should assign this to some special
204+
# status, but for now we're just calling this out-of-sync to
205+
# remain consistent with how this worked before.
206+
stat.sync_state = ExternalStatus.MODEL_MODIFIED
207+
else:
208+
# compare the underlying hashes
209+
stat.sync_state = compare_refs(current_ref, expected_ref_hash)
249210

250211
os.chdir(cwd)
251212

@@ -595,14 +556,58 @@ def _status_v1z_is_dirty(git_output):
595556
#
596557
# ----------------------------------------------------------------
597558
@staticmethod
598-
def _git_branch_vv():
599-
"""Run git branch -vv to obtain verbose branch information, including
600-
upstream tracking and hash.
559+
def _git_current_hash():
560+
"""Return the full hash of the currently checked-out version.
601561
562+
Returns a tuple, (hash_found, hash), where hash_found is a
563+
logical specifying whether a hash was found for HEAD (False
564+
could mean we're not in a git repository at all). (If hash_found
565+
is False, then hash is ''.)
602566
"""
603-
cmd = ['git', 'branch', '--verbose', '--verbose']
604-
git_output = execute_subprocess(cmd, output_to_caller=True)
605-
return git_output
567+
status, git_output = GitRepository._git_revparse_commit("HEAD")
568+
hash_found = not status
569+
if not hash_found:
570+
git_output = ''
571+
return hash_found, git_output
572+
573+
@staticmethod
574+
def _git_current_branch():
575+
"""Determines the name of the current branch.
576+
577+
Returns a tuple, (branch_found, branch_name), where branch_found
578+
is a logical specifying whether a branch name was found for
579+
HEAD. (If branch_found is False, then branch_name is ''.)
580+
"""
581+
cmd = ['git', 'symbolic-ref', '--short', '-q', 'HEAD']
582+
status, git_output = execute_subprocess(cmd,
583+
output_to_caller=True,
584+
status_to_caller=True)
585+
branch_found = not status
586+
if branch_found:
587+
git_output = git_output.strip()
588+
else:
589+
git_output = ''
590+
return branch_found, git_output
591+
592+
@staticmethod
593+
def _git_current_tag():
594+
"""Determines the name tag corresponding to HEAD (if any).
595+
596+
Returns a tuple, (tag_found, tag_name), where tag_found is a
597+
logical specifying whether we found a tag name corresponding to
598+
HEAD. (If tag_found is False, then tag_name is ''.)
599+
"""
600+
# git describe --exact-match --tags HEAD
601+
cmd = ['git', 'describe', '--exact-match', '--tags', 'HEAD']
602+
status, git_output = execute_subprocess(cmd,
603+
output_to_caller=True,
604+
status_to_caller=True)
605+
tag_found = not status
606+
if tag_found:
607+
git_output = git_output.strip()
608+
else:
609+
git_output = ''
610+
return tag_found, git_output
606611

607612
@staticmethod
608613
def _git_showref_tag(ref):
@@ -647,6 +652,7 @@ def _git_revparse_commit(ref):
647652
'{0}^{1}'.format(ref, '{commit}'), ]
648653
status, git_output = execute_subprocess(cmd, status_to_caller=True,
649654
output_to_caller=True)
655+
git_output = git_output.strip()
650656
return status, git_output
651657

652658
@staticmethod

0 commit comments

Comments
 (0)