App-karr

 view release on metacpan or  search on metacpan

lib/App/karr/Git.pm  view on Meta::CPAN

    # Reached only when libgit2 could not answer -- an index it refuses to
    # read, or a Git::Native older than the `index` accessor. `git ls-files`
    # reads the same index and matches a bare directory name as a prefix over
    # everything under it, so it answers the same question for a file or a
    # directory alike -- and $rel is a root-relative pathspec, which is why
    # _run_git resolves it from the work tree root rather than from ->dir.
    # Without a `git` on PATH there is nothing left to ask, and an unanswered
    # question is reported as "not tracked" -- the same
    # not-tracked-as-far-as-we-can-tell that is_tracked returns for a
    # repository it cannot open.
    my $run = $self->_run_git( 'ls-files', '-z', '--', $rel );
    return 0 unless $run->{failure} eq '' && ( $run->{status} >> 8 ) == 0;
    return length $run->{out} ? 1 : 0;
}


# ----- User identity (read via native config, not via CLI) -----

sub _config_string {
    my ( $self, $key ) = @_;
    my $repo = $self->_repo or return '';
    my $val = try { $repo->config_string($key) } catch { undef };
    return defined $val ? from_octets($val) : '';
}

sub git_user_email {
    my ($self) = @_;
    return $self->_config_string('user.email');
}


sub git_user_name {
    my ($self) = @_;
    return $self->_config_string('user.name');
}


sub git_user_identity {
    my ($self) = @_;
    my $name = $self->git_user_name;
    my $email = $self->git_user_email;
    return "$name <$email>" if $name && $email;
    return $email || $name || '';
}


# ----- Ref name validation -----

sub normalize_ref_name {
    my ( $self, $ref ) = @_;
    defined $ref or die "Ref name is required\n";
    $ref =~ s{^/+}{};
    return $ref =~ m{^refs/} ? $ref : "refs/$ref";
}


sub validate_helper_ref {
    my ( $self, $ref ) = @_;
    my $full_ref = $self->normalize_ref_name($ref);

    my @blocked = (
        'refs/heads/',
        'refs/tags/',
        'refs/remotes/',
        'refs/bisect/',
        'refs/replace/',
        'refs/karr/',
        # Pick locks (App::karr::Lock). They were moved out of refs/karr/ so
        # that no refspec could publish them (#93); `karr set-refs` names a ref
        # and pushes it, so leaving it able to reach them would put the same
        # hole back one command over.
        'refs/karr-local/',
    );

    for my $prefix (@blocked) {
        die "Ref '$full_ref' is in a protected namespace\n"
            if index( $full_ref, $prefix ) == 0;
    }
    die "Ref '$full_ref' is in a protected namespace\n"
        if $full_ref eq 'refs/stash' || index( $full_ref, 'refs/stash/' ) == 0;

    # Native validity check via Git::Native.
    die "Ref '$full_ref' is not a valid git ref name\n"
        unless Git::Native->reference_name_is_valid($full_ref);

    return $full_ref;
}


# ----- Ref CRUD (the hotspot — was 4 fork/exec per write_ref) -----

# How many times a contended ref write is re-attempted before karr gives up,
# and the backoff between attempts. Contention here is measured in the time it
# takes libgit2 to take refs/<name>.lock, write and rename -- microseconds --
# so a few milliseconds of randomised sleep is enough to break up a pile-up.
# The randomisation is the point: a fixed delay makes every loser wake up
# together and collide again.
#
# Bounded on purpose. karr is driven by unattended agents, and a write loop
# that can spin forever on a genuinely wedged ref is worse than one that fails
# with something the agent can report.
use constant CAS_ATTEMPTS       => 32;
use constant CAS_BACKOFF_STEP   => 0.001;   # seconds, times the attempt number
use constant CAS_BACKOFF_CAP    => 0.010;   # ...but never longer than this
use constant CAS_BACKOFF_JITTER => 0.005;

# Run $attempt until it commits to an answer, with backoff in between.
#
# $attempt returns the empty list to mean "another writer got in first, read
# again and retry"; any other return value is the final answer and comes back
# to the caller untouched (in list context as the list it returned). Anything
# it dies with propagates immediately -- a real failure is not retried.
#
# Every compare-and-swap caller goes through here, so the rules for what counts
# as contention live in exactly one place (see _is_contended_ref_error).
sub retry_contended {
    my ( $self, $what, $attempt ) = @_;
    for my $try ( 1 .. CAS_ATTEMPTS ) {
        my @answer = $attempt->($try);
        return wantarray ? @answer : $answer[0] if @answer;
        _cas_backoff($try);
    }
    die "karr: gave up updating $what after " . CAS_ATTEMPTS
      . " attempts -- too many agents are writing the board at once. "
      . "Try again.\n";
}


sub _cas_backoff {
    my ($try) = @_;
    my $step = CAS_BACKOFF_STEP * $try;
    $step = CAS_BACKOFF_CAP if $step > CAS_BACKOFF_CAP;
    Time::HiRes::sleep( $step + rand CAS_BACKOFF_JITTER );
    return;
}

