Skip to content

Commit 711393c

Browse files
authored
feat(git): Improve git status check (#263)
2 parents d4ccd4d + e4de782 commit 711393c

File tree

4 files changed

+260
-25
lines changed

4 files changed

+260
-25
lines changed

CHANGES

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ Generally speaking, refactor / magic is in the process of being stripped
1010
out in the next few releases. The API is subject to change significantly
1111
in pre-1.0 builds.
1212

13+
0.4-current
14+
-----------
15+
- [bug] :meth:`libvcs.git.GitRepo.get_current_remote_name()` Handle case where
16+
upstream is unpushed
17+
- [feature] :meth:`libvcs.git.GitRepo.status()` - Retrieve status of repo
18+
- [feature] :func:`libvcs.git.extract_status()` - Return structured info from
19+
``git status``
20+
1321
0.4.1 <2020-08-01>
1422
------------------
1523
- Remove log statement

doc/api.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ Git
3434
:members:
3535
:show-inheritance:
3636

37+
.. autofunction:: libvcs.git.extract_status
38+
3739
Mercurial
3840
---------
3941

libvcs/git.py

Lines changed: 116 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,61 @@
4040
"""
4141

4242

43+
def extract_status(value):
44+
"""Returns ``git status -sb --porcelain=2`` extracted to a dict
45+
46+
Returns
47+
-------
48+
dict :
49+
Dictionary of git repo's status
50+
"""
51+
pattern = re.compile(
52+
r"""[\n\r]?
53+
(
54+
#
55+
\W+
56+
branch.oid\W+
57+
(?P<branch_oid>
58+
[a-f0-9]{40}
59+
)
60+
)?
61+
(
62+
#
63+
\W+
64+
branch.head
65+
[\W]+
66+
(?P<branch_head>
67+
[\w-]*
68+
)
69+
70+
)?
71+
(
72+
#
73+
\W+
74+
branch.upstream
75+
[\W]+
76+
(?P<branch_upstream>
77+
[/\w-]*
78+
)
79+
)?
80+
(
81+
#
82+
\W+
83+
branch.ab
84+
[\W]+
85+
(?P<branch_ab>
86+
\+(?P<branch_ahead>\d+)
87+
\W{1}
88+
\-(?P<branch_behind>\d+)
89+
)
90+
)?
91+
""",
92+
re.VERBOSE | re.MULTILINE,
93+
)
94+
matches = pattern.search(value)
95+
return matches.groupdict()
96+
97+
4398
class GitRepo(BaseRepo):
4499
bin_name = 'git'
45100
schemes = ('git', 'git+http', 'git+https', 'git+ssh', 'git+git', 'git+file')
@@ -307,7 +362,9 @@ def remotes(self, flat=False):
307362
This used to return a dict of tuples, it now returns a dict of dictionaries
308363
with ``name``, ``fetch_url``, and ``push_url``.
309364
310-
:rtype: dict
365+
Returns
366+
-------
367+
dict
311368
"""
312369
remotes = {}
313370

@@ -340,10 +397,15 @@ def remotes_get(self):
340397
def remote(self, name, **kwargs):
341398
"""Get the fetch and push URL for a specified remote name.
342399
343-
:param name: the remote name used to define the fetch and push URL
344-
:type name: str
345-
:returns: remote name and url in tuple form
346-
:rtype: :class:`libvcs.git.GitRemote`
400+
Parameters
401+
----------
402+
name : str
403+
The remote name used to define the fetch and push URL
404+
405+
Returns
406+
-------
407+
:class:`libvcs.git.GitRemote` :
408+
Remote name and url in tuple form
347409
348410
.. versionchanged:: 0.4.0
349411
@@ -429,11 +491,15 @@ def remote_set(self, url, name='origin', overwrite=False, **kwargs):
429491
def chomp_protocol(url):
430492
"""Return clean VCS url from RFC-style url
431493
432-
:param url: url
433-
:type url: str
434-
:rtype: str
435-
:returns: url as VCS software would accept it
436-
:seealso: #14
494+
Parameters
495+
----------
496+
url : str
497+
PIP-style url
498+
499+
Returns
500+
-------
501+
str :
502+
URL as VCS software would accept it
437503
"""
438504
if '+' in url:
439505
url = url.split('+', 1)[1]
@@ -453,7 +519,9 @@ def chomp_protocol(url):
453519
def get_git_version(self):
454520
"""Return current version of git binary
455521
456-
:rtype: str
522+
Returns
523+
-------
524+
str
457525
"""
458526
VERSION_PFX = 'git version '
459527
version = self.run(['version'])
@@ -463,19 +531,44 @@ def get_git_version(self):
463531
version = ''
464532
return '.'.join(version.split('.')[:3])
465533

534+
def status(self):
535+
"""Retrieve status of project in dict format.
536+
537+
Wraps ``git status --sb --porcelain=2``. Does not include changed files, yet.
538+
539+
Examples
540+
--------
541+
542+
::
543+
544+
print(git_repo.status())
545+
{
546+
"branch_oid": 'de6185fde0806e5c7754ca05676325a1ea4d6348',
547+
"branch_head": 'fix-current-remote-name',
548+
"branch_upstream": 'origin/fix-current-remote-name',
549+
"branch_ab": '+0 -0',
550+
"branch_ahead": '0',
551+
"branch_behind": '0',
552+
}
553+
554+
Returns
555+
-------
556+
dict :
557+
Status of current checked out repository
558+
"""
559+
return extract_status(self.run(['status', '-sb', '--porcelain=2']))
560+
466561
def get_current_remote_name(self):
467562
"""Retrieve name of the remote / upstream of currently checked out branch.
468563
469-
:rtype: str, None if no remote set
564+
Returns
565+
-------
566+
str :
567+
If upstream the same, returns ``branch_name``.
568+
If upstream mismatches, returns ``remote_name/branch_name``.
470569
"""
471-
current_status = self.run(['status', '-sb'])
472-
# git status -sb
473-
# ## v1.0-ourbranch...remotename/v1.0-ourbranch
474-
match = re.match(
475-
r'^## (?P<branch>.*)\.{3}(?P<remote_slash_branch>.*)', current_status,
476-
)
477-
if match is None: # No upstream set
478-
return None
479-
return match.group('remote_slash_branch').replace(
480-
'/' + match.group('branch'), ''
481-
)
570+
match = self.status()
571+
572+
if match['branch_upstream'] is None: # no upstream set
573+
return match['branch_head']
574+
return match['branch_upstream'].replace('/' + match['branch_head'], '')

tests/test_git.py

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44

55
import datetime
66
import os
7+
import textwrap
78

89
import pytest
910

1011
from libvcs import exc
11-
from libvcs._compat import string_types
12-
from libvcs.git import GitRemote, GitRepo
12+
from libvcs._compat import PY2, string_types
13+
from libvcs.git import GitRemote, GitRepo, extract_status
1314
from libvcs.shortcuts import create_repo_from_pip_url
1415
from libvcs.util import run, which
1516

@@ -201,3 +202,134 @@ def test_set_remote(git_repo, repo_name, new_repo_url):
201202
assert new_repo_url in git_repo.remote(
202203
name='myrepo'
203204
), 'Running remove_set should overwrite previous remote'
205+
206+
207+
def test_get_git_version(git_repo):
208+
expected_version = git_repo.run(['--version']).replace('git version ', '')
209+
assert git_repo.get_git_version()
210+
assert expected_version == git_repo.get_git_version()
211+
212+
213+
def test_get_current_remote_name(git_repo):
214+
assert git_repo.get_current_remote_name() == 'origin'
215+
216+
new_branch = 'another-branch-with-no-upstream'
217+
git_repo.run(['checkout', '-B', new_branch])
218+
assert (
219+
git_repo.get_current_remote_name() == new_branch
220+
), 'branch w/o upstream should return branch only'
221+
222+
new_remote_name = 'new_remote_name'
223+
git_repo.set_remote(
224+
name=new_remote_name, url='file://' + git_repo.path, overwrite=True
225+
)
226+
git_repo.run(['fetch', new_remote_name])
227+
git_repo.run(
228+
['branch', '--set-upstream-to', '{}/{}'.format(new_remote_name, new_branch)]
229+
)
230+
assert (
231+
git_repo.get_current_remote_name() == new_remote_name
232+
), 'Should reflect new upstream branch (different remote)'
233+
234+
upstream = '{}/{}'.format(new_remote_name, 'master')
235+
236+
git_repo.run(['branch', '--set-upstream-to', upstream])
237+
assert (
238+
git_repo.get_current_remote_name() == upstream
239+
), 'Should reflect upstream branch (differente remote+branch)'
240+
241+
git_repo.run(['checkout', 'master'])
242+
243+
# Different remote, different branch
244+
remote = '{}/{}'.format(new_remote_name, new_branch)
245+
git_repo.run(['branch', '--set-upstream-to', remote])
246+
assert (
247+
git_repo.get_current_remote_name() == remote
248+
), 'Should reflect new upstream branch (different branch)'
249+
250+
251+
def test_extract_status():
252+
FIXTURE_A = textwrap.dedent(
253+
"""
254+
# branch.oid d4ccd4d6af04b53949f89fbf0cdae13719dc5a08
255+
# branch.head fix-current-remote-name
256+
1 .M N... 100644 100644 100644 91082f119279b6f105ee9a5ce7795b3bdbe2b0de 91082f119279b6f105ee9a5ce7795b3bdbe2b0de CHANGES
257+
""" # NOQA: E501
258+
)
259+
assert {
260+
"branch_oid": 'd4ccd4d6af04b53949f89fbf0cdae13719dc5a08',
261+
"branch_head": 'fix-current-remote-name',
262+
}.items() <= extract_status(FIXTURE_A).items()
263+
264+
265+
@pytest.mark.parametrize(
266+
'fixture,expected_result',
267+
[
268+
[
269+
"""
270+
# branch.oid de6185fde0806e5c7754ca05676325a1ea4d6348
271+
# branch.head fix-current-remote-name
272+
# branch.upstream origin/fix-current-remote-name
273+
# branch.ab +0 -0
274+
1 .M N... 100644 100644 100644 91082f119279b6f105ee9a5ce7795b3bdbe2b0de 91082f119279b6f105ee9a5ce7795b3bdbe2b0de CHANGES
275+
1 .M N... 100644 100644 100644 302ca2c18d4c295ce217bff5f93e1ba342dc6665 302ca2c18d4c295ce217bff5f93e1ba342dc6665 tests/test_git.py
276+
""", # NOQA: E501
277+
{
278+
"branch_oid": 'de6185fde0806e5c7754ca05676325a1ea4d6348',
279+
"branch_head": 'fix-current-remote-name',
280+
"branch_upstream": 'origin/fix-current-remote-name',
281+
"branch_ab": '+0 -0',
282+
"branch_ahead": '0',
283+
"branch_behind": '0',
284+
},
285+
],
286+
[
287+
'# branch.upstream moo/origin/myslash/remote',
288+
{"branch_upstream": 'moo/origin/myslash/remote'},
289+
],
290+
],
291+
)
292+
def test_extract_status_b(fixture, expected_result):
293+
if PY2:
294+
assert (
295+
extract_status(textwrap.dedent(fixture)).items() <= expected_result.items()
296+
)
297+
else:
298+
assert (
299+
extract_status(textwrap.dedent(fixture)).items() >= expected_result.items()
300+
)
301+
302+
303+
@pytest.mark.parametrize(
304+
'fixture,expected_result',
305+
[
306+
[
307+
'# branch.ab +1 -83',
308+
{"branch_ab": '+1 -83', "branch_ahead": '1', "branch_behind": '83',},
309+
],
310+
[
311+
"""
312+
# branch.ab +0 -0
313+
""",
314+
{"branch_ab": '+0 -0', "branch_ahead": '0', "branch_behind": '0',},
315+
],
316+
[
317+
"""
318+
# branch.ab +1 -83
319+
""",
320+
{"branch_ab": '+1 -83', "branch_ahead": '1', "branch_behind": '83',},
321+
],
322+
[
323+
"""
324+
# branch.ab +9999999 -9999999
325+
""",
326+
{
327+
"branch_ab": '+9999999 -9999999',
328+
"branch_ahead": '9999999',
329+
"branch_behind": '9999999',
330+
},
331+
],
332+
],
333+
)
334+
def test_extract_status_c(fixture, expected_result):
335+
assert expected_result.items() <= extract_status(textwrap.dedent(fixture)).items()

0 commit comments

Comments
 (0)