| 📦 Turbo Core — files rewritten outside reload into their windows; the agent window wraps what you type and keeps a long paste aside as a token (#28); configrepo fetches a shared .turbo-<slug> from a forge URL (#25) fe23288 k33g 11h ago | 1 | // Package configrepo copies a project's editor directory — .turbo-go, |
| 2 | // .turbo-rust — out of a repository somebody shares, from the URL a forge |
| 3 | // shows for the directory holding it. |
| 4 | // |
| 5 | // A team keeps ready-made configurations in a repository: a directory per |
| 6 | // kind of project, each with the editor's own directory inside. Rather than |
| 7 | // cloning it and copying by hand, an editor is told the URL: |
| 8 | // |
| 9 | // turbo-go -load-config https://rickub.com/turbo-editors/configs/tree/main/golang-init |
| 10 | // |
| 11 | // and it fetches golang-init/.turbo-go into the working directory. The URL is |
| 12 | // the one in the browser's address bar — rickub, GitHub, GitLab and Codeberg |
| 13 | // are all understood — and the fetch is a shallow git clone, because git is |
| 14 | // the one thing every forge speaks the same way, with no token and no API. |
| 15 | package configrepo |
| 16 | |
| 17 | import ( |
| 18 | "context" |
| 19 | "errors" |
| 20 | "fmt" |
| 21 | "net/url" |
| 22 | "os" |
| 23 | "os/exec" |
| 24 | "path/filepath" |
| 25 | "strings" |
| 26 | |
| 27 | "rickub.com/turbo-editors/turbo-core/profile" |
| 28 | ) |
| 29 | |
| 30 | // ErrNoGit is returned when git is not installed: the fetch is a clone, and |
| 31 | // there is nothing else to do it with. |
| 32 | var ErrNoGit = errors.New("configrepo: git is not installed") |
| 33 | |
| 34 | // ErrExists is returned when the working directory already has the editor's |
| 35 | // directory. A project's configuration is a decision the project made, and |
| 36 | // loading another over it is not something to do without being asked twice. |
| 37 | var ErrExists = errors.New("configrepo: the project already has a configuration directory") |
| 38 | |
| 39 | // ErrNotFound is returned when the repository is there but the editor's |
| 40 | // directory is not where the URL says. |
| 41 | var ErrNotFound = errors.New("configrepo: no configuration directory at that path") |
| 42 | |
| 43 | // Source is a directory in a repository, as a forge's URL names it. |
| 44 | // |
| 45 | // src, err := configrepo.Parse("https://github.com/acme/configs/tree/main/go-service") |
| 46 | // // Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service"} |
| 47 | type Source struct { |
| 48 | Host string // the forge, as in the URL |
| 49 | Owner string // the user or organisation — or a GitLab group, slashes and all |
| 50 | Repo string // the repository, without .git |
| 51 | Ref string // the first segment after the tree marker; "" for the default branch |
| 52 | Path string // the directory inside the repository; "" for its root |
| 53 | } |
| 54 | |
| 55 | // Parse reads a forge's URL for a directory, a file, or a repository. |
| 56 | // |
| 57 | // The shapes understood are the ones the browsers show: |
| 58 | // |
| 59 | // https://rickub.com/owner/repo/tree/main/dir rickub, GitHub |
| 60 | // https://gitlab.com/group/sub/repo/-/tree/main/dir GitLab |
| 61 | // https://codeberg.org/owner/repo/src/branch/main/dir Codeberg, Forgejo, Gitea |
| 62 | // https://github.com/owner/repo a repository: its default branch, its root |
| 63 | // |
| 64 | // A branch name with a slash in it cannot be told from the path here — the |
| 65 | // URL does not say where one ends — so Ref is the first segment and the rest |
| 66 | // is Path; Load asks the repository for its branches and settles it there. |
| 67 | func Parse(rawURL string) (Source, error) { |
| 68 | parsed, err := url.Parse(strings.TrimSpace(rawURL)) |
| 69 | if err != nil { |
| 70 | return Source{}, fmt.Errorf("configrepo: %q is not a URL: %w", rawURL, err) |
| 71 | } |
| 72 | if (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" { |
| 73 | return Source{}, fmt.Errorf("configrepo: %q is not an https URL of a repository", rawURL) |
| 74 | } |
| 75 | |
| 76 | segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") |
| 77 | if len(segments) < 2 || segments[0] == "" || segments[1] == "" { |
| 78 | return Source{}, fmt.Errorf("configrepo: %q names no repository (want https://host/owner/repo/…)", rawURL) |
| 79 | } |
| 80 | |
| 81 | repoAt, rest := treeMarker(segments) |
| 82 | src := Source{ |
| 83 | Host: parsed.Host, |
| 84 | Owner: strings.Join(segments[:repoAt], "/"), |
| 85 | Repo: strings.TrimSuffix(segments[repoAt], ".git"), |
| 86 | } |
| 87 | if len(rest) > 0 { |
| 88 | src.Ref = rest[0] |
| 89 | src.Path = strings.Join(rest[1:], "/") |
| 90 | } |
| 91 | return src, nil |
| 92 | } |
| 93 | |
| 94 | // treeMarker finds where the repository ends and the tree begins: the index |
| 95 | // of the repository segment, and the segments after the marker, starting with |
| 96 | // the ref. With no marker the repository is the last segment and there is no |
| 97 | // tree. |
| 98 | func treeMarker(segments []string) (repoAt int, rest []string) { |
| 99 | for i := 2; i < len(segments); i++ { |
| 100 | switch segments[i] { |
| 101 | case "tree", "blob": |
| 102 | return i - 1, segments[i+1:] |
| 103 | case "-": |
| 104 | if i+1 < len(segments) && (segments[i+1] == "tree" || segments[i+1] == "blob") { |
| 105 | return i - 1, segments[i+2:] |
| 106 | } |
| 107 | case "src": |
| 108 | // Gitea and its forks say what kind of ref follows: src/branch/main, |
| 109 | // src/tag/v1, src/commit/abc123. The kind is not part of the name. |
| 110 | if i+1 < len(segments) && (segments[i+1] == "branch" || segments[i+1] == "tag" || segments[i+1] == "commit") { |
| 111 | return i - 1, segments[i+2:] |
| 112 | } |
| 113 | } |
| 114 | } |
| 115 | return len(segments) - 1, nil |
| 116 | } |
| 117 | |
| 118 | // CloneURLs returns where to clone the repository from, most likely first. |
| 119 | // |
| 120 | // GitHub, GitLab and Codeberg serve git at the address the browser shows. |
| 121 | // rickub does not: its pages are on rickub.com and its repositories on |
| 122 | // git.rickub.com, so a second candidate puts "git." in front of the host. |
| 123 | // Load tries them in order and stops at the first that answers. |
| 124 | func (s Source) CloneURLs() []string { |
| 125 | path := "/" + s.Owner + "/" + s.Repo + ".git" |
| 126 | return []string{ |
| 127 | "https://" + s.Host + path, |
| 128 | "https://git." + s.Host + path, |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | // Result says what Load did. |
| 133 | type Result struct { |
| 134 | Remote string // the clone URL that answered |
| 135 | Ref string // the branch or tag the files came from; "" for the default |
| 136 | Dir string // the directory created |
| 137 | Files []string // what it holds, relative to Dir, in walk order |
| 138 | } |
| 139 | |
| 140 | // Load fetches the editor's directory from a repository into a project. |
| 141 | // |
| 142 | // The URL names a directory; the editor's own directory — p.ProjectDir(), |
| 143 | // ".turbo-go" for Turbo Go — is looked for inside it, or the URL may name |
| 144 | // that directory itself. It is copied into dest as dest/.turbo-go, files |
| 145 | // and subdirectories alike. dest is normally the working directory. |
| 146 | // |
| 147 | // It refuses with ErrExists when dest already has such a directory, with |
| 148 | // ErrNoGit when there is no git to clone with, and with ErrNotFound when the |
| 149 | // repository holds no such directory where the URL points. |
| 150 | // |
| 151 | // result, err := configrepo.Load(ctx, golang.Profile(), url, ".") |
| 152 | // if err != nil { |
| 153 | // return err |
| 154 | // } |
| 155 | // fmt.Printf("Copied %s (%d files)\n", result.Dir, len(result.Files)) |
| 156 | func Load(ctx context.Context, p profile.Profile, rawURL, dest string) (Result, error) { |
| 157 | src, err := Parse(rawURL) |
| 158 | if err != nil { |
| 159 | return Result{}, err |
| 160 | } |
| 161 | return loadFrom(ctx, p, src.CloneURLs(), src, dest) |
| 162 | } |
| 163 | |
| 164 | // loadFrom is Load once the URL has been read: it takes the remotes to try, |
| 165 | // so that a test can point it at a repository on disk. |
| 166 | func loadFrom(ctx context.Context, p profile.Profile, remotes []string, src Source, dest string) (Result, error) { |
| 167 | target := filepath.Join(dest, p.ProjectDir()) |
| 168 | g, err := ready(target) |
| 169 | if err != nil { |
| 170 | return Result{}, err |
| 171 | } |
| 172 | |
| 173 | checkout, err := os.MkdirTemp("", "configrepo-*") |
| 174 | if err != nil { |
| 175 | return Result{}, fmt.Errorf("configrepo: %w", err) |
| 176 | } |
| 177 | defer os.RemoveAll(checkout) |
| 178 | |
| 179 | remote, ref, path, err := g.fetch(ctx, remotes, src, checkout) |
| 180 | if err != nil { |
| 181 | return Result{}, err |
| 182 | } |
| 183 | files, err := install(checkout, path, p.ProjectDir(), remote, target) |
| 184 | if err != nil { |
| 185 | return Result{}, err |
| 186 | } |
| 187 | return Result{Remote: remote, Ref: ref, Dir: target, Files: files}, nil |
| 188 | } |
| 189 | |
| 190 | // ready checks what has to be true before anything is fetched — no |
| 191 | // configuration directory in the way, a git to fetch with — and returns that |
| 192 | // git. Both are asked first because both are answered without the network. |
| 193 | func ready(target string) (git, error) { |
| 194 | if _, err := os.Stat(target); err == nil { |
| 195 | return git{}, fmt.Errorf("%w: %s", ErrExists, target) |
| 196 | } |
| 197 | gitPath, err := exec.LookPath("git") |
| 198 | if err != nil { |
| 199 | return git{}, ErrNoGit |
| 200 | } |
| 201 | return git{path: gitPath}, nil |
| 202 | } |
| 203 | |
| 204 | // install copies the editor's directory out of a checkout into the project, |
| 205 | // and returns what was copied. |
| 206 | func install(checkout, path, projectDir, remote, target string) ([]string, error) { |
| 207 | from, err := locate(checkout, path, projectDir, remote) |
| 208 | if err != nil { |
| 209 | return nil, err |
| 210 | } |
| 211 | return copyTree(from, target) |
| 212 | } |
| 213 | |
| 214 | // locate returns the editor's directory inside a checkout: at path/.turbo-go, |
| 215 | // or at path itself when the URL already named it. |
| 216 | func locate(checkout, path, projectDir, remote string) (string, error) { |
| 217 | candidate := filepath.Join(checkout, filepath.FromSlash(path)) |
| 218 | if filepath.Base(candidate) != projectDir { |
| 219 | candidate = filepath.Join(candidate, projectDir) |
| 220 | } |
| 221 | |
| 222 | info, err := os.Stat(candidate) |
| 223 | if err != nil || !info.IsDir() { |
| 224 | where := path |
| 225 | if where == "" { |
| 226 | where = "the root" |
| 227 | } |
| 228 | return "", fmt.Errorf("%w: %s at %s of %s", ErrNotFound, projectDir, where, remote) |
| 229 | } |
| 230 | return candidate, nil |
| 231 | } |