diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 568ccae574..00e0f0f0e9 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -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 diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity.go b/apps/backend/internal/task/repository/sqlite/repository_entity.go index 2dd32ee1d2..ca9fd78267 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity.go @@ -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 } diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index 385c5dcc4c..c56e39a334 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -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), } diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index 46a878e48c..99141f9d59 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -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 diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index c22829fed2..d4ae300a15 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -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() diff --git a/apps/backend/internal/worktree/copyfiles/copyfiles.go b/apps/backend/internal/worktree/copyfiles/copyfiles.go index fd0093608a..12086943dc 100644 --- a/apps/backend/internal/worktree/copyfiles/copyfiles.go +++ b/apps/backend/internal/worktree/copyfiles/copyfiles.go @@ -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 { diff --git a/apps/backend/internal/worktree/copyfiles/copyfiles_symlink_test.go b/apps/backend/internal/worktree/copyfiles/copyfiles_symlink_test.go new file mode 100644 index 0000000000..0ea8555ecd --- /dev/null +++ b/apps/backend/internal/worktree/copyfiles/copyfiles_symlink_test.go @@ -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) + } +} diff --git a/apps/backend/internal/worktree/copyfiles/copyfiles_test.go b/apps/backend/internal/worktree/copyfiles/copyfiles_test.go index d4a346b51e..f9fcf44038 100644 --- a/apps/backend/internal/worktree/copyfiles/copyfiles_test.go +++ b/apps/backend/internal/worktree/copyfiles/copyfiles_test.go @@ -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) } diff --git a/apps/backend/internal/worktree/manager_lifecycle.go b/apps/backend/internal/worktree/manager_lifecycle.go index 5b8c598e00..d03caa51c3 100644 --- a/apps/backend/internal/worktree/manager_lifecycle.go +++ b/apps/backend/internal/worktree/manager_lifecycle.go @@ -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), diff --git a/apps/web/app/settings/workspace/workspace-repositories-dirty.test.ts b/apps/web/app/settings/workspace/workspace-repositories-dirty.test.ts index f343b92eac..178a38eb33 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-dirty.test.ts +++ b/apps/web/app/settings/workspace/workspace-repositories-dirty.test.ts @@ -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); + }); }); diff --git a/apps/web/components/settings/repository-copy-files-help.tsx b/apps/web/components/settings/repository-copy-files-help.tsx index 850194d6e4..5f88a0190a 100644 --- a/apps/web/components/settings/repository-copy-files-help.tsx +++ b/apps/web/components/settings/repository-copy-files-help.tsx @@ -21,11 +21,15 @@ export function CopyFilesField({ repositoryId, copyFiles, onUpdate }: CopyFilesF