11import { useEffect , useRef } from "react" ;
2- import { useQueries } from "@tanstack/react-query" ;
2+ import { useQueries , useQueryClient } from "@tanstack/react-query" ;
33import type { ThreadId } from "@okcode/contracts" ;
44
55import type { AppSettings } from "../appSettings" ;
6- import { gitStatusQueryOptions } from "../lib/gitReactQuery" ;
6+ import { gitQueryKeys , gitStatusQueryOptions } from "../lib/gitReactQuery" ;
77import { readNativeApi } from "../nativeApi" ;
88import { newCommandId } from "../lib/utils" ;
99import { useStore } from "../store" ;
10+ import { formatWorktreePathForDisplay , getOrphanedWorktreePathForThread } from "../worktreeCleanup" ;
1011import { toastManager } from "../components/ui/toast" ;
1112
1213/**
@@ -33,6 +34,7 @@ interface MergedThreadTimer {
3334export function useAutoDeleteMergedThreads ( settings : AppSettings ) {
3435 const threads = useStore ( ( store ) => store . threads ) ;
3536 const projects = useStore ( ( store ) => store . projects ) ;
37+ const queryClient = useQueryClient ( ) ;
3638
3739 // Track active timers per thread so we can cancel on setting change or
3840 // unmount, and avoid double-scheduling.
@@ -72,11 +74,14 @@ export function useAutoDeleteMergedThreads(settings: AppSettings) {
7274 for ( let i = 0 ; i < threads . length ; i ++ ) {
7375 const thread = threads [ i ] ! ;
7476 const prState = statusQueries [ i ] ?. data ?. pr ?. state ;
77+ const orphanedWorktreePath = getOrphanedWorktreePathForThread ( threads , thread . id ) ;
7578
76- if ( prState === "merged" && ! timersRef . current . has ( thread . id ) ) {
79+ if ( prState === "merged" && orphanedWorktreePath && ! timersRef . current . has ( thread . id ) ) {
7780 // PR just detected as merged – start countdown.
7881 const threadTitle = thread . title || `Thread ${ thread . id . slice ( 0 , 8 ) } ` ;
7982 const minutesLabel = delayMinutes === 1 ? "1 minute" : `${ delayMinutes } minutes` ;
83+ const threadProject = projects . find ( ( project ) => project . id === thread . projectId ) ?? null ;
84+ const displayWorktreePath = formatWorktreePathForDisplay ( orphanedWorktreePath ) ;
8085
8186 const toastId = toastManager . add ( {
8287 type : "info" ,
@@ -101,20 +106,33 @@ export function useAutoDeleteMergedThreads(settings: AppSettings) {
101106 } ) ;
102107
103108 const timeoutId = setTimeout ( ( ) => {
104- void deleteThreadById ( thread . id ) ;
105- timersRef . current . delete ( thread . id ) ;
106- toastManager . add ( {
107- type : "success" ,
108- title : "Merged thread deleted" ,
109- description : `"${ threadTitle } " was auto-deleted after its PR was merged.` ,
110- } ) ;
109+ void ( async ( ) => {
110+ try {
111+ await deleteThreadById ( thread . id ) ;
112+ if ( threadProject && orphanedWorktreePath ) {
113+ await removeOrphanedWorktree ( {
114+ threadId : thread . id ,
115+ projectCwd : threadProject . cwd ,
116+ worktreePath : orphanedWorktreePath ,
117+ } ) ;
118+ }
119+ toastManager . add ( {
120+ type : "success" ,
121+ title : "Merged thread deleted" ,
122+ description : `"${ threadTitle } " was auto-deleted after its PR was merged.` ,
123+ } ) ;
124+ } finally {
125+ timersRef . current . delete ( thread . id ) ;
126+ void queryClient . invalidateQueries ( { queryKey : gitQueryKeys . all } ) ;
127+ }
128+ } ) ( ) ;
111129 } , delayMs ) ;
112130
113131 timersRef . current . set ( thread . id , { timeoutId, toastId } ) ;
114132 }
115133
116134 // If a timer exists but the thread is gone (deleted externally), clean up.
117- if ( prState !== "merged" && timersRef . current . has ( thread . id ) ) {
135+ if ( ( prState !== "merged" || ! orphanedWorktreePath ) && timersRef . current . has ( thread . id ) ) {
118136 const timer = timersRef . current . get ( thread . id ) ! ;
119137 clearTimeout ( timer . timeoutId ) ;
120138 if ( timer . toastId !== null ) {
@@ -184,3 +202,33 @@ async function deleteThreadById(threadId: ThreadId): Promise<void> {
184202 threadId,
185203 } ) ;
186204}
205+
206+ async function removeOrphanedWorktree ( input : {
207+ threadId : ThreadId ;
208+ projectCwd : string ;
209+ worktreePath : string ;
210+ } ) : Promise < void > {
211+ const api = readNativeApi ( ) ;
212+ if ( ! api ) return ;
213+
214+ try {
215+ await api . git . removeWorktree ( {
216+ cwd : input . projectCwd ,
217+ path : input . worktreePath ,
218+ force : true ,
219+ } ) ;
220+ } catch ( error ) {
221+ const message = error instanceof Error ? error . message : "Unknown error removing worktree." ;
222+ console . error ( "Failed to remove orphaned worktree after merged-thread auto-delete" , {
223+ threadId : input . threadId ,
224+ projectCwd : input . projectCwd ,
225+ worktreePath : input . worktreePath ,
226+ error,
227+ } ) ;
228+ toastManager . add ( {
229+ type : "error" ,
230+ title : "Thread deleted, but worktree removal failed" ,
231+ description : `Could not remove ${ formatWorktreePathForDisplay ( input . worktreePath ) } . ${ message } ` ,
232+ } ) ;
233+ }
234+ }
0 commit comments