NAME

App::karr::Git - Git operations for karr sync (native via Git::Native + libgit2, with a git-CLI transport fallback)

VERSION

version 0.500

SYNOPSIS

my $git = App::karr::Git->new(dir => '.');

$git->pull;
my @ids = $git->list_task_refs;
my $task = $git->load_task_ref($ids[0]);

DESCRIPTION

App::karr::Git provides the low-level Git interface used by karr for syncing board state through refs/karr/*. Local object/ref ops (read/write/ delete of refs, blobs, trees, commits) run natively via Git::Native (FFI to libgit2) with no fork/exec. SSH-agent and HTTPS-token credentials are supplied through the libgit2 credential-acquire callback.

Network fetch/push (fetch, pull, push, push_ref, pull_ref) also try the native libgit2 transport first. If that transport fails, they fall back to the system git CLI (via IPC::Open3), because libgit2/ libssh2 doesn't read ~/.ssh/config and can't run a ProxyCommand — directives like Host aliases, IdentityFile, and insteadOf only take effect through the CLI. Set KARR_NO_CLI_FALLBACK=1 to disable the fallback and surface native transport failures directly.

Every CLI transport run is bounded by a wall-clock timeout, 120 seconds by default; KARR_TRANSPORT_TIMEOUT overrides it (in seconds, 0 disables it). A run that blows the timeout is killed and reported as a failure.

push sends refs/karr/* under a forced, pruning refspec. pull is its inverse, but it never fetches straight into the board: the remote state lands in a per-remote tracking mirror under refs/karr-remote/, and the local board is then reconciled against it. That mirror is what tells a ref the remote deleted apart from one that only exists locally because it has not been pushed yet -- the first is pruned, the second is kept. Where both sides changed the same ref the remote version takes the slot, the local one is parked under refs/karr-conflict/, and a warning names both. Neither extra namespace is ever pushed.

A reconciliation that would delete every remaining board ref is refused with an exception instead of being applied, and the mirror is left as it was: that outcome is what a karr destroy on another clone looks like, and equally what a re-created origin or a mis-edited remote URL looks like. pull( $remote, accept_wipe => 1 ) -- reached from karr sync --prune -- is the only way through.

The ref count alone cannot tell a swapped remote from the right one, though: a re-initialised origin or a mis-edited remote URL can present a whole different, non-empty board, which reconciliation would happily converge onto (#95). Boards therefore carry an identity in refs/karr/meta/board-id, stamped at init and compared on every pull before any reconciliation. A mismatch is refused the same way as a wholesale wipe, mirror rolled back and all, and pull( $remote, accept_foreign => 1 ) -- karr sync --accept-foreign-board -- is the deliberate way through.

push fails when the far side rejects refs, even though libgit2 returns success in that case: the per-ref outcome only exists in the Git::Native::Remote::Result it hands back, and "push_rejections" carries it on to the caller. The CLI fallback pushes with --porcelain and parses the same outcomes, so both transports fail with the same per-ref reasons.

SEE ALSO

karr, App::karr, App::karr::BoardStore, App::karr::Task, App::karr::Config, Git::Native

new

my $git = App::karr::Git->new( dir => $path );

Constructs a new instance. dir defaults to '.' and is stored as given -- nothing here touches the filesystem or checks that dir is inside a Git repository. That only happens lazily, the first time a method needs the repository handle (see "is_repo").

dir

my $path = $git->dir;   # Path::Tiny

Returns the directory new was constructed with, as a Path::Tiny. This is not necessarily the repository root: libgit2 discovers a repository by walking up from here to the nearest .git, so App::karr::Git->new(dir => 'some/subdir') is legal, and dir keeps returning some/subdir even though every ref and path operation resolves against the discovered root instead ("repo_root") -- see ticket #113. Prefer "repo_root" whenever the actual repository root is what's needed.

last_error

my $why = $git->last_error;

Returns text describing the most recent failure, or undef if none has happened yet. Meaningful only right after a call that itself reported failure -- a later successful call never clears it, so reading this on its own cannot answer "did anything just fail?".

Two unrelated kinds of text can end up here. Historically, the real git CLI stderr from the transport fallback, when a network operation (fetch/push/pull) drops to the system git binary. Since ticket #107, also the reason a native libgit2 read declined to answer: "is_tracked_under" sets this when the index cannot be read natively, before it falls back to git ls-files through the CLI. Both are free-form prose meant for a log line or an error message, not a code a caller branches on.

App::karr::Cmd::Sync, App::karr::SyncGuard and App::karr::Role::SyncLifecycle all read this after a failed pull or push to build the line of diagnostic text shown to the user or written to the sync log.

pending_writes

my $n = $git->pending_writes;

Returns the number of ref writes and deletes that have actually landed in this process so far -- across every App::karr::Git instance, since this is process-global state rather than per-object (see the comment above $WRITES for why: reading it off an object during global destruction is unreliable). App::karr::SyncGuard reads this on the die path to tell "the command died before writing anything" from "local refs changed and were never pushed".

commit_time

my $epoch = $git->commit_time($oid);

Returns the committer time of the commit at $oid (a hex object id, as returned by "read_ref_with_oid" and similar) as a Unix epoch integer, or undef when $oid is missing or empty, the repository can't be opened, or the object can't be read. Takes an OID rather than a ref name deliberately: pass the OID a compare-and-swap is already guarding, not a fresh read of the ref -- the ref may move between the two reads, and a timestamp read that way would not belong to the revision being judged. App::karr::Lock uses this to decide whether a lock is stale.

is_repo

if ( $git->is_repo ) { ... }

Returns true when "dir" is inside a Git repository libgit2 can open -- walking up to find .git, the same discovery "repo_root" relies on -- false otherwise. Always false during Perl's global destruction phase, regardless of the repository's actual state, so that teardown code never re-enters libgit2 (a real crash risk otherwise -- see the comment above _repo below for the story). Sets "last_error" to the exception text on failure.

repo_root

my $root = $git->repo_root;   # Path::Tiny, or undef

Returns the repository's work tree root as libgit2 discovered it by walking up from "dir" -- not "dir" itself, unless they happen to coincide. Falls back to the bare-repo gitdir when there is no work tree. Returns undef when the repository can't be opened (see "is_repo"). Every path-taking operation in this class -- is_tracked, is_tracked_under, the git-CLI fallback -- resolves paths from here rather than from "dir", so code that builds a path relative to "dir" instead silently asks the wrong question the moment dir is a subdirectory of the root (#113).

is_tracked

Returns true when the given working-tree path is under version control -- known to the index or to HEAD. Untracked and ignored paths, paths outside the work tree, and anything in a repository that cannot be opened all return false.

if ( $git->is_tracked($file) ) {
    # deleting or overwriting it would be data loss
}

is_tracked_under

Returns true when the index has an entry at $path, or -- when $path is a directory -- anywhere under it. Unlike "is_tracked", this asks the index rather than the working tree, so it also answers true for a path git tracks but that is currently missing from disk. Paths outside the work tree, and anything in a repository that cannot be opened, return false.

if ( $git->is_tracked_under($dir) ) {
    # the project already owns content under $dir
}

The index is read natively, through "is_tracked_under" in Git::Native::Index. Only when libgit2 declines to answer -- an index it cannot read, or a Git::Native too old to expose one -- does this fall back to git ls-files through the CLI, with "last_error" carrying why. With no git on PATH that fallback has nothing to run, so a failed native read answers false; the native path itself needs no git binary.

git_user_email

my $email = $git->git_user_email;

Returns the repository's configured user.email (read via native git config, not the CLI), or the empty string when unset or the repository can't be opened. Never undef.

git_user_name

Same contract as "git_user_email", for user.name.

git_user_identity

my $id = $git->git_user_identity;   # "Name <email>", or whichever half is set

Returns "$name <email>" when both "git_user_name" and "git_user_email" are set, otherwise whichever one is non-empty, or the empty string when neither is. Never undef.

normalize_ref_name

my $full = $git->normalize_ref_name('karr/foo');      # "refs/karr/foo"
my $full = $git->normalize_ref_name('refs/karr/foo'); # unchanged

Strips any leading / and prefixes refs/ unless the name already starts with it. Dies with "Ref name is required\n" when $ref is undef. Does not otherwise validate the name -- see "validate_helper_ref" and "validate_board_ref" for that.

validate_helper_ref

my $full_ref = $git->validate_helper_ref($ref);

Normalizes $ref ("normalize_ref_name") and dies unless it is both a syntactically valid git ref name and outside every namespace karr itself owns or protects: refs/heads/, refs/tags/, refs/remotes/, refs/bisect/, refs/replace/, refs/stash, refs/karr/ (the board) and refs/karr-local/ (pick locks, deliberately kept out of reach of any refspec -- #93). Returns the normalized ref on success. This is the gate karr set-refs/get-refs go through via "push_ref"/"pull_ref", so a caller cannot point a helper ref at the board or at a branch.

retry_contended

my @result = $git->retry_contended( $what, sub {
    my ($try) = @_;
    ...
    return ();       # lost the race -- read again and retry
    return $answer;  # committed -- stop retrying
} );

Runs $attempt (called with the 1-based attempt number) up to 32 times, with randomised backoff in between, until it returns something other than the empty list. $attempt returning () means "another writer got there first, read again and retry"; any other return value is the final answer and comes back to the caller untouched (as a list, in list context). An exception from $attempt propagates immediately without retrying -- only contention is retried, not a real failure. $what names the thing being updated, for the message if every attempt is exhausted: this then dies with "karr: gave up updating $what after 32 attempts -- too many agents are writing the board at once. Try again.\n".

Every compare-and-swap operation in this class -- "write_ref_cas", "delete_ref_cas", "allocate_next_id_ref" -- runs its attempt through here, which is also where contention is told apart from real failure: a lost race can surface natively as libgit2's GIT_EMODIFIED (the ref moved), GIT_ENOTFOUND (it was deleted) or GIT_ELOCKED (another process currently holds its lock file). GIT_ELOCKED is the one that decides whether this actually works under real concurrency -- it is the common outcome once more than one process is writing, and a retry loop that only recognised GIT_EMODIFIED-style mismatches still lost most writes (16 contenders on one counter left 4 processes dead and 4 increments missing; #85).

write_ref

$git->write_ref( $ref, $content );

Force-writes $ref to a new parentless commit wrapping $content (a character string -- see App::karr::Encoding for the octet boundary), last-writer-wins. Retries transparently through "retry_contended" when another process holds the ref's lock, so an ordinary transient collision is invisible to the caller; it surfaces only as the "gave up after 32 attempts" exception when contention never clears, or as a karr: could not write ... exception for anything else. Returns a true value on success, undef when the repository can't be opened. Every non-CAS ref write in this class goes through here -- "save_task_ref", "write_config_ref", "write_next_id_ref", "write_board_id_ref", "write_encoding_version" -- so it is not safe against another writer's own write landing between two calls; use "write_ref_cas" when that matters.

write_ref_cas

my $ok = $git->write_ref_cas( $ref, $content, $expected_old );

The compare-and-swap sibling of "write_ref": the write only lands if $ref still points at $expected_old (a hex OID), where undef means "the ref must not exist at all". Returns 1 when the write landed. Returns 0 -- not an exception -- when someone else won the race: the ref had already moved, had already been deleted, or another process currently holds its lock file (libgit2's GIT_ELOCKED, the common case under real contention, distinct from and handled alongside the stale-OID GIT_EMODIFIED/GIT_ENOTFOUND case -- #85). A caller getting 0 from a single call is expected to be inside "retry_contended", re-read whatever it just decided the new expected state is, and try again. A genuine failure dies with a karr: could not write ... message rather than returning 0. Unlike "write_ref", a failed write here never increments "pending_writes".

delete_ref_cas

my $ok = $git->delete_ref_cas( $ref, $expected_old );

The compare-and-swap sibling of "delete_ref": the ref is removed only if it still points at $expected_old (a hex OID; required -- dies with "karr: could not delete ...: no expected revision given\n" when omitted). Internally this combines an explicit OID comparison (covering the window between the caller's read and the lookup here) with libgit2's own GIT_EMODIFIED check on the actual removal (covering the window between that lookup and the delete) -- together they make this a real compare-and-swap, which the unguarded git_reference_remove that "delete_ref" uses cannot be (#94). Returns 1 when the delete landed, 0 when the ref had already moved or gone, or when another process currently holds its lock (GIT_ELOCKED -- same contention handling as "write_ref_cas", #85). A caller getting 0 is expected to be inside "retry_contended" and retry. A genuine failure dies with a karr: could not delete ... message, as "delete_ref" does too since #119. What still separates the two is the guard, not the error handling: 0 here means "the ref moved or went first", while 0 from "delete_ref" means "there was nothing to remove".

read_ref_with_oid

my ( $oid, $content ) = $git->read_ref_with_oid($ref);

Reads $ref and returns both its current OID (hex string, or undef when the ref doesn't exist or the repository can't be opened) and the character-string content of the commit it points at (chomped of one trailing newline, matching the old git cat-file transport; empty string when there is nothing to read). Always returns both from the same read -- a compare-and-swap caller that fetched the OID and the content separately would be guarding against the wrong revision if the ref moved in between.

"load_task_ref_with_oid" is the task-shaped version of this, and answers a missing task with (undef, undef) rather than (undef, ''): its second slot holds an App::karr::Task, and there is no empty task the way there is an empty string. The half worth testing is the same in both -- absence is undef in the first slot, which is where every caller in this distribution reads it from.

read_ref

my $content = $git->read_ref($ref);

The content half of "read_ref_with_oid", for callers that don't need the OID. Returns the empty string when the ref doesn't exist, never undef.

ref_exists

if ( $git->ref_exists($ref) ) { ... }

Returns 1 when $ref exists, 0 otherwise -- including when the repository can't be opened.

delete_ref

my $removed = $git->delete_ref($ref);

Deletes $ref. Retries transparently through "retry_contended" while another process holds the ref's lock. Returns 1 when this exact call is the one that removed it and 0 when there was nothing to remove -- the ref was not there, or the repository can't be opened at all (which includes the global-destruction refusal every native operation in this class degrades to, #63). A delete that was attempted and refused dies with a karr: could not delete ... message, the same way "delete_ref_cas" and "write_ref" report a real failure: 0 means "not on the board", never "we could not tell". It used to fold that failure into the same 0, and "break_lock" in App::karr::Lock read it as "already gone", so karr unlock announced a broken lock that was still held (#119). Unlike "delete_ref_cas", the delete itself is unguarded -- whatever is at $ref goes, last-writer-wins.

has_remote

if ( $git->has_remote('origin') ) { ... }

Returns true when $remote (default origin) is configured, false otherwise -- including when the repository can't be opened.

fetch

my $ok = $git->fetch($remote);   # default 'origin'

Runs a plain git fetch using the remote's configured refspecs -- unlike "pull", this does not go through the refs/karr-remote/ mirror or touch the board at all. Returns 1 when $remote isn't configured (a no-op) or the fetch succeeds, 0 on failure with "last_error" set. Tries the native libgit2 transport first and falls back to the system git CLI on failure (see "DESCRIPTION").

push_rejections

my $rejected = $git->push_rejections;
# [ { ref => 'refs/karr/tasks/12/data', reason => 'stale info' }, ... ]

Returns the per-ref rejections from the most recent push or push_ref, as an array reference of { ref => $name, reason => $text } hashes. Empty when the last push succeeded, and empty when it failed as a whole -- no connection, a killed transport -- rather than ref by ref: a rejection is the server's final answer, not a transport failure, and the two are kept apart. Reset to empty at the start of every push attempt, so a rejection from an earlier call never lingers into the read after a later one succeeds.

libgit2's git_remote_push returns success even when the far side refused every single ref -- a pre-receive hook, a protected ref, a non-fast-forward on a non-forced refspec. The per-ref outcome only exists in the Git::Native::Remote::Result push hands back, and karr used to throw that away, so a push that landed nothing was reported as a completed sync and the board diverged in silence (ticket #84). This is where that outcome survives the call; the CLI fallback parses --porcelain output into the same shape, so both transports answer the same way.

App::karr::Role::SyncLifecycle and App::karr::SyncGuard both check this after a failed push and stop retrying once it is non-empty: the remote was reached and gave its answer, so further attempts would only collect the same refusal again.

push

my $ok = $git->push( $remote, $refspec );

Pushes $refspec (default: the forced, pruning board refspec covering all of refs/karr/*) to $remote (default origin). Returns 1 when $remote isn't configured (a no-op) or the push lands, 0 otherwise -- including when the transport itself succeeded but the far side rejected some or all of the refs (see "push_rejections", and "last_error" for the combined message). Tries the native transport first, falls back to the CLI on transport failure ("DESCRIPTION"). Only a push of the default board refspec updates the refs/karr-remote/ mirror afterwards; a custom $refspec (as "push_ref" uses) does not.

pull

my $ok = $git->pull( $remote, accept_wipe => 0, accept_foreign => 0 );

Fetches the remote's board state into the refs/karr-remote/<remote>/ mirror and reconciles the local board against it (see "DESCRIPTION" for the full algorithm and the four cases it resolves). Returns 1 when $remote isn't configured (a no-op) or reconciliation completes, 0 on a transport failure. Three situations die rather than returning 0: a reconciliation that would delete every remaining board ref (pass accept_wipe => 1 -- karr sync --prune -- to allow it), a remote presenting a board with a different refs/karr/meta/board-id (pass accept_foreign => 1 -- karr sync --accept-foreign-board -- to allow it), and a ref the reconciliation decided on but could not write locally, because another process holds its lock file or left a stale one behind. Either refusal leaves the mirror exactly as it was before the fetch; the unapplied-ref failure rolls the mirror back for the refs it could not apply, so the next pull decides them again instead of mistaking the stale local version for unpushed work and force-pushing it over the remote (#154). The exception is what stops the caller's push: this method never reports an unapplied ref as success.

push_ref

my $ok = $git->push_ref( $ref, $remote );

Pushes a single ref (not the board) with a forced, non-pruning refspec, after validating it through "validate_helper_ref" -- so this dies rather than silently pushing when $ref is in a protected namespace or is not a legal ref name. Same return contract as "push": 1 for a no-op or success, 0 on rejection or transport failure. This is what karr set-refs uses to publish a helper ref.

pull_ref

my $ok = $git->pull_ref( $ref, $remote );

Fetches a single ref (not the board) with a forced refspec, after validating it through "validate_helper_ref". Returns 1 on success (or when $remote isn't configured), 0 on failure. This is what karr get-refs uses to pull a helper ref someone else published.

board_encoding_version

my $version = $git->board_encoding_version;

Returns the board's stamped encoding contract version as an integer, or 1 when refs/karr/meta/encoding is absent or unparseable -- 1 means "written before this ref existed", i.e. every board from before ticket #53. Cached per instance after the first read; "write_encoding_version" and "replace_board_refs" both invalidate the cache, since either can change what is currently stamped.

write_encoding_version

$git->write_encoding_version;              # stamps the current contract version
$git->write_encoding_version($version);

Stamps refs/karr/meta/encoding with $version (default: the current contract version). Invalidates the per-instance cache "board_encoding_version" keeps, so the next read reflects the new value. karr repair --yes calls this, and so do karr init and karr import --yes -- but only when they create the board instead of adding to one that already had refs, since the marker speaks for every ref under refs/karr/; see "stamp_encoding_version" in App::karr::BoardStore.

board_is_legacy_encoded

if ( $git->board_is_legacy_encoded ) { ... }

Returns 1 when "board_encoding_version" is below the current contract version -- this board still carries the double-UTF-8-encoded payloads App::karr::Encoding describes -- 0 otherwise.

maybe_repair_legacy

my $data = $git->maybe_repair_legacy($data);

Returns $data unchanged unless "board_is_legacy_encoded", in which case it is run through repair_mojibake first. Callers that read board payloads (task frontmatter, config, activity log entries) route them through this rather than checking the flag themselves.

read_board_id_ref

my $id = $git->read_board_id_ref;

Returns this board's identity (refs/karr/meta/board-id, normalized -- whitespace stripped), or undef when it isn't stamped -- true of every board created before ticket #95. See "DESCRIPTION" for why this exists (telling a swapped remote apart from the right one).

write_board_id_ref

$git->write_board_id_ref($id);

Stamps refs/karr/meta/board-id with $id.

new_board_id

my $id = $git->new_board_id;   # 32 hex chars, 128 bits

Returns a fresh random board identity: 128 bits as lowercase hex. An accident guard, not a secret -- collisions, not adversaries, are what it defends against.

ensure_board_id_ref

my $id = $git->ensure_board_id_ref;

Returns the board's identity, stamping a fresh one first if none exists yet. Read-before-write: an existing id is never replaced, which is what makes calling this safe on a half-initialized board -- re-keying would make every other clone see this one as foreign ("pull"'s accept_foreign case).

save_task_ref

$git->save_task_ref($task);

Writes $task (an App::karr::Task) to its refs/karr/tasks/<id>/data ref via "write_ref" -- last-writer-wins. See "save_task_ref_cas" for the guarded version.

load_task_ref

my $task = $git->load_task_ref($id);

Returns the App::karr::Task at refs/karr/tasks/<id>/data, or undef when it doesn't exist.

load_task_ref_with_oid

my ( $oid, $task ) = $git->load_task_ref_with_oid($id);

Same as "load_task_ref" but also returns the OID the task was read from, for a caller (App::karr::Cmd::Pick) that means to write it back under compare-and-swap -- pairing OID and content from one read for the same reason "read_ref_with_oid" does. Returns (undef, undef) when the task doesn't exist -- note this differs from "read_ref_with_oid", which answers a missing ref with (undef, ''), because that one's second slot is text and this one's is an object. Test the OID, not the second slot: it is undef for an absent thing in both, so a caller carrying a habit from one to the other still asks the right question. Legacy boards ("board_is_legacy_encoded") have their frontmatter repaired as part of the parse.

save_task_ref_cas

my $ok = $git->save_task_ref_cas( $task, $expected_old );

The compare-and-swap sibling of "save_task_ref": same contract as "write_ref_cas", applied to $task's data ref.

list_task_refs

my @ids = $git->list_task_refs;

Returns every task id that has a refs/karr/tasks/<id>/data ref, numerically sorted, deduplicated. Deliberately matches only the data ref and not e.g. .../lock: a lock ref left behind by a process that died mid-pick must not make "load_task_ref" get asked to load a task that no longer exists (#45).

list_refs

my @refs = $git->list_refs($prefix);   # default 'refs/karr/'

Returns the full names of every ref matching "$prefix*", glob-scoped server-side rather than filtered client-side after listing everything. Empty list when the repository can't be opened.

ref_oids

my $oids = $git->ref_oids($prefix);   # { $ref => $hex_oid, ... }

Returns a hashref of every ref under $prefix (default refs/karr/) 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 undef -- not an empty hashref -- when the repository can't be opened; callers throughout this class guard with $git->ref_oids(...) || {}.

read_config_ref

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

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

write_config_ref

$git->write_config_ref($config);

Serializes $config to YAML and writes it to refs/karr/config via "write_ref".

read_next_id_ref

my $next = $git->read_next_id_ref;

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

write_next_id_ref

$git->write_next_id_ref($next_id);

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

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 "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.

validate_board_ref

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

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

replace_board_refs

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

Makes the board consist of exactly the given refs: karr restore's primitive. Every ref name is validated ("validate_board_ref") and every commit object built before any ref is touched, so a single bad name or non-text value in %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 %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 1 once the given refs are in place, even when some stray ref could not be removed. Resets the cached "board_encoding_version", since a restored snapshot may carry a different one than the board had.

delete_refs

$git->delete_refs($prefix);

Deletes every ref currently under $prefix (via "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 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.

SUPPORT

Issues

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

IRC

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

CONTRIBUTING

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

AUTHOR

Torsten Raudssus <getty@cpan.org>

COPYRIGHT AND LICENSE

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

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.