Git-Native
view release on metacpan or search on metacpan
t/49-remote-callbacks.t view on Meta::CPAN
# The regression this pins (karr #16): updated entries used to be
# { ref, from, to } on fetch and { ref, reason } on push, so
# $result->updated->[0]{to} silently returned undef on a push â a key that
# was not there at all rather than a value that was unknown. Anything
# reading a Result generically broke on whichever operation it was not
# written against. The key set is now the contract; if a callback ever
# grows or drops a field again, this is the assertion that fails.
my @fetch_keys = sort keys %{ $fetch_result->updated->[0] };
my @push_keys = sort keys %{ $push_result->updated->[0] };
is \@fetch_keys, [qw( from reason ref to )],
'a fetch update entry has exactly ref/from/to/reason';
is \@push_keys, \@fetch_keys,
'a push update entry has exactly the same keys as a fetch update entry';
# ---- 4. push push_update_reference: the rejection path --------------
#
# The REJECTED branch is unreachable through a file:// remote in libgit2
# 1.5: the local transport does not do report-status and does not run
# receive hooks, so the server side never sends an "ng" for a ref. Rather
# than leave the branch untested until someone sets
# TEST_GIT_NATIVE_SSH_URL, drive the thunk straight from Perl the way
# t/52-credential-callback.t drives the credential thunk â the closure is
# a pure function of ( refname, status, payload ) and an
# FFI::Platypus::Closure is a blessed CODE ref.
#
# int cb(const char *refname, const char *status, void *data)
subtest 'push_update_reference sorts the server verdict into updated/rejected' => sub {
my ( @rejected, @updated );
my ( $closure, $keep ) = Git::Native::Remote::_make_push_update_thunk(
\@rejected, \@updated,
{ 'refs/heads/ok' => 'a' x 40,
'refs/heads/empty' => 'b' x 40,
'refs/heads/gone' => undef },
);
# status == NULL: libgit2's "the server raised no objection".
is $closure->( 'refs/heads/ok', undef, 0 ), 0,
'a NULL status returns 0 so the push continues';
# status == "": a server that reported success with no message.
is $closure->( 'refs/heads/empty', '', 0 ), 0,
'an empty status returns 0 too';
# status != "": the server refused this ref, and said why.
is $closure->( 'refs/heads/nope', 'pre-receive hook declined', 0 ), 0,
'a rejection still returns 0 â one refused ref must not abort the '
. 'reporting of the others';
# A delete refspec: accepted, but there is no oid on the far side now.
is $closure->( 'refs/heads/gone', undef, 0 ), 0,
'a deleted ref is reported through the same callback';
is \@updated, [
{ ref => 'refs/heads/ok', from => undef, to => 'a' x 40, reason => '' },
{ ref => 'refs/heads/empty', from => undef, to => 'b' x 40, reason => '' },
{ ref => 'refs/heads/gone', from => undef, to => undef, reason => '' },
], 'NULL and "" are both "accepted", and each entry carries the four-key '
. 'shape with `to` taken from the refspec target map';
is \@rejected, [
{ ref => 'refs/heads/nope', reason => 'pre-receive hook declined' },
], 'a non-empty status is a rejection carrying the server message verbatim';
ok $keep, 'the thunk returns a keepalive alongside the closure';
};
# A ref the server names but the refspec map does not know (a push through
# a refspec whose source is not a resolvable local reference) must still
# produce the full key set â an unknown oid is undef, never a missing key.
subtest 'an unmapped refname still yields the full four-key shape' => sub {
my ( @rejected, @updated );
my ( $closure, $keep ) = Git::Native::Remote::_make_push_update_thunk(
\@rejected, \@updated,
);
$closure->( 'refs/heads/surprise', undef, 0 );
is \@updated, [
{ ref => 'refs/heads/surprise', from => undef, to => undef, reason => '' },
], 'no target known for the ref: to is an explicit undef';
};
sub commit_in {
my ( $repo, $text ) = @_;
my $blob = $repo->blob_create_frombuffer($text);
my $tb = $repo->tree_builder;
$tb->insert( name => 'data', oid => $blob, mode => 0100644 );
return $repo->commit_create(
tree => $tb->write, parents => [], message => $text,
);
}
# The `to` a push reports comes from pairing the expanded refspecs with the
# local refs they name â the one piece of pure Perl between the refspec list
# and the callback. Unit-test it directly, no remote involved: a wrong map
# here means a push silently reports the wrong oid, which is worse than the
# missing key it replaced.
subtest '_push_update_targets maps destination refnames to local oids' => sub {
my $tmp_t = Path::Tiny->tempdir;
my $t = Git::Native->init("$tmp_t");
my $c = commit_in( $t, "target\n" );
$t->reference_create( 'refs/karr/a', $c, force => 1 );
$t->reference_create( 'refs/heads/main', $c, force => 1 );
$t->set_head('refs/heads/main');
my $rmt = $t->remote_anonymous('file:///nonexistent');
my $map = Git::Native::Remote::_push_update_targets( $rmt, [
'+refs/karr/a:refs/karr/a', # forced, same name
'refs/karr/a:refs/backup/a', # remapped destination
':refs/karr/gone', # delete refspec (what prune emits)
'refs/karr/missing:refs/karr/m', # source that is not a local ref
'HEAD:refs/heads/from-head', # symbolic source
'refs/karr/a', # no colon: source is also destination
] );
is $map->{'refs/karr/a'}, $c->hex,
'a same-name refspec maps the destination to the local oid';
is $map->{'refs/backup/a'}, $c->hex,
'a remapped destination is keyed by the REMOTE name, which is what '
. 'push_update_reference reports back';
ok exists $map->{'refs/karr/gone'},
'a delete refspec is in the map...';
is $map->{'refs/karr/gone'}, undef,
'...with an undef target, which is how a delete becomes to => undef';
is $map->{'refs/karr/m'}, undef,
'an unresolvable source yields undef rather than dying the push';
t/49-remote-callbacks.t view on Meta::CPAN
# Drop it locally and push --prune: the delete refspec we synthesise
# comes back through push_update_reference like any accepted ref.
$src->reference_delete('refs/karr/drop');
my $pr = $src_rmt->push(
refspecs => ['+refs/karr/*:refs/karr/*'], prune => 1,
);
is scalar @{ $pr->rejected }, 0, 'prune push rejected nothing';
my %by_ref = map { $_->{ref} => $_ } @{ $pr->updated };
is [ sort keys %by_ref ], [ 'refs/karr/drop', 'refs/karr/keep' ],
'prune push reports both the written ref and the deleted one';
is $by_ref{'refs/karr/keep'}{to}, $c_keep->hex,
'the ref that was written carries its new oid';
is $by_ref{'refs/karr/drop'}{to}, undef,
'the ref that was DELETED carries to => undef, so a caller can tell the '
. 'two apart without re-listing the remote';
is [ sort keys %{ $by_ref{'refs/karr/drop'} } ], [qw( from reason ref to )],
'a delete entry is not a special shape, just to => undef';
ok !$db_bare->reference_exists('refs/karr/drop'),
'the remote really lost the ref (the report is not lying)';
# Same story on the fetch side.
my $fr = $mir_rmt->fetch(
refspecs => ['+refs/karr/*:refs/karr/*'], prune => 1,
);
my %f_by_ref = map { $_->{ref} => $_ } @{ $fr->updated };
ok exists $f_by_ref{'refs/karr/drop'},
'fetch --prune reports the stale ref it dropped';
is $f_by_ref{'refs/karr/drop'}{to}, undef,
'a pruned mirror ref is to => undef, not the 40-zero oid libgit2 passes';
is $f_by_ref{'refs/karr/drop'}{from}, $c_drop->hex,
'from still says where the ref used to point';
ok !$mirror->reference_exists('refs/karr/drop'),
'the mirror really lost the ref';
};
# ---- 5. Result->ok: semantics -------------------------------------
is( Git::Native::Remote::Result->new->ok, 1,
'a fresh Result with empty lists is ok' );
isnt( Git::Native::Remote::Result->new(
updated => [ { ref => 'r', from => 'a', to => 'b' } ]
)->ok, 1, 'a Result with updates is not ok' );
isnt( Git::Native::Remote::Result->new(
rejected => [ { ref => 'r', reason => 'no' } ]
)->ok, 1, 'a Result with rejections is not ok' );
# ---- 6. closure lifetime: closure must outlive the C call ---------
# Build a fetch inside a scope that ends before the C call returns. If
# the closure is GC'd before the C call, we segfault. We can't observe
# a successful segfault from a test, so this test simply exercises the
# pattern; if Remote keeps the closure in @keep as it claims, the fetch
# completes cleanly. (The hidden contract test: prove by absence of
# segfault.)
sub fetch_in_nested_scope {
my ($url) = @_;
my $tmp = Path::Tiny->tempdir;
my $r = Git::Native->init("$tmp");
my $rmt = $r->remote_create( 'o', $url );
my $kept;
do {
# The fetch result itself holds the keepalive. Discard the
# intermediate; the returned Result's @updated list captures the
# outcome independently of the closure's lifetime.
$kept = $rmt->fetch( refspecs => ['+refs/karr/*:refs/karr/*'] );
};
return $kept;
}
my $nested = fetch_in_nested_scope( 'file://' . $tmp_bare );
ok $nested, 'fetch in nested scope completes without segfault';
ok scalar @{ $nested->updated } >= 1,
'nested-scope fetch still recorded updates';
done_testing;
( run in 2.538 seconds using v1.01-cache-2.11-cpan-14f38c9f855 )