lib/App/karr/Git.pm  view on Meta::CPAN

    };
    return 0 unless $deleted;
    $WRITES++;
    return 1;
}


# Two answers from one read: the OID the ref points at (undef when the ref is
# absent) and the content of that exact commit. Compare-and-swap callers need
# both together -- deciding on content fetched independently of the OID would
# guard the write against the wrong revision.
sub read_ref_with_oid {
    my ( $self, $ref ) = @_;
    my $repo = $self->_repo or return ( undef, '' );

    # Ask whether the ref is there before looking it up. Letting Git::Native
    # throw for a miss would build a full Throwable stack trace, and this runs
    # once per task load.
    return ( undef, '' ) unless $repo->reference_exists($ref);
    my $oid = try { $repo->reference($ref)->target } catch { undef };
    return ( undef, '' ) unless $oid;

    my $content = try {
        my $commit = $repo->commit($oid);
        my $tree   = $commit->tree;
        my $entry  = $tree->entry_by_name('data');
        return '' unless $entry;
        return $repo->blob( $entry->{oid} )->content;
    } catch { '' };
    $content = from_octets($content);
    # Match historical CLI behaviour: cat-file's trailing newline was chomped.
    chomp $content if defined $content;
    return ( $oid->hex, $content );
}


sub read_ref {
    my ( $self, $ref ) = @_;
    return ( $self->read_ref_with_oid($ref) )[1];
}


sub ref_exists {
    my ( $self, $ref ) = @_;
    my $repo = $self->_repo or return 0;
    return $repo->reference_exists($ref) ? 1 : 0;
}


# Returns 1 when this call removed the ref and 0 when there was nothing to
# remove. It used to swallow the exception and answer 1 regardless (#51), which
# made a delete that did nothing look like a delete that worked, and bumped
# $WRITES either way. That counter is what SyncGuard reads to decide whether
# local refs still need pushing, so it may only ever count writes that landed.
#
# A delete that was attempted and refused dies, as every other ref mutation in
# this class does. It used to answer 0 for that too -- the same 0 as "was never
# there" -- and break_lock read that 0 as "gone", so `karr unlock` reported
# "Broke lock on task N" over a lock that was still standing (#119). unlock is
# the escape hatch for a holder that never came back; a false success there
# leaves the card locked for everyone with nobody left to look.
#
# 0 therefore means "the ref is not on this board", never "we could not tell".
# The one seam is an unopenable repository, which is not a refusal to delete
# but the whole class degrading to no-ops -- during global destruction _repo is
# false by design and nothing native may run or throw (#63).
#
# Contention retries on the same terms as write_ref: losing the race for
# refs/<name>.lock is not a failed delete, it is one that has not been
# attempted yet.
sub delete_ref {
    my ( $self, $ref ) = @_;
    my $repo = $self->_repo or return 0;

    return $self->retry_contended( "ref $ref", sub {
        # Asking first keeps "nothing to delete" -- a no-op, not a write --
        # apart from the failure below, which libgit2 reports the same way.
        return 0 unless $repo->reference_exists($ref);

        my $deleted = try {
            $repo->reference_delete($ref);
            1;
        } catch {
            my $err = $_;
            return 0 if _is_contended_ref_error( $err, 0 );
            die _ref_error( 'delete', $ref, $err );
        };
        return () unless $deleted;
        $WRITES++;
        return 1;
    } );
}


# ----- Remote / network ops: native via Git::Native::Remote -----

# The push refspec. Forced, because write_ref builds every board commit with
# `parents => []`: no board ref update is ever a fast-forward, so a non-forced
# refspec can never apply one. push has always been forced; pull was not, and
# libgit2 declines a non-ff fetch update without raising an error, so pull
# returned success while leaving the ref stale -- and the next push then
# force-wrote that stale ref over the other agent's work (#40). Both
# directions are forced now; see _fetch_refspec for the pull side.
#
# The semantics this settles on are last-writer-wins, which is what the
# parentless-commit design already implied everywhere else. Doing better
# would need compare-and-swap on the ref (git_reference_create_matching,
# unbound in Git::Libgit2 -- see ticket #81) plus per-ref rejection reporting
# from libgit2's update_tips/push_update_reference callbacks (not installed
# by Git::Native -- ticket #80). Neither is reachable from karr today.
use constant BOARD_REFSPEC => '+refs/karr/*:refs/karr/*';

use constant BOARD_ROOT => 'refs/karr/';

# The board's identity (#95): stamped once at board birth, compared on every
# pull before any reconciliation -- see _check_board_identity. Declared with
# the other namespace constants because use constant is only visible from its
# textual point on, and _check_board_identity needs it.
use constant BOARD_ID_REF => 'refs/karr/meta/board-id';

# Remote-tracking mirror: refs/karr-remote/<remote>/<X> holds the remote's

