Skip to content

Commit 1bb864c

Browse files
authored
Merge pull request #261 from acunniffe/fix/human-cli-checkpoint-output
improve performance of git-ai checkpoint for 1000+ file human checkpoints
2 parents 9e3b35b + 3f9046c commit 1bb864c

File tree

7 files changed

+726
-79
lines changed

7 files changed

+726
-79
lines changed

agent-support/vscode/src/ai-edit-manager.ts

Lines changed: 148 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

agent-support/vscode/src/consts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { IDEHostKind } from "./utils/host-kind";
22

3-
export const MIN_GIT_AI_VERSION = "1.0.3";
3+
export const MIN_GIT_AI_VERSION = "1.0.22";
44

55
// Use GitHub URL to avoid VS Code open URL safety prompt
66
export const GIT_AI_INSTALL_DOCS_URL = "https://github.com/acunniffe/git-ai?tab=readme-ov-file#quick-start";

agent-support/vscode/src/extension.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@ export function activate(context: vscode.ExtensionContext) {
2828

2929
if (ideHostCfg.kind == IDEHostKindVSCode) {
3030
console.log('[git-ai] Using VS Code/Copilot detection strategy');
31-
// Trigger initial human checkpoint
32-
aiEditManager.triggerInitialHumanCheckpoint();
3331

3432
// Save event
3533
context.subscriptions.push(
@@ -52,6 +50,15 @@ export function activate(context: vscode.ExtensionContext) {
5250
})
5351
);
5452
}
53+
54+
// vscode.commands.getCommands(true)
55+
// .then(commands => {
56+
// const content = commands.join('\n');
57+
// vscode.workspace.openTextDocument({ content, language: 'text' })
58+
// .then(doc => vscode.window.showTextDocument(doc));
59+
// });
5560
}
5661

57-
export function deactivate() { }
62+
export function deactivate() {
63+
console.log('[git-ai] extension deactivated');
64+
}

0 commit comments

Comments
 (0)