Skip to content

Commit 400d728

Browse files
committed
Implemented RemoteProgress parsing for git-fetch, which might become available at some point natively, within the git suite
Progress parsing now deals properly with Ascii_Escape characters that are meant for the tty - git might stop sending this at some point, but we can deal with it no matter what
1 parent 77cde00 commit 400d728

File tree

2 files changed

+115
-50
lines changed

2 files changed

+115
-50
lines changed

lib/git/remote.py

+92-41
Original file line numberDiff line numberDiff line change
@@ -38,32 +38,46 @@ def _call_config(self, method, *args, **kwargs):
3838
return getattr(self._config, method)(self._section_name, *args, **kwargs)
3939

4040

41-
class PushProgress(object):
41+
class RemoteProgress(object):
4242
"""
4343
Handler providing an interface to parse progress information emitted by git-push
44-
and to dispatch callbacks allowing subclasses to react to the progress.
44+
and git-fetch and to dispatch callbacks allowing subclasses to react to the progress.
4545
"""
4646
BEGIN, END, COUNTING, COMPRESSING, WRITING = [ 1 << x for x in range(5) ]
4747
STAGE_MASK = BEGIN|END
4848
OP_MASK = COUNTING|COMPRESSING|WRITING
4949

5050
__slots__ = ("_cur_line", "_seen_ops")
51-
re_op_absolute = re.compile("([\w\s]+):\s+()(\d+)()(.*)")
52-
re_op_relative = re.compile("([\w\s]+):\s+(\d+)% \((\d+)/(\d+)\)(.*)")
51+
re_op_absolute = re.compile("(remote: )?([\w\s]+):\s+()(\d+)()(.*)")
52+
re_op_relative = re.compile("(remote: )?([\w\s]+):\s+(\d+)% \((\d+)/(\d+)\)(.*)")
5353

5454
def __init__(self):
5555
self._seen_ops = list()
5656

5757
def _parse_progress_line(self, line):
5858
"""
5959
Parse progress information from the given line as retrieved by git-push
60-
"""
60+
or git-fetch
61+
@return: list(line, ...) list of lines that could not be processed"""
6162
# handle
6263
# Counting objects: 4, done.
6364
# Compressing objects: 50% (1/2) \rCompressing objects: 100% (2/2) \rCompressing objects: 100% (2/2), done.
6465
self._cur_line = line
6566
sub_lines = line.split('\r')
67+
failed_lines = list()
6668
for sline in sub_lines:
69+
# find esacpe characters and cut them away - regex will not work with
70+
# them as they are non-ascii. As git might expect a tty, it will send them
71+
last_valid_index = None
72+
for i,c in enumerate(reversed(sline)):
73+
if ord(c) < 32:
74+
# its a slice index
75+
last_valid_index = -i-1
76+
# END character was non-ascii
77+
# END for each character in sline
78+
if last_valid_index is not None:
79+
sline = sline[:last_valid_index]
80+
# END cut away invalid part
6781
sline = sline.rstrip()
6882

6983
cur_count, max_count = None, None
@@ -73,11 +87,13 @@ def _parse_progress_line(self, line):
7387

7488
if not match:
7589
self.line_dropped(sline)
90+
failed_lines.append(sline)
7691
continue
7792
# END could not get match
7893

7994
op_code = 0
80-
op_name, percent, cur_count, max_count, message = match.groups()
95+
remote, op_name, percent, cur_count, max_count, message = match.groups()
96+
8197
# get operation id
8298
if op_name == "Counting objects":
8399
op_code |= self.COUNTING
@@ -106,8 +122,8 @@ def _parse_progress_line(self, line):
106122
# END end message handling
107123

108124
self.update(op_code, cur_count, max_count, message)
109-
110125
# END for each sub line
126+
return failed_lines
111127

112128
def line_dropped(self, line):
113129
"""
@@ -574,38 +590,75 @@ def update(self, **kwargs):
574590
self.repo.git.remote("update", self.name)
575591
return self
576592

577-
def _get_fetch_info_from_stderr(self, stderr):
593+
def _digest_process_messages(self, fh, progress):
594+
"""Read progress messages from file-like object fh, supplying the respective
595+
progress messages to the progress instance.
596+
@return: list(line, ...) list of lines without linebreaks that did
597+
not contain progress information"""
598+
line_so_far = ''
599+
dropped_lines = list()
600+
while True:
601+
char = fh.read(1)
602+
if not char:
603+
break
604+
605+
if char in ('\r', '\n'):
606+
dropped_lines.extend(progress._parse_progress_line(line_so_far))
607+
line_so_far = ''
608+
else:
609+
line_so_far += char
610+
# END process parsed line
611+
# END while file is not done reading
612+
return dropped_lines
613+
614+
615+
def _finalize_proc(self, proc):
616+
"""Wait for the process (fetch, pull or push) and handle its errors accordingly"""
617+
try:
618+
proc.wait()
619+
except GitCommandError,e:
620+
# if a push has rejected items, the command has non-zero return status
621+
# a return status of 128 indicates a connection error - reraise the previous one
622+
if proc.poll() == 128:
623+
raise
624+
pass
625+
# END exception handling
626+
627+
628+
def _get_fetch_info_from_stderr(self, proc, progress):
578629
# skip first line as it is some remote info we are not interested in
579630
output = IterableList('name')
580-
err_info = stderr.splitlines()[1:]
631+
632+
633+
# lines which are no progress are fetch info lines
634+
# this also waits for the command to finish
635+
# Skip some progress lines that don't provide relevant information
636+
fetch_info_lines = list()
637+
for line in self._digest_process_messages(proc.stderr, progress):
638+
if line.startswith('From') or line.startswith('remote: Total'):
639+
continue
640+
fetch_info_lines.append(line)
641+
# END for each line
581642

