// Package configrepo copies a project's editor directory — .turbo-go, // .turbo-rust — out of a repository somebody shares, from the URL a forge // shows for the directory holding it. // // A team keeps ready-made configurations in a repository: a directory per // kind of project, each with the editor's own directory inside. Rather than // cloning it and copying by hand, an editor is told the URL: // // turbo-go -load-config https://rickub.com/turbo-editors/configs/tree/main/golang-init // // and it fetches golang-init/.turbo-go into the working directory. The URL is // the one in the browser's address bar — rickub, GitHub, GitLab and Codeberg // are all understood — and the fetch is a shallow git clone, because git is // the one thing every forge speaks the same way, with no token and no API. package configrepo import ( "context" "errors" "fmt" "net/url" "os" "os/exec" "path/filepath" "strings" "rickub.com/turbo-editors/turbo-core/profile" ) // ErrNoGit is returned when git is not installed: the fetch is a clone, and // there is nothing else to do it with. var ErrNoGit = errors.New("configrepo: git is not installed") // ErrExists is returned when the working directory already has the editor's // directory. A project's configuration is a decision the project made, and // loading another over it is not something to do without being asked twice. var ErrExists = errors.New("configrepo: the project already has a configuration directory") // ErrNotFound is returned when the repository is there but the editor's // directory is not where the URL says. var ErrNotFound = errors.New("configrepo: no configuration directory at that path") // Source is a directory in a repository, as a forge's URL names it. // // src, err := configrepo.Parse("https://github.com/acme/configs/tree/main/go-service") // // Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service"} type Source struct { Host string // the forge, as in the URL Owner string // the user or organisation — or a GitLab group, slashes and all Repo string // the repository, without .git Ref string // the first segment after the tree marker; "" for the default branch Path string // the directory inside the repository; "" for its root } // Parse reads a forge's URL for a directory, a file, or a repository. // // The shapes understood are the ones the browsers show: // // https://rickub.com/owner/repo/tree/main/dir rickub, GitHub // https://gitlab.com/group/sub/repo/-/tree/main/dir GitLab // https://codeberg.org/owner/repo/src/branch/main/dir Codeberg, Forgejo, Gitea // https://github.com/owner/repo a repository: its default branch, its root // // A branch name with a slash in it cannot be told from the path here — the // URL does not say where one ends — so Ref is the first segment and the rest // is Path; Load asks the repository for its branches and settles it there. func Parse(rawURL string) (Source, error) { parsed, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil { return Source{}, fmt.Errorf("configrepo: %q is not a URL: %w", rawURL, err) } if (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" { return Source{}, fmt.Errorf("configrepo: %q is not an https URL of a repository", rawURL) } segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") if len(segments) < 2 || segments[0] == "" || segments[1] == "" { return Source{}, fmt.Errorf("configrepo: %q names no repository (want https://host/owner/repo/…)", rawURL) } repoAt, rest := treeMarker(segments) src := Source{ Host: parsed.Host, Owner: strings.Join(segments[:repoAt], "/"), Repo: strings.TrimSuffix(segments[repoAt], ".git"), } if len(rest) > 0 { src.Ref = rest[0] src.Path = strings.Join(rest[1:], "/") } return src, nil } // treeMarker finds where the repository ends and the tree begins: the index // of the repository segment, and the segments after the marker, starting with // the ref. With no marker the repository is the last segment and there is no // tree. func treeMarker(segments []string) (repoAt int, rest []string) { for i := 2; i < len(segments); i++ { switch segments[i] { case "tree", "blob": return i - 1, segments[i+1:] case "-": if i+1 < len(segments) && (segments[i+1] == "tree" || segments[i+1] == "blob") { return i - 1, segments[i+2:] } case "src": // Gitea and its forks say what kind of ref follows: src/branch/main, // src/tag/v1, src/commit/abc123. The kind is not part of the name. if i+1 < len(segments) && (segments[i+1] == "branch" || segments[i+1] == "tag" || segments[i+1] == "commit") { return i - 1, segments[i+2:] } } } return len(segments) - 1, nil } // CloneURLs returns where to clone the repository from, most likely first. // // GitHub, GitLab and Codeberg serve git at the address the browser shows. // rickub does not: its pages are on rickub.com and its repositories on // git.rickub.com, so a second candidate puts "git." in front of the host. // Load tries them in order and stops at the first that answers. func (s Source) CloneURLs() []string { path := "/" + s.Owner + "/" + s.Repo + ".git" return []string{ "https://" + s.Host + path, "https://git." + s.Host + path, } } // Result says what Load did. type Result struct { Remote string // the clone URL that answered Ref string // the branch or tag the files came from; "" for the default Dir string // the directory created Files []string // what it holds, relative to Dir, in walk order } // Load fetches the editor's directory from a repository into a project. // // The URL names a directory; the editor's own directory — p.ProjectDir(), // ".turbo-go" for Turbo Go — is looked for inside it, or the URL may name // that directory itself. It is copied into dest as dest/.turbo-go, files // and subdirectories alike. dest is normally the working directory. // // It refuses with ErrExists when dest already has such a directory, with // ErrNoGit when there is no git to clone with, and with ErrNotFound when the // repository holds no such directory where the URL points. // // result, err := configrepo.Load(ctx, golang.Profile(), url, ".") // if err != nil { // return err // } // fmt.Printf("Copied %s (%d files)\n", result.Dir, len(result.Files)) func Load(ctx context.Context, p profile.Profile, rawURL, dest string) (Result, error) { src, err := Parse(rawURL) if err != nil { return Result{}, err } return loadFrom(ctx, p, src.CloneURLs(), src, dest) } // loadFrom is Load once the URL has been read: it takes the remotes to try, // so that a test can point it at a repository on disk. func loadFrom(ctx context.Context, p profile.Profile, remotes []string, src Source, dest string) (Result, error) { target := filepath.Join(dest, p.ProjectDir()) g, err := ready(target) if err != nil { return Result{}, err } checkout, err := os.MkdirTemp("", "configrepo-*") if err != nil { return Result{}, fmt.Errorf("configrepo: %w", err) } defer os.RemoveAll(checkout) remote, ref, path, err := g.fetch(ctx, remotes, src, checkout) if err != nil { return Result{}, err } files, err := install(checkout, path, p.ProjectDir(), remote, target) if err != nil { return Result{}, err } return Result{Remote: remote, Ref: ref, Dir: target, Files: files}, nil } // ready checks what has to be true before anything is fetched — no // configuration directory in the way, a git to fetch with — and returns that // git. Both are asked first because both are answered without the network. func ready(target string) (git, error) { if _, err := os.Stat(target); err == nil { return git{}, fmt.Errorf("%w: %s", ErrExists, target) } gitPath, err := exec.LookPath("git") if err != nil { return git{}, ErrNoGit } return git{path: gitPath}, nil } // install copies the editor's directory out of a checkout into the project, // and returns what was copied. func install(checkout, path, projectDir, remote, target string) ([]string, error) { from, err := locate(checkout, path, projectDir, remote) if err != nil { return nil, err } return copyTree(from, target) } // locate returns the editor's directory inside a checkout: at path/.turbo-go, // or at path itself when the URL already named it. func locate(checkout, path, projectDir, remote string) (string, error) { candidate := filepath.Join(checkout, filepath.FromSlash(path)) if filepath.Base(candidate) != projectDir { candidate = filepath.Join(candidate, projectDir) } info, err := os.Stat(candidate) if err != nil || !info.IsDir() { where := path if where == "" { where = "the root" } return "", fmt.Errorf("%w: %s at %s of %s", ErrNotFound, projectDir, where, remote) } return candidate, nil }