krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

4c11a67a9fd655dac5708e3ea95d53919610e1bc

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T00:19:05Z

admin backup: consistent archive with restore verified

- gitbayd admin backup [--out]: one tar.gz holding a consistent SQLite
  snapshot (VACUUM INTO, safe against a live daemon under WAL), every
  repository, and the SSH host keys; transient state excluded
  (hook.sock, regenerated hooks dir, askpass helper, WAL/SHM)
- snapshot ordering: database BEFORE repositories, so a push landing
  mid-backup yields orphaned git objects rather than database rows
  pointing at objects the archive missed (the plan stated the reverse
  order; its own rationale requires this one)
- e2e restores the archive into a fresh root and proves it: same host
  key passes strict checking, identity/repo/tag/issue all present,
  and the restored instance accepts new pushes (hooks self-regenerate)
 cmd/gitbayd/backup.go | 162 ++++++++++++++++++++++++++++++++++++++++++++++
 cmd/gitbayd/main.go   |   2 +-
 e2e/backup_test.go    | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 337 insertions(+), 1 deletion(-)

diff --git a/cmd/gitbayd/backup.go b/cmd/gitbayd/backup.go
new file mode 100644
index 0000000..3488bc4
--- /dev/null
+++ b/cmd/gitbayd/backup.go
@@ -0,0 +1,162 @@
+package main
+
+import (
+	"archive/tar"
+	"compress/gzip"
+	"fmt"
+	"io"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/spf13/cobra"
+
+	"gitbay.org/gitbay/internal/config"
+	"gitbay.org/gitbay/internal/store"
+)
+
+// backupCmd produces one tar.gz holding a consistent database snapshot plus
+// every repository and the SSH host keys. Restore by extracting the archive
+// into a fresh server.root.
+//
+// Ordering: the database is snapshotted BEFORE the repositories are read.
+// A push that lands mid-backup then shows up only as unreferenced git
+// objects in the archive (harmless); the reverse order could leave database
+// rows pointing at objects the archive never captured.
+func backupCmd() *cobra.Command {
+	var out string
+	cmd := &cobra.Command{
+		Use:   "backup",
+		Short: "write a consistent backup archive (database snapshot first, then repositories)",
+		Long: `Writes a tar.gz of the server root: a consistent SQLite snapshot,
+all repositories, and the SSH host keys. Transient state (hook socket,
+regenerated hook scripts, askpass helper, WAL files) is excluded.
+
+Restore: extract into an empty directory, point server.root at it, start
+gitbayd. Host keys are preserved, so clients keep their known_hosts entries.`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, err := config.Load(configPath)
+			if err != nil {
+				return err
+			}
+			if out == "" {
+				out = fmt.Sprintf("gitbay-backup-%s.tar.gz", time.Now().UTC().Format("20060102-150405"))
+			}
+			return runBackup(cfg, out)
+		},
+	}
+	cmd.Flags().StringVar(&out, "out", "", "output archive path (default gitbay-backup-<utc timestamp>.tar.gz)")
+	return cmd
+}
+
+func runBackup(cfg config.Config, out string) error {
+	st, err := openStore(cfg)
+	if err != nil {
+		return err
+	}
+	defer st.Close()
+
+	// 1. Consistent database snapshot, before any repository is read.
+	snap := filepath.Join(os.TempDir(), fmt.Sprintf("gitbay-snap-%d.db", os.Getpid()))
+	os.Remove(snap)
+	defer os.Remove(snap)
+	if err := snapshotDB(st, snap); err != nil {
+		return fmt.Errorf("database snapshot: %w", err)
+	}
+
+	f, err := os.Create(out)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+	gz := gzip.NewWriter(f)
+	tw := tar.NewWriter(gz)
+
+	if err := addFile(tw, snap, "gitbay.db"); err != nil {
+		return err
+	}
+
+	// 2. Everything under the root except transient or regenerated state.
+	skip := map[string]bool{
+		"gitbay.db": true, "gitbay.db-wal": true, "gitbay.db-shm": true,
+		"hook.sock": true, "askpass.sh": true, "hooks": true,
+	}
+	repoCount := 0
+	root := cfg.Server.Root
+	err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		rel, err := filepath.Rel(root, path)
+		if err != nil {
+			return err
+		}
+		if rel == "." {
+			return nil
+		}
+		if top, _, _ := strings.Cut(rel, string(filepath.Separator)); skip[top] {
+			if d.IsDir() {
+				return filepath.SkipDir
+			}
+			return nil
+		}
+		if !d.Type().IsRegular() && !d.IsDir() {
+			return nil // sockets, symlinks
+		}
+		if d.IsDir() {
+			if strings.HasSuffix(rel, ".git") {
+				repoCount++
+			}
+			return nil // directories are implied by member paths
+		}
+		return addFile(tw, path, filepath.ToSlash(rel))
+	})
+	if err != nil {
+		return err
+	}
+	if err := tw.Close(); err != nil {
+		return err
+	}
+	if err := gz.Close(); err != nil {
+		return err
+	}
+	if err := f.Close(); err != nil {
+		return err
+	}
+
+	info, _ := os.Stat(out)
+	fmt.Printf("wrote %s (%d repositories, %.1f MB)\n", out, repoCount, float64(info.Size())/1e6)
+	return nil
+}
+
+// snapshotDB writes a consistent copy of the live database. VACUUM INTO
+// takes a read snapshot, so concurrent daemon writes are safe under WAL.
+func snapshotDB(st *store.Store, dest string) error {
+	quoted := strings.ReplaceAll(dest, "'", "''")
+	_, err := st.DB.Exec(fmt.Sprintf("VACUUM INTO '%s'", quoted))
+	return err
+}
+
+func addFile(tw *tar.Writer, path, name string) error {
+	info, err := os.Stat(path)
+	if err != nil {
+		return err
+	}
+	hdr, err := tar.FileInfoHeader(info, "")
+	if err != nil {
+		return err
+	}
+	hdr.Name = name
+	if err := tw.WriteHeader(hdr); err != nil {
+		return err
+	}
+	src, err := os.Open(path)
+	if err != nil {
+		return err
+	}
+	defer src.Close()
+	_, err = io.Copy(tw, src)
+	return err
+}
diff --git a/cmd/gitbayd/main.go b/cmd/gitbayd/main.go
index e0a1ba1..a4bdf85 100644
--- a/cmd/gitbayd/main.go
+++ b/cmd/gitbayd/main.go
@@ -215,7 +215,7 @@ func adminCmd() *cobra.Command {
 		userCmd,
 		emailCmd,
 		notImplemented("invite", "issue registration invites"),
-		notImplemented("backup", "consistent backup: repos first, then database"),
+		backupCmd(),
 		notImplemented("gc", "run git gc across repositories"),
 		notImplemented("stats", "instance statistics"),
 	)
