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