@agi-cli/server 0.1.81 → 0.1.83

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 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
- }