lfs: count it in stats, and gc --lfs for orphaned objects !163

merged merged by cmc on 2026-09-02 03:30 UTC · krz/gitbay:stack-5-lfs into main

6 files changed, +236 −10

Layout: unified · split

cmd/gitbayd/maint.go +48 −3
@@ -4,21 +4,23 @@ import (
44 "fmt"
55 "os"
66 "os/exec"
7 "time"
78
89 "github.com/spf13/cobra"
910
1011 "gitbay.org/gitbay/internal/config"
1112 "gitbay.org/gitbay/internal/control"
1213 "gitbay.org/gitbay/internal/gitutil"
14 "gitbay.org/gitbay/internal/lfs"
1315 "gitbay.org/gitbay/internal/store"
1416)
1517
1618func gcCmd() *cobra.Command {
1719 var repoPath string
18 var aggressive bool
20 var aggressive, withLFS bool
1921 cmd := &cobra.Command{
2022 Use: "gc",
21 Short: "repack and prune repositories (git gc)",
23 Short: "repack and prune repositories (git gc); --lfs removes unreferenced LFS objects",
2224 RunE: func(cmd *cobra.Command, args []string) error {
2325 cfg, err := config.Load(configPath)
2426 if err != nil {
@@ -58,11 +60,15 @@ func gcCmd() *cobra.Command {
5860 fmt.Printf("%s\t%s -> %s\n", r.Path(), human(b), human(a))
5961 }
6062 fmt.Printf("total\t%s -> %s (freed %s)\n", human(before), human(after), human(before-after))
61 return nil
63 if !withLFS {
64 return nil
65 }
66 return gcLFS(cfg, st)
6267 },
6368 }
6469 cmd.Flags().StringVar(&repoPath, "repo", "", "one repository (owner/name) instead of all")
6570 cmd.Flags().BoolVar(&aggressive, "aggressive", false, "more thorough repack (slow; rarely needed)")
71 cmd.Flags().BoolVar(&withLFS, "lfs", false, "also remove LFS objects no repository references (older than a day)")
6672 return cmd
6773}
6874
@@ -78,3 +84,42 @@ func human(b int64) string {
7884 return fmt.Sprintf("%d B", b)
7985 }
8086}
87
88// lfsOrphanAge keeps an object uploaded ahead of the push that references
89// it: git lfs uploads first and pushes second.
90const lfsOrphanAge = 24 * time.Hour
91
92// gcLFS removes LFS objects no repository's pointers name. Every
93// repository is scanned, whatever --repo said: an object is shared across
94// repositories by content, so only the whole set says it is unreferenced.
95func gcLFS(cfg config.Config, st *store.Store) error {
96 repos, err := st.ListAllRepos()
97 if err != nil {
98 return err
99 }
100 referenced := map[string]bool{}
101 for _, r := range repos {
102 oids, err := gitutil.LFSPointerOIDs(control.RepoDir(cfg.Server.Root, r.OwnerName, r.Name))
103 if err != nil {
104 return fmt.Errorf("%s: scanning for LFS pointers: %w", r.Path(), err)
105 }
106 for _, o := range oids {
107 referenced[o] = true
108 }
109 }
110 blobs := lfs.LocalStore{Root: lfs.RootFor(cfg.LFS.Root, cfg.Server.Root)}
111 orphans, err := blobs.Orphans(referenced, lfsOrphanAge)
112 if err != nil {
113 return err
114 }
115 var freed int64
116 for _, o := range orphans {
117 if err := blobs.Delete(o.OID); err != nil {
118 fmt.Fprintf(os.Stderr, "lfs %s: %v\n", o.OID, err)
119 continue
120 }
121 freed += o.Size
122 }
123 fmt.Printf("lfs\t%d referenced, removed %d orphans (%s)\n", len(referenced), len(orphans), human(freed))
124 return nil
125}
e2e/reap_test.go +52
@@ -1,6 +1,8 @@
11package e2e
22
33import (
4 "crypto/sha256"
5 "encoding/hex"
46 "encoding/json"
57 "fmt"
68 "os"
@@ -152,3 +154,53 @@ func TestHealthz(t *testing.T) {
152154 t.Fatalf("healthz registered as a username: %s", errOut)
153155 }
154156}
157
158// gc --lfs removes objects no pointer names, keeps referenced ones, and
159// leaves anything young enough to be an upload ahead of its push.
160func TestGCLFSOrphans(t *testing.T) {
161 inst := startInstance(t)
162 aliceKey := inst.newKey(t, "alice")
163 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
164 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/big"); code != 0 {
165 t.Fatal("repo create failed")
166 }
167 put := func(content string, age time.Duration) string {
168 t.Helper()
169 sum := sha256.Sum256([]byte(content))
170 oid := hex.EncodeToString(sum[:])
171 path := filepath.Join(inst.root, "lfs", oid[:2], oid[2:4], oid)
172 os.MkdirAll(filepath.Dir(path), 0o755)
173 os.WriteFile(path, []byte(content), 0o644)
174 os.Chtimes(path, time.Now().Add(-age), time.Now().Add(-age))
175 return oid
176 }
177 kept := put("referenced payload", 48*time.Hour)
178 orphan := put("nobody points here", 48*time.Hour)
179 young := put("just uploaded", time.Minute)
180
181 work := t.TempDir()
182 env := inst.gitEnv(aliceKey)
183 mustGit(t, work, env, "clone", inst.sshURL("alice/big"), "w")
184 dir := filepath.Join(work, "w")
185 pointer := fmt.Sprintf("version https://git-lfs.github.com/spec/v1\noid sha256:%s\nsize %d\n", kept, len("referenced payload"))
186 os.WriteFile(filepath.Join(dir, "data.bin"), []byte(pointer), 0o644)
187 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
188 mustGit(t, dir, env, "add", ".")
189 mustGit(t, dir, env, "commit", "-q", "-m", "pointer")
190 mustGit(t, dir, env, "push", "-q", "origin", "main")
191
192 if out := inst.admin(t, "admin", "stats", "--json"); !strings.Contains(out, `"lfs_bytes":`) || strings.Contains(out, `"lfs_bytes":0`) {
193 t.Fatalf("stats lfs bytes:\n%s", out)
194 }
195 out := inst.admin(t, "admin", "gc", "--lfs")
196 if !strings.Contains(out, "lfs\t1 referenced, removed 1 orphans") {
197 t.Fatalf("gc --lfs:\n%s", out)
198 }
199 exists := func(oid string) bool {
200 _, err := os.Stat(filepath.Join(inst.root, "lfs", oid[:2], oid[2:4], oid))
201 return err == nil
202 }
203 if !exists(kept) || exists(orphan) || !exists(young) {
204 t.Fatalf("after gc: kept=%v orphan=%v young=%v", exists(kept), exists(orphan), exists(young))
205 }
206}
internal/control/adminhost.go +4 −1
@@ -9,6 +9,7 @@ import (
99 "golang.org/x/crypto/ssh"
1010
1111 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/lfs"
1213 "gitbay.org/gitbay/internal/mail"
1314 "gitbay.org/gitbay/internal/policy"
1415 "gitbay.org/gitbay/internal/protocol"
@@ -301,9 +302,11 @@ func runAdminStats(c *Ctx, args []string) int {
301302 Counts store.Counts `json:"counts"`
302303 DBBytes int64 `json:"db_bytes"`
303304 RepoBytes int64 `json:"repo_bytes"`
305 LFSBytes int64 `json:"lfs_bytes"`
304306 Repos []repoDisk `json:"repos"`
305307 }
306308 d := out{Counts: counts, Repos: []repoDisk{}}
309 d.LFSBytes = lfs.LocalStore{Root: lfs.RootFor(c.Cfg.LFS.Root, c.Cfg.Server.Root)}.Size()
307310 for _, r := range repos {
308311 b := gitutil.DirSize(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
309312 d.Repos = append(d.Repos, repoDisk{r.Path(), b})
@@ -316,7 +319,7 @@ func runAdminStats(c *Ctx, args []string) int {
316319 fmt.Fprintf(w, "users %d · orgs %d · repos %d · issues %d (%d open) · MRs %d (%d open)\n",
317320 counts.Users, counts.Orgs, counts.Repos,
318321 counts.Issues, counts.OpenIssues, counts.MRs, counts.OpenMRs)
319 fmt.Fprintf(w, "database %s · repositories %s\n\n", humanBytes(d.DBBytes), humanBytes(d.RepoBytes))
322 fmt.Fprintf(w, "database %s · repositories %s · lfs %s\n\n", humanBytes(d.DBBytes), humanBytes(d.RepoBytes), humanBytes(d.LFSBytes))
320323 for _, r := range d.Repos {
321324 fmt.Fprintf(w, "%s\t%s\n", r.Path, humanBytes(r.Bytes))
322325 }
internal/gitutil/lfs.go added +77
@@ -0,0 +1,77 @@
1package gitutil
2
3import (
4 "bufio"
5 "bytes"
6 "os/exec"
7 "regexp"
8 "strconv"
9 "strings"
10)
11
12// lfsPointerMax bounds a pointer file; real ones are around 130 bytes.
13const lfsPointerMax = 1024
14
15var lfsOIDLine = regexp.MustCompile(`(?m)^oid sha256:([0-9a-f]{64})$`)
16
17// LFSPointerOIDs returns every LFS object id referenced by a pointer blob
18// anywhere in the repository: every object, not just the reachable ones,
19// since an unreachable blob is still an object gc has not removed.
20func LFSPointerOIDs(dir string) ([]string, error) {
21 list := exec.Command("git", "-C", dir, "cat-file", "--batch-all-objects",
22 "--batch-check=%(objecttype) %(objectsize) %(objectname)")
23 out, err := list.Output()
24 if err != nil {
25 return nil, err
26 }
27 var small []string
28 sc := bufio.NewScanner(bytes.NewReader(out))
29 for sc.Scan() {
30 f := strings.Fields(sc.Text())
31 if len(f) != 3 || f[0] != "blob" {
32 continue
33 }
34 if n, err := strconv.Atoi(f[1]); err == nil && n <= lfsPointerMax {
35 small = append(small, f[2])
36 }
37 }
38 if len(small) == 0 {
39 return nil, nil
40 }
41 cat := exec.Command("git", "-C", dir, "cat-file", "--batch")
42 cat.Stdin = strings.NewReader(strings.Join(small, "\n") + "\n")
43 out, err = cat.Output()
44 if err != nil {
45 return nil, err
46 }
47 // --batch output: "<sha> blob <size>\n<content>\n" per object.
48 var oids []string
49 rest := out
50 for len(rest) > 0 {
51 nl := bytes.IndexByte(rest, '\n')
52 if nl < 0 {
53 break
54 }
55 hdr := strings.Fields(string(rest[:nl]))
56 rest = rest[nl+1:]
57 if len(hdr) != 3 {
58 break
59 }
60 size, _ := strconv.Atoi(hdr[2])
61 if size > len(rest) {
62 break
63 }
64 body := rest[:size]
65 rest = rest[size:]
66 if len(rest) > 0 && rest[0] == '\n' {
67 rest = rest[1:]
68 }
69 if !bytes.HasPrefix(body, []byte("version https://git-lfs.github.com/spec/")) {
70 continue
71 }
72 if m := lfsOIDLine.FindSubmatch(body); m != nil {
73 oids = append(oids, string(m[1]))
74 }
75 }
76 return oids, nil
77}
internal/httpd/lfs.go +1 −6
@@ -5,7 +5,6 @@ import (
55 "fmt"
66 "io"
77 "net/http"
8 "path/filepath"
98 "strings"
109 "time"
1110
@@ -21,11 +20,7 @@ import (
2120const lfsMediaType = "application/vnd.git-lfs+json"
2221
2322func (s *Server) lfsStore() lfs.BlobStore {
24 root := s.cfg.LFS.Root
25 if root == "" {
26 root = filepath.Join(s.cfg.Server.Root, "lfs")
27 }
28 return lfs.LocalStore{Root: root}
23 return lfs.LocalStore{Root: lfs.RootFor(s.cfg.LFS.Root, s.cfg.Server.Root)}
2924}
3025
3126func (s *Server) lfsMaxObject() int64 {
internal/lfs/lfs.go +54
@@ -20,6 +20,7 @@ import (
2020 "encoding/hex"
2121 "fmt"
2222 "io"
23 "io/fs"
2324 "os"
2425 "path/filepath"
2526 "regexp"
@@ -173,3 +174,56 @@ func NewSecret() string {
173174 rand.Read(buf)
174175 return hex.EncodeToString(buf)
175176}
177
178// Orphans lists objects in the store that no repository references and
179// that are older than minAge: an object uploaded ahead of the push that
180// will reference it is not an orphan yet. referenced holds the object ids
181// every repository's pointers name.
182func (s LocalStore) Orphans(referenced map[string]bool, minAge time.Duration) ([]Orphan, error) {
183 cutoff := time.Now().Add(-minAge)
184 var out []Orphan
185 err := filepath.WalkDir(s.Root, func(path string, d fs.DirEntry, err error) error {
186 if err != nil || d.IsDir() {
187 return nil
188 }
189 oid := d.Name()
190 if !OIDPat.MatchString(oid) || referenced[oid] {
191 return nil
192 }
193 info, err := d.Info()
194 if err != nil || info.ModTime().After(cutoff) {
195 return nil
196 }
197 out = append(out, Orphan{OID: oid, Size: info.Size()})
198 return nil
199 })
200 return out, err
201}
202
203// Orphan is one unreferenced object.
204type Orphan struct {
205 OID string
206 Size int64
207}
208
209// Size sums every object in the store.
210func (s LocalStore) Size() int64 {
211 var total int64
212 filepath.WalkDir(s.Root, func(_ string, d fs.DirEntry, err error) error {
213 if err == nil && !d.IsDir() {
214 if fi, err := d.Info(); err == nil {
215 total += fi.Size()
216 }
217 }
218 return nil
219 })
220 return total
221}
222
223// RootFor is the store root a configuration implies.
224func RootFor(lfsRoot, serverRoot string) string {
225 if lfsRoot != "" {
226 return lfsRoot
227 }
228 return filepath.Join(serverRoot, "lfs")
229}