vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software: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 · 17d 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.
internal/lfs/transfer.go +43−10
| @@ -7,10 +7,10 @@ package lfs | |||
| 7 | 7 | // uploaded the bytes it names. | |
| 8 | 8 | // | |
| 9 | 9 | // The wire format is git's pkt-line framing with a JSON-free command set; see | |
| 10 | // git-lfs docs/proposals/ssh_adapter.md. Only what a client needs to move | ||
| 11 | // objects is implemented: version, batch, put-object, get-object, | ||
| 12 | // verify-object, quit. Locking is not supported, so `locking` is never | ||
| 13 | // advertised and a client will not offer it. | ||
| 10 | // git-lfs docs/proposals/ssh_adapter.md. Moving objects needs version, batch, | ||
| 11 | // put-object, get-object, verify-object and quit; locking adds lock, unlock and | ||
| 12 | // list-lock, and is advertised as the `locking` capability — a client will not | ||
| 13 | // offer it otherwise (see locks.go). | ||
| 14 | 14 | ||
| 15 | 15 | import ( | |
| 16 | 16 | "bufio" | |
| @@ -20,6 +20,8 @@ import ( | |||
| 20 | 20 | "log/slog" | |
| 21 | 21 | "strconv" | |
| 22 | 22 | "strings" | |
| 23 | |||
| 24 | "github.com/rodent-software/vole/internal/locks" | ||
| 23 | 25 | ) | |
| 24 | 26 | ||
| 25 | 27 | const ( | |
| @@ -34,15 +36,27 @@ const ( | |||
| 34 | 36 | ||
| 35 | 37 | var errProtocol = errors.New("lfs: protocol error") | |
| 36 | 38 | ||
| 37 | // Transfer serves one LFS session. CanRead and CanWrite come from the repo's | ||
| 38 | // ACL, resolved by the SSH server from the account that owns the key. | ||
| 39 | // Transfer serves one LFS session. CanRead, CanWrite and IsAdmin come from the | ||
| 40 | // repo's ACL, resolved by the SSH server from the account that owns the key. | ||
| 39 | 41 | type Transfer struct { | |
| 40 | Store *Store | ||
| 41 | Repo string | ||
| 42 | Store *Store | ||
| 43 | Repo string | ||
| 44 | // RepoID is the repository's stable id, which is what a lock is keyed by: a | ||
| 45 | // rename must not release a lock. | ||
| 46 | RepoID string | ||
| 42 | 47 | AccountID string | |
| 43 | 48 | CanRead bool | |
| 44 | 49 | CanWrite bool | |
| 45 | Log *slog.Logger | ||
| 50 | // IsAdmin lets an account release somebody else's lock. The lock store does not | ||
| 51 | // read ACLs; the session tells it, so the decision stays in one place. | ||
| 52 | IsAdmin bool | ||
| 53 | // Locks is the lock table. Nil means this server offers none, and the `locking` | ||
| 54 | // capability is not advertised. | ||
| 55 | Locks *locks.Store | ||
| 56 | // Names renders an account id as a name for messages. Nil means ids are shown, | ||
| 57 | // which is honest but ugly; the SSH server always supplies one. | ||
| 58 | Names func(accountID string) string | ||
| 59 | Log *slog.Logger | ||
| 46 | 60 | } | |
| 47 | 61 | ||
| 48 | 62 | // Serve runs the session until the client quits or the connection ends. op is | |
| @@ -54,10 +68,17 @@ func (t *Transfer) Serve(op string, r io.Reader, w io.Writer) error { | |||
| 54 | 68 | pr := newPktReader(r) | |
| 55 | 69 | pw := newPktWriter(w) | |
| 56 | 70 | ||
| 57 | // Capabilities first: the client reads them before it says anything. | ||
| 71 | // Capabilities first: the client reads them before it says anything. `locking` is | ||
| 72 | // advertised only when there is a lock table, because a client that sees it will | ||
| 73 | // offer `git lfs lock` and expects it to work. | ||
| 58 | 74 | if err := pw.pkt("version=1"); err != nil { | |
| 59 | 75 | return err | |
| 60 | 76 | } | |
| 77 | if t.Locks != nil { | ||
| 78 | if err := pw.pkt("locking"); err != nil { | ||
| 79 | return err | ||
| 80 | } | ||
| 81 | } | ||
| 61 | 82 | if err := pw.flush(); err != nil { | |
| 62 | 83 | return err | |
| 63 | 84 | } | |
| @@ -100,6 +121,18 @@ func (t *Transfer) Serve(op string, r io.Reader, w io.Writer) error { | |||
| 100 | 121 | if err := t.verify(strings.TrimSpace(strings.TrimPrefix(line, "verify-object ")), pr, pw); err != nil { | |
| 101 | 122 | return err | |
| 102 | 123 | } | |
| 124 | case line == "lock": | ||
| 125 | if err := t.lock(pr, pw); err != nil { | ||
| 126 | return err | ||
| 127 | } | ||
| 128 | case line == "list-lock": | ||
| 129 | if err := t.listLock(op, pr, pw); err != nil { | ||
| 130 | return err | ||
| 131 | } | ||
| 132 | case strings.HasPrefix(line, "unlock "): | ||
| 133 | if err := t.unlock(strings.TrimSpace(strings.TrimPrefix(line, "unlock ")), pr, pw); err != nil { | ||
| 134 | return err | ||
| 135 | } | ||
| 103 | 136 | default: | |
| 104 | 137 | // Unknown commands must still consume their arguments, or the next | |
| 105 | 138 | // command is read from the middle of this one. | |