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