internal/gitutil/gitutil.go

349 lines · 13440 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	"io/fs"
 10	"os"
 11	"os/exec"
 12	"path/filepath"
 13	"strings"
 14
 15	"gitbay.org/gitbay/internal/toolpath"
 16)
 17
 18// InitBare creates a bare repository with the shared hooks directory wired
 19// via core.hooksPath.
 20func InitBare(path, defaultBranch, hooksPath string) error {
 21	if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
 22		return err
 23	}
 24	cmd := exec.Command(toolpath.Look("git"), "init", "--bare", "--initial-branch="+defaultBranch, path)
 25	if out, err := cmd.CombinedOutput(); err != nil {
 26		return fmt.Errorf("git init: %v\n%s", err, out)
 27	}
 28	cmd = exec.Command(toolpath.Look("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	return nil
 33}
 34
 35// Transport streams one git transport service (upload-pack, receive-pack,
 36// upload-archive). extraEnv entries are appended to the process environment;
 37// hooks read the GITBAY_* variables from it. maxPack caps incoming pack
 38// bytes on receive-pack (0 = unlimited), and keepAlive sets its
 39// receive.keepAlive in seconds (0 = git's default). Closing cancel kills the service
 40// and everything it started; a push killed before its pre-receive hook
 41// answers updates no refs. A nil cancel never fires.
 42func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64, keepAlive int, cancel <-chan struct{}) error {
 43	var args []string
 44	switch service {
 45	case "git-upload-pack", "git-receive-pack", "git-upload-archive":
 46		if service == "git-receive-pack" && maxPack > 0 {
 47			args = []string{"-c", fmt.Sprintf("receive.maxInputSize=%d", maxPack)}
 48		}
 49		if service == "git-receive-pack" && keepAlive > 0 {
 50			// Seconds of silence after which receive-pack sends a
 51			// keepalive while it indexes the pack and runs hooks.
 52			args = append(args, "-c", fmt.Sprintf("receive.keepAlive=%d", keepAlive))
 53		}
 54		if service == "git-upload-pack" {
 55			// Keepalives while pack-objects is still counting keep a
 56			// healthy clone writing; a limited transport kills one that goes quiet.
 57			args = []string{"-c", "uploadpack.keepAlive=5"}
 58		}
 59		args = append(args, strings.TrimPrefix(service, "git-"), repoPath)
 60	default:
 61		return fmt.Errorf("unknown service %q", service)
 62	}
 63	cmd := exec.Command(toolpath.Look("git"), args...)
 64	cmd.Env = append(os.Environ(), extraEnv...)
 65	cmd.Stdin = stdin
 66	cmd.Stdout = stdout
 67	cmd.Stderr = errW
 68	return RunUntil(cmd, cancel)
 69}
 70
 71// RunUntil runs cmd in its own process group. Closing cancel kills the
 72// group; RunUntil returns only once cmd has been waited for. A nil
 73// cancel never fires.
 74func RunUntil(cmd *exec.Cmd, cancel <-chan struct{}) error {
 75	ownProcessGroup(cmd)
 76	if err := cmd.Start(); err != nil {
 77		return err
 78	}
 79	finished := make(chan struct{})
 80	go func() {
 81		select {
 82		case <-cancel:
 83			killTree(cmd)
 84		case <-finished:
 85		}
 86	}()
 87	err := cmd.Wait()
 88	close(finished)
 89	return err
 90}
 91
 92// IsAncestor reports whether old is an ancestor of new in the repository at
 93// dir. It must run with the caller's environment intact so that quarantined
 94// objects during pre-receive remain visible.
 95func IsAncestor(dir, old, new string) (bool, error) {
 96	cmd := exec.Command(toolpath.Look("git"), "-C", dir, "merge-base", "--is-ancestor", old, new)
 97	err := cmd.Run()
 98	if err == nil {
 99		return true, nil
100	}
101	if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
102		return false, nil
103	}
104	return false, err
105}
106
107// Reachable reports whether sha is reachable from some ref in the
108// repository at dir — the condition a clone must find true to have any
109// chance of checking it out. That includes refs outside refs/heads and
110// refs/tags: a merge request head fetched from a fork lives at
111// refs/merge-requests/<n>/head, and is exactly as fetchable as a branch
112// tip, so it must count as reachable too — a first pass of this function
113// restricted the check to branches and tags and read every fork MR's
114// queued build as unreachable, cancelling it. false comes from two
115// shapes, and Reachable does not need to tell them apart: the object is
116// already gone (pruned), or it is still in the object store but nothing
117// points at it any more (a force-push moved the branch, gc has not run
118// yet). Either way the answer is the same: no ref reaches it.
119//
120// A non-nil error means the check itself did not run to a clean answer —
121// missing repository, git failing for its own reasons — and false is
122// meaningless in that case. The caller must not read err as "unreachable":
123// that would cancel a build the check never actually looked at.
124func Reachable(dir, sha string) (bool, error) {
125	if _, err := os.Stat(dir); err != nil {
126		return false, fmt.Errorf("reachable %s: %w", sha, err)
127	}
128	// cat-file -e <object>, unpeeled, is git's own existence predicate with
129	// a documented exit code: 1 means the object is not there, the same
130	// contract IsAncestor above already trusts from merge-base
131	// --is-ancestor. Peeling to ^{commit} breaks that contract: a missing
132	// object then exits 128, indistinguishable from "not a git repository"
133	// or a corrupted one — the ambiguous case that must never read as
134	// "unreachable" and cancel a build the check never actually looked at.
135	err := exec.Command(toolpath.Look("git"), "-C", dir, "cat-file", "-e", "--end-of-options", sha).Run()
136	if err != nil {
137		if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
138			return false, nil
139		}
140		return false, fmt.Errorf("cat-file -e %s: %w", sha, err)
141	}
142	// The object exists; --contains lists every ref whose history includes
143	// it, with no namespace restriction — a branch, a tag, or a
144	// refs/merge-requests/<n>/head are equally "a ref reaches this", and
145	// that is the actual question, not whether it happens to be a branch
146	// or a tag. Empty output with no error is the force-push case: present
147	// in the object store, reachable from nothing.
148	out, err := exec.Command(toolpath.Look("git"), "-C", dir, "for-each-ref",
149		"--count=1", "--format=x", "--contains="+sha).Output()
150	if err != nil {
151		return false, fmt.Errorf("for-each-ref --contains %s: %w", sha, err)
152	}
153	return len(out) > 0, nil
154}
155
156// ZeroSHA reports whether s is an all-zero object id (SHA-1 or SHA-256).
157func ZeroSHA(s string) bool {
158	if len(s) != 40 && len(s) != 64 {
159		return false
160	}
161	for i := 0; i < len(s); i++ {
162		if s[i] != '0' {
163			return false
164		}
165	}
166	return true
167}
168
169// RevList returns up to limit commit SHAs reachable from ref, newest first.
170func RevList(dir, ref string, limit int) ([]string, error) {
171	cmd := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", fmt.Sprintf("--max-count=%d", limit), "--end-of-options", ref)
172	out, err := cmd.Output()
173	if err != nil {
174		return nil, fmt.Errorf("rev-list %s: %w", ref, err)
175	}
176	var shas []string
177	for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
178		if l != "" {
179			shas = append(shas, l)
180		}
181	}
182	return shas, nil
183}
184
185// RevListPath returns up to limit commit SHAs reachable from ref that
186// touch filePath, newest first. The "--" keeps the path from ever being
187// read as an option or ref.
188func RevListPath(dir, ref, filePath string, limit int) ([]string, error) {
189	cmd := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list",
190		fmt.Sprintf("--max-count=%d", limit), "--end-of-options", ref, "--", filePath)
191	out, err := cmd.Output()
192	if err != nil {
193		return nil, fmt.Errorf("rev-list %s -- %s: %w", ref, filePath, err)
194	}
195	var shas []string
196	for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
197		if l != "" {
198			shas = append(shas, l)
199		}
200	}
201	return shas, nil
202}
203
204// PeelToCommit resolves a ref or object to its commit — annotated tags
205// peel to the commit they point at.
206func PeelToCommit(dir, ref string) (string, error) {
207	out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-parse", "--verify", "--end-of-options", ref+"^{commit}").Output()
208	if err != nil {
209		return "", fmt.Errorf("rev-parse %s^{commit}: %w", ref, err)
210	}
211	return strings.TrimSpace(string(out)), nil
212}
213
214// ReadCommit returns the raw commit object bytes.
215func ReadCommit(dir, sha string) ([]byte, error) {
216	cmd := exec.Command(toolpath.Look("git"), "-C", dir, "cat-file", "commit", "--end-of-options", sha)
217	out, err := cmd.Output()
218	if err != nil {
219		return nil, fmt.Errorf("cat-file commit %s: %w", sha, err)
220	}
221	return out, nil
222}
223
224// FetchMirror pulls all branches, tags, and notes from a foreign URL into
225// the bare repository at dir, forcing updates. Progress streams to errW so
226// an interactive caller can watch. pin is git's leading -c options
227// (gitpin.Remote.Args); env is git's whole environment and carries
228// credentials via GIT_ASKPASS: the URL itself must never contain them.
229func FetchMirror(ctx context.Context, dir, url string, errW io.Writer, pin, env []string) error {
230	args := append(append([]string{}, pin...), "-C", dir, "fetch", "--progress", "--no-write-fetch-head", url,
231		"+refs/heads/*:refs/heads/*",
232		"+refs/tags/*:refs/tags/*",
233		"+refs/notes/*:refs/notes/*")
234	cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...)
235	cmd.Env = env
236	cmd.Stderr = errW
237	if err := cmd.Run(); err != nil {
238		return fmt.Errorf("fetch from %s: %w", url, err)
239	}
240	return nil
241}
242
243// FetchPullHeads pulls a GitHub repository's pull-request heads into
244// refs/gh-pull/*, so an imported pull request has something to diff.
245// GitHub publishes every PR head at refs/pull/<n>/head on the git remote,
246// but a mirror made with the default refspecs does not carry them, which
247// is why an import used to produce merge requests with no head at all.
248//
249// One fetch for every pull request rather than one each: the ref count is
250// the repository's history, and asking a hundred times is a hundred
251// handshakes. pin and env are as for FetchMirror; env carries
252// credentials via GIT_ASKPASS, and the URL must never contain them.
253func FetchPullHeads(ctx context.Context, dir, url string, errW io.Writer, pin, env []string) error {
254	args := append(append([]string{}, pin...), "-C", dir, "fetch", "--no-write-fetch-head", "--no-tags",
255		url, "+refs/pull/*/head:refs/gh-pull/*")
256	cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...)
257	cmd.Env = env
258	cmd.Stderr = errW
259	if err := cmd.Run(); err != nil {
260		return fmt.Errorf("fetch pull heads from %s: %w", url, err)
261	}
262	return nil
263}
264
265// RemoteDefaultBranch asks the remote which branch HEAD points at,
266// running in the repository at dir. pin and env are as for FetchMirror.
267func RemoteDefaultBranch(ctx context.Context, dir, url string, pin, env []string) (string, error) {
268	args := append(append([]string{}, pin...), "-C", dir, "ls-remote", "--symref", url, "HEAD")
269	cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...)
270	cmd.Env = env
271	out, err := cmd.Output()
272	if err != nil {
273		return "", fmt.Errorf("ls-remote %s: %w", url, err)
274	}
275	// "ref: refs/heads/<branch>\tHEAD"
276	for _, line := range strings.Split(string(out), "\n") {
277		if rest, ok := strings.CutPrefix(line, "ref: refs/heads/"); ok {
278			if branch, _, ok := strings.Cut(rest, "\t"); ok {
279				return branch, nil
280			}
281		}
282	}
283	return "", fmt.Errorf("remote %s did not advertise a default branch", url)
284}
285
286// SetHead points the bare repo's HEAD at a branch.
287func SetHead(dir, branch string) error {
288	cmd := exec.Command(toolpath.Look("git"), "-C", dir, "symbolic-ref", "HEAD", "refs/heads/"+branch)
289	if out, err := cmd.CombinedOutput(); err != nil {
290		return fmt.Errorf("symbolic-ref: %v\n%s", err, out)
291	}
292	return nil
293}
294
295// gitDefaultDescription is the placeholder git init writes; treated as no
296// description at all.
297const gitDefaultDescription = "Unnamed repository; edit this file 'description' to name the repository."
298
299// ReadDescription returns the repo's description from the classic
300// <repo>.git/description file, empty for the git-init placeholder.
301func ReadDescription(dir string) string {
302	raw, err := os.ReadFile(filepath.Join(dir, "description"))
303	if err != nil {
304		return ""
305	}
306	desc := strings.TrimSpace(string(raw))
307	if desc == gitDefaultDescription {
308		return ""
309	}
310	return desc
311}
312
313// WriteDescription sets the description file: first line only, capped.
314func WriteDescription(dir, desc string) error {
315	desc, _, _ = strings.Cut(strings.TrimSpace(desc), "\n")
316	if len(desc) > 256 {
317		desc = desc[:256]
318	}
319	return os.WriteFile(filepath.Join(dir, "description"), []byte(desc+"\n"), 0o644)
320}
321
322// DirSize sums file sizes under dir; unreadable entries count as zero.
323func DirSize(dir string) int64 {
324	var total int64
325	filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
326		if err != nil || d.IsDir() {
327			return nil
328		}
329		if fi, err := d.Info(); err == nil {
330			total += fi.Size()
331		}
332		return nil
333	})
334	return total
335}
336
337// Every git this package runs prints paths as they are, not quoted with
338// octal escapes the way core.quotepath does by default, so a file called
339// übersicht.txt lists, greps, blames and diffs under its own name.
340// GIT_CONFIG_PARAMETERS reaches every subprocess, hooks included,
341// without touching each call site (#129).
342func init() {
343	const q = "'core.quotepath=off'"
344	if cur := os.Getenv("GIT_CONFIG_PARAMETERS"); cur != "" {
345		os.Setenv("GIT_CONFIG_PARAMETERS", cur+" "+q)
346	} else {
347		os.Setenv("GIT_CONFIG_PARAMETERS", q)
348	}
349}