// Package repo turns an owner and a name into a bare repository, owned by the URL. Chapter 11. package repo import ( "context" "errors" "fmt" "os" "path/filepath" "strconv" "strings" "time" "github.com/barerepo/server/internal/gitx" ) // Dir resolves a path under root, validating the names and the result, because either alone has failed. func Dir(root, owner, name string) (string, error) { // The trash is a sibling of the accounts under root, so an account named trash would own repositories Walk skips and the window erases. 44.4. if !gitx.ValidName(owner) || !gitx.ValidRepoName(name) || owner == TrashDir { return "", fmt.Errorf("no such repository") } absRoot, err := filepath.Abs(root) if err != nil { return "", err } dir := filepath.Join(absRoot, owner, name+".git") if !strings.HasPrefix(dir, absRoot+string(os.PathSeparator)) { return "", fmt.Errorf("no such repository") } return dir, nil } // Exists reports whether the repository is on disk. func Exists(root, owner, name string) bool { dir, err := Dir(root, owner, name) if err != nil { return false } st, err := os.Stat(filepath.Join(dir, "HEAD")) return err == nil && st.Mode().IsRegular() } // Create makes the bare repository and the hooks, and no commit, because empty means empty. func Create(ctx context.Context, root, owner, name, defaultBranch, serverBin string) (string, error) { dir, err := Dir(root, owner, name) if err != nil { return "", err } if Exists(root, owner, name) { return "", errors.New("that repository already exists") } if defaultBranch == "" { defaultBranch = "master" } if !gitx.ValidRef("refs/heads/" + defaultBranch) { return "", fmt.Errorf("%q is not a usable branch name", defaultBranch) } if err := os.MkdirAll(filepath.Dir(dir), 0o750); err != nil { return "", err } // An explicit initial branch, because git's default is a local setting to not inherit. if _, err := gitx.Run(ctx, "", "init", "--bare", "--initial-branch", defaultBranch, "--", dir); err != nil { os.RemoveAll(dir) return "", err } if _, err := gitx.Run(ctx, dir, "symbolic-ref", "HEAD", "refs/heads/"+defaultBranch); err != nil { os.RemoveAll(dir) return "", err } if err := InstallHooks(dir, serverBin); err != nil { os.RemoveAll(dir) return "", err } return dir, nil } // hooks are two lines each, so upgrading the binary upgrades every repository at once. var hooks = []string{"pre-receive", "post-receive", "proc-receive"} // InstallHooks replaces the scripts and deletes the samples, so the directory holds what runs. func InstallHooks(dir, serverBin string) error { hookDir := filepath.Join(dir, "hooks") if err := os.MkdirAll(hookDir, 0o750); err != nil { return err } entries, err := os.ReadDir(hookDir) if err != nil { return err } for _, e := range entries { if strings.HasSuffix(e.Name(), ".sample") || e.Name() == "update" { os.Remove(filepath.Join(hookDir, e.Name())) } } if serverBin == "" { serverBin, err = os.Executable() if err != nil { return err } } for _, h := range hooks { body := fmt.Sprintf("#!/bin/sh\nexec %s hook %s\n", serverBin, h) path := filepath.Join(hookDir, h) tmp := path + ".tmp" if err := os.WriteFile(tmp, []byte(body), 0o750); err != nil { return err } if err := os.Rename(tmp, path); err != nil { return err } } // A prefix, not a glob, and replaced not added, or reinstalling stacks copies of it. if _, err := gitx.Run(context.Background(), dir, "config", "--replace-all", "receive.procReceiveRefs", "refs/proposals"); err != nil { return err } return nil } // HeadBranch reads HEAD every time, because assuming master or main is wrong for half the world. func HeadBranch(ctx context.Context, dir string) (string, error) { // HEAD is a file, and every repository page needs this, so do not spend a process on it. if name, err := gitx.HeadBranch(dir); err == nil { return name, nil } out, err := gitx.Run(ctx, dir, "symbolic-ref", "--short", "HEAD") if err != nil { return "", err } return strings.TrimSpace(out), nil } // IsEmpty reports whether the repository has no refs yet. func IsEmpty(ctx context.Context, dir string) bool { return !gitx.AnyRef(dir) } // Move relocates the directory and rewrites nothing inside it, because that is not a transfer. 44.3. func Move(root, oldOwner, oldName, newOwner, newName string) error { from, err := Dir(root, oldOwner, oldName) if err != nil { return err } to, err := Dir(root, newOwner, newName) if err != nil { return err } if _, err := os.Stat(to); err == nil { return fmt.Errorf("%s/%s already exists on disk", newOwner, newName) } if err := os.MkdirAll(filepath.Dir(to), 0o750); err != nil { return err } return os.Rename(from, to) } // TrashDir is where a delete waits, because barerepo has no channel to answer a support request. 44.4. const TrashDir = "trash" // Trash moves a repository out of service, keeping it for the window. func Trash(root, owner, name string, at time.Time) (string, error) { from, err := Dir(root, owner, name) if err != nil { return "", err } absRoot, err := filepath.Abs(root) if err != nil { return "", err } to := filepath.Join(absRoot, TrashDir, fmt.Sprintf("%d-%s-%s.git", at.Unix(), owner, name)) if err := os.MkdirAll(filepath.Dir(to), 0o750); err != nil { return "", err } return to, os.Rename(from, to) } // EmptyTrash erases what is past the window, which chapter 44.4 puts at 30 days. func EmptyTrash(root string, olderThan time.Duration, now time.Time) (int, error) { dir := filepath.Join(root, TrashDir) entries, err := os.ReadDir(dir) if err != nil { return 0, nil } cutoff := now.Add(-olderThan).Unix() erased := 0 for _, e := range entries { stamp, _, ok := strings.Cut(e.Name(), "-") if !ok { continue } at, err := strconv.ParseInt(stamp, 10, 64) if err != nil || at >= cutoff { continue } if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil { return erased, err } erased++ } return erased, nil } // InTrash reports a name still in its window, where a push fails rather than creates. Appendix D. func InTrash(root, owner, name string) bool { entries, err := os.ReadDir(filepath.Join(root, TrashDir)) if err != nil { return false } // A hyphen is a name character, so a suffix match reads evil/my-bot-x as bot/x and holds a name nobody deleted, and only the stamp varies. want := owner + "-" + name + ".git" for _, e := range entries { if _, rest, ok := strings.Cut(e.Name(), "-"); ok && rest == want { return true } } return false } // Copy is copying, with no relationship afterwards, and it is fast because objects are shared. 21.1. func Copy(ctx context.Context, root, srcOwner, srcName, dstOwner, dstName, serverBin string) (string, error) { src, err := Dir(root, srcOwner, srcName) if err != nil { return "", err } if !Exists(root, srcOwner, srcName) { return "", fmt.Errorf("%s/%s does not exist", srcOwner, srcName) } dst, err := Dir(root, dstOwner, dstName) if err != nil { return "", err } if Exists(root, dstOwner, dstName) { return "", fmt.Errorf("%s/%s already exists", dstOwner, dstName) } if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil { return "", err } // --shared points at the source's objects through alternates, which is what makes this instant. if _, err := gitx.Run(ctx, "", "clone", "--bare", "--shared", "--quiet", "--", src, dst); err != nil { os.RemoveAll(dst) return "", err } // A clone brings heads and tags, so ask for notes and the counter, and never for proposals. if _, err := gitx.Run(ctx, dst, "fetch", "--quiet", src, "refs/notes/*:refs/notes/*", "refs/meta/*:refs/meta/*"); err != nil { os.RemoveAll(dst) return "", err } for _, ref := range []string{"refs/proposals/", "refs/revisions/"} { out, err := gitx.Run(ctx, dst, "for-each-ref", "--format=%(refname)", ref) if err != nil { continue } for _, name := range strings.Split(strings.TrimSpace(out), "\n") { if name != "" { _, _ = gitx.Run(ctx, dst, "update-ref", "-d", name) } } } if err := InstallHooks(dst, serverBin); err != nil { os.RemoveAll(dst) return "", err } return dst, nil } // Detach un-borrows every object, so deleting a source does not take its copies' history. func Detach(ctx context.Context, dir string) error { alternates := filepath.Join(dir, "objects", "info", "alternates") if _, err := os.Stat(alternates); err != nil { return nil // borrows nothing } // Repack with -a to write every borrowed object here, then drop the alternates file. if _, err := gitx.Run(ctx, dir, "repack", "-a", "-d", "--quiet"); err != nil { return err } return os.Remove(alternates) } // Dependents lists repositories that borrow objects from dir. func Dependents(root, dir string) []string { var out []string owners, err := os.ReadDir(root) if err != nil { return nil } for _, owner := range owners { if !owner.IsDir() || owner.Name() == TrashDir { continue } repos, err := os.ReadDir(filepath.Join(root, owner.Name())) if err != nil { continue } for _, r := range repos { candidate := filepath.Join(root, owner.Name(), r.Name()) if candidate == dir { continue } body, err := os.ReadFile(filepath.Join(candidate, "objects", "info", "alternates")) if err != nil { continue } if strings.Contains(string(body), dir) { out = append(out, candidate) } } } return out } // Walk visits every served repository, skipping the trash, where a delete waits out its window. func Walk(root string, fn func(owner, name, dir string) error) error { return filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { if err != nil || !d.IsDir() { return err } if d.Name() == TrashDir { return filepath.SkipDir } if !strings.HasSuffix(p, ".git") { return nil } owner := filepath.Base(filepath.Dir(p)) name := strings.TrimSuffix(filepath.Base(p), ".git") if err := fn(owner, name, p); err != nil { return err } return filepath.SkipDir }) } // Collect runs git's own maintenance, which chapter 26 puts on a schedule and never on a push. func Collect(ctx context.Context, dir string, afterDeletion bool) error { args := []string{"gc", "--quiet"} if !afterDeletion { // --auto costs one process and returns at once unless git thinks there is work. args = append(args, "--auto") } _, err := gitx.Run(ctx, dir, args...) return err }