// Package gitx is spec.sr.ht's git layer: the bare-repo lifecycle of a space,
// the only read path there is, the proposal write path, the tree-splice merge,
// and the refs rule the receive hooks enforce.
//
// # One storage tier
//
// There is no checkout on disk. Every read resolves a tree and reads blobs, so
// the approved head, a pinned ?rev=<sha> and a proposal branch are the same
// code path with a different revision. Nothing downstream of this package ever
// touches the filesystem to find a document.
//
// # No text merge, ever
//
// go-git v5 implements only FastForwardMerge, and the whole-document write
// grain makes a three-way merge unnecessary anyway. Merge is pure plumbing:
// object.Tree manipulation plus a commit with two explicit parents. A conflict
// is always "your base moved, re-propose" (*StaleError), never a conflict
// marker. Merge never calls Repository.Merge.
//
// # Staleness is keyed by document id, not path
//
// Paths move; ids do not. Every changed document is resolved to its path on the
// approved head through its frontmatter id before its blob is compared, so a
// rename between the base and the head neither invents a conflict nor
// resurrects a document that was moved.
//
// # Two writers, one repo
//
// Human pushes arrive through native receive-pack (spawned by sshd) and agent
// proposals through this package in-process: two independent ref-locking
// implementations over the same loose refs. go-git's locking is not verified to
// interoperate with native git's, so every write here takes a per-space mutex
// (process-wide, keyed by the repository's directory) and every ref move is a
// compare-and-swap that retries when it loses.
//
// # Bounded by construction
//
// Every operation derives a timeout from the caller's context, every blob read
// is capped, and every tree walk is capped in both bytes and entries. Nothing
// is truncated to fit: a partially read document would be indexed and served as
// though it were whole, so an over-budget read fails with ErrTooLarge instead.
package gitx
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
const (
// DefaultApprovedBranch is the branch a space is created with when the
// caller does not name one. It is the branch the human pushes to and the
// branch every default read resolves against; "approved" is a property of
// being reachable from it, and of nothing else.
DefaultApprovedBranch = "main"
// defaultTimeout bounds a single gitx operation when the caller's context
// carries no earlier deadline.
defaultTimeout = 30 * time.Second
// maxDocumentSize caps one document blob. Documents are markdown written
// for a human to review; anything past this is either an attachment in the
// wrong place or a runaway agent.
maxDocumentSize = 4 << 20 // 4 MiB
// maxWalkBytes caps the total blob bytes one tree walk may materialize, so
// a single read cannot pull an entire space into memory.
maxWalkBytes = 128 << 20 // 128 MiB
// maxTreeEntries caps how many entries one walk may visit, bounding the
// cost of a pathological tree independently of its byte size.
maxTreeEntries = 100000
// maxTreeDepth caps directory nesting. Deeper than this is not a document
// layout, it is a way to blow the stack.
maxTreeDepth = 64
// casAttempts is how many times a ref compare-and-swap is retried after
// losing to a concurrent writer before giving up with ErrRefRace.
casAttempts = 5
// maxRevLen caps a revision string before it reaches go-git's parser.
maxRevLen = 255
)
// Repo is a handle to one space: a bare git repository at
// "<root>/~<owner>/<name>".
//
// Reads are safe for concurrent use. Writes serialize on a process-wide
// per-space lock keyed by the repository directory, so two Repo values opened
// over the same space still exclude each other.
type Repo struct {
dir string
ref core.SpaceRef
repo *git.Repository
approved string // short branch name, read from HEAD
// Test-only overrides; zero means "use the package constant". They exist so
// truncation and retry paths can be exercised with small fixtures instead
// of pathological ones.
timeout time.Duration
docLimit int64
walkLimit int64
entryLimit int
attemptLimit int
// beforeCAS, when set, runs immediately before each ref compare-and-swap.
// It is the only way to open the window a concurrent native receive-pack
// push would land in, which is the one thing about the retry path that
// cannot be tested from the outside.
beforeCAS func()
}
// raceHook fires the test-only pre-compare-and-swap hook.
func (r *Repo) raceHook() {
if r.beforeCAS != nil {
r.beforeCAS()
}
}
// DiskPath returns the bare repository directory for a space,
// "<root>/~<owner>/<name>". The space reference must already be validated;
// Create and Open do that before they build a path.
func DiskPath(root string, sr core.SpaceRef) string {
return filepath.Join(root, "~"+sr.Owner, sr.Name)
}
// CreateOptions configures Create.
type CreateOptions struct {
// ApprovedBranch names the branch the human pushes to. Empty means
// DefaultApprovedBranch.
ApprovedBranch string
// Owner is the identity on the initial commit, in practice the instance's
// [sr.ht] owner-name/owner-email. It is required: inventing a committer
// would put a fabricated identity in a history whose whole purpose is
// provenance.
Owner Signature
// Message is the initial commit's subject. Empty means a generated
// "Initialize space ~owner/name".
Message string
}
// Create initialises a new space: a bare repository at DiskPath(root, sr) whose
// HEAD points at the approved branch, carrying one empty initial commit.
//
// The initial commit is deliberately not skipped. It makes the approved head
// resolvable from the moment the space exists, so no reader, reconciler or
// merge has to special-case an unborn branch, and it costs the human nothing:
// they clone and push on top of it rather than pushing an unrelated history.
//
// root must be absolute — the per-space write lock is keyed by directory, and
// two spellings of the same directory would be two locks. Create refuses to
// touch an existing directory (ErrExists), and removes what it made if it fails
// partway, so a failed create never leaves a half-built space behind.
func Create(ctx context.Context, root string, sr core.SpaceRef, opts CreateOptions) (_ *Repo, err error) {
if !filepath.IsAbs(root) {
return nil, fmt.Errorf("gitx: Create requires an absolute repos root, got %q", root)
}
if err := validateSpaceRef(sr); err != nil {
return nil, err
}
branch := opts.ApprovedBranch
if branch == "" {
branch = DefaultApprovedBranch
}
if err := ValidateBranch(branch); err != nil {
return nil, err
}
if err := opts.Owner.validate("owner"); err != nil {
return nil, err
}
message := opts.Message
if message == "" {
message = "Initialize space " + sr.String()
}
dir := filepath.Clean(DiskPath(root, sr))
if _, statErr := os.Stat(dir); statErr == nil {
return nil, fmt.Errorf("%w: space %s at %q", ErrExists, sr, dir)
} else if !os.IsNotExist(statErr) {
return nil, fmt.Errorf("gitx: stat %q: %w", dir, statErr)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("gitx: create space dir %q: %w", dir, err)
}
// Nothing past this point may leave a partially built repository behind.
defer func() {
if err != nil {
os.RemoveAll(dir)
}
}()
head := plumbing.NewBranchReferenceName(branch)
repo, err := git.PlainInitWithOptions(dir, &git.PlainInitOptions{
Bare: true,
InitOptions: git.InitOptions{DefaultBranch: head},
})
if err != nil {
return nil, fmt.Errorf("gitx: init bare repo at %q: %w", dir, err)
}
r := &Repo{dir: dir, ref: sr, repo: repo, approved: branch}
unlock, err := r.lock(ctx)
if err != nil {
return nil, err
}
defer unlock()
emptyTree, err := (&mutableTree{}).write(repo.Storer)
if err != nil {
return nil, fmt.Errorf("gitx: write empty tree for %s: %w", sr, err)
}
commit, err := r.writeCommit(CommitMeta{
Message: message,
Author: opts.Owner,
Committer: opts.Owner,
}, emptyTree, nil)
if err != nil {
return nil, err
}
if err := repo.Storer.SetReference(plumbing.NewHashReference(head, commit)); err != nil {
return nil, fmt.Errorf("gitx: set %s for %s: %w", head, sr, err)
}
return r, nil
}
// Open opens an existing space. Invalid names, a missing directory and a
// directory that is not a bare repository all yield ErrNotFound, so existence
// is never leaked and no crafted name escapes root: core's validators reject
// '/', '..' and a leading '-' before any path is built.
//
// The approved branch is read from HEAD rather than configured separately —
// there is exactly one place a space can record it, so there is nothing to keep
// in sync. A detached HEAD is corruption in a space repository and is refused.
func Open(root string, sr core.SpaceRef) (*Repo, error) {
if !filepath.IsAbs(root) {
return nil, fmt.Errorf("gitx: Open requires an absolute repos root, got %q", root)
}
if err := validateSpaceRef(sr); err != nil {
return nil, fmt.Errorf("%w: %v", ErrNotFound, err)
}
dir := filepath.Clean(DiskPath(root, sr))
// Cheap bare-repo sanity check before the path reaches go-git.
if fi, err := os.Stat(filepath.Join(dir, "HEAD")); err != nil || fi.IsDir() {
return nil, fmt.Errorf("%w: space %s", ErrNotFound, sr)
}
repo, err := git.PlainOpen(dir)
if err != nil {
return nil, fmt.Errorf("%w: space %s: %v", ErrNotFound, sr, err)
}
headRef, err := repo.Reference(plumbing.HEAD, false)
if err != nil {
return nil, fmt.Errorf("%w: space %s has no HEAD: %v", ErrNotFound, sr, err)
}
if headRef.Type() != plumbing.SymbolicReference {
return nil, fmt.Errorf("gitx: space %s has a detached HEAD; the approved branch is unknowable", sr)
}
branch := headRef.Target().Short()
if err := ValidateBranch(branch); err != nil {
return nil, fmt.Errorf("gitx: space %s HEAD points at an unusable branch: %w", sr, err)
}
return &Repo{dir: dir, ref: sr, repo: repo, approved: branch}, nil
}
// Dir returns the bare repository's directory.
func (r *Repo) Dir() string { return r.dir }
// SpaceRef returns the space this handle addresses.
func (r *Repo) SpaceRef() core.SpaceRef { return r.ref }
// ApprovedBranch returns the short name of the branch HEAD points at. A
// document is approved exactly when it is reachable from this branch.
func (r *Repo) ApprovedBranch() string { return r.approved }
// withTimeout derives a per-operation deadline. A caller-supplied deadline that
// is already earlier wins, since context.WithTimeout never extends.
func (r *Repo) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
d := r.timeout
if d <= 0 {
d = defaultTimeout
}
return context.WithTimeout(ctx, d)
}
func (r *Repo) blobLimit() int64 {
if r.docLimit > 0 {
return r.docLimit
}
return maxDocumentSize
}
func (r *Repo) totalLimit() int64 {
if r.walkLimit > 0 {
return r.walkLimit
}
return maxWalkBytes
}
func (r *Repo) entryCap() int {
if r.entryLimit > 0 {
return r.entryLimit
}
return maxTreeEntries
}
func (r *Repo) casBudget() int {
if r.attemptLimit > 0 {
return r.attemptLimit
}
return casAttempts
}
// validateSpaceRef checks both halves of a space reference with core's rules.
func validateSpaceRef(sr core.SpaceRef) error {
if err := core.ValidateOwner(sr.Owner); err != nil {
return err
}
return core.ValidateSpaceName(sr.Name)
}
// Signature is a git identity plus the moment it acted.
//
// When is required rather than defaulted to time.Now: a commit whose timestamp
// this package invented would be a fact about the service pretending to be a
// fact about the author, and deterministic timestamps are what make provenance
// testable.
type Signature struct {
Name string
Email string
When time.Time
}
// validate rejects identities git cannot round-trip. Angle brackets and
// newlines would terminate or forge the ident line in the commit object, which
// is how a provenance record comes to say something nobody wrote.
func (s Signature) validate(role string) error {
if strings.TrimSpace(s.Name) == "" {
return fmt.Errorf("gitx: %s name is required", role)
}
if strings.TrimSpace(s.Email) == "" {
return fmt.Errorf("gitx: %s email is required", role)
}
if s.When.IsZero() {
return fmt.Errorf("gitx: %s timestamp is required", role)
}
for _, field := range []struct{ what, val string }{{"name", s.Name}, {"email", s.Email}} {
if strings.ContainsAny(field.val, "<>\n\r\x00") {
return fmt.Errorf("gitx: %s %s %q contains a disallowed character", role, field.what, field.val)
}
}
return nil
}
func (s Signature) toGit() object.Signature {
return object.Signature{Name: s.Name, Email: s.Email, When: s.When}
}