@agi-cli/server 0.1.80 → 0.1.82
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/package.json +3 -3
- package/src/index.ts +6 -2
- package/src/openapi/helpers.ts +64 -0
- package/src/openapi/paths/ask.ts +70 -0
- package/src/openapi/paths/config.ts +164 -0
- package/src/openapi/paths/files.ts +72 -0
- package/src/openapi/paths/git.ts +453 -0
- package/src/openapi/paths/messages.ts +92 -0
- package/src/openapi/paths/sessions.ts +90 -0
- package/src/openapi/paths/stream.ts +26 -0
- package/src/openapi/schemas.ts +293 -0
- package/src/openapi/spec.ts +17 -1142
- package/src/routes/config/agents.ts +44 -0
- package/src/routes/config/cwd.ts +21 -0
- package/src/routes/config/index.ts +14 -0
- package/src/routes/config/main.ts +68 -0
- package/src/routes/config/models.ts +109 -0
- package/src/routes/config/providers.ts +46 -0
- package/src/routes/config/utils.ts +107 -0
- package/src/routes/files.ts +218 -0
- package/src/routes/git/branch.ts +75 -0
- package/src/routes/git/commit.ts +159 -0
- package/src/routes/git/diff.ts +137 -0
- package/src/routes/git/index.ts +18 -0
- package/src/routes/git/push.ts +160 -0
- package/src/routes/git/schemas.ts +47 -0
- package/src/routes/git/staging.ts +208 -0
- package/src/routes/git/status.ts +76 -0
- package/src/routes/git/types.ts +19 -0
- package/src/routes/git/utils.ts +212 -0
- package/src/runtime/runner.ts +3 -6
- package/src/runtime/session-manager.ts +1 -1
- package/src/routes/config.ts +0 -387
- package/src/routes/git.ts +0 -980
package/src/routes/git.ts
DELETED
|
@@ -1,980 +0,0 @@
|
|
|
1
|
-
import type { Hono } from 'hono';
|
|
2
|
-
import { execFile } from 'node:child_process';
|
|
3
|
-
import { extname, join } from 'node:path';
|
|
4
|
-
import { readFile } from 'node:fs/promises';
|
|
5
|
-
import { promisify } from 'node:util';
|
|
6
|
-
import { z } from 'zod';
|
|
7
|
-
import { generateText } from 'ai';
|
|
8
|
-
import type { ProviderId } from '@agi-cli/sdk';
|
|
9
|
-
import { loadConfig, getAuth } from '@agi-cli/sdk';
|
|
10
|
-
import { resolveModel } from '../runtime/provider.ts';
|
|
11
|
-
import { getProviderSpoofPrompt } from '../runtime/prompt.ts';
|
|
12
|
-
|
|
13
|
-
const execFileAsync = promisify(execFile);
|
|
14
|
-
|
|
15
|
-
// Validation schemas - make project optional with default
|
|
16
|
-
const gitStatusSchema = z.object({
|
|
17
|
-
project: z.string().optional(),
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
const gitDiffSchema = z.object({
|
|
21
|
-
project: z.string().optional(),
|
|
22
|
-
file: z.string(),
|
|
23
|
-
staged: z
|
|
24
|
-
.string()
|
|
25
|
-
.optional()
|
|
26
|
-
.transform((val) => val === 'true'),
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
const LANGUAGE_MAP: Record<string, string> = {
|
|
30
|
-
js: 'javascript',
|
|
31
|
-
jsx: 'jsx',
|
|
32
|
-
ts: 'typescript',
|
|
33
|
-
tsx: 'tsx',
|
|
34
|
-
py: 'python',
|
|
35
|
-
rb: 'ruby',
|
|
36
|
-
go: 'go',
|
|
37
|
-
rs: 'rust',
|
|
38
|
-
java: 'java',
|
|
39
|
-
c: 'c',
|
|
40
|
-
cpp: 'cpp',
|
|
41
|
-
h: 'c',
|
|
42
|
-
hpp: 'cpp',
|
|
43
|
-
cs: 'csharp',
|
|
44
|
-
php: 'php',
|
|
45
|
-
sh: 'bash',
|
|
46
|
-
bash: 'bash',
|
|
47
|
-
zsh: 'bash',
|
|
48
|
-
sql: 'sql',
|
|
49
|
-
json: 'json',
|
|
50
|
-
yaml: 'yaml',
|
|
51
|
-
yml: 'yaml',
|
|
52
|
-
xml: 'xml',
|
|
53
|
-
html: 'html',
|
|
54
|
-
css: 'css',
|
|
55
|
-
scss: 'scss',
|
|
56
|
-
md: 'markdown',
|
|
57
|
-
txt: 'plaintext',
|
|
58
|
-
svelte: 'svelte',
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
function inferLanguage(filePath: string): string {
|
|
62
|
-
const extension = extname(filePath).toLowerCase().replace('.', '');
|
|
63
|
-
if (!extension) {
|
|
64
|
-
return 'plaintext';
|
|
65
|
-
}
|
|
66
|
-
return LANGUAGE_MAP[extension] ?? 'plaintext';
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function summarizeDiff(diff: string): {
|
|
70
|
-
insertions: number;
|
|
71
|
-
deletions: number;
|
|
72
|
-
binary: boolean;
|
|
73
|
-
} {
|
|
74
|
-
let insertions = 0;
|
|
75
|
-
let deletions = 0;
|
|
76
|
-
let binary = false;
|
|
77
|
-
|
|
78
|
-
for (const line of diff.split('\n')) {
|
|
79
|
-
if (line.startsWith('Binary files ') || line.includes('GIT binary patch')) {
|
|
80
|
-
binary = true;
|
|
81
|
-
break;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
85
|
-
insertions++;
|
|
86
|
-
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
87
|
-
deletions++;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return { insertions, deletions, binary };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const gitStageSchema = z.object({
|
|
95
|
-
project: z.string().optional(),
|
|
96
|
-
files: z.array(z.string()),
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
const gitUnstageSchema = z.object({
|
|
100
|
-
project: z.string().optional(),
|
|
101
|
-
files: z.array(z.string()),
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
const gitCommitSchema = z.object({
|
|
105
|
-
project: z.string().optional(),
|
|
106
|
-
message: z.string().min(1),
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
const gitGenerateCommitMessageSchema = z.object({
|
|
110
|
-
project: z.string().optional(),
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const gitPushSchema = z.object({
|
|
114
|
-
project: z.string().optional(),
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// Types
|
|
118
|
-
export interface GitFile {
|
|
119
|
-
path: string;
|
|
120
|
-
absPath: string; // NEW: Absolute filesystem path
|
|
121
|
-
status: 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked';
|
|
122
|
-
staged: boolean;
|
|
123
|
-
insertions?: number;
|
|
124
|
-
deletions?: number;
|
|
125
|
-
oldPath?: string; // For renamed files
|
|
126
|
-
isNew: boolean; // NEW: True for untracked or newly added files
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
interface GitRoot {
|
|
130
|
-
gitRoot: string;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
interface GitError {
|
|
134
|
-
error: string;
|
|
135
|
-
code?: string;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Helper functions
|
|
139
|
-
async function validateAndGetGitRoot(
|
|
140
|
-
requestedPath: string,
|
|
141
|
-
): Promise<GitRoot | GitError> {
|
|
142
|
-
try {
|
|
143
|
-
const { stdout: gitRoot } = await execFileAsync(
|
|
144
|
-
'git',
|
|
145
|
-
['rev-parse', '--show-toplevel'],
|
|
146
|
-
{
|
|
147
|
-
cwd: requestedPath,
|
|
148
|
-
},
|
|
149
|
-
);
|
|
150
|
-
return { gitRoot: gitRoot.trim() };
|
|
151
|
-
} catch {
|
|
152
|
-
return {
|
|
153
|
-
error: 'Not a git repository',
|
|
154
|
-
code: 'NOT_A_GIT_REPO',
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Check if a file is new/untracked (not in git index)
|
|
161
|
-
*/
|
|
162
|
-
async function checkIfNewFile(gitRoot: string, file: string): Promise<boolean> {
|
|
163
|
-
try {
|
|
164
|
-
// Check if file exists in git index or committed
|
|
165
|
-
await execFileAsync('git', ['ls-files', '--error-unmatch', file], {
|
|
166
|
-
cwd: gitRoot,
|
|
167
|
-
});
|
|
168
|
-
return false; // File exists in git
|
|
169
|
-
} catch {
|
|
170
|
-
return true; // File is new/untracked
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function parseGitStatus(
|
|
175
|
-
statusOutput: string,
|
|
176
|
-
gitRoot: string,
|
|
177
|
-
): {
|
|
178
|
-
staged: GitFile[];
|
|
179
|
-
unstaged: GitFile[];
|
|
180
|
-
untracked: GitFile[];
|
|
181
|
-
} {
|
|
182
|
-
const lines = statusOutput.trim().split('\n').filter(Boolean);
|
|
183
|
-
const staged: GitFile[] = [];
|
|
184
|
-
const unstaged: GitFile[] = [];
|
|
185
|
-
const untracked: GitFile[] = [];
|
|
186
|
-
|
|
187
|
-
for (const line of lines) {
|
|
188
|
-
// Porcelain v2 format has different line types
|
|
189
|
-
if (line.startsWith('1 ') || line.startsWith('2 ')) {
|
|
190
|
-
// Regular changed entry: "1 XY sub <mH> <mI> <mW> <hH> <hI> <path>"
|
|
191
|
-
// XY is a 2-character field with staged (X) and unstaged (Y) status
|
|
192
|
-
const parts = line.split(' ');
|
|
193
|
-
if (parts.length < 9) continue;
|
|
194
|
-
|
|
195
|
-
const xy = parts[1]; // e.g., ".M", "M.", "MM", "A.", etc.
|
|
196
|
-
const x = xy[0]; // staged status
|
|
197
|
-
const y = xy[1]; // unstaged status
|
|
198
|
-
const path = parts.slice(8).join(' '); // Path can contain spaces
|
|
199
|
-
const absPath = join(gitRoot, path);
|
|
200
|
-
|
|
201
|
-
// Check if file is staged (X is not '.')
|
|
202
|
-
if (x !== '.') {
|
|
203
|
-
staged.push({
|
|
204
|
-
path,
|
|
205
|
-
absPath,
|
|
206
|
-
status: getStatusFromCodeV2(x),
|
|
207
|
-
staged: true,
|
|
208
|
-
isNew: x === 'A',
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Check if file is unstaged (Y is not '.')
|
|
213
|
-
if (y !== '.') {
|
|
214
|
-
unstaged.push({
|
|
215
|
-
path,
|
|
216
|
-
absPath,
|
|
217
|
-
status: getStatusFromCodeV2(y),
|
|
218
|
-
staged: false,
|
|
219
|
-
isNew: false,
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
} else if (line.startsWith('? ')) {
|
|
223
|
-
// Untracked file: "? <path>"
|
|
224
|
-
const path = line.slice(2);
|
|
225
|
-
const absPath = join(gitRoot, path);
|
|
226
|
-
untracked.push({
|
|
227
|
-
path,
|
|
228
|
-
absPath,
|
|
229
|
-
status: 'untracked',
|
|
230
|
-
staged: false,
|
|
231
|
-
isNew: true,
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
return { staged, unstaged, untracked };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function _getStatusFromCode(code: string): GitFile['status'] {
|
|
240
|
-
switch (code) {
|
|
241
|
-
case 'M':
|
|
242
|
-
return 'modified';
|
|
243
|
-
case 'A':
|
|
244
|
-
return 'added';
|
|
245
|
-
case 'D':
|
|
246
|
-
return 'deleted';
|
|
247
|
-
case 'R':
|
|
248
|
-
return 'renamed';
|
|
249
|
-
default:
|
|
250
|
-
return 'modified';
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function getStatusFromCodeV2(code: string): GitFile['status'] {
|
|
255
|
-
switch (code) {
|
|
256
|
-
case 'M':
|
|
257
|
-
return 'modified';
|
|
258
|
-
case 'A':
|
|
259
|
-
return 'added';
|
|
260
|
-
case 'D':
|
|
261
|
-
return 'deleted';
|
|
262
|
-
case 'R':
|
|
263
|
-
return 'renamed';
|
|
264
|
-
case 'C':
|
|
265
|
-
return 'modified'; // Copied - treat as modified
|
|
266
|
-
default:
|
|
267
|
-
return 'modified';
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
async function getAheadBehind(
|
|
272
|
-
gitRoot: string,
|
|
273
|
-
): Promise<{ ahead: number; behind: number }> {
|
|
274
|
-
try {
|
|
275
|
-
const { stdout } = await execFileAsync(
|
|
276
|
-
'git',
|
|
277
|
-
['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'],
|
|
278
|
-
{ cwd: gitRoot },
|
|
279
|
-
);
|
|
280
|
-
const [ahead, behind] = stdout.trim().split(/\s+/).map(Number);
|
|
281
|
-
return { ahead: ahead || 0, behind: behind || 0 };
|
|
282
|
-
} catch {
|
|
283
|
-
return { ahead: 0, behind: 0 };
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
async function getCurrentBranch(gitRoot: string): Promise<string> {
|
|
288
|
-
try {
|
|
289
|
-
const { stdout } = await execFileAsync(
|
|
290
|
-
'git',
|
|
291
|
-
['branch', '--show-current'],
|
|
292
|
-
{
|
|
293
|
-
cwd: gitRoot,
|
|
294
|
-
},
|
|
295
|
-
);
|
|
296
|
-
return stdout.trim();
|
|
297
|
-
} catch {
|
|
298
|
-
return 'unknown';
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
export function registerGitRoutes(app: Hono) {
|
|
303
|
-
// GET /v1/git/status - Get git status
|
|
304
|
-
app.get('/v1/git/status', async (c) => {
|
|
305
|
-
try {
|
|
306
|
-
const query = gitStatusSchema.parse({
|
|
307
|
-
project: c.req.query('project'),
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
const requestedPath = query.project || process.cwd();
|
|
311
|
-
|
|
312
|
-
const validation = await validateAndGetGitRoot(requestedPath);
|
|
313
|
-
if ('error' in validation) {
|
|
314
|
-
return c.json(
|
|
315
|
-
{ status: 'error', error: validation.error, code: validation.code },
|
|
316
|
-
400,
|
|
317
|
-
);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
const { gitRoot } = validation;
|
|
321
|
-
|
|
322
|
-
// Get status
|
|
323
|
-
const { stdout: statusOutput } = await execFileAsync(
|
|
324
|
-
'git',
|
|
325
|
-
['status', '--porcelain=v2'],
|
|
326
|
-
{ cwd: gitRoot },
|
|
327
|
-
);
|
|
328
|
-
|
|
329
|
-
const { staged, unstaged, untracked } = parseGitStatus(
|
|
330
|
-
statusOutput,
|
|
331
|
-
gitRoot,
|
|
332
|
-
);
|
|
333
|
-
|
|
334
|
-
// Get ahead/behind counts
|
|
335
|
-
const { ahead, behind } = await getAheadBehind(gitRoot);
|
|
336
|
-
|
|
337
|
-
// Get current branch
|
|
338
|
-
const branch = await getCurrentBranch(gitRoot);
|
|
339
|
-
|
|
340
|
-
// Calculate hasChanges
|
|
341
|
-
const hasChanges =
|
|
342
|
-
staged.length > 0 || unstaged.length > 0 || untracked.length > 0;
|
|
343
|
-
|
|
344
|
-
return c.json({
|
|
345
|
-
status: 'ok',
|
|
346
|
-
data: {
|
|
347
|
-
branch,
|
|
348
|
-
ahead,
|
|
349
|
-
behind,
|
|
350
|
-
gitRoot, // NEW: Expose git root path
|
|
351
|
-
workingDir: requestedPath, // NEW: Current working directory
|
|
352
|
-
staged,
|
|
353
|
-
unstaged,
|
|
354
|
-
untracked,
|
|
355
|
-
hasChanges,
|
|
356
|
-
},
|
|
357
|
-
});
|
|
358
|
-
} catch (error) {
|
|
359
|
-
return c.json(
|
|
360
|
-
{
|
|
361
|
-
status: 'error',
|
|
362
|
-
error:
|
|
363
|
-
error instanceof Error ? error.message : 'Failed to get status',
|
|
364
|
-
},
|
|
365
|
-
500,
|
|
366
|
-
);
|
|
367
|
-
}
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
// GET /v1/git/diff - Get file diff
|
|
371
|
-
app.get('/v1/git/diff', async (c) => {
|
|
372
|
-
try {
|
|
373
|
-
const query = gitDiffSchema.parse({
|
|
374
|
-
project: c.req.query('project'),
|
|
375
|
-
file: c.req.query('file'),
|
|
376
|
-
staged: c.req.query('staged'),
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
const requestedPath = query.project || process.cwd();
|
|
380
|
-
|
|
381
|
-
const validation = await validateAndGetGitRoot(requestedPath);
|
|
382
|
-
if ('error' in validation) {
|
|
383
|
-
return c.json(
|
|
384
|
-
{ status: 'error', error: validation.error, code: validation.code },
|
|
385
|
-
400,
|
|
386
|
-
);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
const { gitRoot } = validation;
|
|
390
|
-
const absPath = join(gitRoot, query.file);
|
|
391
|
-
|
|
392
|
-
// Check if file is new/untracked
|
|
393
|
-
const isNewFile = await checkIfNewFile(gitRoot, query.file);
|
|
394
|
-
|
|
395
|
-
// For new files, read and return full content
|
|
396
|
-
if (isNewFile) {
|
|
397
|
-
try {
|
|
398
|
-
const content = await readFile(absPath, 'utf-8');
|
|
399
|
-
const lineCount = content.split('\n').length;
|
|
400
|
-
const language = inferLanguage(query.file);
|
|
401
|
-
|
|
402
|
-
return c.json({
|
|
403
|
-
status: 'ok',
|
|
404
|
-
data: {
|
|
405
|
-
file: query.file,
|
|
406
|
-
absPath,
|
|
407
|
-
diff: '', // Empty diff for new files
|
|
408
|
-
content, // NEW: Full file content
|
|
409
|
-
isNewFile: true, // NEW: Flag indicating this is a new file
|
|
410
|
-
isBinary: false,
|
|
411
|
-
insertions: lineCount,
|
|
412
|
-
deletions: 0,
|
|
413
|
-
language,
|
|
414
|
-
staged: !!query.staged, // NEW: Whether showing staged or unstaged
|
|
415
|
-
},
|
|
416
|
-
});
|
|
417
|
-
} catch (error) {
|
|
418
|
-
return c.json(
|
|
419
|
-
{
|
|
420
|
-
status: 'error',
|
|
421
|
-
error:
|
|
422
|
-
error instanceof Error ? error.message : 'Failed to read file',
|
|
423
|
-
},
|
|
424
|
-
500,
|
|
425
|
-
);
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// For existing files, get diff output and stats
|
|
430
|
-
const diffArgs = query.staged
|
|
431
|
-
? ['diff', '--cached', '--', query.file]
|
|
432
|
-
: ['diff', '--', query.file];
|
|
433
|
-
const numstatArgs = query.staged
|
|
434
|
-
? ['diff', '--cached', '--numstat', '--', query.file]
|
|
435
|
-
: ['diff', '--numstat', '--', query.file];
|
|
436
|
-
|
|
437
|
-
const [{ stdout: diffOutput }, { stdout: numstatOutput }] =
|
|
438
|
-
await Promise.all([
|
|
439
|
-
execFileAsync('git', diffArgs, { cwd: gitRoot }),
|
|
440
|
-
execFileAsync('git', numstatArgs, { cwd: gitRoot }),
|
|
441
|
-
]);
|
|
442
|
-
|
|
443
|
-
let insertions = 0;
|
|
444
|
-
let deletions = 0;
|
|
445
|
-
let binary = false;
|
|
446
|
-
|
|
447
|
-
const numstatLine = numstatOutput.trim().split('\n').find(Boolean);
|
|
448
|
-
if (numstatLine) {
|
|
449
|
-
const [rawInsertions, rawDeletions] = numstatLine.split('\t');
|
|
450
|
-
if (rawInsertions === '-' || rawDeletions === '-') {
|
|
451
|
-
binary = true;
|
|
452
|
-
} else {
|
|
453
|
-
insertions = Number.parseInt(rawInsertions, 10) || 0;
|
|
454
|
-
deletions = Number.parseInt(rawDeletions, 10) || 0;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
const diffText = diffOutput ?? '';
|
|
459
|
-
if (!binary) {
|
|
460
|
-
const summary = summarizeDiff(diffText);
|
|
461
|
-
binary = summary.binary;
|
|
462
|
-
if (insertions === 0 && deletions === 0) {
|
|
463
|
-
insertions = summary.insertions;
|
|
464
|
-
deletions = summary.deletions;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
const language = inferLanguage(query.file);
|
|
469
|
-
|
|
470
|
-
return c.json({
|
|
471
|
-
status: 'ok',
|
|
472
|
-
data: {
|
|
473
|
-
file: query.file,
|
|
474
|
-
absPath, // NEW: Absolute path
|
|
475
|
-
diff: diffText,
|
|
476
|
-
isNewFile: false, // NEW: Not a new file
|
|
477
|
-
isBinary: binary,
|
|
478
|
-
insertions,
|
|
479
|
-
deletions,
|
|
480
|
-
language,
|
|
481
|
-
staged: !!query.staged, // NEW: Whether showing staged or unstaged
|
|
482
|
-
},
|
|
483
|
-
});
|
|
484
|
-
} catch (error) {
|
|
485
|
-
return c.json(
|
|
486
|
-
{
|
|
487
|
-
status: 'error',
|
|
488
|
-
error: error instanceof Error ? error.message : 'Failed to get diff',
|
|
489
|
-
},
|
|
490
|
-
500,
|
|
491
|
-
);
|
|
492
|
-
}
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
// POST /v1/git/stage - Stage files
|
|
496
|
-
app.post('/v1/git/stage', async (c) => {
|
|
497
|
-
try {
|
|
498
|
-
const body = await c.req.json();
|
|
499
|
-
const { files, project } = gitStageSchema.parse(body);
|
|
500
|
-
|
|
501
|
-
const requestedPath = project || process.cwd();
|
|
502
|
-
|
|
503
|
-
const validation = await validateAndGetGitRoot(requestedPath);
|
|
504
|
-
if ('error' in validation) {
|
|
505
|
-
return c.json(
|
|
506
|
-
{ status: 'error', error: validation.error, code: validation.code },
|
|
507
|
-
400,
|
|
508
|
-
);
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
const { gitRoot } = validation;
|
|
512
|
-
|
|
513
|
-
if (files.length === 0) {
|
|
514
|
-
return c.json(
|
|
515
|
-
{
|
|
516
|
-
status: 'error',
|
|
517
|
-
error: 'No files specified',
|
|
518
|
-
},
|
|
519
|
-
400,
|
|
520
|
-
);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
// Stage files
|
|
524
|
-
await execFileAsync('git', ['add', ...files], { cwd: gitRoot });
|
|
525
|
-
|
|
526
|
-
return c.json({
|
|
527
|
-
status: 'ok',
|
|
528
|
-
data: {
|
|
529
|
-
staged: files,
|
|
530
|
-
},
|
|
531
|
-
});
|
|
532
|
-
} catch (error) {
|
|
533
|
-
return c.json(
|
|
534
|
-
{
|
|
535
|
-
status: 'error',
|
|
536
|
-
error:
|
|
537
|
-
error instanceof Error ? error.message : 'Failed to stage files',
|
|
538
|
-
},
|
|
539
|
-
500,
|
|
540
|
-
);
|
|
541
|
-
}
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
// POST /v1/git/unstage - Unstage files
|
|
545
|
-
app.post('/v1/git/unstage', async (c) => {
|
|
546
|
-
try {
|
|
547
|
-
const body = await c.req.json();
|
|
548
|
-
const { files, project } = gitUnstageSchema.parse(body);
|
|
549
|
-
|
|
550
|
-
const requestedPath = project || process.cwd();
|
|
551
|
-
|
|
552
|
-
const validation = await validateAndGetGitRoot(requestedPath);
|
|
553
|
-
if ('error' in validation) {
|
|
554
|
-
return c.json(
|
|
555
|
-
{ status: 'error', error: validation.error, code: validation.code },
|
|
556
|
-
400,
|
|
557
|
-
);
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
const { gitRoot } = validation;
|
|
561
|
-
|
|
562
|
-
if (files.length === 0) {
|
|
563
|
-
return c.json(
|
|
564
|
-
{
|
|
565
|
-
status: 'error',
|
|
566
|
-
error: 'No files specified',
|
|
567
|
-
},
|
|
568
|
-
400,
|
|
569
|
-
);
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
// Unstage files
|
|
573
|
-
await execFileAsync('git', ['reset', 'HEAD', '--', ...files], {
|
|
574
|
-
cwd: gitRoot,
|
|
575
|
-
});
|
|
576
|
-
|
|
577
|
-
return c.json({
|
|
578
|
-
status: 'ok',
|
|
579
|
-
data: {
|
|
580
|
-
unstaged: files,
|
|
581
|
-
},
|
|
582
|
-
});
|
|
583
|
-
} catch (error) {
|
|
584
|
-
return c.json(
|
|
585
|
-
{
|
|
586
|
-
status: 'error',
|
|
587
|
-
error:
|
|
588
|
-
error instanceof Error ? error.message : 'Failed to unstage files',
|
|
589
|
-
},
|
|
590
|
-
500,
|
|
591
|
-
);
|
|
592
|
-
}
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
// POST /v1/git/commit - Commit staged changes
|
|
596
|
-
app.post('/v1/git/commit', async (c) => {
|
|
597
|
-
try {
|
|
598
|
-
const body = await c.req.json();
|
|
599
|
-
const { message, project } = gitCommitSchema.parse(body);
|
|
600
|
-
|
|
601
|
-
const requestedPath = project || process.cwd();
|
|
602
|
-
|
|
603
|
-
const validation = await validateAndGetGitRoot(requestedPath);
|
|
604
|
-
if ('error' in validation) {
|
|
605
|
-
return c.json(
|
|
606
|
-
{ status: 'error', error: validation.error, code: validation.code },
|
|
607
|
-
400,
|
|
608
|
-
);
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
const { gitRoot } = validation;
|
|
612
|
-
|
|
613
|
-
// Commit changes
|
|
614
|
-
const { stdout } = await execFileAsync('git', ['commit', '-m', message], {
|
|
615
|
-
cwd: gitRoot,
|
|
616
|
-
});
|
|
617
|
-
|
|
618
|
-
return c.json({
|
|
619
|
-
status: 'ok',
|
|
620
|
-
data: {
|
|
621
|
-
message: stdout.trim(),
|
|
622
|
-
},
|
|
623
|
-
});
|
|
624
|
-
} catch (error) {
|
|
625
|
-
return c.json(
|
|
626
|
-
{
|
|
627
|
-
status: 'error',
|
|
628
|
-
error: error instanceof Error ? error.message : 'Failed to commit',
|
|
629
|
-
},
|
|
630
|
-
500,
|
|
631
|
-
);
|
|
632
|
-
}
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
// POST /v1/git/generate-commit-message - Generate commit message from staged changes
|
|
636
|
-
app.post('/v1/git/generate-commit-message', async (c) => {
|
|
637
|
-
try {
|
|
638
|
-
const body = await c.req.json();
|
|
639
|
-
const { project } = gitGenerateCommitMessageSchema.parse(body);
|
|
640
|
-
|
|
641
|
-
const requestedPath = project || process.cwd();
|
|
642
|
-
|
|
643
|
-
const validation = await validateAndGetGitRoot(requestedPath);
|
|
644
|
-
if ('error' in validation) {
|
|
645
|
-
return c.json(
|
|
646
|
-
{ status: 'error', error: validation.error, code: validation.code },
|
|
647
|
-
400,
|
|
648
|
-
);
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
const { gitRoot } = validation;
|
|
652
|
-
|
|
653
|
-
// Get staged diff
|
|
654
|
-
const { stdout: diff } = await execFileAsync(
|
|
655
|
-
'git',
|
|
656
|
-
['diff', '--cached'],
|
|
657
|
-
{
|
|
658
|
-
cwd: gitRoot,
|
|
659
|
-
},
|
|
660
|
-
);
|
|
661
|
-
|
|
662
|
-
if (!diff.trim()) {
|
|
663
|
-
return c.json(
|
|
664
|
-
{
|
|
665
|
-
status: 'error',
|
|
666
|
-
error: 'No staged changes to generate message from',
|
|
667
|
-
},
|
|
668
|
-
400,
|
|
669
|
-
);
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// Get file list for context
|
|
673
|
-
const { stdout: statusOutput } = await execFileAsync(
|
|
674
|
-
'git',
|
|
675
|
-
['status', '--porcelain=v2'],
|
|
676
|
-
{ cwd: gitRoot },
|
|
677
|
-
);
|
|
678
|
-
const { staged } = parseGitStatus(statusOutput, gitRoot);
|
|
679
|
-
const fileList = staged.map((f) => `${f.status}: ${f.path}`).join('\n');
|
|
680
|
-
|
|
681
|
-
// Load config to get provider settings
|
|
682
|
-
const config = await loadConfig();
|
|
683
|
-
|
|
684
|
-
// Use the default provider and model for quick commit message generation
|
|
685
|
-
const provider = (config.defaults?.provider || 'anthropic') as ProviderId;
|
|
686
|
-
const modelId = config.defaults?.model || 'claude-3-5-sonnet-20241022';
|
|
687
|
-
|
|
688
|
-
// Check if we need OAuth spoof prompt (same as runner)
|
|
689
|
-
const auth = await getAuth(provider, config.projectRoot);
|
|
690
|
-
const needsSpoof = auth?.type === 'oauth';
|
|
691
|
-
const spoofPrompt = needsSpoof
|
|
692
|
-
? getProviderSpoofPrompt(provider)
|
|
693
|
-
: undefined;
|
|
694
|
-
|
|
695
|
-
// Resolve model with proper authentication (3-level fallback: OAuth, API key, env var)
|
|
696
|
-
const model = await resolveModel(provider, modelId, config);
|
|
697
|
-
|
|
698
|
-
// Generate commit message using AI
|
|
699
|
-
const userPrompt = `Generate a concise, conventional commit message for these git changes.
|
|
700
|
-
|
|
701
|
-
Staged files:
|
|
702
|
-
${fileList}
|
|
703
|
-
|
|
704
|
-
Diff (first 2000 chars):
|
|
705
|
-
${diff.slice(0, 2000)}
|
|
706
|
-
|
|
707
|
-
Guidelines:
|
|
708
|
-
- Use conventional commits format (feat:, fix:, docs:, etc.)
|
|
709
|
-
- Keep the first line under 72 characters
|
|
710
|
-
- Be specific but concise
|
|
711
|
-
- Focus on what changed and why, not how
|
|
712
|
-
- Do not include any markdown formatting or code blocks
|
|
713
|
-
- Return ONLY the commit message text, nothing else
|
|
714
|
-
|
|
715
|
-
Commit message:`;
|
|
716
|
-
|
|
717
|
-
// Use spoof prompt as system if OAuth, otherwise use normal system prompt
|
|
718
|
-
const systemPrompt = spoofPrompt
|
|
719
|
-
? spoofPrompt
|
|
720
|
-
: 'You are a helpful assistant that generates git commit messages.';
|
|
721
|
-
|
|
722
|
-
const { text } = await generateText({
|
|
723
|
-
model,
|
|
724
|
-
system: systemPrompt,
|
|
725
|
-
prompt: userPrompt,
|
|
726
|
-
maxTokens: 200,
|
|
727
|
-
});
|
|
728
|
-
|
|
729
|
-
const message = text.trim();
|
|
730
|
-
|
|
731
|
-
return c.json({
|
|
732
|
-
status: 'ok',
|
|
733
|
-
data: {
|
|
734
|
-
message,
|
|
735
|
-
},
|
|
736
|
-
});
|
|
737
|
-
} catch (error) {
|
|
738
|
-
return c.json(
|
|
739
|
-
{
|
|
740
|
-
status: 'error',
|
|
741
|
-
error:
|
|
742
|
-
error instanceof Error
|
|
743
|
-
? error.message
|
|
744
|
-
: 'Failed to generate commit message',
|
|
745
|
-
},
|
|
746
|
-
500,
|
|
747
|
-
);
|
|
748
|
-
}
|
|
749
|
-
});
|
|
750
|
-
|
|
751
|
-
// GET /v1/git/branch - Get branch info
|
|
752
|
-
app.get('/v1/git/branch', async (c) => {
|
|
753
|
-
try {
|
|
754
|
-
const query = gitStatusSchema.parse({
|
|
755
|
-
project: c.req.query('project'),
|
|
756
|
-
});
|
|
757
|
-
|
|
758
|
-
const requestedPath = query.project || process.cwd();
|
|
759
|
-
|
|
760
|
-
const validation = await validateAndGetGitRoot(requestedPath);
|
|
761
|
-
if ('error' in validation) {
|
|
762
|
-
return c.json(
|
|
763
|
-
{ status: 'error', error: validation.error, code: validation.code },
|
|
764
|
-
400,
|
|
765
|
-
);
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
const { gitRoot } = validation;
|
|
769
|
-
|
|
770
|
-
// Get current branch
|
|
771
|
-
const branch = await getCurrentBranch(gitRoot);
|
|
772
|
-
|
|
773
|
-
// Get ahead/behind counts
|
|
774
|
-
const { ahead, behind } = await getAheadBehind(gitRoot);
|
|
775
|
-
|
|
776
|
-
// Get remote info
|
|
777
|
-
try {
|
|
778
|
-
const { stdout: remotes } = await execFileAsync('git', ['remote'], {
|
|
779
|
-
cwd: gitRoot,
|
|
780
|
-
});
|
|
781
|
-
const remoteList = remotes.trim().split('\n').filter(Boolean);
|
|
782
|
-
|
|
783
|
-
return c.json({
|
|
784
|
-
status: 'ok',
|
|
785
|
-
data: {
|
|
786
|
-
branch,
|
|
787
|
-
ahead,
|
|
788
|
-
behind,
|
|
789
|
-
remotes: remoteList,
|
|
790
|
-
},
|
|
791
|
-
});
|
|
792
|
-
} catch {
|
|
793
|
-
return c.json({
|
|
794
|
-
status: 'ok',
|
|
795
|
-
data: {
|
|
796
|
-
branch,
|
|
797
|
-
ahead,
|
|
798
|
-
behind,
|
|
799
|
-
remotes: [],
|
|
800
|
-
},
|
|
801
|
-
});
|
|
802
|
-
}
|
|
803
|
-
} catch (error) {
|
|
804
|
-
return c.json(
|
|
805
|
-
{
|
|
806
|
-
status: 'error',
|
|
807
|
-
error:
|
|
808
|
-
error instanceof Error
|
|
809
|
-
? error.message
|
|
810
|
-
: 'Failed to get branch info',
|
|
811
|
-
},
|
|
812
|
-
500,
|
|
813
|
-
);
|
|
814
|
-
}
|
|
815
|
-
});
|
|
816
|
-
|
|
817
|
-
// POST /v1/git/push - Push commits to remote
|
|
818
|
-
app.post('/v1/git/push', async (c) => {
|
|
819
|
-
try {
|
|
820
|
-
// Parse JSON body, defaulting to empty object if parsing fails
|
|
821
|
-
let body = {};
|
|
822
|
-
try {
|
|
823
|
-
body = await c.req.json();
|
|
824
|
-
} catch (jsonError) {
|
|
825
|
-
// If JSON parsing fails (e.g., empty body), use empty object
|
|
826
|
-
console.warn(
|
|
827
|
-
'Failed to parse JSON body for git push, using empty object:',
|
|
828
|
-
jsonError,
|
|
829
|
-
);
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
const { project } = gitPushSchema.parse(body);
|
|
833
|
-
|
|
834
|
-
const requestedPath = project || process.cwd();
|
|
835
|
-
|
|
836
|
-
const validation = await validateAndGetGitRoot(requestedPath);
|
|
837
|
-
if ('error' in validation) {
|
|
838
|
-
return c.json(
|
|
839
|
-
{ status: 'error', error: validation.error, code: validation.code },
|
|
840
|
-
400,
|
|
841
|
-
);
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
const { gitRoot } = validation;
|
|
845
|
-
|
|
846
|
-
// Check if there's a remote configured
|
|
847
|
-
try {
|
|
848
|
-
const { stdout: remotes } = await execFileAsync('git', ['remote'], {
|
|
849
|
-
cwd: gitRoot,
|
|
850
|
-
});
|
|
851
|
-
if (!remotes.trim()) {
|
|
852
|
-
return c.json(
|
|
853
|
-
{ status: 'error', error: 'No remote repository configured' },
|
|
854
|
-
400,
|
|
855
|
-
);
|
|
856
|
-
}
|
|
857
|
-
} catch {
|
|
858
|
-
return c.json(
|
|
859
|
-
{ status: 'error', error: 'No remote repository configured' },
|
|
860
|
-
400,
|
|
861
|
-
);
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
// Get current branch and check for upstream
|
|
865
|
-
const branch = await getCurrentBranch(gitRoot);
|
|
866
|
-
let hasUpstream = false;
|
|
867
|
-
try {
|
|
868
|
-
await execFileAsync(
|
|
869
|
-
'git',
|
|
870
|
-
['rev-parse', '--abbrev-ref', '@{upstream}'],
|
|
871
|
-
{
|
|
872
|
-
cwd: gitRoot,
|
|
873
|
-
},
|
|
874
|
-
);
|
|
875
|
-
hasUpstream = true;
|
|
876
|
-
} catch {
|
|
877
|
-
// No upstream set
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
// Push to remote - with proper error handling
|
|
881
|
-
try {
|
|
882
|
-
let pushOutput: string;
|
|
883
|
-
let pushError: string;
|
|
884
|
-
|
|
885
|
-
if (hasUpstream) {
|
|
886
|
-
// Push to existing upstream
|
|
887
|
-
const result = await execFileAsync('git', ['push'], { cwd: gitRoot });
|
|
888
|
-
pushOutput = result.stdout;
|
|
889
|
-
pushError = result.stderr;
|
|
890
|
-
} else {
|
|
891
|
-
// Set upstream and push
|
|
892
|
-
const result = await execFileAsync(
|
|
893
|
-
'git',
|
|
894
|
-
['push', '--set-upstream', 'origin', branch],
|
|
895
|
-
{ cwd: gitRoot },
|
|
896
|
-
);
|
|
897
|
-
pushOutput = result.stdout;
|
|
898
|
-
pushError = result.stderr;
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
return c.json({
|
|
902
|
-
status: 'ok',
|
|
903
|
-
data: {
|
|
904
|
-
output: pushOutput.trim() || pushError.trim(),
|
|
905
|
-
},
|
|
906
|
-
});
|
|
907
|
-
} catch (pushErr: unknown) {
|
|
908
|
-
// Handle specific git push errors
|
|
909
|
-
const error = pushErr as {
|
|
910
|
-
message?: string;
|
|
911
|
-
stderr?: string;
|
|
912
|
-
code?: number;
|
|
913
|
-
};
|
|
914
|
-
const errorMessage = error.stderr || error.message || 'Failed to push';
|
|
915
|
-
|
|
916
|
-
// Check for common error patterns
|
|
917
|
-
if (
|
|
918
|
-
errorMessage.includes('failed to push') ||
|
|
919
|
-
errorMessage.includes('rejected')
|
|
920
|
-
) {
|
|
921
|
-
return c.json(
|
|
922
|
-
{
|
|
923
|
-
status: 'error',
|
|
924
|
-
error: 'Push rejected. Try pulling changes first with: git pull',
|
|
925
|
-
details: errorMessage,
|
|
926
|
-
},
|
|
927
|
-
400,
|
|
928
|
-
);
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
if (
|
|
932
|
-
errorMessage.includes('Permission denied') ||
|
|
933
|
-
errorMessage.includes('authentication') ||
|
|
934
|
-
errorMessage.includes('could not read')
|
|
935
|
-
) {
|
|
936
|
-
return c.json(
|
|
937
|
-
{
|
|
938
|
-
status: 'error',
|
|
939
|
-
error: 'Authentication failed. Check your git credentials',
|
|
940
|
-
details: errorMessage,
|
|
941
|
-
},
|
|
942
|
-
401,
|
|
943
|
-
);
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
if (
|
|
947
|
-
errorMessage.includes('Could not resolve host') ||
|
|
948
|
-
errorMessage.includes('network')
|
|
949
|
-
) {
|
|
950
|
-
return c.json(
|
|
951
|
-
{
|
|
952
|
-
status: 'error',
|
|
953
|
-
error: 'Network error. Check your internet connection',
|
|
954
|
-
details: errorMessage,
|
|
955
|
-
},
|
|
956
|
-
503,
|
|
957
|
-
);
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
// Generic push error
|
|
961
|
-
return c.json(
|
|
962
|
-
{
|
|
963
|
-
status: 'error',
|
|
964
|
-
error: 'Failed to push commits',
|
|
965
|
-
details: errorMessage,
|
|
966
|
-
},
|
|
967
|
-
500,
|
|
968
|
-
);
|
|
969
|
-
}
|
|
970
|
-
} catch (error) {
|
|
971
|
-
return c.json(
|
|
972
|
-
{
|
|
973
|
-
status: 'error',
|
|
974
|
-
error: error instanceof Error ? error.message : 'Failed to push',
|
|
975
|
-
},
|
|
976
|
-
500,
|
|
977
|
-
);
|
|
978
|
-
}
|
|
979
|
-
});
|
|
980
|
-
}
|