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