22import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest' ;
33import { GitStateManager } from './GitStateManager' ;
44import type { GitStateLayer } from './types' ;
5+ import { globalEventBus } from '@/infrastructure/event-bus' ;
6+
7+ const workspaceCatalog = vi . hoisted ( ( ) => new Map < string , { workspaceKind : string ; connectionId ?: string } > ( ) ) ;
8+ const peerMode = vi . hoisted ( ( ) => ( { active : false } ) ) ;
9+ vi . mock ( '@/infrastructure/services/business/workspaceManager' , ( ) => ( {
10+ workspaceManager : { getState : ( ) => ( { openedWorkspaces : workspaceCatalog } ) } ,
11+ } ) ) ;
12+ vi . mock ( '@/infrastructure/peer-device/peerModeFlag' , ( ) => ( {
13+ isPeerDeviceModeActive : ( ) => peerMode . active ,
14+ } ) ) ;
515
616const gitApiMocks = vi . hoisted ( ( ) => ( {
717 isGitRepository : vi . fn ( ) ,
@@ -59,6 +69,10 @@ describe('GitStateManager refresh performance guards', () => {
5969
6070 beforeEach ( ( ) => {
6171 vi . useFakeTimers ( ) ;
72+ workspaceCatalog . clear ( ) ;
73+ workspaceCatalog . set ( 'workspace-1' , { workspaceKind : 'normal' } ) ;
74+ workspaceCatalog . set ( 'workspace-2' , { workspaceKind : 'normal' } ) ;
75+ peerMode . active = false ;
6276 GitStateManager . resetInstance ( ) ;
6377 manager = GitStateManager . getInstance ( ) ;
6478 manager . setCacheConfig ( { basic : 0 , status : 0 , detailed : 0 } ) ;
@@ -103,10 +117,10 @@ describe('GitStateManager refresh performance guards', () => {
103117 const addListener = vi . spyOn ( document , 'addEventListener' ) ;
104118 const removeListener = vi . spyOn ( document , 'removeEventListener' ) ;
105119 try {
106- const unsubscribe = manager . subscribe ( repositoryPath , ( ) => { } ) ;
120+ const unsubscribe = manager . registerPolling ( repositoryPath ) ;
107121 hidden . mockReturnValue ( true ) ;
108122 document . dispatchEvent ( new Event ( 'visibilitychange' ) ) ;
109- const unsubscribeOther = manager . subscribe ( { workspaceId : 'workspace-2' } , ( ) => { } ) ;
123+ const unsubscribeOther = manager . registerPolling ( { workspaceId : 'workspace-2' } ) ;
110124 await vi . advanceTimersByTimeAsync ( 4100 ) ;
111125 expect ( gitApiMocks . isGitRepository ) . not . toHaveBeenCalled ( ) ;
112126 const listeners = addListener . mock . calls . filter ( ( [ type ] ) => type === 'visibilitychange' ) ;
@@ -131,7 +145,7 @@ describe('GitStateManager refresh performance guards', () => {
131145 it ( 'does not start polling for an initially hidden document' , async ( ) => {
132146 const hidden = vi . spyOn ( document , 'hidden' , 'get' ) . mockReturnValue ( true ) ;
133147 try {
134- manager . subscribe ( repositoryPath , ( ) => { } ) ;
148+ manager . registerPolling ( repositoryPath ) ;
135149 await vi . advanceTimersByTimeAsync ( 4100 ) ;
136150 expect ( gitApiMocks . isGitRepository ) . not . toHaveBeenCalled ( ) ;
137151 hidden . mockReturnValue ( false ) ;
@@ -164,6 +178,171 @@ describe('GitStateManager refresh performance guards', () => {
164178 } ) ;
165179 } ) ;
166180
181+ it ( 'keeps passive subscriptions free of IO and reference-counts active demand' , async ( ) => {
182+ manager . subscribe ( repositoryPath , ( ) => { } ) ;
183+ await vi . advanceTimersByTimeAsync ( 60_000 ) ;
184+ expect ( gitApiMocks . isGitRepository ) . not . toHaveBeenCalled ( ) ;
185+ const releaseA = manager . registerPolling ( repositoryPath ) ;
186+ const releaseB = manager . registerPolling ( repositoryPath ) ;
187+ releaseA ( ) ;
188+ await vi . advanceTimersByTimeAsync ( 2100 ) ;
189+ expect ( gitApiMocks . getRepositoryBasic ) . toHaveBeenCalledTimes ( 1 ) ;
190+ releaseB ( ) ;
191+ await vi . advanceTimersByTimeAsync ( 60_000 ) ;
192+ expect ( gitApiMocks . getRepositoryBasic ) . toHaveBeenCalledTimes ( 1 ) ;
193+ } ) ;
194+
195+ it ( 'polls active SSH workspaces slowly and never broadcasts unchanged results' , async ( ) => {
196+ workspaceCatalog . set ( 'workspace-1' , { workspaceKind : 'remote' , connectionId : 'ssh-a' } ) ;
197+ workspaceCatalog . set ( 'workspace-2' , { workspaceKind : 'remote' , connectionId : 'ssh-b' } ) ;
198+ for ( const workspaceId of [ 'workspace-1' , 'workspace-2' , 'workspace-3' ] ) {
199+ manager . subscribe ( { workspaceId } , ( ) => { } ) ;
200+ }
201+ manager . registerPolling ( repositoryPath ) ;
202+ await vi . advanceTimersByTimeAsync ( 29_999 ) ;
203+ expect ( gitApiMocks . isGitRepository ) . not . toHaveBeenCalled ( ) ;
204+ await vi . advanceTimersByTimeAsync ( 101 ) ;
205+ expect ( gitApiMocks . getRepositoryBasic ) . toHaveBeenCalledTimes ( 1 ) ;
206+ vi . mocked ( globalEventBus . emit ) . mockClear ( ) ;
207+ gitEventServiceMock . emit . mockClear ( ) ;
208+ await vi . advanceTimersByTimeAsync ( 36_000 ) ;
209+ expect ( gitApiMocks . getRepositoryBasic ) . toHaveBeenCalledTimes ( 2 ) ;
210+ expect ( globalEventBus . emit ) . not . toHaveBeenCalled ( ) ;
211+ expect ( gitEventServiceMock . emit ) . not . toHaveBeenCalled ( ) ;
212+ expect ( gitApiMocks . getStatus ) . not . toHaveBeenCalled ( ) ;
213+ } ) ;
214+
215+ it ( 'serializes background reads on one SSH connection without starving another repository' , async ( ) => {
216+ for ( const workspaceId of [ 'workspace-1' , 'workspace-2' ] ) {
217+ workspaceCatalog . set ( workspaceId , { workspaceKind : 'remote' , connectionId : 'ssh-a' } ) ;
218+ manager . registerPolling ( { workspaceId } ) ;
219+ }
220+ const read = deferred < any > ( ) ;
221+ gitApiMocks . getRepositoryBasic . mockReturnValueOnce ( read . promise ) ;
222+ await vi . advanceTimersByTimeAsync ( 40_000 ) ;
223+ expect ( gitApiMocks . isGitRepository ) . toHaveBeenCalledTimes ( 1 ) ;
224+ read . resolve ( { current_branch : 'main' } ) ;
225+ await vi . advanceTimersByTimeAsync ( 2100 ) ;
226+ expect ( gitApiMocks . isGitRepository ) . toHaveBeenCalledTimes ( 2 ) ;
227+ expect ( gitApiMocks . isGitRepository . mock . calls . map ( ( [ scope ] ) => scope . workspaceId ) )
228+ . toEqual ( [ 'workspace-1' , 'workspace-2' ] ) ;
229+ } ) ;
230+
231+ it ( 'backs off failures, allows immediate manual recovery and clears the visible error' , async ( ) => {
232+ workspaceCatalog . set ( 'workspace-1' , { workspaceKind : 'remote' , connectionId : 'ssh-a' } ) ;
233+ const seen = vi . fn ( ) ;
234+ manager . subscribe ( repositoryPath , seen ) ;
235+ manager . registerPolling ( repositoryPath ) ;
236+ gitApiMocks . isGitRepository . mockRejectedValueOnce ( new Error ( 'SSH disconnected' ) ) ;
237+ await vi . advanceTimersByTimeAsync ( 30_100 ) ;
238+ expect ( seen . mock . lastCall ?. [ 0 ] . error ) . toBe ( 'SSH disconnected' ) ;
239+ await vi . advanceTimersByTimeAsync ( 59_000 ) ;
240+ expect ( gitApiMocks . isGitRepository ) . toHaveBeenCalledTimes ( 1 ) ;
241+ const retry = manager . refresh ( repositoryPath , { layers : [ 'basic' ] , force : true } ) ;
242+ await vi . advanceTimersByTimeAsync ( 100 ) ;
243+ await retry ;
244+ expect ( gitApiMocks . isGitRepository ) . toHaveBeenCalledTimes ( 2 ) ;
245+ expect ( seen . mock . lastCall ?. [ 0 ] . error ) . toBeNull ( ) ;
246+ await vi . advanceTimersByTimeAsync ( 33_000 ) ;
247+ expect ( gitApiMocks . isGitRepository ) . toHaveBeenCalledTimes ( 3 ) ;
248+ } ) ;
249+
250+ it ( 'preserves peer-device polling suppression and uses conservative cadence without metadata' , async ( ) => {
251+ workspaceCatalog . clear ( ) ;
252+ manager . registerPolling ( repositoryPath ) ;
253+ await vi . advanceTimersByTimeAsync ( 6000 ) ;
254+ expect ( gitApiMocks . isGitRepository ) . not . toHaveBeenCalled ( ) ;
255+ peerMode . active = true ;
256+ await vi . advanceTimersByTimeAsync ( 60_000 ) ;
257+ expect ( gitApiMocks . isGitRepository ) . not . toHaveBeenCalled ( ) ;
258+ peerMode . active = false ;
259+ } ) ;
260+
261+ it ( 'increases offline retry delays and caps them at five minutes' , async ( ) => {
262+ workspaceCatalog . set ( 'workspace-1' , { workspaceKind : 'remote' , connectionId : 'ssh-a' } ) ;
263+ gitApiMocks . isGitRepository . mockRejectedValue ( new Error ( 'SSH disconnected' ) ) ;
264+ manager . registerPolling ( repositoryPath ) ;
265+ await vi . advanceTimersByTimeAsync ( 30_100 ) ;
266+ expect ( gitApiMocks . isGitRepository ) . toHaveBeenCalledTimes ( 1 ) ;
267+ await vi . advanceTimersByTimeAsync ( 60_000 ) ;
268+ expect ( gitApiMocks . isGitRepository ) . toHaveBeenCalledTimes ( 1 ) ;
269+ await vi . advanceTimersByTimeAsync ( 2000 ) ;
270+ expect ( gitApiMocks . isGitRepository ) . toHaveBeenCalledTimes ( 2 ) ;
271+ await vi . advanceTimersByTimeAsync ( 120_000 ) ;
272+ expect ( gitApiMocks . isGitRepository ) . toHaveBeenCalledTimes ( 2 ) ;
273+ await vi . advanceTimersByTimeAsync ( 2000 ) ;
274+ expect ( gitApiMocks . isGitRepository ) . toHaveBeenCalledTimes ( 3 ) ;
275+ await vi . advanceTimersByTimeAsync ( 242_000 ) ;
276+ expect ( gitApiMocks . isGitRepository ) . toHaveBeenCalledTimes ( 4 ) ;
277+ await vi . advanceTimersByTimeAsync ( 302_000 ) ;
278+ expect ( gitApiMocks . isGitRepository ) . toHaveBeenCalledTimes ( 5 ) ;
279+ await vi . advanceTimersByTimeAsync ( 302_000 ) ;
280+ expect ( gitApiMocks . isGitRepository ) . toHaveBeenCalledTimes ( 6 ) ;
281+ } ) ;
282+
283+ it ( 'cancels queued polling on deactivation but preserves a merged manual refresh' , async ( ) => {
284+ const release = manager . registerPolling ( repositoryPath ) ;
285+ await vi . advanceTimersByTimeAsync ( 2000 ) ;
286+ release ( ) ;
287+ await vi . advanceTimersByTimeAsync ( 100 ) ;
288+ expect ( gitApiMocks . isGitRepository ) . not . toHaveBeenCalled ( ) ;
289+ const releaseAgain = manager . registerPolling ( repositoryPath ) ;
290+ await vi . advanceTimersByTimeAsync ( 2000 ) ;
291+ const manual = manager . refresh ( repositoryPath , { layers : [ 'basic' ] , force : true } ) ;
292+ releaseAgain ( ) ;
293+ await vi . advanceTimersByTimeAsync ( 100 ) ;
294+ await manual ;
295+ expect ( gitApiMocks . isGitRepository ) . toHaveBeenCalledTimes ( 1 ) ;
296+ } ) ;
297+
298+ it ( 'drops a queued poll if the document becomes hidden during debounce' , async ( ) => {
299+ const hidden = vi . spyOn ( document , 'hidden' , 'get' ) . mockReturnValue ( false ) ;
300+ try {
301+ manager . registerPolling ( repositoryPath ) ;
302+ await vi . advanceTimersByTimeAsync ( 2000 ) ;
303+ hidden . mockReturnValue ( true ) ;
304+ document . dispatchEvent ( new Event ( 'visibilitychange' ) ) ;
305+ await vi . advanceTimersByTimeAsync ( 100 ) ;
306+ expect ( gitApiMocks . isGitRepository ) . not . toHaveBeenCalled ( ) ;
307+ } finally {
308+ hidden . mockRestore ( ) ;
309+ }
310+ } ) ;
311+
312+ it ( 'notifies error recovery even when the branch and files stay unchanged' , async ( ) => {
313+ const first = manager . refresh ( repositoryPath , { layers : [ 'basic' ] , force : true } ) ;
314+ await vi . advanceTimersByTimeAsync ( 100 ) ;
315+ await first ;
316+ const seen = vi . fn ( ) ;
317+ manager . subscribe ( repositoryPath , seen , { immediate : false , layers : [ 'basic' ] } ) ;
318+ gitApiMocks . isGitRepository . mockRejectedValueOnce ( new Error ( 'Offline' ) ) ;
319+ const failed = manager . refresh ( repositoryPath , { layers : [ 'basic' ] , force : true } ) . catch ( ( ) => { } ) ;
320+ await vi . advanceTimersByTimeAsync ( 100 ) ;
321+ await failed ;
322+ expect ( seen . mock . lastCall ?. [ 0 ] . error ) . toBe ( 'Offline' ) ;
323+ const recovered = manager . refresh ( repositoryPath , { layers : [ 'basic' ] , force : true , silent : true } ) ;
324+ await vi . advanceTimersByTimeAsync ( 100 ) ;
325+ await recovered ;
326+ expect ( seen ) . toHaveBeenCalledTimes ( 2 ) ;
327+ expect ( seen . mock . lastCall ?. [ 0 ] . error ) . toBeNull ( ) ;
328+ } ) ;
329+
330+ it ( 'refreshes stale basic data on activation even with the default infinite TTL' , async ( ) => {
331+ manager . setCacheConfig ( { basic : Infinity } ) ;
332+ const first = manager . refresh ( repositoryPath , { layers : [ 'basic' ] , force : true } ) ;
333+ await vi . advanceTimersByTimeAsync ( 100 ) ;
334+ await first ;
335+ await vi . advanceTimersByTimeAsync ( 30_000 ) ;
336+ manager . registerPolling ( repositoryPath ) ;
337+ gitApiMocks . getRepositoryBasic . mockResolvedValueOnce ( { current_branch : 'external-change' } ) ;
338+ const activate = manager . refresh ( repositoryPath , { layers : [ 'basic' ] , reason : 'visibility' } ) ;
339+ await vi . advanceTimersByTimeAsync ( 100 ) ;
340+ await activate ;
341+ expect ( manager . getState ( repositoryPath ) ?. currentBranch ) . toBe ( 'external-change' ) ;
342+ expect ( globalEventBus . emit ) . toHaveBeenLastCalledWith ( 'git:state:changed' ,
343+ expect . objectContaining ( { changedLayers : [ 'basic' ] } ) ) ;
344+ } ) ;
345+
167346 it ( 'merges duplicate mount refreshes for the same repository and layer' , async ( ) => {
168347 const first = manager . refresh ( repositoryPath , {
169348 layers : [ 'basic' , 'status' ] ,
0 commit comments