// Package git is the only package allowed to exec the git binary.
// Every subprocess runs with an explicit, minimal environment so
// inherited GIT_* variables cannot redirect commands to another
// repository or inject git configuration.
package git
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// gitTimeout bounds every browse/merge subprocess; the CGI streaming path
// (backend.go) is exempt — pushes stream bodies of arbitrary duration.
const gitTimeout = 2 * time.Minute
// gitCommand builds a git subprocess rooted at repoPath ("" to inherit
// the process working directory) with a minimal, explicit environment.
// It is bounded by gitTimeout; the context's cancel releases the deadline
// timer when it fires and doubles as the lostcancel silencer.
func gitCommand(repoPath string, args ...string) *exec.Cmd {
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Cancel = func() error {
err := cmd.Process.Kill()
cancel()
return err
}
cmd.Dir = repoPath
cmd.Env = []string{
"PATH=" + os.Getenv("PATH"),
"LANG=C",
"LC_ALL=C",
}
return cmd
}
// runBounded runs cmd and returns at most limit+1 bytes of its stdout.
// When the output exceeds limit the subprocess is killed early and
// oversized is true, so a huge object or patch never lands fully in
// memory; callers decide what the cap means. Any stderr buffer must be
// attached to cmd before the call; wait errors surface unformatted.
func runBounded(cmd *exec.Cmd, limit int) (out []byte, oversized bool, err error) {
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, false, err
}
if err := cmd.Start(); err != nil {
return nil, false, err
}
var buf bytes.Buffer
n, readErr := io.CopyN(&buf, stdout, int64(limit)+1)
if n > int64(limit) {
stdout.Close()
_ = cmd.Process.Kill()
_ = cmd.Wait()
return buf.Bytes(), true, nil
}
if readErr != nil && !errors.Is(readErr, io.EOF) {
_ = cmd.Wait()
return nil, false, readErr
}
if err := cmd.Wait(); err != nil {
return nil, false, err
}
return buf.Bytes(), false, nil
}
// InitBare creates a bare repository at path with HEAD pointing at branch,
// then installs the post-receive hook that calls back into this binary.
func InitBare(path, branch string) error {
cmd := gitCommand("", "init", "--bare", "--initial-branch="+branch, path)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git init --bare %s: %w: %s", path, err, strings.TrimSpace(string(out)))
}
return installPostReceive(path)
}
// installPostReceive writes hooks/post-receive so a push updates repo
// metadata. Git runs hooks without our pinned environment, so the callback
// binary comes from SIMPLEGIT_BIN, which ServeBackend sets explicitly (it
// is absent for out-of-band pushes, where the hook is a no-op). The repo
// path is derived from the hook's own location, so a rename keeps working.
func installPostReceive(repoPath string) error {
absRepo, err := filepath.Abs(repoPath)
if err != nil {
return fmt.Errorf("resolve repo path: %w", err)
}
script := "#!/bin/sh\n" +
"# installed by simplegit; records the push in the metadata DB.\n" +
`[ -n "$SIMPLEGIT_BIN" ] || exit 0` + "\n" +
`repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)` + "\n" +
`exec "$SIMPLEGIT_BIN" hook --repo "$repo"` + "\n"
hookPath := filepath.Join(absRepo, "hooks", "post-receive")
if err := os.WriteFile(hookPath, []byte(script), 0o755); err != nil {
return fmt.Errorf("write post-receive hook: %w", err)
}
if err := os.Chmod(hookPath, 0o755); err != nil {
return fmt.Errorf("chmod post-receive hook: %w", err)
}
return nil
}
// DefaultBranch returns the branch HEAD points at (e.g. "main").
func DefaultBranch(repoPath string) (string, error) {
cmd := gitCommand(repoPath, "symbolic-ref", "--short", "HEAD")
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("git symbolic-ref HEAD in %s: %w: %s", repoPath, err, strings.TrimSpace(stderr.String()))
}
return strings.TrimSpace(string(out)), nil
}
// SetDefaultBranch points HEAD at refs/heads/branch.
func SetDefaultBranch(repoPath, branch string) error {
cmd := gitCommand(repoPath, "symbolic-ref", "HEAD", "refs/heads/"+branch)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git symbolic-ref HEAD %s: %w: %s", branch, err, strings.TrimSpace(string(out)))
}
return nil
}