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