// Package gitcmd runs the `git` binary for all repository access. // It never links a git library. Every call goes through os/exec with a // sanitized environment and an explicit context. package gitcmd import ( "bytes" "context" "errors" "fmt" "os" "os/exec" "path/filepath" "regexp" "slices" "strconv" "strings" "sync" "time" "hearthforge/internal/config" "hearthforge/internal/util" ) // Caps and cache settings for git command results. const ( MaxRefList = 1000 refCacheTTL = 30 * time.Second maxBranchCache = 200 maxTagCache = 200 staleLockAge = 60 * time.Second maxPatchCache = 100 patchCacheTTL = time.Hour ) // Sentinel errors. Handlers map these to 404 / 400 / 409. var ( ErrInvalidName = errors.New("invalid repository name") ErrInvalidRef = errors.New("invalid ref") ErrNotFound = errors.New("not found") ErrExists = errors.New("already exists") ErrBadRef = errors.New("ref does not resolve") ErrConflict = errors.New("patch does not apply") ErrRefChanged = errors.New("ref changed concurrently") ErrTooLarge = errors.New("output too large") ErrNotEmpty = errors.New("repository is not empty") ErrInvalidFormat = errors.New("unknown object format") ) var validRepoName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) // ValidRepoName mirrors VALID_REPO_NAME_RE plus the traversal guard. func ValidRepoName(name string) bool { // "v2" is the container registry prefix. return name != "" && name != "v2" && !strings.Contains(name, "..") && validRepoName.MatchString(name) } // ValidRef rejects names git would read as options or path traversal. // `--end-of-options` covers the option case too. This is a second guard. func ValidRef(ref string) bool { if ref == "" || strings.HasPrefix(ref, "-") || strings.Contains(ref, "..") { return false } // A colon would let a ref smuggle a path into `ref:path` forms. return !strings.ContainsAny(ref, " \t\n\r\x00:\\") } // ValidPath rejects paths that escape the tree or look like an option. func ValidPath(p string) bool { if p == "" || strings.HasPrefix(p, "-") || strings.HasPrefix(p, "/") { return false } if strings.ContainsAny(p, "\x00\n") { return false } for _, seg := range strings.Split(p, "/") { if seg == ".." { return false } } return true } type Git struct { cfg *config.Config env []string mu sync.Mutex locks map[string]*sync.Mutex branches *util.Cache[string, []string] tags *util.Cache[string, []string] archiveSem chan struct{} } func New(cfg *config.Config) *Git { return &Git{ cfg: cfg, env: Env(), locks: map[string]*sync.Mutex{}, branches: util.NewCache[string, []string](maxBranchCache, refCacheTTL), tags: util.NewCache[string, []string](maxTagCache, refCacheTTL), archiveSem: make(chan struct{}, cfg.MaxConcurrentArchives), } } // Env is the sanitized environment every git subprocess runs with. A fixed // environment keeps git output parseable and stops git from reading user or // system config, or prompting for credentials. Callers that spawn git // themselves (the transports, the CI runner) use it too. func Env() []string { return append(os.Environ(), "LC_ALL=C", "LANG=C", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_CONFIG_COUNT=0", "GIT_ASKPASS=echo", "GIT_TERMINAL_PROMPT=0", ) } var validProtocol = regexp.MustCompile(`^[a-z0-9=:._-]{1,64}$`) // EnvWithProtocol is Env plus the client's GIT_PROTOCOL value. Without it, // git falls back to protocol v0, and an empty clone then misses the default // branch name. func EnvWithProtocol(protocol string) []string { env := Env() if validProtocol.MatchString(protocol) { env = append(env, "GIT_PROTOCOL="+protocol) } return env } // RepoPath is the bare repo directory for a validated name. func (g *Git) RepoPath(name string) string { return filepath.Join(g.cfg.ReposDir(), name+".git") } func (g *Git) repoDir(name string) (string, error) { if !ValidRepoName(name) { return "", fmt.Errorf("%q: %w", name, ErrInvalidName) } return g.RepoPath(name), nil } // lock serializes writes per repository. Two concurrent index writes in the // same bare repo corrupt each other. func (g *Git) lock(name string) *sync.Mutex { g.mu.Lock() defer g.mu.Unlock() m, ok := g.locks[name] if !ok { m = &sync.Mutex{} g.locks[name] = m } return m } type runOpts struct { extraEnv []string // appended to the sanitized env stdin []byte maxOut int64 // when > 0, more stdout kills git and returns ErrTooLarge } // cappedWriter kills the process on overflow. Without the kill, git blocks // on a full pipe and Wait never returns. type cappedWriter struct { buf *bytes.Buffer max int64 kill context.CancelFunc over bool } func (w *cappedWriter) Write(p []byte) (int, error) { if int64(w.buf.Len()+len(p)) > w.max { w.over = true w.kill() return 0, ErrTooLarge } return w.buf.Write(p) } // run executes git and returns stdout. Stderr goes into the error. func (g *Git) run(ctx context.Context, opt runOpts, args ...string) ([]byte, error) { var out, errBuf bytes.Buffer var capped *cappedWriter if opt.maxOut > 0 { var cancel context.CancelFunc ctx, cancel = context.WithCancel(ctx) defer cancel() capped = &cappedWriter{buf: &out, max: opt.maxOut, kill: cancel} } cmd := exec.CommandContext(ctx, "git", args...) cmd.Env = g.env if len(opt.extraEnv) > 0 { cmd.Env = append(append([]string(nil), g.env...), opt.extraEnv...) } if opt.stdin != nil { cmd.Stdin = bytes.NewReader(opt.stdin) } cmd.Stdout = &out if capped != nil { cmd.Stdout = capped } cmd.Stderr = &errBuf if err := cmd.Run(); err != nil { if capped != nil && capped.over { return nil, fmt.Errorf("git %s: %w", args[0], ErrTooLarge) } return out.Bytes(), fmt.Errorf("git %s: %w: %s", args[0], err, strings.TrimSpace(errBuf.String())) } return out.Bytes(), nil } func (g *Git) text(ctx context.Context, args ...string) (string, error) { out, err := g.run(ctx, runOpts{}, args...) return string(out), err } func (g *Git) line(ctx context.Context, args ...string) (string, error) { s, err := g.text(ctx, args...) return strings.TrimSpace(s), err } // signArgs configure ssh commit signing with the server host key. func (g *Git) signArgs() []string { return []string{"-c", "gpg.format=ssh", "-c", "user.signingKey=" + g.cfg.SSHHostKeyPath} } // verifyArgs configure signature verification against the allowed_signers file. func (g *Git) verifyArgs() []string { return []string{"-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile=" + g.cfg.AllowedSignersPath()} } // SigStatus is the badge shown next to a commit. type SigStatus string const ( SigGood SigStatus = "good" SigBad SigStatus = "bad" SigUnverified SigStatus = "unverified" SigNone SigStatus = "none" ) // parseSigStatus maps git's %G? codes onto the badges. func parseSigStatus(code string) SigStatus { switch code { case "G": return SigGood case "B", "R": return SigBad case "U", "X", "Y", "E": return SigUnverified } return SigNone } type Commit struct { Hash string Subject string Author string Date string SigStatus SigStatus } type CommitMeta struct { Hash string Subject string Body string Author string Email string Date string Committer string CommitterEmail string CommitterDate string Parents []string SigStatus SigStatus } type TreeEntry struct { Mode string Type string // blob or tree Hash string Size string Name string } type BranchInfo struct { Name string ShortHash string Subject string AuthorName string Date string } type TagInfo struct { Name string ShortHash string Subject string TaggerName string Date string IsAnnotated bool } // Ident is a git author or committer identity. type Ident struct { Name string Email string } func identEnv(author, committer Ident) []string { return []string{ "GIT_AUTHOR_NAME=" + author.Name, "GIT_AUTHOR_EMAIL=" + author.Email, "GIT_COMMITTER_NAME=" + committer.Name, "GIT_COMMITTER_EMAIL=" + committer.Email, } } func splitLines(s string) []string { var out []string for _, l := range strings.Split(s, "\n") { if l != "" { out = append(out, l) } } return out } func field(parts []string, i int) string { if i < len(parts) { return parts[i] } return "" } // --- read operations --- // ObjectFormats are the hash algorithms a repo can use. var ObjectFormats = []string{"sha1", "sha256"} // Init creates a bare repo. An empty format leaves the choice to git. func (g *Git) Init(ctx context.Context, name, branch, format string) error { p, err := g.repoDir(name) if err != nil { return err } if branch == "" { branch = "main" } if !ValidRef(branch) { return fmt.Errorf("%q: %w", branch, ErrInvalidRef) } if format != "" && !slices.Contains(ObjectFormats, format) { return fmt.Errorf("%q: %w", format, ErrInvalidFormat) } m := g.lock(name) m.Lock() defer m.Unlock() return g.initAt(ctx, p, branch, format) } func (g *Git) initAt(ctx context.Context, p, branch, format string) error { args := []string{"init", "--bare", "--initial-branch=" + branch} if format != "" { args = append(args, "--object-format="+format) } _, err := g.run(ctx, runOpts{}, append(args, p)...) return err } // ObjectFormat returns the repo's hash algorithm, "sha1" or "sha256". func (g *Git) ObjectFormat(ctx context.Context, name string) (string, error) { p, err := g.repoDir(name) if err != nil { return "", err } return g.line(ctx, "-C", p, "rev-parse", "--show-object-format") } // SetObjectFormat re-creates an empty repo with another hash algorithm. // git cannot convert a repo in place. func (g *Git) SetObjectFormat(ctx context.Context, name, format string) error { p, err := g.repoDir(name) if err != nil { return err } if !slices.Contains(ObjectFormats, format) { return fmt.Errorf("%q: %w", format, ErrInvalidFormat) } m := g.lock(name) m.Lock() defer m.Unlock() ref, err := g.line(ctx, "-C", p, "for-each-ref", "--count=1", "--format=%(refname)") if err != nil { return err } if ref != "" { return ErrNotEmpty } branch, err := g.line(ctx, "-C", p, "symbolic-ref", "--short", "HEAD") if err != nil { return err } // The suffixes do not end in .git, so the startup scan skips leftovers. tmp, old := p+".reinit", p+".old" _ = os.RemoveAll(tmp) _ = os.RemoveAll(old) if err := g.initAt(ctx, tmp, branch, format); err != nil { return err } // ponytail: pushes do not take g.lock, so a push that lands after the ref // check goes to the old dir and is lost. Admin-only on an empty repo. if err := os.Rename(p, old); err != nil { _ = os.RemoveAll(tmp) return err } if err := os.Rename(tmp, p); err != nil { _ = os.Rename(old, p) return err } return os.RemoveAll(old) } // EnsureBare sets core.bare on a repo discovered on disk. func (g *Git) EnsureBare(ctx context.Context, name string) error { p, err := g.repoDir(name) if err != nil { return err } cfgFile := filepath.Join(p, "config") m := g.lock(name) m.Lock() defer m.Unlock() if cur, err := g.line(ctx, "config", "--file", cfgFile, "--get", "core.bare"); err == nil && cur == "true" { return nil } _, err = g.run(ctx, runOpts{}, "config", "--file", cfgFile, "core.bare", "true") return err } // asBadRef maps git's "this ref does not resolve" stderr onto ErrBadRef. // It covers an unknown revision, a bad default HEAD and a repo with no // commits. Any other failure is returned unchanged. func asBadRef(ref string, err error) error { msg := err.Error() switch { case strings.Contains(msg, "unknown revision"), strings.Contains(msg, "not a valid object name"), strings.Contains(msg, "bad revision"), strings.Contains(msg, "bad object"), strings.Contains(msg, "bad default revision"), strings.Contains(msg, "does not have any commits yet"), strings.Contains(msg, "ambiguous argument"): return fmt.Errorf("%q: %w", ref, ErrBadRef) } return err } // Log returns up to limit commits starting at ref, skipping skip. func (g *Git) Log(ctx context.Context, name, ref string, limit, skip int) ([]Commit, error) { p, err := g.repoDir(name) if err != nil { return nil, err } if ref == "" { ref = "HEAD" } if !ValidRef(ref) { return nil, fmt.Errorf("%q: %w", ref, ErrInvalidRef) } args := append(g.verifyArgs(), "-C", p, "log", "--format=%H%x1f%s%x1f%an%x1f%ai%x1f%G?", "--max-count="+strconv.Itoa(limit), "--skip="+strconv.Itoa(skip), // Everything after --end-of-options is data, never an option. "--end-of-options", ref, "--") out, err := g.text(ctx, args...) if err != nil { return nil, fmt.Errorf("log %s: %w", ref, asBadRef(ref, err)) } var commits []Commit for _, line := range splitLines(out) { parts := strings.Split(line, "\x1f") commits = append(commits, Commit{ Hash: field(parts, 0), Subject: field(parts, 1), Author: field(parts, 2), Date: field(parts, 3), SigStatus: parseSigStatus(field(parts, 4)), }) } return commits, nil } // LsTree lists one directory level. subpath "" means the repo root. func (g *Git) LsTree(ctx context.Context, name, ref, subpath string) ([]TreeEntry, error) { p, err := g.repoDir(name) if err != nil { return nil, err } if !ValidRef(ref) { return nil, fmt.Errorf("%q: %w", ref, ErrInvalidRef) } // A trailing `--` with no pathspec means "match nothing" to ls-tree, // so only add the separator when there is a path. args := []string{"-C", p, "ls-tree", "--long", "-z", "--end-of-options", ref} if subpath != "" { if !ValidPath(subpath) { return nil, fmt.Errorf("%q: %w", subpath, ErrInvalidRef) } args = append(args, "--", subpath+"/") } out, err := g.text(ctx, args...) if err != nil { return nil, fmt.Errorf("ls-tree %s: %w", ref, asBadRef(ref, err)) } prefix := subpath + "/" var entries []TreeEntry for _, line := range strings.Split(out, "\x00") { // format: SP SP SP TAB tab := strings.IndexByte(line, '\t') if tab < 0 { continue } meta := strings.Fields(line[:tab]) e := TreeEntry{ Mode: field(meta, 0), Type: field(meta, 1), Hash: field(meta, 2), Size: field(meta, 3), Name: line[tab+1:], } if subpath != "" { e.Name = strings.TrimPrefix(e.Name, prefix) } entries = append(entries, e) } return entries, nil } // Show returns the blob contents at ref:filePath. func (g *Git) Show(ctx context.Context, name, ref, filePath string) ([]byte, error) { p, err := g.repoDir(name) if err != nil { return nil, err } if !ValidRef(ref) { return nil, fmt.Errorf("%q: %w", ref, ErrInvalidRef) } if !ValidPath(filePath) { return nil, fmt.Errorf("%q: %w", filePath, ErrNotFound) } out, err := g.run(ctx, runOpts{}, "-C", p, "show", "--end-of-options", ref+":"+filePath) if err != nil { // A missing path is a normal answer, for example probing for a CI config. return nil, fmt.Errorf("show %s:%s: %w", ref, filePath, ErrNotFound) } return out, nil } // Diff returns the patch text for one commit. Output larger than maxBytes // stops git and returns ErrTooLarge, so a huge commit is never buffered. func (g *Git) Diff(ctx context.Context, name, sha string, maxBytes int64) (string, error) { p, err := g.repoDir(name) if err != nil { return "", err } if !ValidRef(sha) { return "", fmt.Errorf("%q: %w", sha, ErrInvalidRef) } out, err := g.run(ctx, runOpts{maxOut: maxBytes}, "-C", p, "diff-tree", "--no-commit-id", "-r", "-p", "-M", "--root", "--end-of-options", sha, "--") return string(out), err } // BlobSizes returns the sizes of the given blob ids in one git call. Ids that // do not resolve, like the all-zero id, are missing from the map. func (g *Git) BlobSizes(ctx context.Context, name string, hashes []string) (map[string]int64, error) { p, err := g.repoDir(name) if err != nil { return nil, err } var in strings.Builder for _, h := range hashes { if !ValidRef(h) { return nil, fmt.Errorf("%q: %w", h, ErrInvalidRef) } in.WriteString(h + "\n") } out, err := g.run(ctx, runOpts{stdin: []byte(in.String())}, "-C", p, "cat-file", "--batch-check") if err != nil { return nil, fmt.Errorf("cat-file: %w", err) } // One output line per input line, in input order. sizes := map[string]int64{} for i, line := range strings.Split(strings.TrimSuffix(string(out), "\n"), "\n") { f := strings.Fields(line) if i >= len(hashes) || len(f) != 3 { continue } if n, err := strconv.ParseInt(f[2], 10, 64); err == nil { sizes[hashes[i]] = n } } return sizes, nil } // FileSize returns the size of the blob at ref:filePath. A path that is not a // blob, a directory for example, is reported as not found. func (g *Git) FileSize(ctx context.Context, name, ref, filePath string) (int64, error) { p, err := g.repoDir(name) if err != nil { return 0, err } if !ValidRef(ref) || !ValidPath(filePath) { return 0, ErrInvalidRef } // Without the type a directory would answer with the tree's size, and // the streaming readers would then send an empty body. _, typ, size, err := g.objectInfo(ctx, p, ref, filePath) if err != nil || typ != "blob" { return 0, fmt.Errorf("%s:%s: %w", ref, filePath, ErrNotFound) } return size, nil } // objectInfo returns the id, type, and size of the object at rev:filePath. // All are empty when rev is empty or the path does not exist there. func (g *Git) objectInfo(ctx context.Context, p, rev, filePath string) (id, typ string, size int64, err error) { if rev == "" { return "", "", 0, nil } out, err := g.run(ctx, runOpts{stdin: []byte(rev + ":" + filePath + "\n")}, "-C", p, "cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)") if err != nil { return "", "", 0, err } line := strings.TrimSpace(string(out)) if strings.HasSuffix(line, " missing") { return "", "", 0, nil } f := strings.Fields(line) if len(f) != 3 { return "", "", 0, fmt.Errorf("cat-file: unexpected output %q", line) } size, err = strconv.ParseInt(f[2], 10, 64) return f[0], f[1], size, err } // cachedRefs serves a ref list from cache, or fills it via load. func (g *Git) cachedRefs(cache *util.Cache[string, []string], name string, load func() ([]string, error)) ([]string, error) { if v, ok := cache.Get(name); ok { return v, nil } value, err := load() if err != nil { return nil, err } cache.Set(name, value) return value, nil } // InvalidateRefCache drops the cached branch and tag lists for a repo. func (g *Git) InvalidateRefCache(name string) { g.branches.Delete(name) g.tags.Delete(name) } func (g *Git) Branches(ctx context.Context, name string) ([]string, error) { p, err := g.repoDir(name) if err != nil { return nil, err } return g.cachedRefs(g.branches, name, func() ([]string, error) { out, err := g.text(ctx, "-C", p, "for-each-ref", "--count="+strconv.Itoa(MaxRefList), "--format=%(refname:short)", "refs/heads/") if err != nil { return nil, fmt.Errorf("branches: %w", err) } return splitLines(out), nil }) } func (g *Git) Tags(ctx context.Context, name string) ([]string, error) { p, err := g.repoDir(name) if err != nil { return nil, err } return g.cachedRefs(g.tags, name, func() ([]string, error) { out, err := g.text(ctx, "-C", p, "for-each-ref", "--count="+strconv.Itoa(MaxRefList), "--format=%(refname:short)", "refs/tags/") if err != nil { return nil, fmt.Errorf("tags: %w", err) } return splitLines(out), nil }) } func (g *Git) BranchesWithInfo(ctx context.Context, name string, maxCount int) ([]BranchInfo, error) { p, err := g.repoDir(name) if err != nil { return nil, err } if maxCount <= 0 { maxCount = MaxRefList } // for-each-ref has no %x1f escape, so embed the separator byte directly. const f = "%(refname:short)\x1f%(objectname:short)\x1f%(contents:subject)\x1f%(authorname)\x1f%(authordate:iso8601)" out, err := g.text(ctx, "-C", p, "for-each-ref", "--sort=-creatordate", "--count="+strconv.Itoa(maxCount), "--format="+f, "refs/heads/") if err != nil { return nil, fmt.Errorf("branchesWithInfo: %w", err) } var list []BranchInfo for _, line := range splitLines(out) { parts := strings.Split(line, "\x1f") list = append(list, BranchInfo{ Name: field(parts, 0), ShortHash: field(parts, 1), Subject: field(parts, 2), AuthorName: field(parts, 3), Date: field(parts, 4), }) } return list, nil } func (g *Git) TagsWithInfo(ctx context.Context, name string, maxCount int) ([]TagInfo, error) { p, err := g.repoDir(name) if err != nil { return nil, err } if maxCount <= 0 { maxCount = MaxRefList } // %(*objectname:short) resolves annotated tags to their commit. It is // empty for lightweight tags, which is how we tell the two apart. const f = "%(refname:short)\x1f%(*objectname:short)\x1f%(objectname:short)\x1f%(contents:subject)\x1f%(taggername)\x1f%(creatordate:iso8601)" out, err := g.text(ctx, "-C", p, "for-each-ref", "--sort=-creatordate", "--count="+strconv.Itoa(maxCount), "--format="+f, "refs/tags/") if err != nil { return nil, fmt.Errorf("tagsWithInfo: %w", err) } var list []TagInfo for _, line := range splitLines(out) { parts := strings.Split(line, "\x1f") deref := strings.TrimSpace(field(parts, 1)) own := strings.TrimSpace(field(parts, 2)) hash := own if deref != "" { hash = deref } list = append(list, TagInfo{ Name: field(parts, 0), ShortHash: hash, Subject: field(parts, 3), TaggerName: field(parts, 4), Date: field(parts, 5), IsAnnotated: deref != "", }) } return list, nil } // DefaultBranch trusts HEAD only when it names a branch that exists. func (g *Git) DefaultBranch(ctx context.Context, name string) string { p, err := g.repoDir(name) if err != nil { return "main" } branches, err := g.Branches(ctx, name) if err != nil { return "main" } head, _ := g.line(ctx, "-C", p, "symbolic-ref", "--short", "HEAD") for _, b := range branches { if b == head { return head } } for _, want := range []string{"main", "master"} { for _, b := range branches { if b == want { return want } } } if len(branches) > 0 { return branches[0] } return "main" } // ResolveRef returns the object id a ref points at. func (g *Git) ResolveRef(ctx context.Context, name, ref string) (string, error) { p, err := g.repoDir(name) if err != nil { return "", err } if !ValidRef(ref) { return "", fmt.Errorf("%q: %w", ref, ErrInvalidRef) } out, err := g.line(ctx, "-C", p, "rev-parse", "--verify", "--end-of-options", ref) if err != nil || out == "" { return "", fmt.Errorf("%q: %w", ref, ErrBadRef) } return out, nil } // HasCommits reports whether the repo has at least one commit. func (g *Git) HasCommits(ctx context.Context, name string) bool { p, err := g.repoDir(name) if err != nil { return false } out, err := g.line(ctx, "-C", p, "log", "--oneline", "-1", "--") return err == nil && out != "" } // CommitMeta returns the full detail for one commit, including its // signature badge. func (g *Git) CommitMeta(ctx context.Context, name, sha string) (*CommitMeta, error) { p, err := g.repoDir(name) if err != nil { return nil, err } if !ValidRef(sha) { return nil, fmt.Errorf("%q: %w", sha, ErrInvalidRef) } args := append(g.verifyArgs(), "-C", p, "show", "--no-patch", "--format=%H%x1f%an%x1f%ae%x1f%ai%x1f%cn%x1f%ce%x1f%ci%x1f%P%x1f%G?", "--end-of-options", sha, "--") metaOut, err := g.line(ctx, args...) if err != nil { return nil, fmt.Errorf("commitMeta %s: %w", sha, ErrNotFound) } msgOut, err := g.text(ctx, "-C", p, "log", "--format=%B", "-1", "--end-of-options", sha, "--") if err != nil { return nil, fmt.Errorf("commitMeta message %s: %w", sha, err) } parts := strings.Split(metaOut, "\x1f") full := strings.TrimRight(msgOut, "\n") subject, body, _ := strings.Cut(full, "\n") hash := field(parts, 0) if hash == "" { hash = sha } return &CommitMeta{ Hash: hash, Subject: subject, Body: strings.TrimSpace(body), Author: field(parts, 1), Email: field(parts, 2), Date: field(parts, 3), Committer: field(parts, 4), CommitterEmail: field(parts, 5), CommitterDate: field(parts, 6), Parents: strings.Fields(field(parts, 7)), SigStatus: parseSigStatus(field(parts, 8)), }, nil } // SetHead points HEAD at a branch. func (g *Git) SetHead(ctx context.Context, name, branch string) error { p, err := g.repoDir(name) if err != nil { return err } if !ValidRef(branch) { return fmt.Errorf("%q: %w", branch, ErrInvalidRef) } _, err = g.run(ctx, runOpts{}, "-C", p, "symbolic-ref", "HEAD", "refs/heads/"+branch) return err }