@codewithdan/zingit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,244 @@
1
+ // server/src/index.ts
2
+ import { WebSocketServer } from 'ws';
3
+ import { CopilotAgent } from './agents/copilot.js';
4
+ import { ClaudeCodeAgent } from './agents/claude.js';
5
+ import { CodexAgent } from './agents/codex.js';
6
+ import { detectAgents } from './utils/agent-detection.js';
7
+ import { GitManager, GitManagerError } from './services/git-manager.js';
8
+ import { sendMessage, handleGetAgents, handleSelectAgent, handleBatch, handleMessage, handleReset, handleStop, handleGetHistory, handleUndo, handleRevertTo, handleClearHistory } from './handlers/messageHandlers.js';
9
+ const PORT = parseInt(process.env.PORT || '3000', 10);
10
+ // Legacy support: still allow AGENT env var for backwards compatibility
11
+ const DEFAULT_AGENT = process.env.AGENT || null;
12
+ if (!process.env.PROJECT_DIR) {
13
+ console.error('ERROR: PROJECT_DIR environment variable is required');
14
+ console.error('Example: PROJECT_DIR=/path/to/your/project npm run dev');
15
+ process.exit(1);
16
+ }
17
+ const PROJECT_DIR = process.env.PROJECT_DIR;
18
+ // Agent registry
19
+ const agentClasses = {
20
+ copilot: CopilotAgent,
21
+ claude: ClaudeCodeAgent,
22
+ codex: CodexAgent,
23
+ };
24
+ // Cache for initialized agents (lazy initialization)
25
+ const initializedAgents = new Map();
26
+ /**
27
+ * Get or initialize an agent
28
+ */
29
+ async function getAgent(agentName) {
30
+ // Check cache first
31
+ const cached = initializedAgents.get(agentName);
32
+ if (cached) {
33
+ return cached;
34
+ }
35
+ // Initialize new agent
36
+ const AgentClass = agentClasses[agentName];
37
+ if (!AgentClass) {
38
+ throw new Error(`Unknown agent: ${agentName}`);
39
+ }
40
+ const agent = new AgentClass();
41
+ await agent.start();
42
+ initializedAgents.set(agentName, agent);
43
+ return agent;
44
+ }
45
+ // ConnectionState is now defined in handlers/messageHandlers.ts
46
+ async function main() {
47
+ // Detect available agents on startup
48
+ const availableAgents = await detectAgents();
49
+ console.log('✓ Agent availability:');
50
+ for (const agent of availableAgents) {
51
+ const status = agent.available ? '✓' : '✗';
52
+ const version = agent.version ? ` (${agent.version})` : '';
53
+ const reason = agent.reason ? ` - ${agent.reason}` : '';
54
+ console.log(` ${status} ${agent.displayName}${version}${reason}`);
55
+ }
56
+ // If DEFAULT_AGENT is set, pre-initialize it for backwards compatibility
57
+ if (DEFAULT_AGENT) {
58
+ const agentInfo = availableAgents.find(a => a.name === DEFAULT_AGENT);
59
+ if (agentInfo?.available) {
60
+ try {
61
+ await getAgent(DEFAULT_AGENT);
62
+ console.log(`✓ Pre-initialized agent: ${DEFAULT_AGENT}`);
63
+ }
64
+ catch (err) {
65
+ console.warn(`⚠ Failed to pre-initialize ${DEFAULT_AGENT}:`, err.message);
66
+ }
67
+ }
68
+ }
69
+ // WebSocket server with payload limit to prevent accidental memory issues
70
+ const wss = new WebSocketServer({
71
+ port: PORT,
72
+ maxPayload: 10 * 1024 * 1024 // 10MB limit (prevents accidental memory exhaustion)
73
+ });
74
+ console.log(`✓ ZingIt server running on ws://localhost:${PORT}`);
75
+ console.log(`✓ Project directory: ${PROJECT_DIR}`);
76
+ if (DEFAULT_AGENT) {
77
+ console.log(`✓ Default agent: ${DEFAULT_AGENT}`);
78
+ }
79
+ else {
80
+ console.log('✓ Dynamic agent selection enabled (client chooses agent)');
81
+ }
82
+ // Track connections and use shared global state for session persistence
83
+ const connections = new Set();
84
+ // Global state that persists across WebSocket reconnections
85
+ // NOTE: This is designed for single-user local development.
86
+ // Multiple simultaneous clients will share the same agent session.
87
+ const gitManager = new GitManager(PROJECT_DIR);
88
+ gitManager.initialize().catch((err) => {
89
+ console.warn('Failed to initialize GitManager:', err.message);
90
+ });
91
+ const globalState = {
92
+ session: null,
93
+ agentName: DEFAULT_AGENT, // Use default if set
94
+ agent: DEFAULT_AGENT ? initializedAgents.get(DEFAULT_AGENT) || null : null,
95
+ gitManager,
96
+ currentCheckpointId: null,
97
+ };
98
+ // Track cleanup timer to prevent race conditions
99
+ let sessionCleanupTimer = null;
100
+ wss.on('connection', (ws) => {
101
+ console.log('Client connected');
102
+ connections.add(ws);
103
+ const state = globalState; // Use shared state
104
+ // Clear any pending cleanup timer since we have an active connection
105
+ if (sessionCleanupTimer) {
106
+ clearTimeout(sessionCleanupTimer);
107
+ sessionCleanupTimer = null;
108
+ console.log('Cancelled session cleanup - client reconnected');
109
+ }
110
+ // Heartbeat mechanism to detect dead connections
111
+ let isAlive = true;
112
+ ws.on('pong', () => {
113
+ isAlive = true;
114
+ });
115
+ const heartbeatInterval = setInterval(() => {
116
+ if (!isAlive) {
117
+ console.log('Client failed to respond to ping - terminating connection');
118
+ clearInterval(heartbeatInterval);
119
+ ws.terminate(); // This will trigger the 'close' event which handles cleanup
120
+ return;
121
+ }
122
+ isAlive = false;
123
+ ws.ping();
124
+ }, 30000); // Ping every 30 seconds
125
+ // Create dependencies object for handlers
126
+ const deps = {
127
+ projectDir: PROJECT_DIR,
128
+ detectAgents,
129
+ getAgent
130
+ };
131
+ ws.on('message', async (data) => {
132
+ let msg;
133
+ try {
134
+ msg = JSON.parse(data.toString());
135
+ }
136
+ catch {
137
+ sendMessage(ws, { type: 'error', message: 'Invalid JSON' });
138
+ return;
139
+ }
140
+ try {
141
+ switch (msg.type) {
142
+ case 'get_agents':
143
+ await handleGetAgents(ws, deps);
144
+ break;
145
+ case 'select_agent':
146
+ await handleSelectAgent(ws, state, msg, deps);
147
+ break;
148
+ case 'batch':
149
+ await handleBatch(ws, state, msg, deps);
150
+ break;
151
+ case 'message':
152
+ await handleMessage(ws, state, msg);
153
+ break;
154
+ case 'reset':
155
+ await handleReset(ws, state);
156
+ break;
157
+ case 'stop':
158
+ await handleStop(ws, state);
159
+ break;
160
+ case 'get_history':
161
+ await handleGetHistory(ws, state);
162
+ break;
163
+ case 'undo':
164
+ await handleUndo(ws, state, GitManagerError);
165
+ break;
166
+ case 'revert_to':
167
+ await handleRevertTo(ws, state, msg, GitManagerError);
168
+ break;
169
+ case 'clear_history':
170
+ await handleClearHistory(ws, state);
171
+ break;
172
+ }
173
+ }
174
+ catch (err) {
175
+ sendMessage(ws, { type: 'error', message: err.message });
176
+ }
177
+ });
178
+ ws.on('close', async () => {
179
+ console.log('Client disconnected');
180
+ connections.delete(ws);
181
+ // Clean up heartbeat interval
182
+ clearInterval(heartbeatInterval);
183
+ // Don't destroy session immediately - keep it alive for reconnection (page reload)
184
+ // Clear any existing cleanup timer to prevent race conditions
185
+ if (sessionCleanupTimer) {
186
+ clearTimeout(sessionCleanupTimer);
187
+ sessionCleanupTimer = null;
188
+ }
189
+ // Set new cleanup timer only if no connections remain
190
+ if (connections.size === 0) {
191
+ sessionCleanupTimer = setTimeout(async () => {
192
+ if (state.session && connections.size === 0) {
193
+ console.log('Cleaning up stale session after 5 minutes of inactivity');
194
+ try {
195
+ await state.session.destroy();
196
+ }
197
+ catch (err) {
198
+ console.error('Error destroying session during cleanup:', err.message);
199
+ }
200
+ finally {
201
+ state.session = null;
202
+ sessionCleanupTimer = null;
203
+ }
204
+ }
205
+ }, 300000); // 5 minutes
206
+ }
207
+ });
208
+ ws.on('error', (err) => {
209
+ console.error('WebSocket error:', err.message);
210
+ // Clean up heartbeat interval on error
211
+ clearInterval(heartbeatInterval);
212
+ });
213
+ // Send connected message with current state
214
+ sendMessage(ws, {
215
+ type: 'connected',
216
+ agent: state.agentName || undefined,
217
+ model: state.agent?.model,
218
+ projectDir: PROJECT_DIR
219
+ });
220
+ });
221
+ // Graceful shutdown
222
+ process.on('SIGINT', async () => {
223
+ console.log('\nShutting down...');
224
+ if (globalState.session) {
225
+ try {
226
+ await globalState.session.destroy();
227
+ }
228
+ catch (err) {
229
+ console.error('Error destroying session during shutdown:', err.message);
230
+ }
231
+ }
232
+ for (const agent of initializedAgents.values()) {
233
+ try {
234
+ await agent.stop();
235
+ }
236
+ catch (err) {
237
+ console.error('Error stopping agent during shutdown:', err.message);
238
+ }
239
+ }
240
+ wss.close();
241
+ process.exit(0);
242
+ });
243
+ }
244
+ main().catch(console.error);
@@ -0,0 +1,104 @@
1
+ import type { Annotation } from '../types.js';
2
+ export interface Checkpoint {
3
+ id: string;
4
+ timestamp: string;
5
+ commitHash: string;
6
+ branchName: string;
7
+ annotations: AnnotationSummary[];
8
+ pageUrl: string;
9
+ pageTitle: string;
10
+ agentName: string;
11
+ status: 'pending' | 'applied' | 'reverted';
12
+ filesModified: number;
13
+ linesChanged: number;
14
+ }
15
+ export interface AnnotationSummary {
16
+ id: string;
17
+ identifier: string;
18
+ notes: string;
19
+ }
20
+ export interface FileChange {
21
+ checkpointId: string;
22
+ filePath: string;
23
+ changeType: 'created' | 'modified' | 'deleted';
24
+ linesAdded: number;
25
+ linesRemoved: number;
26
+ }
27
+ export interface ChangeHistory {
28
+ projectDir: string;
29
+ checkpoints: Checkpoint[];
30
+ currentCheckpointId: string | null;
31
+ }
32
+ export interface CheckpointInfo {
33
+ id: string;
34
+ timestamp: string;
35
+ annotations: AnnotationSummary[];
36
+ filesModified: number;
37
+ linesChanged: number;
38
+ agentName: string;
39
+ pageUrl: string;
40
+ status: 'pending' | 'applied' | 'reverted';
41
+ canUndo: boolean;
42
+ }
43
+ export declare class GitManager {
44
+ private projectDir;
45
+ private historyFile;
46
+ private zingitDir;
47
+ constructor(projectDir: string);
48
+ /**
49
+ * Initialize ZingIt tracking in the project
50
+ */
51
+ initialize(): Promise<void>;
52
+ /**
53
+ * Check if git repo exists and get status
54
+ */
55
+ checkGitStatus(): Promise<{
56
+ isRepo: boolean;
57
+ isClean: boolean;
58
+ branch: string;
59
+ error?: string;
60
+ }>;
61
+ /**
62
+ * Create a checkpoint before AI modifications
63
+ */
64
+ createCheckpoint(metadata: {
65
+ annotations: Annotation[];
66
+ pageUrl: string;
67
+ pageTitle: string;
68
+ agentName: string;
69
+ }): Promise<Checkpoint>;
70
+ /**
71
+ * Finalize checkpoint after AI modifications complete
72
+ */
73
+ finalizeCheckpoint(checkpointId: string): Promise<FileChange[]>;
74
+ /**
75
+ * Undo the most recent checkpoint
76
+ */
77
+ undoLastCheckpoint(): Promise<{
78
+ checkpointId: string;
79
+ filesReverted: string[];
80
+ }>;
81
+ /**
82
+ * Revert to a specific checkpoint
83
+ */
84
+ revertToCheckpoint(checkpointId: string): Promise<{
85
+ filesReverted: string[];
86
+ }>;
87
+ /**
88
+ * Get change history formatted for client
89
+ */
90
+ getHistory(): Promise<CheckpointInfo[]>;
91
+ /**
92
+ * Clear all history (dangerous operation)
93
+ */
94
+ clearHistory(): Promise<void>;
95
+ private loadHistory;
96
+ private saveHistory;
97
+ }
98
+ /**
99
+ * Custom error class for GitManager errors
100
+ */
101
+ export declare class GitManagerError extends Error {
102
+ readonly code: 'NOT_GIT_REPO' | 'DIRTY_WORKING_TREE' | 'CHECKPOINT_NOT_FOUND' | 'NO_CHANGES_TO_UNDO' | 'INVALID_CHECKPOINT_STATE';
103
+ constructor(message: string, code: 'NOT_GIT_REPO' | 'DIRTY_WORKING_TREE' | 'CHECKPOINT_NOT_FOUND' | 'NO_CHANGES_TO_UNDO' | 'INVALID_CHECKPOINT_STATE');
104
+ }
@@ -0,0 +1,317 @@
1
+ // server/src/services/git-manager.ts
2
+ import { exec, execFile } from 'child_process';
3
+ import { promisify } from 'util';
4
+ import * as fs from 'fs/promises';
5
+ import * as path from 'path';
6
+ import { v4 as uuidv4 } from 'uuid';
7
+ const execAsync = promisify(exec);
8
+ const execFileAsync = promisify(execFile);
9
+ /**
10
+ * Sanitize a string for safe use in git commit messages.
11
+ * Removes/escapes characters that could cause shell injection.
12
+ */
13
+ function sanitizeForGit(str) {
14
+ if (!str)
15
+ return '';
16
+ // Remove or replace dangerous characters
17
+ return str
18
+ .replace(/[`$"\\]/g, '') // Remove shell-dangerous chars
19
+ .replace(/[\r\n]/g, ' ') // Replace newlines with spaces
20
+ .trim()
21
+ .slice(0, 200); // Limit length
22
+ }
23
+ export class GitManager {
24
+ projectDir;
25
+ historyFile;
26
+ zingitDir;
27
+ constructor(projectDir) {
28
+ this.projectDir = projectDir;
29
+ this.zingitDir = path.join(projectDir, '.zingit');
30
+ this.historyFile = path.join(this.zingitDir, 'history.json');
31
+ }
32
+ /**
33
+ * Initialize ZingIt tracking in the project
34
+ */
35
+ async initialize() {
36
+ // Ensure .zingit directory exists
37
+ await fs.mkdir(this.zingitDir, { recursive: true });
38
+ // Add .zingit to .gitignore if not present
39
+ const gitignorePath = path.join(this.projectDir, '.gitignore');
40
+ try {
41
+ const gitignore = await fs.readFile(gitignorePath, 'utf-8');
42
+ if (!gitignore.includes('.zingit')) {
43
+ await fs.appendFile(gitignorePath, '\n# ZingIt history\n.zingit/\n');
44
+ }
45
+ }
46
+ catch {
47
+ // .gitignore doesn't exist - that's okay, we'll create history anyway
48
+ }
49
+ // Initialize history file if it doesn't exist
50
+ try {
51
+ await fs.access(this.historyFile);
52
+ }
53
+ catch {
54
+ await this.saveHistory({
55
+ projectDir: this.projectDir,
56
+ checkpoints: [],
57
+ currentCheckpointId: null,
58
+ });
59
+ }
60
+ }
61
+ /**
62
+ * Check if git repo exists and get status
63
+ */
64
+ async checkGitStatus() {
65
+ try {
66
+ const { stdout: branch } = await execAsync('git rev-parse --abbrev-ref HEAD', {
67
+ cwd: this.projectDir,
68
+ });
69
+ const { stdout: status } = await execAsync('git status --porcelain', {
70
+ cwd: this.projectDir,
71
+ });
72
+ return {
73
+ isRepo: true,
74
+ isClean: status.trim() === '',
75
+ branch: branch.trim(),
76
+ };
77
+ }
78
+ catch (err) {
79
+ return {
80
+ isRepo: false,
81
+ isClean: false,
82
+ branch: '',
83
+ error: err.message,
84
+ };
85
+ }
86
+ }
87
+ /**
88
+ * Create a checkpoint before AI modifications
89
+ */
90
+ async createCheckpoint(metadata) {
91
+ const status = await this.checkGitStatus();
92
+ if (!status.isRepo) {
93
+ throw new GitManagerError('Project directory is not a git repository. Initialize git first with: git init', 'NOT_GIT_REPO');
94
+ }
95
+ // If there are uncommitted changes, auto-commit them
96
+ if (!status.isClean) {
97
+ try {
98
+ await execFileAsync('git', ['add', '-A'], { cwd: this.projectDir });
99
+ await execFileAsync('git', ['commit', '-m', '[ZingIt] Auto-save before AI modifications'], {
100
+ cwd: this.projectDir,
101
+ });
102
+ }
103
+ catch (err) {
104
+ // Commit might fail if nothing to commit after add, that's okay
105
+ console.log('Note: Auto-commit skipped -', err.message);
106
+ }
107
+ }
108
+ // Get current commit hash
109
+ const { stdout: commitHash } = await execAsync('git rev-parse HEAD', {
110
+ cwd: this.projectDir,
111
+ });
112
+ const checkpoint = {
113
+ id: uuidv4(),
114
+ timestamp: new Date().toISOString(),
115
+ commitHash: commitHash.trim(),
116
+ branchName: status.branch,
117
+ annotations: metadata.annotations.map((a) => ({
118
+ id: a.id,
119
+ identifier: a.identifier,
120
+ notes: a.notes,
121
+ })),
122
+ pageUrl: metadata.pageUrl,
123
+ pageTitle: metadata.pageTitle,
124
+ agentName: metadata.agentName,
125
+ status: 'pending',
126
+ filesModified: 0,
127
+ linesChanged: 0,
128
+ };
129
+ // Save checkpoint to history
130
+ const history = await this.loadHistory();
131
+ history.checkpoints.push(checkpoint);
132
+ await this.saveHistory(history);
133
+ console.log(`[GitManager] Created checkpoint ${checkpoint.id.slice(0, 8)}`);
134
+ return checkpoint;
135
+ }
136
+ /**
137
+ * Finalize checkpoint after AI modifications complete
138
+ */
139
+ async finalizeCheckpoint(checkpointId) {
140
+ const history = await this.loadHistory();
141
+ const checkpoint = history.checkpoints.find((c) => c.id === checkpointId);
142
+ if (!checkpoint) {
143
+ throw new GitManagerError(`Checkpoint not found: ${checkpointId}`, 'CHECKPOINT_NOT_FOUND');
144
+ }
145
+ // Get list of changed files since checkpoint
146
+ let diffStat = '';
147
+ try {
148
+ const result = await execAsync(`git diff --name-status ${checkpoint.commitHash}`, {
149
+ cwd: this.projectDir,
150
+ });
151
+ diffStat = result.stdout;
152
+ }
153
+ catch {
154
+ // No changes
155
+ diffStat = '';
156
+ }
157
+ const fileChanges = [];
158
+ const lines = diffStat.trim().split('\n').filter((l) => l);
159
+ let totalLinesAdded = 0;
160
+ let totalLinesRemoved = 0;
161
+ for (const line of lines) {
162
+ const [statusChar, ...filePathParts] = line.split('\t');
163
+ const filePath = filePathParts.join('\t'); // Handle filenames with tabs
164
+ const changeType = statusChar === 'A' ? 'created' : statusChar === 'D' ? 'deleted' : 'modified';
165
+ // Get line counts for this file
166
+ let linesAdded = 0;
167
+ let linesRemoved = 0;
168
+ try {
169
+ const { stdout: numstat } = await execAsync(`git diff --numstat ${checkpoint.commitHash} -- "${filePath}"`, { cwd: this.projectDir });
170
+ const parts = numstat.trim().split('\t');
171
+ linesAdded = parseInt(parts[0]) || 0;
172
+ linesRemoved = parseInt(parts[1]) || 0;
173
+ }
174
+ catch {
175
+ // Ignore errors for individual files
176
+ }
177
+ totalLinesAdded += linesAdded;
178
+ totalLinesRemoved += linesRemoved;
179
+ fileChanges.push({
180
+ checkpointId,
181
+ filePath,
182
+ changeType,
183
+ linesAdded,
184
+ linesRemoved,
185
+ });
186
+ }
187
+ // Commit the changes if there are any
188
+ if (fileChanges.length > 0) {
189
+ try {
190
+ await execFileAsync('git', ['add', '-A'], { cwd: this.projectDir });
191
+ const identifiers = checkpoint.annotations.map((a) => sanitizeForGit(a.identifier)).join(', ');
192
+ const commitMsg = `[ZingIt] ${identifiers}`;
193
+ // Use execFile with array args to avoid shell injection
194
+ await execFileAsync('git', ['commit', '-m', commitMsg], { cwd: this.projectDir });
195
+ }
196
+ catch (err) {
197
+ console.log('Note: Commit skipped -', err.message);
198
+ }
199
+ }
200
+ // Update checkpoint status and stats
201
+ checkpoint.status = 'applied';
202
+ checkpoint.filesModified = fileChanges.length;
203
+ checkpoint.linesChanged = totalLinesAdded + totalLinesRemoved;
204
+ history.currentCheckpointId = checkpointId;
205
+ await this.saveHistory(history);
206
+ console.log(`[GitManager] Finalized checkpoint ${checkpointId.slice(0, 8)}: ${fileChanges.length} files, ${checkpoint.linesChanged} lines`);
207
+ return fileChanges;
208
+ }
209
+ /**
210
+ * Undo the most recent checkpoint
211
+ */
212
+ async undoLastCheckpoint() {
213
+ const history = await this.loadHistory();
214
+ if (!history.currentCheckpointId) {
215
+ throw new GitManagerError('No changes to undo', 'NO_CHANGES_TO_UNDO');
216
+ }
217
+ const checkpoint = history.checkpoints.find((c) => c.id === history.currentCheckpointId);
218
+ if (!checkpoint) {
219
+ throw new GitManagerError('Current checkpoint not found', 'CHECKPOINT_NOT_FOUND');
220
+ }
221
+ if (checkpoint.status !== 'applied') {
222
+ throw new GitManagerError('Checkpoint is not in applied state', 'INVALID_CHECKPOINT_STATE');
223
+ }
224
+ // Reset to the checkpoint's original commit
225
+ await execAsync(`git reset --hard ${checkpoint.commitHash}`, { cwd: this.projectDir });
226
+ // Get files that were reverted
227
+ const filesReverted = [];
228
+ // We could compute this but for now just return empty - the checkpoint has the info
229
+ // Update history
230
+ checkpoint.status = 'reverted';
231
+ const currentIndex = history.checkpoints.findIndex((c) => c.id === history.currentCheckpointId);
232
+ history.currentCheckpointId =
233
+ currentIndex > 0 ? history.checkpoints[currentIndex - 1].id : null;
234
+ await this.saveHistory(history);
235
+ console.log(`[GitManager] Undid checkpoint ${checkpoint.id.slice(0, 8)}`);
236
+ return { checkpointId: checkpoint.id, filesReverted };
237
+ }
238
+ /**
239
+ * Revert to a specific checkpoint
240
+ */
241
+ async revertToCheckpoint(checkpointId) {
242
+ const history = await this.loadHistory();
243
+ const checkpoint = history.checkpoints.find((c) => c.id === checkpointId);
244
+ if (!checkpoint) {
245
+ throw new GitManagerError(`Checkpoint not found: ${checkpointId}`, 'CHECKPOINT_NOT_FOUND');
246
+ }
247
+ // Reset to that commit
248
+ await execAsync(`git reset --hard ${checkpoint.commitHash}`, { cwd: this.projectDir });
249
+ // Mark all checkpoints after this one as reverted
250
+ const checkpointIndex = history.checkpoints.findIndex((c) => c.id === checkpointId);
251
+ for (let i = checkpointIndex + 1; i < history.checkpoints.length; i++) {
252
+ history.checkpoints[i].status = 'reverted';
253
+ }
254
+ // The target checkpoint itself should be marked as the current state
255
+ // but it was the state BEFORE changes, so set currentCheckpointId to previous
256
+ history.currentCheckpointId = checkpointIndex > 0 ? history.checkpoints[checkpointIndex - 1].id : null;
257
+ await this.saveHistory(history);
258
+ console.log(`[GitManager] Reverted to checkpoint ${checkpointId.slice(0, 8)}`);
259
+ return { filesReverted: [] };
260
+ }
261
+ /**
262
+ * Get change history formatted for client
263
+ */
264
+ async getHistory() {
265
+ const history = await this.loadHistory();
266
+ return history.checkpoints.map((cp, index) => ({
267
+ id: cp.id,
268
+ timestamp: cp.timestamp,
269
+ annotations: cp.annotations,
270
+ filesModified: cp.filesModified,
271
+ linesChanged: cp.linesChanged,
272
+ agentName: cp.agentName,
273
+ pageUrl: cp.pageUrl,
274
+ status: cp.status,
275
+ canUndo: cp.id === history.currentCheckpointId && cp.status === 'applied',
276
+ }));
277
+ }
278
+ /**
279
+ * Clear all history (dangerous operation)
280
+ */
281
+ async clearHistory() {
282
+ await this.saveHistory({
283
+ projectDir: this.projectDir,
284
+ checkpoints: [],
285
+ currentCheckpointId: null,
286
+ });
287
+ console.log('[GitManager] History cleared');
288
+ }
289
+ async loadHistory() {
290
+ try {
291
+ const data = await fs.readFile(this.historyFile, 'utf-8');
292
+ return JSON.parse(data);
293
+ }
294
+ catch {
295
+ return {
296
+ projectDir: this.projectDir,
297
+ checkpoints: [],
298
+ currentCheckpointId: null,
299
+ };
300
+ }
301
+ }
302
+ async saveHistory(history) {
303
+ await fs.mkdir(this.zingitDir, { recursive: true });
304
+ await fs.writeFile(this.historyFile, JSON.stringify(history, null, 2));
305
+ }
306
+ }
307
+ /**
308
+ * Custom error class for GitManager errors
309
+ */
310
+ export class GitManagerError extends Error {
311
+ code;
312
+ constructor(message, code) {
313
+ super(message);
314
+ this.code = code;
315
+ this.name = 'GitManagerError';
316
+ }
317
+ }
@@ -0,0 +1,2 @@
1
+ export { GitManager, GitManagerError } from './git-manager.js';
2
+ export type { Checkpoint, CheckpointInfo, ChangeHistory, FileChange, AnnotationSummary } from './git-manager.js';
@@ -0,0 +1,2 @@
1
+ // server/src/services/index.ts
2
+ export { GitManager, GitManagerError } from './git-manager.js';