// SPDX-License-Identifier: AGPL-3.0-or-later package gitcli import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "io/fs" "os" "os/exec" "path/filepath" "strings" "time" "github.com/bnema/zerowrap" "golang.org/x/sys/unix" "bnema.dev/ermite/internal/domain" "bnema.dev/ermite/internal/ports" ) var _ ports.GitRepos = (*Git)(nil) const gitSuffix = ".git" func (g *Git) reposDir() string { return filepath.Join(g.dataDir, "repos") } func (g *Git) stagingDir() string { return filepath.Join(g.reposDir(), ".staging") } func (g *Git) trashDir() string { return filepath.Join(g.dataDir, "trash") } // Stage runs `git init --bare` in a new directory under repos/.staging/ and // sets the repository defaults. The initial branch is main. func (g *Git) Stage(ctx context.Context) (ports.StagedID, error) { var raw [16]byte if _, err := rand.Read(raw[:]); err != nil { return "", fmt.Errorf("random staging name: %w", err) } id := ports.StagedID(hex.EncodeToString(raw[:])) dir := filepath.Join(g.stagingDir(), string(id)) _, err := g.run(ctx, "init", "--quiet", "--bare", "--object-format=sha1", "--ref-format=files", "--initial-branch=main", dir) if err == nil { // changedPathsVersion=2: the Bloom filters of the commit-graph hash // like v2, which has no signed/unsigned char ambiguity (readers on // other platforms agree with the writer). for _, kv := range [][2]string{{"repack.writeBitmaps", "true"}, {"core.commitGraph", "true"}, {"commitGraph.changedPathsVersion", "2"}} { if _, err = g.run(ctx, "-C", dir, "config", "--local", kv[0], kv[1]); err != nil { break } } } if err != nil { if rerr := os.RemoveAll(dir); rerr != nil { log := zerowrap.FromCtx(ctx) log.Error().Err(rerr).Str("dir", dir).Msg("remove failed staging directory") } return "", fmt.Errorf("stage repository: %w", err) } log := zerowrap.FromCtx(ctx) log.Debug().Str("staged", string(id)).Msg("repository staged") return id, nil } // Publish renames the staged repository to repos/.git without // replacing anything. func (g *Git) Publish(ctx context.Context, id ports.StagedID, name domain.RepoName) error { src, err := g.stagedPath(id) if err != nil { return err } dst := filepath.Join(g.reposDir(), name.String()+gitSuffix) if err := renameNoReplace(src, dst); err != nil { if errors.Is(err, fs.ErrExist) { return domain.ErrAlreadyExists } return fmt.Errorf("publish repository %q: %w", name, err) } log := zerowrap.FromCtx(ctx) // The rename succeeded: the repository exists under its final name. A // failed sync only weakens crash durability; returning an error here // would make the caller roll back the metadata of a published directory. if err := syncDir(g.reposDir()); err != nil { log.Warn().Err(err).Str("repo", name.String()).Msg("sync repositories directory after publish") } log.Debug().Str("repo", name.String()).Msg("repository published") return nil } // Discard removes a staged repository. func (g *Git) Discard(_ context.Context, id ports.StagedID) error { dir, err := g.stagedPath(id) if err != nil { return err } if err := os.RemoveAll(dir); err != nil { return fmt.Errorf("discard staged repository %s: %w", id, err) } return nil } // PurgeStaging removes everything under repos/.staging/. // //hexcheck:ignore suspicious-business-logic-in-adapter port method name only func (g *Git) PurgeStaging(ctx context.Context) error { entries, err := os.ReadDir(g.stagingDir()) if err != nil { if errors.Is(err, fs.ErrNotExist) { return nil } return fmt.Errorf("read staging directory: %w", err) } for _, e := range entries { if err := os.RemoveAll(filepath.Join(g.stagingDir(), e.Name())); err != nil { return fmt.Errorf("remove staging leftover %s: %w", e.Name(), err) } log := zerowrap.FromCtx(ctx) log.Info().Str("entry", e.Name()).Msg("removed staging leftover") } return nil } // List returns the repository directories. Entries that are not // ".git" directories are reported as invalid; .staging is not an // entry. func (g *Git) List(_ context.Context) (ports.RepoDirs, error) { entries, err := os.ReadDir(g.reposDir()) if err != nil { return ports.RepoDirs{}, fmt.Errorf("read repositories directory: %w", err) } var out ports.RepoDirs for _, e := range entries { if e.Name() == ".staging" { continue } base, ok := strings.CutSuffix(e.Name(), gitSuffix) if !ok || !e.IsDir() { out.Invalid = append(out.Invalid, e.Name()) continue } name, err := domain.ParseRepoName(base) if err != nil { out.Invalid = append(out.Invalid, e.Name()) continue } out.Names = append(out.Names, name) } return out, nil } // Trash moves repos/.git to trash/-.git. func (g *Git) Trash(ctx context.Context, name domain.RepoName) error { src := filepath.Join(g.reposDir(), name.String()+gitSuffix) if _, err := os.Lstat(src); err != nil { if errors.Is(err, fs.ErrNotExist) { return domain.ErrNotFound } return fmt.Errorf("trash repository %q: %w", name, err) } stamp := time.Now().Unix() for i := range 100 { dst := filepath.Join(g.trashDir(), fmt.Sprintf("%s-%d%s", name, stamp+int64(i), gitSuffix)) err := renameNoReplace(src, dst) if errors.Is(err, fs.ErrExist) { continue } if err != nil { return fmt.Errorf("trash repository %q: %w", name, err) } // A cat-file process of the old directory must not serve a repository // created later under the same name. This runs after the rename, not // before: a rename that fails leaves the repository where it was, with // its process still serving it, and a process dropped first would only // be started again by the next read. Reads between the rename and the // forget find no directory (repoDir fails first), and the pool is keyed // by directory, so the stale process cannot answer for a new repository // before it is dropped here. g.cat.forget(src) if fn := g.onTrash.Load(); fn != nil { (*fn)(src) } if err := syncDir(g.reposDir()); err != nil { return fmt.Errorf("trash repository %q: %w", name, err) } log := zerowrap.FromCtx(ctx) log.Info().Str("repo", name.String()).Str("trash", filepath.Base(dst)).Msg("repository moved to trash") return nil } return fmt.Errorf("trash repository %q: no free name in trash", name) } // stagedPath returns the directory of a staged repository, rejecting // identifiers that are not the hex names Stage produces. func (g *Git) stagedPath(id ports.StagedID) (string, error) { if _, err := hex.DecodeString(string(id)); err != nil || len(id) != 32 { return "", fmt.Errorf("invalid staged repository id %q", id) } return filepath.Join(g.stagingDir(), string(id)), nil } // renameNoReplace renames src to dst and fails with fs.ErrExist if dst // exists, even as an empty directory. Filesystems without RENAME_NOREPLACE // fall back to a check before the rename. func renameNoReplace(src, dst string) error { err := unix.Renameat2(unix.AT_FDCWD, src, unix.AT_FDCWD, dst, unix.RENAME_NOREPLACE) switch { case err == nil: return nil case errors.Is(err, unix.EEXIST): return fs.ErrExist case errors.Is(err, unix.EINVAL), errors.Is(err, unix.ENOSYS), errors.Is(err, unix.ENOTSUP): if _, serr := os.Lstat(dst); serr == nil { return fs.ErrExist } return os.Rename(src, dst) } return &os.LinkError{Op: "rename", Old: src, New: dst, Err: err} } // syncDir flushes a directory so a rename in it survives a crash. func syncDir(dir string) error { f, err := os.Open(dir) if err != nil { return err } defer func() { _ = f.Close() }() return f.Sync() } // RepoDir returns the directory of the repository, repos/.git below the // data directory. It does not check that it exists. func RepoDir(dataDir string, name domain.RepoName) string { return filepath.Join(dataDir, "repos", name.String()+gitSuffix) } // OnTrash registers fn, which Trash calls with the directory the repository // had, after it moved it away. Another reader of the repository (the // ermitegit adapter) uses it to drop what it holds open. Call it once while // wiring, before the adapter serves. func (g *Git) OnTrash(fn func(dir string)) { g.onTrash.Store(&fn) } // repoDir returns repos/.git, or domain.ErrNotFound if it is missing. func (g *Git) repoDir(name domain.RepoName) (string, error) { dir := RepoDir(g.dataDir, name) fi, err := os.Stat(dir) if err != nil { if errors.Is(err, fs.ErrNotExist) { return "", domain.ErrNotFound } return "", fmt.Errorf("repository %q: %w", name, err) } if !fi.IsDir() { return "", domain.ErrNotFound } return dir, nil } // DefaultBranch reads HEAD and checks the branch with `git for-each-ref`. func (g *Git) DefaultBranch(ctx context.Context, name domain.RepoName) (string, bool, error) { dir, err := g.repoDir(name) if err != nil { return "", false, err } ref, err := readHead(dir) if err != nil { return "", false, fmt.Errorf("read HEAD of %q: %w", name, err) } branch, ok := strings.CutPrefix(ref, headsRefPrefix) if !ok { return "", false, fmt.Errorf("HEAD of %q points outside refs/heads: %q", name, ref) } out, err := g.run(ctx, "-C", dir, "for-each-ref", "--count=1", "--format=%(refname)", ref) if err != nil { return "", false, fmt.Errorf("look up %s in %q: %w", ref, name, err) } return branch, strings.TrimSpace(out) == ref, nil } // HasBranch checks refs/heads/ with `git for-each-ref` after // `git check-ref-format`, which rejects the glob characters for-each-ref // would otherwise interpret. // //hexcheck:ignore suspicious-business-logic-in-adapter port method name only func (g *Git) HasBranch(ctx context.Context, name domain.RepoName, branch string) (bool, error) { dir, err := g.repoDir(name) if err != nil { return false, err } ref := headsRefPrefix + branch if branch == "" || strings.HasPrefix(branch, "-") { return false, nil } if _, err := g.run(ctx, "check-ref-format", ref); err != nil { var exit *exec.ExitError if errors.As(err, &exit) { return false, nil } return false, err } out, err := g.run(ctx, "-C", dir, "for-each-ref", "--format=%(refname)", ref) if err != nil { return false, fmt.Errorf("look up %s in %q: %w", ref, name, err) } return strings.TrimSpace(out) == ref, nil } // HeadBranch reads the HEAD file of the bare repository (files ref backend). // It is empty when HEAD is detached. func (g *Git) HeadBranch(_ context.Context, name domain.RepoName) (string, error) { dir, err := g.repoDir(name) if err != nil { return "", err } ref, err := readHead(dir) if err != nil { return "", fmt.Errorf("read HEAD of %q: %w", name, err) } return strings.TrimPrefix(ref, headsRefPrefix), nil } // SetDefaultBranch runs `git symbolic-ref HEAD refs/heads/`. func (g *Git) SetDefaultBranch(ctx context.Context, name domain.RepoName, branch string) error { dir, err := g.repoDir(name) if err != nil { return err } if branch == "" || strings.HasPrefix(branch, "-") { return fmt.Errorf("invalid branch name %q", branch) } if _, err := g.run(ctx, "check-ref-format", "--branch", branch); err != nil { return fmt.Errorf("invalid branch name %q: %w", branch, err) } if _, err := g.run(ctx, "-C", dir, "symbolic-ref", "HEAD", headsRefPrefix+branch); err != nil { return fmt.Errorf("set HEAD of %q: %w", name, err) } return nil } // Maintain runs `git commit-graph write --reachable --changed-paths --split`. func (g *Git) Maintain(ctx context.Context, name domain.RepoName) error { dir, err := g.repoDir(name) if err != nil { return err } if _, err := g.run(ctx, "-C", dir, "commit-graph", "write", "--reachable", "--changed-paths", "--split"); err != nil { return fmt.Errorf("maintain %q: %w", name, err) } return nil }