// Talking to git: which remote answers, what it calls its branches, and a // shallow clone of one of them. package configrepo import ( "context" "errors" "fmt" "os/exec" "strings" ) // git runs the git that was found on PATH. type git struct { path string } // fetch finds the repository among the remotes, settles which ref the URL // meant, and clones that ref into dir. It returns the remote that answered, // the ref, and the path inside the repository once the ref has taken its // share of it. func (g git) fetch(ctx context.Context, remotes []string, src Source, dir string) (remote, ref, path string, err error) { remote, refs, err := g.firstAnswering(ctx, remotes) if err != nil { return "", "", "", err } ref, path = resolveRef(refs, src.Ref, src.Path) if err := g.clone(ctx, remote, ref, dir); err != nil { return "", "", "", err } return remote, ref, path, nil } // firstAnswering asks each remote for its refs and returns the first that // answers, with what it said. // // The listing does two jobs: it tells the candidates apart — a forge whose // pages and repositories live on different hosts fails fast here, not after // a clone — and it is what a ref with a slash in it is resolved against. func (g git) firstAnswering(ctx context.Context, remotes []string) (string, []string, error) { var failures []error for _, remote := range remotes { refs, err := g.lsRemote(ctx, remote) if err == nil { return remote, refs, nil } failures = append(failures, err) } return "", nil, fmt.Errorf("configrepo: no repository answered: %w", errors.Join(failures...)) } // lsRemote returns the names of a remote's branches and tags. func (g git) lsRemote(ctx context.Context, remote string) ([]string, error) { out, err := g.run(ctx, "ls-remote", "--heads", "--tags", "--", remote) if err != nil { return nil, err } return parseRefs(out), nil } // parseRefs reads ls-remote's output — a hash, a tab, a ref — into names. // Peeled tags ("v1^{}") name the same tag twice and are dropped. func parseRefs(output string) []string { var names []string for _, line := range strings.Split(output, "\n") { _, ref, ok := strings.Cut(line, "\t") if !ok || strings.HasSuffix(ref, "^{}") { continue } for _, prefix := range []string{"refs/heads/", "refs/tags/"} { if name, found := strings.CutPrefix(ref, prefix); found { names = append(names, name) } } } return names } // resolveRef settles where the branch ends and the path begins. // // The URL gave a first segment and a rest; a branch called "feature/x" makes // the first segment "feature", which names nothing. So the two are joined // back together and the **longest** branch or tag that begins them wins, // with the remainder as the path. Nothing matching leaves both as they were, // and the clone then says, in git's own words, that there is no such branch. func resolveRef(refs []string, ref, path string) (string, string) { if ref == "" { return ref, path } full := ref if path != "" { full += "/" + path } best := "" for _, name := range refs { if name != full && !strings.HasPrefix(full, name+"/") { continue } if len(name) > len(best) { best = name } } if best == "" { return ref, path } return best, strings.TrimPrefix(strings.TrimPrefix(full, best), "/") } // clone makes a shallow checkout of one ref — or of the default branch when // ref is empty — into dir, which must exist and be empty. func (g git) clone(ctx context.Context, remote, ref, dir string) error { args := []string{"clone", "--quiet", "--depth", "1"} if ref != "" { args = append(args, "--branch", ref) } args = append(args, "--", remote, dir) _, err := g.run(ctx, args...) return err } // run executes git and returns what it printed, or what it complained about. func (g git) run(ctx context.Context, args ...string) (string, error) { cmd := exec.CommandContext(ctx, g.path, args...) // Never a password prompt: this runs from a command line that asked for a // URL, and a repository that needs one is reported, not waited on. cmd.Env = append(cmd.Environ(), "GIT_TERMINAL_PROMPT=0") out, err := cmd.Output() if err != nil { var exit *exec.ExitError if errors.As(err, &exit) && len(exit.Stderr) > 0 { return "", fmt.Errorf("configrepo: git %s: %s", args[0], strings.TrimSpace(string(exit.Stderr))) } return "", fmt.Errorf("configrepo: git %s: %w", args[0], err) } return string(out), nil }