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