App-karr

 view release on metacpan or  search on metacpan

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

    return \%oids;
}


sub read_config_ref {
    my ($self) = @_;
    my $content = $self->read_ref('refs/karr/config');
    return {} unless $content;
    return $self->maybe_repair_legacy( yaml_load($content) );
}


sub write_config_ref {
    my ( $self, $data ) = @_;
    return $self->write_ref( 'refs/karr/config', yaml_dump($data) );
}


sub _parse_next_id {
    my ($raw) = @_;
    $raw = '' unless defined $raw;
    $raw =~ s/\s+\z//;
    return $raw =~ /^\d+$/ ? int($raw) : 1;
}

sub read_next_id_ref {
    my ($self) = @_;
    return _parse_next_id( $self->read_ref(NEXT_ID_REF) );
}


sub write_next_id_ref {
    my ( $self, $next_id ) = @_;
    return $self->write_ref( NEXT_ID_REF, "$next_id\n" );
}


# Hand out one id and move the counter past it in a single guarded step.
#
# The old read-then-write lost tasks outright: two agents that read the same
# counter both got that id, wrote the same refs/karr/tasks/N/data, and the
# loser's task was destroyed with both processes reporting success -- 40
# parallel creates produced 32 tasks (#44). The counter has to be re-read
# inside the loop, not once outside it: retrying with the value that already
# lost would just lose again.
sub allocate_next_id_ref {
    my ($self) = @_;
    return $self->retry_contended( 'the next-id counter', sub {
        my ( $oid, $raw ) = $self->read_ref_with_oid(NEXT_ID_REF);
        my $id = _parse_next_id($raw);
        return () unless $self->write_ref_cas( NEXT_ID_REF, ($id + 1) . "\n", $oid );
        return $id;
    } );
}


# ----- Whole-board replacement (restore) -----

# The mirror image of validate_helper_ref, which keeps helper refs out of the
# board namespace: a snapshot may only address refs inside it. Without this a
# hand-edited backup could point refs/heads/main at a parentless karr commit,
# because restore fed whatever keys the YAML carried straight to
# reference_create.
sub validate_board_ref {
    my ( $self, $ref ) = @_;
    defined $ref && length $ref
        or die "karr: snapshot contains a ref with no name\n";
    die "karr: '$ref' is outside the board namespace " . BOARD_ROOT . "\n"
        unless index( $ref, BOARD_ROOT ) == 0;
    die "karr: '$ref' is not a valid git ref name\n"
        unless Git::Native->reference_name_is_valid($ref);
    return $ref;
}


