1515 */
1616
1717import { describe , it , expect , beforeEach , afterEach } from 'vitest' ;
18+ import fs from 'fs' ;
19+ import os from 'os' ;
20+ import path from 'path' ;
1821import Database from 'better-sqlite3' ;
1922import {
2023 upsertWorktree ,
@@ -23,7 +26,11 @@ import {
2326 migrateWorktreeIdPreservingChildren ,
2427} from '@/lib/db' ;
2528import { runMigrations } from '@/lib/db/db-migrations' ;
26- import { syncWorktreesToDB } from '@/lib/git/worktrees' ;
29+ import {
30+ syncWorktreesToDB ,
31+ pruneStaleRepositoryWorktrees ,
32+ repositoryExistsOnDisk ,
33+ } from '@/lib/git/worktrees' ;
2734import type { Worktree } from '@/types/models' ;
2835
2936const REPO_PATH = '/repos/anvil' ;
@@ -313,3 +320,155 @@ describe('Issue #1151: branch-switch history preservation', () => {
313320 } ) ;
314321 } ) ;
315322} ) ;
323+
324+ /**
325+ * Issue #1349: a repository whose directory is deleted or de-gitified makes
326+ * `scanWorktrees` return `[]` (git exit 128). The repository then never appears
327+ * in the scan handed to `syncWorktreesToDB`, so its worktree rows are never
328+ * pruned and survive forever as ghost rows in the sidebar. `syncWorktreesToDB`'s
329+ * global early-return only fires when *every* repository is empty, so a single
330+ * vanished repository among healthy ones is never reconciled.
331+ *
332+ * `pruneStaleRepositoryWorktrees` is the global-sync reconciliation step. These
333+ * tests exercise it against a real in-memory SQLite database plus real temp
334+ * directories, asserting that:
335+ * - a vanished directory (or a de-gitified one) is pruned (CASCADE),
336+ * - a directory that still exists is NEVER pruned even when the scan is empty
337+ * (transient git error / network-drive blip → keep, do not destroy history),
338+ * - a repository present in the current scan is treated as alive regardless of
339+ * the filesystem, and other repositories are left untouched.
340+ */
341+ describe ( 'Issue #1349: pruneStaleRepositoryWorktrees' , ( ) => {
342+ let db : Database . Database ;
343+ const tempDirs : string [ ] = [ ] ;
344+
345+ beforeEach ( ( ) => {
346+ db = new Database ( ':memory:' ) ;
347+ runMigrations ( db ) ;
348+ // Mirror production (src/lib/db/db-instance.ts): enforce FK so CASCADE fires.
349+ db . pragma ( 'foreign_keys = ON' ) ;
350+ } ) ;
351+
352+ afterEach ( ( ) => {
353+ db . close ( ) ;
354+ for ( const dir of tempDirs . splice ( 0 ) ) {
355+ fs . rmSync ( dir , { recursive : true , force : true } ) ;
356+ }
357+ } ) ;
358+
359+ /** Create a real temp repo directory (optionally with a `.git` entry). */
360+ function makeRepoDir ( withGit = true ) : string {
361+ const dir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'cm-1349-' ) ) ;
362+ if ( withGit ) fs . mkdirSync ( path . join ( dir , '.git' ) ) ;
363+ tempDirs . push ( dir ) ;
364+ return dir ;
365+ }
366+
367+ function repoWorktree ( repoPath : string , id : string , branch : string ) : Worktree {
368+ return {
369+ id,
370+ name : branch ,
371+ branch,
372+ path : repoPath ,
373+ repositoryPath : repoPath ,
374+ repositoryName : path . basename ( repoPath ) ,
375+ } ;
376+ }
377+
378+ describe ( 'repositoryExistsOnDisk' , ( ) => {
379+ it ( 'is true only when both the directory and its .git exist' , ( ) => {
380+ const withGit = makeRepoDir ( true ) ;
381+ const noGit = makeRepoDir ( false ) ;
382+
383+ expect ( repositoryExistsOnDisk ( withGit ) ) . toBe ( true ) ;
384+ expect ( repositoryExistsOnDisk ( noGit ) ) . toBe ( false ) ; // de-gitified
385+ expect ( repositoryExistsOnDisk ( '/definitely/not/here/cm-1349' ) ) . toBe ( false ) ;
386+ expect ( repositoryExistsOnDisk ( '' ) ) . toBe ( false ) ;
387+ } ) ;
388+ } ) ;
389+
390+ it ( 'prunes worktree rows (CASCADE) when the repository directory is deleted' , ( ) => {
391+ const repo = makeRepoDir ( ) ;
392+ syncWorktreesToDB ( db , [ repoWorktree ( repo , 'r-main' , 'main' ) ] ) ;
393+ seedChildData ( db , 'r-main' ) ;
394+ expect ( getWorktreeById ( db , 'r-main' ) ) . not . toBeNull ( ) ;
395+ expect ( totalChildRows ( db ) ) . toBe ( CHILD_TABLES . length ) ;
396+
397+ // Directory deleted → next global sync scans it to [] (absent from live set).
398+ fs . rmSync ( repo , { recursive : true , force : true } ) ;
399+ const deleted = pruneStaleRepositoryWorktrees ( db , [ ] ) ;
400+
401+ expect ( deleted ) . toContain ( 'r-main' ) ;
402+ expect ( getWorktreeById ( db , 'r-main' ) ) . toBeNull ( ) ;
403+ // Child data is CASCADE-deleted with the row.
404+ expect ( countChildData ( db , 'r-main' ) . chat_messages ) . toBe ( 0 ) ;
405+ expect ( totalChildRows ( db ) ) . toBe ( 0 ) ;
406+ } ) ;
407+
408+ it ( 'prunes when the directory exists but .git was removed (de-gitified)' , ( ) => {
409+ const repo = makeRepoDir ( ) ;
410+ syncWorktreesToDB ( db , [ repoWorktree ( repo , 'r-main' , 'main' ) ] ) ;
411+
412+ fs . rmSync ( path . join ( repo , '.git' ) , { recursive : true , force : true } ) ;
413+ const deleted = pruneStaleRepositoryWorktrees ( db , [ ] ) ;
414+
415+ expect ( deleted ) . toContain ( 'r-main' ) ;
416+ expect ( getWorktreeById ( db , 'r-main' ) ) . toBeNull ( ) ;
417+ } ) ;
418+
419+ it ( 'does NOT prune when the directory + .git still exist but the scan is empty' , ( ) => {
420+ // Conservative guard: a present repository that merely errored in git (or a
421+ // transiently-invisible network drive whose mount point is still present)
422+ // must never be pruned.
423+ const repo = makeRepoDir ( ) ;
424+ syncWorktreesToDB ( db , [ repoWorktree ( repo , 'r-main' , 'main' ) ] ) ;
425+ seedChildData ( db , 'r-main' ) ;
426+
427+ const deleted = pruneStaleRepositoryWorktrees ( db , [ ] ) ;
428+
429+ expect ( deleted ) . toEqual ( [ ] ) ;
430+ expect ( getWorktreeById ( db , 'r-main' ) ) . not . toBeNull ( ) ;
431+ expect ( totalChildRows ( db ) ) . toBe ( CHILD_TABLES . length ) ;
432+ } ) ;
433+
434+ it ( 'prunes only the vanished repository, leaving live repositories untouched' , ( ) => {
435+ const gone = makeRepoDir ( ) ;
436+ const alive = makeRepoDir ( ) ;
437+ syncWorktreesToDB ( db , [
438+ repoWorktree ( gone , 'gone-main' , 'main' ) ,
439+ repoWorktree ( alive , 'alive-main' , 'main' ) ,
440+ ] ) ;
441+ seedChildData ( db , 'gone-main' ) ;
442+ seedChildData ( db , 'alive-main' ) ;
443+
444+ fs . rmSync ( gone , { recursive : true , force : true } ) ;
445+ // Current scan reports only the alive repository.
446+ const deleted = pruneStaleRepositoryWorktrees ( db , [
447+ repoWorktree ( alive , 'alive-main' , 'main' ) ,
448+ ] ) ;
449+
450+ expect ( deleted ) . toEqual ( [ 'gone-main' ] ) ;
451+ expect ( getWorktreeById ( db , 'gone-main' ) ) . toBeNull ( ) ;
452+ expect ( getWorktreeById ( db , 'alive-main' ) ) . not . toBeNull ( ) ;
453+ expect ( countChildData ( db , 'alive-main' ) . chat_messages ) . toBe ( 1 ) ;
454+ } ) ;
455+
456+ it ( 'treats a repository present in the scan as alive even if its dir check would fail' , ( ) => {
457+ // A repo that produced worktrees was listed by git and is alive by
458+ // definition; the scan result wins over a racing filesystem read.
459+ const repo = makeRepoDir ( ) ;
460+ syncWorktreesToDB ( db , [ repoWorktree ( repo , 'r-main' , 'main' ) ] ) ;
461+
462+ fs . rmSync ( repo , { recursive : true , force : true } ) ;
463+ const deleted = pruneStaleRepositoryWorktrees ( db , [
464+ repoWorktree ( repo , 'r-main' , 'main' ) ,
465+ ] ) ;
466+
467+ expect ( deleted ) . toEqual ( [ ] ) ;
468+ expect ( getWorktreeById ( db , 'r-main' ) ) . not . toBeNull ( ) ;
469+ } ) ;
470+
471+ it ( 'returns an empty array when there is nothing to prune' , ( ) => {
472+ expect ( pruneStaleRepositoryWorktrees ( db , [ ] ) ) . toEqual ( [ ] ) ;
473+ } ) ;
474+ } ) ;
0 commit comments