Skip to content

Allow directory entries in worktree.symlinks via trailing-slash opt-in #805

@amrmelsayed

Description

@amrmelsayed

Summary

worktree.symlinks in .codev/config.local.json currently accepts file entries only. Directory entries are silently dropped because symlinkConfigFiles globs with nodir: true. Add a trailing-slash opt-in (e.g. ".local-user-data/") so a directory entry produces a working directory symlink inside each spawned worktree, while files without a trailing slash keep their existing behaviour.

Current behaviour

packages/codev/src/agent-farm/commands/spawn-worktree.ts:83-91:

for (const pattern of getWorktreeConfig(config.workspaceRoot).symlinks) {
  for (const rel of globSync(pattern, { cwd: config.workspaceRoot, dot: true, nodir: true })) {
    const target = resolve(worktreePath, rel);
    if (existsSync(target)) continue;
    mkdirSync(dirname(target), { recursive: true });
    symlinkSync(resolve(config.workspaceRoot, rel), target);
    logger.info(`Linked ${rel} from workspace root`);
  }
}

nodir: true means a directory entry like ".local-user-data" is silently filtered out by the glob — even though symlinkSync itself handles directories on POSIX. There is no log line indicating the entry was skipped.

Why nodir: true exists (and why we want to keep its intent)

Without a guard, a pattern like "apps/auth" would symlink the worktree's own source directory back at the parent checkout. The builder's branch would silently edit the parent's working copy — a hard-to-detect footgun that defeats the point of git worktree add. So the goal is opt-in for directories, not a blanket relaxation.

Use case

Sharing per-worktree runtime state directories that are gitignored and intentionally not branch-isolated. Concrete example: in cluesmith/shannon, tools/dev-local.mjs anchors SANDBOX_VOLUME_MOUNT_PATH to <REPO_ROOT>/.local-user-data/ — OAuth tokens, persona prefs, agent session JSONLs, calendar cache, sandbox volume state. Today every builder worktree re-bootstraps that dir from scratch. With a directory symlink we can boot a builder against the parent's existing state.

Proposed behaviour

Trailing slash on an entry == explicit "this is a directory" marker.

  • Entries without trailing slash → unchanged. globSync(pattern, { dot: true, nodir: true }), current code path.
  • Entries with trailing slash → strip the slash, treat as a literal path (or glob with nodir: false), symlinkSync(srcAbs, dest). Keep existsSync(target) → continue for idempotency. Add existsSync(srcAbs) since the glob will no longer be filtering out missing sources for this branch.

That keeps the footgun guard for the common case: \"apps/auth\" still can't mask source, only \"apps/auth/\" can — and that's now explicitly opted in.

Windows note: directory symlinks need symlinkSync(src, dest, 'dir'). If cross-platform matters, branch on statSync(srcAbs).isDirectory(); otherwise document POSIX-only.

Acceptance

  • A worktree.symlinks entry ".local-user-data/" produces <worktree>/.local-user-data → <workspaceRoot>/.local-user-data after spawn.
  • Writes through the link land at the parent's path.
  • Source not existing at spawn time is non-fatal (dangling link is acceptable — runtime tooling creates the dir).
  • A worktree.symlinks entry without a trailing slash continues to skip directories silently, as today.
  • Existing file-only configs are unaffected.

Out of scope

  • Auto-detecting tracked-vs-untracked paths via git check-ignore. Trailing-slash opt-in is simpler and self-documenting.
  • Cross-repo follow-up: shannon will add \".local-user-data/\" to its .codev/config.local.json once this lands. That's a one-line change tracked separately.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions