Skip to content

Commit 0f54268

Browse files
committed
Merge remote-tracking branch 'jorio/push_transfer_progress'
2 parents 47ca99a + 7bccdff commit 0f54268

File tree

4 files changed

+123
-14
lines changed

4 files changed

+123
-14
lines changed

pygit2/callbacks.py

+34-2
Original file line numberDiff line numberDiff line change
@@ -183,15 +183,30 @@ def certificate_check(self, certificate, valid, host):
183183

184184
def transfer_progress(self, stats):
185185
"""
186-
Transfer progress callback. Override with your own function to report
187-
transfer progress.
186+
During the download of new data, this will be regularly called with
187+
the indexer's progress.
188+
189+
Override with your own function to report transfer progress.
188190
189191
Parameters:
190192
191193
stats : TransferProgress
192194
The progress up to now.
193195
"""
194196

197+
def push_transfer_progress(
198+
self, objects_pushed: int, total_objects: int, bytes_pushed: int
199+
):
200+
"""
201+
During the upload portion of a push, this will be regularly called
202+
with progress information.
203+
204+
Be aware that this is called inline with pack building operations,
205+
so performance may be affected.
206+
207+
Override with your own function to report push transfer progress.
208+
"""
209+
195210
def update_tips(self, refname, old, new):
196211
"""
197212
Update tips callback. Override with your own function to report
@@ -370,6 +385,13 @@ def git_push_options(payload, opts=None):
370385
opts.callbacks.credentials = C._credentials_cb
371386
opts.callbacks.certificate_check = C._certificate_check_cb
372387
opts.callbacks.push_update_reference = C._push_update_reference_cb
388+
# Per libgit2 sources, push_transfer_progress may incur a performance hit.
389+
# So, set it only if the user has overridden the no-op stub.
390+
if (
391+
type(payload).push_transfer_progress
392+
is not RemoteCallbacks.push_transfer_progress
393+
):
394+
opts.callbacks.push_transfer_progress = C._push_transfer_progress_cb
373395
# Payload
374396
handle = ffi.new_handle(payload)
375397
opts.callbacks.payload = handle
@@ -554,6 +576,16 @@ def _transfer_progress_cb(stats_ptr, data):
554576
return 0
555577

556578

579+
@libgit2_callback
580+
def _push_transfer_progress_cb(current, total, bytes_pushed, payload):
581+
push_transfer_progress = getattr(payload, 'push_transfer_progress', None)
582+
if not push_transfer_progress:
583+
return 0
584+
585+
push_transfer_progress(current, total, bytes_pushed)
586+
return 0
587+
588+
557589
@libgit2_callback
558590
def _update_tips_cb(refname, a, b, data):
559591
update_tips = getattr(data, 'update_tips', None)

pygit2/decl/callbacks.h

+6
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ extern "Python" int _transfer_progress_cb(
3838
const git_indexer_progress *stats,
3939
void *payload);
4040

41+
extern "Python" int _push_transfer_progress_cb(
42+
unsigned int objects_pushed,
43+
unsigned int total_objects,
44+
size_t bytes_pushed,
45+
void *payload);
46+
4147
extern "Python" int _update_tips_cb(
4248
const char *refname,
4349
const git_oid *a,

test/test_remote.py

+74-12
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,8 @@ def test_fetch_depth_one(testrepo):
252252

253253
def test_transfer_progress(emptyrepo):
254254
class MyCallbacks(pygit2.RemoteCallbacks):
255-
def transfer_progress(emptyrepo, stats):
256-
emptyrepo.tp = stats
255+
def transfer_progress(self, stats):
256+
self.tp = stats
257257

258258
callbacks = MyCallbacks()
259259
remote = emptyrepo.remotes[0]
@@ -362,6 +362,59 @@ def test_push_when_up_to_date_succeeds(origin, clone, remote):
362362
assert origin_tip == clone_tip
363363

364364

365+
def test_push_transfer_progress(origin, clone, remote):
366+
tip = clone[clone.head.target]
367+
new_tip_id = clone.create_commit(
368+
'refs/heads/master',
369+
tip.author,
370+
tip.author,
371+
'empty commit',
372+
tip.tree.id,
373+
[tip.id],
374+
)
375+
376+
# NOTE: We're currently not testing bytes_pushed due to a bug in libgit2
377+
# 1.9.0: it passes a junk value for bytes_pushed when pushing to a remote
378+
# on the local filesystem, as is the case in this unit test. (When pushing
379+
# to a remote over the network, the value is correct.)
380+
class MyCallbacks(pygit2.RemoteCallbacks):
381+
def push_transfer_progress(self, objects_pushed, total_objects, bytes_pushed):
382+
self.objects_pushed = objects_pushed
383+
self.total_objects = total_objects
384+
385+
assert origin.branches['master'].target == tip.id
386+
387+
callbacks = MyCallbacks()
388+
remote.push(['refs/heads/master'], callbacks=callbacks)
389+
assert callbacks.objects_pushed == 1
390+
assert callbacks.total_objects == 1
391+
assert origin.branches['master'].target == new_tip_id
392+
393+
394+
def test_push_interrupted_from_callbacks(origin, clone, remote):
395+
tip = clone[clone.head.target]
396+
clone.create_commit(
397+
'refs/heads/master',
398+
tip.author,
399+
tip.author,
400+
'empty commit',
401+
tip.tree.id,
402+
[tip.id],
403+
)
404+
405+
class MyCallbacks(pygit2.RemoteCallbacks):
406+
def push_transfer_progress(self, objects_pushed, total_objects, bytes_pushed):
407+
raise InterruptedError('retreat! retreat!')
408+
409+
assert origin.branches['master'].target == tip.id
410+
411+
callbacks = MyCallbacks()
412+
with pytest.raises(InterruptedError, match='retreat! retreat!'):
413+
remote.push(['refs/heads/master'], callbacks=callbacks)
414+
415+
assert origin.branches['master'].target == tip.id
416+
417+
365418
def test_push_non_fast_forward_commits_to_remote_fails(origin, clone, remote):
366419
tip = origin[origin.head.target]
367420
origin.create_commit(
@@ -386,22 +439,31 @@ def test_push_non_fast_forward_commits_to_remote_fails(origin, clone, remote):
386439
remote.push(['refs/heads/master'])
387440

388441

389-
@patch.object(pygit2.callbacks, 'RemoteCallbacks')
390-
def test_push_options(mock_callbacks, origin, clone, remote):
391-
remote.push(['refs/heads/master'])
392-
remote_push_options = mock_callbacks.return_value.push_options.remote_push_options
442+
def test_push_options(origin, clone, remote):
443+
from pygit2 import RemoteCallbacks
444+
445+
callbacks = RemoteCallbacks()
446+
remote.push(['refs/heads/master'], callbacks)
447+
remote_push_options = callbacks.push_options.remote_push_options
393448
assert remote_push_options.count == 0
394449

395-
remote.push(['refs/heads/master'], push_options=[])
396-
remote_push_options = mock_callbacks.return_value.push_options.remote_push_options
450+
callbacks = RemoteCallbacks()
451+
remote.push(['refs/heads/master'], callbacks, push_options=[])
452+
remote_push_options = callbacks.push_options.remote_push_options
397453
assert remote_push_options.count == 0
398454

399-
remote.push(['refs/heads/master'], push_options=['foo'])
400-
remote_push_options = mock_callbacks.return_value.push_options.remote_push_options
455+
callbacks = RemoteCallbacks()
456+
# Local remotes don't support push_options, so pushing will raise an error.
457+
# However, push_options should still be set in RemoteCallbacks.
458+
with pytest.raises(pygit2.GitError, match='push-options not supported by remote'):
459+
remote.push(['refs/heads/master'], callbacks, push_options=['foo'])
460+
remote_push_options = callbacks.push_options.remote_push_options
401461
assert remote_push_options.count == 1
402462
# strings pointed to by remote_push_options.strings[] are already freed
403463

404-
remote.push(['refs/heads/master'], push_options=['Option A', 'Option B'])
405-
remote_push_options = mock_callbacks.return_value.push_options.remote_push_options
464+
callbacks = RemoteCallbacks()
465+
with pytest.raises(pygit2.GitError, match='push-options not supported by remote'):
466+
remote.push(['refs/heads/master'], callbacks, push_options=['Opt A', 'Opt B'])
467+
remote_push_options = callbacks.push_options.remote_push_options
406468
assert remote_push_options.count == 2
407469
# strings pointed to by remote_push_options.strings[] are already freed

test/test_repository.py

+9
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ def __init__(self):
9999
super().__init__()
100100
self.conflicting_paths = set()
101101
self.updated_paths = set()
102+
self.completed_steps = -1
103+
self.total_steps = -1
102104

103105
def checkout_notify_flags(self) -> CheckoutNotify:
104106
return CheckoutNotify.CONFLICT | CheckoutNotify.UPDATED
@@ -109,12 +111,17 @@ def checkout_notify(self, why, path, baseline, target, workdir):
109111
elif why == CheckoutNotify.UPDATED:
110112
self.updated_paths.add(path)
111113

114+
def checkout_progress(self, path: str, completed_steps: int, total_steps: int):
115+
self.completed_steps = completed_steps
116+
self.total_steps = total_steps
117+
112118
# checkout i18n with conflicts and default strategy should not be possible
113119
callbacks = MyCheckoutCallbacks()
114120
with pytest.raises(pygit2.GitError):
115121
testrepo.checkout(ref_i18n, callbacks=callbacks)
116122
# make sure the callbacks caught that
117123
assert {'bye.txt'} == callbacks.conflicting_paths
124+
assert -1 == callbacks.completed_steps # shouldn't have done anything
118125

119126
# checkout i18n with GIT_CHECKOUT_FORCE
120127
head = testrepo.head
@@ -125,6 +132,8 @@ def checkout_notify(self, why, path, baseline, target, workdir):
125132
# make sure the callbacks caught the files affected by the checkout
126133
assert set() == callbacks.conflicting_paths
127134
assert {'bye.txt', 'new'} == callbacks.updated_paths
135+
assert callbacks.completed_steps > 0
136+
assert callbacks.completed_steps == callbacks.total_steps
128137

129138

130139
def test_checkout_aborted_from_callbacks(testrepo):

0 commit comments

Comments
 (0)