11import { EventEmitter } from 'events' ;
2- import { watch , FSWatcher } from 'fs' ;
2+ import { watch , FSWatcher , existsSync , readFileSync , statSync } from 'fs' ;
3+ import { dirname , isAbsolute , join , resolve } from 'path' ;
34import type { Logger } from '../../infrastructure/logging/logger' ;
45
56interface WatchedSession {
67 sessionId : string ;
78 worktreePath : string ;
89 watcher ?: FSWatcher ;
10+ gitWatchers ?: FSWatcher [ ] ;
911 lastModified : number ;
1012 pendingRefresh : boolean ;
1113}
1214
15+ export function resolveGitDir ( worktreePath : string ) : string | null {
16+ const dotGit = join ( worktreePath , '.git' ) ;
17+ if ( ! existsSync ( dotGit ) ) return null ;
18+
19+ try {
20+ const stat = statSync ( dotGit ) ;
21+ if ( stat . isDirectory ( ) ) return dotGit ;
22+ } catch {
23+ // ignore
24+ }
25+
26+ // In a linked worktree, `.git` is a file containing `gitdir: <path>`
27+ try {
28+ const content = readFileSync ( dotGit , 'utf8' ) . trim ( ) ;
29+ const m = content . match ( / ^ g i t d i r : \s * ( .+ ) \s * $ / i) ;
30+ if ( ! m ) return null ;
31+ const raw = m [ 1 ] . trim ( ) ;
32+ if ( ! raw ) return null ;
33+ const base = dirname ( dotGit ) ;
34+ return isAbsolute ( raw ) ? raw : resolve ( base , raw ) ;
35+ } catch {
36+ return null ;
37+ }
38+ }
39+
1340/**
1441 * Smart file watcher that detects when git status actually needs refreshing
1542 *
@@ -21,7 +48,8 @@ interface WatchedSession {
2148export class GitFileWatcher extends EventEmitter {
2249 private watchedSessions : Map < string , WatchedSession > = new Map ( ) ;
2350 private refreshDebounceTimers : Map < string , NodeJS . Timeout > = new Map ( ) ;
24- private readonly DEBOUNCE_MS = 1500 ; // 1.5 second debounce for file changes
51+ private readonly WORKTREE_DEBOUNCE_MS = 200 ; // fast for UI responsiveness
52+ private readonly GIT_DEBOUNCE_MS = 50 ; // index/HEAD updates should feel instant
2553 private readonly IGNORE_PATTERNS = [
2654 '.git/' ,
2755 'node_modules/' ,
@@ -49,17 +77,35 @@ export class GitFileWatcher extends EventEmitter {
4977 this . logger ?. info ( `[GitFileWatcher] Starting watch for session ${ sessionId } at ${ worktreePath } ` ) ;
5078
5179 try {
52- // Create a watcher for the worktree directory
53- const watcher = watch ( worktreePath , { recursive : true } , ( eventType , filename ) => {
54- if ( filename ) {
55- this . handleFileChange ( sessionId , filename , eventType ) ;
56- }
57- } ) ;
80+ let watcher : FSWatcher | undefined ;
81+ try {
82+ // Prefer recursive watch when available (macOS/Windows).
83+ watcher = watch ( worktreePath , { recursive : true } , ( eventType , filename ) => {
84+ if ( filename ) {
85+ this . handleFileChange ( sessionId , filename , eventType ) ;
86+ } else {
87+ // Some platforms do not provide filename; still refresh.
88+ this . handleWorktreeUnknownChange ( sessionId , eventType ) ;
89+ }
90+ } ) ;
91+ } catch {
92+ // Fallback: non-recursive watch for top-level changes.
93+ watcher = watch ( worktreePath , { recursive : false } , ( eventType , filename ) => {
94+ if ( filename ) {
95+ this . handleFileChange ( sessionId , filename , eventType ) ;
96+ } else {
97+ this . handleWorktreeUnknownChange ( sessionId , eventType ) ;
98+ }
99+ } ) ;
100+ }
101+
102+ const gitWatchers = this . startGitMetadataWatch ( sessionId , worktreePath ) ;
58103
59104 this . watchedSessions . set ( sessionId , {
60105 sessionId,
61106 worktreePath,
62107 watcher,
108+ gitWatchers,
63109 lastModified : Date . now ( ) ,
64110 pendingRefresh : false
65111 } ) ;
@@ -75,6 +121,9 @@ export class GitFileWatcher extends EventEmitter {
75121 const session = this . watchedSessions . get ( sessionId ) ;
76122 if ( session ) {
77123 session . watcher ?. close ( ) ;
124+ session . gitWatchers ?. forEach ( ( w ) => {
125+ try { w . close ( ) ; } catch { /* ignore */ }
126+ } ) ;
78127 this . watchedSessions . delete ( sessionId ) ;
79128
80129 // Clear any pending refresh timer
@@ -114,7 +163,23 @@ export class GitFileWatcher extends EventEmitter {
114163 session . pendingRefresh = true ;
115164
116165 // Debounce the refresh to batch rapid changes
117- this . scheduleRefreshCheck ( sessionId ) ;
166+ this . scheduleRefreshCheck ( sessionId , this . WORKTREE_DEBOUNCE_MS ) ;
167+ }
168+
169+ private handleWorktreeUnknownChange ( sessionId : string , eventType : string ) : void {
170+ const session = this . watchedSessions . get ( sessionId ) ;
171+ if ( ! session ) return ;
172+ session . lastModified = Date . now ( ) ;
173+ session . pendingRefresh = true ;
174+ this . scheduleRefreshCheck ( sessionId , this . WORKTREE_DEBOUNCE_MS ) ;
175+ }
176+
177+ private handleGitMetadataChange ( sessionId : string , filename : string , eventType : string ) : void {
178+ const session = this . watchedSessions . get ( sessionId ) ;
179+ if ( ! session ) return ;
180+ session . lastModified = Date . now ( ) ;
181+ session . pendingRefresh = true ;
182+ this . scheduleRefreshCheck ( sessionId , this . GIT_DEBOUNCE_MS ) ;
118183 }
119184
120185 /**
@@ -155,10 +220,51 @@ export class GitFileWatcher extends EventEmitter {
155220 return false ;
156221 }
157222
223+ private startGitMetadataWatch ( sessionId : string , worktreePath : string ) : FSWatcher [ ] {
224+ const gitdir = resolveGitDir ( worktreePath ) ;
225+ if ( ! gitdir ) return [ ] ;
226+
227+ // Zed-style: watch git metadata files that reflect status changes instantly.
228+ const paths = [
229+ join ( gitdir , 'index' ) ,
230+ join ( gitdir , 'HEAD' ) ,
231+ join ( gitdir , 'logs' , 'HEAD' ) ,
232+ join ( gitdir , 'packed-refs' ) ,
233+ ] ;
234+
235+ const watchers : FSWatcher [ ] = [ ] ;
236+
237+ for ( const p of paths ) {
238+ try {
239+ const w = watch ( p , { persistent : true } , ( eventType ) => {
240+ this . handleGitMetadataChange ( sessionId , p , eventType ) ;
241+ } ) ;
242+ watchers . push ( w ) ;
243+ } catch {
244+ // ignore missing files / unsupported watch
245+ }
246+ }
247+
248+ // Also watch the gitdir itself for ref changes (best-effort, non-recursive).
249+ try {
250+ const w = watch ( gitdir , { persistent : true } , ( eventType , filename ) => {
251+ const name = filename ? String ( filename ) : '' ;
252+ if ( name . startsWith ( 'refs' ) || name === 'refs' || name === 'packed-refs' ) {
253+ this . handleGitMetadataChange ( sessionId , join ( gitdir , name ) , eventType ) ;
254+ }
255+ } ) ;
256+ watchers . push ( w ) ;
257+ } catch {
258+ // ignore
259+ }
260+
261+ return watchers ;
262+ }
263+
158264 /**
159265 * Schedule a refresh check for a session
160266 */
161- private scheduleRefreshCheck ( sessionId : string ) : void {
267+ private scheduleRefreshCheck ( sessionId : string , debounceMs : number ) : void {
162268 // Clear existing timer
163269 const existingTimer = this . refreshDebounceTimers . get ( sessionId ) ;
164270 if ( existingTimer ) {
@@ -169,7 +275,7 @@ export class GitFileWatcher extends EventEmitter {
169275 const timer = setTimeout ( ( ) => {
170276 this . refreshDebounceTimers . delete ( sessionId ) ;
171277 this . performRefreshCheck ( sessionId ) ;
172- } , this . DEBOUNCE_MS ) ;
278+ } , debounceMs ) ;
173279
174280 this . refreshDebounceTimers . set ( sessionId , timer ) ;
175281 }
0 commit comments