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