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({
-
+
>
);
}
diff --git a/apps/web/components/settings/repository-copy-files-help.tsx b/apps/web/components/settings/repository-copy-files-help.tsx
new file mode 100644
index 0000000000..850194d6e4
--- /dev/null
+++ b/apps/web/components/settings/repository-copy-files-help.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { IconInfoCircle } from "@tabler/icons-react";
+import { Label } from "@kandev/ui/label";
+import { Textarea } from "@kandev/ui/textarea";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
+import type { Repository } from "@/lib/types/http";
+
+type CopyFilesFieldProps = {
+ repositoryId: string;
+ copyFiles: string;
+ onUpdate: (repoId: string, updates: Partial) => void;
+};
+
+export function CopyFilesField({ repositoryId, copyFiles, onUpdate }: CopyFilesFieldProps) {
+ return (
+
+ );
+}
+
+function CopyFilesHelpTip() {
+ return (
+
+
+
+
+
+
+
+ Paths are resolved relative to the repository root and seeded into every new worktree,
+ preserving their relative location. Existing files in the worktree are not overwritten.
+
+ Supported patterns:
+
+ -
+
.env literal file or directory
+ (directories copy recursively)
+
+ -
+
*,{" "}
+ ?,{" "}
+ [abc] single-segment wildcards
+
+ -
+
** matches any number of
+ directories, e.g. **/.env
+
+ -
+
{"{a,b}"} brace alternation,
+ e.g. .env{"{,.local}"}
+
+
+
+ Files over 5 MiB are skipped when copying to remote executors. Local worktrees copy them
+ without a size cap.
+
+
+
+
+ );
+}