~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
551 lines · 30.1 KB Raw History
# How repositories are stored

A knotless repository lives in its owner's atproto account and nowhere else.
Nothing is installed on the owner's PDS, the server that holds the account's
records and blobs. Every git operation is a handful of ordinary calls to that
server. This page explains which records hold a repository, and how push,
fetch and compaction work within what a stock PDS allows. For the protocol
itself, see https://atproto.com.

## What a stock PDS offers

Everything has to work against the reference PDS (`@atproto/pds`) as shipped,
under its default limits. That server offers records (JSON documents in a
collection, each addressed by a record key, or rkey), blobs (binary data
stored beside records), and `applyWrites`, one call that creates, updates and
deletes several records, all or none.

It offers no locks. It has no per-record compare-and-swap either: the only
guard `applyWrites` takes is `swapCommit`, which names the account's latest
commit and so conflicts with every other write to the account. Only an
account can write to its own records, so a collaborator cannot push straight
to the owner's repository. And every call is a round trip that a person, or
an agent running a loop, waits on.

| Limit | Reference PDS | What it forces |
| --- | --- | --- |
| Blob upload | 5 MB (`PDS_BLOB_UPLOAD_LIMIT`), advertised by `describeServer` | Large packs are split into parts |
| JSON request body | about 1 MB | Packs ride inside a record only when small |
| Writes per `applyWrites` | 200 | Large pushes span several calls |
| `listRecords` | filters by collection, not by key prefix | One repository's keys form one range |
| Write points, when rate limits are on (as on bsky.social) | 5000 an hour per account: create 3, update 2, delete 1 | A push is few writes |
| `uploadBlob`, when rate limits are on | 1000 a day | Small packs use no blobs |

## The records

A repository is five kinds of record, all in the owner's account, with
schemas in `lexicons/dev/knotless/`.

| Collection | Keyed by | Holds |
| --- | --- | --- |
| `dev.knotless.repo` | the repository's name | its identity and settings |
| `dev.knotless.ref` | `<key>.<h(ref)>` | where one ref points, its generation, and the pack holding its target |
| `dev.knotless.claim` | `<key>.<h(ref)>.<generation>` | one move of one ref |
| `dev.knotless.pack` | `<key>.<tid>` | a git pack, inline or over blobs |
| `dev.knotless.redirect` | a former name | where a renamed repository went |

A private repository stores the same records inside a space, a permissioned
area of the account that only its members can read (see access.md). A public
repository is read with no authentication.

The repo record is keyed by the repository's name, so a clone URL resolves
with one `getRecord`. Because the name doubles as a record key, it must be 1
to 100 bytes of `[A-Za-z0-9._~-]`, and not `.` or `..`. Its `key` field is a
TID, a time-ordered record key, minted when the repository is created and
never reused. Every other record is keyed by `key`, not by the name, so a
rename rewrites only the repo record and its redirects (see identity.md).

A repo record is never written over by a new one of the same name, since
its refs and packs would be left under a key nothing names. Creating,
forking, accepting a transfer and renaming first look for a repository
under the name, public or in the owner's space of that name, and refuse
with `<url> already exists` before writing anything. The write itself
does not count on the PDS refusing a create over a record it holds: some
PDSes, pds.js among them, treat that as an update. A public record is
written with `com.atproto.repo.putRecord` and a null `swapRecord`, which
the PDS refuses when any record is there. A space's `putRecord` has no
swap, so a private record is an `applyWrites` create, behind the look.
The record also holds what the repository says it is (`description`),
the `links` its pages show (its website first, up to five, each an http
or https address), the default branch, `collaborators`, `runners`,
`gateway`, fork and transfer links, branch `rules`, the `labels` its
pulls and issues may carry, the `triagers` whose labels count, and the
`agents` whose pulls run as they move, as code no one vouches for (see
runner.md), and `archivedAt` while it is archived and read only (see
collaboration.md). A client that
rewrites it keeps fields a newer client wrote that it does not know.

A ref record holds the full ref `name`, its `target` commit, when it was
made (`createdAt`), and a `generation`: a counter that goes up by one every
time the ref moves. A new ref starts at the time in milliseconds, not at 1.
Deleting a ref deletes its record, beside the claim on the generation it was
at, so a writer that read it still collides with that claim. A ref made again
under the same name starts at the time once more. That is past any generation
the earlier ref reached, since no ref moves once a millisecond, so the
earlier ref's claims, kept a day, never stand in the way of the new one's.
That leans on the writer's clock: one running far enough behind can make a
ref at a generation its earlier life passed through, and its push off that
generation is then refused until compaction drops the earlier claim.