# Make the board consist of exactly the refs in %$refs (name => content).
#
# `karr restore` used to delete refs/karr/* first and write the snapshot back
# one ref at a time, so anything that failed on the way took the board with it.
# A single unusable ref name in the snapshot -- one that sorts before
# refs/karr/config is enough -- left the board empty locally and on the remote,
# because the END-block push insurance faithfully mirrored the half-executed
# destruction, prune and all (#47). The tool people reach for when they are
# already in trouble is the one that must not be able to make it worse.
#
# Nothing destructive happens here until the whole restore is known to be
# writable. Phase one validates every name and builds every commit object;
# neither touches a ref, so a failure leaves the board exactly as it was.
# Phase two then overwrites in place instead of starting from an empty
# namespace, so the board is never empty in between.
#
# Phase two is also atomic across its own writes: every ref's pre-restore
# content is snapshotted before the first write, and any die out of
# _write_ref_oid unwinds the writes that already landed before raising --
# otherwise a write failure on the second of eight refs would leave a
# board with the snapshot's config and the live board's tasks, which is
# the half-apply disaster recovery is supposed to prevent (#155).
sub replace_board_refs {
    my ( $self, $refs ) = @_;
    my $repo = $self->_repo
        or die "karr: no usable git repository: "
             . ( $self->last_error // 'unknown error' ) . "\n";

    my @wanted = sort keys %$refs;
    for my $ref (@wanted) {
        $self->validate_board_ref($ref);
        die "karr: snapshot value for '$ref' is not text\n" if ref $refs->{$ref};
    }

    my %commit;
    for my $ref (@wanted) {
        $commit{$ref} =
            $self->_commit_for_content( $repo, $refs->{$ref} // '' );
    }

    # Snapshot the current OID+content of every ref the restore is about to
    # touch, so a phase-two failure can put each one back where it was.
    # Anything that existed before the restore but is not in @wanted is also
    # captured, because the cleanup loop below would have deleted it on
    # success and the unwind has to undo that too.
    my %pre_exist = map { $_ => 1 } $self->list_refs(BOARD_ROOT);

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

mapped to its current OID as a hex string. Refs that can't be resolved are
silently omitted rather than included with an undef value. Returns C<undef>
-- not an empty hashref -- when the repository can't be opened; callers
throughout this class guard with C<< $git->ref_oids(...) || {} >>.

=head2 read_config_ref

    my $config = $git->read_config_ref;   # hashref

Returns the board config as a hashref, parsed from C<refs/karr/config>
(YAML) and repaired if the board is legacy-encoded. Returns C<{}> -- not
C<undef> -- when the ref is absent or empty.

=head2 write_config_ref

    $git->write_config_ref($config);

Serializes C<$config> to YAML and writes it to C<refs/karr/config> via
L</write_ref>.

=head2 read_next_id_ref

    my $next = $git->read_next_id_ref;

Returns the next task id to be handed out, as an integer. Returns C<1> when
the ref is absent or unparseable. This is a plain, unguarded read -- see
L</allocate_next_id_ref> for the version that actually reserves an id.

=head2 write_next_id_ref

    $git->write_next_id_ref($next_id);

Unconditionally writes the next-id counter via L</write_ref>. Not
compare-and-swapped -- a direct caller races with L</allocate_next_id_ref>;
this is for whole-board writers (C<karr import>, C<repair>) restamping the
counter outright, not for handing out an id.

=head2 allocate_next_id_ref

    my $id = $git->allocate_next_id_ref;

Hands out one task id and advances the counter past it, atomically: the read
and the compare-and-swapped write happen inside one L</retry_contended> loop,
so two callers racing for the same id can never both receive it and silently
overwrite each other's task (#44). Returns the allocated id.

That makes this the sole authority for handing out an id, but only for as long
as nothing else moves the counter: it was still possible for two creates to
receive the same id when a pull walked the counter backwards between them
(#172), which is why L</pull> merges that ref forward instead of adopting the
remote's value.

=head2 validate_board_ref

    my $ref = $git->validate_board_ref($ref);

The mirror image of L</validate_helper_ref>: dies unless C<$ref> is
non-empty, inside the board namespace C<refs/karr/>, and a syntactically
valid git ref name. Returns C<$ref> unchanged on success.
L</replace_board_refs> (C<karr restore>) validates every ref in a snapshot
through this before writing anything, so a hand-edited backup can't point a
ref like C<refs/heads/main> at a board commit.

=head2 replace_board_refs

    $git->replace_board_refs( \%refs );   # { $ref => $content, ... }

Makes the board consist of exactly the given refs: C<karr restore>'s
primitive. Every ref name is validated (L</validate_board_ref>) and every
commit object built I<before> any ref is touched, so a single bad name or
non-text value in C<%refs> dies without leaving the board half-overwritten.
The given refs are then written in place -- never through a
delete-everything-then-rewrite step, so the board is never briefly empty --
and any existing board ref not present in C<%refs> is deleted afterwards,
best-effort: a ref that resists deletion is left in place with a warning
rather than failing the whole restore. Always returns C<1> once the given
refs are in place, even when some stray ref could not be removed. Resets the
cached L</board_encoding_version>, since a restored snapshot may carry a
different one than the board had.

=head2 delete_refs

    $git->delete_refs($prefix);

Deletes every ref currently under C<$prefix> (via L</delete_ref>, so each one
is itself retried against lock contention). Every ref is attempted even when
an earlier one refuses. Re-reads the prefix afterwards rather than trusting
the deletes to have all landed, and dies if anything is still there -- naming
each refusal and its reason, or naming the leftover refs when nothing raised
one. This is what C<karr destroy> uses, and a partial destroy reported as a
success would be worse than one that fails loudly. A ref that another process
removed in the meantime is not a failure: gone is gone.

=head1 SUPPORT

=head2 Issues

Please report bugs and feature requests on GitHub at
L<https://github.com/Getty/karr/issues>.

=head2 IRC

Join C<#langertha> on C<irc.perl.org> or message Getty directly.

=head1 CONTRIBUTING

Contributions are welcome! Please fork the repository and submit a pull request.

=head1 AUTHOR

Torsten Raudssus <getty@cpan.org>

=head1 COPYRIGHT AND LICENSE

This software is Copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> L<https://raudssus.de/>.

This is free software, licensed under:

  The Artistic License 2.0 (GPL Compatible)

=cut



( run in 0.558 second using v1.01-cache-2.11-cpan-4ef0a570458 )