| 📦 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 ago | 1 | package configrepo |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "os/exec" |
| 8 | "path/filepath" |
| 9 | "reflect" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "rickub.com/turbo-editors/turbo-core/profile" |
| 14 | ) |
| 15 | |
| 16 | func TestParseReadsTheURLsTheForgesShow(t *testing.T) { |
| 17 | cases := []struct { |
| 18 | url string |
| 19 | want Source |
| 20 | }{ |
| 21 | {"https://rickub.com/turbo-editors/configs/tree/main/golang-init", |
| 22 | Source{Host: "rickub.com", Owner: "turbo-editors", Repo: "configs", Ref: "main", Path: "golang-init"}}, |
| 23 | {"https://github.com/acme/configs/tree/main/go/service", |
| 24 | Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go/service"}}, |
| 25 | {"https://gitlab.com/acme/platform/configs/-/tree/main/go-service", |
| 26 | Source{Host: "gitlab.com", Owner: "acme/platform", Repo: "configs", Ref: "main", Path: "go-service"}}, |
| 27 | {"https://codeberg.org/acme/configs/src/branch/main/go-service", |
| 28 | Source{Host: "codeberg.org", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service"}}, |
| 29 | {"https://codeberg.org/acme/configs/src/tag/v1.0.0/go-service", |
| 30 | Source{Host: "codeberg.org", Owner: "acme", Repo: "configs", Ref: "v1.0.0", Path: "go-service"}}, |
| 31 | {"https://github.com/acme/configs", |
| 32 | Source{Host: "github.com", Owner: "acme", Repo: "configs"}}, |
| 33 | {"https://github.com/acme/configs.git", |
| 34 | Source{Host: "github.com", Owner: "acme", Repo: "configs"}}, |
| 35 | {"https://github.com/acme/configs/tree/main", |
| 36 | Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main"}}, |
| 37 | {"https://github.com/acme/configs/tree/main/go-service/", |
| 38 | Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service"}}, |
| 39 | {"https://github.com/acme/configs/blob/main/go-service/.turbo-go/settings.toml", |
| 40 | Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service/.turbo-go/settings.toml"}}, |
| 41 | {" https://rickub.com/turbo-editors/configs/tree/main/golang-init-with-agents\n", |
| 42 | Source{Host: "rickub.com", Owner: "turbo-editors", Repo: "configs", Ref: "main", Path: "golang-init-with-agents"}}, |
| 43 | } |
| 44 | for _, c := range cases { |
| 45 | got, err := Parse(c.url) |
| 46 | if err != nil { |
| 47 | t.Errorf("Parse(%q) error = %v", c.url, err) |
| 48 | continue |
| 49 | } |
| 50 | if got != c.want { |
| 51 | t.Errorf("Parse(%q) = %+v, want %+v", c.url, got, c.want) |
| 52 | } |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | func TestParseRefusesWhatIsNotARepository(t *testing.T) { |
| 57 | for _, bad := range []string{"", "configs", "https://rickub.com", "https://rickub.com/turbo-editors", "ftp://x/y/z", "not a url at all ://"} { |
| 58 | if _, err := Parse(bad); err == nil { |
| 59 | t.Errorf("Parse(%q) accepted something that names no repository", bad) |
| 60 | } |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | func TestCloneURLsTryTheHostThenItsGitSubdomain(t *testing.T) { |
| 65 | // rickub's pages are on rickub.com and its repositories on git.rickub.com. |
| 66 | src := Source{Host: "rickub.com", Owner: "turbo-editors", Repo: "configs"} |
| 67 | |
| 68 | want := []string{"https://rickub.com/turbo-editors/configs.git", "https://git.rickub.com/turbo-editors/configs.git"} |
| 69 | if got := src.CloneURLs(); !reflect.DeepEqual(got, want) { |
| 70 | t.Errorf("CloneURLs() = %v, want %v", got, want) |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | func TestResolveRefSettlesABranchWithASlashInIt(t *testing.T) { |
| 75 | refs := []string{"main", "feature/x", "feature/x/deeper", "v1.0.0"} |
| 76 | cases := []struct { |
| 77 | ref, path, wantRef, wantPath string |
| 78 | }{ |
| 79 | {"main", "golang-init", "main", "golang-init"}, |
| 80 | {"feature", "x/golang-init", "feature/x", "golang-init"}, |
| 81 | {"feature", "x/deeper/golang-init", "feature/x/deeper", "golang-init"}, |
| 82 | {"feature", "x", "feature/x", ""}, |
| 83 | {"v1.0.0", "", "v1.0.0", ""}, |
| 84 | {"nowhere", "golang-init", "nowhere", "golang-init"}, // left alone; the clone will say |
| 85 | {"", "", "", ""}, // the default branch |
| 86 | } |
| 87 | for _, c := range cases { |
| 88 | ref, path := resolveRef(refs, c.ref, c.path) |
| 89 | if ref != c.wantRef || path != c.wantPath { |
| 90 | t.Errorf("resolveRef(%q, %q) = %q, %q; want %q, %q", c.ref, c.path, ref, path, c.wantRef, c.wantPath) |
| 91 | } |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | func TestParseRefsReadsBranchesAndTagsAndDropsPeeledTags(t *testing.T) { |
| 96 | output := "abc\tHEAD\nabc\trefs/heads/main\ndef\trefs/heads/feature/x\n123\trefs/tags/v1.0.0\n456\trefs/tags/v1.0.0^{}\n" |
| 97 | |
| 98 | want := []string{"main", "feature/x", "v1.0.0"} |
| 99 | if got := parseRefs(output); !reflect.DeepEqual(got, want) { |
| 100 | t.Errorf("parseRefs() = %v, want %v", got, want) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | // testProfile is an editor whose project directory is .turbo-test. |
| 105 | func testProfile() profile.Profile { |
| 106 | return profile.Profile{Name: "Turbo Test", Slug: "turbo-test", Language: "Test"} |
| 107 | } |
| 108 | |
| 109 | // sampleRepo makes a repository on disk shaped like turbo-editors/configs: |
| 110 | // golang-init/.turbo-test with three files and a nested one, on a branch |
| 111 | // called main, and the same on a branch called feature/x with a marker file. |
| 112 | func sampleRepo(t *testing.T) string { |
| 113 | t.Helper() |
| 114 | if _, err := exec.LookPath("git"); err != nil { |
| 115 | t.Skip("git is not installed") |
| 116 | } |
| 117 | |
| 118 | repo := t.TempDir() |
| 119 | run := func(args ...string) { |
| 120 | t.Helper() |
| 121 | cmd := exec.Command("git", append([]string{"-C", repo, "-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false"}, args...)...) |
| 122 | if out, err := cmd.CombinedOutput(); err != nil { |
| 123 | t.Fatalf("git %v: %v\n%s", args, err, out) |
| 124 | } |
| 125 | } |
| 126 | write := func(name, contents string) { |
| 127 | t.Helper() |
| 128 | path := filepath.Join(repo, filepath.FromSlash(name)) |
| 129 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | run("init", "--quiet", "--initial-branch=main") |
| 138 | write("README.md", "# configs\n") |
| 139 | write("golang-init/README.md", "# golang-init\n") |
| 140 | write("golang-init/.turbo-test/settings.toml", "theme = \"turbo-dark\"\n") |
| 141 | write("golang-init/.turbo-test/snippets.toml", "[[snippet]]\n") |
| 142 | write("golang-init/.turbo-test/tools.toml", "[[tool]]\n") |
| 143 | write("golang-init/.turbo-test/agents/bob.yaml", "name: bob\n") |
| 144 | write("empty-init/README.md", "nothing for the editor here\n") |
| 145 | run("add", "-A") |
| 146 | run("commit", "--quiet", "-m", "sample") |
| 147 | run("checkout", "--quiet", "-b", "feature/x") |
| 148 | write("golang-init/.turbo-test/feature.txt", "from the branch\n") |
| 149 | run("add", "-A") |
| 150 | run("commit", "--quiet", "-m", "branch") |
| 151 | run("checkout", "--quiet", "main") |
| 152 | return repo |
| 153 | } |
| 154 | |
| 155 | func TestLoadCopiesTheEditorsDirectoryIntoTheProject(t *testing.T) { |
| 156 | repo := sampleRepo(t) |
| 157 | dest := t.TempDir() |
| 158 | |
| 159 | result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "golang-init"}, dest) |
| 160 | if err != nil { |
| 161 | t.Fatalf("loadFrom() error = %v", err) |
| 162 | } |
| 163 | |
| 164 | if result.Dir != filepath.Join(dest, ".turbo-test") || result.Remote != repo || result.Ref != "main" { |
| 165 | t.Errorf("Result = %+v", result) |
| 166 | } |
| 167 | want := []string{"agents/bob.yaml", "settings.toml", "snippets.toml", "tools.toml"} |
| 168 | if !reflect.DeepEqual(result.Files, want) { |
| 169 | t.Errorf("Files = %v, want %v", result.Files, want) |
| 170 | } |
| 171 | data, err := os.ReadFile(filepath.Join(dest, ".turbo-test", "settings.toml")) |
| 172 | if err != nil || string(data) != "theme = \"turbo-dark\"\n" { |
| 173 | t.Errorf("settings.toml = %q, %v", data, err) |
| 174 | } |
| 175 | if _, err := os.Stat(filepath.Join(dest, "README.md")); err == nil { |
| 176 | t.Error("the directory's README came along; only the editor's directory should") |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | func TestLoadTakesAURLThatAlreadyNamesTheEditorsDirectory(t *testing.T) { |
| 181 | repo := sampleRepo(t) |
| 182 | dest := t.TempDir() |
| 183 | |
| 184 | result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "golang-init/.turbo-test"}, dest) |
| 185 | if err != nil { |
| 186 | t.Fatalf("loadFrom() error = %v", err) |
| 187 | } |
| 188 | if len(result.Files) != 4 { |
| 189 | t.Errorf("Files = %v, want the four files", result.Files) |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | func TestLoadResolvesABranchWithASlashAgainstTheRepository(t *testing.T) { |
| 194 | // The URL .../tree/feature/x/golang-init reads as ref "feature", path |
| 195 | // "x/golang-init"; only the repository knows the branch is feature/x. |
| 196 | repo := sampleRepo(t) |
| 197 | dest := t.TempDir() |
| 198 | |
| 199 | result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "feature", Path: "x/golang-init"}, dest) |
| 200 | if err != nil { |
| 201 | t.Fatalf("loadFrom() error = %v", err) |
| 202 | } |
| 203 | if result.Ref != "feature/x" { |
| 204 | t.Errorf("Ref = %q, want feature/x", result.Ref) |
| 205 | } |
| 206 | if _, err := os.Stat(filepath.Join(dest, ".turbo-test", "feature.txt")); err != nil { |
| 207 | t.Error("the file only the branch has did not come: the wrong ref was cloned") |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | func TestLoadUsesTheDefaultBranchWhenTheURLNamesNone(t *testing.T) { |
| 212 | repo := sampleRepo(t) |
| 213 | dest := t.TempDir() |
| 214 | |
| 215 | result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Path: "golang-init"}, dest) |
| 216 | if err != nil { |
| 217 | t.Fatalf("loadFrom() error = %v", err) |
| 218 | } |
| 219 | if result.Ref != "" { |
| 220 | t.Errorf("Ref = %q, want the default branch (empty)", result.Ref) |
| 221 | } |
| 222 | if _, err := os.Stat(filepath.Join(dest, ".turbo-test", "feature.txt")); err == nil { |
| 223 | t.Error("a file from the feature branch came from the default branch") |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | func TestLoadRefusesToOverwriteAConfigurationTheProjectHas(t *testing.T) { |
| 228 | repo := sampleRepo(t) |
| 229 | dest := t.TempDir() |
| 230 | if err := os.MkdirAll(filepath.Join(dest, ".turbo-test"), 0o755); err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | kept := filepath.Join(dest, ".turbo-test", "settings.toml") |
| 234 | if err := os.WriteFile(kept, []byte("mine\n"), 0o644); err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | |
| 238 | _, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "golang-init"}, dest) |
| 239 | |
| 240 | if !errors.Is(err, ErrExists) { |
| 241 | t.Fatalf("error = %v, want ErrExists", err) |
| 242 | } |
| 243 | if data, _ := os.ReadFile(kept); string(data) != "mine\n" { |
| 244 | t.Errorf("the project's own settings were overwritten: %q", data) |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | func TestLoadSaysWhenTheDirectoryHasNoConfiguration(t *testing.T) { |
| 249 | repo := sampleRepo(t) |
| 250 | |
| 251 | _, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "empty-init"}, t.TempDir()) |
| 252 | |
| 253 | if !errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), ".turbo-test at empty-init") { |
| 254 | t.Errorf("error = %v, want ErrNotFound naming the directory and the path", err) |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | func TestLoadReportsABranchThatDoesNotExistInGitsWords(t *testing.T) { |
| 259 | repo := sampleRepo(t) |
| 260 | |
| 261 | _, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "nowhere", Path: "golang-init"}, t.TempDir()) |
| 262 | |
| 263 | if err == nil || !strings.Contains(err.Error(), "nowhere") { |
| 264 | t.Errorf("error = %v, want git's complaint about the branch", err) |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | func TestLoadFallsThroughToTheRemoteThatAnswers(t *testing.T) { |
| 269 | // rickub.com does not serve git; git.rickub.com does. The first candidate |
| 270 | // failing must cost a round trip, not the load. |
| 271 | repo := sampleRepo(t) |
| 272 | dest := t.TempDir() |
| 273 | |
| 274 | result, err := loadFrom(context.Background(), testProfile(), []string{filepath.Join(t.TempDir(), "nowhere.git"), repo}, Source{Ref: "main", Path: "golang-init"}, dest) |
| 275 | if err != nil { |
| 276 | t.Fatalf("loadFrom() error = %v", err) |
| 277 | } |
| 278 | if result.Remote != repo { |
| 279 | t.Errorf("Remote = %q, want the one that answered", result.Remote) |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | func TestLoadNamesEveryRemoteWhenNoneAnswers(t *testing.T) { |
| 284 | sampleRepo(t) // for the git skip |
| 285 | |
| 286 | _, err := loadFrom(context.Background(), testProfile(), []string{filepath.Join(t.TempDir(), "a.git"), filepath.Join(t.TempDir(), "b.git")}, Source{}, t.TempDir()) |
| 287 | |
| 288 | if err == nil || !strings.Contains(err.Error(), "a.git") || !strings.Contains(err.Error(), "b.git") { |
| 289 | t.Errorf("error = %v, want both remotes named", err) |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | func TestLoadFromRickubOverTheNetwork(t *testing.T) { |
| 294 | // The real thing, against the repository the feature was asked for. Off by |
| 295 | // default: the suite must pass on a machine with no network. |
| 296 | if os.Getenv("TURBO_CORE_NETWORK") == "" { |
| 297 | t.Skip("TURBO_CORE_NETWORK is not set") |
| 298 | } |
| 299 | dest := t.TempDir() |
| 300 | p := profile.Profile{Name: "Turbo Go", Slug: "turbo-go", Language: "Go"} |
| 301 | |
| 302 | result, err := Load(context.Background(), p, "https://rickub.com/turbo-editors/configs/tree/main/golang-init", dest) |
| 303 | if err != nil { |
| 304 | t.Fatalf("Load() error = %v", err) |
| 305 | } |
| 306 | if result.Remote != "https://git.rickub.com/turbo-editors/configs.git" { |
| 307 | t.Errorf("Remote = %q", result.Remote) |
| 308 | } |
| 309 | for _, name := range []string{"settings.toml", "snippets.toml", "tools.toml"} { |
| 310 | if _, err := os.Stat(filepath.Join(dest, ".turbo-go", name)); err != nil { |
| 311 | t.Errorf("%s did not arrive: %v", name, err) |
| 312 | } |
| 313 | } |
| 314 | } |