@@ -8,7 +8,7 @@ export class AIEditManager {
88 private workspaceBaseStoragePath : string | null = null ;
99 private gitAiVersion : string | null = null ;
1010 private hasShownGitAiErrorMessage = false ;
11- private lastHumanCheckpointAt : Date | null = null ;
11+ private lastHumanCheckpointAt = new Map < string , number > ( ) ;
1212 private pendingSaves = new Map < string , {
1313 timestamp : number ;
1414 timer : NodeJS . Timeout ;
@@ -20,6 +20,9 @@ export class AIEditManager {
2020 } > ( ) ;
2121 private readonly SAVE_EVENT_DEBOUNCE_WINDOW_MS = 300 ;
2222 private readonly HUMAN_CHECKPOINT_DEBOUNCE_MS = 500 ;
23+ private readonly HUMAN_CHECKPOINT_CLEANUP_INTERVAL_MS = 60000 ; // 1 minute
24+ private readonly MAX_SNAPSHOT_AGE_MS = 10_000 ; // 10 seconds; used to avoid triggering AI checkpoints on stale snapshots
25+ private cleanupTimer : NodeJS . Timeout ;
2326
2427 constructor ( context : vscode . ExtensionContext ) {
2528 if ( context . storageUri ?. fsPath ) {
@@ -28,6 +31,39 @@ export class AIEditManager {
2831 // No workspace active (extension will be re-activated when a workspace is opened)
2932 console . warn ( '[git-ai] No workspace storage URI available' ) ;
3033 }
34+
35+ // Periodically clean up old entries from lastHumanCheckpointAt to avoid memory leaks
36+ this . cleanupTimer = setInterval ( ( ) => {
37+ this . cleanupOldCheckpointEntries ( ) ;
38+ } , this . HUMAN_CHECKPOINT_CLEANUP_INTERVAL_MS ) ;
39+ }
40+
41+ public dispose ( ) : void {
42+ if ( this . cleanupTimer ) {
43+ clearInterval ( this . cleanupTimer ) ;
44+ }
45+ }
46+
47+ private cleanupOldCheckpointEntries ( ) : void {
48+ const now = Date . now ( ) ;
49+ const entriesToDelete : string [ ] = [ ] ;
50+
51+ // Remove entries older than 5 minutes
52+ const MAX_AGE_MS = 5 * 60 * 1000 ;
53+
54+ this . lastHumanCheckpointAt . forEach ( ( timestamp , filePath ) => {
55+ if ( now - timestamp > MAX_AGE_MS ) {
56+ entriesToDelete . push ( filePath ) ;
57+ }
58+ } ) ;
59+
60+ entriesToDelete . forEach ( filePath => {
61+ this . lastHumanCheckpointAt . delete ( filePath ) ;
62+ } ) ;
63+
64+ if ( entriesToDelete . length > 0 ) {
65+ console . log ( '[git-ai] AIEditManager: Cleaned up' , entriesToDelete . length , 'old checkpoint entries' ) ;
66+ }
3167 }
3268
3369 public handleSaveEvent ( doc : vscode . TextDocument ) : void {
@@ -53,7 +89,8 @@ export class AIEditManager {
5389 }
5490
5591 public handleOpenEvent ( doc : vscode . TextDocument ) : void {
56- if ( doc . uri . scheme === "chat-editing-snapshot-text-model" ) {
92+ console . log ( '[git-ai] AIEditManager: Open event detected for' , doc ) ;
93+ if ( doc . uri . scheme === "chat-editing-snapshot-text-model" || doc . uri . scheme === "chat-editing-text-model" ) {
5794 const filePath = doc . uri . fsPath ;
5895 const now = Date . now ( ) ;
5996
@@ -69,14 +106,18 @@ export class AIEditManager {
69106 } ) ;
70107 }
71108
72- console . log ( '[git-ai] AIEditManager: Snapshot open event tracked for' , filePath , 'count:' , this . snapshotOpenEvents . get ( filePath ) ?. count ) ;
109+ // Trigger human checkpoint when whenever we see a snapshot open (before any changes are made -- debounce logic is handled in the triggerHumanCheckpoint method)
110+ console . log ( '[git-ai] AIEditManager: Snapshot open event detected for' , filePath , 'scheme:' , doc . uri . scheme , 'seen count:' , this . snapshotOpenEvents . get ( filePath ) ?. count , '- triggering human checkpoint' ) ;
111+ this . triggerHumanCheckpoint ( [ filePath ] ) ;
73112 }
74113 }
75114
76115 public handleCloseEvent ( doc : vscode . TextDocument ) : void {
77- if ( doc . uri . scheme === "chat-editing-snapshot-text-model" ) {
78- console . log ( '[git-ai] AIEditManager: Snapshot close event detected, triggering human checkpoint' ) ;
79- this . checkpoint ( "human" ) ;
116+ if ( doc . uri . scheme === "chat-editing-snapshot-text-model" || doc . uri . scheme === "chat-editing-text-model" ) {
117+ console . log ( '[git-ai] AIEditManager: Snapshot close event detected for' , doc ) ;
118+ // console.log('[git-ai] AIEditManager: Snapshot close event detected, triggering human checkpoint');
119+ // const filePath = doc.uri.fsPath;
120+ // this.triggerHumanCheckpoint([filePath]);
80121 }
81122 }
82123
@@ -101,24 +142,29 @@ export class AIEditManager {
101142 let checkpointTriggered = false ;
102143
103144 if ( snapshotInfo && snapshotInfo . count >= 1 && snapshotInfo . uri ?. query ) {
104- const storagePath = this . workspaceBaseStoragePath ;
105- if ( ! storagePath ) {
106- console . warn ( '[git-ai] AIEditManager: Missing workspace storage path, skipping AI checkpoint for' , filePath ) ;
145+ // Check if the snapshot is fresh to avoid triggering AI checkpoints on stale snapshots
146+ const snapshotAge = Date . now ( ) - snapshotInfo . timestamp ;
147+
148+ if ( snapshotAge >= this . MAX_SNAPSHOT_AGE_MS ) {
149+ console . log ( '[git-ai] AIEditManager: Snapshot is too old (' + Math . round ( snapshotAge / 1000 ) + 's), skipping AI checkpoint for' , filePath ) ;
107150 } else {
108- try {
109- const params = JSON . parse ( snapshotInfo . uri . query ) ;
110- const sessionId = params . sessionId ;
111- const requestId = params . requestId ;
112-
113- if ( ! sessionId || ! requestId ) {
114- console . warn ( '[git-ai] AIEditManager: Snapshot URI missing session or request id, skipping AI checkpoint for' , filePath ) ;
115- } else {
116- const workspaceFolder = vscode . workspace . getWorkspaceFolder ( vscode . Uri . file ( filePath ) ) ;
117- if ( ! workspaceFolder ) {
118- console . warn ( '[git-ai] AIEditManager: No workspace folder found for' , filePath , '- skipping AI checkpoint' ) ;
151+ const storagePath = this . workspaceBaseStoragePath ;
152+ if ( ! storagePath ) {
153+ console . warn ( '[git-ai] AIEditManager: Missing workspace storage path, skipping AI checkpoint for' , filePath ) ;
154+ } else {
155+ try {
156+ const params = JSON . parse ( snapshotInfo . uri . query ) ;
157+ const sessionId = params . chatSessionId || params . sessionId ;
158+
159+ if ( ! sessionId ) {
160+ console . warn ( '[git-ai] AIEditManager: Snapshot URI missing session id, skipping AI checkpoint for' , filePath ) ;
119161 } else {
162+ const workspaceFolder = vscode . workspace . getWorkspaceFolder ( vscode . Uri . file ( filePath ) ) ;
163+ if ( ! workspaceFolder ) {
164+ console . warn ( '[git-ai] AIEditManager: No workspace folder found for' , filePath , '- skipping AI checkpoint' ) ;
165+ } else {
120166 const chatSessionPath = path . join ( storagePath , 'chatSessions' , `${ sessionId } .json` ) ;
121- console . log ( '[git-ai] AIEditManager: AI edit detected for' , filePath , '- triggering AI checkpoint (sessionId:' , sessionId , ', requestId:' , requestId , ', chatSessionPath:', chatSessionPath , ', workspaceFolder:' , workspaceFolder . uri . fsPath , ')' ) ;
167+ console . log ( '[git-ai] AIEditManager: AI edit detected for' , filePath , '- triggering AI checkpoint (sessionId:' , sessionId , ', chatSessionPath:' , chatSessionPath , ', workspaceFolder:' , workspaceFolder . uri . fsPath , ')' ) ;
122168
123169 // Get dirty files and ensure the saved file is included with its content from VS Code
124170 const dirtyFiles = this . getDirtyFiles ( ) ;
@@ -134,52 +180,104 @@ export class AIEditManager {
134180
135181 console . log ( '[git-ai] AIEditManager: Dirty files with saved file content:' , dirtyFiles ) ;
136182 this . checkpoint ( "ai" , JSON . stringify ( {
183+ hook_event_name : "after_edit" ,
137184 chatSessionPath,
138185 sessionId,
139- requestId,
140186 workspaceFolder : workspaceFolder . uri . fsPath ,
141187 dirtyFiles,
142188 } ) ) ;
143189 checkpointTriggered = true ;
190+ }
144191 }
192+ } catch ( e ) {
193+ console . error ( '[git-ai] AIEditManager: Unable to trigger AI checkpoint for' , filePath , e ) ;
145194 }
146- } catch ( e ) {
147- console . error ( '[git-ai] AIEditManager: Unable to trigger AI checkpoint for' , filePath , e ) ;
148195 }
149196 }
150197 }
151198
152199 if ( ! checkpointTriggered ) {
153- console . log ( '[git-ai] AIEditManager: No AI pattern detected for' , filePath , '- triggering human checkpoint' ) ;
154- this . checkpoint ( "human" ) ;
200+ console . log ( '[git-ai] AIEditManager: No AI pattern detected for' , filePath , '- skipping checkpoint' ) ;
155201 }
156202
157203 // Cleanup
158204 this . pendingSaves . delete ( filePath ) ;
159205 this . snapshotOpenEvents . delete ( filePath ) ;
160206 }
161207
162- public triggerInitialHumanCheckpoint ( ) : void {
163- console . log ( '[git-ai] AIEditManager: Triggering initial human checkpoint' ) ;
164- this . checkpoint ( "human" ) ;
165- }
166-
167- async checkpoint ( author : "human" | "ai" | "ai_tab" , hookInput ?: string ) : Promise < boolean > {
168- if ( ! ( await this . checkGitAi ( ) ) ) {
169- return false ;
208+ /**
209+ * Trigger a human checkpoint with debouncing per file.
210+ * Debounce logic: trigger immediately, but skip files that were already checkpointed within the debounce window.
211+ */
212+ private triggerHumanCheckpoint ( willEditFilepaths : string [ ] ) : void {
213+ if ( ! willEditFilepaths || willEditFilepaths . length === 0 ) {
214+ console . warn ( '[git-ai] AIEditManager: Cannot trigger human checkpoint without files' ) ;
215+ return ;
170216 }
171217
172- // Throttle human checkpoints
173- if ( author === "human" ) {
174- const now = new Date ( ) ;
175- if ( this . lastHumanCheckpointAt && ( now . getTime ( ) - this . lastHumanCheckpointAt . getTime ( ) ) < this . HUMAN_CHECKPOINT_DEBOUNCE_MS ) {
176- console . log ( '[git-ai] AIEditManager: Skipping human checkpoint due to debounce' ) ;
218+ // Filter out files that were recently checkpointed (within debounce window)
219+ const now = Date . now ( ) ;
220+ const filesToCheckpoint = willEditFilepaths . filter ( filePath => {
221+ const lastCheckpoint = this . lastHumanCheckpointAt . get ( filePath ) ;
222+ if ( lastCheckpoint && ( now - lastCheckpoint ) < this . HUMAN_CHECKPOINT_DEBOUNCE_MS ) {
223+ console . log ( '[git-ai] AIEditManager: Skipping file due to debounce:' , filePath ) ;
177224 return false ;
178225 }
179- this . lastHumanCheckpointAt = now ;
226+ return true ;
227+ } ) ;
228+
229+ if ( filesToCheckpoint . length === 0 ) {
230+ console . log ( '[git-ai] AIEditManager: All files were recently checkpointed, skipping' ) ;
231+ return ;
232+ }
233+
234+ // Update last checkpoint time for files we're about to checkpoint
235+ filesToCheckpoint . forEach ( filePath => {
236+ this . lastHumanCheckpointAt . set ( filePath , now ) ;
237+ } ) ;
238+
239+ // Get dirty files
240+ const dirtyFiles = this . getDirtyFiles ( ) ;
241+
242+ // Add the files we're checkpointing to dirtyFiles (even if they're not dirty)
243+ // Read from VS Code to handle codespaces lag
244+ filesToCheckpoint . forEach ( filePath => {
245+ const fileDoc = vscode . workspace . textDocuments . find ( doc =>
246+ doc . uri . fsPath === filePath && doc . uri . scheme === "file"
247+ ) ;
248+ if ( fileDoc ) {
249+ dirtyFiles [ filePath ] = fileDoc . getText ( ) ;
250+ }
251+ } ) ;
252+
253+ // Find workspace folder
254+ const workspaceFolder = vscode . workspace . getWorkspaceFolder ( vscode . Uri . file ( filesToCheckpoint [ 0 ] ) )
255+ || vscode . workspace . workspaceFolders ?. [ 0 ] ;
256+
257+ if ( ! workspaceFolder ) {
258+ console . warn ( '[git-ai] AIEditManager: No workspace folder found for human checkpoint' ) ;
259+ return ;
260+ }
261+
262+ console . log ( '[git-ai] AIEditManager: Triggering human checkpoint for files:' , filesToCheckpoint ) ;
263+
264+ // Prepare hook input for human checkpoint (session ID is not reliable, so we skip it)
265+ const hookInput = JSON . stringify ( {
266+ hook_event_name : "before_edit" ,
267+ workspaceFolder : workspaceFolder . uri . fsPath ,
268+ will_edit_filepaths : filesToCheckpoint ,
269+ dirtyFiles : dirtyFiles ,
270+ } ) ;
271+
272+ this . checkpoint ( "human" , hookInput ) ;
273+ }
274+
275+ async checkpoint ( author : "human" | "ai" | "ai_tab" , hookInput : string ) : Promise < boolean > {
276+ if ( ! ( await this . checkGitAi ( ) ) ) {
277+ return false ;
180278 }
181279
182- return new Promise < boolean > ( ( resolve , reject ) => {
280+ return new Promise < boolean > ( ( resolve ) => {
183281 let workspaceRoot : string | undefined ;
184282
185283 const activeEditor = vscode . window . activeTextEditor ;
@@ -202,12 +300,16 @@ export class AIEditManager {
202300 }
203301
204302 const args = [ "checkpoint" ] ;
205- if ( author === "ai" ) {
303+ if ( author === "ai_tab" ) {
304+ args . push ( "ai_tab" ) ;
305+ } else {
206306 args . push ( "github-copilot" ) ;
207307 }
208- if ( hookInput ) {
209- args . push ( "--hook-input" , "stdin" ) ;
210- }
308+ args . push ( "--hook-input" , "stdin" ) ;
309+
310+ console . log ( '[git-ai] AIEditManager: Spawning git-ai with args:' , args ) ;
311+ console . log ( '[git-ai] AIEditManager: Workspace root:' , workspaceRoot ) ;
312+ console . log ( '[git-ai] AIEditManager: Hook input:' , hookInput ) ;
211313
212314 const proc = spawn ( "git-ai" , args , { cwd : workspaceRoot } ) ;
213315
@@ -320,4 +422,4 @@ export class AIEditManager {
320422 } ) ;
321423 } ) ;
322424 }
323- }
425+ }
0 commit comments