A repository so holds a record only for each ref it has now. Clients before
0.6 kept a deleted ref's record with no target, so that its generation would
never repeat; compaction drops each of those once a day old (see
[Compaction](#compaction)). Past the first page of 100, the rest of the range is cut by the
first letter of the hash that ends each key and read in up to eight ranges
side by side, not as pages one after another. On a repository with 590 ref
records, this takes 0.37 seconds rather than 1.7. A repository whose refs
fit on one page is read in one call, as before.

A claim records one move of one ref. Its key ends in the generation the ref
moved from, and its body holds `from`, `to` and the `pack` written with the
move. A delete's has no `to`. A new ref has no claim: creating its record
fails if one exists, which is all a claim would guard, and its record, its
`createdAt` the same as its `updatedAt`, is its reflog entry. A claim is never updated. Its key is the
ref's compare-and-swap, and its body is a reflog entry.

A claim, and a ref record, written by the [gateway](gateway.md) for anyone but
the owner names them as `pusher`: a collaborator, or a runner landing a merge
queue. An owner's own push leaves it out. Readers credit the move to the
pusher while the repository record names them among its `collaborators` or
`runners`, and to the owner otherwise, since the owner's account wrote it.

A pack record holds one git pack: every object reachable from its `tips` and
not from its `bases`, the objects its history needs and does not hold. A pack
is self-contained, with no thin deltas, so it can be indexed alone. It is
also immutable. Exactly one of `data` (the pack inline) and `parts` (the
blobs it was split over, in order) is present. A pack written by compaction
also lists, in `supersedes`, the keys of the packs it replaced.

A rename leaves a redirect under the old name, pointing at the repo record by
AT-URI and key, and repoints every older redirect, so an old name is one hop
from the current one. A redirect is followed only within the account, and
only to a repo record with the key it names. Creating a repository under a
redirected name replaces the redirect.

Details:

- Every write is `com.atproto.repo.applyWrites` (`com.atproto.space.applyWrites`
  in a space) with `#create`, `#update` and `#delete` writes, never
  `swapCommit`. The one `putRecord` is the creation of a public repo
  record outside a push, with a null `swapRecord` (above).
- A push's pack has its `tips` sorted and de-duplicated; a compaction
  pack lists one per live ref, in ref-record order. `bases` are omitted
  when empty.
  `objects` comes from the pack header; `size` is the byte length, which a
  fetch checks against what it downloads.
- A redirect of a private repository renamed since it was made carries
  `space`, the space its records live in.
- A lookup follows up to 4 redirect hops, so a read can race a rename.

### Deleting a repository

`knotless repo delete` (the `repo_delete` tool, the pages' Delete) deletes
every record keyed by the repository's key, its refs, claims, packs,
releases and reruns, and what its owner wrote about it in their own
account: their pulls, issues, comments, states, labels, hides, queue
entries and blocks naming it, their star of it, and their reactions to
those, unless another account took it up, whose copy shows what was said
on this one. Blobs go with the records that named them, as the PDS reaps a blob
no record references. What other accounts wrote about it stays in their
accounts. The repo record goes last, in a write of its own, with the
redirect a repository taken up by another account leaves under its name:
a delete cut short finds the record still there, and run again carries on
where it stopped. A private repository's space is deleted instead, and
everything in it goes with it.

### Deleting your knotless data

`knotless account delete-data` (the `account_delete_data` tool, the pages'
account settings) deletes every record an account holds in a
`dev.knotless.*` collection, whichever its PDS's `describeRepo` lists,
and leaves the atproto account and other apps' records alone. It says
first what there is: each collection's count, and the repositories,
those another account pushes to or reads named, so their owner can
transfer them first. Then it deletes, as the account, in this order:

1. the spaces the account owns, each private repository's and those a
   rename left, as deleting a private repository does;
2. each public repository, as `knotless repo delete` does;
3. what is left, a collection at a time, 100 records an `applyWrites`.

It works a part at a time, so a run cut short carries on when run again,
and the pages show how far it has got. Once nothing is left, the appview
forgets the account ([serve.md](serve.md#an-account-deleting-its-data)).
Records the account wrote in other accounts' spaces, as a member of
their private repositories, are theirs to keep and stay.

## Keys, and one repository as one range

`listRecords` lists a collection, and a collection holds the records of every
repository in the account. Reading one repository's refs must not mean
reading all of them. The keys solve this.

Every record of a repository except the repo record is keyed
`<key>.<suffix>`, so a repository's records sit together in each collection.
A scan starts from the cursor `<key>/`. The character `/` (0x2F) sorts just
after `.` (0x2E), so `<key>/` is above every `<key>.…` key. The reference PDS
lists highest keys first and pages below the cursor, so the scan walks the
repository's records and stops at the first key outside the prefix. Every
TID is 13 symbols long, so no repository's key is a prefix of another's.

A ref name can hold characters a record key cannot. `h(ref)` turns any name
git allows into a valid key: the first 26 characters of the lowercase base32
of the name's SHA-256, 130 bits of the hash. Pack keys end in a TID, so a
scan of packs walks them newest first, in reverse push order.

Details:

- A page asks for 100 records. A short page, or one that reaches another
  repository's key, ends the scan.
- A record that does not parse fails the scan rather than being skipped.

## The snapshot

Every operation starts by reading a snapshot: the repo record and all its
refs. With the repository's key cached locally, the two reads go out at
once, one round trip. The repo record still comes along, to catch a name
that now means a different repository: if its key differs, the refs are
reread under the new one. Without a cached key, the repo record comes
first, two round trips. A name with no repo record gives an empty
snapshot, which a first push can fill.

The snapshot leaves out all but the newest packs. Small packs ride inline in
their records, so a page of them can be megabytes, and a listing that size
takes the PDS longer than the refs do: every push, and every check for
anything to fetch, would wait on it. Each ref instead names the pack holding
its target (see Fetch). A snapshot read for a fetch, as git's `list` before
one, also reads the newest 4 packs beside the refs, in the same round trip:
a push is one pack, so what was pushed since the client last fetched is
usually among them. A clone, into a repository with no refs yet, wants
every pack, so reads a whole page of 100 instead. A `list for-push` leaves
them out.

## Push

A push has to move refs safely against concurrent pushes, without locking
the account, in as few round trips as possible. A warm push takes two: the
snapshot, then one `applyWrites`.

1. Read the snapshot.
2. Check each refspec. A ref already at its new target writes nothing. An
   unforced update is refused "fetch first" if its old target is not
   present locally, and "non-fast-forward" if that target is not an
   ancestor of the new one. A forced update, a new ref and a delete skip
   these checks.
3. For a first push, mint the key and create the repo record, with `main`
   as default branch if it was pushed, else the first branch pushed.
4. Run `git pack-objects` with the new targets as tips and every live remote
   ref target the local repository has as bases. Place the pack inline or
   over blobs.
5. For each accepted ref, create its claim at the generation read and
   update its ref record to generation + 1, or delete the record for a
   delete; for a new ref, create its record at the time in milliseconds,
   with no claim. The record's `pack` names the pack holding its new
   target: the push's own, or, for a commit some ref already points at,
   and so a base the push's pack left out, the pack that ref names.
6. Refuse the whole push if the refs were read more than 12 hours ago.
7. Send the writes in `applyWrites` calls of at most 200, the first carrying
   the repo and pack records, each claim beside its ref.

A small push costs 8 rate-limit points (pack create 3, claim create 3, ref
update 2), about 600 an hour per account, and a new ref 3. A push of more
than about 99 moved refs, or 199 new ones, spans several calls, which are
not atomic together: an earlier one can land while a later one loses. One
the rate limit refuses (429) ends the push there: the refs of the calls
that landed are pushed, and the rest say when the limit's
`RateLimit-Reset` lets writes in again. Pushing them again then writes only
those, since a ref already at its target writes nothing. A delete, or a new ref at a commit the remote
already has, writes no pack.

### Compare-and-swap through claims

Two writers that both read generation `n` of a ref both try to create
`claim(<key>.<h>.<n>)`. A create on an existing key fails, so the PDS accepts
only the first. The ref update travels in the same `applyWrites` as its
claim, so it lands only if the claim does. Each ref thus has its own
compare-and-swap, with no contention against the account's other writes and
no extra round trip. A new ref record is a create, so two racing creates of
one new ref fail the same way.

```mermaid
sequenceDiagram
    participant A as Pusher A
    participant P as Owner's PDS
    participant B as Pusher B
    A->>P: snapshot: main at generation 7
    B->>P: snapshot: main at generation 7
    A->>P: applyWrites: create claim main.7, update ref main to 8
    P-->>A: ok
    B->>P: applyWrites: create claim main.7, update ref main to 8
    P-->>B: refused: claim main.7 exists
    B->>P: reread refs: main at generation 8
    Note over B: main moved, so "fetch first"
```

A refusal says nothing reliable about its cause: the reference PDS answers a
duplicate create with a 500. So after any refused batch the client rereads
the refs, one more round trip, and compares each pending ref's generation
with the one it read. A ref that moved is reported "fetch first". The other
refs of that batch and later ones are reported "not pushed: another ref in
the push moved concurrently", and refs in batches that landed are ok. If no
ref moved, the push fails with the original error, such as a bad token. On a
first push, a repo record that now exists means a racing first push won.

A claim guards its generation only while it exists, and compaction deletes
claims a day old (`KNOTLESS_CLAIM_GRACE_SECS`, 86400 by default). A push
therefore refuses to write against refs read more than half that grace
ago. No writer can hold a generation whose claim is
gone, so an old claim can never be replayed.

Of several writers pushing straight to one branch, one lands per round, and
the reference PDS also serializes an account's writes. Many agents work best
on a branch each, or by committing through a gateway, which queues commits
per branch (see gateway.md).

## Large packs

A pack up to 192 KiB is stored inline in its record's `data`. Small pushes
then cost no blob uploads, which hosts limit far more tightly than record
writes, and a fetch gets the pack with the listing. 192 KiB is 256 KiB of
base64, the lexicon's cap on `data`, well inside the 1 MB body, and small
enough for the firehose, which carries every record to every subscriber.

A larger pack is cut into consecutive parts and uploaded with `uploadBlob`.
Parts are 4,000,000 bytes, never above the blob limit the PDS advertises. A
PDS's advertised limit is not always what is in front of it, a proxy for
instance. So a part refused as too large halves the part size and the pack
is uploaded again, until parts are no larger than 64 KiB. From the default
size, the last try uses parts of 62,500 bytes.

Details:

- Parts upload four at a time, in order, as
  `application/x-git-packed-objects`.
- Too large means HTTP 413, `BlobTooLarge` or `PayloadTooLargeError`.

## Fetch

A fetch has to find the packs holding what it wants without reading every
pack, and has to survive compaction replacing packs while it reads.

If every wanted object is already present locally, there is nothing to do.
Otherwise the fetch looks in three places, in order, and stops as soon as
everything wanted is present:

1. The newest packs the snapshot read, if it read them. Those with any tip
   missing locally are downloaded and indexed, at no further round trip
   for an inline pack. Their `bases` join the wanted set, and the set is
   checked against the local repository again.
2. The packs the refs name: for each ref whose target is still missing,
   the pack its record's `pack` names, read by its key with `getRecord`,
   all at once, and indexed. Their bases join the wanted set too. A base
   that is another ref's target names a pack of its own, so this repeats
   until no missing target names a pack not yet read. A mirror that syncs
   often, as knotless-serve's and the runner's do, reads no packs in its
   snapshot, and starts here.
3. A walk of the pack pages, newest first, for what is left: a pointer gone
   stale because a compaction folded its pack (compaction moves no refs), a
   ref written by a client from before refs named their packs, or a base no
   ref points at any more, as an earlier commit of a branch a client is far
   behind on, or the tip of a branch deleted since. On each page the walk
   downloads and indexes the packs with any tip missing locally, and their
   bases join the wanted set in the same way. When the snapshot's newest
   packs were not enough, the walk goes on from where they end.

A tip being present says only that some pack brought that commit, not its
history. A branch cut from where a force push left main, say, is pushed on
top of a commit no ref holds, so its pack carries that commit too, while
the commit's parents are in the older pack it came in first. Read by name
first, the newer pack makes the older one's tip present. So a walk that runs
out of pages with something still missing walks once more taking every
pack it has not yet indexed, whatever its tips.

A compaction can delete packs between two pages, after the walk has passed
the newest page where their replacement lands. That second walk, from the
newest page, finds its replacement too. A compaction can also delete a pack the walk has listed but not yet
downloaded, and its blobs with it. A download that fails restarts the walk
from the newest page the same way. The two cases share the one restart. If
something is still missing after it, the fetch fails: "the remote has no
pack holding <sha>"; a download that fails again fails the fetch.

A fetch of a small push takes the snapshot's one round trip: its inline
pack is among the newest packs, and arrives with them. A client further
behind adds a round trip for the packs its refs name. Larger packs add
parallel blob reads.

Details:

- Up to 8 packs download and index at once; a pack's parts download 8 at a
  time and are concatenated in order.
- Each pack is indexed with `git index-pack --stdin --fix-thin`.

## Compaction

Every push adds a pack, and a clone reads them all. Compaction folds old
packs into one. `knotless repo compact` runs it on demand, as do MCP's
`compact` and the Compact button in a repository's settings on the pages,
and the owner's `git
push` through the helper starts it once the repository holds 32 packs
(`KNOTLESS_COMPACT_THRESHOLD`). The snapshot holds no packs, so the count
is the compaction's own: every owner's push that lands starts `knotless
repo compact --due`, which reads the newest page of packs, counting a further
page as 100 more, and folds only at the threshold. `KNOTLESS_COMPACT` runs
it in the `background` (the default, detached, so git does not wait for the
count), `inline` before the push returns, or `off`. It writes and deletes
the owner's records, so only the owner runs it.

No other write starts it: not a push through the gateway, nor `knotless
repo commit`, nor a commit over MCP. A repository written only those ways gains a
pack with every write until its owner runs `knotless repo compact`.

Compaction must not pull packs out from under a fetch in progress. Only
packs older than ten minutes, aged by the TID in their key, are folded. A
reader partway through a walk still finds the young packs it listed, and one
that loses an old pack restarts its walk once, as above. A fold needs at
least two old packs.

The fold syncs the local mirror, then packs every live ref target with no
bases, so the new pack holds everything the live refs reach. It also holds
the bases the young packs that stay name. A push names every tip the
repository had as a base, including branches deleted since, such as a
landed pull's. A fetch of a young pack wants its bases too, and without
them in the fold a base whose branch is gone would be in no pack, and
every fresh clone would fail with "the remote has no pack holding <sha>".
A base the fold cannot fetch stops it, with nothing deleted, rather than
being left out. It folds later, once the pack naming it is old enough to go
too. It is placed
like a push's pack, keyed with a fresh TID, its `supersedes` naming the
packs it replaces. No ref is rewritten to name it, since moving a ref record
takes a claim and would race pushes: a ref naming a folded pack is a stale
pointer, which a fetch walks past to the fold, and the ref's next move
names a live pack again. The same writes delete the old
packs and the repository's claims older than a day, in chunks of 200 with
the new pack in the first.

Compaction drops objects that no live ref or young pack's base reaches
and that were only in old packs, the old pack records, their blobs (which the PDS removes once nothing
references them), and claims a day old. The PDS thus holds about a day of
reflog; knotless-serve's index keeps every claim it sees (see serve.md). Packs
younger than the grace stay, as do the repo record and every ref record but
the empty ones older clients left of deleted refs. Each of those a day old
goes, beside a claim on its generation: a writer that read it would move the
ref off that generation, so it collides with the claim rather than moving a
ref made again since, and that ref started past it.

## Commits without a checkout

An agent that changes three files should not need a working copy.
`knotless repo commit` builds the commit in the local mirror and pushes it. Its
changes are puts and deletes of named paths.

Each attempt reads the snapshot, syncs the mirror and picks the parent: the
branch's tip, or for a new branch the given base or the default branch's tip.
It builds a tree from the parent's with a temporary index, writes the
commit, and pushes it. A tree equal to the parent's pushes nothing. The push
is forced, because the claim pins the parent's generation, which already
guarantees what git's fast-forward check would.

If the branch moved, the push is refused "fetch first". Puts and deletes are
defined by path, so applying the same changes to the new tip is a rebase that
cannot conflict. The commit replays, up to five more times by default (`--retries`), after
a random pause that grows with each lost round, so that losers do not return
together. Each lost race costs three round trips: the reread, a snapshot and
an `applyWrites`. Given a base commit, a change instead fails "conflict" if a
path it touches changed between the base and the current tip.

A logged-in reader who is not the owner commits through the repository's
gateway (see gateway.md); a repository without one refuses non-owner writes.

Details:

- Puts have mode 100644 or 100755. A path must be relative, with no empty,
  `.` or `..` segment, no NUL or newline, and no `.git` segment in any case.
- The pause before a replay is under 20 ms × 2^attempts, at most 1.28 s.
- With no author given, the author is whom git commits as on the
  committer's machine, or else the committer's handle with email
  `<did>@knotless.invalid`, which needs no email claim (see
  [Commit authors](identity.md#commit-authors)). The committer is the
  author.

## The local cache

A warm operation depends on what the client remembers: a handle's DID and
PDS, the session, and the repository's key. The cache lives in
`KNOTLESS_CACHE_DIR`, else `$XDG_CACHE_HOME/knotless`, else
`~/.cache/knotless`. Sessions and space credentials live in the config
directory instead.

| File | Maps | Kept for |
| --- | --- | --- |
| `identity.json` | handle or DID to DID and PDS | an hour, or until that PDS says the account is not there |
| `handles.json` | DID to verified handle | an hour |
| `keys.json` | `<did>/<name>` (or `#space`) to key | always; each snapshot checks it |
| `servers.json` | PDS to advertised blob limit | an hour |
| `emails.json` | DIDs whose email claim was looked up | always |

Several processes share the cache, so every JSON file is written to a
temporary file and renamed, and a reader never sees half a file. A missing
or unreadable file reads as empty.

A mirror is a bare copy of a repository at `mirrors/<did>/<key>.git` in the
cache, with each `:` of the DID written `_`, kept in step with the records.
Compaction, checkout-less commits, branches, merges, the gateway and
knotless-serve all work from one, shared by every process using the cache. A
sync fetches what the snapshot's live refs need, then sets the mirror's refs
exactly to the snapshot's in one `git update-ref --stdin` transaction. Fetches
into a mirror take turns, under the operating system's lock on a file inside
it: the newest packs land first, so a commit being there does not say its
parents are until the fetch that brought it has finished. The ref transaction
runs under the same kind of lock on a file beside the mirror. The system lets
go of either when its process ends, so a crashed process cannot block the
mirror. The transaction runs in a task of its own that
holds the lock until git is done. A caller that stops waiting, such as a
request whose asker left, does not end it, so the next sync never finds
git's own lock files on the refs still there. A git killed part way, as by
its server being stopped, can still leave one, which would refuse every later
update, so the transaction first removes any lock file under `refs/`: it holds
the lock on the refs, so no other git is at them. A new mirror is created under a temporary name and
renamed into place, so racing processes end up with the same one.

A mirror is created with git's own gc off (`gc.auto=0`). Instead, a sync
that leaves it holding more than 50 packs repacks them into one, keeping
objects no ref reaches. The repack runs outside the lock on the refs, under
a lock of its own that a second repack skips rather than waits for. A large
mirror's repack takes seconds. knotless-serve starts it in a task of its own
once a sync has set the refs, so the request that synced, and every request
waiting on that sync, goes on reading the packs that are there meanwhile.

A sync that moves the refs, or finds a mirror with no commit-graph, then
writes one (`git commit-graph write --reachable --split`): each commit's
parents and date in one file beside the packs, which walks of history read
rather than each commit from the packs. On the rust repository's 345,000
commits, a walk that takes 2.4 seconds without one takes 0.17 with it. Split,
the write adds only the commits the graph does not hold yet, so a push costs
it milliseconds. Only the first, of a mirror's whole history, takes long.

## The git remote helper

git runs `git-remote-knotless` for `knotless://` URLs. It lists live refs,
with the default branch as HEAD, fetches in one walk, and sends all of a
push's refs as one push, answering per ref. It refuses what git refuses, so
`git push` behaves as it does against any remote.

An unforced update whose old remote target is not in the local repository is
refused "fetch first": it would overwrite commits the pusher has never seen.
An update that is not a descendant is refused "non-fast-forward". A forced
push skips both checks but still goes through the claim's compare-and-swap,
so it loses to a concurrent move rather than overwriting it. A delete keeps
the ref record with no target and the next generation.

A handle can change hands while a remote's URL keeps naming it, and the
remote would then fetch someone else's refs. So on first contact through a
named remote, the helper stores the account's DID in
`remote.<name>.knotlessdid`, and refuses a later run whose URL resolves to
another DID. A URL given straight on the command line is not pinned. A
repository that was renamed or moved is followed, and the helper suggests
the `git remote set-url` for its new place.

A logged-in reader who is not the owner pushes through the repository's
gateway. ../reference/performance.md has round-trip timings, and
../reference/cli.md the commands and the environment variables that set the
limits on this page.