Files
kandev/docs/decisions/0010-worktree-copy-files.md
T

6.8 KiB

0010: Worktree copy-files — per-repo, idempotent, host-local

Status: accepted Date: 2026-05-19 Area: backend, frontend

Context

Git worktrees don't carry gitignored files. The most painful case for kandev users is .env and friends — every new task creates a fresh worktree, and the agent's first step is usually pnpm dev or make run, which fails immediately because there's no .env. Users were copying files manually after each task creation. Issue #946 asked for a built-in solution, citing vibe-kanban's copy_files setting as prior art.

The feature has several axes of choice that aren't obvious from the issue text alone, and a couple are surprising enough that future maintainers will reasonably question them.

Decision

A per-repository, comma-separated list of paths/globs (repositories.copy_files). Copied from the source repo dir into each new worktree at task creation, in the Go process on the host. Target-exists check makes the copy idempotent.

Concretely:

  1. Scope: per-repository, not per-task or per-workspace. The set of secret files in a repo (.env, .env.local, config/local.yml) is a property of the repo, not of any individual task. Storing it once per repo means users configure it during workspace setup and never think about it again. A per-task field would push friction back onto every task creation, defeating the purpose. A per-workspace field would force every repo in the workspace to share the same list — wrong for polyglot workspaces.

  2. Wire format: comma-separated string of paths or filepath.Glob patterns. Matches vibe-kanban exactly, which means users migrating between tools don't relearn syntax. Stdlib filepath.Glob supports *, ?, [...] — enough for .env, *.local, config/*.json. No **/doublestar in v1: it's a single transitive dependency we don't need to take to ship the feature, and the literal-file-plus-directory-recursion path (config/ recursively walks all files) covers the rest of the issue's examples. Adding doublestar later is additive.

  3. Trigger: at worktree creation, between persistAndCacheWorktree and runWorktreeSetupScript. Setup scripts often read .env (e.g. pnpm install with NODE_ENV-gated postinstalls), so the copy must complete first. Placing it after persistAndCacheWorktree means a failed copy doesn't leave a phantom DB row — the worktree is already committed, and copy errors are recoverable.

  4. Host-local Go copy, not an agentctl RPC. The worktree filesystem is on the host (today). The copy is io.Copy between two *os.Files under a path-traversal guard. No subprocess, no shell, no Windows cp issue, no agentctl round-trip.

  5. Idempotent: skip if target exists. Re-running the copy on the same worktree (e.g. future "re-prepare worktree" actions, or session resume that re-enters this code path) is a no-op for already-copied files. The cost is that updating copy_files in settings does not propagate to existing worktrees — only future ones. The frontend helper text says so explicitly. The alternative — overwrite-on-update — would clobber files the agent had already mutated mid-task, which is worse.

  6. Missing files = warning, not error. Acceptance criterion in the issue. A typo in copy_files should not block task creation; the agent failing later with a clearer "ENOENT: .env" is better than the worktree refusing to spawn.

  7. Path-traversal guard: canonicalize, then filepath.Rel under canonical root. EvalSymlinks(sourceDir) is called once; all pattern joins and result paths use that canonical root, not the raw sourceDir argument. This matters because macOS resolves /tmp → /private/tmp and many users have ~/code → /data/code style symlinks; without canonicalization, every match looks "outside source dir" via filepath.Rel and the feature silently no-ops. (Regression test: TestCopy_SymlinkedSourceDir.) Resolved matches outside the canonical root are still rejected — symlink traversal as an exfiltration vector is closed.

Consequences

  • Remote executors are supported via a parallel "ship bytes through agentctl" path (added 2026-05-24). The host reads source files into memory via copyfiles.Plan and POSTs them to POST /workspace/copy-files, which calls copyfiles.WriteEntries against the container workspace root. Both paths share Plan / WriteEntries so path-traversal guard, idempotency, and skip-if-exists are identical. Per-file payloads are capped at 5 MB (copyfiles.MaxEntryBytes) to bound launch-time RAM + wire bytes against accidental matches like node_modules/; oversize files are warn-skipped rather than silently dropped. Wired into local_docker and sprites; remote_docker / k8s (planned) inherit automatically as long as their executors expose an agentctl.Client on the returned instance. The shipping step appears in the prepare-progress panel as Copy N ignored files, matching the worktree path's naming via the shared buildCopyFilesStep helper.
  • Disk-fill DoS is bounded for remote paths by the 5 MB cap above. For the host-side worktree path it remains self-inflicted but unbounded — a user adding ** or node_modules/ would explode disk usage across many tasks. If support tickets surface this, add a soft warning at >100 MB / >1000 files for the local case as well.
  • UI feedback for warnings. The prepare-progress panel now surfaces copy warnings (missing patterns, oversize files, ship failures) inline on the Copy N ignored files step. Backend zap logs remain for deeper debugging.
  • The worktree.RepositoryAdapter indirection stays — it maps models.Repository → worktree.Repository (a minimal struct exposing only {ID, SetupScript, CleanupScript, CopyFiles}). Adding more cross-cutting per-repo settings should go through this adapter, not by widening the worktree package's import surface.

Alternatives considered

  • Extend setup_script. Users could already write cp /path/to/.env . in setup. Rejected: requires every user to write shell, fights Windows (no cp), doesn't canonicalize source paths, and surfaces secrets in script output that gets streamed to the session UI. A dedicated field is purpose-built.
  • Per-task override on the task creation dialog. Reasonable for one-off needs, but adds friction to the common path. Defer until a user asks.
  • Watch and re-copy on copy_files setting change. Would make settings updates "live" for existing worktrees. Rejected: existing worktrees may have agent-modified copies, and silent re-copy risks data loss. The current design forces the user to re-create the task if they want the new files, which is the safe default.
  • JSON or YAML for the spec. Considered for future expressiveness ({src, dest, mode} tuples). Rejected for v1: vibe-kanban's flat comma-separated format is proven, trivial to migrate from, and we have no use case yet for dest differing from src.