KIT

Reference snapshot · source revision 7a2e36f2b250934a095e542ae20e62dcfb0d5f70

SPEC-REFS — kit v1 ref wire format and semantics

Status: Normative and Stable for kit v1 — the wire format, namespace layout, and CAS semantics below are settled and backed by shipped, tested transports (memory, file, s3, http, ssh). Every transport's .missing and .match CAS is atomic among the callers that share it, given the server support §5.1 requires for s3 and http; the in-process memory transport's atomicity is in-process only, because it is never shared across processes. The local kit-core refs::cas_write helper used by commands that write refs directly is serialized under a per-ref lock (§5.1). See SPEC-CONVENTIONS §2 for what draft/stable and normative/advisory mean. Scope: ref names, ref wire bytes, ref storage layout, and the exact semantics of listRefs(prefix) and updateRef(condition) across transports.

This spec fixes the ref name grammar and the write rules.


1. Ref wire format

A single ref value on the wire is:

[64 bytes lowercase hex]
[1 byte '\n' = 0x0A]

Total: 65 bytes.

Hash value: 32 bytes of BLAKE3. Writer computes hex = bytes_to_hex(hash, lowercase); reader computes hash = hex_to_bytes(wire[0..64]).


2. Ref namespace

Refs live under refs/, in these namespaces:

refs/heads/<name>                  branch refs
refs/tags/<name>                   tag refs
refs/remotes/<remote>/<name>       remote-tracking branch refs
refs/kit/packmap/<name>           pack map of branch <name> (pushed with it)

Served names. A transport server (kit serve or any Connect server) MUST serve only ref names under refs/. It MUST refuse any other name that passes the §3 grammar (for example main, heads/main) on every read and write (ReadRef, UpdateRef and both names of AdvanceRefs), before touching storage, with an explicit error: invalid_argument on Connect (SPEC-TRANSPORT-CONNECT §5) and Error{ERROR_CODE_INVALID_REQUEST} on the ssh frame protocol (SPEC-TRANSPORT §4.2.1), whose message starts ref name must start with refs/. It MUST NOT answer such a name as absent. A ListRefs prefix is not restricted: a prefix outside refs/ lists nothing. A name that fails the §3 grammar is refused as §3 says. The local and client-side transports (file, s3, memory) are not servers and are unaffected.

Informative. Every kit client writes only refs/heads/, refs/tags/ and refs/kit/packmap/ names. A third-party client could have left a grammar-valid name as a file at the served root (main as <root>/main). The server does not serve such files, and docs/CLI.md ("Refs outside refs/") says how an operator finds and moves them.

On local disk (.kit/refs/heads/<name>, .kit/refs/tags/<name>, .kit/refs/remotes/<remote>/<name>). On transports, branch and tag refs use the same path shape relative to the transport's root.

refs/remotes/<remote>/<name> is local-only remote-tracking state. The single-remote CLI stores fetched branch tips under refs/remotes/default/<name> and never writes fetched tips directly to refs/heads/<name>. kit pull may then fast-forward the current local branch from the matching remote-tracking ref.

Named remotes (kit remote add <name> <url>) store their tracking refs under refs/remotes/<name>/<branch>. kit push uses the local remote-tracking ref as its CAS lease: a default (current-branch) push writes the remote refs/heads/<branch> with a Match(tracked) condition (or Missing for a first push), so a remote that has moved past the last-seen tip rejects the update as non-fast-forward. On a successful push, kit advances the local refs/remotes/<remote>/<branch> to the pushed tip. --force-with-lease keeps this lease; --force drops to an unconditional write.

HEAD is a special file at .kit/HEAD containing either:

ref: refs/heads/<name>\n

or a bare 64-char lowercase hex hash + \n (detached HEAD).

The file .kit/shallow contains one <64-hex>\n per line denoting commits beyond which the local repo does not have history.


3. Ref name grammar

Normative:

ref_name    := segment ( '/' segment )*
segment     := char+
char        := ALNUM | '.' | '_' | '-'
ALNUM       := [0-9A-Za-z]

Plus these rejections:

Informative: derived local bounds. A local branch or tag name is short; its wire name adds a prefix. A push of branch b writes both refs/heads/<b> and refs/kit/packmap/<b>, so a new branch name is at most 512 − 17 = 495 bytes (MAX_BRANCH_NAME_BYTES); a tag's wire name is refs/tags/<t>, so a new tag name is at most 512 − 10 = 502 bytes (MAX_TAG_NAME_BYTES). kit refuses to create a longer one and says which bound applies. A branch created before these bounds, up to 512 bytes, can still be committed to, renamed and deleted locally, but a push of it fails with "ref name too long", naming the wire name.

Notable divergences from Git ref naming:

This is a deliberately restrictive grammar to simplify cross-transport name validation.

Implementations MUST validate ref names with this grammar at every transport boundary — writing a ref, reading a ref, listing refs, and parsing a client-supplied name. No transport may silently lower case or canonicalize.


4. Prefix semantics for listRefs

This section is normative for transport implementations of listRefs(prefix). The local kit-core API (see refs.rs) exposes the simpler list_refs(kit_dir)/list_tags(kit_dir) which return every ref under refs/heads/ or refs/tags/ respectively — the prefix has been pre-applied implicitly by the function choice. The algorithm below is what a transport server / a future list_refs_with_prefix core API MUST implement to remain cross- transport compatible.

listRefs(prefix) -> [{name, hash}] walks the ref namespace and returns all refs whose full name begins with prefix at a path- component boundary. The name in each returned tuple has prefix (plus the separating /) stripped.

