feat(worktree): add per-file copy/symlink materialization per repository (#1650)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user