Commit d201d6acb1

d201d6acb1a350c3ef7f5a2a66df1aa0f7ac6ea8

parent: 55cbdaf81e

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 22:25 UTC

backup: archive each repository's refs before its objects

Ref #259

Layout: unified · split

.gitbay/wiki/Architecture/08-Operations.org +4 −2
@@ -52,8 +52,10 @@ the product activity feed, not an audit trail.
5252| Database only | hourly | 48 | SQLite snapshot; age-encrypted when =[backup] age_recipients= is set |
5353| Offsite (restic)| nightly | per prune policy | =/var/lib/gitbay= and a staged database copy, to object storage |
5454
55- The database snapshot is taken before repositories are read, so a
56 push during the backup leaves only unreferenced objects
55- The database snapshot is taken before repositories are read, and
56 each repository's HEAD, packed-refs and refs/ are archived before its
57 objects, so every archived ref finds the objects it reaches. A push
58 during the backup is missing or present as unreferenced objects
5759 (=cmd/gitbayd/backup.go=).
5860- Excluded: WAL files, the hook socket, askpass scripts, generated
5961 hooks.
.gitbay/wiki/Threat-Model.org +6 −3
@@ -262,9 +262,12 @@ assume has been checked.
262262 pixel against a viewer. A profile is the wider surface of the two: it
263263 is linked from every commit and issue its owner touches. Documented;
264264 proxying is future work.
265- Backups are consistent per the DB-snapshot-first ordering, and
266 repository deletes and moves wait out a full backup; a push during
267 one leaves only unreferenced objects (see [[Admin]]).
265- Backups snapshot the database first, and repository deletes and
266 moves wait out a full backup. Each repository's refs are archived
267 before its objects, so every archived ref finds the objects it
268 reaches. A push during a backup may be missing from the archive, or
269 present as objects no archived ref names, and a repository's refs may
270 be newer than the database snapshot (see [[Admin]]).
268271- The audit log lives in the database the daemon writes, so anyone with
269272 the daemon user's access can change it. The hash chain makes an edited
270273 or removed row show as a break under =gitbayd admin audit verify=,
cmd/gitbayd/backup.go +73 −8
@@ -4,6 +4,7 @@ import (
44 "archive/tar"
55 "bufio"
66 "compress/gzip"
7 "errors"
78 "fmt"
89 "io"
910 "io/fs"
@@ -25,10 +26,11 @@ import (
2526// every repository and the SSH host keys. Restore by extracting the archive
2627// into a fresh server.root.
2728//
28// Ordering: the database is snapshotted BEFORE the repositories are read.
29// A push that lands mid-backup then shows up only as unreferenced git
30// objects in the archive (harmless); the reverse order could leave database
31// rows pointing at objects the archive never captured.
29// Ordering: the database is snapshotted BEFORE the repositories are read,
30// and each repository's refs before its objects. A push that lands
31// mid-backup then shows up only as unreferenced git objects in the archive
32// (harmless) or not at all; the reverse order could leave database rows or
33// refs pointing at objects the archive never captured.
3234func backupCmd() *cobra.Command {
3335 var out, verify, identity string
3436 var dbOnly bool
@@ -180,15 +182,29 @@ func runBackup(cfg config.Config, out string, dbOnly bool) error {
180182 if !d.Type().IsRegular() && !d.IsDir() {
181183 return nil // sockets, symlinks
182184 }
183 if d.IsDir() {
184 if strings.HasSuffix(rel, ".git") {
185 repoCount++
185 // A repository's refs were archived on entering it.
186 if strings.HasSuffix(filepath.Dir(rel), ".git") && refNames[d.Name()] {
187 if d.IsDir() {
188 return filepath.SkipDir
186189 }
190 return nil
191 }
192 if d.IsDir() {
187193 // A directory entry, even for one that holds no file (a
188194 // bare repository's refs/heads and refs/tags once every
189195 // ref is packed), so extraction recreates it: git's own
190196 // repository discovery needs refs/ to exist.
191 return addDir(tw, path, filepath.ToSlash(rel))
197 if err := addDir(tw, path, filepath.ToSlash(rel)); err != nil {
198 return err
199 }
200 if strings.HasSuffix(rel, ".git") {
201 repoCount++
202 if err := addRefs(tw, path, filepath.ToSlash(rel)); err != nil {
203 return err
204 }
205 afterRefs(path)
206 }
207 return nil
192208 }
193209 return addFile(tw, path, filepath.ToSlash(rel))
194210 })
@@ -230,6 +246,55 @@ func runBackup(cfg config.Config, out string, dbOnly bool) error {
230246 return nil
231247}
232248
249// refNames are what a repository's refs are read from. WalkDir would
250// reach objects/ before packed-refs and refs/, so a push landing mid-walk
251// could leave an archived ref naming objects the archive lacks. addRefs
252// archives these first on entering the repository; objects are only ever
253// added, so the walk that follows finds every object those refs reach.
254var refNames = map[string]bool{"HEAD": true, "packed-refs": true, "refs": true}
255
256// afterRefs runs between a repository's refs and the rest of it. Tests
257// use it to write into the repository at that point.
258var afterRefs = func(repo string) {}
259
260// addRefs archives HEAD, packed-refs and refs/ of the repository at
261// path, whichever exist.
262func addRefs(tw *tar.Writer, path, name string) error {
263 for _, f := range []string{"HEAD", "packed-refs"} {
264 fi, err := os.Lstat(filepath.Join(path, f))
265 if errors.Is(err, fs.ErrNotExist) || err == nil && !fi.Mode().IsRegular() {
266 continue
267 }
268 if err != nil {
269 return err
270 }
271 if err := addFile(tw, filepath.Join(path, f), name+"/"+f); err != nil {
272 return err
273 }
274 }
275 refs := filepath.Join(path, "refs")
276 if _, err := os.Lstat(refs); errors.Is(err, fs.ErrNotExist) {
277 return nil
278 }
279 return filepath.WalkDir(refs, func(p string, d fs.DirEntry, err error) error {
280 if err != nil {
281 return err
282 }
283 rel, err := filepath.Rel(path, p)
284 if err != nil {
285 return err
286 }
287 member := name + "/" + filepath.ToSlash(rel)
288 switch {
289 case d.IsDir():
290 return addDir(tw, p, member)
291 case d.Type().IsRegular():
292 return addFile(tw, p, member)
293 }
294 return nil
295 })
296}
297
233298// syncDir makes a rename in dir durable.
234299func syncDir(dir string) error {
235300 d, err := os.Open(dir)
cmd/gitbayd/backup_test.go +65
@@ -483,3 +483,68 @@ func TestBackupPreservesPackedRefDirs(t *testing.T) {
483483 }
484484 gitIn(t, restoredRepo, "fsck", "--connectivity-only", "--no-progress", "--no-dangling")
485485}
486
487// A commit pushed after a repository's refs are archived and before its
488// objects are leaves the archive with the earlier refs and every object
489// they reach, plus the new ones unreferenced (#259).
490func TestBackupArchivesRefsBeforeObjects(t *testing.T) {
491 cfg := testConfig(t)
492 st, err := openStore(cfg)
493 if err != nil {
494 t.Fatal(err)
495 }
496 uid, err := st.CreateUser("krz", false)
497 if err != nil {
498 t.Fatal(err)
499 }
500 if _, err := st.CreateRepo("user", uid, "thing", "public"); err != nil {
501 t.Fatal(err)
502 }
503 st.Close()
504
505 work := t.TempDir()
506 gitIn(t, work, "init", "-q", "-b", "main")
507 if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("a\n"), 0o644); err != nil {
508 t.Fatal(err)
509 }
510 gitIn(t, work, "add", "a.txt")
511 gitIn(t, work, "commit", "-q", "-m", "one")
512 dir := filepath.Join(cfg.Server.Root, "repos", "krz", "thing.git")
513 gitIn(t, work, "clone", "-q", "--bare", work, dir)
514 first := gitIn(t, dir, "rev-parse", "refs/heads/main")
515
516 var second string
517 afterRefs = func(repo string) {
518 if repo != dir {
519 return
520 }
521 if err := os.WriteFile(filepath.Join(work, "b.txt"), []byte("b\n"), 0o644); err != nil {
522 t.Fatal(err)
523 }
524 gitIn(t, work, "add", "b.txt")
525 gitIn(t, work, "commit", "-q", "-m", "two")
526 gitIn(t, work, "push", "-q", dir, "main")
527 second = gitIn(t, dir, "rev-parse", "refs/heads/main")
528 }
529 t.Cleanup(func() { afterRefs = func(string) {} })
530
531 archive := filepath.Join(t.TempDir(), "b.tar.gz")
532 if err := runBackup(cfg, archive, false); err != nil {
533 t.Fatal(err)
534 }
535 if second == "" || second == first {
536 t.Fatal("the push between the refs and the objects did not happen")
537 }
538 if err := verifyBackup(archive, ""); err != nil {
539 t.Fatalf("verify: %v", err)
540 }
541 restored := t.TempDir()
542 if out, err := exec.Command("tar", "-xzf", archive, "-C", restored).CombinedOutput(); err != nil {
543 t.Fatalf("extract: %v\n%s", err, out)
544 }
545 repo := filepath.Join(restored, "repos", "krz", "thing.git")
546 if got := gitIn(t, repo, "rev-parse", "refs/heads/main"); got != first {
547 t.Errorf("archived main is %s, want %s from before the push", got, first)
548 }
549 gitIn(t, repo, "cat-file", "-e", second)
550}