turbo-editors/turbo-corepublic Fork 0
main
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
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
// 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
}