582643
# read head information
583644
fp = open(os.path.join(self.repo.git_dir, 'FETCH_HEAD'),'r')
584645
fetch_head_info = fp.readlines()
585646
fp.close()
586647

648+
assert len(fetch_info_lines) == len(fetch_head_info)
649+
587650
output.extend(FetchInfo._from_line(self.repo, err_line, fetch_line)
588-
for err_line,fetch_line in zip(err_info, fetch_head_info))
651+
for err_line,fetch_line in zip(fetch_info_lines, fetch_head_info))
652+
653+
self._finalize_proc(proc)
589654
return output
590655

591656
def _get_push_info(self, proc, progress):
592657
# read progress information from stderr
593658
# we hope stdout can hold all the data, it should ...
594659
# read the lines manually as it will use carriage returns between the messages
595660
# to override the previous one. This is why we read the bytes manually
596-
line_so_far = ''
597-
while True:
598-
char = proc.stderr.read(1)
599-
if not char:
600-
break
601-
602-
if char in ('\r', '\n'):
603-
progress._parse_progress_line(line_so_far)
604-
line_so_far = ''
605-
else:
606-
line_so_far += char
607-
# END process parsed line
608-
# END for each progress line
661+
self._digest_process_messages(proc.stderr, progress)
609662

610663
output = IterableList('name')
611664
for line in proc.stdout.readlines():
@@ -616,19 +669,12 @@ def _get_push_info(self, proc, progress):
616669
pass
617670
# END exception handling
618671
# END for each line
619-
try:
620-
proc.wait()
621-
except GitCommandError,e:
622-
# if a push has rejected items, the command has non-zero return status
623-
# a return status of 128 indicates a connection error - reraise the previous one
624-
if proc.poll() == 128:
625-
raise
626-
pass
627-
# END exception handling
672+
673+
self._finalize_proc(proc)
628674
return output
629675

630676

631-
def fetch(self, refspec=None, **kwargs):
677+
def fetch(self, refspec=None, progress=None, **kwargs):
632678
"""
633679
Fetch the latest changes for this remote
634680
@@ -643,7 +689,9 @@ def fetch(self, refspec=None, **kwargs):
643689
See also git-push(1).
644690
645691
Taken from the git manual
646-
692+
``progress``
693+
See 'push' method
694+
647695
``**kwargs``
648696
Additional arguments to be passed to git-fetch
649697
@@ -655,25 +703,28 @@ def fetch(self, refspec=None, **kwargs):
655703
As fetch does not provide progress information to non-ttys, we cannot make
656704
it available here unfortunately as in the 'push' method.
657705
"""
658-
status, stdout, stderr = self.repo.git.fetch(self, refspec, with_extended_output=True, v=True, **kwargs)
659-
return self._get_fetch_info_from_stderr(stderr)
706+
proc = self.repo.git.fetch(self, refspec, with_extended_output=True, as_process=True, v=True, **kwargs)
707+
return self._get_fetch_info_from_stderr(proc, progress or RemoteProgress())
660708

661-
def pull(self, refspec=None, **kwargs):
709+
def pull(self, refspec=None, progress=None, **kwargs):
662710
"""
663711
Pull changes from the given branch, being the same as a fetch followed
664712
by a merge of branch with your local branch.
665713
666714
``refspec``
667715
see 'fetch' method
716+
717+
``progress``
718+
see 'push' method
668719
669720
``**kwargs``
670721
Additional arguments to be passed to git-pull
671722
672723
Returns
673724
Please see 'fetch' method
674725
"""
675-
status, stdout, stderr = self.repo.git.pull(self, refspec, with_extended_output=True, v=True, **kwargs)
676-
return self._get_fetch_info_from_stderr(stderr)
726+
proc = self.repo.git.pull(self, refspec, with_extended_output=True, as_process=True, v=True, **kwargs)
727+
return self._get_fetch_info_from_stderr(proc, progress or RemoteProgress())
677728

