internal/gitutil/gitutil.go

ac6714f569b1b7f1cf59ffa97b99c505ae2d6698
gitbay/internal/gitutil/gitutil.go history · blame · raw

183 lines · 6140 bytes

  1// Package gitutil wraps the system git binary. All repository access goes
  2// through git subprocesses; there is no in-process git implementation.
  3package gitutil
  4
  5import (
  6	"context"
  7	"fmt"
  8	"io"
  9	"os"
 10	"os/exec"
 11	"path/filepath"
 12	"strings"
 13)
 14
 15// InitBare creates a bare repository with the shared hooks directory wired
 16// via core.hooksPath.
 17func InitBare(path, defaultBranch, hooksPath string) error {
 18	if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
 19		return err
 20	}
 21	cmd := exec.Command("git", "init", "--bare", "--initial-branch="+defaultBranch, path)
 22	if out, err := cmd.CombinedOutput(); err != nil {
 23		return fmt.Errorf("git init: %v\n%s", err, out)
 24	}
 25	// An empty hooksPath leaves the bare repo with no hooks — used for
 26	// companion repos (wikis) that carry no ref policy.
 27	if hooksPath != "" {
 28		cmd = exec.Command("git", "-C", path, "config", "core.hooksPath", hooksPath)
 29		if out, err := cmd.CombinedOutput(); err != nil {
 30			return fmt.Errorf("git config core.hooksPath: %v\n%s", err, out)
 31		}
 32	}
 33	return nil
 34}
 35
 36// Transport streams one git transport service (upload-pack, receive-pack,
 37// upload-archive). extraEnv entries are appended to the process environment;
 38// hooks read the GITBAY_* variables from it. maxPack caps incoming pack
 39// bytes on receive-pack (0 = unlimited).
 40func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64) error {
 41	var args []string
 42	switch service {
 43	case "git-upload-pack", "git-receive-pack", "git-upload-archive":
 44		if service == "git-receive-pack" && maxPack > 0 {
 45			args = []string{"-c", fmt.Sprintf("receive.maxInputSize=%d", maxPack)}
 46		}
 47		args = append(args, strings.TrimPrefix(service, "git-"), repoPath)
 48	default:
 49		return fmt.Errorf("unknown service %q", service)
 50	}
 51	cmd := exec.Command("git", args...)
 52	cmd.Env = append(os.Environ(), extraEnv...)
 53	cmd.Stdin = stdin
 54	cmd.Stdout = stdout
 55	cmd.Stderr = errW
 56	return cmd.Run()
 57}
 58
 59// IsAncestor reports whether old is an ancestor of new in the repository at
 60// dir. It must run with the caller's environment intact so that quarantined
 61// objects during pre-receive remain visible.
 62func IsAncestor(dir, old, new string) (bool, error) {
 63	cmd := exec.Command("git", "-C", dir, "merge-base", "--is-ancestor", old, new)
 64	err := cmd.Run()
 65	if err == nil {
 66		return true, nil
 67	}
 68	if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
 69		return false, nil
 70	}
 71	return false, err
 72}
 73
 74// ZeroSHA reports whether s is an all-zero object id (SHA-1 or SHA-256).
 75func ZeroSHA(s string) bool {
 76	if len(s) != 40 && len(s) != 64 {
 77		return false
 78	}
 79	for i := 0; i < len(s); i++ {
 80		if s[i] != '0' {
 81			return false
 82		}
 83	}
 84	return true
 85}
 86
 87// RevList returns up to limit commit SHAs reachable from ref, newest first.
 88func RevList(dir, ref string, limit int) ([]string, error) {
 89	cmd := exec.Command("git", "-C", dir, "rev-list", fmt.Sprintf("--max-count=%d", limit), ref)
 90	out, err := cmd.Output()
 91	if err != nil {
 92		return nil, fmt.Errorf("rev-list %s: %w", ref, err)
 93	}
 94	var shas []string
 95	for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
 96		if l != "" {
 97			shas = append(shas, l)
 98		}
 99	}
100	return shas, nil
101}
102
103// ReadCommit returns the raw commit object bytes.
104func ReadCommit(dir, sha string) ([]byte, error) {
105	cmd := exec.Command("git", "-C", dir, "cat-file", "commit", sha)
106	out, err := cmd.Output()
107	if err != nil {
108		return nil, fmt.Errorf("cat-file commit %s: %w", sha, err)
109	}
110	return out, nil
111}
112
113// FetchMirror pulls all branches, tags, and notes from a foreign URL into
114// the bare repository at dir, forcing updates. Progress streams to errW so
115// an interactive caller can watch. extraEnv carries credentials via
116// GIT_ASKPASS; the URL itself must never contain them.
117func FetchMirror(ctx context.Context, dir, url string, errW io.Writer, extraEnv []string) error {
118	cmd := exec.CommandContext(ctx, "git", "-C", dir, "fetch", "--progress", "--no-write-fetch-head", url,
119		"+refs/heads/*:refs/heads/*",
120		"+refs/tags/*:refs/tags/*",
121		"+refs/notes/*:refs/notes/*")
122	cmd.Env = append(os.Environ(), extraEnv...)
123	cmd.Stderr = errW
124	if err := cmd.Run(); err != nil {
125		return fmt.Errorf("fetch from %s: %w", url, err)
126	}
127	return nil
128}
129
130// RemoteDefaultBranch asks the remote which branch HEAD points at.
131func RemoteDefaultBranch(ctx context.Context, url string, extraEnv []string) (string, error) {
132	cmd := exec.CommandContext(ctx, "git", "ls-remote", "--symref", url, "HEAD")
133	cmd.Env = append(os.Environ(), extraEnv...)
134	out, err := cmd.Output()
135	if err != nil {
136		return "", fmt.Errorf("ls-remote %s: %w", url, err)
137	}
138	// "ref: refs/heads/<branch>\tHEAD"
139	for _, line := range strings.Split(string(out), "\n") {
140		if rest, ok := strings.CutPrefix(line, "ref: refs/heads/"); ok {
141			if branch, _, ok := strings.Cut(rest, "\t"); ok {
142				return branch, nil
143			}
144		}
145	}
146	return "", fmt.Errorf("remote %s did not advertise a default branch", url)
147}
148
149// SetHead points the bare repo's HEAD at a branch.
150func SetHead(dir, branch string) error {
151	cmd := exec.Command("git", "-C", dir, "symbolic-ref", "HEAD", "refs/heads/"+branch)
152	if out, err := cmd.CombinedOutput(); err != nil {
153		return fmt.Errorf("symbolic-ref: %v\n%s", err, out)
154	}
155	return nil
156}
157
158// gitDefaultDescription is the placeholder git init writes; treated as no
159// description at all.
160const gitDefaultDescription = "Unnamed repository; edit this file 'description' to name the repository."
161
162// ReadDescription returns the repo's description from the classic
163// <repo>.git/description file, empty for the git-init placeholder.
164func ReadDescription(dir string) string {
165	raw, err := os.ReadFile(filepath.Join(dir, "description"))
166	if err != nil {
167		return ""
168	}
169	desc := strings.TrimSpace(string(raw))
170	if desc == gitDefaultDescription {
171		return ""
172	}
173	return desc
174}
175
176// WriteDescription sets the description file: first line only, capped.
177func WriteDescription(dir, desc string) error {
178	desc, _, _ = strings.Cut(strings.TrimSpace(desc), "\n")
179	if len(desc) > 256 {
180		desc = desc[:256]
181	}
182	return os.WriteFile(filepath.Join(dir, "description"), []byte(desc+"\n"), 0o644)
183}