gitcmd.go
⎇
Raw
1// Package gitcmd runs the `git` binary for all repository access.
2// It never links a git library. Every call goes through os/exec with a
3// sanitized environment and an explicit context.
4package gitcmd
5
6import (
7 "bytes"
8 "context"
9 "errors"
10 "fmt"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "regexp"
15 "slices"
16 "strconv"
17 "strings"
18 "sync"
19 "time"
20
21 "hearthforge/internal/config"
22 "hearthforge/internal/util"
23)
24
25// Caps and cache settings for git command results.
26const (
27 MaxRefList = 1000
28 refCacheTTL = 30 * time.Second
29 maxBranchCache = 200
30 maxTagCache = 200
31 staleLockAge = 60 * time.Second
32 maxPatchCache = 100
33 patchCacheTTL = time.Hour
34)
35
36// Sentinel errors. Handlers map these to 404 / 400 / 409.
37var (
38 ErrInvalidName = errors.New("invalid repository name")
39 ErrInvalidRef = errors.New("invalid ref")
40 ErrNotFound = errors.New("not found")
41 ErrExists = errors.New("already exists")
42 ErrBadRef = errors.New("ref does not resolve")
43 ErrConflict = errors.New("patch does not apply")
44 ErrRefChanged = errors.New("ref changed concurrently")
45 ErrTooLarge = errors.New("output too large")
46 ErrNotEmpty = errors.New("repository is not empty")
47 ErrInvalidFormat = errors.New("unknown object format")
48)
49
50var validRepoName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
51
52// ValidRepoName mirrors VALID_REPO_NAME_RE plus the traversal guard.
53func ValidRepoName(name string) bool {
54 // "v2" is the container registry prefix.
55 return name != "" && name != "v2" && !strings.Contains(name, "..") && validRepoName.MatchString(name)
56}
57
58// ValidRef rejects names git would read as options or path traversal.
59// `--end-of-options` covers the option case too. This is a second guard.
60func ValidRef(ref string) bool {
61 if ref == "" || strings.HasPrefix(ref, "-") || strings.Contains(ref, "..") {
62 return false
63 }
64 // A colon would let a ref smuggle a path into `ref:path` forms.
65 return !strings.ContainsAny(ref, " \t\n\r\x00:\\")
66}
67
68// ValidPath rejects paths that escape the tree or look like an option.
69func ValidPath(p string) bool {
70 if p == "" || strings.HasPrefix(p, "-") || strings.HasPrefix(p, "/") {
71 return false
72 }
73 if strings.ContainsAny(p, "\x00\n") {
74 return false
75 }
76 for _, seg := range strings.Split(p, "/") {
77 if seg == ".." {
78 return false
79 }
80 }
81 return true
82}
83
84type Git struct {
85 cfg *config.Config
86 env []string
87
88 mu sync.Mutex
89 locks map[string]*sync.Mutex
90 branches *util.Cache[string, []string]
91 tags *util.Cache[string, []string]
92
93 archiveSem chan struct{}
94
95 // OnRefUpdate runs after a write op moves a branch or tag. rev is any
96 // revision that names the new target. It must not block: the repo lock
97 // is still held.
98 OnRefUpdate func(repo, ref, rev string)
99}
100
101func New(cfg *config.Config) *Git {
102 return &Git{
103 cfg: cfg,
104 env: Env(),
105 locks: map[string]*sync.Mutex{},
106 branches: util.NewCache[string, []string](maxBranchCache, refCacheTTL),
107 tags: util.NewCache[string, []string](maxTagCache, refCacheTTL),
108 archiveSem: make(chan struct{}, cfg.MaxConcurrentArchives),
109 }
110}
111
112// Env is the sanitized environment every git subprocess runs with. A fixed
113// environment keeps git output parseable and stops git from reading user or
114// system config, or prompting for credentials. Callers that spawn git
115// themselves (the transports, the CI runner) use it too.
116func Env() []string {
117 return append(os.Environ(),
118 "LC_ALL=C",
119 "LANG=C",
120 "GIT_CONFIG_GLOBAL=/dev/null",
121 "GIT_CONFIG_SYSTEM=/dev/null",
122 "GIT_CONFIG_COUNT=0",
123 "GIT_ASKPASS=echo",
124 "GIT_TERMINAL_PROMPT=0",
125 )
126}
127
128var validProtocol = regexp.MustCompile(`^[a-z0-9=:._-]{1,64}$`)
129
130// EnvWithProtocol is Env plus the client's GIT_PROTOCOL value. Without it,
131// git falls back to protocol v0, and an empty clone then misses the default
132// branch name.
133func EnvWithProtocol(protocol string) []string {
134 env := Env()
135 if validProtocol.MatchString(protocol) {
136 env = append(env, "GIT_PROTOCOL="+protocol)
137 }
138 return env
139}
140
141// RepoPath is the bare repo directory for a validated name.
142func (g *Git) RepoPath(name string) string {
143 return filepath.Join(g.cfg.ReposDir(), name+".git")
144}
145
146func (g *Git) repoDir(name string) (string, error) {
147 if !ValidRepoName(name) {
148 return "", fmt.Errorf("%q: %w", name, ErrInvalidName)
149 }
150 return g.RepoPath(name), nil
151}
152
153// lock serializes writes per repository. Two concurrent index writes in the
154// same bare repo corrupt each other.
155func (g *Git) lock(name string) *sync.Mutex {
156 g.mu.Lock()
157 defer g.mu.Unlock()
158 m, ok := g.locks[name]
159 if !ok {
160 m = &sync.Mutex{}
161 g.locks[name] = m
162 }
163 return m
164}
165
166type runOpts struct {
167 extraEnv []string // appended to the sanitized env
168 stdin []byte
169 maxOut int64 // when > 0, more stdout kills git and returns ErrTooLarge
170}
171
172// cappedWriter kills the process on overflow. Without the kill, git blocks
173// on a full pipe and Wait never returns.
174type cappedWriter struct {
175 buf *bytes.Buffer
176 max int64
177 kill context.CancelFunc
178 over bool
179}
180
181func (w *cappedWriter) Write(p []byte) (int, error) {
182 if int64(w.buf.Len()+len(p)) > w.max {
183 w.over = true
184 w.kill()
185 return 0, ErrTooLarge
186 }
187 return w.buf.Write(p)
188}
189
190// run executes git and returns stdout. Stderr goes into the error.
191func (g *Git) run(ctx context.Context, opt runOpts, args ...string) ([]byte, error) {
192 var out, errBuf bytes.Buffer
193 var capped *cappedWriter
194 if opt.maxOut > 0 {
195 var cancel context.CancelFunc
196 ctx, cancel = context.WithCancel(ctx)
197 defer cancel()
198 capped = &cappedWriter{buf: &out, max: opt.maxOut, kill: cancel}
199 }
200 cmd := exec.CommandContext(ctx, "git", args...)
201 cmd.Env = g.env
202 if len(opt.extraEnv) > 0 {
203 cmd.Env = append(append([]string(nil), g.env...), opt.extraEnv...)
204 }
205 if opt.stdin != nil {
206 cmd.Stdin = bytes.NewReader(opt.stdin)
207 }
208 cmd.Stdout = &out
209 if capped != nil {
210 cmd.Stdout = capped
211 }
212 cmd.Stderr = &errBuf
213 if err := cmd.Run(); err != nil {
214 if capped != nil && capped.over {
215 return nil, fmt.Errorf("git %s: %w", args[0], ErrTooLarge)
216 }
217 return out.Bytes(), fmt.Errorf("git %s: %w: %s", args[0], err, strings.TrimSpace(errBuf.String()))
218 }
219 return out.Bytes(), nil
220}
221
222func (g *Git) text(ctx context.Context, args ...string) (string, error) {
223 out, err := g.run(ctx, runOpts{}, args...)
224 return string(out), err
225}
226
227func (g *Git) line(ctx context.Context, args ...string) (string, error) {
228 s, err := g.text(ctx, args...)
229 return strings.TrimSpace(s), err
230}
231
232// signArgs configure ssh commit signing with the server host key.
233func (g *Git) signArgs() []string {
234 return []string{"-c", "gpg.format=ssh", "-c", "user.signingKey=" + g.cfg.SSHHostKeyPath}
235}
236
237// verifyArgs configure signature verification against the allowed_signers file.
238func (g *Git) verifyArgs() []string {
239 return []string{"-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile=" + g.cfg.AllowedSignersPath()}
240}
241
242// SigStatus is the badge shown next to a commit.
243type SigStatus string
244
245const (
246 SigGood SigStatus = "good"
247 SigBad SigStatus = "bad"
248 SigUnverified SigStatus = "unverified"
249 SigNone SigStatus = "none"
250)
251
252// parseSigStatus maps git's %G? codes onto the badges.
253func parseSigStatus(code string) SigStatus {
254 switch code {
255 case "G":
256 return SigGood
257 case "B", "R":
258 return SigBad
259 case "U", "X", "Y", "E":
260 return SigUnverified
261 }
262 return SigNone
263}
264
265type Commit struct {
266 Hash string
267 Subject string
268 Author string
269 Date string
270 SigStatus SigStatus
271}
272
273type CommitMeta struct {
274 Hash string
275 Subject string
276 Body string
277 Author string
278 Email string
279 Date string
280 Committer string
281 CommitterEmail string
282 CommitterDate string
283 Parents []string
284 SigStatus SigStatus
285}
286
287type TreeEntry struct {
288 Mode string
289 Type string // blob or tree
290 Hash string
291 Size string
292 Name string
293}
294
295type BranchInfo struct {
296 Name string
297 ShortHash string
298 Subject string
299 AuthorName string
300 Date string
301}
302
303type TagInfo struct {
304 Name string
305 ShortHash string
306 Subject string
307 TaggerName string
308 Date string
309 IsAnnotated bool
310}
311
312// Ident is a git author or committer identity.
313type Ident struct {
314 Name string
315 Email string
316}
317
318func identEnv(author, committer Ident) []string {
319 return []string{
320 "GIT_AUTHOR_NAME=" + author.Name,
321 "GIT_AUTHOR_EMAIL=" + author.Email,
322 "GIT_COMMITTER_NAME=" + committer.Name,
323 "GIT_COMMITTER_EMAIL=" + committer.Email,
324 }
325}
326
327func splitLines(s string) []string {
328 var out []string
329 for _, l := range strings.Split(s, "\n") {
330 if l != "" {
331 out = append(out, l)
332 }
333 }
334 return out
335}
336
337func field(parts []string, i int) string {
338 if i < len(parts) {
339 return parts[i]
340 }
341 return ""
342}
343
344// --- read operations ---
345
346// ObjectFormats are the hash algorithms a repo can use.
347var ObjectFormats = []string{"sha1", "sha256"}
348
349// Init creates a bare repo. An empty format leaves the choice to git.
350func (g *Git) Init(ctx context.Context, name, branch, format string) error {
351 p, err := g.repoDir(name)
352 if err != nil {
353 return err
354 }
355 if branch == "" {
356 branch = "main"
357 }
358 if !ValidRef(branch) {
359 return fmt.Errorf("%q: %w", branch, ErrInvalidRef)
360 }
361 if format != "" && !slices.Contains(ObjectFormats, format) {
362 return fmt.Errorf("%q: %w", format, ErrInvalidFormat)
363 }
364 m := g.lock(name)
365 m.Lock()
366 defer m.Unlock()
367 return g.initAt(ctx, p, branch, format)
368}
369
370func (g *Git) initAt(ctx context.Context, p, branch, format string) error {
371 args := []string{"init", "--bare", "--initial-branch=" + branch}
372 if format != "" {
373 args = append(args, "--object-format="+format)
374 }
375 _, err := g.run(ctx, runOpts{}, append(args, p)...)
376 return err
377}
378
379// ObjectFormat returns the repo's hash algorithm, "sha1" or "sha256".
380func (g *Git) ObjectFormat(ctx context.Context, name string) (string, error) {
381 p, err := g.repoDir(name)
382 if err != nil {
383 return "", err
384 }
385 return g.line(ctx, "-C", p, "rev-parse", "--show-object-format")
386}
387
388// SetObjectFormat re-creates an empty repo with another hash algorithm.
389// git cannot convert a repo in place.
390func (g *Git) SetObjectFormat(ctx context.Context, name, format string) error {
391 p, err := g.repoDir(name)
392 if err != nil {
393 return err
394 }
395 if !slices.Contains(ObjectFormats, format) {
396 return fmt.Errorf("%q: %w", format, ErrInvalidFormat)
397 }
398 m := g.lock(name)
399 m.Lock()
400 defer m.Unlock()
401 ref, err := g.line(ctx, "-C", p, "for-each-ref", "--count=1", "--format=%(refname)")
402 if err != nil {
403 return err
404 }
405 if ref != "" {
406 return ErrNotEmpty
407 }
408 branch, err := g.line(ctx, "-C", p, "symbolic-ref", "--short", "HEAD")
409 if err != nil {
410 return err
411 }
412 // The suffixes do not end in .git, so the startup scan skips leftovers.
413 tmp, old := p+".reinit", p+".old"
414 _ = os.RemoveAll(tmp)
415 _ = os.RemoveAll(old)
416 if err := g.initAt(ctx, tmp, branch, format); err != nil {
417 return err
418 }
419 // ponytail: pushes do not take g.lock, so a push that lands after the ref
420 // check goes to the old dir and is lost. Admin-only on an empty repo.
421 if err := os.Rename(p, old); err != nil {
422 _ = os.RemoveAll(tmp)
423 return err
424 }
425 if err := os.Rename(tmp, p); err != nil {
426 _ = os.Rename(old, p)
427 return err
428 }
429 return os.RemoveAll(old)
430}
431
432// EnsureBare sets core.bare on a repo discovered on disk.
433func (g *Git) EnsureBare(ctx context.Context, name string) error {
434 p, err := g.repoDir(name)
435 if err != nil {
436 return err
437 }
438 cfgFile := filepath.Join(p, "config")
439 m := g.lock(name)
440 m.Lock()
441 defer m.Unlock()
442 if cur, err := g.line(ctx, "config", "--file", cfgFile, "--get", "core.bare"); err == nil && cur == "true" {
443 return nil
444 }
445 _, err = g.run(ctx, runOpts{}, "config", "--file", cfgFile, "core.bare", "true")
446 return err
447}
448
449// asBadRef maps git's "this ref does not resolve" stderr onto ErrBadRef.
450// It covers an unknown revision, a bad default HEAD and a repo with no
451// commits. Any other failure is returned unchanged.
452func asBadRef(ref string, err error) error {
453 msg := err.Error()
454 switch {
455 case strings.Contains(msg, "unknown revision"),
456 strings.Contains(msg, "not a valid object name"),
457 strings.Contains(msg, "bad revision"),
458 strings.Contains(msg, "bad object"),
459 strings.Contains(msg, "bad default revision"),
460 strings.Contains(msg, "does not have any commits yet"),
461 strings.Contains(msg, "ambiguous argument"):
462 return fmt.Errorf("%q: %w", ref, ErrBadRef)
463 }
464 return err
465}
466
467// Log returns up to limit commits starting at ref, skipping skip.
468func (g *Git) Log(ctx context.Context, name, ref string, limit, skip int) ([]Commit, error) {
469 p, err := g.repoDir(name)
470 if err != nil {
471 return nil, err
472 }
473 if ref == "" {
474 ref = "HEAD"
475 }
476 if !ValidRef(ref) {
477 return nil, fmt.Errorf("%q: %w", ref, ErrInvalidRef)
478 }
479 args := append(g.verifyArgs(), "-C", p, "log",
480 "--format=%H%x1f%s%x1f%an%x1f%ai%x1f%G?",
481 "--max-count="+strconv.Itoa(limit),
482 "--skip="+strconv.Itoa(skip),
483 // Everything after --end-of-options is data, never an option.
484 "--end-of-options", ref, "--")
485 out, err := g.text(ctx, args...)
486 if err != nil {
487 return nil, fmt.Errorf("log %s: %w", ref, asBadRef(ref, err))
488 }
489 var commits []Commit
490 for _, line := range splitLines(out) {
491 parts := strings.Split(line, "\x1f")
492 commits = append(commits, Commit{
493 Hash: field(parts, 0),
494 Subject: field(parts, 1),
495 Author: field(parts, 2),
496 Date: field(parts, 3),
497 SigStatus: parseSigStatus(field(parts, 4)),
498 })
499 }
500 return commits, nil
501}
502
503// LsTree lists one directory level. subpath "" means the repo root.
504func (g *Git) LsTree(ctx context.Context, name, ref, subpath string) ([]TreeEntry, error) {
505 p, err := g.repoDir(name)
506 if err != nil {
507 return nil, err
508 }
509 if !ValidRef(ref) {
510 return nil, fmt.Errorf("%q: %w", ref, ErrInvalidRef)
511 }
512 // A trailing `--` with no pathspec means "match nothing" to ls-tree,
513 // so only add the separator when there is a path.
514 args := []string{"-C", p, "ls-tree", "--long", "-z", "--end-of-options", ref}
515 if subpath != "" {
516 if !ValidPath(subpath) {
517 return nil, fmt.Errorf("%q: %w", subpath, ErrInvalidRef)
518 }
519 args = append(args, "--", subpath+"/")
520 }
521 out, err := g.text(ctx, args...)
522 if err != nil {
523 return nil, fmt.Errorf("ls-tree %s: %w", ref, asBadRef(ref, err))
524 }
525 prefix := subpath + "/"
526 var entries []TreeEntry
527 for _, line := range strings.Split(out, "\x00") {
528 // format: <mode> SP <type> SP <object> SP <size> TAB <file>
529 tab := strings.IndexByte(line, '\t')
530 if tab < 0 {
531 continue
532 }
533 meta := strings.Fields(line[:tab])
534 e := TreeEntry{
535 Mode: field(meta, 0),
536 Type: field(meta, 1),
537 Hash: field(meta, 2),
538 Size: field(meta, 3),
539 Name: line[tab+1:],
540 }
541 if subpath != "" {
542 e.Name = strings.TrimPrefix(e.Name, prefix)
543 }
544 entries = append(entries, e)
545 }
546 return entries, nil
547}
548
549// Show returns the blob contents at ref:filePath.
550func (g *Git) Show(ctx context.Context, name, ref, filePath string) ([]byte, error) {
551 p, err := g.repoDir(name)
552 if err != nil {
553 return nil, err
554 }
555 if !ValidRef(ref) {
556 return nil, fmt.Errorf("%q: %w", ref, ErrInvalidRef)
557 }
558 if !ValidPath(filePath) {
559 return nil, fmt.Errorf("%q: %w", filePath, ErrNotFound)
560 }
561 out, err := g.run(ctx, runOpts{}, "-C", p, "show", "--end-of-options", ref+":"+filePath)
562 if err != nil {
563 // A missing path is a normal answer, for example probing for a CI config.
564 return nil, fmt.Errorf("show %s:%s: %w", ref, filePath, ErrNotFound)
565 }
566 return out, nil
567}
568
569// Diff returns the patch text for one commit. Output larger than maxBytes
570// stops git and returns ErrTooLarge, so a huge commit is never buffered.
571func (g *Git) Diff(ctx context.Context, name, sha string, maxBytes int64) (string, error) {
572 p, err := g.repoDir(name)
573 if err != nil {
574 return "", err
575 }
576 if !ValidRef(sha) {
577 return "", fmt.Errorf("%q: %w", sha, ErrInvalidRef)
578 }
579 out, err := g.run(ctx, runOpts{maxOut: maxBytes}, "-C", p, "diff-tree", "--no-commit-id", "-r", "-p", "-M", "--root",
580 "--end-of-options", sha, "--")
581 return string(out), err
582}
583
584// BlobSizes returns the sizes of the given blob ids in one git call. Ids that
585// do not resolve, like the all-zero id, are missing from the map.
586func (g *Git) BlobSizes(ctx context.Context, name string, hashes []string) (map[string]int64, error) {
587 p, err := g.repoDir(name)
588 if err != nil {
589 return nil, err
590 }
591 var in strings.Builder
592 for _, h := range hashes {
593 if !ValidRef(h) {
594 return nil, fmt.Errorf("%q: %w", h, ErrInvalidRef)
595 }
596 in.WriteString(h + "\n")
597 }
598 out, err := g.run(ctx, runOpts{stdin: []byte(in.String())}, "-C", p, "cat-file", "--batch-check")
599 if err != nil {
600 return nil, fmt.Errorf("cat-file: %w", err)
601 }
602 // One output line per input line, in input order.
603 sizes := map[string]int64{}
604 for i, line := range strings.Split(strings.TrimSuffix(string(out), "\n"), "\n") {
605 f := strings.Fields(line)
606 if i >= len(hashes) || len(f) != 3 {
607 continue
608 }
609 if n, err := strconv.ParseInt(f[2], 10, 64); err == nil {
610 sizes[hashes[i]] = n
611 }
612 }
613 return sizes, nil
614}
615
616// FileSize returns the size of the blob at ref:filePath. A path that is not a
617// blob, a directory for example, is reported as not found.
618func (g *Git) FileSize(ctx context.Context, name, ref, filePath string) (int64, error) {
619 p, err := g.repoDir(name)
620 if err != nil {
621 return 0, err
622 }
623 if !ValidRef(ref) || !ValidPath(filePath) {
624 return 0, ErrInvalidRef
625 }
626 // Without the type a directory would answer with the tree's size, and
627 // the streaming readers would then send an empty body.
628 _, typ, size, err := g.objectInfo(ctx, p, ref, filePath)
629 if err != nil || typ != "blob" {
630 return 0, fmt.Errorf("%s:%s: %w", ref, filePath, ErrNotFound)
631 }
632 return size, nil
633}
634
635// objectInfo returns the id, type, and size of the object at rev:filePath.
636// All are empty when rev is empty or the path does not exist there.
637func (g *Git) objectInfo(ctx context.Context, p, rev, filePath string) (id, typ string, size int64, err error) {
638 if rev == "" {
639 return "", "", 0, nil
640 }
641 out, err := g.run(ctx, runOpts{stdin: []byte(rev + ":" + filePath + "\n")},
642 "-C", p, "cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)")
643 if err != nil {
644 return "", "", 0, err
645 }
646 line := strings.TrimSpace(string(out))
647 if strings.HasSuffix(line, " missing") {
648 return "", "", 0, nil
649 }
650 f := strings.Fields(line)
651 if len(f) != 3 {
652 return "", "", 0, fmt.Errorf("cat-file: unexpected output %q", line)
653 }
654 size, err = strconv.ParseInt(f[2], 10, 64)
655 return f[0], f[1], size, err
656}
657
658// cachedRefs serves a ref list from cache, or fills it via load.
659func (g *Git) cachedRefs(cache *util.Cache[string, []string], name string, load func() ([]string, error)) ([]string, error) {
660 if v, ok := cache.Get(name); ok {
661 return v, nil
662 }
663 value, err := load()
664 if err != nil {
665 return nil, err
666 }
667 cache.Set(name, value)
668 return value, nil
669}
670
671// InvalidateRefCache drops the cached branch and tag lists for a repo.
672func (g *Git) InvalidateRefCache(name string) {
673 g.branches.Delete(name)
674 g.tags.Delete(name)
675}
676
677func (g *Git) Branches(ctx context.Context, name string) ([]string, error) {
678 p, err := g.repoDir(name)
679 if err != nil {
680 return nil, err
681 }
682 return g.cachedRefs(g.branches, name, func() ([]string, error) {
683 out, err := g.text(ctx, "-C", p, "for-each-ref", "--count="+strconv.Itoa(MaxRefList),
684 "--format=%(refname:short)", "refs/heads/")
685 if err != nil {
686 return nil, fmt.Errorf("branches: %w", err)
687 }
688 return splitLines(out), nil
689 })
690}
691
692func (g *Git) Tags(ctx context.Context, name string) ([]string, error) {
693 p, err := g.repoDir(name)
694 if err != nil {
695 return nil, err
696 }
697 return g.cachedRefs(g.tags, name, func() ([]string, error) {
698 out, err := g.text(ctx, "-C", p, "for-each-ref", "--count="+strconv.Itoa(MaxRefList),
699 "--format=%(refname:short)", "refs/tags/")
700 if err != nil {
701 return nil, fmt.Errorf("tags: %w", err)
702 }
703 return splitLines(out), nil
704 })
705}
706
707func (g *Git) BranchesWithInfo(ctx context.Context, name string, maxCount int) ([]BranchInfo, error) {
708 p, err := g.repoDir(name)
709 if err != nil {
710 return nil, err
711 }
712 if maxCount <= 0 {
713 maxCount = MaxRefList
714 }
715 // for-each-ref has no %x1f escape, so embed the separator byte directly.
716 const f = "%(refname:short)\x1f%(objectname:short)\x1f%(contents:subject)\x1f%(authorname)\x1f%(authordate:iso8601)"
717 out, err := g.text(ctx, "-C", p, "for-each-ref", "--sort=-creatordate",
718 "--count="+strconv.Itoa(maxCount), "--format="+f, "refs/heads/")
719 if err != nil {
720 return nil, fmt.Errorf("branchesWithInfo: %w", err)
721 }
722 var list []BranchInfo
723 for _, line := range splitLines(out) {
724 parts := strings.Split(line, "\x1f")
725 list = append(list, BranchInfo{
726 Name: field(parts, 0), ShortHash: field(parts, 1), Subject: field(parts, 2),
727 AuthorName: field(parts, 3), Date: field(parts, 4),
728 })
729 }
730 return list, nil
731}
732
733func (g *Git) TagsWithInfo(ctx context.Context, name string, maxCount int) ([]TagInfo, error) {
734 p, err := g.repoDir(name)
735 if err != nil {
736 return nil, err
737 }
738 if maxCount <= 0 {
739 maxCount = MaxRefList
740 }
741 // %(*objectname:short) resolves annotated tags to their commit. It is
742 // empty for lightweight tags, which is how we tell the two apart.
743 const f = "%(refname:short)\x1f%(*objectname:short)\x1f%(objectname:short)\x1f%(contents:subject)\x1f%(taggername)\x1f%(creatordate:iso8601)"
744 out, err := g.text(ctx, "-C", p, "for-each-ref", "--sort=-creatordate",
745 "--count="+strconv.Itoa(maxCount), "--format="+f, "refs/tags/")
746 if err != nil {
747 return nil, fmt.Errorf("tagsWithInfo: %w", err)
748 }
749 var list []TagInfo
750 for _, line := range splitLines(out) {
751 parts := strings.Split(line, "\x1f")
752 deref := strings.TrimSpace(field(parts, 1))
753 own := strings.TrimSpace(field(parts, 2))
754 hash := own
755 if deref != "" {
756 hash = deref
757 }
758 list = append(list, TagInfo{
759 Name: field(parts, 0), ShortHash: hash, Subject: field(parts, 3),
760 TaggerName: field(parts, 4), Date: field(parts, 5), IsAnnotated: deref != "",
761 })
762 }
763 return list, nil
764}
765
766// DefaultBranch trusts HEAD only when it names a branch that exists.
767func (g *Git) DefaultBranch(ctx context.Context, name string) string {
768 p, err := g.repoDir(name)
769 if err != nil {
770 return "main"
771 }
772 branches, err := g.Branches(ctx, name)
773 if err != nil {
774 return "main"
775 }
776 head, _ := g.line(ctx, "-C", p, "symbolic-ref", "--short", "HEAD")
777 for _, b := range branches {
778 if b == head {
779 return head
780 }
781 }
782 for _, want := range []string{"main", "master"} {
783 for _, b := range branches {
784 if b == want {
785 return want
786 }
787 }
788 }
789 if len(branches) > 0 {
790 return branches[0]
791 }
792 return "main"
793}
794
795// ResolveRef returns the object id a ref points at.
796func (g *Git) ResolveRef(ctx context.Context, name, ref string) (string, error) {
797 p, err := g.repoDir(name)
798 if err != nil {
799 return "", err
800 }
801 if !ValidRef(ref) {
802 return "", fmt.Errorf("%q: %w", ref, ErrInvalidRef)
803 }
804 out, err := g.line(ctx, "-C", p, "rev-parse", "--verify", "--end-of-options", ref)
805 if err != nil || out == "" {
806 return "", fmt.Errorf("%q: %w", ref, ErrBadRef)
807 }
808 return out, nil
809}
810
811// HasCommits reports whether the repo has at least one commit.
812func (g *Git) HasCommits(ctx context.Context, name string) bool {
813 p, err := g.repoDir(name)
814 if err != nil {
815 return false
816 }
817 out, err := g.line(ctx, "-C", p, "log", "--oneline", "-1", "--")
818 return err == nil && out != ""
819}
820
821// CommitMeta returns the full detail for one commit, including its
822// signature badge.
823func (g *Git) CommitMeta(ctx context.Context, name, sha string) (*CommitMeta, error) {
824 p, err := g.repoDir(name)
825 if err != nil {
826 return nil, err
827 }
828 if !ValidRef(sha) {
829 return nil, fmt.Errorf("%q: %w", sha, ErrInvalidRef)
830 }
831 args := append(g.verifyArgs(), "-C", p, "show", "--no-patch",
832 "--format=%H%x1f%an%x1f%ae%x1f%ai%x1f%cn%x1f%ce%x1f%ci%x1f%P%x1f%G?",
833 "--end-of-options", sha, "--")
834 metaOut, err := g.line(ctx, args...)
835 if err != nil {
836 return nil, fmt.Errorf("commitMeta %s: %w", sha, ErrNotFound)
837 }
838 msgOut, err := g.text(ctx, "-C", p, "log", "--format=%B", "-1", "--end-of-options", sha, "--")
839 if err != nil {
840 return nil, fmt.Errorf("commitMeta message %s: %w", sha, err)
841 }
842 parts := strings.Split(metaOut, "\x1f")
843 full := strings.TrimRight(msgOut, "\n")
844 subject, body, _ := strings.Cut(full, "\n")
845 hash := field(parts, 0)
846 if hash == "" {
847 hash = sha
848 }
849 return &CommitMeta{
850 Hash: hash,
851 Subject: subject,
852 Body: strings.TrimSpace(body),
853 Author: field(parts, 1),
854 Email: field(parts, 2),
855 Date: field(parts, 3),
856 Committer: field(parts, 4),
857 CommitterEmail: field(parts, 5),
858 CommitterDate: field(parts, 6),
859 Parents: strings.Fields(field(parts, 7)),
860 SigStatus: parseSigStatus(field(parts, 8)),
861 }, nil
862}
863
864// SetHead points HEAD at a branch.
865func (g *Git) SetHead(ctx context.Context, name, branch string) error {
866 p, err := g.repoDir(name)
867 if err != nil {
868 return err
869 }
870 if !ValidRef(branch) {
871 return fmt.Errorf("%q: %w", branch, ErrInvalidRef)
872 }
873 _, err = g.run(ctx, runOpts{}, "-C", p, "symbolic-ref", "HEAD", "refs/heads/"+branch)
874 return err
875}
876