josie / simplegit

// 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
}