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