internal/gitutil/gitutil.go

94884a22329740d40876a987689c17ab77aa9965
gitbay/internal/gitutil/gitutil.go history · blame · raw

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