lib/App/karr/Git.pm  view on Meta::CPAN

    my @rejected;
    for my $line ( split /\n/, $out // '' ) {
        next unless $line =~ /\A!\t([^\t]*)\t(.*)\z/;
        my ( $refspec, $summary ) = ( $1, $2 );
        # "<src>:<dst>", and <src> is empty for a delete. The board ref is the
        # destination either way.
        my $ref = $refspec =~ /:([^:]*)\z/ ? $1 : $refspec;
        my $reason = $summary =~ /\(([^)]*)\)\s*\z/ ? $1 : $summary;
        CORE::push @rejected, { ref => $ref, reason => $reason };
    }
    return \@rejected;
}

# Everything that is not a rejection line and not git's own "To <url>" /
# "Done" framing was a ref the server took, which is what turns the message
# into "rejected 2 of 5" instead of a bare list.
sub _count_push_porcelain_accepted {
    my ($out) = @_;
    my $accepted = 0;
    for my $line ( split /\n/, $out // '' ) {
        next unless $line =~ /\A([ +\-*=!])\t[^\t]*\t/;
        $accepted++ unless $1 eq '!';
    }
    return $accepted;
}

# Wall-clock budget for one `git` CLI run, in seconds. 0 (or a non-numeric
# value) disables it. karr is driven by unattended agents, so the default is a
# ceiling rather than a guess at how slow a legitimate transfer may be.
use constant DEFAULT_TRANSPORT_TIMEOUT => 120;

# Cap on how much of each stream is kept. Draining continues past it -- the
# point is only to stop a runaway git from being buffered into memory whole.
use constant CLI_OUTPUT_LIMIT => 65_536;

sub _transport_timeout {
    my $raw = $ENV{KARR_TRANSPORT_TIMEOUT};
    return DEFAULT_TRANSPORT_TIMEOUT
        unless defined $raw && $raw =~ /\A\d+(?:\.\d+)?\z/;
    return $raw + 0;
}

# Run `git -C <work tree root> @args` and return
#   { ok => 0|1, failure => ''|'start'|'timeout', status => $?, out, err,
#     timeout => $seconds }
#
# The root, not ->dir. Everything this class hands git as a path comes out of
# _relative_to_root and is therefore measured from the work tree root, while a
# git pathspec is resolved against the process cwd -- so running from ->dir
# asked about `subdir/tasks` whenever ->dir was not the root, and `git ls-files`
# answers that with exit 0 and no output, i.e. "not tracked" (#113). Pinning
# the cwd here puts both routes on one origin instead of correcting the
# pathspec at each call site. It costs the transport verbs nothing: git
# discovers the same repository from either directory. Only a repository
# libgit2 cannot open has no root to run from, and there ->dir is all that is
# left -- the same degradation every other native operation makes.
#
# Both pipes are drained through one IO::Select loop. Reading stdout to EOF
# first, as this used to, deadlocks the moment the child fills the 64 KiB
# stderr pipe buffer: the child blocks on write and so never exits or closes
# stdout, while the parent is still blocked reading stdout. A diverged board
# reaches that at roughly 700 rejected refs, and it could strike inside
# bin/karr's END flush, i.e. after the command had already printed its result
# (#43). The loop is also bounded by a deadline, so a transport that stalls
# (an ssh ProxyCommand hanging on a jump host, a grandchild holding the pipes
# open past the child's exit) fails instead of hanging an unattended agent.
#
# `status` is the raw waitpid status, not `$? >> 8`, so callers can tell a
# clean exit from a death by signal (#42).
sub _run_git {
    my ( $self, @args ) = @_;

    my $cwd     = $self->repo_root // $self->dir;
    my @cmd     = ( 'git', '-C', $cwd->stringify, @args );
    my $timeout = _transport_timeout();
    my %result  = (
        ok => 0, failure => 'start', status => 0,
        out => '', err => '', timeout => $timeout,
    );

    my ( $pid, $timed_out );
    my $started = try {
        local $ENV{GIT_TERMINAL_PROMPT} = 0;   # never hang on an interactive prompt
        my $err_fh = gensym;
        $pid = open3( my $in, my $out_fh, $err_fh, @cmd );
        close $in;

        my %sink = (
            fileno($out_fh) => \$result{out},
            fileno($err_fh) => \$result{err},
        );
        my $select   = IO::Select->new( $out_fh, $err_fh );
        my $deadline = $timeout ? Time::HiRes::time() + $timeout : undef;

        while ( $select->count ) {
            my $left = defined $deadline
                     ? $deadline - Time::HiRes::time() : undef;
            if ( defined $left && $left <= 0 ) { $timed_out = 1; last }
            # Poll in slices so the deadline is still honoured while git is
            # quiet on both streams.
            my $slice = !defined $left || $left > 1 ? 1 : $left;
            for my $fh ( $select->can_read($slice) ) {
                my $read = sysread $fh, my $chunk, 65_536;
                if ( !defined $read ) {
                    next if $! == EINTR;
                    $select->remove($fh);
                    next;
                }
                if ( !$read ) { $select->remove($fh); next }
                my $buffer = $sink{ fileno($fh) };
                $$buffer .= $chunk if length($$buffer) < CLI_OUTPUT_LIMIT;
            }
        }
        1;
    } catch {
        $result{err} = "$_";
        0;
    };
    return \%result unless $started;

    if ($timed_out) {



( run in 1.711 second using v1.01-cache-2.11-cpan-788537b7465 )