feat(worktree): support ** and brace globs in copy_files patterns (#1248)

This commit is contained in:
Carlos Florêncio
2026-06-02 19:37:10 +01:00
committed by GitHub
parent 8eb0850c51
commit 570c747959
6 changed files with 288 additions and 13 deletions
+1
View File
@@ -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
+2
View File
@@ -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=
@@ -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
@@ -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()
@@ -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({
</p>
</div>
<div className="space-y-2">
<Label>Copy Files</Label>
<Textarea
value={copyFiles}
onChange={(e) => onUpdate(repositoryId, { copy_files: e.target.value })}
placeholder=".env, .env.local, config/local.yml"
rows={2}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">Gitignored paths copied into new worktrees.</p>
</div>
<CopyFilesField repositoryId={repositoryId} copyFiles={copyFiles} onUpdate={onUpdate} />
</>
);
}
@@ -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<Repository>) => void;
};
export function CopyFilesField({ repositoryId, copyFiles, onUpdate }: CopyFilesFieldProps) {
return (
<div className="space-y-2">
<Label className="flex items-center gap-1.5">
Copy Files <CopyFilesHelpTip />
</Label>
<Textarea
value={copyFiles}
onChange={(e) => onUpdate(repositoryId, { copy_files: e.target.value })}
placeholder=".env, .env.*, apps/**/.env, config/{local,dev}.yml"
rows={2}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">Gitignored paths copied into new worktrees.</p>
</div>
);
}
function CopyFilesHelpTip() {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground/50 hover:text-muted-foreground cursor-help shrink-0" />
</TooltipTrigger>
<TooltipContent className="max-w-sm space-y-2 text-xs">
<p>
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.
</p>
<p className="font-medium">Supported patterns:</p>
<ul className="space-y-1 pl-3 list-disc">
<li>
<code className="px-1 py-0.5 bg-muted rounded">.env</code> literal file or directory
(directories copy recursively)
</li>
<li>
<code className="px-1 py-0.5 bg-muted rounded">*</code>,{" "}
<code className="px-1 py-0.5 bg-muted rounded">?</code>,{" "}
<code className="px-1 py-0.5 bg-muted rounded">[abc]</code> single-segment wildcards
</li>
<li>
<code className="px-1 py-0.5 bg-muted rounded">**</code> matches any number of
directories, e.g. <code className="px-1 py-0.5 bg-muted rounded">**/.env</code>
</li>
<li>
<code className="px-1 py-0.5 bg-muted rounded">{"{a,b}"}</code> brace alternation,
e.g. <code className="px-1 py-0.5 bg-muted rounded">.env{"{,.local}"}</code>
</li>
</ul>
<p className="text-muted-foreground">
Files over 5 MiB are skipped when copying to remote executors. Local worktrees copy them
without a size cap.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}