App-karr

 view release on metacpan or  search on metacpan

t/78-push-rejection.t  view on Meta::CPAN

use strict;
use warnings;
use Test::More;
use lib 't/lib';
use TestGit qw( require_git_c );
require_git_c();
use File::Temp qw( tempdir );
use Path::Tiny qw( path );
use Time::HiRes ();
use App::karr::Git;
use App::karr::Task;
use App::karr::Role::SyncLifecycle;
use Git::Native::Remote;
use Git::Native::Remote::Result;

# Ticket #84: a push whose refs the server refused was reported as a completed
# sync. libgit2 returns 0 from git_remote_push even when every single ref was
# rejected -- a pre-receive hook, a protected ref, a non-ff on a non-forced
# refspec -- so the per-ref status exists only in the
# Git::Native::Remote::Result 0.004 hands back, and karr threw that away. The
# board then diverged from the remote with no signal at all.
#
# Note on how this is tested: libgit2's *local* transport (a file-path remote)
# bypasses a bare repo's pre-receive hook entirely -- verified while writing
# this: the hook exits 1 and the refs land on the remote anyway. So a file-path
# remote cannot produce a native server rejection, and the native half is
# covered two ways instead: with the Result handed back directly (deterministic,
# always runs), and end to end over `git daemon`, which does run receive-pack
# and its hooks (skipped where no daemon can be started). The CLI fallback runs
# the real git binary against a file-path remote, where the hook does fire.

my $DAEMON_PID;
END { kill 'TERM', $DAEMON_PID if $DAEMON_PID }

sub task {
    my ( $id, $title ) = @_;
    return App::karr::Task->new(
        id => $id, title => $title, status => 'todo',
        priority => 'high', class => 'standard', body => '',
    );
}

# A bare origin whose pre-receive hook declines everything, plus a clone with a
# board written into refs/karr/* and nothing pushed yet.
sub protected_remote {
    my (%opt) = @_;
    my $work = tempdir( CLEANUP => 1 );
    system( 'git', 'init', '-q', '--bare', "$work/origin.git" );
    if ( $opt{hook} ) {
        my $hook = path("$work/origin.git/hooks/pre-receive");
        $hook->spew("#!/bin/sh\necho 'board is protected' >&2\nexit 1\n");
        chmod 0755, "$hook";
    }
    system("git clone -q '$work/origin.git' '$work/a' 2>/dev/null");
    system( 'git', '-C', "$work/a", 'config', 'user.email', 'a@karr.test' );
    system( 'git', '-C', "$work/a", 'config', 'user.name',  'agent-a' );

    my $git = App::karr::Git->new( dir => "$work/a" );
    $git->write_ref( 'refs/karr/config', "board:\n  name: demo\n" );
    $git->save_task_ref( task( 1, 'One' ) );
    return ( $work, $git );
}

sub origin_refs {
    my ($work) = @_;
    my @refs =
      `git -C '$work/origin.git' for-each-ref --format='%(refname)' 'refs/karr/'`;
    chomp @refs;
    return [ sort @refs ];
}

# ---------------------------------------------------------------------
# The native contract, taken straight off the Result: libgit2 said "fine"
# (no exception, rc 0) and the Result says two refs were refused.
# ---------------------------------------------------------------------
subtest 'native: a Result with rejections fails the push and names every ref' => sub {
    my ( $work, $git ) = protected_remote();

    my $rv = do {
        no warnings 'redefine';
        local *Git::Native::Remote::push = sub {
            return Git::Native::Remote::Result->new(
                updated  => [],
                rejected => [
                    { ref => 'refs/karr/config', reason => 'pre-receive hook declined' },
                    { ref => 'refs/karr/tasks/1/data', reason => 'protected ref' },
                ],
            );
        };
        $git->push;
    };

    ok !$rv, 'push reports failure instead of announcing a sync that never happened';
    like $git->last_error, qr/rejected all 2 refs/,
        'last_error says how many refs the remote refused';
    like $git->last_error, qr{refs/karr/config: pre-receive hook declined},
        'and names the first ref with the reason the server gave';
    like $git->last_error, qr{refs/karr/tasks/1/data: protected ref},
        'and the second one with its own reason, not a shared generic one';
    is_deeply [ map { $_->{ref} } @{ $git->push_rejections } ],
        [ 'refs/karr/config', 'refs/karr/tasks/1/data' ],
        'push_rejections carries the per-ref outcomes to the caller';

    # A rejection is the server's answer, not a broken connection, so it must
    # not be retried through the CLI fallback. The origin here is a perfectly
    # working bare repo without a hook: had the fallback run, the refs would
    # have landed.
    is_deeply origin_refs($work), [],
        'no CLI fallback after a rejection -- the far side already answered';
};

