orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3// Every repository read here is a git subprocess. The rules that keep that safe:
4// plumbing commands only, NUL delimiters (-z and %x00) because ref names and
5// commit messages contain newlines, and "--" before every user supplied ref or path.
6
7import (
8 "bufio"
9 "bytes"
10 "context"
11 "fmt"
12 "io"
13 "os"
14 "os/exec"
15 "path/filepath"
16 "sort"
17 "strconv"
18 "strings"
19 "sync"
20 "time"
21)
22
23// gitTimeout stays under Cloudflare's 100 second ceiling, so a wedged subprocess
24// surfaces as a 500 here rather than a 524 from the edge.
25const gitTimeout = 20 * time.Second
26
27// maxBlobSize is the largest file read into memory to render. Anything larger is
28// offered as a raw download, which streams.
29const maxBlobSize = 25 << 20
30
31// Repo is one bare repository on disk. Name is the URL segment and the display
32// name; Path is the --git-dir.
33type Repo struct {
34 Name string
35 Path string
36}
37
38// validName gates every name arriving from a URL before it is joined to a path,
39// and is the traversal fence. Interior dots are allowed because
40// `blog.bythewood.me` is a repository name here; a leading dot is not.
41func validName(name string) bool {
42 if name == "" || len(name) > 100 {
43 return false
44 }
45 if strings.HasPrefix(name, ".") {
46 return false
47 }
48 // A leading dash reads as an option to any git command that sees it unguarded.
49 if strings.HasPrefix(name, "-") {
50 return false
51 }
52 if strings.Contains(name, "..") {
53 return false
54 }
55 if strings.ContainsAny(name, "/\\\x00") {
56 return false
57 }
58 for _, r := range name {
59 switch {
60 case r >= 'a' && r <= 'z',
61 r >= 'A' && r <= 'Z',
62 r >= '0' && r <= '9',
63 r == '.' || r == '-' || r == '_':
64 default:
65 return false
66 }
67 }
68 return true
69}
70
71// Store owns the repository root, the cat-file readers keyed by repository, and
72// the listing cards keyed the same way.
73type Store struct {
74 Root string
75
76 mu sync.Mutex
77 batches map[string]*catFile
78
79 // A separate lock from mu so reading a card never waits on a cat-file open.
80 overviewMu sync.Mutex
81 overviews map[string]overviewEntry
82}
83
84func NewStore(root string) *Store {
85 return &Store{
86 Root: root,
87 batches: make(map[string]*catFile),
88 overviews: make(map[string]overviewEntry),
89 }
90}
91
92// Open resolves a name to a repository. Invalid or missing is (Repo{}, false),
93// never a git error that leaks a path.
94func (s *Store) Open(name string) (Repo, bool) {
95 if !validName(name) {
96 return Repo{}, false
97 }
98 path := filepath.Join(s.Root, name+".git")
99 info, err := os.Stat(path)
100 if err != nil || !info.IsDir() {
101 return Repo{}, false
102 }
103 return Repo{Name: name, Path: path}, true
104}
105
106// Discover lists every bare repository under the root; anything not ending in
107// .git is ignored, so the root may hold other things without publishing them.
108func (s *Store) Discover() ([]Repo, error) {
109 entries, err := os.ReadDir(s.Root)
110 if err != nil {
111 return nil, fmt.Errorf("read repo root: %w", err)
112 }
113
114 var repos []Repo
115 for _, e := range entries {
116 if !e.IsDir() || !strings.HasSuffix(e.Name(), ".git") {
117 continue
118 }
119 name := strings.TrimSuffix(e.Name(), ".git")
120 if !validName(name) {
121 continue
122 }
123 repos = append(repos, Repo{Name: name, Path: filepath.Join(s.Root, e.Name())})
124 }
125 sort.Slice(repos, func(i, j int) bool { return repos[i].Name < repos[j].Name })
126 return repos, nil
127}
128
129// gitCmd builds a git invocation against one repository with the safety flags
130// every call in this file needs, so no call site has to remember them.
131func gitCmd(ctx context.Context, repo Repo, args ...string) *exec.Cmd {
132 full := []string{
133 // Without this git escapes non-ASCII path bytes into octal in its own output.
134 "-c", "core.quotePath=false",
135 // A served repository is data, never a place to run a program from.
136 "-c", "core.fsmonitor=false",
137 "--git-dir", repo.Path,
138 }
139 cmd := exec.CommandContext(ctx, "git", append(full, args...)...)
140 // git refuses some operations without a HOME, the mirror fetch among them.
141 cmd.Env = append(os.Environ(),
142 "GIT_TERMINAL_PROMPT=0",
143 "GIT_CONFIG_NOSYSTEM=1",
144 "HOME="+os.TempDir(),
145 )
146 return cmd
147}
148
149// run executes a plumbing command and returns stdout; stderr is folded into the error.
150func run(ctx context.Context, repo Repo, args ...string) ([]byte, error) {
151 ctx, cancel := context.WithTimeout(ctx, gitTimeout)
152 defer cancel()
153
154 cmd := gitCmd(ctx, repo, args...)
155 var stdout, stderr bytes.Buffer
156 cmd.Stdout = &stdout
157 cmd.Stderr = &stderr
158
159 if err := cmd.Run(); err != nil {
160 return nil, fmt.Errorf("git %s: %w: %s",
161 strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
162 }
163 return stdout.Bytes(), nil
164}
165
166// Ref is a branch or a tag with its commit already resolved. %(*objectname) is
167// the commit an annotated tag points at and is empty for a lightweight one, so
168// Target picks whichever of the two is real.
169type Ref struct {
170 Name string
171 FullName string
172 Target string
173 Subject string
174 Author string
175 When time.Time
176 Annotated bool
177 Message string
178}
179
180// refFormat is one line per ref, NUL between fields. A trailing %00 plus -z would
181// double-terminate, so for-each-ref keeps the record separator.
182const refFormat = "%(refname:short)%00%(refname)%00%(objectname)%00%(*objectname)%00" +
183 "%(contents:subject)%00%(authorname)%00%(taggername)%00%(creatordate:iso-strict)%00%(contents:body)"
184
185func (s *Store) refs(ctx context.Context, repo Repo, pattern string) ([]Ref, error) {
186 out, err := run(ctx, repo, "for-each-ref",
187 "--sort=-creatordate", "--format="+refFormat, pattern)
188 if err != nil {
189 return nil, err
190 }
191
192 var refs []Ref
193 for _, line := range strings.Split(string(out), "\n") {
194 if strings.TrimSpace(line) == "" {
195 continue
196 }
197 f := strings.Split(line, "\x00")
198 if len(f) < 9 {
199 continue
200 }
201 author := f[5]
202 if author == "" {
203 author = f[6]
204 }
205 // The starred object name is set only for an annotated tag.
206 target, annotated := f[2], false
207 if f[3] != "" {
208 target, annotated = f[3], true
209 }
210 refs = append(refs, Ref{
211 Name: f[0],
212 FullName: f[1],
213 Target: target,
214 Subject: f[4],
215 Author: author,
216 When: parseISO(f[7]),
217 Annotated: annotated,
218 Message: f[8],
219 })
220 }
221 return refs, nil
222}
223
224func (s *Store) Branches(ctx context.Context, repo Repo) ([]Ref, error) {
225 return s.refs(ctx, repo, "refs/heads/")
226}
227
228func (s *Store) Tags(ctx context.Context, repo Repo) ([]Ref, error) {
229 return s.refs(ctx, repo, "refs/tags/")
230}
231
232// Head returns the default branch's short name. HEAD is a symbolic ref, so this
233// answers on an empty repository too.
234func (s *Store) Head(ctx context.Context, repo Repo) string {
235 out, err := run(ctx, repo, "symbolic-ref", "--short", "HEAD")
236 if err != nil {
237 return "main"
238 }
239 return strings.TrimSpace(string(out))
240}
241
242// IsEmpty reports a repository with no commits; every other read fails on one.
243func (s *Store) IsEmpty(ctx context.Context, repo Repo) bool {
244 _, err := run(ctx, repo, "rev-parse", "--verify", "--quiet", "HEAD")
245 return err != nil
246}
247
248// Commit is one entry in a log.
249type Commit struct {
250 SHA string
251 Short string
252 Author string
253 Email string
254 When time.Time
255 Commit time.Time
256 Parents []string
257 Refs string
258 Subject string
259 Body string
260}
261
262// logFormat is ten NUL separated fields. With -z git appends a NUL after each
263// record too, so splitting the stream on NUL yields groups of ten.
264const logFormat = "%H%x00%h%x00%an%x00%ae%x00%aI%x00%cI%x00%P%x00%D%x00%s%x00%b"
265
266const logFields = 10
267
268// Log reads commits reachable from rev; the caller caps limit.
269func (s *Store) Log(ctx context.Context, repo Repo, rev string, skip, limit int) ([]Commit, error) {
270 args := []string{"log", "-z", "--format=" + logFormat,
271 "--skip=" + strconv.Itoa(skip), "-n", strconv.Itoa(limit), rev, "--"}
272 out, err := run(ctx, repo, args...)
273 if err != nil {
274 return nil, err
275 }
276 return parseLog(out), nil
277}
278
279// LogFile is Log narrowed to one path.
280func (s *Store) LogFile(ctx context.Context, repo Repo, rev, path string, skip, limit int) ([]Commit, error) {
281 args := []string{"log", "-z", "--format=" + logFormat,
282 "--skip=" + strconv.Itoa(skip), "-n", strconv.Itoa(limit), rev, "--", path}
283 out, err := run(ctx, repo, args...)
284 if err != nil {
285 return nil, err
286 }
287 return parseLog(out), nil
288}
289
290func parseLog(out []byte) []Commit {
291 fields := strings.Split(string(out), "\x00")
292 var commits []Commit
293 for i := 0; i+logFields <= len(fields); i += logFields {
294 f := fields[i : i+logFields]
295 // Some git versions leave a leading newline on the next group's first
296 // field, so trim rather than trusting the separator.
297 sha := strings.TrimLeft(f[0], "\n")
298 if len(sha) < 7 {
299 break
300 }
301 var parents []string
302 if f[6] != "" {
303 parents = strings.Fields(f[6])
304 }
305 commits = append(commits, Commit{
306 SHA: sha,
307 Short: f[1],
308 Author: f[2],
309 Email: f[3],
310 When: parseISO(f[4]),
311 Commit: parseISO(f[5]),
312 Parents: parents,
313 Refs: f[7],
314 Subject: f[8],
315 Body: strings.TrimRight(f[9], "\n"),
316 })
317 }
318 return commits
319}
320
321// CommitOne reads a single commit, or errors if rev does not name one.
322func (s *Store) CommitOne(ctx context.Context, repo Repo, rev string) (Commit, error) {
323 out, err := run(ctx, repo, "log", "-z", "--format="+logFormat, "-n", "1", rev, "--")
324 if err != nil {
325 return Commit{}, err
326 }
327 commits := parseLog(out)
328 if len(commits) == 0 {
329 return Commit{}, fmt.Errorf("no such commit: %s", rev)
330 }
331 return commits[0], nil
332}
333
334// CountCommits walks the graph, and is the one number on a repository page that
335// costs real work on a large history.
336func (s *Store) CountCommits(ctx context.Context, repo Repo, rev string) int {
337 out, err := run(ctx, repo, "rev-list", "--count", rev, "--")
338 if err != nil {
339 return 0
340 }
341 n, _ := strconv.Atoi(strings.TrimSpace(string(out)))
342 return n
343}
344
345// TreeEntry is one row of a directory listing.
346type TreeEntry struct {
347 Mode string
348 Type string
349 SHA string
350 Size int64
351 Name string
352 Path string
353}
354
355// IsDir is true for a subdirectory. A gitlink is neither tree nor blob, so this
356// is not the same as "not a blob".
357func (e TreeEntry) IsDir() bool { return e.Type == "tree" }
358
359// IsSubmodule reports a gitlink, which ls-tree types as "commit" inside a tree.
360func (e TreeEntry) IsSubmodule() bool { return e.Type == "commit" }
361
362// IsSymlink reads the mode, since a symlink is a blob whose content is its target.
363func (e TreeEntry) IsSymlink() bool { return e.Mode == "120000" }
364
365// Tree lists one directory, not recursively. --long asks for sizes, which git
366// answers from the object header rather than by inflating each blob.
367func (s *Store) Tree(ctx context.Context, repo Repo, rev, path string) ([]TreeEntry, error) {
368 spec := rev + ":" + path
369 if path == "" {
370 spec = rev + ":"
371 }
372 out, err := run(ctx, repo, "ls-tree", "-z", "--long", spec)
373 if err != nil {
374 return nil, err
375 }
376
377 var entries []TreeEntry
378 for _, rec := range strings.Split(string(out), "\x00") {
379 if rec == "" {
380 continue
381 }
382 // "<mode> SP <type> SP <sha> SP* <size> TAB <name>": the size is space
383 // padded and "-" for a tree, so split on the tab first, Fields second.
384 tab := strings.IndexByte(rec, '\t')
385 if tab < 0 {
386 continue
387 }
388 meta, name := strings.Fields(rec[:tab]), rec[tab+1:]
389 if len(meta) < 4 {
390 continue
391 }
392 size, _ := strconv.ParseInt(meta[3], 10, 64)
393 full := name
394 if path != "" {
395 full = path + "/" + name
396 }
397 entries = append(entries, TreeEntry{
398 Mode: meta[0], Type: meta[1], SHA: meta[2],
399 Size: size, Name: name, Path: full,
400 })
401 }
402
403 // git returns tree order, which is byte order with a trailing slash on trees
404 // and reads as arbitrary to a person.
405 sort.SliceStable(entries, func(i, j int) bool {
406 if entries[i].IsDir() != entries[j].IsDir() {
407 return entries[i].IsDir()
408 }
409 return entries[i].Name < entries[j].Name
410 })
411 return entries, nil
412}
413
414// Blob reads a file at a revision. The size comes from the object header first,
415// so an oversized file costs a header read rather than a buffer.
416func (s *Store) Blob(ctx context.Context, repo Repo, rev, path string) ([]byte, int64, error) {
417 spec := rev + ":" + path
418
419 size, err := s.objectSize(ctx, repo, spec)
420 if err != nil {
421 return nil, 0, err
422 }
423 if size > maxBlobSize {
424 return nil, size, errTooLarge
425 }
426
427 out, err := run(ctx, repo, "cat-file", "blob", spec)
428 if err != nil {
429 return nil, size, err
430 }
431 return out, size, nil
432}
433
434var errTooLarge = fmt.Errorf("blob exceeds %d bytes", maxBlobSize)
435
436func (s *Store) objectSize(ctx context.Context, repo Repo, spec string) (int64, error) {
437 out, err := run(ctx, repo, "cat-file", "-s", spec)
438 if err != nil {
439 return 0, err
440 }
441 return strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
442}
443
444// StreamBlob writes a file's bytes to w without buffering, which is what makes
445// /raw work on a file too large to render.
446func (s *Store) StreamBlob(ctx context.Context, repo Repo, rev, path string, w io.Writer) error {
447 cmd := gitCmd(ctx, repo, "cat-file", "blob", rev+":"+path)
448 cmd.Stdout = w
449 cmd.Stderr = io.Discard
450 return cmd.Run()
451}
452
453// Resolve turns a user supplied revision into a commit SHA, and is the validation
454// step for anything arriving in a URL. The ^{commit} peel means every caller
455// downstream can assume it holds a commit rather than a tag.
456func (s *Store) Resolve(ctx context.Context, repo Repo, rev string) (string, error) {
457 out, err := run(ctx, repo, "rev-parse", "--verify", "--quiet", rev+"^{commit}")
458 if err != nil {
459 return "", fmt.Errorf("no such revision: %s", rev)
460 }
461 sha := strings.TrimSpace(string(out))
462 if sha == "" {
463 return "", fmt.Errorf("no such revision: %s", rev)
464 }
465 return sha, nil
466}
467
468// Size reports the on-disk size in bytes, the number that decides whether a first
469// push fits under cloudflareBodyLimit.
470func (s *Store) Size(ctx context.Context, repo Repo) int64 {
471 out, err := run(ctx, repo, "count-objects", "-v")
472 if err != nil {
473 return 0
474 }
475 var total int64
476 for _, line := range strings.Split(string(out), "\n") {
477 k, v, ok := strings.Cut(line, ": ")
478 if !ok {
479 continue
480 }
481 // size and size-pack are both reported in KiB.
482 if k == "size" || k == "size-pack" {
483 n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
484 total += n * 1024
485 }
486 }
487 return total
488}
489
490// Overview is one repository's listing card. The index renders one per
491// repository and push-to-create means that count only grows, so this read is
492// the one whose cost scales with the site.
493type Overview struct {
494 Size int64
495 Branches int
496 Tags int
497 LastPush time.Time
498 Empty bool
499}
500
501type overviewEntry struct {
502 value Overview
503 at time.Time
504}
505
506// overviewTTL is a backstop. Every path in this process that writes to a
507// repository invalidates it, so the TTL only covers a change made to the volume
508// from outside, which means a repository seeded by hand.
509const overviewTTL = 10 * time.Minute
510
511// overviewFormat is the ref name and the date of the commit under it.
512// committerdate is empty on an annotated tag object, which is why only
513// refs/heads/ feeds LastPush.
514const overviewFormat = "%(refname)%00%(committerdate:iso-strict)"
515
516// Overview answers from cache when it can. A miss costs two subprocesses, and a
517// concurrent miss on one repository may pay that twice rather than hold a lock
518// across a fork.
519func (s *Store) Overview(ctx context.Context, repo Repo) Overview {
520 s.overviewMu.Lock()
521 entry, ok := s.overviews[repo.Name]
522 s.overviewMu.Unlock()
523 if ok && time.Since(entry.at) < overviewTTL {
524 return entry.value
525 }
526
527 fresh := s.readOverview(ctx, repo)
528
529 s.overviewMu.Lock()
530 s.overviews[repo.Name] = overviewEntry{value: fresh, at: time.Now()}
531 s.overviewMu.Unlock()
532 return fresh
533}
534
535// InvalidateOverview drops one repository's card, and is called from every write
536// path here so a push that has landed is never listed as if it had not.
537func (s *Store) InvalidateOverview(name string) {
538 s.overviewMu.Lock()
539 delete(s.overviews, name)
540 s.overviewMu.Unlock()
541}
542
543// readOverview walks both ref namespaces once, because the branch count, the
544// tag count and the newest commit date all come out of the same walk.
545func (s *Store) readOverview(ctx context.Context, repo Repo) Overview {
546 o := Overview{Size: s.Size(ctx, repo)}
547
548 // A card that cannot be read renders empty rather than failing the page,
549 // which is how the listing treats every other per-repository error.
550 out, _ := run(ctx, repo, "for-each-ref",
551 "--format="+overviewFormat, "refs/heads/", "refs/tags/")
552
553 for _, line := range strings.Split(string(out), "\n") {
554 name, date, ok := strings.Cut(line, "\x00")
555 if !ok {
556 continue
557 }
558 switch {
559 case strings.HasPrefix(name, "refs/heads/"):
560 o.Branches++
561 if when := parseISO(date); when.After(o.LastPush) {
562 o.LastPush = when
563 }
564 case strings.HasPrefix(name, "refs/tags/"):
565 o.Tags++
566 }
567 }
568
569 // No refs of either kind is a repository push-to-create made with nothing
570 // landed in it yet. The repository page asks git itself through IsEmpty.
571 o.Empty = o.Branches == 0 && o.Tags == 0
572 return o
573}
574
575// Archive streams a tarball or zip of a revision. The prefix gives it a top level
576// directory, so extracting does not scatter files into the current one.
577func (s *Store) Archive(ctx context.Context, repo Repo, rev, format, prefix string, w io.Writer) error {
578 switch format {
579 case "tar.gz", "zip":
580 default:
581 return fmt.Errorf("unsupported archive format: %s", format)
582 }
583 cmd := gitCmd(ctx, repo, "archive",
584 "--format="+format, "--prefix="+prefix+"/", rev)
585 cmd.Stdout = w
586 cmd.Stderr = io.Discard
587 return cmd.Run()
588}
589
590// InitBare creates a repository and is the whole of push-to-create.
591// receive.autogc=false keeps a repack out of a push, and core.logAllRefUpdates
592// gives a bare repository a reflog so a force push leaves the old tip recoverable.
593func (s *Store) InitBare(ctx context.Context, name string) (Repo, error) {
594 if !validName(name) {
595 return Repo{}, fmt.Errorf("invalid repository name: %q", name)
596 }
597 path := filepath.Join(s.Root, name+".git")
598 if _, err := os.Stat(path); err == nil {
599 return Repo{Name: name, Path: path}, nil
600 }
601
602 ctx, cancel := context.WithTimeout(ctx, gitTimeout)
603 defer cancel()
604
605 cmd := exec.CommandContext(ctx, "git", "init", "--bare", "--initial-branch=main", path)
606 cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+os.TempDir())
607 if out, err := cmd.CombinedOutput(); err != nil {
608 return Repo{}, fmt.Errorf("git init %s: %w: %s", name, err, out)
609 }
610
611 repo := Repo{Name: name, Path: path}
612 for _, kv := range [][2]string{
613 {"receive.autogc", "false"},
614 {"core.logAllRefUpdates", "true"},
615 {"gc.reflogExpire", "never"},
616 {"gc.reflogExpireUnreachable", "never"},
617 // Do not set http.receivepack. An explicit false denies the
618 // authenticated smart push too, so push-to-create would create the
619 // repository and then reject the push that made it with a 403.
620 } {
621 if _, err := run(ctx, repo, "config", kv[0], kv[1]); err != nil {
622 return Repo{}, err
623 }
624 }
625 return repo, nil
626}
627
628// GC repacks one repository. Called from a ticker, never from a request.
629func (s *Store) GC(ctx context.Context, repo Repo) error {
630 ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
631 defer cancel()
632
633 cmd := gitCmd(ctx, repo, "gc", "--auto", "--quiet")
634 var stderr bytes.Buffer
635 cmd.Stderr = &stderr
636 if err := cmd.Run(); err != nil {
637 return fmt.Errorf("gc %s: %w: %s", repo.Name, err, strings.TrimSpace(stderr.String()))
638 }
639 // Repacking is the other thing that moves a card, since it moves the size.
640 s.InvalidateOverview(repo.Name)
641 return nil
642}
643
644// parseISO reads git's iso-strict form, returning a zero time on failure so a
645// malformed date on one ref does not fail a page.
646func parseISO(s string) time.Time {
647 s = strings.TrimSpace(s)
648 if s == "" {
649 return time.Time{}
650 }
651 t, err := time.Parse(time.RFC3339, s)
652 if err != nil {
653 return time.Time{}
654 }
655 return t
656}
657
658// catFile is a long lived `git cat-file --batch` per repository, so a listing does
659// not fork once per object. Access is serialised because the protocol is a
660// conversation, and two callers on one pipe would read each other's bytes.
661type catFile struct {
662 mu sync.Mutex
663 cmd *exec.Cmd
664 stdin io.WriteCloser
665 stdout *bufio.Reader
666}
667
668func newCatFile(repo Repo) (*catFile, error) {
669 // Not CommandContext: this process outlives any one request and is closed by
670 // Store.Close at shutdown.
671 cmd := exec.Command("git", "-c", "core.quotePath=false",
672 "--git-dir", repo.Path, "cat-file", "--batch")
673 cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+os.TempDir())
674
675 stdin, err := cmd.StdinPipe()
676 if err != nil {
677 return nil, err
678 }
679 stdout, err := cmd.StdoutPipe()
680 if err != nil {
681 return nil, err
682 }
683 if err := cmd.Start(); err != nil {
684 return nil, err
685 }
686 return &catFile{cmd: cmd, stdin: stdin, stdout: bufio.NewReaderSize(stdout, 64<<10)}, nil
687}
688
689// object reads one object by revision. The reply is "<sha> <type> <size>" then
690// exactly size bytes and a newline, or "<spec> missing".
691func (c *catFile) object(spec string) (typ string, data []byte, err error) {
692 c.mu.Lock()
693 defer c.mu.Unlock()
694
695 if _, err := io.WriteString(c.stdin, spec+"\n"); err != nil {
696 return "", nil, err
697 }
698
699 header, err := c.stdout.ReadString('\n')
700 if err != nil {
701 return "", nil, err
702 }
703 header = strings.TrimSuffix(header, "\n")
704
705 parts := strings.Fields(header)
706 if len(parts) < 3 {
707 // "missing" or "ambiguous": what a request for an absent path looks like.
708 return "", nil, fmt.Errorf("cat-file: %s", header)
709 }
710 size, err := strconv.ParseInt(parts[2], 10, 64)
711 if err != nil {
712 return "", nil, fmt.Errorf("cat-file: bad size in %q", header)
713 }
714 if size > maxBlobSize {
715 // The payload still has to be drained or the stream desynchronises
716 // and every later read on this pipe returns another object's bytes.
717 if _, err := io.CopyN(io.Discard, c.stdout, size+1); err != nil {
718 return "", nil, err
719 }
720 return parts[1], nil, errTooLarge
721 }
722
723 buf := make([]byte, size)
724 if _, err := io.ReadFull(c.stdout, buf); err != nil {
725 return "", nil, err
726 }
727 // The trailing newline git writes after every payload.
728 if _, err := c.stdout.Discard(1); err != nil {
729 return "", nil, err
730 }
731 return parts[1], buf, nil
732}
733
734func (c *catFile) close() {
735 c.mu.Lock()
736 defer c.mu.Unlock()
737 _ = c.stdin.Close()
738 _ = c.cmd.Wait()
739}
740
741// batch returns the reader for a repository, starting one on first use.
742func (s *Store) batch(repo Repo) (*catFile, error) {
743 s.mu.Lock()
744 defer s.mu.Unlock()
745
746 if c, ok := s.batches[repo.Name]; ok {
747 return c, nil
748 }
749 c, err := newCatFile(repo)
750 if err != nil {
751 return nil, err
752 }
753 s.batches[repo.Name] = c
754 return c, nil
755}
756
757// Object reads one object through the long lived reader.
758func (s *Store) Object(repo Repo, spec string) (string, []byte, error) {
759 c, err := s.batch(repo)
760 if err != nil {
761 return "", nil, err
762 }
763 return c.object(spec)
764}
765
766// Close stops every cat-file reader, so a restart leaves no git process per
767// repository behind.
768func (s *Store) Close() {
769 s.mu.Lock()
770 defer s.mu.Unlock()
771 for name, c := range s.batches {
772 c.close()
773 delete(s.batches, name)
774 }
775}