turbo-editors/turbo-corepublic Fork 0
fe2328870c726beecc5bc34fd3d05acba9fec94f
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

configrepo.go · 231 lines · 8.6 KBGo Blame HistoryRaw
📦 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 12h ago1// 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.
15package configrepo
16
17import (
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.
32var 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.
37var 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.
41var 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"}
47type 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.
67func 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.
98func 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.
124func (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.
133type 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))
156func 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.
166func 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.
193func 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.
206func 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.
216func 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}