subtest 'native: a partial rejection is still a failed push' => sub {

t/78-push-rejection.t  view on Meta::CPAN

}

subtest 'the same failure three times over is reported once, not three times' => sub {
    my ( $stderr, $err ) = capture_stderr( sub {
        my $board = RejectBoard->new( git => RepeatingGit->new );
        $board->sync_before;
    } );

    like $err, qr/Pull failed after 3 attempts/, 'the pull still fails loudly';
    is count_of( $stderr, 'DISTINCTIVE-REASON' ), 1,
        'the per-ref reason is on STDERR exactly once, not once per attempt';
    like $stderr, qr/Pull retry 2 of 3/,
        'the retries are still announced, so the wait is not unexplained';
};

subtest 'a failure that changes between attempts is reported each time' => sub {
    my ( $stderr, $err ) = capture_stderr( sub {
        my $board = RejectBoard->new( git => ChangingGit->new );
        $board->sync_before;
    } );

    like $stderr, qr/ERROR-NUMBER-1/, 'the first cause is shown';
    like $stderr, qr/ERROR-NUMBER-2/, 'and so is a different one after it';
    like $stderr, qr/ERROR-NUMBER-3/, 'deduplication never hides new information';
};

# ---------------------------------------------------------------------
# The native transport end to end, against a server that really runs
# receive-pack. This is the only setup that produces a genuine libgit2
# push rejection; it needs a `git daemon` on a loopback port.
# ---------------------------------------------------------------------
sub start_daemon {
    my ($work) = @_;
    for ( 1 .. 5 ) {
        my $port = 20000 + int rand 20000;
        my $pid  = fork();
        return () unless defined $pid;
        if ( !$pid ) {
            open STDOUT, '>',  "$work/daemon.log";
            open STDERR, '>>', "$work/daemon.log";
            exec( 'git', 'daemon', '--export-all', '--enable=receive-pack',
                "--base-path=$work", "--port=$port", $work );
            exit 1;
        }
        for ( 1 .. 25 ) {
            Time::HiRes::sleep(0.1);
            system("git ls-remote 'git://127.0.0.1:$port/origin.git' >/dev/null 2>&1");
            return ( $pid, $port ) if $? == 0;
        }
        kill 'TERM', $pid;
        waitpid $pid, 0;
    }
    return ();
}

subtest 'native, end to end: a real receive-pack rejection fails the push' => sub {
    my $work = tempdir( CLEANUP => 1 );
    system( 'git', 'init', '-q', '--bare', "$work/origin.git" );
    my $hook = path("$work/origin.git/hooks/pre-receive");
    $hook->spew("#!/bin/sh\necho 'board is protected' >&2\nexit 1\n");
    chmod 0755, "$hook";
    path("$work/origin.git/git-daemon-export-ok")->spew('');

    my ( $pid, $port ) = start_daemon($work);
    plan skip_all => 'no git daemon on a loopback port here' unless $pid;
    $DAEMON_PID = $pid;

    system("git clone -q 'git://127.0.0.1:$port/origin.git' '$work/a' 2>/dev/null");
    system( 'git', '-C', "$work/a", 'config', 'user.email', 'a@karr.test' );
    system( 'git', '-C', "$work/a", 'config', 'user.name',  'agent-a' );

    my $git = App::karr::Git->new( dir => "$work/a" );
    $git->save_task_ref( task( 1, 'One' ) );

    # Native only: no fallback may paper over what libgit2 reports.
    my $rv = do {
        local $ENV{KARR_NO_CLI_FALLBACK} = 1;
        $git->push;
    };

    ok !$rv, 'libgit2 returned 0, the server said no, and karr reports the no';
    like $git->last_error, qr{refs/karr/tasks/1/data: pre-receive hook declined},
        'with the reason receive-pack sent back';
    is_deeply origin_refs($work), [], 'and nothing reached the remote';

    kill 'TERM', $pid;
    waitpid $pid, 0;
    undef $DAEMON_PID;
};

done_testing;



( run in 1.709 second using v1.01-cache-2.11-cpan-6736b670a1e )