Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Phase 5: file locking, for the repositories git cannot merge

bf1e027ce09298d66cb8225fb1f5871e959f42bc · Keenan Nemetz · 16d ago · parent 21d48210

Phase 5: file locking, for the repositories git cannot merge

`git lfs lock <path>` works over the SSH transfer, and while a path is held a
push that changes it is refused. This is what makes a repository of design
files usable by more than one person: two people pushing changes to the same
binary leaves one version and silently discards the other, and "discarded" is
not a conflict anybody can resolve.

The HTTP locking API is the usual path for git-lfs, and it is a *write*
surface, which this instance does not have — its web is anonymous and
read-only. So locking rides the SSH transfer protocol instead (lock,
list-lock, unlock, advertised as the `locking` capability): the key that
pushed is the key that locks, with no second endpoint and nothing new to
authenticate.

The decision worth remembering is where the refusal lives. git-lfs enforces
locks on the *client* — it asks the server what is held and declines to send
the push — which is a courtesy, and a plain `git push` walks straight past
it. So the rule also sits in pre-receive, at the one place this forge
enforces everything else, where a client setting cannot turn it off. The
client-side check becomes a faster error message rather than the authority.
It guards only the paths the push actually writes, so reading, branching and
merging untouched history are unaffected. An admin may override, audited, for
the same reason force-push is allowed: a lock whose holder has left would
otherwise make a path unpushable forever.

Locks live in data/users.db beside the roster and the access rules — the file
holding what git cannot rebuild — because a lock resurrected from history
would be exactly wrong. The point of a lock is that it is released. They are
keyed by account id, so a rename releases nothing, and path normalization
makes "./art.psd" and "art.psd" one lock rather than two.

/r/<repo>/locks answers "who is holding what", which the protocol otherwise
hides completely: the next person to push is refused with no way to find out
why without asking somebody.

Measured: TestALockRefusesAPushUntilItIsReleased drives the whole lifecycle
through the binary over SSH — lock, a refused push naming the holder, unlock,
the same push landing — with git-lfs deliberately absent so the refusal can
only come from pre-receive. TestLocksAreAdvertisedOnlyWhenOffered pins that
the capability follows the lock table, since a client that reads `locking`
will offer a command that must work. Store tests cover path normalization,
paging, owner partitioning and the audited override.

Known gap, stated rather than assumed: git-lfs is not installed here, so the
protocol was written against docs/proposals/ssh_adapter.md and verified with
a client written to that document — not with a shipped client. The framing,
the 201/409 argument blocks and the five-line lock-spec records are the
spec's; whether a real `git lfs lock` agrees is the first thing to check on a
machine that has one.

docs/roadmap.md +42−3

