Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

internal/gitx/git.go

3a50036d96eb14462e96598fd169d1c79264ac4b · 12.6 KB · 455 lines raw

1 // Package gitx is the only place that knows how git works.
2 // Everything above it sees plain Go values. Swap this for go-git or
3 // git2go later without touching a single module or template.
4 package gitx
5
6 import (
7 "bytes"
8 "context"
9 "fmt"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "regexp"
14 "strconv"
15 "strings"
16 "time"
17 )
18
19 // Separators. exec() argv cannot contain NUL bytes, so the FORMAT strings
20 // use git's own hex escapes and git emits the real bytes; we split on those.
21 // (for-each-ref spells them %00, git-log spells them %x00.)
22 const (
23 sepF = "\x00" // field separator, as it appears in git's OUTPUT
24 sepR = "\x1e" // record separator, as it appears in git's OUTPUT
25
26 escF = "%00" // field separator, as written in a for-each-ref format
27 escLogF = "%x00" // field separator, as written in a git-log format
28 escLogR = "%x1e" // record separator, as written in a git-log format
29 )
30
31 type Repo struct {
32 Dir string // path to the git dir (bare repo, or <worktree>/.git)
33 Slug string // url-safe short name, derived from the directory
34 empty bool // no commits yet (unborn HEAD)
35
36 ctx context.Context
37 maxBytes int64
38 }
39
40 // WithContext returns a view of the repo bound to a request context and an
41 // output cap. Serving means every git invocation is attacker-influenced
42 // work: without a deadline, one `git log` on a huge repo pins a core, and
43 // without a cap one diff of a 200MB blob eats the heap.
44 func (r *Repo) WithContext(ctx context.Context, maxBytes int64) *Repo {
45 c := *r
46 c.ctx = ctx
47 c.maxBytes = maxBytes
48 return &c
49 }
50
51 // Empty reports whether the repository has no commits. This is a normal
52 // state for a freshly created bare repo, not an error -- Log and Tree
53 // return nothing rather than failing, so a view still renders.
54 func (r *Repo) Empty() bool { return r.empty }
55
56 func Open(dir string) (*Repo, error) {
57 gitDir := dir
58 if st, err := os.Stat(filepath.Join(dir, ".git")); err == nil && st.IsDir() {
59 gitDir = filepath.Join(dir, ".git")
60 }
61 r := &Repo{
62 Dir: gitDir,
63 Slug: strings.TrimSuffix(filepath.Base(strings.TrimSuffix(dir, "/")), ".git"),
64 }
65 if _, err := r.run("rev-parse", "--git-dir"); err != nil {
66 return nil, fmt.Errorf("open %s: %w", dir, err)
67 }
68 _, err := r.run("rev-parse", "--verify", "--quiet", "HEAD")
69 r.empty = err != nil
70 return r, nil
71 }
72
73 // ErrTruncated is returned alongside partial output that hit maxBytes.
74 var ErrTruncated = fmt.Errorf("output truncated")
75
76 func (r *Repo) run(args ...string) ([]byte, error) {
77 ctx := r.ctx
78 if ctx == nil {
79 ctx = context.Background()
80 }
81 full := append([]string{"--git-dir", r.Dir}, args...)
82 cmd := exec.CommandContext(ctx, "git", full...)
83 // Refuse to inherit anything that alters git's behaviour.
84 cmd.Env = []string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "GIT_TERMINAL_PROMPT=0", "PATH=" + os.Getenv("PATH")}
85
86 var out, errb bytes.Buffer
87 cmd.Stdout, cmd.Stderr = &out, &errb
88 err := cmd.Run()
89
90 if r.maxBytes > 0 && int64(out.Len()) > r.maxBytes {
91 return out.Bytes()[:r.maxBytes], ErrTruncated
92 }
93 if err != nil {
94 if ctx.Err() != nil {
95 return nil, fmt.Errorf("git %s: %w", args[0], ctx.Err())
96 }
97 return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(errb.String()))
98 }
99 return out.Bytes(), nil
100 }
101
102 // ------------------------------------------------------------------ config
103
104 // Config reads a key from the repository's own config file, the way cgit
105 // does with enable-git-config. A missing key is empty, not an error.
106 func (r *Repo) Config(key string) string {
107 out, err := r.run("config", "--get", key)
108 if err != nil {
109 return "" // exit 1 == key absent
110 }
111 return strings.TrimSpace(string(out))
112 }
113
114 // ConfigFirst returns the first key that has a value, for fallback chains
115 // such as gsg.description -> gitweb.description.
116 func (r *Repo) ConfigFirst(keys ...string) string {
117 for _, k := range keys {
118 if v := r.Config(k); v != "" {
119 return v
120 }
121 }
122 return ""
123 }
124
125 const placeholderDesc = "Unnamed repository; edit this file 'description' to name the repository."
126
127 // DescriptionFile reads the legacy `description` file, ignoring git's placeholder.
128 func (r *Repo) DescriptionFile() string {
129 b, err := os.ReadFile(filepath.Join(r.Dir, "description"))
130 if err != nil {
131 return ""
132 }
133 s := strings.TrimSpace(string(b))
134 if s == placeholderDesc {
135 return ""
136 }
137 return s
138 }
139
140 // -------------------------------------------------------------------- refs
141
142 type Ref struct {
143 Name string
144 Kind string // "branch" | "tag"
145 Target string // commit sha
146 When time.Time
147 Title string
148 }
149
150 func (r *Repo) Refs() ([]Ref, error) {
151 const f = "%(refname:short)" + escF + "%(objectname)" + escF +
152 "%(creatordate:iso-strict)" + escF + "%(subject)" + escF + "%(refname)"
153 out, err := r.run("for-each-ref", "--format="+f, "refs/heads", "refs/tags")
154 if err != nil {
155 return nil, err
156 }
157 var refs []Ref
158 for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
159 if line == "" {
160 continue
161 }
162 p := strings.Split(line, sepF)
163 if len(p) < 5 {
164 continue
165 }
166 kind := "branch"
167 if strings.HasPrefix(p[4], "refs/tags/") {
168 kind = "tag"
169 }
170 t, _ := time.Parse(time.RFC3339, p[2])
171 refs = append(refs, Ref{Name: p[0], Kind: kind, Target: p[1], When: t, Title: p[3]})
172 }
173 return refs, nil
174 }
175
176 var shaRE = regexp.MustCompile(`^[0-9a-fA-F]{7,40}$`)
177
178 // Resolve turns user-supplied ref text into a commit SHA.
179 //
180 // SECURITY: the input is never handed to `git rev-parse`. rev-parse accepts
181 // an expression language (`:/regex` searches history, `@{...}` reads the
182 // reflog) and git subcommands treat a leading `-` as an option. So input is
183 // either a hex SHA that must already exist, or an exact match against the
184 // repo's own ref list. Everything else is rejected.
185 func (r *Repo) Resolve(ref string) (string, error) {
186 if ref == "" || ref == "HEAD" {
187 ref = r.HeadRef()
188 }
189 if strings.ContainsAny(ref, "\x00\n") || strings.HasPrefix(ref, "-") {
190 return "", fmt.Errorf("invalid ref %q", ref)
191 }
192 if shaRE.MatchString(ref) {
193 if _, err := r.run("cat-file", "-e", ref+"^{commit}"); err != nil {
194 return "", fmt.Errorf("unknown commit %q", ref)
195 }
196 out, err := r.run("rev-parse", "--verify", ref+"^{commit}")
197 if err != nil {
198 return "", err
199 }
200 return strings.TrimSpace(string(out)), nil
201 }
202 refs, err := r.Refs()
203 if err != nil {
204 return "", err
205 }
206 for _, x := range refs {
207 if x.Name == ref {
208 out, err := r.run("rev-parse", "--verify", x.Target+"^{commit}")
209 if err != nil {
210 return "", err
211 }
212 return strings.TrimSpace(string(out)), nil
213 }
214 }
215 return "", fmt.Errorf("unknown ref %q", ref)
216 }
217
218 // SplitRefPath separates "feature/x/src/main.go" into ref "feature/x" and
219 // path "src/main.go" by matching the LONGEST prefix that is a real ref.
220 // Branch names contain slashes, so a naive split at the first "/" is wrong;
221 // cgit dodges this with ?h= query params, GitHub resolves against the ref
222 // list. This does the latter.
223 func (r *Repo) SplitRefPath(rest string) (ref, path string) {
224 rest = strings.Trim(rest, "/")
225 if rest == "" {
226 return r.HeadRef(), ""
227 }
228 names := map[string]bool{}
229 if refs, err := r.Refs(); err == nil {
230 for _, x := range refs {
231 names[x.Name] = true
232 }
233 }
234 segs := strings.Split(rest, "/")
235 for i := len(segs); i > 0; i-- {
236 cand := strings.Join(segs[:i], "/")
237 if names[cand] || shaRE.MatchString(cand) {
238 return cand, strings.Join(segs[i:], "/")
239 }
240 }
241 // No ref matched: treat the whole thing as a path at HEAD.
242 return r.HeadRef(), rest
243 }
244
245 // HeadRef resolves the repo's default branch, rather than guessing main/master.
246 func (r *Repo) HeadRef() string {
247 out, err := r.run("symbolic-ref", "--short", "HEAD")
248 if err != nil {
249 return "HEAD"
250 }
251 return strings.TrimSpace(string(out))
252 }
253
254 // ----------------------------------------------------------------- commits
255
256 type Commit struct {
257 SHA string
258 Short string
259 Author string
260 Email string
261 When time.Time
262 Subject string
263 Body string
264 Parents []string
265 }
266
267 // Log walks history from rev in ONE git process. n <= 0 means unbounded.
268 // Never call this once per commit; fork overhead dominates at scale.
269 func (r *Repo) Log(rev string, n int) ([]Commit, error) {
270 if r.empty {
271 return nil, nil
272 }
273 format := strings.Join([]string{"%H", "%an", "%ae", "%aI", "%P", "%s", "%b"}, escLogF)
274 args := []string{"log", "--format=" + escLogR + format}
275 if n > 0 {
276 args = append(args, "-n", strconv.Itoa(n))
277 }
278 args = append(args, rev)
279 return r.parseLog(r.run(args...))
280 }
281
282 func (r *Repo) parseLog(out []byte, err error) ([]Commit, error) {
283 if err != nil {
284 return nil, err
285 }
286 var commits []Commit
287 for _, rec := range strings.Split(string(out), sepR) {
288 rec = strings.TrimRight(rec, "\n")
289 if rec == "" {
290 continue
291 }
292 p := strings.SplitN(rec, sepF, 7)
293 if len(p) < 7 {
294 continue
295 }
296 t, _ := time.Parse(time.RFC3339, p[3])
297 c := Commit{
298 SHA: p[0], Author: p[1], Email: p[2], When: t,
299 Subject: p[5], Body: strings.TrimSpace(p[6]),
300 }
301 if len(c.SHA) >= 8 {
302 c.Short = c.SHA[:8]
303 }
304 if p[4] != "" {
305 c.Parents = strings.Fields(p[4])
306 }
307 commits = append(commits, c)
308 }
309 return commits, nil
310 }
311
312 // MergeBase returns the common ancestor of two resolved commits.
313 func (r *Repo) MergeBase(a, b string) (string, error) {
314 out, err := r.run("merge-base", a, b)
315 if err != nil {
316 return "", err
317 }
318 return strings.TrimSpace(string(out)), nil
319 }
320
321 // LogAll walks every ref in topological order. This is the input to the
322 // branch graph: --topo-order guarantees a child always precedes its
323 // parents, which the lane assignment depends on.
324 func (r *Repo) LogAll(n int) ([]Commit, error) {
325 if r.empty {
326 return nil, nil
327 }
328 format := strings.Join([]string{"%H", "%an", "%ae", "%aI", "%P", "%s", "%b"}, escLogF)
329 return r.parseLog(r.run("log", "--all", "--topo-order", "--date-order",
330 "-n", strconv.Itoa(n), "--format="+escLogR+format))
331 }
332
333 // ------------------------------------------------------------------- trees
334
335 type Entry struct {
336 Mode string
337 Type string // "blob" | "tree" | "commit" (submodule)
338 SHA string
339 Size int64
340 Name string // basename
341 Path string // full path from repo root
342 }
343
344 func (e Entry) IsDir() bool { return e.Type == "tree" }
345
346 // Tree lists one directory level at rev.
347 func (r *Repo) Tree(rev, path string) ([]Entry, error) {
348 if r.empty {
349 return nil, nil
350 }
351 spec := rev + ":"
352 if path != "" && path != "." {
353 spec = rev + ":" + path
354 }
355 out, err := r.run("ls-tree", "-z", "--long", spec)
356 if err != nil {
357 return nil, err
358 }
359 var entries []Entry
360 for _, rec := range strings.Split(string(out), "\x00") {
361 if rec == "" {
362 continue
363 }
364 head, name, ok := strings.Cut(rec, "\t")
365 if !ok {
366 continue
367 }
368 f := strings.Fields(head)
369 if len(f) < 3 {
370 continue
371 }
372 e := Entry{Mode: f[0], Type: f[1], SHA: f[2], Name: name}
373 if len(f) > 3 {
374 e.Size, _ = strconv.ParseInt(f[3], 10, 64)
375 }
376 e.Path = strings.TrimPrefix(path+"/"+name, "/")
377 entries = append(entries, e)
378 }
379 return entries, nil
380 }
381
382 // ------------------------------------------------------------------ diffs
383
384 type FileStat struct {
385 Added, Deleted int
386 Path, OldPath string
387 Status string
388 }
389
390 type Diff struct {
391 Stats []FileStat
392 Patch string
393 Truncated bool
394 }
395
396 // DiffRange returns the patch between two resolved commits.
397 // Callers MUST pass SHAs from Resolve, never raw user input.
398 func (r *Repo) DiffRange(base, head string, threeDot bool) (Diff, error) {
399 sep := ".."
400 if threeDot {
401 sep = "..."
402 }
403 return r.diff("diff", "-p", "--no-color", "--no-ext-diff", "-M", "--numstat", base+sep+head)
404 }
405
406 // DiffCommit returns the patch for a single commit. --root makes the first
407 // commit diff against the empty tree instead of erroring.
408 func (r *Repo) DiffCommit(sha string) (Diff, error) {
409 return r.diff("diff-tree", "-p", "--root", "--no-color", "--no-ext-diff",
410 "-M", "--numstat", "--format=", sha)
411 }
412
413 func (r *Repo) diff(args ...string) (Diff, error) {
414 var d Diff
415 raw, err := r.run(args...)
416 if err == ErrTruncated {
417 d.Truncated = true
418 } else if err != nil {
419 return d, err
420 }
421 body := string(raw)
422 // numstat block comes first, then the patch; they are separated by the
423 // first line starting with "diff --git".
424 if i := strings.Index(body, "diff --git"); i >= 0 {
425 d.Stats = parseNumstat(body[:i])
426 d.Patch = body[i:]
427 } else {
428 d.Stats = parseNumstat(body)
429 }
430 return d, nil
431 }
432
433 func parseNumstat(s string) []FileStat {
434 var out []FileStat
435 for _, line := range strings.Split(s, "\n") {
436 f := strings.Split(strings.TrimSpace(line), "\t")
437 if len(f) < 3 {
438 continue
439 }
440 fs := FileStat{Path: f[2], Status: "M"}
441 if f[0] == "-" {
442 fs.Status = "B" // binary
443 } else {
444 fs.Added, _ = strconv.Atoi(f[0])
445 fs.Deleted, _ = strconv.Atoi(f[1])
446 }
447 out = append(out, fs)
448 }
449 return out
450 }
451
452 // Blob returns file contents at rev.
453 func (r *Repo) Blob(rev, path string) ([]byte, error) {
454 return r.run("cat-file", "blob", rev+":"+path)
455 }