678729
def push(self, refspec=None, progress=None, **kwargs):
679730
"""
@@ -683,7 +734,7 @@ def push(self, refspec=None, progress=None, **kwargs):
683734
see 'fetch' method
684735
685736
``progress``
686-
Instance of type PushProgress allowing the caller to receive
737+
Instance of type RemoteProgress allowing the caller to receive
687738
progress information until the method returns.
688739
If None, progress information will be discarded
689740
@@ -700,7 +751,7 @@ def push(self, refspec=None, progress=None, **kwargs):
700751
be null.
701752
"""
702753
proc = self.repo.git.push(self, refspec, porcelain=True, as_process=True, **kwargs)
703-
return self._get_push_info(proc, progress or PushProgress())
754+
return self._get_push_info(proc, progress or RemoteProgress())
704755

705756
@property
706757
def config_reader(self):

test/git/test_remote.py

+23-9
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,21 @@
1414
# assure we have repeatable results
1515
random.seed(0)
1616

17-
class TestPushProgress(PushProgress):
18-
__slots__ = ( "_seen_lines", "_stages_per_op" )
17+
class TestRemoteProgress(RemoteProgress):
18+
__slots__ = ( "_seen_lines", "_stages_per_op", '_num_progress_messages' )
1919
def __init__(self):
20-
super(TestPushProgress, self).__init__()
20+
super(TestRemoteProgress, self).__init__()
2121
self._seen_lines = list()
2222
self._stages_per_op = dict()
23+
self._num_progress_messages = 0
2324

2425
def _parse_progress_line(self, line):
2526
# we may remove the line later if it is dropped
2627
# Keep it for debugging
2728
self._seen_lines.append(line)
28-
super(TestPushProgress, self)._parse_progress_line(line)
29+
rval = super(TestRemoteProgress, self)._parse_progress_line(line)
2930
assert len(line) > 1, "line %r too short" % line
31+
return rval
3032

3133
def line_dropped(self, line):
3234
try:
@@ -44,11 +46,15 @@ def update(self, op_code, cur_count, max_count=None, message=''):
4446

4547
if op_code & (self.WRITING|self.END) == (self.WRITING|self.END):
4648
assert message
47-
# END check we get message
49+
# END check we get message
50+
51+
self._num_progress_messages += 1
52+
4853

4954
def make_assertion(self):
55+
# we don't always receive messages
5056
if not self._seen_lines:
51-
return
57+
return
5258

5359
# sometimes objects are not compressed which is okay
5460
assert len(self._seen_ops) in (2,3)
@@ -59,6 +65,10 @@ def make_assertion(self):
5965
assert stages & self.STAGE_MASK == self.STAGE_MASK
6066
# END for each op/stage
6167

68+
def assert_received_message(self):
69+
assert self._num_progress_messages
70+
71+
6272
class TestRemote(TestBase):
6373

6474
def _print_fetchhead(self, repo):
@@ -124,7 +134,10 @@ def _test_fetch(self,remote, rw_repo, remote_repo):
124134
self._test_fetch_info(rw_repo)
125135

126136
def fetch_and_test(remote, **kwargs):
137+
progress = TestRemoteProgress()
138+
kwargs['progress'] = progress
127139
res = remote.fetch(**kwargs)
140+
progress.make_assertion()
128141
self._test_fetch_result(res, remote)
129142
return res
130143
# END fetch and check
@@ -257,7 +270,7 @@ def _test_push_and_pull(self,remote, rw_repo, remote_repo):
257270

258271
# simple file push
259272
self._commit_random_file(rw_repo)
260-
progress = TestPushProgress()
273+
progress = TestRemoteProgress()
261274
res = remote.push(lhead.reference, progress)
262275
assert isinstance(res, IterableList)
263276
self._test_push_result(res, remote)
@@ -281,7 +294,7 @@ def _test_push_and_pull(self,remote, rw_repo, remote_repo):
281294
assert len(res) == 0
282295

283296
# push new tags
284-
progress = TestPushProgress()
297+
progress = TestRemoteProgress()
285298
to_be_updated = "my_tag.1.0RV"
286299
new_tag = TagReference.create(rw_repo, to_be_updated)
287300
other_tag = TagReference.create(rw_repo, "my_obj_tag.2.1aRV", message="my message")
@@ -305,10 +318,11 @@ def _test_push_and_pull(self,remote, rw_repo, remote_repo):
305318
res = remote.push(":%s" % new_tag.path)
306319
self._test_push_result(res, remote)
307320
assert res[0].flags & PushInfo.DELETED
321+
progress.assert_received_message()
308322

309323
# push new branch
310324
new_head = Head.create(rw_repo, "my_new_branch")
311-
progress = TestPushProgress()
325+
progress = TestRemoteProgress()
312326
res = remote.push(new_head, progress)
313327
assert res[0].flags & PushInfo.NEW_HEAD
314328
progress.make_assertion()

0 commit comments

Comments
 (0)