Git backed by object storage because you can't stop me
git
object-storage
kefka
1package main
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "log/slog"
9 "net"
10 "net/url"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/go-git/go-billy/v6"
16 git "github.com/go-git/go-git/v6"
17 "github.com/go-git/go-git/v6/plumbing"
18 "github.com/go-git/go-git/v6/plumbing/cache"
19 "github.com/go-git/go-git/v6/plumbing/format/pktline"
20 "github.com/go-git/go-git/v6/plumbing/protocol/packp"
21 "github.com/go-git/go-git/v6/plumbing/transport"
22 "github.com/go-git/go-git/v6/storage"
23 "github.com/go-git/go-git/v6/storage/filesystem"
24)
25
26// handshakeTimeout bounds how long a client has to send its git-proto-request.
27// It is cleared once the (possibly long) transfer begins.
28const handshakeTimeout = 30 * time.Second
29
30// streamingStorer wraps a storage.Storer to hide its optional
31// storer.PackfileWriter capability. go-git's UpdateObjectStorage drains the
32// incoming pack into PackfileWriter via io.CopyBuffer, which only returns on
33// io.EOF — fine over HTTP (the request body has a natural EOF) but a deadlock
34// over git://, where the client holds the connection open waiting for the
35// server's report-status. With PackfileWriter hidden, UpdateObjectStorage
36// falls through to Parser.Parse, which knows the end of the pack from the
37// pack format itself and never waits for an EOF.
38//
39// Trade-off: Parser.Parse writes loose objects (one Rename → one S3 PUT each)
40// instead of one packfile, so large git:// pushes incur more S3 calls. HTTP
41// keeps the fast PackfileWriter path.
42type streamingStorer struct {
43 storage.Storer
44}
45
46// daemon serves the git:// (TCP) protocol out of a billy filesystem.
47type daemon struct {
48 fs billy.Filesystem
49 loader transport.Loader
50 allowPush bool
51
52 // allowHooks gates running .objgit/hooks/receive-pack after a push.
53 allowHooks bool
54 hookTimeout time.Duration
55 // hookWG tracks in-flight async hooks so shutdown can drain them.
56 hookWG sync.WaitGroup
57}
58
59// Serve accepts connections on l until ctx is cancelled or Accept fails.
60func (d *daemon) Serve(ctx context.Context, l net.Listener) error {
61 go func() {
62 <-ctx.Done()
63 _ = l.Close()
64 }()
65
66 for {
67 conn, err := l.Accept()
68 if err != nil {
69 if ctx.Err() != nil {
70 return nil
71 }
72 return fmt.Errorf("objgitd: accept: %w", err)
73 }
74
75 go func() {
76 if err := d.handle(ctx, conn); err != nil {
77 slog.Error("connection failed",
78 "remote", conn.RemoteAddr().String(),
79 "err", err,
80 )
81 }
82 }()
83 }
84}
85
86// handle services a single git:// connection: decode the request line, resolve
87// the repository, and hand the socket to the matching server command.
88func (d *daemon) handle(ctx context.Context, conn net.Conn) error {
89 defer conn.Close()
90
91 // A silent client must not be able to pin a goroutine forever.
92 _ = conn.SetReadDeadline(time.Now().Add(handshakeTimeout))
93
94 var req packp.GitProtoRequest
95 if err := req.Decode(conn); err != nil {
96 return fmt.Errorf("decoding git-proto-request: %w", err)
97 }
98
99 // The transfer that follows can take a while; drop the handshake deadline.
100 _ = conn.SetReadDeadline(time.Time{})
101
102 slog.Info("serving request",
103 "service", req.RequestCommand,
104 "path", req.Pathname,
105 "remote", conn.RemoteAddr().String(),
106 )
107
108 // ExtraParams carries e.g. "version=2"; transport.ProtocolVersion splits on ":".
109 gitProtocol := strings.Join(req.ExtraParams, ":")
110
111 // UploadPack/ReceivePack call r.Close() between negotiation rounds, so the
112 // reader must be a no-op closer or the socket dies mid-conversation. The
113 // writer is the raw conn: its final Close() ends the connection.
114 r := io.NopCloser(conn)
115
116 switch req.RequestCommand {
117 case transport.UploadPackService:
118 st, err := d.loader.Load(&url.URL{Path: req.Pathname})
119 if err != nil {
120 _, _ = pktline.WriteError(conn, fmt.Errorf("repository %q not found", req.Pathname))
121 return fmt.Errorf("loading %q: %w", req.Pathname, err)
122 }
123 return transport.UploadPack(ctx, st, r, conn, &transport.UploadPackRequest{
124 GitProtocol: gitProtocol,
125 })
126
127 case transport.UploadArchiveService:
128 st, err := d.loader.Load(&url.URL{Path: req.Pathname})
129 if err != nil {
130 _, _ = pktline.WriteError(conn, fmt.Errorf("repository %q not found", req.Pathname))
131 return fmt.Errorf("loading %q: %w", req.Pathname, err)
132 }
133 return transport.UploadArchive(ctx, st, r, conn, &transport.UploadArchiveRequest{})
134
135 case transport.ReceivePackService:
136 if !d.allowPush {
137 _, _ = pktline.WriteError(conn, fmt.Errorf("push is disabled on this server"))
138 return fmt.Errorf("push rejected for %q", req.Pathname)
139 }
140 st, err := d.loadOrInit(req.Pathname)
141 if err != nil {
142 _, _ = pktline.WriteError(conn, fmt.Errorf("cannot open repository %q", req.Pathname))
143 return fmt.Errorf("opening %q for push: %w", req.Pathname, err)
144 }
145 return d.receivePack(ctx, streamingStorer{Storer: st}, st, req.Pathname, r, conn, &transport.ReceivePackRequest{
146 GitProtocol: gitProtocol,
147 })
148
149 default:
150 _, _ = pktline.WriteError(conn, fmt.Errorf("unsupported service %q", req.RequestCommand))
151 return fmt.Errorf("unsupported service: %s", req.RequestCommand)
152 }
153}
154
155// loadOrInit returns the storer for repoPath, creating an empty bare repository
156// on demand. Git's daemon never auto-creates; objgitd does, so a first push to
157// a new path just works.
158func (d *daemon) loadOrInit(repoPath string) (storage.Storer, error) {
159 st, err := d.loader.Load(&url.URL{Path: repoPath})
160 if err == nil {
161 return st, nil
162 }
163 if !errors.Is(err, transport.ErrRepositoryNotFound) {
164 return nil, err
165 }
166
167 fs, err := d.fs.Chroot(repoPath)
168 if err != nil {
169 return nil, fmt.Errorf("chroot %q: %w", repoPath, err)
170 }
171
172 st = filesystem.NewStorage(fs, cache.NewObjectLRUDefault())
173 if _, err := git.Init(st, git.WithDefaultBranch(plumbing.NewBranchReferenceName("main"))); err != nil {
174 return nil, fmt.Errorf("init bare repo: %w", err)
175 }
176
177 slog.Info("created repository", "path", repoPath)
178 return st, nil
179}