Prefix matching requires a component boundary, unconditionally. A bare string prefix would make the prefix-to-name mapping non-injective (refs/heads/feat/x and refs/heads/featx would both strip to the name x, which this document's §4.1 uniqueness invariant disallows) and could produce a returned name that starts with / or violates the §3 name grammar. This matches Git's own for-each-ref/show-ref prefix semantics.

Normative behavior (this is the single source of truth; all transports MUST conform):

Given full ref name F, prefix P, returned name N:

    if P is empty:
        N = F
    else:
        let P' = P with any trailing '/' removed
        if F == P':
            # A ref named exactly the prefix, no separator.
            # SHOULD NOT be returned (refs never coincide with a "directory").
            skip
        elif F starts with P' + '/':
            N = F[len(P') + 1 ..]
        else:
            # F does not extend P' at a component boundary — including
            # the case where F is a bare string-prefix match with no
            # following '/' (for example P' = "refs/heads/feat", F =
            # "refs/heads/featx"). This is NOT a match.
            skip

In practice the common case is:

Callers MUST NOT assume prefix includes a trailing /; the transport normalizes either form.

The spec pins the behavior above; transports MUST conform or fail conformance tests. kit-transport-memory and kit-transport-s3's list_refs are pinned against this exact rule by the regression test list_refs_prefix_respects_path_component_boundary (one per crate).

4.1 Ordering and duplicates

Returned refs MUST be sorted lexicographically by name. No duplicates (name is unique within the result). Empty result is returned as an empty slice, not null.

4.2 Prefix validation

The prefix itself must be empty or pass the same grammar as a ref name (§3), including its 512-byte bound, once its trailing /s are removed: trailing /s are ignored, so refs/heads, refs/heads/ and refs/heads// are the same prefix (§4 normalizes all three to P'). Transports MUST reject invalid prefixes (the core helper validate_ref_prefix returns a boolean; transports wrap the false case as their domain-specific InvalidRef error, for example RefError::InvalidRefName on the file backend).


5. CAS (updateRef) semantics

updateRef(name, condition, new_hash)

condition := .any
           | .missing
           | .match(expected_hash)

.any — unconditional write. Clobbers any existing value.

.missing — write only if the ref does not currently exist. If it exists → RefConflict.

.match(H) — write only if the ref currently contains H. If it does not exist or contains a different hash → RefConflict.

5.1 Per-transport atomicity matrix

(Normative — this is what conformance tests verify.)

Transport .any .missing .match Notes
memory atomic* atomic* atomic* — the read-check-write runs under the instance's one Mutex *In-process only: every verb of one MemoryTransport instance holds its Mutex for the whole operation, so concurrent threads sharing the instance cannot interleave. Never shared across processes.
file atomic atomic (via O_EXCL create) atomic — OS exclusive file lock (<root>/.kit/refs/.lock) serializes the read-check-write across processes Lock guard released on drop (including panic-unwind).
s3 atomic atomic via If-None-Match: * atomic via If-Match: "<md5-of-wire>" Requires a store that supports conditional writes.
http atomic atomic via If-None-Match: * atomic via If-Match: "<hex-hash>" Worker-flavored server. Generic S3 and nginx do NOT conform.
ssh atomic atomic (OP_WRITE_REF_IF_ABSENT path via condition byte) atomic (CONDITION_MATCH with 32-byte expected hash) Server enforces CAS; client trusts STATUS_ERROR.

memory CAS: kit-transport-memory's update_ref reads the current value, checks the condition and writes the new value while holding the instance's single Mutex, so all three conditions are atomic for every thread sharing that instance. It provides no cross-process atomicity and needs none: the memory transport is an in-process test transport. The file transport's .match is atomic across processes via the OS lock above. As of, the local kit-core refs::cas_write helper used by commands that mutate refs directly on disk (without going through the file transport) is also atomic across processes: every write condition takes a dedicated <common_dir>/refs-<ref>.lock OS exclusive lock (distinct from the file transport's <root>/.kit/refs/.lock, but the same blocking-kernel-lock primitive) around the read-check-write, so two uncoordinated callers on the same repo — for example branch -m and commit, or update-ref from two linked worktrees — can no longer both observe a stale current value and both report success while one write is silently lost.

Direct local mixed mutation contract: Any, Missing, Match, and both conditional and unconditional deletes MUST serialize on the same full-ref identity. This includes branch, tag, remote-tracking, and batched remote-tracking mutations. Any has no value precondition but MUST NOT bypass another operation's critical section. File transport Any/Missing/Match writes share its separate RefLock; that transport does not acquire the direct local full-ref guard. The complete nesting order, including history → ref mutation locks, is SPEC-CONCURRENCY §4. every_ref_mutation_waits_for_the_cas_guard and every_write_condition_waits_for_the_file_cas_guard enforce participation.

Coordination with local worktree operations: <root>/.kit/refs/.lock by itself only serializes the file transport against concurrent instances of itself — it does not, on its own, coordinate against a commit, checkout, or gc running locally against the same directory via the worktrees.lock/worktree.lock/refs-history-<branch>.lock/ refs-<ref>.lock path. There is no rule that closes this gap — see SPEC-CONCURRENCY §3.1, which documents it as a real, currently unresolved coordination gap, and states kit's supported deployment shape (a served root a worktree-owning process does not also mutate directly) rather than a locking fix. The server added detection (a stderr warning when a local command runs against a root a live kit serve holds serve.lock on) without closing the gap — see SPEC-CONCURRENCY §3.1 for exactly what is and is not covered.

History-enabled local branch writes publish the versioned first-parent ancestry descriptor under the same mutation guard (SPEC-HISTORY-PROOF §4). Raw writes that change a ref invalidate its active descriptor; deletion also invalidates it, so an ABA rewrite or delete/recreate cannot reuse a generation. A pending history intent blocks raw mutation until recovered. Neither this metadata nor the guard changes the 65-byte ref wire format.

5.2 ETag encoding divergence

The .match(H) condition is serialized differently by transport:

Kit v1 does not unify these encodings. The transport spec explicitly states which encoding to produce. Clients and servers for a single transport MUST agree on the transport's encoding; cross-transport compatibility is not in scope.

5.3 .any with conditional hint

The HTTP transport's buildRefWriteHeaders(.any, Some(current)) emits If-Match: "<hex>" opportunistically when the caller knows the prior value. This is an optimization and MUST NOT be treated as binding CAS — a server that ignores the header is still conforming for .any. Clients requiring CAS MUST use .match explicitly.


6. Ref storage (local disk)

.kit/refs/heads/<name>     65 bytes wire (HEADS_DIR = "refs/heads")
.kit/refs/tags/<name>      65 bytes wire (TAGS_DIR  = "refs/tags")
.kit/HEAD                  symbolic ("ref: refs/heads/<name>\n") or detached (64-hex + '\n')
.kit/shallow               concatenation of N × 65-byte ref-wire blobs (one hash per line)

HEAD content size is capped at 4 KiB; a single ref file at 128 bytes; the shallow file at 1 MiB. Reads exceeding these bounds yield RefError::InvalidHead/RefError::InvalidRef respectively.

Writers MUST use atomic write-then-rename on local disk to avoid torn reads. The temp-name pattern is .<file>.tmp.<pid>.<seq>, identical to SPEC-INDEX §5.

HEAD reads tolerate trailing \r, space, and tab so a Windows- edited file does not brick a repo; ref-file reads tolerate the same trailing whitespace plus the optional \r before the terminating \n. Fresh writes always emit the strict 65-byte form.

Listing .kit/refs/heads/ is recursive (nested directories like feature/x/y are supported), with a hard depth cap of 32 levels to defeat adversarial nesting. Files that fail validate_ref_name or whose bytes do not decode to a valid ref wire are silently skipped from listings.

6.1 Operation-state files (merge / cherry-pick / rebase)

Core tree merge, revert and cherry-pick are synchronous algorithms over verified object sources and sinks. Successful writes MUST be readable by subsequent steps through the same source/sink. They return a tree and conflicts (conflicted paths retain ours when present, otherwise the surviving modified entry); they do not sign commits, mutate refs, acquire worktree locks or persist conflict state. Root-commit revert uses an empty parent tree; merge-commit revert refuses without mainline support. Cherry-pick retains explicit mainline selection for merge commits.

In-memory hosts MUST bound prefetch and operation resources independently. MemoryOverlay requires aggregate read-call/read-byte and retained-output byte/object caps; exhaustion returns StoreError::OperationLimitExceeded. Tree recursion remains capped at 128 levels (TreeTooDeep). Read budgets bound history traversal, tree breadth and accumulated conflict work; caller source object-size caps also bound transient read/decode/text-merge allocations. These are work/retention budgets, not a hard process heap ceiling. Earlier writes can survive a later error; the host owns abandonment and publication. The filesystem CLI retains its existing budgets and operation-state semantics.

Resumable history operations persist their state under .kit/ using Git-compatible names plus one documented kit sidecar. These are not refs (they are not listed by listRefs and are not part of the ref namespace); they are operation scratch state consumed by --continue/--abort/--skip.

.kit/MERGE_HEAD           64-hex plus '\n'  — other parent of an in-progress merge
.kit/CHERRY_PICK_HEAD     64-hex plus '\n'  — commit being applied by a cherry-pick
.kit/ORIG_HEAD            64-hex plus '\n'  — HEAD before the operation (for --abort)
.kit/MERGE_MSG            raw bytes      — pending merge commit message
.kit/CHERRY_PICK_MSG      raw bytes      — pending cherry-pick commit message
.kit/kit-conflicts       sidecar (below)
.kit/rebase-apply/        rebase state dir; holds a kit-conflicts sidecar when paused

Presence of MERGE_HEAD ⇒ a merge is in progress; CHERRY_PICK_HEAD ⇒ a cherry-pick; rebase-apply/ ⇒ a rebase. Starting any of the three while one is already in progress is refused.

The kit-conflicts sidecar is line-oriented, one line per conflicting path, tab-separated, with the path last (so it may not contain a tab):

<kind>\t<base_hex|->\t<ours_hex|->\t<theirs_hex|->\t<path>\n

where <kind> ∈ {modify, addadd, deletemodify}, a missing side is encoded as a single -, and <path> is validated with the same rules as a staged index path (SPEC-INDEX §2). Hash files tolerate trailing whitespace on read. The whole sidecar is capped at 1 MiB. This sidecar does not change the .kit/index format: the index remains a single-stage resolved staging area (no unmerged stages); conflict base/ours/theirs material lives only in this sidecar.


7. Test vectors

  1. Wire encode/decode: hash = BLAKE3("test-ref") → 64 hex + \n. Record the 65-byte wire.
  2. Reject uppercase wire on read.
  3. listRefs prefix cross-transport parity: memory, file, s3, http, ssh all asked listRefs("refs/heads") and listRefs("refs/heads/") on an identical seeded state — results MUST be byte-identical.
  4. .missing race: two concurrent .missing writers to the same ref — exactly one succeeds, the other returns RefConflict. Run on s3, http, ssh.
  5. .match on file transport, exactly-one-winner: two concurrent .match writers to the same ref — exactly one succeeds, the other returns RefConflict and the ref is left at the winner's value. Enforced by the OS exclusive lock (§5.1); the local kit-core refs::cas_write helper (not the file transport) has the same exactly-one-winner property, proven by cas_match_race_never_loses_an_update_across_uncoordinated_callers.
  6. Ref name grammar: valid — main, feat/v1.0-beta, release/2024_09. Invalid — feat/.., /main, main@v1, feat\branch, .hidden, `` (empty).
  7. S3 MD5-of-wire ETag: compute MD5 of the 65-byte wire for a known hash; compare against an S3 PUT's Content-MD5 on the same wire.
  8. HTTP hex-hash ETag: compute If-Match: "<64-hex>" for a known hash; server echoes back matching state.

8. Invariants

Invariant Enforced by
A ref value on any byte-exact transport is exactly 65 bytes: 64 lowercase hex plus \n writers MUST emit lowercase; readers reject uppercase with InvalidRef (§1)
Every ref name crossing a transport boundary satisfies the §3 grammar validation at every boundary — write, read, list, client-supplied parse; no silent case-folding or canonicalization (§3)
No ref shadows HEAD or a lock file grammar rejections for final-segment HEAD and the .lock suffix (§3)
listRefs(prefix) is byte-identical across transports single normative stripping algorithm; lexicographic order, no duplicates; conformance-tested (§4, §4.1, test vector 3)
A .missing write succeeds at most once per ref O_EXCL/If-None-Match: * / condition byte, per the atomicity matrix (§5.1)
A .match(H) write on s3/http/ssh/file/memory cannot clobber a moved ref conditional-write CAS per transport encoding, the OS exclusive lock for file, or the instance Mutex for memory (in-process only) (§5.1, §5.2)
An .any conditional hint is never binding CAS clients requiring CAS MUST use .match explicitly (§5.3)
A default push cannot silently rewind a remote that advanced Match(tracked)/Missing CAS lease on the remote-tracking ref (§2)
Fetched tips never overwrite local branches fetch writes only refs/remotes/<remote>/<name>; pull fast-forwards from it (§2)
Local ref reads never observe a torn write atomic write-then-rename with the SPEC-INDEX §5 temp-name pattern (§6)
Ref parsing is allocation-bounded 128 B ref / 4 KiB HEAD / 1 MiB shallow caps; 32-level listing depth cap (§6)
At most one of merge / cherry-pick / rebase is in progress starting a second operation while state files exist is refused (§6.1)

The memory transport's CAS is atomic only among threads sharing one MemoryTransport instance; it gives no guarantee across processes or across separate instances, which never share state (§5.1). The file transport's own .match is race-free across processes (OS exclusive lock), and the local kit-core refs::cas_write helper used by commands that write refs directly rather than through the file transport is, as of, likewise serialized under a per-ref lock (§5.1).