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