feat(worktree): support ** and brace globs in copy_files patterns (#1248)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user