// Package gitx is the git access layer of compare.sr.ht. It opens bare
// repositories on disk with go-git and answers ref, log, and diff queries
// entirely in-process (no git binary is executed at runtime). Every operation
// is bounded by a context timeout, generated patch text is capped in size (so
// a pathological diff cannot be streamed unbounded to a browser), and every
// user-controlled revision is validated with core.ValidRef before it reaches
// go-git's revision parser.
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/object"
"sourcecraft.dev/bigbes/sr-ht-compare/core"
)
const (
// defaultTimeout bounds a single gitx operation.
defaultTimeout = 10 * time.Second
// pageDiffLimit caps an in-page rendered diff; overflow sets Truncated.
pageDiffLimit = 5 << 20 // 5 MiB
// rawDiffLimit caps a downloadable .patch.
rawDiffLimit = 50 << 20 // 50 MiB
// shortSHALen is how many hex chars an abbreviated SHA carries.
shortSHALen = 8
)
// diffOpts are the go-git tree-diff options used everywhere: rename detection
// on, matching git's default behaviour.
var diffOpts = object.DefaultDiffTreeOptions
// Repo is a handle to a bare git repository on disk. It is safe for concurrent
// use: go-git's object reads are read-only and the struct holds no mutable
// per-request state.
type Repo struct {
dir string
repo *git.Repository
// limitOverride, when > 0, replaces the diff-family byte cap. It exists so
// tests can exercise truncation with a tiny cap instead of huge fixtures.
limitOverride int64
// timeout overrides defaultTimeout when > 0 (tests may shorten it).
timeout time.Duration
}
// Open validates owner/name, resolves the bare-repo path under reposRoot,
// verifies it looks like a bare repository (its HEAD file exists), and opens it
// with go-git. Invalid names and missing/broken repositories all yield
// core.ErrNotFound, so repository existence is never leaked and no crafted name
// can escape reposRoot: the validators reject '/', '..' and a leading '-'
// before any path is built.
func Open(reposRoot, owner, name string) (*Repo, error) {
if !core.ValidOwner(owner) {
return nil, fmt.Errorf("%w: invalid owner", core.ErrNotFound)
}
if !core.ValidRepoName(name) {
return nil, fmt.Errorf("%w: invalid repo name", core.ErrNotFound)
}
dir := filepath.Join(reposRoot, "~"+owner, name)
// Cheap bare-repo sanity check before handing the path to go-git.
if fi, err := os.Stat(filepath.Join(dir, "HEAD")); err != nil || fi.IsDir() {
return nil, fmt.Errorf("%w: ~%s/%s", core.ErrNotFound, owner, name)
}
repo, err := git.PlainOpen(dir)
if err != nil {
return nil, fmt.Errorf("%w: ~%s/%s: %v", core.ErrNotFound, owner, name, err)
}
return &Repo{dir: dir, repo: repo}, nil
}
// Dir returns the on-disk path of the bare repository.
func (r *Repo) Dir() string { return r.dir }
// withTimeout derives a per-operation timeout context.
func (r *Repo) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
d := r.timeout
if d <= 0 {
d = defaultTimeout
}
return context.WithTimeout(ctx, d)
}
// diffLimit returns the effective byte cap for a diff-family command, honoring
// a test override.
func (r *Repo) diffLimit(def int64) int64 {
if r.limitOverride > 0 {
return r.limitOverride
}
return def
}
// badRef wraps a go-git revision-resolution failure as core.ErrBadRef.
func badRef(rev string, err error) error {
return fmt.Errorf("%w: %q: %v", core.ErrBadRef, rev, err)
}
// commitInfo projects a go-git commit into the package's CommitInfo.
func commitInfo(c *object.Commit) *CommitInfo {
subject, body := splitMessage(c.Message)
sha := c.Hash.String()
short := sha
if len(short) > shortSHALen {
short = short[:shortSHALen]
}
ci := &CommitInfo{
SHA: sha,
ShortSHA: short,
AuthorName: c.Author.Name,
AuthorEmail: c.Author.Email,
Date: c.Author.When,
Subject: subject,
Body: body,
}
for _, p := range c.ParentHashes {
ci.ParentSHAs = append(ci.ParentSHAs, p.String())
}
return ci
}
// splitMessage splits a commit message into its subject (first line) and body
// (the remainder, with the separating blank line removed).
func splitMessage(msg string) (subject, body string) {
msg = strings.TrimRight(msg, "\n")
if i := strings.IndexByte(msg, '\n'); i >= 0 {
return msg[:i], strings.TrimLeft(msg[i+1:], "\n")
}
return msg, ""
}
// cutPatch enforces the size cap on generated patch text. go-git materializes
// the whole patch in memory, so the cap is applied after generation. To keep
// the browser-side parser from ever seeing a torn hunk, the text is cut at a
// file boundary: the last "\ndiff --git " that starts at or before the limit.
// If a single leading file already exceeds the limit there is no earlier
// boundary and the text is cut hard at the limit.
func cutPatch(text string, limit int64) (string, bool) {
if limit <= 0 || int64(len(text)) <= limit {
return text, false
}
head := text[:limit]
// A unified-diff file header is the only place "diff --git" starts a line;
// content lines always carry a +/-/space prefix after the newline. Cutting
// before the last such header keeps only whole files.
if idx := strings.LastIndex(head, "\ndiff --git"); idx >= 0 {
return text[:idx+1], true
}
return head, true
}