@@ -4,12 +4,15 @@ import { tmpdir } from "node:os";
44import { join } from "node:path" ;
55import {
66 BunGitGateway ,
7+ filterLiveWorktreeEntries ,
8+ listGitWorktrees ,
79 listLocalGitBranches ,
810 parseGitWorktreePorcelain ,
911 readGitWorktreeStatus ,
1012 removeGitWorktree ,
1113 resolveWorktreeGitDir ,
1214 resolveWorktreeRoot ,
15+ worktreeEntryPathExists ,
1316} from "../adapters/git" ;
1417
1518function normalizePath ( path : string ) : string {
@@ -323,6 +326,79 @@ describe("BunGitGateway", () => {
323326 } ) ;
324327} ) ;
325328
329+ describe ( "stale worktree resilience" , ( ) => {
330+ let repoRoot = "" ;
331+
332+ afterEach ( async ( ) => {
333+ if ( repoRoot ) {
334+ await rm ( repoRoot , { recursive : true , force : true } ) ;
335+ repoRoot = "" ;
336+ }
337+ } ) ;
338+
339+ it ( "does not throw posix_spawn ENOENT for tryRunGit-backed callers when cwd is missing" , ( ) => {
340+ // Regression for the crash: Bun.spawnSync throws synchronously when cwd is gone.
341+ // tryRunGit-backed callers (readDiff, listUnpushedCommits, fetchBranch, ...) must
342+ // surface this as a normal failure, not propagate the throw.
343+ const gateway = new BunGitGateway ( ) ;
344+ expect ( ( ) => gateway . readDiff ( "/tmp/webmux-missing-xyz-12345-does-not-exist" ) ) . not . toThrow ( ) ;
345+ expect ( gateway . readDiff ( "/tmp/webmux-missing-xyz-12345-does-not-exist" ) ) . toBe ( "" ) ;
346+ expect ( ( ) => gateway . listUnpushedCommits ( "/tmp/webmux-missing-xyz-12345-does-not-exist" ) ) . not . toThrow ( ) ;
347+ expect ( gateway . listUnpushedCommits ( "/tmp/webmux-missing-xyz-12345-does-not-exist" ) ) . toEqual ( [ ] ) ;
348+ } ) ;
349+
350+ it ( "throws a controlled error when runGit is invoked against a missing cwd" , ( ) => {
351+ // runGit-backed callers re-raise with a readable message that names the cwd —
352+ // not a bare posix_spawn stack trace.
353+ expect ( ( ) => listLocalGitBranches ( "/tmp/webmux-missing-xyz-12345-does-not-exist" ) )
354+ . toThrow ( / c w d = \/ t m p \/ w e b m u x - m i s s i n g - x y z - 1 2 3 4 5 - d o e s - n o t - e x i s t / ) ;
355+ } ) ;
356+
357+ it ( "worktreeEntryPathExists rejects missing paths" , ( ) => {
358+ expect ( worktreeEntryPathExists ( {
359+ path : "/tmp/webmux-missing-xyz-12345-does-not-exist" ,
360+ head : null ,
361+ branch : null ,
362+ detached : false ,
363+ bare : false ,
364+ } ) ) . toBe ( false ) ;
365+ } ) ;
366+
367+ it ( "filterLiveWorktreeEntries drops stale entries and keeps live ones" , async ( ) => {
368+ repoRoot = await mkdtemp ( join ( tmpdir ( ) , "webmux-filter-live-" ) ) ;
369+ expect ( filterLiveWorktreeEntries ( [
370+ { path : repoRoot , head : "abc" , branch : "main" , detached : false , bare : false } ,
371+ { path : "/tmp/webmux-missing-xyz-12345-does-not-exist" , head : "def" , branch : "stale" , detached : false , bare : false } ,
372+ ] ) ) . toEqual ( [
373+ { path : repoRoot , head : "abc" , branch : "main" , detached : false , bare : false } ,
374+ ] ) ;
375+ } ) ;
376+
377+ it ( "BunGitGateway.listLiveWorktrees omits registrations whose directory was deleted" , async ( ) => {
378+ repoRoot = await mkdtemp ( join ( tmpdir ( ) , "webmux-stale-wt-" ) ) ;
379+ run ( [ "git" , "init" , "-b" , "main" ] , repoRoot ) ;
380+ run ( [ "git" , "config" , "user.name" , "Test User" ] , repoRoot ) ;
381+ run ( [ "git" , "config" , "user.email" , "test@example.com" ] , repoRoot ) ;
382+ await Bun . write ( join ( repoRoot , "README.md" ) , "# repo\n" ) ;
383+ run ( [ "git" , "add" , "README.md" ] , repoRoot ) ;
384+ run ( [ "git" , "commit" , "-m" , "init" ] , repoRoot ) ;
385+
386+ const worktreePath = join ( repoRoot , "__worktrees" , "stale" ) ;
387+ await mkdir ( join ( repoRoot , "__worktrees" ) , { recursive : true } ) ;
388+ run ( [ "git" , "worktree" , "add" , "-b" , "stale" , worktreePath ] , repoRoot ) ;
389+
390+ await rm ( worktreePath , { recursive : true , force : true } ) ;
391+
392+ // Raw list keeps the dangling registration (semantics preserved for removeGitWorktree).
393+ expect ( listGitWorktrees ( repoRoot ) . some ( ( entry ) => entry . path === worktreePath ) ) . toBe ( true ) ;
394+
395+ // Live list filters it out.
396+ const gateway = new BunGitGateway ( ) ;
397+ expect ( gateway . listLiveWorktrees ( repoRoot ) . some ( ( entry ) => entry . path === worktreePath ) ) . toBe ( false ) ;
398+ expect ( gateway . listLiveWorktrees ( repoRoot ) . some ( ( entry ) => entry . path === repoRoot ) ) . toBe ( true ) ;
399+ } ) ;
400+ } ) ;
401+
326402describe ( "removeGitWorktree" , ( ) => {
327403 it ( "cleans up the leftover directory when git already unregistered the worktree" , ( ) => {
328404 const removedPaths : string [ ] = [ ] ;
0 commit comments