NAME
App::karr::Git - Git operations for karr sync (native via Git::Native + libgit2, with a git-CLI transport fallback)
VERSION
version 0.600
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). What the fallback is there for is ~/.ssh/config, which libgit2 does not read: a Host alias, the IdentityFile, User or Port written under it, and a ProxyCommand all take effect through the CLI and nowhere else. libssh2 reads a remote spelled board:karr.git as the literal host board and stops at the name lookup, so for a board reached through an alias the CLI is not a second opinion but the only route. Set KARR_NO_CLI_FALLBACK=1 to disable the fallback and surface native transport failures directly.
Git's own URL rewriting is not part of that list, though this paragraph listed it for a long time. libgit2 applies url.<base>.insteadOf itself, and pushInsteadOf alongside it -- on the 1.9.3 that Alien::Libgit2 carries, a fetch goes where the first rule points and a push where the second does. That was settled by aiming the two rules at two different repositories and reading back which one each end had reached, because a rewrite is easy to measure wrongly: a rule pointing at a path that does not exist fails as unsupported URL protocol, which looks exactly like no rewrite having happened at all. The substitution is textual and runs before anything inspects the protocol, so it is not confined to the local paths it was first tried on -- ssh:// and https:// serve on either side of a rule, as the URL being rewritten or as what it is rewritten to.
One corner does not hold, and it is the second thing the fallback is there for. Where a remote's push URL resolves to a local path other than its fetch URL -- from pushInsteadOf, or from an explicit remote.<name>.pushurl -- libgit2's local transport connects to the push URL and then writes the objects and refs to the fetch URL regardless, reporting success. Nothing fails, so nothing used to fall back, and the board was published to the wrong repository in silence. Such a push is therefore taken off the native transport before it runs and sent through the CLI, which lands at the push URL in every one of these cases; with KARR_NO_CLI_FALLBACK there is no route left and it fails naming both URLs instead (#208). A push URL that is absent, that names the fetch URL, or that is a real transport is untouched by this -- libgit2 is correct there, which is where ssh and https boards are.
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. The same setting bounds the native transport, as libgit2's per-read/write network timeout -- one knob for both routes. It reaches every transport libgit2 speaks, ssh:// included: those reads go through libssh2, which used to retry past the socket timeout, and the Alien::Libgit2 this distribution requires carries the libgit2 1.9.3 that stopped it (#174). The two bounds are not the same shape, though -- the CLI's is the whole run's wall clock, libgit2's is one read or write -- so nothing that finishes under the CLI rule can fail under the native one. "remote_has_board" narrows both: it runs unasked in front of a read command (#173), so its budget is capped at 10 seconds and split between its two attempts.
push sends refs/karr/* under a forced refspec, plus one delete refspec for every board ref this clone deleted and has not published yet. It deliberately does not prune: a prune makes the pusher's local refs the whole truth of the namespace, which is wrong the moment another clone holds a card this one has never seen -- that push took the card off the remote, and the mirror update behind it made the next pull delete it locally as well (#178). 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 deleted here as well, 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.
The id counter refs/karr/meta/next-id is the one exception to last-writer-wins: it is merged forward rather than adopted, because a counter that moves backwards hands out an id a card already holds (#172).
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. Not every rejection is a refusal, though: two pushes racing for the same ref are refused by the receiving side and go through on the very next attempt, so "push_contention" tells that case apart from a hook or a protected ref.
refs/karr-foundation/* -- karr-foundation's shared chain, run logs and question mailbox (App::karr::Foundation::ChainStore) -- syncs the same way through "pull_foundation" and "push_foundation": its own mirror, the same four cases, the same tombstoned deletions, and no prune. It is coordination state rather than board state, so it drops the board's wholesale-wipe refusal, its board-identity check and its conflict parking, and each omission is argued where the namespace constants are declared. karr sync is the one command that carries it; the implicit per-command sync (App::karr::Role::SyncLifecycle) stays board-only.
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);
my $full_ref = $git->validate_helper_ref( $ref, for_write => 1 );
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), refs/karr-local/ (pick locks and deletion tombstones, deliberately kept out of reach of any refspec -- #93, #178), refs/karr-remote/ (the remote-tracking mirror every pull reconciles against) and refs/karr-conflict/ (where a displaced local version is parked). 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.
for_write adds the namespaces that may be read through a helper ref but not written through one: refs/karr-foundation/chain/ and refs/karr-foundation/log/, which App::karr::Foundation::ChainStore writes with a schema and with compare-and-swap, and refs/karr-foundation/questions/, which App::karr::Foundation::Questions writes the same way -- answering by hand is karr-foundation answer, a door built for it, rather than a payload this command could only mangle. The rest of refs/karr-foundation/ stays writable -- the fleet design document itself lives there and was written with karr set-refs -- and reading is never restricted, because karr get-refs refs/karr-foundation/chain/step/3 is how one looks at a step and cannot damage it. karr set-refs passes the flag; karr get-refs does not.
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.
remote_has_board
my $there = $git->remote_has_board($remote); # default 'origin'
Asks $remote whether it advertises anything under refs/karr/ without fetching. Three answers, and the third is not the second: 1 when the remote has a board, 0 when it answered and has none ($remote not being configured included), and undef when the question could not be put -- unreachable remote, no git CLI, or no answer within the probe's budget. "last_error" carries the reason for undef.
The budget is KARR_TRANSPORT_TIMEOUT capped at 10 seconds, and the cap applies to 0 ("no limit") as well: this call runs unasked in front of a read command, and an unasked round trip that can hang forever is worse than one that gives up. It is the budget for the probe, not for each attempt: the native transport is asked first with half of it, and the git CLI gets whatever is left of the deadline, so a silent remote costs the cap once rather than twice. KARR_NO_CLI_FALLBACK leaves the native attempt alone with the whole budget instead.
The CLI is still worth a fallback now that the native transport is bounded for ssh:// too (#174, #203), because libgit2 reads no ~/.ssh/config: a Host alias, the IdentityFile, User or Port under it, and a ProxyCommand exist only for the CLI, and a remote written as board:x.git is taken by libssh2 for the literal host board.
Neither route can stop and ask: no credential prompt, no ssh passphrase prompt. A key that needs a passphrase with no agent behind it fails the probe instead.
"require_local_board" in App::karr::Role::BoardDiscovery is the caller: a fresh clone holds no refs/karr/* because git clone does not fetch them, which is indistinguishable from having no board until someone asks the remote.
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 -- unless "push_contention" says the answer was "someone else got here first", which is not an answer worth keeping.
push_contention
if ( !$git->push and $git->push_contention ) {
# transient: the same push again can land
}
Returns true when the last push was rejected and every rejected ref was rejected because another push reached it first, rather than because the far side refused it. False when the push succeeded, when it failed as a whole, and whenever a single one of the rejected refs carries a reason that is a real refusal: one protected ref among ten contended ones still makes the push final, because pushing again cannot change that ref's answer.
Two concurrent pushes creating the same brand-new ref are refused by the receiving side, and the next push of the same refspec goes through. libgit2's local transport says "failed to write reference 'X': a reference with that name already exists"; git-receive-pack -- every real remote -- says "failed to update ref", having also lost updates whose advertised old value moved underneath the push. Both are recognised here, so App::karr::Role::SyncLifecycle and App::karr::SyncGuard spend their retries on them instead of reporting a create that wrote its card as a failure (#181). It is not a local-transport quirk: against a real git daemon it is the more common outcome of the two, because receive-pack updates all refs in one transaction and one contended ref fails the lot.
push
my $ok = $git->push( $remote, $refspec );
Pushes $refspec (default: the forced board refspec covering all of refs/karr/*) to $remote (default origin). Returns 1 when $remote isn't configured 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").
A push of the default board refspec is the only one that carries board semantics: it adds a delete refspec for every ref this clone deleted and has not published yet (recorded under refs/karr-local/deleted/, so the record outlives the process that made the deletion), and it updates the refs/karr-remote/ mirror afterwards. It does not prune -- a ref on the remote that this clone has never seen is another agent's card, not a leftover, and pruning it is how a card was lost outright (#178). A custom $refspec (as "push_ref" uses) does neither.
Without a configured remote it is nearly a no-op: it still clears those deletion records, because they are what this clone owes a remote and there is no remote to owe (#197). Otherwise a repository that never pushes accumulates one tombstone per deleted card forever, and adding a remote later publishes every deletion it ever made in a single push.
A remote whose push URL is a local path other than its fetch URL never takes the native transport at all: libgit2 connects to the push URL there and writes to the fetch URL anyway, so the CLI carries it instead, or -- under KARR_NO_CLI_FALLBACK -- this returns 0 with "last_error" naming both URLs (#208, and "DESCRIPTION" for the whole of it).
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). One ref is not reconciled last-writer-wins: the id counter refs/karr/meta/next-id only ever moves forward, so a remote value behind the local one is left where it is and published by the next push instead (#172). 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.
has_pending_deletes
if ( $git->has_pending_deletes ) { ... }
True when this clone has deleted board refs that no push has published yet (the tombstones under refs/karr-local/deleted/; see "push").
It is what tells a karr destroy whose push has not landed apart from a fresh clone: both hold nothing under refs/karr/ while the remote still has the whole board, and the automatic fetch in "require_local_board" in App::karr::Role::BoardDiscovery would answer the first one by fetching back exactly what was just destroyed.
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.
pull_foundation
my $ok = $git->pull_foundation($remote); # default 'origin'
Fetches refs/karr-foundation/* into the refs/karr-local/foundation-remote/<remote>/ mirror and reconciles the local namespace against it, on the same four cases "pull" resolves for the board: a ref the remote added is adopted, one the remote deleted since the last sync is deleted here too, one that exists only locally is unpushed work and is kept, and where both sides moved the same ref the remote's version takes the slot and a warning names it.
Three things the board's pull does that this does not, each deliberate and argued at FOUNDATION_ROOT in the source: there is no wholesale-wipe refusal (an emptied chain is a normal end state, and a guard that has to be waved through routinely stops being read), no board-identity check of its own (the board's runs first, in the same karr sync, against the same remote), and no conflict parking (a displaced chain step is re-planned, not read back).
Returns 1 when $remote isn't configured (a no-op) or reconciliation completes, 0 on a transport failure. Like "pull" it dies -- rather than returning 0 -- when it decided on a ref it could not then write locally, because the caller's next step is the push and pushing after a partial pull would force the older version over the newer one (#154).
push_foundation
my $ok = $git->push_foundation($remote); # default 'origin'
Publishes refs/karr-foundation/* under a forced, non-pruning refspec, plus one delete refspec for every ref of that namespace this clone deleted and has not published yet, and then updates the mirror. Same return contract as "push".
The deletions are the point. refs/karr-foundation/* is written from more machines than a board is, and its retention really removes refs ("prune_logs" in App::karr::Foundation::ChainStore), so both obvious shortcuts lose data: a pruning push takes another machine's just-written run log off the remote (#178, one namespace over), and publishing no deletions at all makes every pruned run come straight back on the next pull, forever. So deletions travel the way the board's have since #178 -- as tombstones under refs/karr-local/foundation-deleted/, written by "delete_ref" before the ref goes and cleared by the push that published them.
A clone with nothing under refs/karr-foundation/ and no such tombstone pushes nothing at all, which is both correct and what keeps this off the wire in every repository that does not carry fleet state. With no remote configured it clears those tombstones instead of keeping them, for the reason spelled out at "push": they are what this clone owes a remote, and there is none (#197).
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.
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 "pull" merges that ref forward instead of adopting the remote's value.
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, licensed under:
The Artistic License 2.0 (GPL Compatible)