internal/gitutil/gitutil.go

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

265 lines · 9380 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	// An empty hooksPath leaves the bare repo with no hooks — used for
 29	// companion repos (wikis) that carry no ref policy.
 30	if hooksPath != "" {
 31		cmd = exec.Command(toolpath.Look("git"), "-C", path, "config", "core.hooksPath", hooksPath)
 32		if out, err := cmd.CombinedOutput(); err != nil {
 33			return fmt.Errorf("git config core.hooksPath: %v\n%s", err, out)
 34		}
 35	}
 36	return nil
 37}
 38
 39// Transport streams one git transport service (upload-pack, receive-pack,
 40// upload-archive). extraEnv entries are appended to the process environment;
 41// hooks read the GITBAY_* variables from it. maxPack caps incoming pack
 42// bytes on receive-pack (0 = unlimited).
 43func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64) error {
 44	var args []string
 45	switch service {
 46	case "git-upload-pack", "git-receive-pack", "git-upload-archive":
 47		if service == "git-receive-pack" && maxPack > 0 {
 48			args = []string{"-c", fmt.Sprintf("receive.maxInputSize=%d", maxPack)}
 49		}
 50		args = append(args, strings.TrimPrefix(service, "git-"), repoPath)
 51	default:
 52		return fmt.Errorf("unknown service %q", service)
 53	}
 54	cmd := exec.Command(toolpath.Look("git"), args...)
 55	cmd.Env = append(os.Environ(), extraEnv...)
 56	cmd.Stdin = stdin
 57	cmd.Stdout = stdout
 58	cmd.Stderr = errW
 59	return cmd.Run()
 60}
 61
 62// IsAncestor reports whether old is an ancestor of new in the repository at
 63// dir. It must run with the caller's environment intact so that quarantined
 64// objects during pre-receive remain visible.
 65func IsAncestor(dir, old, new string) (bool, error) {
 66	cmd := exec.Command(toolpath.Look("git"), "-C", dir, "merge-base", "--is-ancestor", old, new)
 67	err := cmd.Run()
 68	if err == nil {
 69		return true, nil
 70	}
 71	if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
 72		return false, nil
 73	}
 74	return false, err
 75}
 76
 77// ZeroSHA reports whether s is an all-zero object id (SHA-1 or SHA-256).
 78func ZeroSHA(s string) bool {
 79	if len(s) != 40 && len(s) != 64 {
 80		return false
 81	}
 82	for i := 0; i < len(s); i++ {
 83		if s[i] != '0' {
 84			return false
 85		}
 86	}
 87	return true
 88}
 89
 90// RevList returns up to limit commit SHAs reachable from ref, newest first.
 91func RevList(dir, ref string, limit int) ([]string, error) {
 92	cmd := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", fmt.Sprintf("--max-count=%d", limit), "--end-of-options", ref)
 93	out, err := cmd.Output()
 94	if err != nil {
 95		return nil, fmt.Errorf("rev-list %s: %w", ref, err)
 96	}
 97	var shas []string
 98	for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
 99		if l != "" {
100			shas = append(shas, l)
101		}
102	}
103	return shas, nil
104}
105
106// RevListPath returns up to limit commit SHAs reachable from ref that
107// touch filePath, newest first. The "--" keeps the path from ever being
108// read as an option or ref.
109func RevListPath(dir, ref, filePath string, limit int) ([]string, error) {
110	cmd := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list",
111		fmt.Sprintf("--max-count=%d", limit), "--end-of-options", ref, "--", filePath)
112	out, err := cmd.Output()
113	if err != nil {
114		return nil, fmt.Errorf("rev-list %s -- %s: %w", ref, filePath, err)
115	}
116	var shas []string
117	for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
118		if l != "" {
119			shas = append(shas, l)
120		}
121	}
122	return shas, nil
123}
124
125// PeelToCommit resolves a ref or object to its commit — annotated tags
126// peel to the commit they point at.
127func PeelToCommit(dir, ref string) (string, error) {
128	out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-parse", "--verify", "--end-of-options", ref+"^{commit}").Output()
129	if err != nil {
130		return "", fmt.Errorf("rev-parse %s^{commit}: %w", ref, err)
131	}
132	return strings.TrimSpace(string(out)), nil
133}
134
135// ReadCommit returns the raw commit object bytes.
136func ReadCommit(dir, sha string) ([]byte, error) {
137	cmd := exec.Command(toolpath.Look("git"), "-C", dir, "cat-file", "commit", "--end-of-options", sha)
138	out, err := cmd.Output()
139	if err != nil {
140		return nil, fmt.Errorf("cat-file commit %s: %w", sha, err)
141	}
142	return out, nil
143}
144
145// FetchMirror pulls all branches, tags, and notes from a foreign URL into
146// the bare repository at dir, forcing updates. Progress streams to errW so
147// an interactive caller can watch. extraEnv carries credentials via
148// GIT_ASKPASS; the URL itself must never contain them.
149func FetchMirror(ctx context.Context, dir, url string, errW io.Writer, extraEnv []string) error {
150	cmd := exec.CommandContext(ctx, toolpath.Look("git"), "-C", dir, "fetch", "--progress", "--no-write-fetch-head", url,
151		"+refs/heads/*:refs/heads/*",
152		"+refs/tags/*:refs/tags/*",
153		"+refs/notes/*:refs/notes/*")
154	cmd.Env = append(os.Environ(), extraEnv...)
155	cmd.Stderr = errW
156	if err := cmd.Run(); err != nil {
157		return fmt.Errorf("fetch from %s: %w", url, err)
158	}
159	return nil
160}
161
162// FetchPullHeads pulls a GitHub repository's pull-request heads into
163// refs/gh-pull/*, so an imported pull request has something to diff.
164// GitHub publishes every PR head at refs/pull/<n>/head on the git remote,
165// but a mirror made with the default refspecs does not carry them, which
166// is why an import used to produce merge requests with no head at all.
167//
168// One fetch for every pull request rather than one each: the ref count is
169// the repository's history, and asking a hundred times is a hundred
170// handshakes. extraEnv carries credentials via GIT_ASKPASS; the URL must
171// never contain them.
172func FetchPullHeads(ctx context.Context, dir, url string, errW io.Writer, extraEnv []string) error {
173	cmd := exec.CommandContext(ctx, toolpath.Look("git"), "-C", dir, "fetch", "--no-write-fetch-head", "--no-tags",
174		url, "+refs/pull/*/head:refs/gh-pull/*")
175	cmd.Env = append(os.Environ(), extraEnv...)
176	cmd.Stderr = errW
177	if err := cmd.Run(); err != nil {
178		return fmt.Errorf("fetch pull heads from %s: %w", url, err)
179	}
180	return nil
181}
182
183// RemoteDefaultBranch asks the remote which branch HEAD points at.
184func RemoteDefaultBranch(ctx context.Context, url string, extraEnv []string) (string, error) {
185	cmd := exec.CommandContext(ctx, toolpath.Look("git"), "ls-remote", "--symref", url, "HEAD")
186	cmd.Env = append(os.Environ(), extraEnv...)
187	out, err := cmd.Output()
188	if err != nil {
189		return "", fmt.Errorf("ls-remote %s: %w", url, err)
190	}
191	// "ref: refs/heads/<branch>\tHEAD"
192	for _, line := range strings.Split(string(out), "\n") {
193		if rest, ok := strings.CutPrefix(line, "ref: refs/heads/"); ok {
194			if branch, _, ok := strings.Cut(rest, "\t"); ok {
195				return branch, nil
196			}
197		}
198	}
199	return "", fmt.Errorf("remote %s did not advertise a default branch", url)
200}
201
202// SetHead points the bare repo's HEAD at a branch.
203func SetHead(dir, branch string) error {
204	cmd := exec.Command(toolpath.Look("git"), "-C", dir, "symbolic-ref", "HEAD", "refs/heads/"+branch)
205	if out, err := cmd.CombinedOutput(); err != nil {
206		return fmt.Errorf("symbolic-ref: %v\n%s", err, out)
207	}
208	return nil
209}
210
211// gitDefaultDescription is the placeholder git init writes; treated as no
212// description at all.
213const gitDefaultDescription = "Unnamed repository; edit this file 'description' to name the repository."
214
215// ReadDescription returns the repo's description from the classic
216// <repo>.git/description file, empty for the git-init placeholder.
217func ReadDescription(dir string) string {
218	raw, err := os.ReadFile(filepath.Join(dir, "description"))
219	if err != nil {
220		return ""
221	}
222	desc := strings.TrimSpace(string(raw))
223	if desc == gitDefaultDescription {
224		return ""
225	}
226	return desc
227}
228
229// WriteDescription sets the description file: first line only, capped.
230func WriteDescription(dir, desc string) error {
231	desc, _, _ = strings.Cut(strings.TrimSpace(desc), "\n")
232	if len(desc) > 256 {
233		desc = desc[:256]
234	}
235	return os.WriteFile(filepath.Join(dir, "description"), []byte(desc+"\n"), 0o644)
236}
237
238// DirSize sums file sizes under dir; unreadable entries count as zero.
239func DirSize(dir string) int64 {
240	var total int64
241	filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
242		if err != nil || d.IsDir() {
243			return nil
244		}
245		if fi, err := d.Info(); err == nil {
246			total += fi.Size()
247		}
248		return nil
249	})
250	return total
251}
252
253// Every git this package runs prints paths as they are, not quoted with
254// octal escapes the way core.quotepath does by default, so a file called
255// übersicht.txt lists, greps, blames and diffs under its own name.
256// GIT_CONFIG_PARAMETERS reaches every subprocess, hooks included,
257// without touching each call site (#129).
258func init() {
259	const q = "'core.quotepath=off'"
260	if cur := os.Getenv("GIT_CONFIG_PARAMETERS"); cur != "" {
261		os.Setenv("GIT_CONFIG_PARAMETERS", cur+" "+q)
262	} else {
263		os.Setenv("GIT_CONFIG_PARAMETERS", q)
264	}
265}