feat(worktree): add per-file copy/symlink materialization per repository (#1650)

This commit is contained in:
Jérémy Coatelen
2026-07-13 11:54:23 +02:00
committed by GitHub
parent b1f0cbc022
commit fd7c7a2b0f
12 changed files with 427 additions and 79 deletions
+2
View File
@@ -180,6 +180,8 @@ 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.
**Executor default scripts:** Default prepare scripts are in `internal/agent/runtime/lifecycle/default_scripts.go`; `internal/scriptengine/` handles placeholder resolution.
## Conventions
@@ -26,29 +26,51 @@ 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
}
// GetRepository retrieves a repository by ID
func (r *Repository) GetRepository(ctx context.Context, id string) (*models.Repository, error) {
repository := &models.Repository{}
// 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`
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(
// 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,
&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)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("%w: %s", repoerrors.ErrRepositoryNotFound, id)
}
@@ -62,11 +84,14 @@ 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
}
@@ -127,11 +152,8 @@ 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 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)
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)
if err != nil {
return nil, err
}
@@ -139,12 +161,7 @@ func (r *Repository) ListRepositories(ctx context.Context, workspaceID string) (
var result []*models.Repository
for rows.Next() {
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,
)
repository, err := scanRepository(rows)
if err != nil {
return nil, err
}
@@ -156,17 +173,11 @@ 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) {
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,
)
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)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -447,6 +447,8 @@ func (s *Service) publishRepositoryEvent(ctx context.Context, eventType string,
"pull_before_worktree": repository.PullBeforeWorktree,
"setup_script": repository.SetupScript,
"cleanup_script": repository.CleanupScript,
"dev_script": repository.DevScript,
"copy_files": repository.CopyFiles,
"created_at": repository.CreatedAt.Format(time.RFC3339),
"updated_at": repository.UpdatedAt.Format(time.RFC3339),
}
@@ -16,6 +16,7 @@ import (
"github.com/kandev/kandev/internal/task/models"
taskrepo "github.com/kandev/kandev/internal/task/repository"
"github.com/kandev/kandev/internal/worktree"
"github.com/kandev/kandev/internal/worktree/copyfiles"
)
const (
@@ -539,6 +540,9 @@ func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryReq
if req.PullBeforeWorktree != nil {
pullBeforeWorktree = *req.PullBeforeWorktree
}
if err := copyfiles.ValidateSpec(req.CopyFiles); err != nil {
return nil, fmt.Errorf("%w: %s", ErrInvalidRepositorySettings, err)
}
repository := &models.Repository{
ID: uuid.New().String(),
WorkspaceID: req.WorkspaceID,
@@ -721,6 +725,9 @@ func applyRepositoryUpdates(repository *models.Repository, req *UpdateRepository
repository.DevScript = *req.DevScript
}
if req.CopyFiles != nil {
if err := copyfiles.ValidateSpec(*req.CopyFiles); err != nil {
return fmt.Errorf("%w: %s", ErrInvalidRepositorySettings, err)
}
repository.CopyFiles = *req.CopyFiles
}
return nil
@@ -1080,6 +1080,55 @@ func TestService_CreateRepository_DefaultWorktreeBranchPrefix(t *testing.T) {
}
}
func TestService_CreateRepository_CopyFilesSymlinkKeyword(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
created, err := svc.CreateRepository(ctx, &CreateRepositoryRequest{
WorkspaceID: "ws-1",
Name: "Test Repo",
CopyFiles: ".env, .env.local:symlink",
})
if err != nil {
t.Fatalf("CreateRepository failed: %v", err)
}
if created.CopyFiles != ".env, .env.local:symlink" {
t.Fatalf("copy_files not persisted verbatim: %q", created.CopyFiles)
}
}
func TestService_CreateRepository_InvalidCopyFilesKeyword(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{
WorkspaceID: "ws-1",
Name: "Test Repo",
CopyFiles: ".env.local:hardlink",
})
if !errors.Is(err, ErrInvalidRepositorySettings) {
t.Fatalf("expected ErrInvalidRepositorySettings, got %v", err)
}
}
func TestService_UpdateRepository_InvalidCopyFilesKeyword(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
created, err := svc.CreateRepository(ctx, &CreateRepositoryRequest{WorkspaceID: "ws-1", Name: "Test Repo"})
if err != nil {
t.Fatalf("CreateRepository failed: %v", err)
}
bad := ".env:move"
_, err = svc.UpdateRepository(ctx, created.ID, &UpdateRepositoryRequest{CopyFiles: &bad})
if !errors.Is(err, ErrInvalidRepositorySettings) {
t.Fatalf("expected ErrInvalidRepositorySettings, got %v", err)
}
}
func TestService_CreateRepository_DefaultWorktreeBranchTemplate(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
@@ -51,27 +51,69 @@ 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.
const KeywordSymlink = "symlink"
// 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 {
Pattern string
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, ""
}
return strings.TrimSpace(entry[:idx]), strings.TrimSpace(entry[idx+1:])
}
// 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.
// non-empty patterns with any `:keyword` 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
// path, where symlinks back to the host repo can't apply.
func Parse(spec string) []string {
specs := ParseSpecs(spec)
if len(specs) == 0 {
return nil
}
out := make([]string, 0, len(specs))
for _, s := range specs {
out = append(out, s.Pattern)
}
return out
}
// 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.
func ParseSpecs(spec string) []PatternSpec {
if spec == "" {
return nil
}
parts := splitTopLevelCommas(spec)
out := make([]string, 0, len(parts))
out := make([]PatternSpec, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
pattern, keyword := splitKeyword(strings.TrimSpace(p))
if pattern == "" {
continue
}
if _, ok := seen[p]; ok {
if _, ok := seen[pattern]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
seen[pattern] = struct{}{}
out = append(out, PatternSpec{Pattern: pattern, Symlink: keyword == KeywordSymlink})
}
if len(out) == 0 {
return nil
@@ -79,6 +121,30 @@ func Parse(spec string) []string {
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.
func ValidateSpec(spec string) error {
if spec == "" {
return nil
}
for _, p := range splitTopLevelCommas(spec) {
p = strings.TrimSpace(p)
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)
}
}
return nil
}
// 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.
@@ -105,13 +171,15 @@ func splitTopLevelCommas(s string) []string {
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
// included), one warning per problematic pattern or rejected match, and an
// error only for IO failures that would corrupt the target.
func Copy(ctx context.Context, sourceDir, targetDir string, patterns []string, log *zap.Logger) ([]string, []string, error) {
if len(patterns) == 0 {
// Copy resolves each spec's pattern relative to sourceDir and copies (or, for
// `:symlink` entries, symlinks) matches into targetDir, preserving relative
// paths. It returns the relative paths of files newly written into targetDir
// (skip-if-exists matches are NOT included), one warning per problematic
// pattern or rejected match, and an error only for IO failures that would
// corrupt the target. Symlink entries create a relative link back to the
// source in the main repo, so shared files stay centrally managed.
func Copy(ctx context.Context, sourceDir, targetDir string, specs []PatternSpec, log *zap.Logger) ([]string, []string, error) {
if len(specs) == 0 {
return nil, nil, nil
}
if err := ctx.Err(); err != nil {
@@ -131,11 +199,12 @@ func Copy(ctx context.Context, sourceDir, targetDir string, patterns []string, l
copied: make(map[string]struct{}),
}
for _, pattern := range patterns {
for _, spec := range specs {
if err := ctx.Err(); err != nil {
return state.copiedRel, state.warnings, fmt.Errorf("copyfiles: %w", err)
}
if err := state.expandPattern(pattern); err != nil {
state.symlinkMode = spec.Symlink
if err := state.expandPattern(spec.Pattern); err != nil {
return state.copiedRel, state.warnings, err
}
}
@@ -157,6 +226,12 @@ type copyState struct {
// the filesystem.
planMode bool
entries []Entry
// symlinkMode, when true, symlinks matches back to the source instead of
// copying their bytes. Set per-pattern by Copy(). Ignored in planMode: a
// symlink into the host repo can't apply on a remote executor, so those
// entries fall back to a byte copy.
symlinkMode bool
}
// Plan resolves each pattern relative to sourceDir and reads every match
@@ -484,12 +559,58 @@ func (s *copyState) handleMatch(matchPath, pattern string) error {
return nil
}
// Symlink mode (host-side only): link the match — file or directory — back
// to the source in the main repo rather than copying its bytes. Skipped in
// planMode so remote executors fall back to a copy.
if s.symlinkMode && !s.planMode {
return s.symlinkMatch(safe, rel)
}
if rInfo.IsDir() {
return s.copyDir(resolved, rel)
}
return s.copyFile(resolved, rel, rInfo)
}
// symlinkMatch creates a relative symlink at targetDir/rel pointing back to the
// source match (src, the pre-resolution path inside the repo). Idempotent:
// skips when the destination already exists. The relative target keeps the link
// valid if the whole worktree tree is relocated.
func (s *copyState) symlinkMatch(src, rel string) error {
dst := filepath.Join(s.targetDir, rel)
if _, dup := s.copied[dst]; dup {
return nil
}
if _, err := os.Lstat(dst); err == nil {
s.copied[dst] = struct{}{}
s.debug("skip existing", zap.String("rel", rel))
return nil
}
parent := filepath.Dir(dst)
// Reject a symlinked destination ancestor before MkdirAll/os.Symlink follow
// it out of the worktree (e.g. base branch ships `config -> /tmp` and the
// entry is `config/.env:symlink`). Mirrors the remote WriteEntries guard.
if warn, ok := validateParentChain(s.targetDir, parent); !ok {
s.warn("symlink %q rejected: %s", rel, warn)
return nil
}
if err := os.MkdirAll(parent, 0o755); err != nil {
return fmt.Errorf("copyfiles: mkdir %s: %w", parent, err)
}
target, err := filepath.Rel(parent, src)
if err != nil {
target = src
}
if err := os.Symlink(target, dst); err != nil {
s.warn("symlink %q -> %q: %v", dst, target, err)
return nil
}
s.copied[dst] = struct{}{}
s.copiedRel = append(s.copiedRel, filepath.ToSlash(rel))
s.debug("symlinked", zap.String("rel", rel))
return nil
}
// copyDir walks src recursively and copies every regular file inside.
func (s *copyState) copyDir(src, relRoot string) error {
return filepath.WalkDir(src, func(path string, d fs.DirEntry, walkErr error) error {
@@ -0,0 +1,120 @@
package copyfiles
import (
"context"
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
)
func TestParseSpecs(t *testing.T) {
t.Parallel()
got := ParseSpecs(".env, .env.local:symlink, config/{local,dev}.yml")
want := []PatternSpec{
{Pattern: ".env"},
{Pattern: ".env.local", Symlink: true},
{Pattern: "config/{local,dev}.yml"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSpecs = %#v, want %#v", got, want)
}
}
func TestValidateSpec(t *testing.T) {
t.Parallel()
for _, ok := range []string{"", ".env", ".env, .env.local:symlink", "config/{local,dev}.yml", ".env:"} {
if err := ValidateSpec(ok); err != nil {
t.Errorf("ValidateSpec(%q) unexpected error: %v", ok, err)
}
}
for _, bad := range []string{".env:hardlink", ".env, foo:move"} {
if err := ValidateSpec(bad); err == nil {
t.Errorf("ValidateSpec(%q) = nil, want error", bad)
}
}
}
func TestCopy_SymlinkMode_File(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation often requires privilege on Windows")
}
src := t.TempDir()
dst := t.TempDir()
writeFile(t, filepath.Join(src, ".env.local"), "SECRET=1", 0o644)
copied, warnings, err := Copy(context.Background(), src, dst,
[]PatternSpec{{Pattern: ".env.local", Symlink: true}}, 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.local"))
if err != nil {
t.Fatalf("lstat: %v", err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("expected a symlink, got mode %s", info.Mode())
}
// The link resolves to the source content.
got, err := os.ReadFile(filepath.Join(dst, ".env.local"))
if err != nil {
t.Fatalf("read through symlink: %v", err)
}
if string(got) != "SECRET=1" {
t.Fatalf("symlink content = %q", got)
}
if len(copied) != 1 || copied[0] != ".env.local" {
t.Fatalf("copied = %v, want [.env.local]", copied)
}
}
// 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)
entries, warnings, err := Plan(context.Background(), src, Parse(".env.local:symlink"), 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)
}
}
// A symlink entry whose destination parent is itself a symlink pointing outside
// the worktree must be rejected before MkdirAll/os.Symlink follow it out.
func TestCopy_SymlinkMode_RejectsSymlinkedParent(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation often requires privilege on Windows")
}
src := t.TempDir()
dst := t.TempDir()
outside := t.TempDir()
writeFile(t, filepath.Join(src, "config", ".env"), "SECRET=1", 0o644)
// The worktree already has a symlinked `config` ancestor pointing outside.
if err := os.Symlink(outside, filepath.Join(dst, "config")); err != nil {
t.Skipf("symlink unsupported: %v", err)
}
_, warnings, err := Copy(context.Background(), src, dst,
[]PatternSpec{{Pattern: "config/.env", Symlink: true}}, nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
if len(warnings) != 1 {
t.Fatalf("expected 1 rejection warning, got %v", warnings)
}
// Nothing was written through the symlinked parent into the outside dir.
if _, statErr := os.Lstat(filepath.Join(outside, ".env")); !os.IsNotExist(statErr) {
t.Fatalf("symlink escaped through symlinked parent: %v", statErr)
}
}
@@ -11,6 +11,15 @@ import (
"testing"
)
// toSpecs wraps bare patterns as copy-mode PatternSpecs for the Copy tests.
func toSpecs(patterns ...string) []PatternSpec {
out := make([]PatternSpec, len(patterns))
for i, p := range patterns {
out[i] = PatternSpec{Pattern: p}
}
return out
}
func TestParse(t *testing.T) {
t.Parallel()
@@ -79,7 +88,7 @@ func TestCopy_LiteralFile(t *testing.T) {
writeFile(t, filepath.Join(src, ".env"), "X=1", 0o600)
_, warnings, err := Copy(context.Background(), src, dst, []string{".env"}, nil)
_, warnings, err := Copy(context.Background(), src, dst, toSpecs(".env"), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
@@ -110,7 +119,7 @@ func TestCopy_Glob(t *testing.T) {
writeFile(t, filepath.Join(src, "b.local"), "B", 0o644)
writeFile(t, filepath.Join(src, "c.txt"), "C", 0o644)
_, warnings, err := Copy(context.Background(), src, dst, []string{"*.local"}, nil)
_, warnings, err := Copy(context.Background(), src, dst, toSpecs("*.local"), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
@@ -136,7 +145,7 @@ func TestCopy_DirectoryRecursive(t *testing.T) {
writeFile(t, filepath.Join(src, "config", "local.yml"), "y", 0o644)
writeFile(t, filepath.Join(src, "config", "sub", "dev.json"), "j", 0o644)
_, warnings, err := Copy(context.Background(), src, dst, []string{"config"}, nil)
_, warnings, err := Copy(context.Background(), src, dst, toSpecs("config"), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
@@ -158,7 +167,7 @@ func TestCopy_NestedFile(t *testing.T) {
writeFile(t, filepath.Join(src, "config", "local.yml"), "y", 0o644)
_, warnings, err := Copy(context.Background(), src, dst, []string{"config/local.yml"}, nil)
_, warnings, err := Copy(context.Background(), src, dst, toSpecs("config/local.yml"), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
@@ -175,7 +184,7 @@ func TestCopy_MissingPattern(t *testing.T) {
src := t.TempDir()
dst := t.TempDir()
_, warnings, err := Copy(context.Background(), src, dst, []string{".env"}, nil)
_, warnings, err := Copy(context.Background(), src, dst, toSpecs(".env"), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
@@ -202,7 +211,7 @@ func TestCopy_DoubleStarRecursive(t *testing.T) {
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)
_, warnings, err := Copy(context.Background(), src, dst, toSpecs("**/.env"), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
@@ -235,7 +244,7 @@ func TestCopy_DoubleStarScoped(t *testing.T) {
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)
_, warnings, err := Copy(context.Background(), src, dst, toSpecs("apps/**/config.yml"), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
@@ -262,7 +271,7 @@ func TestCopy_BraceAlternation(t *testing.T) {
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)
_, warnings, err := Copy(context.Background(), src, dst, toSpecs(".env{,.local}"), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
@@ -294,7 +303,7 @@ func TestCopy_ParseBraceRoundTrip(t *testing.T) {
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)
_, warnings, err := Copy(context.Background(), src, dst, toSpecs(patterns...), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
@@ -322,7 +331,7 @@ func TestCopy_GlobNoMatch(t *testing.T) {
writeFile(t, filepath.Join(src, "foo.txt"), "foo", 0o644)
_, warnings, err := Copy(context.Background(), src, dst, []string{"*.local"}, nil)
_, warnings, err := Copy(context.Background(), src, dst, toSpecs("*.local"), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
@@ -343,7 +352,7 @@ func TestCopy_PathTraversal_Relative(t *testing.T) {
// Create a file outside src
writeFile(t, filepath.Join(parent, "escape.txt"), "leak", 0o644)
_, warnings, err := Copy(context.Background(), src, dst, []string{"../escape.txt"}, nil)
_, warnings, err := Copy(context.Background(), src, dst, toSpecs("../escape.txt"), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
@@ -371,7 +380,7 @@ func TestCopy_PathTraversal_Absolute(t *testing.T) {
outside := filepath.Join(parent, "abs_escape.txt")
writeFile(t, outside, "leak", 0o644)
_, warnings, err := Copy(context.Background(), src, dst, []string{outside}, nil)
_, warnings, err := Copy(context.Background(), src, dst, toSpecs(outside), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
@@ -397,7 +406,7 @@ func TestCopy_SymlinkInside(t *testing.T) {
t.Skipf("symlink unsupported: %v", err)
}
_, warnings, err := Copy(context.Background(), src, dst, []string{".env"}, nil)
_, warnings, err := Copy(context.Background(), src, dst, toSpecs(".env"), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
@@ -438,7 +447,7 @@ func TestCopy_SymlinkOutside(t *testing.T) {
t.Skipf("symlink unsupported: %v", err)
}
_, warnings, err := Copy(context.Background(), src, dst, []string{"leak"}, nil)
_, warnings, err := Copy(context.Background(), src, dst, toSpecs("leak"), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
@@ -458,7 +467,7 @@ func TestCopy_Idempotent(t *testing.T) {
writeFile(t, filepath.Join(src, ".env"), "SRC", 0o644)
writeFile(t, filepath.Join(dst, ".env"), "DST_ORIGINAL", 0o644)
_, warnings, err := Copy(context.Background(), src, dst, []string{".env"}, nil)
_, warnings, err := Copy(context.Background(), src, dst, toSpecs(".env"), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
@@ -477,7 +486,7 @@ func TestCopy_EmptyPatterns(t *testing.T) {
dst := t.TempDir()
for _, patterns := range [][]string{nil, {}} {
_, warnings, err := Copy(context.Background(), src, dst, patterns, nil)
_, warnings, err := Copy(context.Background(), src, dst, toSpecs(patterns...), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
@@ -500,7 +509,7 @@ func TestCopy_NilLogger(t *testing.T) {
}
}()
if _, _, err := Copy(context.Background(), src, dst, []string{".env"}, nil); err != nil {
if _, _, err := Copy(context.Background(), src, dst, toSpecs(".env"), nil); err != nil {
t.Fatalf("Copy err: %v", err)
}
}
@@ -518,7 +527,7 @@ func TestCopy_SymlinkedSourceDir(t *testing.T) {
writeFile(t, filepath.Join(realDir, ".env"), "X=1", 0o600)
target := t.TempDir()
_, warnings, err := Copy(context.Background(), linkedDir, target, []string{".env"}, nil)
_, warnings, err := Copy(context.Background(), linkedDir, target, toSpecs(".env"), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
@@ -533,7 +542,7 @@ func TestCopy_SymlinkedSourceDir(t *testing.T) {
func TestCopy_MissingSourceDir(t *testing.T) {
t.Parallel()
_, _, err := Copy(context.Background(), "/nonexistent-dir-xyz", t.TempDir(), []string{".env"}, nil)
_, _, err := Copy(context.Background(), "/nonexistent-dir-xyz", t.TempDir(), toSpecs(".env"), nil)
if err == nil {
t.Fatalf("expected error for missing source dir")
}
@@ -549,7 +558,7 @@ func TestCopy_ContextCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, _, err := Copy(ctx, src, dst, []string{".env"}, nil)
_, _, err := Copy(ctx, src, dst, toSpecs(".env"), nil)
if err == nil {
t.Fatalf("expected error from cancelled context")
}
@@ -825,7 +834,7 @@ func TestCopy_ReturnsCopiedRelPaths(t *testing.T) {
writeFile(t, filepath.Join(dst, "already.txt"), "preexisting", 0o644)
copied, warnings, err := Copy(context.Background(), src, dst,
[]string{".env", "config", "already.txt"}, nil)
toSpecs(".env", "config", "already.txt"), nil)
if err != nil {
t.Fatalf("Copy err: %v", err)
}
@@ -805,11 +805,11 @@ func (m *Manager) copyConfiguredFiles(ctx context.Context, req CreateRequest, wt
if repo == nil || repo.CopyFiles == "" {
return
}
patterns := copyfiles.Parse(repo.CopyFiles)
if len(patterns) == 0 {
specs := copyfiles.ParseSpecs(repo.CopyFiles)
if len(specs) == 0 {
return
}
copied, warnings, err := copyfiles.Copy(ctx, req.RepositoryPath, wt.Path, patterns, m.logger.Zap())
copied, warnings, err := copyfiles.Copy(ctx, req.RepositoryPath, wt.Path, specs, m.logger.Zap())
if err != nil {
m.logger.Warn("worktree copy-files failed",
zap.String("session_id", req.SessionID),
@@ -61,4 +61,16 @@ describe("isRepositoryDirty", () => {
const repo = makeRepo();
expect(isRepositoryDirty(repo, undefined)).toBe(true);
});
it("treats a copy_files entry with a :symlink keyword as clean when unchanged", () => {
const saved = makeRepo({ copy_files: ".env, .env.local:symlink" });
const repo = makeRepo({ copy_files: ".env, .env.local:symlink" });
expect(isRepositoryDirty(repo, saved)).toBe(false);
});
it("returns true when a copy_files entry's keyword changes", () => {
const saved = makeRepo({ copy_files: ".env.local" });
const repo = makeRepo({ copy_files: ".env.local:symlink" });
expect(isRepositoryDirty(repo, saved)).toBe(true);
});
});
@@ -21,11 +21,15 @@ export function CopyFilesField({ repositoryId, copyFiles, onUpdate }: CopyFilesF
<Textarea
value={copyFiles}
onChange={(e) => onUpdate(repositoryId, { copy_files: e.target.value })}
placeholder=".env, .env.*, apps/**/.env, config/{local,dev}.yml"
placeholder=".env, .env.*, apps/**/.env, .env.local:symlink"
rows={2}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">Gitignored paths copied into new worktrees.</p>
<p 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.
</p>
</div>
);
}
@@ -61,6 +65,12 @@ function CopyFilesHelpTip() {
<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
+5
View File
@@ -199,6 +199,11 @@ export type Repository = {
setup_script: string;
cleanup_script: string;
dev_script: string;
/**
* 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.
*/
copy_files: string;
created_at: string;
updated_at: string;