From 570c747959a5af171eefd5cf4d429d1a5fda9aff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 2 Jun 2026 19:37:10 +0100 Subject: [PATCH] feat(worktree): support ** and brace globs in copy_files patterns (#1248) --- apps/backend/go.mod | 1 + apps/backend/go.sum | 2 + .../internal/worktree/copyfiles/copyfiles.go | 42 ++++- .../worktree/copyfiles/copyfiles_test.go | 170 ++++++++++++++++++ .../components/settings/repository-card.tsx | 13 +- .../settings/repository-copy-files-help.tsx | 73 ++++++++ 6 files changed, 288 insertions(+), 13 deletions(-) create mode 100644 apps/web/components/settings/repository-copy-files-help.tsx diff --git a/apps/backend/go.mod b/apps/backend/go.mod index b5e02e345e..20ab0865d8 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( github.com/UserExistsError/conpty v0.1.4 + github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/coder/acp-go-sdk v0.13.0 github.com/creack/pty v1.1.21 github.com/docker/docker v28.5.2+incompatible diff --git a/apps/backend/go.sum b/apps/backend/go.sum index 853170460c..077ec789d3 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -50,6 +50,8 @@ github.com/UserExistsError/conpty v0.1.4 h1:+3FhJhiqhyEJa+K5qaK3/w6w+sN3Nh9O9VbJ github.com/UserExistsError/conpty v0.1.4/go.mod h1:PDglKIkX3O/2xVk0MV9a6bCWxRmPVfxqZoTG/5sSd9I= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= diff --git a/apps/backend/internal/worktree/copyfiles/copyfiles.go b/apps/backend/internal/worktree/copyfiles/copyfiles.go index 0c8f2b239e..fd0093608a 100644 --- a/apps/backend/internal/worktree/copyfiles/copyfiles.go +++ b/apps/backend/internal/worktree/copyfiles/copyfiles.go @@ -4,6 +4,13 @@ // seeds a new worktree with environment / config files that are normally // gitignored. // +// Pattern syntax (via github.com/bmatcuk/doublestar): +// - `*` matches any run of non-separator characters +// - `?` matches a single non-separator character +// - `[abc]` / `[a-z]` character classes +// - `**` matches zero or more path segments (e.g. `**/.env` or `apps/**/config.yml`) +// - `{a,b}` brace alternation +// // Two entry points: // // - Copy: host-side, streams files directly to disk. Used by the worktree @@ -24,6 +31,7 @@ import ( "path/filepath" "strings" + "github.com/bmatcuk/doublestar/v4" "go.uber.org/zap" ) @@ -45,11 +53,13 @@ type Entry struct { // Parse splits a comma-separated user spec into trimmed, deduplicated, // non-empty patterns. Order is preserved (first occurrence wins on dedupe). +// Commas inside `{...}` are treated as part of the pattern (brace alternation), +// so `config/{local,dev}.yml` is parsed as a single pattern. func Parse(spec string) []string { if spec == "" { return nil } - parts := strings.Split(spec, ",") + parts := splitTopLevelCommas(spec) out := make([]string, 0, len(parts)) seen := make(map[string]struct{}, len(parts)) for _, p := range parts { @@ -69,6 +79,32 @@ func Parse(spec string) []string { return out } +// splitTopLevelCommas splits s on commas that sit outside any `{...}` group, +// so brace alternation patterns like `config/{local,dev}.yml` survive intact. +// Nested braces are tracked; an unbalanced `}` is treated as a literal. +func splitTopLevelCommas(s string) []string { + out := make([]string, 0, 4) + depth := 0 + start := 0 + for i := 0; i < len(s); i++ { + switch s[i] { + case '{': + depth++ + case '}': + if depth > 0 { + depth-- + } + case ',': + if depth == 0 { + out = append(out, s[start:i]) + start = i + 1 + } + } + } + out = append(out, s[start:]) + return out +} + // Copy resolves each pattern relative to sourceDir and copies matches into // targetDir, preserving relative paths. It returns the relative paths of // files newly written into targetDir (skip-if-exists matches are NOT @@ -378,6 +414,8 @@ func (s *copyState) debug(msg string, fields ...zap.Field) { } // expandPattern handles literal files, literal directories, and globs. +// Globs use doublestar syntax — see the package doc for the full grammar +// (notably `**` for recursive descent and `{a,b}` for alternation). func (s *copyState) expandPattern(pattern string) error { joined := pattern if !filepath.IsAbs(pattern) { @@ -389,7 +427,7 @@ func (s *copyState) expandPattern(pattern string) error { return s.handleMatch(joined, pattern) } - matches, err := filepath.Glob(joined) + matches, err := doublestar.FilepathGlob(joined) if err != nil { s.warn("invalid pattern %q: %v", pattern, err) return nil diff --git a/apps/backend/internal/worktree/copyfiles/copyfiles_test.go b/apps/backend/internal/worktree/copyfiles/copyfiles_test.go index a9f5fef2f5..d4a346b51e 100644 --- a/apps/backend/internal/worktree/copyfiles/copyfiles_test.go +++ b/apps/backend/internal/worktree/copyfiles/copyfiles_test.go @@ -25,6 +25,13 @@ func TestParse(t *testing.T) { {"two", ".env,.env.local", []string{".env", ".env.local"}}, {"dedupe", ".env, .env, .env.local", []string{".env", ".env.local"}}, {"empties dropped", " .env , , .env.local ", []string{".env", ".env.local"}}, + {"brace keeps comma", "config/{local,dev}.yml", []string{"config/{local,dev}.yml"}}, + { + "brace with siblings", + ".env, config/{local,dev}.yml, .env.local", + []string{".env", "config/{local,dev}.yml", ".env.local"}, + }, + {"nested braces", "{a,{b,c}}.txt", []string{"{a,{b,c}}.txt"}}, } for _, tc := range cases { @@ -184,6 +191,130 @@ func TestCopy_MissingPattern(t *testing.T) { } } +func TestCopy_DoubleStarRecursive(t *testing.T) { + t.Parallel() + src := t.TempDir() + dst := t.TempDir() + + writeFile(t, filepath.Join(src, ".env"), "ROOT", 0o644) + writeFile(t, filepath.Join(src, "apps", "web", ".env"), "WEB", 0o644) + writeFile(t, filepath.Join(src, "apps", "backend", ".env"), "BACK", 0o644) + writeFile(t, filepath.Join(src, "apps", "backend", "nested", ".env"), "DEEP", 0o644) + writeFile(t, filepath.Join(src, "apps", "web", "ignore.txt"), "IGN", 0o644) + + _, warnings, err := Copy(context.Background(), src, dst, []string{"**/.env"}, nil) + if err != nil { + t.Fatalf("Copy err: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("warnings: %v", warnings) + } + cases := map[string]string{ + ".env": "ROOT", + "apps/web/.env": "WEB", + "apps/backend/.env": "BACK", + "apps/backend/nested/.env": "DEEP", + } + for rel, want := range cases { + got := readFile(t, filepath.Join(dst, filepath.FromSlash(rel))) + if got != want { + t.Fatalf("%s = %q, want %q", rel, got, want) + } + } + if _, err := os.Stat(filepath.Join(dst, "apps", "web", "ignore.txt")); !os.IsNotExist(err) { + t.Fatalf("ignore.txt should not exist, err=%v", err) + } +} + +func TestCopy_DoubleStarScoped(t *testing.T) { + t.Parallel() + src := t.TempDir() + dst := t.TempDir() + + writeFile(t, filepath.Join(src, "apps", "web", "config.yml"), "WEB", 0o644) + writeFile(t, filepath.Join(src, "apps", "backend", "deep", "config.yml"), "BACK", 0o644) + writeFile(t, filepath.Join(src, "services", "config.yml"), "SVC", 0o644) + + _, warnings, err := Copy(context.Background(), src, dst, []string{"apps/**/config.yml"}, nil) + if err != nil { + t.Fatalf("Copy err: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("warnings: %v", warnings) + } + if readFile(t, filepath.Join(dst, "apps", "web", "config.yml")) != "WEB" { + t.Fatalf("apps/web/config.yml missing") + } + if readFile(t, filepath.Join(dst, "apps", "backend", "deep", "config.yml")) != "BACK" { + t.Fatalf("apps/backend/deep/config.yml missing") + } + if _, err := os.Stat(filepath.Join(dst, "services", "config.yml")); !os.IsNotExist(err) { + t.Fatalf("services/config.yml should not exist, err=%v", err) + } +} + +func TestCopy_BraceAlternation(t *testing.T) { + t.Parallel() + src := t.TempDir() + dst := t.TempDir() + + writeFile(t, filepath.Join(src, ".env"), "E", 0o644) + writeFile(t, filepath.Join(src, ".env.local"), "L", 0o644) + writeFile(t, filepath.Join(src, ".envrc"), "R", 0o644) + + _, warnings, err := Copy(context.Background(), src, dst, []string{".env{,.local}"}, nil) + if err != nil { + t.Fatalf("Copy err: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("warnings: %v", warnings) + } + if readFile(t, filepath.Join(dst, ".env")) != "E" { + t.Fatalf(".env missing") + } + if readFile(t, filepath.Join(dst, ".env.local")) != "L" { + t.Fatalf(".env.local missing") + } + if _, err := os.Stat(filepath.Join(dst, ".envrc")); !os.IsNotExist(err) { + t.Fatalf(".envrc should not be copied, err=%v", err) + } +} + +// TestCopy_ParseBraceRoundTrip exercises the real ingestion path: a +// comma-separated user spec containing a brace pattern with an internal comma +// must survive Parse and reach the glob engine intact. +func TestCopy_ParseBraceRoundTrip(t *testing.T) { + t.Parallel() + src := t.TempDir() + dst := t.TempDir() + + writeFile(t, filepath.Join(src, ".env"), "E", 0o644) + writeFile(t, filepath.Join(src, "config", "local.yml"), "L", 0o644) + writeFile(t, filepath.Join(src, "config", "dev.yml"), "D", 0o644) + writeFile(t, filepath.Join(src, "config", "prod.yml"), "P", 0o644) + + patterns := Parse(".env, config/{local,dev}.yml") + _, warnings, err := Copy(context.Background(), src, dst, patterns, nil) + if err != nil { + t.Fatalf("Copy err: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("warnings: %v", warnings) + } + if readFile(t, filepath.Join(dst, ".env")) != "E" { + t.Fatalf(".env missing") + } + if readFile(t, filepath.Join(dst, "config", "local.yml")) != "L" { + t.Fatalf("config/local.yml missing") + } + if readFile(t, filepath.Join(dst, "config", "dev.yml")) != "D" { + t.Fatalf("config/dev.yml missing") + } + if _, err := os.Stat(filepath.Join(dst, "config", "prod.yml")); !os.IsNotExist(err) { + t.Fatalf("config/prod.yml should not be copied, err=%v", err) + } +} + func TestCopy_GlobNoMatch(t *testing.T) { t.Parallel() src := t.TempDir() @@ -460,6 +591,45 @@ func TestPlan_HappyPath(t *testing.T) { } } +// TestPlan_DoubleStarRecursive mirrors TestCopy_DoubleStarRecursive but goes +// through Plan to guard against future regressions in the planMode branch. +func TestPlan_DoubleStarRecursive(t *testing.T) { + t.Parallel() + src := t.TempDir() + + writeFile(t, filepath.Join(src, ".env"), "root", 0o644) + writeFile(t, filepath.Join(src, "apps", "web", ".env"), "web", 0o644) + writeFile(t, filepath.Join(src, "apps", "backend", ".env"), "backend", 0o644) + writeFile(t, filepath.Join(src, "apps", "backend", "nested", ".env"), "nested", 0o644) + writeFile(t, filepath.Join(src, "README.md"), "readme", 0o644) + + entries, warnings, err := Plan(context.Background(), src, []string{"**/.env"}, nil) + if err != nil { + t.Fatalf("Plan err: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %v", warnings) + } + got := map[string]string{} + for _, e := range entries { + got[e.RelPath] = string(e.Content) + } + want := map[string]string{ + ".env": "root", + "apps/web/.env": "web", + "apps/backend/.env": "backend", + "apps/backend/nested/.env": "nested", + } + for rel, content := range want { + if got[rel] != content { + t.Errorf("entry %q = %q, want %q", rel, got[rel], content) + } + } + if _, ok := got["README.md"]; ok { + t.Errorf("README.md should not be planned") + } +} + func TestPlan_OversizedFileSkipped(t *testing.T) { t.Parallel() src := t.TempDir() diff --git a/apps/web/components/settings/repository-card.tsx b/apps/web/components/settings/repository-card.tsx index f003895eec..d12cfe799b 100644 --- a/apps/web/components/settings/repository-card.tsx +++ b/apps/web/components/settings/repository-card.tsx @@ -14,6 +14,7 @@ import { useToast } from "@/components/toast-provider"; import { UnsavedChangesBadge, UnsavedSaveButton } from "@/components/settings/unsaved-indicator"; import { EditableCard } from "@/components/settings/editable-card"; import { DeleteRepositoryDialog } from "@/components/settings/repository-delete-dialog"; +import { CopyFilesField } from "@/components/settings/repository-copy-files-help"; import { getRepositoryActiveSessionCountAction } from "@/app/actions/workspaces"; import type { Repository, RepositoryScript } from "@/lib/types/http"; @@ -165,17 +166,7 @@ function RepositoryScriptFields({

-
- -