@@ -64,23 +64,6 @@ function withWorktreeLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
6464 return next ;
6565}
6666
67- // --- Symlink candidates ---
68-
69- const SYMLINK_CANDIDATES = [
70- '.claude' ,
71- '.cursor' ,
72- '.aider' ,
73- '.copilot' ,
74- '.codeium' ,
75- '.continue' ,
76- '.windsurf' ,
77- '.env' ,
78- 'node_modules' ,
79- ] ;
80-
81- /** Entries inside `.claude` that must NOT be symlinked (kept per-worktree). */
82- const CLAUDE_DIR_EXCLUDE = new Set ( [ 'plans' , 'settings.local.json' ] ) ;
83-
8467// --- Internal helpers ---
8568
8669async function detectMainBranch ( repoRoot : string ) : Promise < string > {
@@ -303,33 +286,6 @@ async function computeBranchDiffStats(
303286 return { linesAdded, linesRemoved } ;
304287}
305288
306- /**
307- * "Shallow-symlink" a directory: create a real directory at `target` and
308- * symlink each entry from `source` into it, EXCEPT entries in `exclude`.
309- */
310- function shallowSymlinkDir ( source : string , target : string , exclude : Set < string > ) : void {
311- fs . mkdirSync ( target , { recursive : true } ) ;
312- let entries : fs . Dirent [ ] ;
313- try {
314- entries = fs . readdirSync ( source , { withFileTypes : true } ) ;
315- } catch ( err ) {
316- console . warn ( `Failed to read directory ${ source } for shallow-symlink:` , err ) ;
317- return ;
318- }
319- for ( const entry of entries ) {
320- if ( exclude . has ( entry . name ) ) continue ;
321- const src = path . join ( source , entry . name ) ;
322- const dst = path . join ( target , entry . name ) ;
323- try {
324- if ( ! fs . existsSync ( dst ) ) {
325- fs . symlinkSync ( src , dst ) ;
326- }
327- } catch {
328- /* ignore */
329- }
330- }
331- }
332-
333289// --- Public functions (used by tasks.ts and register.ts) ---
334290
335291export async function createWorktree (
@@ -365,21 +321,19 @@ export async function createWorktree(
365321 await exec ( 'git' , [ 'worktree' , 'add' , '-b' , branchName , worktreePath ] , { cwd : repoRoot } ) ;
366322
367323 // Symlink selected directories
324+ const resolvedRoot = path . resolve ( repoRoot ) + path . sep ;
325+ const resolvedWorktree = path . resolve ( worktreePath ) + path . sep ;
368326 for ( const name of symlinkDirs ) {
369- // Reject names that could escape the worktree directory
370- if ( name . includes ( '/' ) || name . includes ( '\\' ) || name . includes ( '..' ) || name === '.' ) continue ;
327+ if ( name . includes ( '..' ) ) continue ;
371328 const source = path . join ( repoRoot , name ) ;
372329 const target = path . join ( worktreePath , name ) ;
330+ if ( ! path . resolve ( source ) . startsWith ( resolvedRoot ) ) continue ;
331+ if ( ! path . resolve ( target ) . startsWith ( resolvedWorktree ) ) continue ;
373332 try {
374333 if ( ! fs . existsSync ( source ) ) continue ;
375334 if ( fs . existsSync ( target ) ) continue ;
376-
377- if ( name === '.claude' ) {
378- // Shallow-symlink: real dir with per-entry symlinks, excluding per-worktree entries
379- shallowSymlinkDir ( source , target , CLAUDE_DIR_EXCLUDE ) ;
380- } else {
381- fs . symlinkSync ( source , target ) ;
382- }
335+ fs . mkdirSync ( path . dirname ( target ) , { recursive : true } ) ;
336+ fs . symlinkSync ( source , target ) ;
383337 } catch {
384338 /* ignore */
385339 }
@@ -425,23 +379,24 @@ export async function removeWorktree(
425379
426380// --- IPC command functions ---
427381
428- export async function getGitIgnoredDirs ( projectRoot : string ) : Promise < string [ ] > {
429- const results : string [ ] = [ ] ;
430- for ( const name of SYMLINK_CANDIDATES ) {
431- const dirPath = path . join ( projectRoot , name ) ;
432- try {
433- fs . statSync ( dirPath ) ; // throws if entry doesn't exist
434- } catch {
435- continue ;
436- }
437- try {
438- await exec ( 'git' , [ 'check-ignore' , '-q' , name ] , { cwd : projectRoot } ) ;
439- results . push ( name ) ;
440- } catch {
441- /* not ignored */
442- }
382+ export function listProjectEntries (
383+ projectRoot : string ,
384+ subpath ?: string ,
385+ ) : { name : string ; isDir : boolean } [ ] {
386+ const HIDDEN = new Set ( [ '.git' , '.worktrees' ] ) ;
387+ const dir = subpath ? path . join ( projectRoot , subpath ) : projectRoot ;
388+ // Prevent traversal outside project root
389+ const resolvedDir = path . resolve ( dir ) ;
390+ const resolvedRoot = path . resolve ( projectRoot ) ;
391+ if ( resolvedDir !== resolvedRoot && ! resolvedDir . startsWith ( resolvedRoot + path . sep ) ) return [ ] ;
392+ try {
393+ return fs
394+ . readdirSync ( dir , { withFileTypes : true } )
395+ . filter ( ( e ) => ! HIDDEN . has ( e . name ) )
396+ . map ( ( e ) => ( { name : e . name , isDir : e . isDirectory ( ) } ) ) ;
397+ } catch {
398+ return [ ] ;
443399 }
444- return results ;
445400}
446401
447402export async function getMainBranch ( projectRoot : string ) : Promise < string > {
0 commit comments