fix(worktree): preserve colon paths in copy files (#1674)

This commit is contained in:
Carlos Florêncio
2026-07-13 13:56:40 +01:00
committed by GitHub
parent 248e8bf48e
commit 892ce9bf09
13 changed files with 465 additions and 168 deletions
+1 -1
View File
@@ -180,7 +180,7 @@ Client (WS) ← Orchestrator ← Lifecycle Manager ←──── stream update
**Worktrees:** `internal/worktree/Manager` provides workspace isolation. Each session can have its own worktree (branch) to prevent conflicts between concurrent agents.
**Worktree file materialization:** `copy_files` (`Repository.CopyFiles`) is a single comma-separated spec of gitignored files/globs seeded into each new worktree. Each entry may carry an optional `:keyword` suffix; the only keyword is `:symlink` (e.g. `.env.local:symlink`), which links the entry back to the main repo instead of copying it so shared files stay centralized. No suffix = copy (the default, fully backward compatible). Parsing, validation, and materialization live in `internal/worktree/copyfiles/` (`ParseSpecs` → `[]PatternSpec{Pattern, Symlink}`, `ValidateSpec` gates unknown keywords at save time, `Copy` honors symlink mode host-side). `Manager.copyConfiguredFiles` (in `manager_lifecycle.go`) runs it during worktree creation. Non-fatal: per-entry failures become warnings. Symlinks use relative targets (unix/macOS) and only apply on the host path — the remote-executor path (`Plan`/`WriteEntries`) strips the keyword and falls back to a byte copy, since a link back to the host repo can't apply remotely.
**Worktree file materialization:** `copy_files` (`Repository.CopyFiles`) is a comma-separated spec of repository-relative gitignored paths/doublestar globs seeded into each new worktree. Copy is the default; the exact terminal `:symlink` suffix (for example `.env.local:symlink`) creates a relative host-worktree link so source changes propagate live. Other colons stay literal (`config:dev`, `.env:`); use `::symlink` to copy a literal path ending in `:symlink`. Malformed reserved syntax is rejected at save time. Parsing and materialization live in `internal/worktree/copyfiles/` (`ParseSpecs`, `ValidateSpec`, `Copy`); duplicates and overlapping matches are first-entry-wins. `Manager.copyConfiguredFiles` runs before setup during worktree creation. Source and destination containment checks reject traversal and symlinked destination parents. Failures are non-fatal warnings. Windows link creation is best-effort. Remote executors cannot link to the host, so `Parse`/`Plan` preserve literal colon paths but turn symlink-mode entries into copied bytes delivered through `WriteEntries`.
**Executor default scripts:** Default prepare scripts are in `internal/agent/runtime/lifecycle/default_scripts.go`; `internal/scriptengine/` handles placeholder resolution.
@@ -109,7 +109,7 @@ func TestRunRemoteCopyfiles_HappyPath_EmitsStep(t *testing.T) {
runRemoteCopyfiles(context.Background(), logger.Default(), remoteCopyfilesRequest{
SourceRepoPath: src,
CopyFilesSpec: ".env",
CopyFilesSpec: ".env:symlink",
Client: cli,
OnProgress: cb,
})
@@ -26,51 +26,29 @@ func (r *Repository) CreateRepository(ctx context.Context, repository *models.Re
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO repositories (
id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner,
provider_name, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script,
copy_files, created_at, updated_at, deleted_at
provider_name, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), repository.ID, repository.WorkspaceID, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider,
repository.ProviderRepoID, repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix,
repository.WorktreeBranchTemplate, dialect.BoolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript,
repository.CopyFiles,
repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt)
repository.WorktreeBranchTemplate, dialect.BoolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CopyFiles, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt)
return err
}
// repositoryColumns is the shared SELECT column list for repository rows, in the
// order expected by scanRepository.
const repositoryColumns = `id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner,
provider_name, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script,
copy_files, created_at, updated_at, deleted_at`
// rowScanner abstracts *sql.Row and *sql.Rows so a single scan routine serves
// both single-row and iterating queries.
type rowScanner interface {
Scan(dest ...any) error
}
// scanRepository decodes one repository row into a models.Repository.
func scanRepository(s rowScanner) (*models.Repository, error) {
repository := &models.Repository{}
err := s.Scan(
&repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath,
&repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName,
&repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.WorktreeBranchTemplate, &repository.PullBeforeWorktree, &repository.SetupScript,
&repository.CleanupScript, &repository.DevScript, &repository.CopyFiles,
&repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt,
)
if err != nil {
return nil, err
}
return repository, nil
}
// GetRepository retrieves a repository by ID
func (r *Repository) GetRepository(ctx context.Context, id string) (*models.Repository, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(
`SELECT `+repositoryColumns+` FROM repositories WHERE id = ? AND deleted_at IS NULL`), id)
repository, err := scanRepository(row)
repository := &models.Repository{}
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner,
provider_name, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at
FROM repositories WHERE id = ? AND deleted_at IS NULL
`), id).Scan(
&repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath,
&repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName,
&repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.WorktreeBranchTemplate, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CopyFiles, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("%w: %s", repoerrors.ErrRepositoryNotFound, id)
}
@@ -84,14 +62,11 @@ func (r *Repository) UpdateRepository(ctx context.Context, repository *models.Re
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE repositories SET
name = ?, source_type = ?, local_path = ?, provider = ?, provider_repo_id = ?, provider_owner = ?,
provider_name = ?, default_branch = ?, worktree_branch_prefix = ?, worktree_branch_template = ?, pull_before_worktree = ?, setup_script = ?, cleanup_script = ?, dev_script = ?,
copy_files = ?, updated_at = ?
provider_name = ?, default_branch = ?, worktree_branch_prefix = ?, worktree_branch_template = ?, pull_before_worktree = ?, setup_script = ?, cleanup_script = ?, dev_script = ?, copy_files = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
`), repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, repository.ProviderRepoID,
repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, repository.WorktreeBranchTemplate, dialect.BoolToInt(repository.PullBeforeWorktree),
repository.SetupScript, repository.CleanupScript, repository.DevScript,
repository.CopyFiles,
repository.UpdatedAt, repository.ID)
repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CopyFiles, repository.UpdatedAt, repository.ID)
if err != nil {
return err
}
@@ -152,8 +127,11 @@ func (r *Repository) DeleteRepositoryIfNoActiveTaskSessions(ctx context.Context,
// ListRepositories returns all repositories for a workspace
func (r *Repository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) {
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(
`SELECT `+repositoryColumns+` FROM repositories WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY created_at DESC`), workspaceID)
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner,
provider_name, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at
FROM repositories WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY created_at DESC
`), workspaceID)
if err != nil {
return nil, err
}
@@ -161,7 +139,12 @@ func (r *Repository) ListRepositories(ctx context.Context, workspaceID string) (
var result []*models.Repository
for rows.Next() {
repository, err := scanRepository(rows)
repository := &models.Repository{}
err := rows.Scan(
&repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath,
&repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName,
&repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.WorktreeBranchTemplate, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CopyFiles, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt,
)
if err != nil {
return nil, err
}
@@ -173,11 +156,17 @@ func (r *Repository) ListRepositories(ctx context.Context, workspaceID string) (
// GetRepositoryByProviderInfo finds a repository by workspace, provider, owner, and name.
// Returns nil, nil if not found.
func (r *Repository) GetRepositoryByProviderInfo(ctx context.Context, workspaceID, provider, owner, name string) (*models.Repository, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(
`SELECT `+repositoryColumns+` FROM repositories
WHERE workspace_id = ? AND provider = ? AND provider_owner = ? AND provider_name = ? AND deleted_at IS NULL`),
workspaceID, provider, owner, name)
repository, err := scanRepository(row)
repository := &models.Repository{}
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner,
provider_name, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at
FROM repositories
WHERE workspace_id = ? AND provider = ? AND provider_owner = ? AND provider_name = ? AND deleted_at IS NULL
`), workspaceID, provider, owner, name).Scan(
&repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath,
&repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName,
&repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.WorktreeBranchTemplate, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CopyFiles, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -1098,22 +1098,25 @@ func TestService_CreateRepository_CopyFilesSymlinkKeyword(t *testing.T) {
}
}
func TestService_CreateRepository_InvalidCopyFilesKeyword(t *testing.T) {
func TestService_CreateRepository_ColonBearingCopyFilesPath(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
_, err := svc.CreateRepository(ctx, &CreateRepositoryRequest{
created, err := svc.CreateRepository(ctx, &CreateRepositoryRequest{
WorkspaceID: "ws-1",
Name: "Test Repo",
CopyFiles: ".env.local:hardlink",
})
if !errors.Is(err, ErrInvalidRepositorySettings) {
t.Fatalf("expected ErrInvalidRepositorySettings, got %v", err)
if err != nil {
t.Fatalf("CreateRepository failed: %v", err)
}
if created.CopyFiles != ".env.local:hardlink" {
t.Fatalf("copy_files not persisted verbatim: %q", created.CopyFiles)
}
}
func TestService_UpdateRepository_InvalidCopyFilesKeyword(t *testing.T) {
func TestService_UpdateRepository_MalformedCopyFilesMode(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
@@ -1122,7 +1125,7 @@ func TestService_UpdateRepository_InvalidCopyFilesKeyword(t *testing.T) {
t.Fatalf("CreateRepository failed: %v", err)
}
bad := ".env:move"
bad := ":symlink"
_, err = svc.UpdateRepository(ctx, created.ID, &UpdateRepositoryRequest{CopyFiles: &bad})
if !errors.Is(err, ErrInvalidRepositorySettings) {
t.Fatalf("expected ErrInvalidRepositorySettings, got %v", err)
@@ -51,11 +51,14 @@ type Entry struct {
Content []byte `json:"content"`
}
// KeywordSymlink is the only per-entry keyword recognized after the `:`
// separator. `.env.local:symlink` links the entry back to the source repo
// instead of copying it.
// KeywordSymlink is the only reserved per-entry mode suffix.
const KeywordSymlink = "symlink"
const (
symlinkSuffix = ":" + KeywordSymlink
escapedSymlinkSuffix = ":" + symlinkSuffix
)
// PatternSpec is a single copy-files entry: a pattern plus its per-entry mode.
// Symlink is true when the entry carried the `:symlink` keyword.
type PatternSpec struct {
@@ -63,20 +66,27 @@ type PatternSpec struct {
Symlink bool
}
// splitKeyword splits a raw entry into its pattern and optional trailing
// `:keyword`. The keyword is whatever follows the last `:`; entries without a
// colon have an empty keyword. Repo-relative env/config paths don't contain
// colons in practice, so the last-colon rule is unambiguous for real specs.
func splitKeyword(entry string) (pattern, keyword string) {
idx := strings.LastIndex(entry, ":")
if idx < 0 {
return entry, ""
// parsePatternSpec recognizes only the exact terminal :symlink suffix. Other
// colons remain literal path characters for compatibility with existing specs.
// A doubled colon escapes a literal filename ending in :symlink.
func parsePatternSpec(entry string) (PatternSpec, error) {
entry = strings.TrimSpace(entry)
if strings.HasSuffix(entry, escapedSymlinkSuffix) {
pattern := strings.TrimSpace(strings.TrimSuffix(entry, escapedSymlinkSuffix)) + symlinkSuffix
return PatternSpec{Pattern: pattern}, nil
}
return strings.TrimSpace(entry[:idx]), strings.TrimSpace(entry[idx+1:])
if !strings.HasSuffix(entry, symlinkSuffix) {
return PatternSpec{Pattern: entry}, nil
}
pattern := strings.TrimSpace(strings.TrimSuffix(entry, symlinkSuffix))
if pattern == "" {
return PatternSpec{}, fmt.Errorf("invalid copy-files mode syntax %q: missing path before %q", entry, symlinkSuffix)
}
return PatternSpec{Pattern: pattern, Symlink: true}, nil
}
// Parse splits a comma-separated user spec into trimmed, deduplicated,
// non-empty patterns with any `:keyword` suffix stripped. Order is preserved
// non-empty patterns with the exact `:symlink` suffix stripped. 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. This is the copy-only view used by the remote-executor
@@ -94,9 +104,10 @@ func Parse(spec string) []string {
}
// ParseSpecs splits a comma-separated user spec into trimmed, deduplicated,
// non-empty PatternSpecs, extracting the per-entry `:symlink` mode. Dedupe is
// by pattern (first occurrence wins). An unrecognized keyword is treated as
// copy mode here — ValidateSpec is the gate that rejects it at save time.
// non-empty PatternSpecs, extracting the exact per-entry `:symlink` mode.
// Dedupe is by normalized pattern and the first occurrence wins. Invalid
// entries are skipped; ValidateSpec reports them before repository settings
// are persisted.
func ParseSpecs(spec string) []PatternSpec {
if spec == "" {
return nil
@@ -105,15 +116,15 @@ func ParseSpecs(spec string) []PatternSpec {
out := make([]PatternSpec, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, p := range parts {
pattern, keyword := splitKeyword(strings.TrimSpace(p))
if pattern == "" {
parsed, err := parsePatternSpec(p)
if err != nil || parsed.Pattern == "" {
continue
}
if _, ok := seen[pattern]; ok {
if _, ok := seen[parsed.Pattern]; ok {
continue
}
seen[pattern] = struct{}{}
out = append(out, PatternSpec{Pattern: pattern, Symlink: keyword == KeywordSymlink})
seen[parsed.Pattern] = struct{}{}
out = append(out, parsed)
}
if len(out) == 0 {
return nil
@@ -121,10 +132,8 @@ func ParseSpecs(spec string) []PatternSpec {
return out
}
// ValidateSpec reports the first invalid entry in a copy-files spec: a `:`
// suffix whose keyword is not in the whitelist (currently only `symlink`).
// Empty entries and plain patterns are always valid. Called at save time so
// misconfigured keywords surface immediately instead of silently copying.
// ValidateSpec reports malformed use of the reserved :symlink suffix. Unknown
// colon suffixes remain literal paths for backward compatibility.
func ValidateSpec(spec string) error {
if spec == "" {
return nil
@@ -134,12 +143,8 @@ func ValidateSpec(spec string) error {
if p == "" {
continue
}
if !strings.Contains(p, ":") {
continue
}
_, keyword := splitKeyword(p)
if keyword != "" && keyword != KeywordSymlink {
return fmt.Errorf("invalid copy-files keyword %q in %q (only %q is supported)", keyword, p, KeywordSymlink)
if _, err := parsePatternSpec(p); err != nil {
return err
}
}
return nil
@@ -11,11 +11,14 @@ import (
func TestParseSpecs(t *testing.T) {
t.Parallel()
got := ParseSpecs(".env, .env.local:symlink, config/{local,dev}.yml")
got := ParseSpecs(".env, .env.local:symlink, config/{local,dev}.yml, config:dev, .env:, literal::symlink")
want := []PatternSpec{
{Pattern: ".env"},
{Pattern: ".env.local", Symlink: true},
{Pattern: "config/{local,dev}.yml"},
{Pattern: "config:dev"},
{Pattern: ".env:"},
{Pattern: "literal:symlink"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSpecs = %#v, want %#v", got, want)
@@ -24,18 +27,34 @@ func TestParseSpecs(t *testing.T) {
func TestValidateSpec(t *testing.T) {
t.Parallel()
for _, ok := range []string{"", ".env", ".env, .env.local:symlink", "config/{local,dev}.yml", ".env:"} {
for _, ok := range []string{
"", ".env", ".env, .env.local:symlink", "config/{local,dev}.yml",
".env:", "config:dev", ".env:hardlink", "literal::symlink",
} {
if err := ValidateSpec(ok); err != nil {
t.Errorf("ValidateSpec(%q) unexpected error: %v", ok, err)
}
}
for _, bad := range []string{".env:hardlink", ".env, foo:move"} {
for _, bad := range []string{":symlink", ".env, :symlink"} {
if err := ValidateSpec(bad); err == nil {
t.Errorf("ValidateSpec(%q) = nil, want error", bad)
}
}
}
func TestParseSpecs_DuplicateAndOverlappingPatternsUseFirstEntry(t *testing.T) {
t.Parallel()
got := ParseSpecs(".env:symlink, .env, .env:symlink, .*")
want := []PatternSpec{
{Pattern: ".env", Symlink: true},
{Pattern: ".*"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSpecs = %#v, want %#v", got, want)
}
}
func TestCopy_SymlinkMode_File(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation often requires privilege on Windows")
@@ -67,26 +86,97 @@ func TestCopy_SymlinkMode_File(t *testing.T) {
if string(got) != "SECRET=1" {
t.Fatalf("symlink content = %q", got)
}
writeFile(t, filepath.Join(src, ".env.local"), "SECRET=updated", 0o644)
if got := readFile(t, filepath.Join(dst, ".env.local")); got != "SECRET=updated" {
t.Fatalf("updated symlink content = %q, want live source content", got)
}
if len(copied) != 1 || copied[0] != ".env.local" {
t.Fatalf("copied = %v, want [.env.local]", copied)
}
}
func TestCopy_DuplicateAndOverlappingPatternsUseFirstEntry(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation often requires privilege on Windows")
}
for _, tc := range []struct {
name string
spec string
wantSymlink bool
}{
{name: "symlink before copy overlap", spec: ".env:symlink, .*", wantSymlink: true},
{name: "copy before symlink duplicate", spec: ".*, .env:symlink", wantSymlink: false},
} {
t.Run(tc.name, func(t *testing.T) {
src := t.TempDir()
dst := t.TempDir()
writeFile(t, filepath.Join(src, ".env"), "SECRET=1", 0o644)
_, warnings, err := Copy(context.Background(), src, dst, ParseSpecs(tc.spec), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
if len(warnings) != 0 {
t.Fatalf("unexpected warnings: %v", warnings)
}
info, err := os.Lstat(filepath.Join(dst, ".env"))
if err != nil {
t.Fatalf("lstat: %v", err)
}
if got := info.Mode()&os.ModeSymlink != 0; got != tc.wantSymlink {
t.Fatalf("is symlink = %v, want %v", got, tc.wantSymlink)
}
})
}
}
func TestCopy_EscapedSymlinkSuffixIsLiteralPath(t *testing.T) {
t.Parallel()
src := t.TempDir()
dst := t.TempDir()
writeFile(t, filepath.Join(src, "config:symlink"), "literal", 0o644)
_, warnings, err := Copy(context.Background(), src, dst, ParseSpecs("config::symlink"), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
if len(warnings) != 0 {
t.Fatalf("unexpected warnings: %v", warnings)
}
if got := readFile(t, filepath.Join(dst, "config:symlink")); got != "literal" {
t.Fatalf("content = %q, want literal", got)
}
}
// Plan is the remote path; symlink entries must fall back to a byte copy there
// since a link back to the host repo can't apply on a remote executor.
func TestCopy_SymlinkMode_IgnoredInPlanMode(t *testing.T) {
src := t.TempDir()
writeFile(t, filepath.Join(src, ".env.local"), "SECRET=1", 0o644)
writeFile(t, filepath.Join(src, "config:dev"), "MODE=dev", 0o644)
writeFile(t, filepath.Join(src, ".env:"), "TRAILING=colon", 0o644)
entries, warnings, err := Plan(context.Background(), src, Parse(".env.local:symlink"), nil)
entries, warnings, err := Plan(context.Background(), src,
Parse(".env.local:symlink, config:dev, .env:"), nil)
if err != nil {
t.Fatalf("Plan err: %v", err)
}
if len(warnings) != 0 {
t.Fatalf("unexpected warnings: %v", warnings)
}
if len(entries) != 1 || entries[0].RelPath != ".env.local" || string(entries[0].Content) != "SECRET=1" {
t.Fatalf("expected a copied entry with content, got %#v", entries)
want := map[string]string{
".env.local": "SECRET=1",
"config:dev": "MODE=dev",
".env:": "TRAILING=colon",
}
if len(entries) != len(want) {
t.Fatalf("entries = %#v, want %d entries", entries, len(want))
}
for _, entry := range entries {
if string(entry.Content) != want[entry.RelPath] {
t.Fatalf("entry %q content = %q, want %q", entry.RelPath, entry.Content, want[entry.RelPath])
}
}
}
@@ -5,6 +5,7 @@ import (
"errors"
"os"
"path/filepath"
"runtime"
"testing"
)
@@ -94,6 +95,37 @@ func TestManagerCreate_CopiesFiles_HappyPath(t *testing.T) {
}
}
func TestManagerCreate_SymlinksConfiguredFileToSource(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation often requires privilege on Windows")
}
repoPath := initGitRepoForWorktreeTest(t)
sourcePath := writeSourceFile(t, repoPath, ".env", "SECRET=source\n")
provider := &fakeRepoProvider{
repo: &Repository{ID: "repo-symlink", CopyFiles: ".env:symlink"},
}
mgr := newManagerForCopyTest(t, provider)
wt, err := mgr.Create(context.Background(), createReqForCopyTest(repoPath, "symlink"))
if err != nil {
t.Fatalf("Create() unexpected error: %v", err)
}
linkPath := filepath.Join(wt.Path, ".env")
target, err := os.Readlink(linkPath)
if err != nil {
t.Fatalf("Readlink(%q): %v", linkPath, err)
}
if filepath.IsAbs(target) {
t.Fatalf("symlink target = %q, want relative path", target)
}
resolved := filepath.Clean(filepath.Join(filepath.Dir(linkPath), target))
if resolved != sourcePath {
t.Fatalf("resolved symlink = %q, want %q", resolved, sourcePath)
}
}
func TestManagerCreate_CopyFilesEmpty_NoOp(t *testing.T) {
repoPath := initGitRepoForWorktreeTest(t)
// Add an untracked file that would be copied if CopyFiles were set —
@@ -1,9 +1,8 @@
"use client";
import { IconInfoCircle } from "@tabler/icons-react";
import { IconChevronDown, 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 = {
@@ -13,71 +12,76 @@ type CopyFilesFieldProps = {
};
export function CopyFilesField({ repositoryId, copyFiles, onUpdate }: CopyFilesFieldProps) {
const inputId = `copy-files-${repositoryId}`;
const helpId = `copy-files-help-${repositoryId}`;
return (
<div className="space-y-2">
<Label className="flex items-center gap-1.5">
Copy Files <CopyFilesHelpTip />
</Label>
<Label htmlFor={inputId}>Copy Files</Label>
<Textarea
id={inputId}
data-testid={`copy-files-input-${repositoryId}`}
aria-describedby={helpId}
value={copyFiles}
onChange={(e) => onUpdate(repositoryId, { copy_files: e.target.value })}
placeholder=".env, .env.*, apps/**/.env, .env.local:symlink"
rows={2}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
<p id={helpId} className="text-xs text-muted-foreground">
Gitignored paths copied into new worktrees. Append{" "}
<code className="px-1 py-0.5 bg-muted rounded">:symlink</code> to an entry to link it back
to the main repo instead.
to the main repo. Use <code className="px-1 py-0.5 bg-muted rounded">::symlink</code> for a
literal filename ending in <code className="px-1 py-0.5 bg-muted rounded">:symlink</code>.
</p>
<p data-testid="copy-files-remote-fallback" className="text-xs text-muted-foreground">
Remote executors copy file contents instead of creating symlinks.
</p>
<CopyFilesDetails />
</div>
);
}
function CopyFilesHelpTip() {
function CopyFilesDetails() {
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>
<li>
<code className="px-1 py-0.5 bg-muted rounded">:symlink</code> suffix links the entry
back to the main repo instead of copying it, e.g.{" "}
<code className="px-1 py-0.5 bg-muted rounded">.env.local:symlink</code> (default is
copy; symlinks fall back to a copy on remote executors)
</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>
<details className="group text-xs text-muted-foreground">
<summary className="flex min-h-11 w-fit cursor-pointer list-none items-center gap-1.5 py-2 font-medium text-foreground">
<IconInfoCircle className="h-4 w-4 shrink-0" aria-hidden="true" />
Pattern syntax
<IconChevronDown
className="h-4 w-4 shrink-0 transition-transform group-open:rotate-180"
aria-hidden="true"
/>
</summary>
<div className="max-w-sm space-y-2 pb-1">
<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>
</div>
</details>
);
}
@@ -0,0 +1,61 @@
import { expect, type Page } from "@playwright/test";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
export async function configureSymlinkAndCreateTask(options: {
page: Page;
apiClient: ApiClient;
seedData: SeedData;
title: string;
}) {
const { page, apiClient, seedData, title } = options;
await page.goto(`/settings/workspace/${seedData.workspaceId}/repositories`);
await page.getByRole("button", { name: "Edit" }).first().click();
const input = page.getByTestId(`copy-files-input-${seedData.repositoryId}`);
await expect(input).toBeVisible();
await expect(page.getByTestId("copy-files-remote-fallback")).toBeVisible();
await input.fill(".env:symlink");
const saved = page.waitForResponse(
(response) =>
response.url().endsWith(`/api/v1/repositories/${seedData.repositoryId}`) &&
response.request().method() === "PATCH" &&
response.ok(),
);
await page.getByRole("button", { name: "Save", exact: true }).click();
await saved;
const { executors } = await apiClient.listExecutors();
const worktreeProfileName = executors
.flatMap((executor) => executor.profiles ?? [])
.find((profile) => profile.id === seedData.worktreeExecutorProfileId)?.name;
if (!worktreeProfileName) throw new Error("worktree executor profile not found");
const kanban = new KanbanPage(page);
await kanban.goto();
const mobileFab = page.getByTestId("mobile-fab");
if (await mobileFab.isVisible()) {
await mobileFab.click();
} else {
await kanban.createTaskButton.filter({ visible: true }).click();
}
await expect(page.getByTestId("create-task-dialog")).toBeVisible();
await page.getByTestId("task-title-input").fill(title);
await page.getByTestId("task-description-input").fill("/e2e:simple-message");
await page.getByTestId("executor-profile-selector").click();
await page.getByRole("option", { name: worktreeProfileName }).click();
await expect(page.getByTestId("submit-start-agent")).toBeEnabled({ timeout: 30_000 });
const createdResponse = page.waitForResponse(
(response) =>
response.url().endsWith("/api/v1/tasks") &&
response.request().method() === "POST" &&
response.ok(),
);
await page.getByTestId("submit-start-agent").click();
const created = (await (await createdResponse).json()) as { id?: string };
const taskId = created.id;
if (!taskId) throw new Error("task id missing from create response");
return taskId;
}
@@ -0,0 +1,46 @@
import fs from "node:fs";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import { useRegularMode } from "../../helpers/regular-mode";
import { configureSymlinkAndCreateTask } from "./copy-files-symlink-helpers";
useRegularMode();
test("configures symlink mode and creates a task", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(60_000);
const sourcePath = path.join(backend.tmpDir, "repos", "e2e-repo", ".env");
fs.writeFileSync(sourcePath, "COPY_FILES_E2E=1\n");
try {
const taskId = await configureSymlinkAndCreateTask({
page: testPage,
apiClient,
seedData,
title: "Copy Files Symlink Flow",
});
await expect
.poll(
async () => {
const environment = await apiClient.getTaskEnvironment(taskId);
const worktreePath = environment?.worktree_path;
if (!worktreePath) return false;
const linkPath = path.join(worktreePath, ".env");
if (!fs.existsSync(linkPath)) return false;
const target = fs.readlinkSync(linkPath);
return (
!path.isAbsolute(target) && path.resolve(path.dirname(linkPath), target) === sourcePath
);
},
{ timeout: 30_000 },
)
.toBe(true);
} finally {
fs.rmSync(sourcePath, { force: true });
await apiClient.updateRepository(seedData.repositoryId, { copy_files: "" }).catch(() => {});
}
});
@@ -0,0 +1,46 @@
import fs from "node:fs";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import { useRegularMode } from "../../helpers/regular-mode";
import { configureSymlinkAndCreateTask } from "./copy-files-symlink-helpers";
useRegularMode();
test("configures symlink mode and creates a task on mobile", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(60_000);
const sourcePath = path.join(backend.tmpDir, "repos", "e2e-repo", ".env");
fs.writeFileSync(sourcePath, "COPY_FILES_MOBILE_E2E=1\n");
try {
const taskId = await configureSymlinkAndCreateTask({
page: testPage,
apiClient,
seedData,
title: "Mobile Copy Files Symlink Flow",
});
await expect
.poll(
async () => {
const environment = await apiClient.getTaskEnvironment(taskId);
const worktreePath = environment?.worktree_path;
if (!worktreePath) return false;
const linkPath = path.join(worktreePath, ".env");
if (!fs.existsSync(linkPath)) return false;
const target = fs.readlinkSync(linkPath);
return (
!path.isAbsolute(target) && path.resolve(path.dirname(linkPath), target) === sourcePath
);
},
{ timeout: 30_000 },
)
.toBe(true);
} finally {
fs.rmSync(sourcePath, { force: true });
await apiClient.updateRepository(seedData.repositoryId, { copy_files: "" }).catch(() => {});
}
});
+2 -1
View File
@@ -206,7 +206,8 @@ export type Repository = {
/**
* Comma-separated gitignored files/globs seeded into each new worktree.
* Append `:symlink` to an entry (e.g. `.env.local:symlink`) to link it back
* to the main repo instead of copying it; the default is copy.
* to the main repo instead of copying it; `::symlink` escapes a literal
* suffix. Remote executors always copy the bytes.
*/
copy_files: string;
created_at: string;
+42 -22
View File
@@ -1,45 +1,65 @@
# 0010: Worktree copy-files — per-repo, idempotent, host-local
# 0010: Worktree copy-files with per-entry materialization modes
**Status:** accepted
**Status:** accepted (amended 2026-07-13)
**Date:** 2026-05-19
**Area:** backend, frontend
## Context
Git worktrees don't carry gitignored files. The most painful case for kandev users is `.env` and friends — every new task creates a fresh worktree, and the agent's first step is usually `pnpm dev` or `make run`, which fails immediately because there's no `.env`. Users were copying files manually after each task creation. Issue #946 asked for a built-in solution, citing vibe-kanban's `copy_files` setting as prior art.
Git worktrees do not contain gitignored files. Users commonly need `.env`, local configuration, or certificates before a task's setup script can run. Issue #946 introduced a per-repository `copy_files` setting, following vibe-kanban's comma-separated format.
The feature has several axes of choice that aren't obvious from the issue text alone, and a couple are surprising enough that future maintainers will reasonably question them.
Copying isolates each task but does not propagate later source changes. Some files instead need one centrally managed source whose changes are immediately visible in every host worktree. PR #1650 added a symlink mode, but its first parser treated every final colon segment as a keyword. That broke previously valid POSIX paths such as `config:dev` and `.env:` and required an explicit compatibility rule.
## Decision
**A per-repository, comma-separated list of paths/globs (`repositories.copy_files`). Copied from the source repo dir into each new worktree at task creation, in the Go process on the host. Target-exists check makes the copy idempotent.**
`repositories.copy_files` remains a per-repository, comma-separated list of repository-relative paths or doublestar patterns. Entries are materialized during worktree creation, before the setup script, and existing destinations are never overwritten.
Concretely:
Each entry has one of two modes:
1. **Scope: per-repository, not per-task or per-workspace.** The set of secret files in a repo (`.env`, `.env.local`, `config/local.yml`) is a property of the repo, not of any individual task. Storing it once per repo means users configure it during workspace setup and never think about it again. A per-task field would push friction back onto every task creation, defeating the purpose. A per-workspace field would force every repo in the workspace to share the same list — wrong for polyglot workspaces.
- **Copy**, the default: copy file contents or recursively copy a matched directory.
- **Symlink**: an exact terminal `:symlink` suffix, for example `.env:symlink`, creates a relative symlink at `.env` pointing to the source repository's `.env`.
2. **Wire format: comma-separated string of paths or `filepath.Glob` patterns.** Matches vibe-kanban exactly, which means users migrating between tools don't relearn syntax. Stdlib `filepath.Glob` supports `*`, `?`, `[...]` — enough for `.env`, `*.local`, `config/*.json`. **No `**`/doublestar in v1**: it's a single transitive dependency we don't need to take to ship the feature, and the literal-file-plus-directory-recursion path (`config/` recursively walks all files) covers the rest of the issue's examples. Adding doublestar later is additive.
The grammar is deliberately narrow:
3. **Trigger: at worktree creation, between `persistAndCacheWorktree` and `runWorktreeSetupScript`.** Setup scripts often read `.env` (e.g. `pnpm install` with `NODE_ENV`-gated postinstalls), so the copy must complete first. Placing it after `persistAndCacheWorktree` means a failed copy doesn't leave a phantom DB row — the worktree is already committed, and copy errors are recoverable.
1. Only the exact terminal `:symlink` suffix is reserved. Other colons are literal path characters, so `config:dev`, `.env:`, and `file:hardlink` retain their pre-mode meaning.
2. A doubled colon escapes the reserved suffix. `config::symlink` copies the literal path `config:symlink`.
3. A reserved suffix without a path, such as `:symlink`, is malformed and repository create/update rejects it.
4. Entries are normalized and deduplicated by path. The first entry wins for duplicate paths and for distinct patterns that overlap the same file. Ordering therefore selects the mode for an overlapping match.
5. Commas inside brace alternation remain part of the pattern. `*`, `?`, character classes, `**`, and brace alternation are provided by `doublestar`.
4. **Host-local Go copy, not an agentctl RPC.** The worktree filesystem is on the host (today). The copy is `io.Copy` between two `*os.File`s under a path-traversal guard. No subprocess, no shell, no Windows `cp` issue, no agentctl round-trip.
This suffix-plus-escape grammar is less extensible than structured JSON, but it preserves the established string field and all legacy colon-bearing paths except the newly reserved exact `:symlink` form. That exact form must stay reserved because it shipped in PR #1650; the doubled-colon escape keeps a literal filename ending in `:symlink` representable. Future modes must not reinterpret arbitrary trailing colon segments.
5. **Idempotent: skip if target exists.** Re-running the copy on the same worktree (e.g. future "re-prepare worktree" actions, or session resume that re-enters this code path) is a no-op for already-copied files. The cost is that **updating `copy_files` in settings does not propagate to existing worktrees** — only future ones. The frontend helper text says so explicitly. The alternative — overwrite-on-update — would clobber files the agent had already mutated mid-task, which is worse.
## Host And Remote Behavior
6. **Missing files = warning, not error.** Acceptance criterion in the issue. A typo in `copy_files` should not block task creation; the agent failing later with a clearer "ENOENT: .env" is better than the worktree refusing to spawn.
Host worktrees materialize through `copyfiles.Copy`:
7. **Path-traversal guard: canonicalize, then `filepath.Rel` under canonical root.** `EvalSymlinks(sourceDir)` is called once; all pattern joins and result paths use that canonical root, not the raw `sourceDir` argument. This matters because macOS resolves `/tmp` → `/private/tmp` and many users have `~/code` → `/data/code` style symlinks; without canonicalization, *every* match looks "outside source dir" via `filepath.Rel` and the feature silently no-ops. (Regression test: `TestCopy_SymlinkedSourceDir`.) Resolved matches outside the canonical root are still rejected — symlink traversal as an exfiltration vector is closed.
- Copy mode snapshots bytes when the worktree is created. Later source changes do not propagate.
- Symlink mode creates a relative link to the source repository. Reads and writes through the worktree link immediately affect the shared source and are visible through every link to it.
- A symlink creation failure is warned and skipped; it does not silently change to copy mode.
Remote executors cannot use a link to the host repository. `copyfiles.Parse` strips the recognized mode while preserving literal colon-bearing paths, `copyfiles.Plan` reads the source bytes, and agentctl `WriteEntries` writes those bytes remotely. Symlink entries therefore fall back to copy mode for local Docker, Sprites, SSH, and future remote executors using this path. The repository settings UI states this fallback without requiring hover. Remote payloads are capped at 5 MiB per file; oversized matches are warned and skipped.
Windows copy mode is supported. Host symlink mode is best-effort because Windows symlink creation can require Developer Mode or elevated privileges; failures remain warnings so task creation continues. Relative symlink behavior is covered on Unix and macOS, while Windows tests skip when the platform cannot create links.
## Security And Containment
- Source roots are canonicalized with `EvalSymlinks`; matches that resolve outside the source repository are rejected.
- Host destinations must remain under the worktree. Existing symlinked destination parents are rejected before directories or links are created, preventing writes through a parent link outside the worktree.
- Symlink targets are relative and point only to already-contained source matches.
- Remote `WriteEntries` canonicalizes both the workspace containment root and target repository. Every untrusted relative entry is rejected if it is absolute, traverses outside the target, or reaches a symlinked parent outside containment.
- Existing destinations are skipped for idempotency. Missing or rejected matches produce visible warnings rather than blocking task creation.
## Consequences
- **Remote executors are supported** via a parallel "ship bytes through agentctl" path (added 2026-05-24). The host reads source files into memory via `copyfiles.Plan` and POSTs them to `POST /workspace/copy-files`, which calls `copyfiles.WriteEntries` against the container workspace root. Both paths share `Plan` / `WriteEntries` so path-traversal guard, idempotency, and skip-if-exists are identical. Per-file payloads are capped at **5 MB** (`copyfiles.MaxEntryBytes`) to bound launch-time RAM + wire bytes against accidental matches like `node_modules/`; oversize files are warn-skipped rather than silently dropped. Wired into `local_docker` and `sprites`; `remote_docker` / `k8s` (planned) inherit automatically as long as their executors expose an `agentctl.Client` on the returned instance. The shipping step appears in the prepare-progress panel as `Copy N ignored files`, matching the worktree path's naming via the shared `buildCopyFilesStep` helper.
- **Disk-fill DoS** is bounded for remote paths by the 5 MB cap above. For the host-side worktree path it remains self-inflicted but unbounded — a user adding `**` or `node_modules/` would explode disk usage across many tasks. If support tickets surface this, add a soft warning at >100 MB / >1000 files for the local case as well.
- **UI feedback for warnings.** The prepare-progress panel now surfaces copy warnings (missing patterns, oversize files, ship failures) inline on the `Copy N ignored files` step. Backend zap logs remain for deeper debugging.
- **The `worktree.RepositoryAdapter` indirection** stays — it maps `models.Repository` → `worktree.Repository` (a minimal struct exposing only `{ID, SetupScript, CleanupScript, CopyFiles}`). Adding more cross-cutting per-repo settings should go through this adapter, not by widening the worktree package's import surface.
- Configuration is per repository rather than per task or workspace, so all future tasks for that repository share the policy.
- Changing `copy_files` affects only worktrees created afterward. The exception is content reached through an existing symlink, which is live by definition.
- Copy mode can consume unbounded host disk for broad patterns; remote memory and wire use are bounded by the per-file cap.
- Prepare progress reports copied/materialized files and warnings. The `worktree.RepositoryAdapter` remains the boundary from task repository models to worktree configuration.
## Alternatives considered
## Alternatives Considered
- **Extend `setup_script`.** Users could already write `cp /path/to/.env .` in setup. Rejected: requires every user to write shell, fights Windows (no `cp`), doesn't canonicalize source paths, and surfaces secrets in script output that gets streamed to the session UI. A dedicated field is purpose-built.
- **Per-task override on the task creation dialog.** Reasonable for one-off needs, but adds friction to the common path. Defer until a user asks.
- **Watch and re-copy on `copy_files` setting change.** Would make settings updates "live" for existing worktrees. Rejected: existing worktrees may have agent-modified copies, and silent re-copy risks data loss. The current design forces the user to re-create the task if they want the new files, which is the safe default.
- **JSON or YAML for the spec.** Considered for future expressiveness (`{src, dest, mode}` tuples). Rejected for v1: vibe-kanban's flat comma-separated format is proven, trivial to migrate from, and we have no use case yet for `dest` differing from `src`.
- **Setup scripts.** Rejected because they require platform-specific shell commands, can expose secret paths in output, and duplicate containment logic.
- **Per-task overrides.** Rejected because repeated task configuration defeats the repository-level default.
- **Overwrite or watch copied files.** Rejected because agents may modify worktree copies and automatic propagation would destroy task-local changes.
- **JSON/YAML structured entries.** More extensible and unambiguous, but changing the persisted/API shape would impose migration and UI complexity on an established simple field. The exact suffix plus escape solves the current two-mode requirement while preserving legacy paths.
- **Treat every final colon segment as a mode.** Rejected because POSIX permits colons in filenames and existing valid settings would change meaning or fail validation.