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.
Summary
worktree.symlinksin.codev/config.local.jsoncurrently accepts file entries only. Directory entries are silently dropped becausesymlinkConfigFilesglobs withnodir: 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:nodir: truemeans a directory entry like".local-user-data"is silently filtered out by the glob — even thoughsymlinkSyncitself handles directories on POSIX. There is no log line indicating the entry was skipped.Why
nodir: trueexists (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 ofgit 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.mjsanchorsSANDBOX_VOLUME_MOUNT_PATHto<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.
globSync(pattern, { dot: true, nodir: true }), current code path.nodir: false),symlinkSync(srcAbs, dest). KeepexistsSync(target) → continuefor idempotency. AddexistsSync(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 onstatSync(srcAbs).isDirectory(); otherwise document POSIX-only.Acceptance
worktree.symlinksentry".local-user-data/"produces<worktree>/.local-user-data → <workspaceRoot>/.local-user-dataafter spawn.worktree.symlinksentry without a trailing slash continues to skip directories silently, as today.Out of scope
git check-ignore. Trailing-slash opt-in is simpler and self-documenting.\".local-user-data/\"to its.codev/config.local.jsononce this lands. That's a one-line change tracked separately.