@agi-cli/server 0.1.61 → 0.1.63

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/src/routes/git.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type { Hono } from 'hono';
2
2
  import { execFile } from 'node:child_process';
3
- import { promisify } from 'node:util';
4
3
  import { extname } from 'node:path';
4
+ import { promisify } from 'node:util';
5
5
  import { z } from 'zod';
6
- import { generateText, resolveModel } from '@agi-cli/sdk';
6
+ import { generateText, resolveModel, type ProviderId } from '@agi-cli/sdk';
7
7
  import { loadConfig } from '@agi-cli/sdk';
8
8
 
9
9
  const execFileAsync = promisify(execFile);
@@ -22,6 +22,71 @@ const gitDiffSchema = z.object({
22
22
  .transform((val) => val === 'true'),
23
23
  });
24
24
 
25
+ const LANGUAGE_MAP: Record<string, string> = {
26
+ js: 'javascript',
27
+ jsx: 'jsx',
28
+ ts: 'typescript',
29
+ tsx: 'tsx',
30
+ py: 'python',
31
+ rb: 'ruby',
32
+ go: 'go',
33
+ rs: 'rust',
34
+ java: 'java',
35
+ c: 'c',
36
+ cpp: 'cpp',
37
+ h: 'c',
38
+ hpp: 'cpp',
39
+ cs: 'csharp',
40
+ php: 'php',
41
+ sh: 'bash',
42
+ bash: 'bash',
43
+ zsh: 'bash',
44
+ sql: 'sql',
45
+ json: 'json',
46
+ yaml: 'yaml',
47
+ yml: 'yaml',
48
+ xml: 'xml',
49
+ html: 'html',
50
+ css: 'css',
51
+ scss: 'scss',
52
+ md: 'markdown',
53
+ txt: 'plaintext',
54
+ svelte: 'svelte',
55
+ };
56
+
57
+ function inferLanguage(filePath: string): string {
58
+ const extension = extname(filePath).toLowerCase().replace('.', '');
59
+ if (!extension) {
60
+ return 'plaintext';
61
+ }
62
+ return LANGUAGE_MAP[extension] ?? 'plaintext';
63
+ }
64
+
65
+ function summarizeDiff(diff: string): {
66
+ insertions: number;
67
+ deletions: number;
68
+ binary: boolean;
69
+ } {
70
+ let insertions = 0;
71
+ let deletions = 0;
72
+ let binary = false;
73
+
74
+ for (const line of diff.split('\n')) {
75
+ if (line.startsWith('Binary files ') || line.includes('GIT binary patch')) {
76
+ binary = true;
77
+ break;
78
+ }
79
+
80
+ if (line.startsWith('+') && !line.startsWith('+++')) {
81
+ insertions++;
82
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
83
+ deletions++;
84
+ }
85
+ }
86
+
87
+ return { insertions, deletions, binary };
88
+ }
89
+
25
90
  const gitStageSchema = z.object({
26
91
  project: z.string().optional(),
27
92
  files: z.array(z.string()),
@@ -41,6 +106,10 @@ const gitGenerateCommitMessageSchema = z.object({
41
106
  project: z.string().optional(),
42
107
  });
43
108
 
109
+ const gitPushSchema = z.object({
110
+ project: z.string().optional(),
111
+ });
112
+
44
113
  // Types
45
114
  export interface GitFile {
46
115
  path: string;
@@ -48,144 +117,75 @@ export interface GitFile {
48
117
  staged: boolean;
49
118
  insertions?: number;
50
119
  deletions?: number;
51
- oldPath?: string;
52
- }
53
-
54
- export interface GitStatus {
55
- branch: string;
56
- ahead: number;
57
- behind: number;
58
- staged: GitFile[];
59
- unstaged: GitFile[];
60
- untracked: GitFile[];
61
- hasChanges: boolean;
62
120
  }
63
121
 
64
- // File extension to language mapping
65
- const languageMap: Record<string, string> = {
66
- '.ts': 'typescript',
67
- '.tsx': 'typescript',
68
- '.js': 'javascript',
69
- '.jsx': 'javascript',
70
- '.py': 'python',
71
- '.java': 'java',
72
- '.c': 'c',
73
- '.cpp': 'cpp',
74
- '.go': 'go',
75
- '.rs': 'rust',
76
- '.rb': 'ruby',
77
- '.php': 'php',
78
- '.css': 'css',
79
- '.html': 'html',
80
- '.json': 'json',
81
- '.xml': 'xml',
82
- '.yaml': 'yaml',
83
- '.yml': 'yaml',
84
- '.md': 'markdown',
85
- '.sh': 'bash',
86
- };
87
-
88
- function detectLanguage(filePath: string): string {
89
- const ext = extname(filePath).toLowerCase();
90
- return languageMap[ext] || 'plaintext';
122
+ interface GitRoot {
123
+ gitRoot: string;
91
124
  }
92
125
 
93
- // Helper function to check if path is in a git repository
94
- async function isGitRepository(path: string): Promise<boolean> {
95
- try {
96
- await execFileAsync('git', ['rev-parse', '--git-dir'], { cwd: path });
97
- return true;
98
- } catch {
99
- return false;
100
- }
126
+ interface GitError {
127
+ error: string;
128
+ code?: string;
101
129
  }
102
130
 
103
- // Helper function to find git root directory
104
- async function findGitRoot(startPath: string): Promise<string | null> {
131
+ // Helper functions
132
+ async function validateAndGetGitRoot(
133
+ requestedPath: string,
134
+ ): Promise<GitRoot | GitError> {
105
135
  try {
106
- const { stdout } = await execFileAsync(
136
+ const { stdout: gitRoot } = await execFileAsync(
107
137
  'git',
108
138
  ['rev-parse', '--show-toplevel'],
109
- { cwd: startPath },
139
+ {
140
+ cwd: requestedPath,
141
+ },
110
142
  );
111
- return stdout.trim();
143
+ return { gitRoot: gitRoot.trim() };
112
144
  } catch {
113
- return null;
114
- }
115
- }
116
-
117
- // Helper to validate git repo and get root
118
- async function validateAndGetGitRoot(
119
- path: string,
120
- ): Promise<{ gitRoot: string } | { error: string; code: string }> {
121
- if (!(await isGitRepository(path))) {
122
- return { error: 'Not a git repository', code: 'NOT_A_GIT_REPO' };
123
- }
124
-
125
- const gitRoot = await findGitRoot(path);
126
- if (!gitRoot) {
127
145
  return {
128
- error: 'Could not find git repository root',
129
- code: 'GIT_ROOT_NOT_FOUND',
146
+ error: 'Not a git repository',
147
+ code: 'NOT_A_GIT_REPO',
130
148
  };
131
149
  }
132
-
133
- return { gitRoot };
134
150
  }
135
151
 
136
- // Git status parsing
137
- function parseGitStatus(porcelainOutput: string): {
152
+ function parseGitStatus(statusOutput: string): {
138
153
  staged: GitFile[];
139
154
  unstaged: GitFile[];
140
155
  untracked: GitFile[];
141
156
  } {
157
+ const lines = statusOutput.trim().split('\n').filter(Boolean);
142
158
  const staged: GitFile[] = [];
143
159
  const unstaged: GitFile[] = [];
144
160
  const untracked: GitFile[] = [];
145
161
 
146
- const lines = porcelainOutput.split('\n').filter((line) => line.trim());
147
-
148
162
  for (const line of lines) {
149
- if (line.length < 4) continue;
150
-
151
- const stagedStatus = line[0];
152
- const unstagedStatus = line[1];
153
- const filePath = line.slice(3);
154
-
155
- // Parse staged files
156
- if (stagedStatus !== ' ' && stagedStatus !== '?') {
157
- let status: GitFile['status'] = 'modified';
158
- if (stagedStatus === 'A') status = 'added';
159
- else if (stagedStatus === 'D') status = 'deleted';
160
- else if (stagedStatus === 'R') status = 'renamed';
161
- else if (stagedStatus === 'M') status = 'modified';
163
+ const x = line[0]; // staged status
164
+ const y = line[1]; // unstaged status
165
+ const path = line.slice(3).trim();
162
166
 
167
+ // Check if file is staged (X is not space or ?)
168
+ if (x !== ' ' && x !== '?') {
163
169
  staged.push({
164
- path: filePath,
165
- status,
170
+ path,
171
+ status: getStatusFromCode(x),
166
172
  staged: true,
167
173
  });
168
174
  }
169
175
 
170
- // Parse unstaged files
171
- // NOTE: A file can appear in both staged and unstaged if it has staged changes
172
- // and additional working directory changes
173
- if (unstagedStatus !== ' ' && unstagedStatus !== '?') {
174
- let status: GitFile['status'] = 'modified';
175
- if (unstagedStatus === 'M') status = 'modified';
176
- else if (unstagedStatus === 'D') status = 'deleted';
177
-
176
+ // Check if file is unstaged (Y is not space)
177
+ if (y !== ' ' && y !== '?') {
178
178
  unstaged.push({
179
- path: filePath,
180
- status,
179
+ path,
180
+ status: getStatusFromCode(y),
181
181
  staged: false,
182
182
  });
183
183
  }
184
184
 
185
- // Parse untracked files
186
- if (stagedStatus === '?' && unstagedStatus === '?') {
185
+ // Check if file is untracked
186
+ if (x === '?' && y === '?') {
187
187
  untracked.push({
188
- path: filePath,
188
+ path,
189
189
  status: 'untracked',
190
190
  staged: false,
191
191
  });
@@ -195,63 +195,54 @@ function parseGitStatus(porcelainOutput: string): {
195
195
  return { staged, unstaged, untracked };
196
196
  }
197
197
 
198
- async function parseNumstat(
199
- numstatOutput: string,
200
- files: GitFile[],
201
- ): Promise<void> {
202
- const lines = numstatOutput.split('\n').filter((line) => line.trim());
203
-
204
- for (const line of lines) {
205
- const parts = line.split('\t');
206
- if (parts.length < 3) continue;
207
-
208
- const insertions = Number.parseInt(parts[0], 10);
209
- const deletions = Number.parseInt(parts[1], 10);
210
- const filePath = parts[2];
211
-
212
- const file = files.find((f) => f.path === filePath);
213
- if (file) {
214
- file.insertions = Number.isNaN(insertions) ? 0 : insertions;
215
- file.deletions = Number.isNaN(deletions) ? 0 : deletions;
216
- }
198
+ function getStatusFromCode(code: string): GitFile['status'] {
199
+ switch (code) {
200
+ case 'M':
201
+ return 'modified';
202
+ case 'A':
203
+ return 'added';
204
+ case 'D':
205
+ return 'deleted';
206
+ case 'R':
207
+ return 'renamed';
208
+ default:
209
+ return 'modified';
217
210
  }
218
211
  }
219
212
 
220
- async function getCurrentBranch(cwd: string): Promise<string> {
213
+ async function getAheadBehind(
214
+ gitRoot: string,
215
+ ): Promise<{ ahead: number; behind: number }> {
221
216
  try {
222
217
  const { stdout } = await execFileAsync(
223
218
  'git',
224
- ['branch', '--show-current'],
225
- { cwd },
219
+ ['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'],
220
+ { cwd: gitRoot },
226
221
  );
227
- return stdout.trim() || 'HEAD';
222
+ const [ahead, behind] = stdout.trim().split(/\s+/).map(Number);
223
+ return { ahead: ahead || 0, behind: behind || 0 };
228
224
  } catch {
229
- return 'HEAD';
225
+ return { ahead: 0, behind: 0 };
230
226
  }
231
227
  }
232
228
 
233
- async function getAheadBehind(
234
- cwd: string,
235
- ): Promise<{ ahead: number; behind: number }> {
229
+ async function getCurrentBranch(gitRoot: string): Promise<string> {
236
230
  try {
237
231
  const { stdout } = await execFileAsync(
238
232
  'git',
239
- ['rev-list', '--left-right', '--count', 'HEAD...@{u}'],
240
- { cwd },
233
+ ['branch', '--show-current'],
234
+ {
235
+ cwd: gitRoot,
236
+ },
241
237
  );
242
- const parts = stdout.trim().split('\t');
243
- return {
244
- ahead: Number.parseInt(parts[0], 10) || 0,
245
- behind: Number.parseInt(parts[1], 10) || 0,
246
- };
238
+ return stdout.trim();
247
239
  } catch {
248
- return { ahead: 0, behind: 0 };
240
+ return 'unknown';
249
241
  }
250
242
  }
251
243
 
252
- // Route handlers
253
244
  export function registerGitRoutes(app: Hono) {
254
- // GET /v1/git/status - Get current git status
245
+ // GET /v1/git/status - Get git status
255
246
  app.get('/v1/git/status', async (c) => {
256
247
  try {
257
248
  const query = gitStatusSchema.parse({
@@ -270,7 +261,7 @@ export function registerGitRoutes(app: Hono) {
270
261
 
271
262
  const { gitRoot } = validation;
272
263
 
273
- // Get git status
264
+ // Get status
274
265
  const { stdout: statusOutput } = await execFileAsync(
275
266
  'git',
276
267
  ['status', '--porcelain=v1'],
@@ -279,67 +270,41 @@ export function registerGitRoutes(app: Hono) {
279
270
 
280
271
  const { staged, unstaged, untracked } = parseGitStatus(statusOutput);
281
272
 
282
- // Get stats for staged files
283
- if (staged.length > 0) {
284
- try {
285
- const { stdout: stagedNumstat } = await execFileAsync(
286
- 'git',
287
- ['diff', '--cached', '--numstat'],
288
- { cwd: gitRoot },
289
- );
290
- await parseNumstat(stagedNumstat, staged);
291
- } catch {
292
- // Ignore numstat errors
293
- }
294
- }
295
-
296
- // Get stats for unstaged files
297
- if (unstaged.length > 0) {
298
- try {
299
- const { stdout: unstagedNumstat } = await execFileAsync(
300
- 'git',
301
- ['diff', '--numstat'],
302
- { cwd: gitRoot },
303
- );
304
- await parseNumstat(unstagedNumstat, unstaged);
305
- } catch {
306
- // Ignore numstat errors
307
- }
308
- }
273
+ // Get ahead/behind counts
274
+ const { ahead, behind } = await getAheadBehind(gitRoot);
309
275
 
310
- // Get branch info
276
+ // Get current branch
311
277
  const branch = await getCurrentBranch(gitRoot);
312
- const { ahead, behind } = await getAheadBehind(gitRoot);
313
278
 
314
- const status: GitStatus = {
315
- branch,
316
- ahead,
317
- behind,
318
- staged,
319
- unstaged,
320
- untracked,
321
- hasChanges:
322
- staged.length > 0 || unstaged.length > 0 || untracked.length > 0,
323
- };
279
+ // Calculate hasChanges
280
+ const hasChanges =
281
+ staged.length > 0 || unstaged.length > 0 || untracked.length > 0;
324
282
 
325
283
  return c.json({
326
284
  status: 'ok',
327
- data: status,
285
+ data: {
286
+ branch,
287
+ ahead,
288
+ behind,
289
+ staged,
290
+ unstaged,
291
+ untracked,
292
+ hasChanges,
293
+ },
328
294
  });
329
295
  } catch (error) {
330
- const errorMessage =
331
- error instanceof Error ? error.message : 'Failed to get git status';
332
296
  return c.json(
333
297
  {
334
298
  status: 'error',
335
- error: errorMessage,
299
+ error:
300
+ error instanceof Error ? error.message : 'Failed to get status',
336
301
  },
337
302
  500,
338
303
  );
339
304
  }
340
305
  });
341
306
 
342
- // GET /v1/git/diff - Get diff for a specific file
307
+ // GET /v1/git/diff - Get file diff
343
308
  app.get('/v1/git/diff', async (c) => {
344
309
  try {
345
310
  const query = gitDiffSchema.parse({
@@ -359,187 +324,105 @@ export function registerGitRoutes(app: Hono) {
359
324
  }
360
325
 
361
326
  const { gitRoot } = validation;
362
- const file = query.file;
363
- const staged = query.staged;
364
327
 
365
- // Check if file is untracked (new file)
366
- const { stdout: statusOutput } = await execFileAsync(
367
- 'git',
368
- ['status', '--porcelain=v1', file],
369
- { cwd: gitRoot },
370
- );
328
+ // Get diff output and stats for the requested file
329
+ const diffArgs = query.staged
330
+ ? ['diff', '--cached', '--', query.file]
331
+ : ['diff', '--', query.file];
332
+ const numstatArgs = query.staged
333
+ ? ['diff', '--cached', '--numstat', '--', query.file]
334
+ : ['diff', '--numstat', '--', query.file];
371
335
 
372
- const isUntracked = statusOutput.trim().startsWith('??');
336
+ const [{ stdout: diffOutput }, { stdout: numstatOutput }] =
337
+ await Promise.all([
338
+ execFileAsync('git', diffArgs, { cwd: gitRoot }),
339
+ execFileAsync('git', numstatArgs, { cwd: gitRoot }),
340
+ ]);
373
341
 
374
- let diffOutput = '';
375
342
  let insertions = 0;
376
343
  let deletions = 0;
377
-
378
- if (isUntracked) {
379
- // For untracked files, show the entire file content as additions
380
- try {
381
- const { readFile } = await import('node:fs/promises');
382
- const { join } = await import('node:path');
383
- const filePath = join(gitRoot, file);
384
- const content = await readFile(filePath, 'utf-8');
385
- const lines = content.split('\n');
386
-
387
- // Create a diff-like output showing all lines as additions
388
- diffOutput = `diff --git a/${file} b/${file}\n`;
389
- diffOutput += `new file mode 100644\n`;
390
- diffOutput += `--- /dev/null\n`;
391
- diffOutput += `+++ b/${file}\n`;
392
- diffOutput += `@@ -0,0 +1,${lines.length} @@\n`;
393
- diffOutput += lines.map((line) => `+${line}`).join('\n');
394
-
395
- insertions = lines.length;
396
- deletions = 0;
397
- } catch (err) {
398
- diffOutput = `Error reading file: ${err instanceof Error ? err.message : 'Unknown error'}`;
399
- }
400
- } else {
401
- // For tracked files, use git diff
402
- const args = ['diff'];
403
- if (staged) {
404
- args.push('--cached');
405
- }
406
- args.push('--', file);
407
-
408
- const { stdout: gitDiff } = await execFileAsync('git', args, {
409
- cwd: gitRoot,
410
- });
411
- diffOutput = gitDiff;
412
-
413
- // Get stats
414
- const numstatArgs = ['diff', '--numstat'];
415
- if (staged) {
416
- numstatArgs.push('--cached');
344
+ let binary = false;
345
+
346
+ const numstatLine = numstatOutput.trim().split('\n').find(Boolean);
347
+ if (numstatLine) {
348
+ const [rawInsertions, rawDeletions] = numstatLine.split('\t');
349
+ if (rawInsertions === '-' || rawDeletions === '-') {
350
+ binary = true;
351
+ } else {
352
+ insertions = Number.parseInt(rawInsertions, 10) || 0;
353
+ deletions = Number.parseInt(rawDeletions, 10) || 0;
417
354
  }
418
- numstatArgs.push('--', file);
355
+ }
419
356
 
420
- try {
421
- const { stdout: numstatOutput } = await execFileAsync(
422
- 'git',
423
- numstatArgs,
424
- { cwd: gitRoot },
425
- );
426
- const parts = numstatOutput.trim().split('\t');
427
- if (parts.length >= 2) {
428
- insertions = Number.parseInt(parts[0], 10) || 0;
429
- deletions = Number.parseInt(parts[1], 10) || 0;
430
- }
431
- } catch {
432
- // Ignore numstat errors
357
+ const diffText = diffOutput ?? '';
358
+ if (!binary) {
359
+ const summary = summarizeDiff(diffText);
360
+ binary = summary.binary;
361
+ if (insertions === 0 && deletions === 0) {
362
+ insertions = summary.insertions;
363
+ deletions = summary.deletions;
433
364
  }
434
365
  }
435
366
 
436
- // Check if binary
437
- const isBinary = diffOutput.includes('Binary files');
367
+ const language = inferLanguage(query.file);
438
368
 
439
369
  return c.json({
440
370
  status: 'ok',
441
371
  data: {
442
- file,
443
- diff: diffOutput,
372
+ file: query.file,
373
+ diff: diffText,
444
374
  insertions,
445
375
  deletions,
446
- language: detectLanguage(file),
447
- binary: isBinary,
376
+ language,
377
+ binary,
448
378
  },
449
379
  });
450
380
  } catch (error) {
451
- const errorMessage =
452
- error instanceof Error ? error.message : 'Failed to get git diff';
453
- return c.json({ status: 'error', error: errorMessage }, 500);
381
+ return c.json(
382
+ {
383
+ status: 'error',
384
+ error: error instanceof Error ? error.message : 'Failed to get diff',
385
+ },
386
+ 500,
387
+ );
454
388
  }
455
389
  });
456
390
 
457
- // POST /v1/git/generate-commit-message - Generate AI commit message
458
- app.post('/v1/git/generate-commit-message', async (c) => {
391
+ // POST /v1/git/stage - Stage files
392
+ app.post('/v1/git/stage', async (c) => {
459
393
  try {
460
394
  const body = await c.req.json();
461
- const { project } = gitGenerateCommitMessageSchema.parse(body);
395
+ const { files, project } = gitStageSchema.parse(body);
396
+
462
397
  const requestedPath = project || process.cwd();
463
- const gitRoot = await findGitRoot(requestedPath);
464
398
 
465
- // Check if there are staged changes
466
- const { stdout: statusOutput } = await execFileAsync(
467
- 'git',
468
- ['diff', '--cached', '--name-only'],
469
- { cwd: gitRoot },
470
- );
399
+ const validation = await validateAndGetGitRoot(requestedPath);
400
+ if ('error' in validation) {
401
+ return c.json(
402
+ { status: 'error', error: validation.error, code: validation.code },
403
+ 400,
404
+ );
405
+ }
406
+
407
+ const { gitRoot } = validation;
471
408
 
472
- if (!statusOutput.trim()) {
409
+ if (files.length === 0) {
473
410
  return c.json(
474
411
  {
475
412
  status: 'error',
476
- error: 'No staged changes to generate commit message for',
413
+ error: 'No files specified',
477
414
  },
478
415
  400,
479
416
  );
480
417
  }
481
418
 
482
- // Get the full staged diff
483
- const { stdout: stagedDiff } = await execFileAsync(
484
- 'git',
485
- ['diff', '--cached'],
486
- { cwd: gitRoot },
487
- );
488
-
489
- // Limit diff size to avoid token limits (keep first 8000 chars)
490
- const limitedDiff =
491
- stagedDiff.length > 8000
492
- ? `${stagedDiff.slice(0, 8000)}\n\n... (diff truncated due to size)`
493
- : stagedDiff;
494
-
495
- // Generate commit message using AI
496
- const prompt = `Based on the following git diff of staged changes, generate a clear and concise commit message following these guidelines:
497
-
498
- 1. Use conventional commit format: <type>(<scope>): <subject>
499
- 2. Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
500
- 3. Keep the subject line under 72 characters
501
- 4. Use imperative mood ("add" not "added" or "adds")
502
- 5. Don't end the subject line with a period
503
- 6. If there are multiple significant changes, focus on the most important one
504
- 7. Be specific about what changed
505
-
506
- Git diff of staged changes:
507
- \`\`\`diff
508
- ${limitedDiff}
509
- \`\`\`
510
-
511
- Generate only the commit message, nothing else.`;
512
-
513
- // Load config to get default provider/model
514
- const cfg = await loadConfig(gitRoot);
515
-
516
- // Resolve the model using SDK - this doesn't create any session
517
- const model = await resolveModel(
518
- cfg.defaults.provider,
519
- cfg.defaults.model,
520
- );
521
-
522
- // Generate text directly - no session involved
523
- const result = await generateText({
524
- model: model as Parameters<typeof generateText>[0]['model'],
525
- prompt,
526
- });
527
-
528
- // Extract and clean up the message
529
- let message = result.text || '';
530
-
531
- // Clean up the message (remove markdown code blocks if present)
532
- if (typeof message === 'string') {
533
- message = message
534
- .replace(/^```(?:text|commit)?\n?/gm, '')
535
- .replace(/```$/gm, '')
536
- .trim();
537
- }
419
+ // Stage files
420
+ await execFileAsync('git', ['add', ...files], { cwd: gitRoot });
538
421
 
539
422
  return c.json({
540
423
  status: 'ok',
541
424
  data: {
542
- message,
425
+ staged: files,
543
426
  },
544
427
  });
545
428
  } catch (error) {
@@ -547,32 +430,50 @@ Generate only the commit message, nothing else.`;
547
430
  {
548
431
  status: 'error',
549
432
  error:
550
- error instanceof Error
551
- ? error.message
552
- : 'Failed to generate commit message',
433
+ error instanceof Error ? error.message : 'Failed to stage files',
553
434
  },
554
435
  500,
555
436
  );
556
437
  }
557
438
  });
558
439
 
559
- // POST /v1/git/stage - Stage files
560
- app.post('/v1/git/stage', async (c) => {
440
+ // POST /v1/git/unstage - Unstage files
441
+ app.post('/v1/git/unstage', async (c) => {
561
442
  try {
562
443
  const body = await c.req.json();
563
- const { project, files } = gitStageSchema.parse(body);
444
+ const { files, project } = gitUnstageSchema.parse(body);
564
445
 
565
446
  const requestedPath = project || process.cwd();
566
- const gitRoot = await findGitRoot(requestedPath);
567
447
 
568
- // Stage files - git add handles paths relative to git root
569
- await execFileAsync('git', ['add', ...files], { cwd: gitRoot });
448
+ const validation = await validateAndGetGitRoot(requestedPath);
449
+ if ('error' in validation) {
450
+ return c.json(
451
+ { status: 'error', error: validation.error, code: validation.code },
452
+ 400,
453
+ );
454
+ }
455
+
456
+ const { gitRoot } = validation;
457
+
458
+ if (files.length === 0) {
459
+ return c.json(
460
+ {
461
+ status: 'error',
462
+ error: 'No files specified',
463
+ },
464
+ 400,
465
+ );
466
+ }
467
+
468
+ // Unstage files
469
+ await execFileAsync('git', ['reset', 'HEAD', '--', ...files], {
470
+ cwd: gitRoot,
471
+ });
570
472
 
571
473
  return c.json({
572
474
  status: 'ok',
573
475
  data: {
574
- staged: files,
575
- failed: [],
476
+ unstaged: files,
576
477
  },
577
478
  });
578
479
  } catch (error) {
@@ -580,129 +481,161 @@ Generate only the commit message, nothing else.`;
580
481
  {
581
482
  status: 'error',
582
483
  error:
583
- error instanceof Error ? error.message : 'Failed to stage files',
484
+ error instanceof Error ? error.message : 'Failed to unstage files',
584
485
  },
585
486
  500,
586
487
  );
587
488
  }
588
489
  });
589
490
 
590
- // POST /v1/git/unstage - Unstage files
591
- app.post('/v1/git/unstage', async (c) => {
491
+ // POST /v1/git/commit - Commit staged changes
492
+ app.post('/v1/git/commit', async (c) => {
592
493
  try {
593
494
  const body = await c.req.json();
594
- const { project, files } = gitUnstageSchema.parse(body);
495
+ const { message, project } = gitCommitSchema.parse(body);
595
496
 
596
497
  const requestedPath = project || process.cwd();
597
- const gitRoot = await findGitRoot(requestedPath);
598
498
 
599
- // Try modern git restore first, fallback to reset
600
- try {
601
- await execFileAsync('git', ['restore', '--staged', ...files], {
602
- cwd: gitRoot,
603
- });
604
- } catch {
605
- // Fallback to older git reset HEAD
606
- await execFileAsync('git', ['reset', 'HEAD', ...files], {
607
- cwd: gitRoot,
608
- });
499
+ const validation = await validateAndGetGitRoot(requestedPath);
500
+ if ('error' in validation) {
501
+ return c.json(
502
+ { status: 'error', error: validation.error, code: validation.code },
503
+ 400,
504
+ );
609
505
  }
610
506
 
507
+ const { gitRoot } = validation;
508
+
509
+ // Commit changes
510
+ const { stdout } = await execFileAsync('git', ['commit', '-m', message], {
511
+ cwd: gitRoot,
512
+ });
513
+
611
514
  return c.json({
612
515
  status: 'ok',
613
516
  data: {
614
- unstaged: files,
615
- failed: [],
517
+ message: stdout.trim(),
616
518
  },
617
519
  });
618
520
  } catch (error) {
619
521
  return c.json(
620
522
  {
621
523
  status: 'error',
622
- error:
623
- error instanceof Error ? error.message : 'Failed to unstage files',
524
+ error: error instanceof Error ? error.message : 'Failed to commit',
624
525
  },
625
526
  500,
626
527
  );
627
528
  }
628
529
  });
629
530
 
630
- // POST /v1/git/commit - Commit staged changes
631
- app.post('/v1/git/commit', async (c) => {
531
+ // POST /v1/git/generate-commit-message - Generate commit message from staged changes
532
+ app.post('/v1/git/generate-commit-message', async (c) => {
632
533
  try {
633
534
  const body = await c.req.json();
634
- const { project, message } = gitCommitSchema.parse(body);
535
+ const { project } = gitGenerateCommitMessageSchema.parse(body);
635
536
 
636
537
  const requestedPath = project || process.cwd();
637
- const gitRoot = await findGitRoot(requestedPath);
638
538
 
639
- // Check if there are staged changes
640
- const { stdout: statusOutput } = await execFileAsync(
539
+ const validation = await validateAndGetGitRoot(requestedPath);
540
+ if ('error' in validation) {
541
+ return c.json(
542
+ { status: 'error', error: validation.error, code: validation.code },
543
+ 400,
544
+ );
545
+ }
546
+
547
+ const { gitRoot } = validation;
548
+
549
+ // Get staged diff
550
+ const { stdout: diff } = await execFileAsync(
641
551
  'git',
642
- ['diff', '--cached', '--name-only'],
643
- { cwd: gitRoot },
552
+ ['diff', '--cached'],
553
+ {
554
+ cwd: gitRoot,
555
+ },
644
556
  );
645
557
 
646
- if (!statusOutput.trim()) {
558
+ if (!diff.trim()) {
647
559
  return c.json(
648
560
  {
649
561
  status: 'error',
650
- error: 'No staged changes to commit',
562
+ error: 'No staged changes to generate message from',
651
563
  },
652
564
  400,
653
565
  );
654
566
  }
655
567
 
656
- // Commit
657
- const { stdout: commitOutput } = await execFileAsync(
568
+ // Get file list for context
569
+ const { stdout: statusOutput } = await execFileAsync(
658
570
  'git',
659
- ['commit', '-m', message],
571
+ ['status', '--porcelain=v1'],
660
572
  { cwd: gitRoot },
661
573
  );
574
+ const { staged } = parseGitStatus(statusOutput);
575
+ const fileList = staged.map((f) => `${f.status}: ${f.path}`).join('\n');
662
576
 
663
- // Parse commit output for hash
664
- const hashMatch = commitOutput.match(/[\w/]+ ([a-f0-9]+)\]/);
665
- const hash = hashMatch ? hashMatch[1] : '';
577
+ // Load config to get provider settings
578
+ const config = await loadConfig();
666
579
 
667
- // Get commit stats
668
- const { stdout: statOutput } = await execFileAsync(
669
- 'git',
670
- ['show', '--stat', '--format=', 'HEAD'],
671
- { cwd: gitRoot },
580
+ // Use a simple model for quick commit message generation
581
+ const provider = config.defaults?.provider || 'anthropic';
582
+ const model = await resolveModel(
583
+ provider as ProviderId,
584
+ config.defaults?.model,
585
+ undefined,
672
586
  );
673
587
 
674
- const filesChangedMatch = statOutput.match(/(\d+) files? changed/);
675
- const insertionsMatch = statOutput.match(/(\d+) insertions?/);
676
- const deletionsMatch = statOutput.match(/(\d+) deletions?/);
588
+ // Generate commit message using AI
589
+ const prompt = `Generate a concise, conventional commit message for these git changes.
590
+
591
+ Staged files:
592
+ ${fileList}
593
+
594
+ Diff (first 2000 chars):
595
+ ${diff.slice(0, 2000)}
596
+
597
+ Guidelines:
598
+ - Use conventional commits format (feat:, fix:, docs:, etc.)
599
+ - Keep the first line under 72 characters
600
+ - Be specific but concise
601
+ - Focus on what changed and why, not how
602
+ - Do not include any markdown formatting or code blocks
603
+ - Return ONLY the commit message text, nothing else
604
+
605
+ Commit message:`;
606
+
607
+ const { text } = await generateText({
608
+ provider: provider as ProviderId,
609
+ model: model.id,
610
+ systemPrompt:
611
+ 'You are a helpful assistant that generates git commit messages.',
612
+ prompt,
613
+ maxTokens: 200,
614
+ });
615
+
616
+ const message = text.trim();
677
617
 
678
618
  return c.json({
679
619
  status: 'ok',
680
620
  data: {
681
- hash,
682
- message: message.split('\n')[0],
683
- filesChanged: filesChangedMatch
684
- ? Number.parseInt(filesChangedMatch[1], 10)
685
- : 0,
686
- insertions: insertionsMatch
687
- ? Number.parseInt(insertionsMatch[1], 10)
688
- : 0,
689
- deletions: deletionsMatch
690
- ? Number.parseInt(deletionsMatch[1], 10)
691
- : 0,
621
+ message,
692
622
  },
693
623
  });
694
624
  } catch (error) {
695
625
  return c.json(
696
626
  {
697
627
  status: 'error',
698
- error: error instanceof Error ? error.message : 'Failed to commit',
628
+ error:
629
+ error instanceof Error
630
+ ? error.message
631
+ : 'Failed to generate commit message',
699
632
  },
700
633
  500,
701
634
  );
702
635
  }
703
636
  });
704
637
 
705
- // GET /v1/git/branch - Get branch information
638
+ // GET /v1/git/branch - Get branch info
706
639
  app.get('/v1/git/branch', async (c) => {
707
640
  try {
708
641
  const query = gitStatusSchema.parse({
@@ -721,56 +654,211 @@ Generate only the commit message, nothing else.`;
721
654
 
722
655
  const { gitRoot } = validation;
723
656
 
657
+ // Get current branch
724
658
  const branch = await getCurrentBranch(gitRoot);
659
+
660
+ // Get ahead/behind counts
725
661
  const { ahead, behind } = await getAheadBehind(gitRoot);
726
662
 
727
- // Get all branches
728
- let allBranches: string[] = [];
663
+ // Get remote info
729
664
  try {
730
- const { stdout: branchesOutput } = await execFileAsync(
731
- 'git',
732
- ['branch', '--list'],
733
- { cwd: gitRoot },
665
+ const { stdout: remotes } = await execFileAsync('git', ['remote'], {
666
+ cwd: gitRoot,
667
+ });
668
+ const remoteList = remotes.trim().split('\n').filter(Boolean);
669
+
670
+ return c.json({
671
+ status: 'ok',
672
+ data: {
673
+ branch,
674
+ ahead,
675
+ behind,
676
+ remotes: remoteList,
677
+ },
678
+ });
679
+ } catch {
680
+ return c.json({
681
+ status: 'ok',
682
+ data: {
683
+ branch,
684
+ ahead,
685
+ behind,
686
+ remotes: [],
687
+ },
688
+ });
689
+ }
690
+ } catch (error) {
691
+ return c.json(
692
+ {
693
+ status: 'error',
694
+ error:
695
+ error instanceof Error
696
+ ? error.message
697
+ : 'Failed to get branch info',
698
+ },
699
+ 500,
700
+ );
701
+ }
702
+ });
703
+
704
+ // POST /v1/git/push - Push commits to remote
705
+ app.post('/v1/git/push', async (c) => {
706
+ try {
707
+ // Parse JSON body, defaulting to empty object if parsing fails
708
+ let body = {};
709
+ try {
710
+ body = await c.req.json();
711
+ } catch (jsonError) {
712
+ // If JSON parsing fails (e.g., empty body), use empty object
713
+ console.warn(
714
+ 'Failed to parse JSON body for git push, using empty object:',
715
+ jsonError,
734
716
  );
735
- allBranches = branchesOutput
736
- .split('\n')
737
- .map((line) => line.replace(/^\*?\s*/, '').trim())
738
- .filter(Boolean);
717
+ }
718
+
719
+ const { project } = gitPushSchema.parse(body);
720
+
721
+ const requestedPath = project || process.cwd();
722
+
723
+ const validation = await validateAndGetGitRoot(requestedPath);
724
+ if ('error' in validation) {
725
+ return c.json(
726
+ { status: 'error', error: validation.error, code: validation.code },
727
+ 400,
728
+ );
729
+ }
730
+
731
+ const { gitRoot } = validation;
732
+
733
+ // Check if there's a remote configured
734
+ try {
735
+ const { stdout: remotes } = await execFileAsync('git', ['remote'], {
736
+ cwd: gitRoot,
737
+ });
738
+ if (!remotes.trim()) {
739
+ return c.json(
740
+ { status: 'error', error: 'No remote repository configured' },
741
+ 400,
742
+ );
743
+ }
739
744
  } catch {
740
- allBranches = [branch];
745
+ return c.json(
746
+ { status: 'error', error: 'No remote repository configured' },
747
+ 400,
748
+ );
741
749
  }
742
750
 
743
- // Get upstream branch
744
- let upstream = '';
751
+ // Get current branch and check for upstream
752
+ const branch = await getCurrentBranch(gitRoot);
753
+ let hasUpstream = false;
745
754
  try {
746
- const { stdout: upstreamOutput } = await execFileAsync(
755
+ await execFileAsync(
747
756
  'git',
748
- ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
749
- { cwd: gitRoot },
757
+ ['rev-parse', '--abbrev-ref', '@{upstream}'],
758
+ {
759
+ cwd: gitRoot,
760
+ },
750
761
  );
751
- upstream = upstreamOutput.trim();
762
+ hasUpstream = true;
752
763
  } catch {
753
- // No upstream configured
764
+ // No upstream set
754
765
  }
755
766
 
756
- return c.json({
757
- status: 'ok',
758
- data: {
759
- current: branch,
760
- upstream,
761
- ahead,
762
- behind,
763
- all: allBranches,
764
- },
765
- });
767
+ // Push to remote - with proper error handling
768
+ try {
769
+ let pushOutput: string;
770
+ let pushError: string;
771
+
772
+ if (hasUpstream) {
773
+ // Push to existing upstream
774
+ const result = await execFileAsync('git', ['push'], { cwd: gitRoot });
775
+ pushOutput = result.stdout;
776
+ pushError = result.stderr;
777
+ } else {
778
+ // Set upstream and push
779
+ const result = await execFileAsync(
780
+ 'git',
781
+ ['push', '--set-upstream', 'origin', branch],
782
+ { cwd: gitRoot },
783
+ );
784
+ pushOutput = result.stdout;
785
+ pushError = result.stderr;
786
+ }
787
+
788
+ return c.json({
789
+ status: 'ok',
790
+ data: {
791
+ output: pushOutput.trim() || pushError.trim(),
792
+ },
793
+ });
794
+ } catch (pushErr: unknown) {
795
+ // Handle specific git push errors
796
+ const error = pushErr as {
797
+ message?: string;
798
+ stderr?: string;
799
+ code?: number;
800
+ };
801
+ const errorMessage = error.stderr || error.message || 'Failed to push';
802
+
803
+ // Check for common error patterns
804
+ if (
805
+ errorMessage.includes('failed to push') ||
806
+ errorMessage.includes('rejected')
807
+ ) {
808
+ return c.json(
809
+ {
810
+ status: 'error',
811
+ error: 'Push rejected. Try pulling changes first with: git pull',
812
+ details: errorMessage,
813
+ },
814
+ 400,
815
+ );
816
+ }
817
+
818
+ if (
819
+ errorMessage.includes('Permission denied') ||
820
+ errorMessage.includes('authentication') ||
821
+ errorMessage.includes('could not read')
822
+ ) {
823
+ return c.json(
824
+ {
825
+ status: 'error',
826
+ error: 'Authentication failed. Check your git credentials',
827
+ details: errorMessage,
828
+ },
829
+ 401,
830
+ );
831
+ }
832
+
833
+ if (
834
+ errorMessage.includes('Could not resolve host') ||
835
+ errorMessage.includes('network')
836
+ ) {
837
+ return c.json(
838
+ {
839
+ status: 'error',
840
+ error: 'Network error. Check your internet connection',
841
+ details: errorMessage,
842
+ },
843
+ 503,
844
+ );
845
+ }
846
+
847
+ // Generic push error
848
+ return c.json(
849
+ {
850
+ status: 'error',
851
+ error: 'Failed to push commits',
852
+ details: errorMessage,
853
+ },
854
+ 500,
855
+ );
856
+ }
766
857
  } catch (error) {
767
858
  return c.json(
768
859
  {
769
860
  status: 'error',
770
- error:
771
- error instanceof Error
772
- ? error.message
773
- : 'Failed to get branch info',
861
+ error: error instanceof Error ? error.message : 'Failed to push',
774
862
  },
775
863
  500,
776
864
  );