krz/gitbay

A CLI-first git forge.

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

d47a2ef73f46cfbc3083c56e041d79130ae9eb71

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T22:31:56Z

M2: repositories, git transport over SSH, ref policy hooks

- repo create/list/show/delete, access grant/revoke/list, settings
  protect/unprotect as control commands
- git upload-pack/receive-pack/upload-archive streamed over the SSH
  channel after access and scope checks; private repos answer
  'repository not found' identically to nonexistence
- shared core.hooksPath directory regenerated at startup; pre-receive
  computes git facts in the hook process (quarantine-safe) and asks the
  daemon for policy over a unix socket
- ref policy: protected branches refuse force-push and deletion;
  refs/merge-requests/* unpushable by clients
- access policy matrix and deploy-key scope checks with unit tests
- e2e: full M2 scenario with real git (private denial, read grant,
  read-only push denial, protect/force/delete/unprotect)
 cmd/forged/hook.go             |  71 ++++++++++
 cmd/forged/main.go             |  19 +++
 e2e/git_test.go                | 156 +++++++++++++++++++++
 internal/control/repo.go       | 311 +++++++++++++++++++++++++++++++++++++++++
 internal/gitutil/gitutil.go    |  76 ++++++++++
 internal/hookd/hookd.go        | 126 +++++++++++++++++
 internal/policy/access.go      |  98 +++++++++++++
 internal/policy/access_test.go |  95 +++++++++++++
 internal/sshd/sshd.go          |  63 +++++++--
 internal/store/repos.go        | 192 +++++++++++++++++++++++++
 10 files changed, 1198 insertions(+), 9 deletions(-)

diff --git a/cmd/forged/hook.go b/cmd/forged/hook.go
new file mode 100644
index 0000000..e76e79d
--- /dev/null
+++ b/cmd/forged/hook.go
@@ -0,0 +1,71 @@
+package main
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"strconv"
+	"strings"
+
+	"github.com/spf13/cobra"
+
+	"github.com/krazywarez/forge/internal/gitutil"
+	"github.com/krazywarez/forge/internal/hookd"
+	"github.com/krazywarez/forge/internal/policy"
+)
+
+// hookCmd runs inside a git hook. It computes git facts here — the hook
+// process inherits git's quarantine environment, so incoming objects are
+// visible — and asks the daemon for a policy decision over the unix socket.
+func hookCmd() *cobra.Command {
+	return &cobra.Command{
+		Use:    "hook <pre-receive|post-receive>",
+		Hidden: true,
+		Args:   cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			sock := os.Getenv(hookd.EnvSocket)
+			repoID, err1 := strconv.ParseInt(os.Getenv(hookd.EnvRepoID), 10, 64)
+			userID, err2 := strconv.ParseInt(os.Getenv(hookd.EnvUserID), 10, 64)
+			if sock == "" || err1 != nil || err2 != nil {
+				return fmt.Errorf("missing FORGE_* environment; this command only runs as a git hook")
+			}
+
+			var updates []policy.RefUpdate
+			scanner := bufio.NewScanner(os.Stdin)
+			for scanner.Scan() {
+				fields := strings.Fields(scanner.Text())
+				if len(fields) != 3 {
+					continue
+				}
+				u := policy.RefUpdate{Old: fields[0], New: fields[1], Ref: fields[2]}
+				u.IsDelete = gitutil.ZeroSHA(u.New)
+				if !u.IsDelete && !gitutil.ZeroSHA(u.Old) {
+					anc, err := gitutil.IsAncestor(".", u.Old, u.New)
+					if err != nil {
+						return fmt.Errorf("checking ancestry for %s: %w", u.Ref, err)
+					}
+					u.IsForce = !anc
+				}
+				updates = append(updates, u)
+			}
+			if err := scanner.Err(); err != nil {
+				return err
+			}
+
+			resp, err := hookd.Ask(sock, hookd.Request{
+				Hook:    args[0],
+				RepoID:  repoID,
+				UserID:  userID,
+				Updates: updates,
+			})
+			if err != nil {
+				return fmt.Errorf("forge daemon unreachable: %w", err)
+			}
+			if !resp.Allow {
+				fmt.Fprintln(os.Stderr, resp.Message)
+				os.Exit(1)
+			}
+			return nil
+		},
+	}
+}
diff --git a/cmd/forged/main.go b/cmd/forged/main.go
index bec7d21..6242fae 100644
--- a/cmd/forged/main.go
+++ b/cmd/forged/main.go
@@ -14,6 +14,8 @@ import (
 	"golang.org/x/crypto/ssh"
 
 	"github.com/krazywarez/forge/internal/config"
+	"github.com/krazywarez/forge/internal/control"
+	"github.com/krazywarez/forge/internal/hookd"
 	"github.com/krazywarez/forge/internal/policy"
 	"github.com/krazywarez/forge/internal/sshd"
 	"github.com/krazywarez/forge/internal/store"
@@ -47,6 +49,7 @@ func main() {
 		serveCmd(),
 		migrateCmd(),
 		adminCmd(),
+		hookCmd(),
 	)
 
 	if err := root.Execute(); err != nil {
@@ -96,6 +99,22 @@ func serveCmd() *cobra.Command {
 			if cfg.SSH.Mode != "embedded" {
 				return fmt.Errorf("ssh.mode = %q not implemented (M9)", cfg.SSH.Mode)
 			}
+
+			// Regenerate hook scripts so a moved binary self-heals, then
+			// start the hook policy socket.
+			self, err := os.Executable()
+			if err != nil {
+				return err
+			}
+			if err := hookd.WriteHookScripts(control.HooksDir(cfg.Server.Root), self); err != nil {
+				return err
+			}
+			stopHookd, err := hookd.Serve(cfg.Server.Root, st)
+			if err != nil {
+				return err
+			}
+			defer stopHookd()
+
 			srv, err := sshd.New(cfg, st)
 			if err != nil {
 				return err
diff --git a/e2e/git_test.go b/e2e/git_test.go
new file mode 100644
index 0000000..bd938bd
--- /dev/null
+++ b/e2e/git_test.go
@@ -0,0 +1,156 @@
+package e2e
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// gitEnv returns the environment for running the git client against the
+// instance with the given key.
+func (i *instance) gitEnv(key string) []string {
+	sshCmd := fmt.Sprintf(
+		"ssh -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=%s -o BatchMode=yes",
+		key, filepath.Join(i.sshDir, "known_hosts"))
+	return append(os.Environ(),
+		"GIT_SSH_COMMAND="+sshCmd,
+		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
+		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test",
+	)
+}
+
+func (i *instance) sshURL(repo string) string {
+	return fmt.Sprintf("ssh://git@127.0.0.1:%d/%s.git", i.port, repo)
+}
+
+// git runs a git command; returns combined output and exit code.
+func gitRun(t *testing.T, dir string, env []string, args ...string) (string, int) {
+	t.Helper()
+	cmd := exec.Command("git", args...)
+	cmd.Dir = dir
+	cmd.Env = env
+	out, err := cmd.CombinedOutput()
+	code := 0
+	if ee, ok := err.(*exec.ExitError); ok {
+		code = ee.ExitCode()
+	} else if err != nil {
+		t.Fatalf("git %v: %v", args, err)
+	}
+	return string(out), code
+}
+
+func mustGit(t *testing.T, dir string, env []string, args ...string) string {
+	t.Helper()
+	out, code := gitRun(t, dir, env, args...)
+	if code != 0 {
+		t.Fatalf("git %v failed (%d):\n%s", args, code, out)
+	}
+	return out
+}
+
+func TestGitOverSSH(t *testing.T) {
+	inst := startInstance(t)
+
+	aliceKey := inst.newKey(t, "alice")
+	bobKey := inst.newKey(t, "bob")
+	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
+	inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
+
+	// Alice creates a private repo over bare ssh.
+	_, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/proj", "--private")
+	if code != 0 {
+		t.Fatalf("repo create: exit %d, %s", code, errOut)
+	}
+
+	// Alice clones (empty), commits, pushes.
+	work := t.TempDir()
+	aliceEnv := inst.gitEnv(aliceKey)
+	mustGit(t, work, aliceEnv, "clone", inst.sshURL("alice/proj"), "proj")
+	dir := filepath.Join(work, "proj")
+	if err := os.WriteFile(filepath.Join(dir, "README"), []byte("hello\n"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	mustGit(t, dir, aliceEnv, "checkout", "-q", "-b", "main")
+	mustGit(t, dir, aliceEnv, "add", "README")
+	mustGit(t, dir, aliceEnv, "commit", "-q", "-m", "init")
+	mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
+
+	// Bob is denied clone of the private repo, indistinguishable from
+	// nonexistence.
+	bobEnv := inst.gitEnv(bobKey)
+	out, code := gitRun(t, t.TempDir(), bobEnv, "clone", inst.sshURL("alice/proj"), "proj")
+	if code == 0 {
+		t.Fatal("bob cloned a private repo without access")
+	}
+	if !strings.Contains(out, "repository not found") {
+		t.Fatalf("denial should read as not-found, got:\n%s", out)
+	}
+
+	// Alice grants bob read; clone succeeds; push is denied.
+	_, errOut, code = inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/proj", "bob", "read")
+	if code != 0 {
+		t.Fatalf("access grant: exit %d, %s", code, errOut)
+	}
+	bobWork := t.TempDir()
+	mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("alice/proj"), "proj")
+	bobDir := filepath.Join(bobWork, "proj")
+	if err := os.WriteFile(filepath.Join(bobDir, "x"), []byte("x\n"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	mustGit(t, bobDir, bobEnv, "add", "x")
+	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "bob")
+	out, code = gitRun(t, bobDir, bobEnv, "push", "origin", "main")
+	if code == 0 {
+		t.Fatal("bob pushed with read-only access")
+	}
+	if !strings.Contains(out, "write access to alice/proj denied") {
+		t.Fatalf("push denial message:\n%s", out)
+	}
+
+	// Alice protects main: force-push and deletion are refused by the hook,
+	// normal pushes still work.
+	_, errOut, code = inst.ssh(t, aliceKey, "", "repo", "settings", "protect", "alice/proj", "main")
+	if code != 0 {
+		t.Fatalf("protect: exit %d, %s", code, errOut)
+	}
+
+	mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "second")
+	mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
+
+	mustGit(t, dir, aliceEnv, "reset", "-q", "--hard", "HEAD~1")
+	mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "rewritten")
+	out, code = gitRun(t, dir, aliceEnv, "push", "--force", "origin", "main")
+	if code == 0 {
+		t.Fatal("force-push to protected branch succeeded")
+	}
+	if !strings.Contains(out, "force-push refused") {
+		t.Fatalf("force-push denial message:\n%s", out)
+	}
+
+	out, code = gitRun(t, dir, aliceEnv, "push", "origin", ":main")
+	if code == 0 {
+		t.Fatal("deletion of protected branch succeeded")
+	}
+	if !strings.Contains(out, "deletion refused") {
+		t.Fatalf("deletion denial message:\n%s", out)
+	}
+
+	// refs/merge-requests/* is unpushable even by the owner.
+	out, code = gitRun(t, dir, aliceEnv, "push", "origin", "HEAD:refs/merge-requests/1/head")
+	if code == 0 {
+		t.Fatal("client pushed into refs/merge-requests/*")
+	}
+	if !strings.Contains(out, "server-owned") {
+		t.Fatalf("mr-ref denial message:\n%s", out)
+	}
+
+	// Unprotect: force-push now goes through.
+	_, _, code = inst.ssh(t, aliceKey, "", "repo", "settings", "unprotect", "alice/proj", "main")
+	if code != 0 {
+		t.Fatal("unprotect failed")
+	}
+	mustGit(t, dir, aliceEnv, "push", "-q", "--force", "origin", "main")
+}
diff --git a/internal/control/repo.go b/internal/control/repo.go
new file mode 100644
index 0000000..2d6f833
--- /dev/null
+++ b/internal/control/repo.go
@@ -0,0 +1,311 @@
+package control
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"slices"
+	"strings"
+
+	"github.com/krazywarez/forge/internal/gitutil"
+	"github.com/krazywarez/forge/internal/policy"
+	"github.com/krazywarez/forge/internal/protocol"
+	"github.com/krazywarez/forge/internal/store"
+)
+
+// RepoDir returns the on-disk path for a repository.
+func RepoDir(root, owner, name string) string {
+	return filepath.Join(root, "repos", owner, name+".git")
+}
+
+// HooksDir is the shared core.hooksPath directory.
+func HooksDir(root string) string { return filepath.Join(root, "hooks") }
+
+func init() {
+	register(Command{Path: []string{"repo", "create"},
+		Summary: "create a repository: repo create <owner/name> [--private]", Run: runRepoCreate})
+	register(Command{Path: []string{"repo", "list"},
+		Summary: "list repositories you own or can access", Run: runRepoList})
+	register(Command{Path: []string{"repo", "show"},
+		Summary: "show repository details: repo show <owner/name>", Run: runRepoShow})
+	register(Command{Path: []string{"repo", "delete"},
+		Summary: "delete a repository: repo delete <owner/name> --yes", Run: runRepoDelete})
+	register(Command{Path: []string{"repo", "access", "grant"},
+		Summary: "grant access: repo access grant <owner/name> <user> read|write|admin", Run: runAccessGrant})
+	register(Command{Path: []string{"repo", "access", "revoke"},
+		Summary: "revoke access: repo access revoke <owner/name> <user>", Run: runAccessRevoke})
+	register(Command{Path: []string{"repo", "access", "list"},
+		Summary: "list access grants: repo access list <owner/name>", Run: runAccessList})
+	register(Command{Path: []string{"repo", "settings", "show"},
+		Summary: "show settings: repo settings show <owner/name>", Run: runSettingsShow})
+	register(Command{Path: []string{"repo", "settings", "protect"},
+		Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect})
+	register(Command{Path: []string{"repo", "settings", "unprotect"},
+		Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect})
+}
+
+// resolveRepo loads a repo and checks the given permission for c.User.
+func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string) bool) (store.Repo, int) {
+	repo, err := c.Store.RepoByPath(path)
+	if err != nil {
+		if errors.Is(err, store.ErrNotFound) {
+			// Same message whether it doesn't exist or is invisible.
+			return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
+		}
+		return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
+	}
+	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
+	if err != nil {
+		return repo, c.fail(protocol.ExitFailure, "checking access: %v", err)
+	}
+	if !check(c.User, repo, grant) {
+		if !policy.CanRead(c.User, repo, grant) {
+			// Invisible repos 404, per the enumeration rule.
+			return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
+		}
+		return repo, c.fail(protocol.ExitDenied, "permission denied on %s", path)
+	}
+	return repo, -1
+}
+
+func runRepoCreate(c *Ctx, args []string) int {
+	visibility := "public"
+	var path string
+	for _, a := range args {
+		switch a {
+		case "--private":
+			visibility = "private"
+		default:
+			if path != "" {
+				return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
+			}
+			path = a
+		}
+	}
+	owner, name, ok := strings.Cut(path, "/")
+	if !ok {
+		return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
+	}
+	if owner != c.User.Username {
+		return c.fail(protocol.ExitDenied, "cannot create repositories under %q (orgs not yet supported)", owner)
+	}
+	if err := policyValidateRepoName(name); err != nil {
+		return c.fail(protocol.ExitUsage, "%v", err)
+	}
+	id, err := c.Store.CreateRepo("user", c.User.ID, name, visibility)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	dir := RepoDir(c.Cfg.Server.Root, owner, name)
+	if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
+		c.Store.DeleteRepo(id)
+		return c.fail(protocol.ExitFailure, "initializing repository: %v", err)
+	}
+	type out struct {
+		Path       string `json:"path"`
+		Visibility string `json:"visibility"`
+		SSHURL     string `json:"ssh_url"`
+	}
+	d := out{Path: path, Visibility: visibility, SSHURL: "ssh://git@" + hostOf(c.Cfg.Server.SiteURL) + "/" + path + ".git"}
+	return c.emit(d, func(w io.Writer) {
+		fmt.Fprintf(w, "created %s (%s)\nclone: git clone %s\n", d.Path, d.Visibility, d.SSHURL)
+	})
+}
+
+func policyValidateRepoName(name string) error { return policy.ValidateName(name) }
+
+func hostOf(siteURL string) string {
+	s := strings.TrimPrefix(strings.TrimPrefix(siteURL, "https://"), "http://")
+	return strings.TrimSuffix(s, "/")
+}
+
+func runRepoList(c *Ctx, args []string) int {
+	repos, err := c.Store.ListReposForUser(c.User.ID)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	type out struct {
+		Path       string `json:"path"`
+		Visibility string `json:"visibility"`
+	}
+	var ds []out
+	for _, r := range repos {
+		ds = append(ds, out{r.Path(), r.Visibility})
+	}
+	return c.emit(ds, func(w io.Writer) {
+		for _, d := range ds {
+			fmt.Fprintf(w, "%s\t%s\n", d.Path, d.Visibility)
+		}
+	})
+}
+
+func runRepoShow(c *Ctx, args []string) int {
+	if len(args) != 1 {
+		return c.fail(protocol.ExitUsage, "usage: repo show <owner/name>")
+	}
+	repo, code := resolveRepo(c, args[0], policy.CanRead)
+	if code >= 0 {
+		return code
+	}
+	type out struct {
+		Path              string   `json:"path"`
+		Visibility        string   `json:"visibility"`
+		DefaultBranch     string   `json:"default_branch"`
+		ProtectedBranches []string `json:"protected_branches,omitempty"`
+	}
+	d := out{repo.Path(), repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches}
+	return c.emit(d, func(w io.Writer) {
+		fmt.Fprintf(w, "%s\t%s\tdefault: %s\n", d.Path, d.Visibility, d.DefaultBranch)
+		if len(d.ProtectedBranches) > 0 {
+			fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
+		}
+	})
+}
+
+func runRepoDelete(c *Ctx, args []string) int {
+	var path string
+	var yes bool
+	for _, a := range args {
+		if a == "--yes" {
+			yes = true
+		} else if path == "" {
+			path = a
+		} else {
+			return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
+		}
+	}
+	if path == "" {
+		return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
+	}
+	repo, code := resolveRepo(c, path, policy.CanAdmin)
+	if code >= 0 {
+		return code
+	}
+	if !yes {
+		return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
+	}
+	if err := c.Store.DeleteRepo(repo.ID); err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
+		return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
+	}
+	return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
+		fmt.Fprintf(w, "deleted %s\n", repo.Path())
+	})
+}
+
+func runAccessGrant(c *Ctx, args []string) int {
+	if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
+		return c.fail(protocol.ExitUsage, "usage: repo access grant <owner/name> <user> read|write|admin")
+	}
+	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
+	if code >= 0 {
+		return code
+	}
+	target, err := c.Store.UserByUsername(args[1])
+	if err != nil {
+		return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
+	}
+	if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	return c.emit(map[string]string{"granted": args[2], "user": target.Username},
+		func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
+}
+
+func runAccessRevoke(c *Ctx, args []string) int {
+	if len(args) != 2 {
+		return c.fail(protocol.ExitUsage, "usage: repo access revoke <owner/name> <user>")
+	}
+	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
+	if code >= 0 {
+		return code
+	}
+	target, err := c.Store.UserByUsername(args[1])
+	if err != nil {
+		return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
+	}
+	if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
+		if errors.Is(err, store.ErrNotFound) {
+			return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
+		}
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	return c.emit(map[string]string{"revoked": target.Username},
+		func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
+}
+
+func runAccessList(c *Ctx, args []string) int {
+	if len(args) != 1 {
+		return c.fail(protocol.ExitUsage, "usage: repo access list <owner/name>")
+	}
+	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
+	if code >= 0 {
+		return code
+	}
+	entries, err := c.Store.ListAccess(repo.ID)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	type out struct {
+		User string `json:"user"`
+		Role string `json:"role"`
+	}
+	var ds []out
+	for _, e := range entries {
+		ds = append(ds, out{e.Username, e.Role})
+	}
+	return c.emit(ds, func(w io.Writer) {
+		for _, d := range ds {
+			fmt.Fprintf(w, "%s\t%s\n", d.User, d.Role)
+		}
+	})
+}
+
+func runSettingsShow(c *Ctx, args []string) int {
+	if len(args) != 1 {
+		return c.fail(protocol.ExitUsage, "usage: repo settings show <owner/name>")
+	}
+	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
+	if code >= 0 {
+		return code
+	}
+	return c.emit(repo.Settings, func(w io.Writer) {
+		fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\n",
+			strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits)
+	})
+}
+
+func runProtect(c *Ctx, args []string) int   { return setProtect(c, args, true) }
+func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
+
+func setProtect(c *Ctx, args []string, protect bool) int {
+	if len(args) != 2 {
+		return c.fail(protocol.ExitUsage, "usage: repo settings protect|unprotect <owner/name> <branch>")
+	}
+	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
+	if code >= 0 {
+		return code
+	}
+	branch := args[1]
+	s := repo.Settings
+	has := slices.Contains(s.ProtectedBranches, branch)
+	if protect && !has {
+		s.ProtectedBranches = append(s.ProtectedBranches, branch)
+		slices.Sort(s.ProtectedBranches)
+	}
+	if !protect && has {
+		s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
+	}
+	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	verb := "protected"
+	if !protect {
+		verb = "unprotected"
+	}
+	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
+}
diff --git a/internal/gitutil/gitutil.go b/internal/gitutil/gitutil.go
new file mode 100644
index 0000000..99c5370
--- /dev/null
+++ b/internal/gitutil/gitutil.go
@@ -0,0 +1,76 @@
+// Package gitutil wraps the system git binary. All repository access goes
+// through git subprocesses; there is no in-process git implementation.
+package gitutil
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+)
+
+// InitBare creates a bare repository with the shared hooks directory wired
+// via core.hooksPath.
+func InitBare(path, defaultBranch, hooksPath string) error {
+	if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
+		return err
+	}
+	cmd := exec.Command("git", "init", "--bare", "--initial-branch="+defaultBranch, path)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("git init: %v\n%s", err, out)
+	}
+	cmd = exec.Command("git", "-C", path, "config", "core.hooksPath", hooksPath)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("git config core.hooksPath: %v\n%s", err, out)
+	}
+	return nil
+}
+
+// Transport streams one git transport service (upload-pack, receive-pack,
+// upload-archive) over rw. extraEnv entries are appended to the daemon's
+// environment; hooks read the FORGE_* variables from it.
+func Transport(service, repoPath string, rw io.ReadWriter, errW io.Writer, extraEnv []string) error {
+	var args []string
+	switch service {
+	case "git-upload-pack", "git-receive-pack", "git-upload-archive":
+		args = []string{strings.TrimPrefix(service, "git-"), repoPath}
+	default:
+		return fmt.Errorf("unknown service %q", service)
+	}
+	cmd := exec.Command("git", args...)
+	cmd.Env = append(os.Environ(), extraEnv...)
+	cmd.Stdin = rw
+	cmd.Stdout = rw
+	cmd.Stderr = errW
+	return cmd.Run()
+}
+
+// IsAncestor reports whether old is an ancestor of new in the repository at
+// dir. It must run with the caller's environment intact so that quarantined
+// objects during pre-receive remain visible.
+func IsAncestor(dir, old, new string) (bool, error) {
+	cmd := exec.Command("git", "-C", dir, "merge-base", "--is-ancestor", old, new)
+	err := cmd.Run()
+	if err == nil {
+		return true, nil
+	}
+	if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
+		return false, nil
+	}
+	return false, err
+}
+
+// ZeroSHA reports whether s is an all-zero object id (SHA-1 or SHA-256).
+func ZeroSHA(s string) bool {
+	if len(s) != 40 && len(s) != 64 {
+		return false
+	}
+	for i := 0; i < len(s); i++ {
+		if s[i] != '0' {
+			return false
+		}
+	}
+	return true
+}
diff --git a/internal/hookd/hookd.go b/internal/hookd/hookd.go
new file mode 100644
index 0000000..afc0f11
--- /dev/null
+++ b/internal/hookd/hookd.go
@@ -0,0 +1,126 @@
+// Package hookd is the unix-socket bridge between git hooks and the daemon.
+// The hook process (forged in hook mode) computes git facts — it inherits
+// git's quarantine environment, which the daemon does not see — and sends
+// them here; the daemon answers with a pure policy decision.
+package hookd
+
+import (
+	"encoding/json"
+	"fmt"
+	"net"
+	"os"
+	"path/filepath"
+
+	"github.com/krazywarez/forge/internal/policy"
+	"github.com/krazywarez/forge/internal/store"
+)
+
+// Env variable names passed to git transport subprocesses and inherited by
+// hooks.
+const (
+	EnvSocket = "FORGE_HOOK_SOCKET"
+	EnvRepoID = "FORGE_REPO_ID"
+	EnvUserID = "FORGE_USER_ID"
+)
+
+type Request struct {
+	Hook    string             `json:"hook"` // pre-receive | post-receive
+	RepoID  int64              `json:"repo_id"`
+	UserID  int64              `json:"user_id"`
+	Updates []policy.RefUpdate `json:"updates"`
+}
+
+type Response struct {
+	Allow   bool   `json:"allow"`
+	Message string `json:"message,omitempty"`
+}
+
+// SocketPath returns the hook socket location under the server root.
+func SocketPath(root string) string { return filepath.Join(root, "hook.sock") }
+
+type Server struct {
+	st *store.Store
+}
+
+// Serve listens on the unix socket until the listener is closed.
+func Serve(root string, st *store.Store) (func() error, error) {
+	path := SocketPath(root)
+	os.Remove(path)
+	ln, err := net.Listen("unix", path)
+	if err != nil {
+		return nil, err
+	}
+	s := &Server{st: st}
+	go func() {
+		for {
+			conn, err := ln.Accept()
+			if err != nil {
+				return
+			}
+			go s.handle(conn)
+		}
+	}()
+	return ln.Close, nil
+}
+
+func (s *Server) handle(conn net.Conn) {
+	defer conn.Close()
+	var req Request
+	if err := json.NewDecoder(conn).Decode(&req); err != nil {
+		json.NewEncoder(conn).Encode(Response{Allow: false, Message: "bad hook request"})
+		return
+	}
+	json.NewEncoder(conn).Encode(s.decide(req))
+}
+
+func (s *Server) decide(req Request) Response {
+	switch req.Hook {
+	case "pre-receive":
+		repo, err := s.st.RepoByID(req.RepoID)
+		if err != nil {
+			return Response{Allow: false, Message: "unknown repository"}
+		}
+		if msg := policy.CheckPush(repo, req.Updates); msg != "" {
+			return Response{Allow: false, Message: msg}
+		}
+		return Response{Allow: true}
+	case "post-receive":
+		// Event recording and signature verification enqueue land in M4.
+		return Response{Allow: true}
+	default:
+		return Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)}
+	}
+}
+
+// Ask sends one request from the hook process to the daemon.
+func Ask(socketPath string, req Request) (Response, error) {
+	conn, err := net.Dial("unix", socketPath)
+	if err != nil {
+		return Response{}, err
+	}
+	defer conn.Close()
+	if err := json.NewEncoder(conn).Encode(req); err != nil {
+		return Response{}, err
+	}
+	var resp Response
+	if err := json.NewDecoder(conn).Decode(&resp); err != nil {
+		return Response{}, err
+	}
+	return resp, nil
+}
+
+// WriteHookScripts (re)generates the shared hooks directory. Called at
+// daemon startup so a moved binary self-heals; every repo points here via
+// core.hooksPath.
+func WriteHookScripts(hooksDir, forgedPath string) error {
+	if err := os.MkdirAll(hooksDir, 0o755); err != nil {
+		return err
+	}
+	for _, hook := range []string{"pre-receive", "post-receive"} {
+		script := fmt.Sprintf("#!/bin/sh\nexec %q hook %s\n", forgedPath, hook)
+		if err := os.WriteFile(filepath.Join(hooksDir, hook), []byte(script), 0o755); err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/internal/policy/access.go b/internal/policy/access.go
new file mode 100644
index 0000000..d9cc60f
--- /dev/null
+++ b/internal/policy/access.go
@@ -0,0 +1,98 @@
+package policy
+
+import (
+	"strings"
+
+	"github.com/krazywarez/forge/internal/store"
+)
+
+// CanRead reports whether user may read repo over an authenticated channel.
+// Public repos are readable by any authenticated user; private repos require
+// ownership or an explicit grant.
+func CanRead(user store.User, repo store.Repo, grant string) bool {
+	if isOwner(user, repo) {
+		return true
+	}
+	if repo.Visibility == "public" {
+		return true
+	}
+	return grant == "read" || grant == "write" || grant == "admin"
+}
+
+// CanWrite reports whether user may push to repo.
+func CanWrite(user store.User, repo store.Repo, grant string) bool {
+	if isOwner(user, repo) {
+		return true
+	}
+	return grant == "write" || grant == "admin"
+}
+
+// CanAdmin reports whether user may change repo settings and access.
+func CanAdmin(user store.User, repo store.Repo, grant string) bool {
+	if isOwner(user, repo) {
+		return true
+	}
+	return grant == "admin"
+}
+
+func isOwner(user store.User, repo store.Repo) bool {
+	return repo.OwnerKind == "user" && repo.OwnerID == user.ID
+}
+
+// ScopeAllowsGit reports whether an SSH key scope permits the requested git
+// transport on repoPath ("owner/name"). write=true for receive-pack.
+func ScopeAllowsGit(scope, repoPath string, write bool) bool {
+	switch scope {
+	case "full", "git":
+		return true
+	}
+	rest, ok := strings.CutPrefix(scope, "deploy:")
+	if !ok {
+		return false
+	}
+	target, mode, ok := strings.Cut(rest, ":")
+	if !ok || target != repoPath {
+		return false
+	}
+	switch mode {
+	case "rw":
+		return true
+	case "ro":
+		return !write
+	}
+	return false
+}
+
+// RefUpdate is one proposed ref change, with git facts computed by the hook
+// process (which can see quarantined objects; the daemon cannot).
+type RefUpdate struct {
+	Ref      string `json:"ref"`
+	Old      string `json:"old"`
+	New      string `json:"new"`
+	IsDelete bool   `json:"is_delete"`
+	IsForce  bool   `json:"is_force"`
+}
+
+// CheckPush applies ref policy for a push by a user with write access
+// already established. It returns a denial message, or "" to allow.
+func CheckPush(repo store.Repo, updates []RefUpdate) string {
+	protected := map[string]bool{}
+	for _, b := range repo.Settings.ProtectedBranches {
+		protected["refs/heads/"+b] = true
+	}
+	for _, u := range updates {
+		if strings.HasPrefix(u.Ref, "refs/merge-requests/") {
+			return "refs/merge-requests/* is server-owned and cannot be pushed"
+		}
+		if protected[u.Ref] {
+			branch := strings.TrimPrefix(u.Ref, "refs/heads/")
+			if u.IsDelete {
+				return "branch " + branch + " is protected: deletion refused"
+			}
+			if u.IsForce {
+				return "branch " + branch + " is protected: force-push refused"
+			}
+		}
+	}
+	return ""
+}
diff --git a/internal/policy/access_test.go b/internal/policy/access_test.go
new file mode 100644
index 0000000..a71c456
--- /dev/null
+++ b/internal/policy/access_test.go
@@ -0,0 +1,95 @@
+package policy
+
+import (
+	"testing"
+
+	"github.com/krazywarez/forge/internal/store"
+)
+
+var (
+	owner    = store.User{ID: 1, Username: "alice"}
+	stranger = store.User{ID: 2, Username: "bob"}
+	priv     = store.Repo{ID: 10, OwnerKind: "user", OwnerID: 1, OwnerName: "alice", Name: "p", Visibility: "private"}
+	pub      = store.Repo{ID: 11, OwnerKind: "user", OwnerID: 1, OwnerName: "alice", Name: "q", Visibility: "public"}
+)
+
+func TestAccessMatrix(t *testing.T) {
+	cases := []struct {
+		name  string
+		user  store.User
+		repo  store.Repo
+		grant string
+		read  bool
+		write bool
+		admin bool
+	}{
+		{"owner private", owner, priv, "", true, true, true},
+		{"stranger private no grant", stranger, priv, "", false, false, false},
+		{"stranger private read", stranger, priv, "read", true, false, false},
+		{"stranger private write", stranger, priv, "write", true, true, false},
+		{"stranger private admin", stranger, priv, "admin", true, true, true},
+		{"stranger public no grant", stranger, pub, "", true, false, false},
+		{"stranger public write", stranger, pub, "write", true, true, false},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := CanRead(tc.user, tc.repo, tc.grant); got != tc.read {
+				t.Errorf("CanRead = %v, want %v", got, tc.read)
+			}
+			if got := CanWrite(tc.user, tc.repo, tc.grant); got != tc.write {
+				t.Errorf("CanWrite = %v, want %v", got, tc.write)
+			}
+			if got := CanAdmin(tc.user, tc.repo, tc.grant); got != tc.admin {
+				t.Errorf("CanAdmin = %v, want %v", got, tc.admin)
+			}
+		})
+	}
+}
+
+func TestScopeAllowsGit(t *testing.T) {
+	cases := []struct {
+		scope string
+		repo  string
+		write bool
+		want  bool
+	}{
+		{"full", "a/b", true, true},
+		{"git", "a/b", true, true},
+		{"deploy:a/b:ro", "a/b", false, true},
+		{"deploy:a/b:ro", "a/b", true, false},
+		{"deploy:a/b:rw", "a/b", true, true},
+		{"deploy:a/b:rw", "a/c", false, false}, // wrong repo
+		{"deploy:a/b", "a/b", false, false},    // malformed
+		{"", "a/b", false, false},
+	}
+	for _, tc := range cases {
+		if got := ScopeAllowsGit(tc.scope, tc.repo, tc.write); got != tc.want {
+			t.Errorf("ScopeAllowsGit(%q, %q, write=%v) = %v, want %v", tc.scope, tc.repo, tc.write, got, tc.want)
+		}
+	}
+}
+
+func TestCheckPush(t *testing.T) {
+	repo := store.Repo{Settings: store.RepoSettings{ProtectedBranches: []string{"main"}}}
+	cases := []struct {
+		name    string
+		updates []RefUpdate
+		denied  bool
+	}{
+		{"normal push to protected", []RefUpdate{{Ref: "refs/heads/main"}}, false},
+		{"force to protected", []RefUpdate{{Ref: "refs/heads/main", IsForce: true}}, true},
+		{"delete protected", []RefUpdate{{Ref: "refs/heads/main", IsDelete: true}}, true},
+		{"force to unprotected", []RefUpdate{{Ref: "refs/heads/dev", IsForce: true}}, false},
+		{"delete unprotected", []RefUpdate{{Ref: "refs/heads/dev", IsDelete: true}}, false},
+		{"mr namespace", []RefUpdate{{Ref: "refs/merge-requests/1/head"}}, true},
+		{"tag alongside protected", []RefUpdate{{Ref: "refs/tags/v1"}, {Ref: "refs/heads/main"}}, false},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			msg := CheckPush(repo, tc.updates)
+			if (msg != "") != tc.denied {
+				t.Errorf("CheckPush = %q, denied should be %v", msg, tc.denied)
+			}
+		})
+	}
+}
diff --git a/internal/sshd/sshd.go b/internal/sshd/sshd.go
index 32fbbb4..ff9fbb0 100644
--- a/internal/sshd/sshd.go
+++ b/internal/sshd/sshd.go
@@ -13,12 +13,14 @@ import (
 	"os"
 	"path/filepath"
 	"strconv"
-	"strings"
 
 	"golang.org/x/crypto/ssh"
 
 	"github.com/krazywarez/forge/internal/config"
 	"github.com/krazywarez/forge/internal/control"
+	"github.com/krazywarez/forge/internal/gitutil"
+	"github.com/krazywarez/forge/internal/hookd"
+	"github.com/krazywarez/forge/internal/policy"
 	"github.com/krazywarez/forge/internal/protocol"
 	"github.com/krazywarez/forge/internal/store"
 )
@@ -183,19 +185,17 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string)
 	}
 	_ = s.st.TouchSSHKey(keyID)
 
-	if name, _, ok := strings.Cut(cmdline, " "); ok || name != "" {
-		switch name {
-		case "git-upload-pack", "git-receive-pack", "git-upload-archive":
-			fmt.Fprintln(ch.Stderr(), "git transport not implemented (M2)")
-			return protocol.ExitFailure
-		}
-	}
-
 	argv, err := protocol.Tokenize(cmdline)
 	if err != nil {
 		fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err)
 		return protocol.ExitUsage
 	}
+	if len(argv) > 0 {
+		switch argv[0] {
+		case "git-upload-pack", "git-receive-pack", "git-upload-archive":
+			return s.runGit(ch, user, ext["scope"], argv)
+		}
+	}
 	ctx := &control.Ctx{
 		User:   user,
 		Scope:  ext["scope"],
@@ -207,3 +207,48 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string)
 	}
 	return control.Dispatch(ctx, argv)
 }
+
+// runGit streams a git transport service after access checks.
+func (s *Server) runGit(ch ssh.Channel, user store.User, scope string, argv []string) int {
+	service := argv[0]
+	if len(argv) != 2 {
+		fmt.Fprintf(ch.Stderr(), "usage: %s <path>\n", service)
+		return protocol.ExitUsage
+	}
+	write := service == "git-receive-pack"
+
+	repo, err := s.st.RepoByPath(argv[1])
+	if err != nil {
+		fmt.Fprintln(ch.Stderr(), "repository not found")
+		return protocol.ExitNotFound
+	}
+	grant, err := s.st.AccessRole(repo.ID, user.ID)
+	if err != nil {
+		fmt.Fprintln(ch.Stderr(), "internal error")
+		return protocol.ExitFailure
+	}
+	if !policy.CanRead(user, repo, grant) {
+		// Same answer as nonexistence: private repos must not be enumerable.
+		fmt.Fprintln(ch.Stderr(), "repository not found")
+		return protocol.ExitNotFound
+	}
+	if !policy.ScopeAllowsGit(scope, repo.Path(), write) {
+		fmt.Fprintf(ch.Stderr(), "this key's scope (%s) does not allow %s on %s\n", scope, service, repo.Path())
+		return protocol.ExitDenied
+	}
+	if write && !policy.CanWrite(user, repo, grant) {
+		fmt.Fprintf(ch.Stderr(), "write access to %s denied\n", repo.Path())
+		return protocol.ExitDenied
+	}
+
+	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
+	env := []string{
+		hookd.EnvSocket + "=" + hookd.SocketPath(s.cfg.Server.Root),
+		hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10),
+		hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10),
+	}
+	if err := gitutil.Transport(service, dir, ch, ch.Stderr(), env); err != nil {
+		return protocol.ExitFailure
+	}
+	return protocol.ExitOK
+}
diff --git a/internal/store/repos.go b/internal/store/repos.go
new file mode 100644
index 0000000..4a48a4b
--- /dev/null
+++ b/internal/store/repos.go
@@ -0,0 +1,192 @@
+package store
+
+import (
+	"database/sql"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+)
+
+type Repo struct {
+	ID            int64
+	OwnerKind     string // user | org
+	OwnerID       int64
+	OwnerName     string // resolved for display and disk paths
+	Name          string
+	Visibility    string // public | private
+	DefaultBranch string
+	Settings      RepoSettings
+}
+
+type RepoSettings struct {
+	ProtectedBranches    []string `json:"protected_branches,omitempty"`
+	RequireSignedCommits bool     `json:"require_signed_commits,omitempty"`
+}
+
+// Path returns the canonical owner/name form.
+func (r Repo) Path() string { return r.OwnerName + "/" + r.Name }
+
+func (s *Store) CreateRepo(ownerKind string, ownerID int64, name, visibility string) (int64, error) {
+	res, err := s.DB.Exec(
+		"INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES (?, ?, ?, ?)",
+		ownerKind, ownerID, name, visibility)
+	if err != nil {
+		if isUniqueErr(err) {
+			return 0, fmt.Errorf("repository %q already exists", name)
+		}
+		return 0, err
+	}
+	return res.LastInsertId()
+}
+
+// RepoByPath resolves "owner/name". Only user owners exist until orgs land.
+func (s *Store) RepoByPath(path string) (Repo, error) {
+	owner, name, ok := strings.Cut(strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".git"), "/")
+	if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
+		return Repo{}, fmt.Errorf("%w: repository path must be owner/name", ErrNotFound)
+	}
+	var r Repo
+	var settingsJSON string
+	err := s.DB.QueryRow(`
+		SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json
+		FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
+		WHERE u.username = ? AND r.name = ?`, owner, name).
+		Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON)
+	if errors.Is(err, sql.ErrNoRows) {
+		return Repo{}, ErrNotFound
+	}
+	if err != nil {
+		return Repo{}, err
+	}
+	if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
+		return Repo{}, fmt.Errorf("repo %d settings: %w", r.ID, err)
+	}
+	return r, nil
+}
+
+func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error {
+	raw, err := json.Marshal(settings)
+	if err != nil {
+		return err
+	}
+	_, err = s.DB.Exec("UPDATE repos SET settings_json = ? WHERE id = ?", string(raw), repoID)
+	return err
+}
+
+func (s *Store) DeleteRepo(repoID int64) error {
+	res, err := s.DB.Exec("DELETE FROM repos WHERE id = ?", repoID)
+	if err != nil {
+		return err
+	}
+	if n, _ := res.RowsAffected(); n == 0 {
+		return ErrNotFound
+	}
+	return nil
+}
+
+// ListReposForUser returns repos the user owns or has an explicit grant on.
+func (s *Store) ListReposForUser(userID int64) ([]Repo, error) {
+	rows, err := s.DB.Query(`
+		SELECT DISTINCT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json
+		FROM repos r
+		JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
+		LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?
+		WHERE r.owner_id = ? OR a.subject_id IS NOT NULL
+		ORDER BY u.username, r.name`, userID, userID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []Repo
+	for rows.Next() {
+		var r Repo
+		var settingsJSON string
+		if err := rows.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON); err != nil {
+			return nil, err
+		}
+		if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
+			return nil, err
+		}
+		out = append(out, r)
+	}
+	return out, rows.Err()
+}
+
+// AccessRole returns the explicit grant for userID on repoID ("" if none).
+func (s *Store) AccessRole(repoID, userID int64) (string, error) {
+	var role string
+	err := s.DB.QueryRow(
+		"SELECT role FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
+		repoID, userID).Scan(&role)
+	if errors.Is(err, sql.ErrNoRows) {
+		return "", nil
+	}
+	return role, err
+}
+
+func (s *Store) GrantAccess(repoID, userID int64, role string) error {
+	_, err := s.DB.Exec(`
+		INSERT INTO repo_access (repo_id, subject_kind, subject_id, role) VALUES (?, 'user', ?, ?)
+		ON CONFLICT (repo_id, subject_kind, subject_id) DO UPDATE SET role = excluded.role`,
+		repoID, userID, role)
+	return err
+}
+
+func (s *Store) RevokeAccess(repoID, userID int64) error {
+	res, err := s.DB.Exec(
+		"DELETE FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
+		repoID, userID)
+	if err != nil {
+		return err
+	}
+	if n, _ := res.RowsAffected(); n == 0 {
+		return ErrNotFound
+	}
+	return nil
+}
+
+type AccessEntry struct {
+	Username string
+	Role     string
+}
+
+func (s *Store) ListAccess(repoID int64) ([]AccessEntry, error) {
+	rows, err := s.DB.Query(`
+		SELECT u.username, a.role FROM repo_access a
+		JOIN users u ON a.subject_kind = 'user' AND u.id = a.subject_id
+		WHERE a.repo_id = ? ORDER BY u.username`, repoID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []AccessEntry
+	for rows.Next() {
+		var e AccessEntry
+		if err := rows.Scan(&e.Username, &e.Role); err != nil {
+			return nil, err
+		}
+		out = append(out, e)
+	}
+	return out, rows.Err()
+}
+
+func (s *Store) RepoByID(id int64) (Repo, error) {
+	var r Repo
+	var settingsJSON string
+	err := s.DB.QueryRow(`
+		SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json
+		FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
+		WHERE r.id = ?`, id).
+		Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON)
+	if errors.Is(err, sql.ErrNoRows) {
+		return Repo{}, ErrNotFound
+	}
+	if err != nil {
+		return Repo{}, err
+	}
+	if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
+		return Repo{}, err
+	}
+	return r, nil
+}