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.

internal/server/server.go +17−1

@@ -17,6 +17,7 @@ import (
17 17 "github.com/rodent-software/vole/internal/identity"
18 18 "github.com/rodent-software/vole/internal/index"
19 19 "github.com/rodent-software/vole/internal/lfs"
20 "github.com/rodent-software/vole/internal/locks"
20 21 "github.com/rodent-software/vole/internal/pages"
21 22 "github.com/rodent-software/vole/internal/repo"
22 23 "github.com/rodent-software/vole/internal/runs"
@@ -34,6 +35,7 @@ type Server struct {
34 35 Runs *runs.Store
35 36 Index *index.Store
36 37 LFS *lfs.Store
38 Locks *locks.Store
37 39 Pages *pages.Server
38 40 Proxy *transport.Proxy
39 41 SSH *sshserver.Server
@@ -70,6 +72,14 @@ func New(cfg *config.Config, binPath string, log *slog.Logger) (*Server, error)
70 72 return nil, fmt.Errorf("open access rules: %w", err)
71 73 }
72 74 acls.AuditPath = cfg.AuditLog()
75 // Locks live in the same database as the roster and the access rules: all three are
76 // keyed by account id and none of them can be rebuilt from git, so they are backed
77 // up and restored together or the ids stop resolving.
78 lockStore, err := locks.Open(cfg.UsersDB())
79 if err != nil {
80 return nil, fmt.Errorf("open locks: %w", err)
81 }
82 lockStore.AuditPath = cfg.AuditLog()
73 83 rs := repo.New(cfg.ReposDir(), binPath, acls)
74 84 rs.MaxInputBytes = cfg.ReceiveMaxBytes
75 85 if n, err := rs.ImportACLs(); err != nil {
@@ -100,6 +110,7 @@ func New(cfg *config.Config, binPath string, log *slog.Logger) (*Server, error)
100 110 SpoolDir: cfg.SpoolDir(),
101 111 RunsDB: cfg.RunsDB(),
102 112 Runs: runStore,
113 Locks: lockStore,
103 114 Lease: cfg.Lease(),
104 115 MaxJobsPerRepo: cfg.MaxJobsPerRepo,
105 116 PagesRef: cfg.PagesRef,
@@ -113,10 +124,11 @@ func New(cfg *config.Config, binPath string, log *slog.Logger) (*Server, error)
113 124 return nil, err
114 125 }
115 126 wp.LFS = lfsStore
127 wp.Locks = lockStore
116 128 wp.Logger = func(format string, args ...any) { log.Warn(fmt.Sprintf(format, args...)) }
117 129
118 130 s := &Server{
119 Cfg: cfg, Repos: rs, Identity: ident, Runs: runStore, Index: ix, LFS: lfsStore,
131 Cfg: cfg, Repos: rs, Identity: ident, Runs: runStore, Index: ix, LFS: lfsStore, Locks: lockStore,
120 132 Proxy: proxy, SSH: ssh, Web: wp, Log: log,
121 133 }
122 134 // Sites are opt-in: without a pages domain there is no host to serve them on,
@@ -160,6 +172,7 @@ func (s *Server) routes() *http.ServeMux {
160 172 mux.HandleFunc("GET /r/{name}/requests/{id}", w.RequestView)
161 173 // A tag may contain "/", so the detail route takes the rest of the path; the
162 174 // two archive routes are more specific and win for their suffixes.
175 mux.HandleFunc("GET /r/{name}/locks", w.LocksPage)
163 176 mux.HandleFunc("GET /r/{name}/releases", w.Releases)
164 177 mux.HandleFunc("GET /r/{name}/releases/{tag...}", w.ReleaseView)
165 178 mux.HandleFunc("GET /r/{name}/releases/{tag}/archive.tar", w.ReleaseTar)
@@ -275,6 +288,9 @@ func (s *Server) Close() error {
275 288 if s.Runs != nil {
276 289 _ = s.Runs.Close()
277 290 }
291 if s.Locks != nil {
292 _ = s.Locks.Close()
293 }
278 294 if s.Index != nil {
279 295 if err := s.Index.Close(); err != nil {
280 296 return err