A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 00fb4320ce

00fb4320ce2299ae2a089515b0ac85b2965a0cda

parent: 01ba0f0bd8

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T20:44:19Z

Implement admin gc, admin stats, weekly maintenance timer

Closes #27

admin gc runs git gc (optionally --aggressive) across all repositories
or one via --repo, reporting per-repo and total disk before/after.
admin stats reports instance counts (users/orgs/repos/issues/MRs with
open splits), database size, and per-repository disk usage, with
--json. cloud-init gains a gitbay-gc.timer running admin gc weekly,
enabled alongside the backup timer.
cmd/gitbayd/main.go +2 −11
@@ -242,15 +242,6 @@ func adminCmd() *cobra.Command {
242242 Use: "admin",
243243 Short: "host-local administration",
244244 }
245 notImplemented := func(use, short string) *cobra.Command {
246 return &cobra.Command{
247 Use: use,
248 Short: short,
249 RunE: func(cmd *cobra.Command, args []string) error {
250 return fmt.Errorf("not implemented")
251 },
252 }
253 }
254245 userCmd := &cobra.Command{Use: "user", Short: "manage users"}
255246 userCmd.AddCommand(adminUserCreateCmd())
256247 emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"}
@@ -260,8 +251,8 @@ func adminCmd() *cobra.Command {
260251 emailCmd,
261252 adminInviteCmd(),
262253 backupCmd(),
263 notImplemented("gc", "run git gc across repositories"),
264 notImplemented("stats", "instance statistics"),
254 gcCmd(),
255 statsCmd(),
265256 )
266257 return admin
267258 }
cmd/gitbayd/maint.go added +159
@@ -0,0 +1,159 @@
1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 "io/fs"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "text/tabwriter"
11
12 "github.com/spf13/cobra"
13
14 "gitbay.org/gitbay/internal/config"
15 "gitbay.org/gitbay/internal/control"
16 "gitbay.org/gitbay/internal/store"
17)
18
19func gcCmd() *cobra.Command {
20 var repoPath string
21 var aggressive bool
22 cmd := &cobra.Command{
23 Use: "gc",
24 Short: "repack and prune repositories (git gc)",
25 RunE: func(cmd *cobra.Command, args []string) error {
26 cfg, err := config.Load(configPath)
27 if err != nil {
28 return err
29 }
30 st, err := openStore(cfg)
31 if err != nil {
32 return err
33 }
34 defer st.Close()
35
36 var repos []store.Repo
37 if repoPath != "" {
38 r, err := st.RepoByPath(repoPath)
39 if err != nil {
40 return fmt.Errorf("no repository %q", repoPath)
41 }
42 repos = []store.Repo{r}
43 } else if repos, err = st.ListAllRepos(); err != nil {
44 return err
45 }
46
47 var before, after int64
48 for _, r := range repos {
49 dir := control.RepoDir(cfg.Server.Root, r.OwnerName, r.Name)
50 b := duDir(dir)
51 gcArgs := []string{"-C", dir, "gc", "--quiet"}
52 if aggressive {
53 gcArgs = append(gcArgs, "--aggressive")
54 }
55 if out, err := exec.Command("git", gcArgs...).CombinedOutput(); err != nil {
56 fmt.Fprintf(os.Stderr, "%s: gc failed: %v\n%s", r.Path(), err, out)
57 continue
58 }
59 a := duDir(dir)
60 before, after = before+b, after+a
61 fmt.Printf("%s\t%s -> %s\n", r.Path(), human(b), human(a))
62 }
63 fmt.Printf("total\t%s -> %s (freed %s)\n", human(before), human(after), human(before-after))
64 return nil
65 },
66 }
67 cmd.Flags().StringVar(&repoPath, "repo", "", "one repository (owner/name) instead of all")
68 cmd.Flags().BoolVar(&aggressive, "aggressive", false, "more thorough repack (slow; rarely needed)")
69 return cmd
70}
71
72func statsCmd() *cobra.Command {
73 var asJSON bool
74 cmd := &cobra.Command{
75 Use: "stats",
76 Short: "instance statistics: counts and per-repository disk usage",
77 RunE: func(cmd *cobra.Command, args []string) error {
78 cfg, err := config.Load(configPath)
79 if err != nil {
80 return err
81 }
82 st, err := openStore(cfg)
83 if err != nil {
84 return err
85 }
86 defer st.Close()
87
88 counts, err := st.InstanceCounts()
89 if err != nil {
90 return err
91 }
92 repos, err := st.ListAllRepos()
93 if err != nil {
94 return err
95 }
96 type repoDisk struct {
97 Path string `json:"path"`
98 Bytes int64 `json:"bytes"`
99 }
100 var disks []repoDisk
101 var totalDisk int64
102 for _, r := range repos {
103 b := duDir(control.RepoDir(cfg.Server.Root, r.OwnerName, r.Name))
104 disks = append(disks, repoDisk{r.Path(), b})
105 totalDisk += b
106 }
107 var dbBytes int64
108 if fi, err := os.Stat(cfg.Server.Root + "/gitbay.db"); err == nil {
109 dbBytes = fi.Size()
110 }
111
112 if asJSON {
113 return json.NewEncoder(os.Stdout).Encode(map[string]any{
114 "counts": counts, "db_bytes": dbBytes,
115 "repo_bytes": totalDisk, "repos": disks,
116 })
117 }
118 fmt.Printf("users %d · orgs %d · repos %d · issues %d (%d open) · MRs %d (%d open)\n",
119 counts.Users, counts.Orgs, counts.Repos,
120 counts.Issues, counts.OpenIssues, counts.MRs, counts.OpenMRs)
121 fmt.Printf("database %s · repositories %s\n\n", human(dbBytes), human(totalDisk))
122 w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
123 for _, d := range disks {
124 fmt.Fprintf(w, "%s\t%s\n", d.Path, human(d.Bytes))
125 }
126 return w.Flush()
127 },
128 }
129 cmd.Flags().BoolVar(&asJSON, "json", false, "machine-readable output")
130 return cmd
131}
132
133// duDir sums file sizes under dir; errors count as zero.
134func duDir(dir string) int64 {
135 var total int64
136 filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
137 if err != nil || d.IsDir() {
138 return nil
139 }
140 if fi, err := d.Info(); err == nil {
141 total += fi.Size()
142 }
143 return nil
144 })
145 return total
146}
147
148func human(b int64) string {
149 switch {
150 case b >= 1<<30:
151 return fmt.Sprintf("%.1f GiB", float64(b)/(1<<30))
152 case b >= 1<<20:
153 return fmt.Sprintf("%.1f MiB", float64(b)/(1<<20))
154 case b >= 1<<10:
155 return fmt.Sprintf("%.1f KiB", float64(b)/(1<<10))
156 default:
157 return fmt.Sprintf("%d B", b)
158 }
159}
deploy/cloud-init.yaml +22 −2
@@ -115,6 +115,26 @@ write_files:
115115 [Install]
116116 WantedBy=timers.target
117117
118 - path: /etc/systemd/system/gitbay-gc.service
119 content: |
120 [Unit]
121 Description=gitbay weekly repository maintenance
122 [Service]
123 Type=oneshot
124 User=gitbay
125 ExecStart=/usr/local/bin/gitbayd --config /etc/gitbay/config.toml admin gc
126
127 - path: /etc/systemd/system/gitbay-gc.timer
128 content: |
129 [Unit]
130 Description=gitbay weekly repository maintenance
131 [Timer]
132 OnCalendar=Sun *-*-* 07:00:00 UTC
133 RandomizedDelaySec=30m
134 Persistent=true
135 [Install]
136 WantedBy=timers.target
137
118138 runcmd:
119139 - adduser --system --group --home /var/lib/gitbay --shell /usr/sbin/nologin gitbay
120140 - install -d -o gitbay -g gitbay -m 750 /var/lib/gitbay /var/backups/gitbay
@@ -126,5 +146,5 @@ runcmd:
126146 - ufw --force enable
127147 - systemctl daemon-reload
128148 - systemctl restart ssh.socket || systemctl restart ssh
129 - systemctl enable gitbayd gitbay-backup.timer
130 - systemctl start gitbay-backup.timer
149 - systemctl enable gitbayd gitbay-backup.timer gitbay-gc.timer
150 - systemctl start gitbay-backup.timer gitbay-gc.timer
docs/admin.org +13
@@ -121,6 +121,19 @@ gitbayd admin invite --email b@example.org # mails a code; prints it if no
121121 records which. Verified emails are what make commit signatures
122122 meaningful — an unverified address never produces a =verified= badge.
123123
124* Maintenance
125
126#+begin_src sh
127gitbayd admin stats [--json] # counts, database size, per-repo disk
128gitbayd admin gc [--repo owner/name] # git gc: repack and prune; per-repo sizes
129gitbayd admin gc --aggressive # thorough repack; slow, rarely needed
130#+end_src
131
132=deploy/cloud-init.yaml= ships a =gitbay-gc.timer= that runs =admin gc=
133weekly (Sunday 07:00 UTC). Imported repositories keep whatever pack
134layout the source sent, so a first manual =admin gc= after a bulk
135import is worthwhile.
136
124137 * Backup and restore
125138
126139 #+begin_src sh
e2e/maint_test.go added +67
@@ -0,0 +1,67 @@
1package e2e
2
3import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11func TestAdminGCAndStats(t *testing.T) {
12 inst := startInstance(t)
13 aliceKey := inst.newKey(t, "alice")
14 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
15
16 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
17 t.Fatalf("repo create: %s", errOut)
18 }
19 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/other"); code != 0 {
20 t.Fatal("second repo create failed")
21 }
22 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'x'"); code != 0 {
23 t.Fatal("issue create failed")
24 }
25
26 // Push several commits so the repo has loose objects worth packing.
27 work := t.TempDir()
28 env := inst.gitEnv(aliceKey)
29 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
30 dir := filepath.Join(work, "w")
31 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
32 for i := 0; i < 10; i++ {
33 os.WriteFile(filepath.Join(dir, "f.txt"), []byte(strings.Repeat(fmt.Sprint(i), 2000)+"\n"), 0o644)
34 mustGit(t, dir, env, "add", ".")
35 mustGit(t, dir, env, "commit", "-q", "-m", fmt.Sprintf("c%d", i))
36 }
37 mustGit(t, dir, env, "push", "-q", "origin", "main")
38
39 // Stats: counts and per-repo disk usage.
40 out := inst.admin(t, "admin", "stats")
41 if !strings.Contains(out, "repos 2") || !strings.Contains(out, "issues 1 (1 open)") ||
42 !strings.Contains(out, "alice/app") || !strings.Contains(out, "alice/other") {
43 t.Fatalf("stats output: %s", out)
44 }
45 if out = inst.admin(t, "admin", "stats", "--json"); !strings.Contains(out, `"repos":2`) ||
46 !strings.Contains(out, `"path":"alice/app"`) {
47 t.Fatalf("stats json: %s", out)
48 }
49
50 // GC one repo, then all; the repo must survive (clone still works).
51 out = inst.admin(t, "admin", "gc", "--repo", "alice/app")
52 if !strings.Contains(out, "alice/app") || !strings.Contains(out, "total") ||
53 strings.Contains(out, "alice/other") {
54 t.Fatalf("scoped gc: %s", out)
55 }
56 if out = inst.admin(t, "admin", "gc"); !strings.Contains(out, "alice/other") {
57 t.Fatalf("full gc: %s", out)
58 }
59 if out = inst.forgedAdminErr(t, "admin", "gc", "--repo", "alice/nope"); !strings.Contains(out, "no repository") {
60 t.Fatalf("bogus repo: %s", out)
61 }
62 work2 := t.TempDir()
63 mustGit(t, work2, env, "clone", "-q", inst.sshURL("alice/app"), "r")
64 if data, err := os.ReadFile(filepath.Join(work2, "r", "f.txt")); err != nil || len(data) == 0 {
65 t.Fatal("repo damaged by gc")
66 }
67}
internal/store/stats.go added +50
@@ -0,0 +1,50 @@
1package store
2
3// ListAllRepos returns every repository, for host-local admin tooling.
4func (s *Store) ListAllRepos() ([]Repo, error) {
5 rows, err := s.DB.Query(repoSelect + " ORDER BY 4, r.name")
6 if err != nil {
7 return nil, err
8 }
9 defer rows.Close()
10 var out []Repo
11 for rows.Next() {
12 r, err := scanRepo(rows)
13 if err != nil {
14 return nil, err
15 }
16 out = append(out, r)
17 }
18 return out, rows.Err()
19}
20
21type Counts struct {
22 Users int64 `json:"users"`
23 Orgs int64 `json:"orgs"`
24 Repos int64 `json:"repos"`
25 Issues int64 `json:"issues"`
26 OpenIssues int64 `json:"open_issues"`
27 MRs int64 `json:"mrs"`
28 OpenMRs int64 `json:"open_mrs"`
29}
30
31func (s *Store) InstanceCounts() (Counts, error) {
32 var c Counts
33 for _, q := range []struct {
34 dst *int64
35 query string
36 }{
37 {&c.Users, "SELECT COUNT(*) FROM users"},
38 {&c.Orgs, "SELECT COUNT(*) FROM orgs"},
39 {&c.Repos, "SELECT COUNT(*) FROM repos"},
40 {&c.Issues, "SELECT COUNT(*) FROM issues"},
41 {&c.OpenIssues, "SELECT COUNT(*) FROM issues WHERE state = 'open'"},
42 {&c.MRs, "SELECT COUNT(*) FROM merge_requests"},
43 {&c.OpenMRs, "SELECT COUNT(*) FROM merge_requests WHERE state IN ('open','source_gone')"},
44 } {
45 if err := s.DB.QueryRow(q.query).Scan(q.dst); err != nil {
46 return c, err
47 }
48 }
49 return c, nil
50}