@@ -608,7 +608,7 @@ a push whose spool file was never written still runs ✅ (the refs are reconcile
608 608 by `TestReconcileRebuildsAQueueThatWasDeleted`, which deletes the queue and its WAL and
609 609 gets the live tip queued again).
610 610
611 ### Phase 5 — LFS — ✅ complete (locking deferred)
611 ### Phase 5 — LFS — ✅ complete (object scoping outstanding)
612 612
613 613 ✅ **Slice 1 — the store, and reads through the proxy.** Objects live at
614 614 `data/lfs/objects/<aa>/<bb>/<sha256>`, shared by every repository on the instance, and
@@ -656,12 +656,50 @@ Remaining:
656 656 from any repository whose read rule you pass, if you know its id. Acceptable for
657 657 release artifacts, worth revisiting if repositories ever need to be isolated from
658 658 each other.
659 - Locking. `locking` is not advertised, so a client prints one harmless "does not
660 support the Git LFS locking API" line before an upload.
661 659 - Reference counting was replaced by mark-and-sweep. A counter that is one too low
662 660 deletes somebody's artifact after a crash; a walk over the refs cannot be wrong about
663 661 what is reachable, only slow — and `gc` is an operator command, not a hot path.
664 662
663 ✅ **Slice 5 — file locking.** `git lfs lock <path>` works over the SSH transfer, so a
664 binary that git cannot merge can be reserved by one person before they edit it: a push
665 that changes a locked path is refused, naming the holder, and the lock is released when
666 its holder says so. This is what makes a repository of design files usable by more than
667 one person, and it is the reason a studio would pick a forge with it over one without.
668
669 The HTTP locking API is the usual path for git-lfs, and it is a *write* surface — which
670 this instance does not have, because its web is anonymous and read-only. So locking rides
671 the SSH transfer protocol instead (`lock`, `list-lock`, `unlock`, advertised as the
672 `locking` capability), which means the key that pushed is the key that locks: no second
673 endpoint, no token, nothing new to authenticate. Locks live in `data/users.db` beside the
674 roster and the access rules — the file that holds what git cannot rebuild — because a lock
675 resurrected from history would be exactly wrong: the point of a lock is that it is
676 released. They are keyed by account id, so a rename changes nothing.
677
678 The interesting decision is *where the refusal lives*. git-lfs enforces locks on the
679 client: it asks the server what is held and declines to send the push. That is a courtesy,
680 and a plain `git push` walks straight past it — so the rule also sits in `pre-receive`, at
681 the one place this forge enforces everything else, where it cannot be turned off by a
682 client setting. What it guards is narrow on purpose: only the paths the push actually
683 writes, so reading, branching and merging untouched history are all unaffected. An admin
684 may override, audited, for the same reason a force-push is allowed: otherwise a lock whose
685 holder has left makes a path unpushable forever.
686
687 `/r/<repo>/locks` answers "who is holding what", which the protocol otherwise hides
688 completely — the next person to push is refused with no way to find out why without asking
689 somebody.
690 (Measured: `TestALockRefusesAPushUntilItIsReleased` drives it through the binary over SSH —
691 lock, refused push naming the holder, unlock, the same push landing; `TestLocksAre-
692 AdvertisedOnlyWhenOffered` pins that the capability follows the lock table, since a client
693 that reads `locking` will offer a command that must work; the store's own tests cover path
694 normalization (one file, one lock, however it was typed), paging, owner partitioning and
695 the audited override.)
696
697 **Known gap, stated rather than assumed:** git-lfs is not installed in this environment, so
698 the protocol was implemented against `docs/proposals/ssh_adapter.md` and verified with a
699 client written to that document, not with a shipped client. The framing, the 201/409
700 argument blocks and the five-line lock-spec records are the spec's; whether a real
701 `git lfs lock` agrees is the first thing to check on a machine that has one.
702
665 703 **Exit criteria:** a clone of an LFS repository pulls real objects through the proxy
666 704 (measured: a 256 KiB artifact pushed over SSH and pulled back byte-identical via
667 705 `http://…/git/<repo>.git`, with the sha256 checked on both ends); a pointer pushed
@@ -1100,6 +1138,7 @@ Filled in as phases land. Append; don't rewrite.
1100 1138 | 2026-10-23 | A repository may be bounded with `max_jobs_per_repo`, and finishing a job wakes the lease | Ordering and bounding answer different questions: ordering (least busy repository first) keeps one repository's burst from taking the whole fleet, and a cap is what bounds one repository's footprint on purpose. Enforcing it as a skip in the lease rather than a refusal keeps the other repositories an account may run serviceable, which makes `nil` mean two things — "nothing to do" and "work, but not for you yet" — and a cap-bound queue is exactly where the second meaning would hang forever, since a parked runner has nothing to wake it. So `Finish` notifies as well: a released slot is leasable work. The runners page renders `4 of 4 (at the cap)` for the same reason, because a depth that is full and not draining with no explanation is the question that page exists to answer |
1101 1139 | 2026-10-23 | A runner's `--repos` filter is applied by the server, as an intersection with the allowlist | A filter and a grant are different things: `--repos` says what one machine is *for*, and the runner allowlist says what its account *may do*. Applying the filter server-side, against the grant, means the client's list can only shrink the answer — if the server honoured the request list directly, an SSH flag would have become an access-control decision, and the first person to notice would be whoever read the flag's name and assumed otherwise. An empty filter means "no narrowing" rather than "nothing" so an unfiltered runner keeps following allowlist edits instead of freezing at connect time. The cost is a filter that matches nothing, which presents as a healthy idle runner, so `ping` reports the grant and the runner refuses at startup instead. `Lease` consequently takes a struct: it now holds two lists of names, and adjacent `[]string` arguments are the shape that transposes silently |
1102 1140 | 2026-10-23 | `runs-on` labels are pinned at enqueue, and a runner's labels are a claim rather than a grant | Pinning reuses the rule that already governs steps and artifact patterns: a workflow edited after a push must not change what an already-queued job is. That settles the derived-store question too — `run_labels` is queue state, not history, because a finished run's requirement is recoverable from the pinned workflow blob, so `index rebuild` does not restore labels and nothing needs it to. The asymmetry with `--repos` is the interesting part: a runner's labels are a claim about itself, so lying only wins it work it will do badly, while a runner's repository list is enforced by the server because an SSH flag must not become an access-control decision. Both are in the same lease request and neither is trusted for the same reason. Labels are lowercase and validated by `pre-receive` and by the runner with one shared rule, because a label no runner can carry leaves a job queued forever behind an error nobody sees |
1141 | 2026-10-23 | LFS locking rides the SSH transfer protocol, and the refusal lives in `pre-receive` | The HTTP locking API is a write surface, and this instance has none: HTTP is anonymous read-only. The SSH adapter defines `lock`, `list-lock` and `unlock`, so locking costs no new authentication — the key that pushed is the key that locks. Where the refusal lives is the part worth remembering. git-lfs checks locks on the *client*, which means a plain `git push`, or a client with its hook disabled, walks past a lock entirely — so the rule also sits in `pre-receive`, at the one place this forge enforces everything else. The client-side check becomes a faster error message rather than the authority. Narrow on purpose: only the paths the push writes, so branching and merging untouched history are unaffected. Admin override is audited, for the same reason force-push is: a lock whose holder has left would otherwise make a path unpushable forever |
1103 1142
1104 1143 ## Open questions
1105 1144