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