internal/gitutil/gitutil.go
263 lines · 9160 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// FetchPullHeads pulls a GitHub repository's pull-request heads into
161// refs/gh-pull/*, so an imported pull request has something to diff.
162// GitHub publishes every PR head at refs/pull/<n>/head on the git remote,
163// but a mirror made with the default refspecs does not carry them, which
164// is why an import used to produce merge requests with no head at all.
165//
166// One fetch for every pull request rather than one each: the ref count is
167// the repository's history, and asking a hundred times is a hundred
168// handshakes. extraEnv carries credentials via GIT_ASKPASS; the URL must
169// never contain them.
170func FetchPullHeads(ctx context.Context, dir, url string, errW io.Writer, extraEnv []string) error {
171 cmd := exec.CommandContext(ctx, "git", "-C", dir, "fetch", "--no-write-fetch-head", "--no-tags",
172 url, "+refs/pull/*/head:refs/gh-pull/*")
173 cmd.Env = append(os.Environ(), extraEnv...)
174 cmd.Stderr = errW
175 if err := cmd.Run(); err != nil {
176 return fmt.Errorf("fetch pull heads from %s: %w", url, err)
177 }
178 return nil
179}
180
181// RemoteDefaultBranch asks the remote which branch HEAD points at.
182func RemoteDefaultBranch(ctx context.Context, url string, extraEnv []string) (string, error) {
183 cmd := exec.CommandContext(ctx, "git", "ls-remote", "--symref", url, "HEAD")
184 cmd.Env = append(os.Environ(), extraEnv...)
185 out, err := cmd.Output()
186 if err != nil {
187 return "", fmt.Errorf("ls-remote %s: %w", url, err)
188 }
189 // "ref: refs/heads/<branch>\tHEAD"
190 for _, line := range strings.Split(string(out), "\n") {
191 if rest, ok := strings.CutPrefix(line, "ref: refs/heads/"); ok {
192 if branch, _, ok := strings.Cut(rest, "\t"); ok {
193 return branch, nil
194 }
195 }
196 }
197 return "", fmt.Errorf("remote %s did not advertise a default branch", url)
198}
199
200// SetHead points the bare repo's HEAD at a branch.
201func SetHead(dir, branch string) error {
202 cmd := exec.Command("git", "-C", dir, "symbolic-ref", "HEAD", "refs/heads/"+branch)
203 if out, err := cmd.CombinedOutput(); err != nil {
204 return fmt.Errorf("symbolic-ref: %v\n%s", err, out)
205 }
206 return nil
207}
208
209// gitDefaultDescription is the placeholder git init writes; treated as no
210// description at all.
211const gitDefaultDescription = "Unnamed repository; edit this file 'description' to name the repository."
212
213// ReadDescription returns the repo's description from the classic
214// <repo>.git/description file, empty for the git-init placeholder.
215func ReadDescription(dir string) string {
216 raw, err := os.ReadFile(filepath.Join(dir, "description"))
217 if err != nil {
218 return ""
219 }
220 desc := strings.TrimSpace(string(raw))
221 if desc == gitDefaultDescription {
222 return ""
223 }
224 return desc
225}
226
227// WriteDescription sets the description file: first line only, capped.
228func WriteDescription(dir, desc string) error {
229 desc, _, _ = strings.Cut(strings.TrimSpace(desc), "\n")
230 if len(desc) > 256 {
231 desc = desc[:256]
232 }
233 return os.WriteFile(filepath.Join(dir, "description"), []byte(desc+"\n"), 0o644)
234}
235
236// DirSize sums file sizes under dir; unreadable entries count as zero.
237func DirSize(dir string) int64 {
238 var total int64
239 filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
240 if err != nil || d.IsDir() {
241 return nil
242 }
243 if fi, err := d.Info(); err == nil {
244 total += fi.Size()
245 }
246 return nil
247 })
248 return total
249}
250
251// Every git this package runs prints paths as they are, not quoted with
252// octal escapes the way core.quotepath does by default, so a file called
253// übersicht.txt lists, greps, blames and diffs under its own name.
254// GIT_CONFIG_PARAMETERS reaches every subprocess, hooks included,
255// without touching each call site (#129).
256func init() {
257 const q = "'core.quotepath=off'"
258 if cur := os.Getenv("GIT_CONFIG_PARAMETERS"); cur != "" {
259 os.Setenv("GIT_CONFIG_PARAMETERS", cur+" "+q)
260 } else {
261 os.Setenv("GIT_CONFIG_PARAMETERS", q)
262 }
263}