@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.
- package/AGENTS.md +214 -0
- package/LICENSE +21 -0
- package/README.md +301 -0
- package/bin/cli.js +90 -0
- package/client/dist/zingit-client.js +2974 -0
- package/package.json +69 -0
- package/server/dist/agents/base.d.ts +20 -0
- package/server/dist/agents/base.js +136 -0
- package/server/dist/agents/claude.d.ts +18 -0
- package/server/dist/agents/claude.js +141 -0
- package/server/dist/agents/codex.d.ts +12 -0
- package/server/dist/agents/codex.js +194 -0
- package/server/dist/agents/copilot.d.ts +12 -0
- package/server/dist/agents/copilot.js +168 -0
- package/server/dist/handlers/messageHandlers.d.ts +57 -0
- package/server/dist/handlers/messageHandlers.js +329 -0
- package/server/dist/index.d.ts +1 -0
- package/server/dist/index.js +244 -0
- package/server/dist/services/git-manager.d.ts +104 -0
- package/server/dist/services/git-manager.js +317 -0
- package/server/dist/services/index.d.ts +2 -0
- package/server/dist/services/index.js +2 -0
- package/server/dist/types.d.ts +74 -0
- package/server/dist/types.js +2 -0
- package/server/dist/utils/agent-detection.d.ts +17 -0
- package/server/dist/utils/agent-detection.js +91 -0
- package/server/dist/validation/payload.d.ts +12 -0
- package/server/dist/validation/payload.js +64 -0
|
@@ -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
|
+
}
|