diff --git a/e2e/backup_test.go b/e2e/backup_test.go
new file mode 100644
index 0000000..7c060f0
--- /dev/null
+++ b/e2e/backup_test.go
@@ -0,0 +1,174 @@
+package e2e
+
+import (
+	"fmt"
+	"net"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestAdminBackup(t *testing.T) {
+	inst := startInstance(t)
+	aliceKey := inst.newKey(t, "alice")
+	inst.admin(t, "admin", "user", "create", "alice",
+		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
+
+	// Content worth backing up: a repo with commits and a tag, and an issue.
+	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/keep"); code != 0 {
+		t.Fatalf("repo create: %s", errOut)
+	}
+	work := t.TempDir()
+	env := inst.gitEnv(aliceKey)
+	mustGit(t, work, env, "clone", inst.sshURL("alice/keep"), "w")
+	dir := filepath.Join(work, "w")
+	os.WriteFile(filepath.Join(dir, "data.txt"), []byte("precious\n"), 0o644)
+	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
+	mustGit(t, dir, env, "add", ".")
+	mustGit(t, dir, env, "commit", "-q", "-m", "keep me")
+	mustGit(t, dir, env, "tag", "v1")
+	mustGit(t, dir, env, "push", "-q", "origin", "main", "v1")
+	if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/keep", "--title", "'survives backup'"); code != 0 {
+		t.Fatal("issue create failed")
+	}
+
+	// Back up while the daemon is running.
+	archive := filepath.Join(t.TempDir(), "backup.tar.gz")
+	out := inst.admin(t, "admin", "backup", "--out", archive)
+	if !strings.Contains(out, "1 repositories") {
+		t.Fatalf("backup summary: %s", out)
+	}
+
+	// The archive holds the snapshot, the repo, and the host key — and none
+	// of the transient state.
+	list, err := exec.Command("tar", "-tzf", archive).Output()
+	if err != nil {
+		t.Fatal(err)
+	}
+	names := string(list)
+	for _, want := range []string{"gitbay.db", "repos/alice/keep.git/", "ssh/host_ed25519"} {
+		if !strings.Contains(names, want) {
+			t.Fatalf("archive missing %s:\n%s", want, names)
+		}
+	}
+	for _, line := range strings.Split(strings.TrimSpace(names), "\n") {
+		// Top-level transient state must be absent; a repo's own inert
+		// sample hooks directory (keep.git/hooks/) is fine.
+		for _, banned := range []string{"hook.sock", "hooks/", "askpass.sh", "gitbay.db-wal"} {
+			if line == banned || strings.HasPrefix(line, banned) {
+				t.Fatalf("archive contains transient state %s:\n%s", line, names)
+			}
+		}
+	}
+
+	// Restore: extract into a fresh root and serve from it.
+	root2 := t.TempDir()
+	if outB, err := exec.Command("tar", "-xzf", archive, "-C", root2).CombinedOutput(); err != nil {
+		t.Fatalf("extract: %v\n%s", err, outB)
+	}
+	port2 := freePort(t)
+	httpPort2 := freePort(t)
+	config2 := filepath.Join(root2, "config.toml")
+	cfg := fmt.Sprintf(`
+[server]
+root = %q
+site_url = "https://gitbay.test"
+[ssh]
+port = %d
+[http]
+addr = "127.0.0.1:%d"
+tls = "off"
+`, root2, port2, httpPort2)
+	if err := os.WriteFile(config2, []byte(cfg), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	proc2 := exec.Command(inst.gitbayd, "--config", config2, "serve")
+	proc2.Stderr = os.Stderr
+	if err := proc2.Start(); err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { proc2.Process.Kill(); proc2.Wait() })
+	deadline := time.Now().Add(10 * time.Second)
+	for {
+		conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port2), 200*time.Millisecond)
+		if err == nil {
+			conn.Close()
+			break
+		}
+		if time.Now().After(deadline) {
+			t.Fatal("restored gitbayd did not start")
+		}
+		time.Sleep(50 * time.Millisecond)
+	}
+
+	// Strict host key checking against the ORIGINAL instance's host key:
+	// the preserved key means the restored server is cryptographically the
+	// same host. known_hosts entries are per host:port, so rebind the
+	// original entry to the new port.
+	khRaw, err := os.ReadFile(filepath.Join(inst.sshDir, "known_hosts"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	fields := strings.Fields(strings.SplitN(string(khRaw), "\n", 2)[0])
+	if len(fields) < 3 {
+		t.Fatalf("unexpected known_hosts: %q", khRaw)
+	}
+	kh2 := filepath.Join(t.TempDir(), "known_hosts")
+	entry := fmt.Sprintf("[127.0.0.1]:%d %s %s\n", port2, fields[1], fields[2])
+	if err := os.WriteFile(kh2, []byte(entry), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	ssh2 := func(args ...string) (string, string, int) {
+		base := []string{
+			"-p", fmt.Sprint(port2), "-i", aliceKey,
+			"-o", "IdentitiesOnly=yes",
+			"-o", "UserKnownHostsFile=" + kh2,
+			"-o", "StrictHostKeyChecking=yes",
+			"-o", "BatchMode=yes",
+			"git@127.0.0.1",
+		}
+		cmd := exec.Command("ssh", append(base, args...)...)
+		var o, e strings.Builder
+		cmd.Stdout, cmd.Stderr = &o, &e
+		err := cmd.Run()
+		code := 0
+		if ee, ok := err.(*exec.ExitError); ok {
+			code = ee.ExitCode()
+		} else if err != nil {
+			t.Fatalf("ssh: %v", err)
+		}
+		return o.String(), e.String(), code
+	}
+
+	// Identity, repo data, and issue all survived.
+	out2, errOut, code := ssh2("whoami")
+	if code != 0 || strings.TrimSpace(out2) != "alice" {
+		t.Fatalf("whoami on restored instance: exit %d, %q, %s", code, out2, errOut)
+	}
+	if out2, _, code = ssh2("repo", "log", "alice/keep"); code != 0 || !strings.Contains(out2, "keep me") {
+		t.Fatalf("restored log: %d\n%s", code, out2)
+	}
+	if out2, _, code = ssh2("issue", "show", "alice/keep", "1"); code != 0 || !strings.Contains(out2, "survives backup") {
+		t.Fatalf("restored issue: %d\n%s", code, out2)
+	}
+
+	// The restored instance accepts new pushes: hooks were regenerated at
+	// startup, not restored from the archive.
+	env2 := append(os.Environ(),
+		fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=%s -o BatchMode=yes",
+			aliceKey, kh2),
+		"GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
+		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
+		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test")
+	work2 := t.TempDir()
+	mustGit(t, work2, env2, "clone", fmt.Sprintf("ssh://git@127.0.0.1:%d/alice/keep.git", port2), "w")
+	dir2 := filepath.Join(work2, "w")
+	if data, _ := os.ReadFile(filepath.Join(dir2, "data.txt")); string(data) != "precious\n" {
+		t.Fatalf("restored content: %q", data)
+	}
+	mustGit(t, dir2, env2, "commit", "-q", "--allow-empty", "-m", "post-restore")
+	mustGit(t, dir2, env2, "push", "-q", "origin", "main")
+}