@andrebuzeli/git-mcp 15.2.1 → 15.2.3
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 +5 -7
- package/src/tools/git-workflow.js +13 -0
- package/src/utils/gitAdapter.js +584 -1087
package/src/utils/gitAdapter.js
CHANGED
|
@@ -1,1087 +1,584 @@
|
|
|
1
|
-
import
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
if (
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
// ============ HISTORY ============
|
|
586
|
-
async log(dir, { ref = "HEAD", maxCount = 50 } = {}) {
|
|
587
|
-
const commits = await git.log({ fs, dir, ref, depth: maxCount });
|
|
588
|
-
return commits.map(c => ({
|
|
589
|
-
sha: c.oid,
|
|
590
|
-
message: c.commit.message,
|
|
591
|
-
author: c.commit.author,
|
|
592
|
-
date: new Date(c.commit.author.timestamp * 1000).toISOString()
|
|
593
|
-
}));
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// ============ SYNC (FETCH/PULL) ============
|
|
597
|
-
async fetch(dir, remote, branch) {
|
|
598
|
-
const remotes = await git.listRemotes({ fs, dir });
|
|
599
|
-
const info = remotes.find(r => r.remote === remote);
|
|
600
|
-
if (!info) {
|
|
601
|
-
throw createError("REMOTE_NOT_FOUND", {
|
|
602
|
-
message: `Remote '${remote}' não encontrado`,
|
|
603
|
-
availableRemotes: remotes.map(r => r.remote)
|
|
604
|
-
});
|
|
605
|
-
}
|
|
606
|
-
const onAuth = this.getAuth(info.url);
|
|
607
|
-
try {
|
|
608
|
-
await withRetry(() => git.fetch({ fs, http, dir, remote, ref: branch, singleBranch: true, onAuth }));
|
|
609
|
-
} catch (e) {
|
|
610
|
-
throw mapExternalError(e, { type: "fetch", remote, branch, provider: info.url.includes("github") ? "github" : "gitea" });
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
async pull(dir, remote, branch) {
|
|
615
|
-
const remotes = await git.listRemotes({ fs, dir });
|
|
616
|
-
const info = remotes.find(r => r.remote === remote);
|
|
617
|
-
if (!info) {
|
|
618
|
-
throw createError("REMOTE_NOT_FOUND", {
|
|
619
|
-
message: `Remote '${remote}' não encontrado`,
|
|
620
|
-
availableRemotes: remotes.map(r => r.remote)
|
|
621
|
-
});
|
|
622
|
-
}
|
|
623
|
-
const onAuth = this.getAuth(info.url);
|
|
624
|
-
const author = await this.getAuthor(dir);
|
|
625
|
-
try {
|
|
626
|
-
await withRetry(async () => git.pull({ fs, http, dir, remote, ref: branch, singleBranch: true, onAuth, author }));
|
|
627
|
-
} catch (e) {
|
|
628
|
-
if (e.message?.includes("conflict")) {
|
|
629
|
-
throw createError("MERGE_CONFLICT", { message: e.message, remote, branch });
|
|
630
|
-
}
|
|
631
|
-
throw mapExternalError(e, { type: "pull", remote, branch, provider: info.url.includes("github") ? "github" : "gitea" });
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
// ============ RESET ============
|
|
636
|
-
async _resolveRef(dir, ref) {
|
|
637
|
-
// Resolve refs como HEAD~1, HEAD~2, etc.
|
|
638
|
-
const match = ref.match(/^(HEAD|[a-zA-Z0-9_\-\/]+)~(\d+)$/);
|
|
639
|
-
if (match) {
|
|
640
|
-
const baseRef = match[1];
|
|
641
|
-
const steps = parseInt(match[2], 10);
|
|
642
|
-
const commits = await git.log({ fs, dir, ref: baseRef, depth: steps + 1 });
|
|
643
|
-
if (commits.length > steps) return commits[steps].oid;
|
|
644
|
-
throw createError("INSUFFICIENT_HISTORY", {
|
|
645
|
-
message: `Não foi possível resolver ${ref}`,
|
|
646
|
-
requestedSteps: steps,
|
|
647
|
-
availableCommits: commits.length,
|
|
648
|
-
suggestion: `Histórico tem apenas ${commits.length} commits. Use HEAD~${commits.length - 1} no máximo.`
|
|
649
|
-
});
|
|
650
|
-
}
|
|
651
|
-
// Refs normais
|
|
652
|
-
try {
|
|
653
|
-
return await git.resolveRef({ fs, dir, ref });
|
|
654
|
-
} catch (e) {
|
|
655
|
-
const branches = await git.listBranches({ fs, dir }).catch(() => []);
|
|
656
|
-
const tags = await git.listTags({ fs, dir }).catch(() => []);
|
|
657
|
-
throw createError("REF_NOT_FOUND", {
|
|
658
|
-
message: `Ref '${ref}' não encontrada`,
|
|
659
|
-
ref,
|
|
660
|
-
availableBranches: branches.slice(0, 10),
|
|
661
|
-
availableTags: tags.slice(0, 10)
|
|
662
|
-
});
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
async resetSoft(dir, ref) {
|
|
667
|
-
const oid = await this._resolveRef(dir, ref);
|
|
668
|
-
const headPath = path.join(dir, ".git", "HEAD");
|
|
669
|
-
const headContent = fs.readFileSync(headPath, "utf8").trim();
|
|
670
|
-
if (headContent.startsWith("ref:")) {
|
|
671
|
-
const branchRef = headContent.replace("ref: ", "");
|
|
672
|
-
const branchPath = path.join(dir, ".git", branchRef);
|
|
673
|
-
fs.writeFileSync(branchPath, oid + "\n");
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
async resetMixed(dir, ref) {
|
|
678
|
-
await this.resetSoft(dir, ref);
|
|
679
|
-
// Reset index to match ref
|
|
680
|
-
const oid = await this._resolveRef(dir, ref);
|
|
681
|
-
await git.checkout({ fs, dir, ref: oid, noUpdateHead: true });
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
async resetHard(dir, ref) {
|
|
685
|
-
const oid = await this._resolveRef(dir, ref);
|
|
686
|
-
await git.checkout({ fs, dir, ref: oid, force: true });
|
|
687
|
-
await this.resetSoft(dir, ref);
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// ============ GITIGNORE ============
|
|
691
|
-
async listGitignore(dir) {
|
|
692
|
-
const ignorePath = path.join(dir, ".gitignore");
|
|
693
|
-
if (!fs.existsSync(ignorePath)) return [];
|
|
694
|
-
return fs.readFileSync(ignorePath, "utf8").split("\n").filter(l => l.trim() && !l.startsWith("#"));
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
async createGitignore(dir, patterns = []) {
|
|
698
|
-
const ignorePath = path.join(dir, ".gitignore");
|
|
699
|
-
fs.writeFileSync(ignorePath, patterns.join("\n") + "\n");
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
async addToGitignore(dir, patterns = []) {
|
|
703
|
-
const ignorePath = path.join(dir, ".gitignore");
|
|
704
|
-
const existing = fs.existsSync(ignorePath) ? fs.readFileSync(ignorePath, "utf8") : "";
|
|
705
|
-
const newContent = existing.trimEnd() + "\n" + patterns.join("\n") + "\n";
|
|
706
|
-
fs.writeFileSync(ignorePath, newContent);
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
async removeFromGitignore(dir, patterns = []) {
|
|
710
|
-
const ignorePath = path.join(dir, ".gitignore");
|
|
711
|
-
if (!fs.existsSync(ignorePath)) return;
|
|
712
|
-
let lines = fs.readFileSync(ignorePath, "utf8").split("\n");
|
|
713
|
-
lines = lines.filter(l => !patterns.includes(l.trim()));
|
|
714
|
-
fs.writeFileSync(ignorePath, lines.join("\n"));
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
// ============ REMOTES ============
|
|
718
|
-
async listRemotes(dir) {
|
|
719
|
-
return await git.listRemotes({ fs, dir });
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
// ============ MERGE ============
|
|
723
|
-
async merge(dir, branch, options = {}) {
|
|
724
|
-
const { message, noCommit, squash } = options;
|
|
725
|
-
const author = await this.getAuthor(dir);
|
|
726
|
-
|
|
727
|
-
try {
|
|
728
|
-
// Resolve the branch to merge
|
|
729
|
-
const theirOid = await git.resolveRef({ fs, dir, ref: branch });
|
|
730
|
-
const ourOid = await git.resolveRef({ fs, dir, ref: "HEAD" });
|
|
731
|
-
|
|
732
|
-
// Find merge base
|
|
733
|
-
const bases = await git.findMergeBase({ fs, dir, oids: [ourOid, theirOid] });
|
|
734
|
-
if (bases.length === 0) {
|
|
735
|
-
throw createError("MERGE_NO_BASE", {
|
|
736
|
-
message: "Não foi possível encontrar ancestral comum para merge"
|
|
737
|
-
});
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// Check if fast-forward is possible
|
|
741
|
-
if (bases[0] === ourOid) {
|
|
742
|
-
// Fast-forward merge
|
|
743
|
-
await git.checkout({ fs, dir, ref: theirOid, force: true });
|
|
744
|
-
const currentBranch = await this.getCurrentBranch(dir);
|
|
745
|
-
if (currentBranch !== "HEAD") {
|
|
746
|
-
const branchPath = path.join(dir, ".git", "refs", "heads", currentBranch);
|
|
747
|
-
fs.mkdirSync(path.dirname(branchPath), { recursive: true });
|
|
748
|
-
fs.writeFileSync(branchPath, theirOid + "\n");
|
|
749
|
-
}
|
|
750
|
-
return { fastForward: true, sha: theirOid };
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
// Regular merge - we need to do a three-way merge
|
|
754
|
-
// For now, use a simplified approach that handles non-conflicting cases
|
|
755
|
-
const result = await git.merge({
|
|
756
|
-
fs,
|
|
757
|
-
dir,
|
|
758
|
-
ours: ourOid,
|
|
759
|
-
theirs: theirOid,
|
|
760
|
-
author,
|
|
761
|
-
message: message || `Merge branch '${branch}'`
|
|
762
|
-
}).catch(async (e) => {
|
|
763
|
-
// Check for conflicts
|
|
764
|
-
if (e.message?.includes("conflict")) {
|
|
765
|
-
return {
|
|
766
|
-
conflicts: true,
|
|
767
|
-
message: e.message,
|
|
768
|
-
files: await this._detectConflicts(dir)
|
|
769
|
-
};
|
|
770
|
-
}
|
|
771
|
-
throw e;
|
|
772
|
-
});
|
|
773
|
-
|
|
774
|
-
if (result.conflicts) {
|
|
775
|
-
return {
|
|
776
|
-
merged: false,
|
|
777
|
-
conflicts: result.files || [],
|
|
778
|
-
message: "Merge com conflitos. Resolva manualmente e faça commit."
|
|
779
|
-
};
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
return {
|
|
783
|
-
merged: true,
|
|
784
|
-
sha: result?.oid || result,
|
|
785
|
-
fastForward: false
|
|
786
|
-
};
|
|
787
|
-
} catch (e) {
|
|
788
|
-
if (e.code === "NotFoundError" || e.message?.includes("Could not find")) {
|
|
789
|
-
throw createError("BRANCH_NOT_FOUND", {
|
|
790
|
-
message: `Branch '${branch}' não encontrada`,
|
|
791
|
-
branch
|
|
792
|
-
});
|
|
793
|
-
}
|
|
794
|
-
throw e;
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
async _detectConflicts(dir) {
|
|
799
|
-
const matrix = await git.statusMatrix({ fs, dir });
|
|
800
|
-
const conflicts = [];
|
|
801
|
-
for (const row of matrix) {
|
|
802
|
-
const [file, head, workdir, stage] = row;
|
|
803
|
-
// Conflito: diferentes estados
|
|
804
|
-
if (head !== workdir || workdir !== stage) {
|
|
805
|
-
conflicts.push(file);
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
return conflicts;
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
async getMergeStatus(dir) {
|
|
812
|
-
const mergePath = path.join(dir, ".git", "MERGE_HEAD");
|
|
813
|
-
const isMerging = fs.existsSync(mergePath);
|
|
814
|
-
|
|
815
|
-
if (!isMerging) {
|
|
816
|
-
return { merging: false };
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
const mergeHead = fs.readFileSync(mergePath, "utf8").trim();
|
|
820
|
-
return {
|
|
821
|
-
merging: true,
|
|
822
|
-
mergeHead,
|
|
823
|
-
conflicts: await this._detectConflicts(dir)
|
|
824
|
-
};
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
async abortMerge(dir) {
|
|
828
|
-
const mergePath = path.join(dir, ".git", "MERGE_HEAD");
|
|
829
|
-
const mergeMsg = path.join(dir, ".git", "MERGE_MSG");
|
|
830
|
-
|
|
831
|
-
if (!fs.existsSync(mergePath)) {
|
|
832
|
-
throw createError("NO_MERGE_IN_PROGRESS", {
|
|
833
|
-
message: "Nenhum merge em andamento para abortar"
|
|
834
|
-
});
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
// Reset to HEAD
|
|
838
|
-
await this.resetHard(dir, "HEAD");
|
|
839
|
-
|
|
840
|
-
// Remove merge files
|
|
841
|
-
if (fs.existsSync(mergePath)) fs.unlinkSync(mergePath);
|
|
842
|
-
if (fs.existsSync(mergeMsg)) fs.unlinkSync(mergeMsg);
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
// ============ DIFF ============
|
|
846
|
-
async diff(dir, options = {}) {
|
|
847
|
-
const { file, context = 3 } = options;
|
|
848
|
-
const matrix = await git.statusMatrix({ fs, dir });
|
|
849
|
-
const changes = [];
|
|
850
|
-
|
|
851
|
-
for (const row of matrix) {
|
|
852
|
-
const [filepath, head, workdir, stage] = row;
|
|
853
|
-
|
|
854
|
-
// Skip if file filter is set and doesn't match
|
|
855
|
-
if (file && filepath !== file) continue;
|
|
856
|
-
|
|
857
|
-
// Skip unchanged files
|
|
858
|
-
if (head === workdir && workdir === stage) continue;
|
|
859
|
-
|
|
860
|
-
const change = {
|
|
861
|
-
path: filepath,
|
|
862
|
-
status: this._getFileStatus(head, workdir, stage)
|
|
863
|
-
};
|
|
864
|
-
|
|
865
|
-
// Get actual diff content for modified files
|
|
866
|
-
if (change.status === "modified" || change.status === "staged-modified") {
|
|
867
|
-
try {
|
|
868
|
-
const oldContent = await this._getFileAtRef(dir, filepath, "HEAD");
|
|
869
|
-
const newContent = fs.existsSync(path.join(dir, filepath))
|
|
870
|
-
? fs.readFileSync(path.join(dir, filepath), "utf8")
|
|
871
|
-
: "";
|
|
872
|
-
change.diff = this._createUnifiedDiff(oldContent, newContent, filepath, context);
|
|
873
|
-
} catch (e) {
|
|
874
|
-
change.diff = `[Não foi possível gerar diff: ${e.message}]`;
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
changes.push(change);
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
return changes;
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
_getFileStatus(head, workdir, stage) {
|
|
885
|
-
if (head === 0 && workdir === 2 && stage === 0) return "new-untracked";
|
|
886
|
-
if (head === 0 && workdir === 2 && stage === 2) return "new-staged";
|
|
887
|
-
if (head === 1 && workdir === 2 && stage === 1) return "modified";
|
|
888
|
-
if (head === 1 && workdir === 2 && stage === 2) return "staged-modified";
|
|
889
|
-
if (head === 1 && workdir === 0 && stage === 1) return "deleted";
|
|
890
|
-
if (head === 1 && workdir === 0 && stage === 0) return "staged-deleted";
|
|
891
|
-
return "unknown";
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
async _getFileAtRef(dir, filepath, ref) {
|
|
895
|
-
try {
|
|
896
|
-
const oid = await git.resolveRef({ fs, dir, ref });
|
|
897
|
-
const { blob } = await git.readBlob({ fs, dir, oid, filepath });
|
|
898
|
-
return new TextDecoder().decode(blob);
|
|
899
|
-
} catch {
|
|
900
|
-
return "";
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
_createUnifiedDiff(oldContent, newContent, filename, context = 3) {
|
|
905
|
-
const oldLines = oldContent.split("\n");
|
|
906
|
-
const newLines = newContent.split("\n");
|
|
907
|
-
|
|
908
|
-
// Simple line-by-line diff
|
|
909
|
-
const diff = [];
|
|
910
|
-
diff.push(`--- a/${filename}`);
|
|
911
|
-
diff.push(`+++ b/${filename}`);
|
|
912
|
-
|
|
913
|
-
let i = 0, j = 0;
|
|
914
|
-
while (i < oldLines.length || j < newLines.length) {
|
|
915
|
-
if (oldLines[i] === newLines[j]) {
|
|
916
|
-
diff.push(` ${oldLines[i] || ""}`);
|
|
917
|
-
i++; j++;
|
|
918
|
-
} else if (j < newLines.length && (i >= oldLines.length || oldLines[i] !== newLines[j])) {
|
|
919
|
-
diff.push(`+${newLines[j]}`);
|
|
920
|
-
j++;
|
|
921
|
-
} else {
|
|
922
|
-
diff.push(`-${oldLines[i]}`);
|
|
923
|
-
i++;
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
return diff.join("\n");
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
async diffCommits(dir, from, to, options = {}) {
|
|
931
|
-
const { file, context = 3 } = options;
|
|
932
|
-
|
|
933
|
-
const fromOid = await this._resolveRef(dir, from);
|
|
934
|
-
const toOid = to ? await this._resolveRef(dir, to) : null;
|
|
935
|
-
|
|
936
|
-
// Get file lists for both commits
|
|
937
|
-
const fromFiles = await this.listFiles(dir, fromOid);
|
|
938
|
-
const toFiles = toOid
|
|
939
|
-
? await this.listFiles(dir, toOid)
|
|
940
|
-
: await this._getWorkingFiles(dir);
|
|
941
|
-
|
|
942
|
-
const allFiles = [...new Set([...fromFiles, ...toFiles])];
|
|
943
|
-
const changes = [];
|
|
944
|
-
|
|
945
|
-
for (const filepath of allFiles) {
|
|
946
|
-
if (file && filepath !== file) continue;
|
|
947
|
-
|
|
948
|
-
const inFrom = fromFiles.includes(filepath);
|
|
949
|
-
const inTo = toFiles.includes(filepath);
|
|
950
|
-
|
|
951
|
-
let status, diff;
|
|
952
|
-
|
|
953
|
-
if (!inFrom && inTo) {
|
|
954
|
-
status = "added";
|
|
955
|
-
} else if (inFrom && !inTo) {
|
|
956
|
-
status = "deleted";
|
|
957
|
-
} else {
|
|
958
|
-
// Compare content
|
|
959
|
-
const fromContent = await this._getFileAtRef(dir, filepath, fromOid);
|
|
960
|
-
const toContent = toOid
|
|
961
|
-
? await this._getFileAtRef(dir, filepath, toOid)
|
|
962
|
-
: fs.readFileSync(path.join(dir, filepath), "utf8");
|
|
963
|
-
|
|
964
|
-
if (fromContent === toContent) continue;
|
|
965
|
-
|
|
966
|
-
status = "modified";
|
|
967
|
-
diff = this._createUnifiedDiff(fromContent, toContent, filepath, context);
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
changes.push({ path: filepath, status, diff });
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
return changes;
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
async _getWorkingFiles(dir) {
|
|
977
|
-
const files = [];
|
|
978
|
-
const walk = (dirPath, prefix = "") => {
|
|
979
|
-
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
980
|
-
for (const entry of entries) {
|
|
981
|
-
if (entry.name === ".git") continue;
|
|
982
|
-
const fullPath = path.join(dirPath, entry.name);
|
|
983
|
-
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
984
|
-
if (entry.isDirectory()) {
|
|
985
|
-
walk(fullPath, relativePath);
|
|
986
|
-
} else {
|
|
987
|
-
files.push(relativePath);
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
};
|
|
991
|
-
walk(dir);
|
|
992
|
-
return files;
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
async diffStats(dir, from, to) {
|
|
996
|
-
const changes = await this.diffCommits(dir, from, to);
|
|
997
|
-
|
|
998
|
-
let insertions = 0;
|
|
999
|
-
let deletions = 0;
|
|
1000
|
-
|
|
1001
|
-
for (const change of changes) {
|
|
1002
|
-
if (change.diff) {
|
|
1003
|
-
const lines = change.diff.split("\n");
|
|
1004
|
-
for (const line of lines) {
|
|
1005
|
-
if (line.startsWith("+") && !line.startsWith("+++")) insertions++;
|
|
1006
|
-
if (line.startsWith("-") && !line.startsWith("---")) deletions++;
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
return {
|
|
1012
|
-
filesChanged: changes.length,
|
|
1013
|
-
insertions,
|
|
1014
|
-
deletions,
|
|
1015
|
-
files: changes.map(c => ({ path: c.path, status: c.status }))
|
|
1016
|
-
};
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
// ============ CLONE ============
|
|
1020
|
-
async clone(url, dir, options = {}) {
|
|
1021
|
-
const { branch, depth, singleBranch } = options;
|
|
1022
|
-
|
|
1023
|
-
// Create directory if it doesn't exist
|
|
1024
|
-
if (!fs.existsSync(dir)) {
|
|
1025
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
// Detect auth from URL
|
|
1029
|
-
let onAuth = undefined;
|
|
1030
|
-
try {
|
|
1031
|
-
onAuth = this.getAuth(url);
|
|
1032
|
-
} catch {
|
|
1033
|
-
// Public repo, no auth needed
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
const cloneOptions = {
|
|
1037
|
-
fs,
|
|
1038
|
-
http,
|
|
1039
|
-
dir,
|
|
1040
|
-
url,
|
|
1041
|
-
onAuth,
|
|
1042
|
-
singleBranch: singleBranch || false
|
|
1043
|
-
};
|
|
1044
|
-
|
|
1045
|
-
if (branch) cloneOptions.ref = branch;
|
|
1046
|
-
if (depth) cloneOptions.depth = depth;
|
|
1047
|
-
|
|
1048
|
-
await withRetry(() => git.clone(cloneOptions));
|
|
1049
|
-
|
|
1050
|
-
const currentBranch = await this.getCurrentBranch(dir);
|
|
1051
|
-
const remotes = await this.listRemotes(dir);
|
|
1052
|
-
|
|
1053
|
-
return {
|
|
1054
|
-
branch: currentBranch,
|
|
1055
|
-
remotes: remotes.map(r => r.remote)
|
|
1056
|
-
};
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
// ============ RESET IMPROVED ============
|
|
1060
|
-
async resetHardClean(dir, ref) {
|
|
1061
|
-
// First do regular hard reset
|
|
1062
|
-
await this.resetHard(dir, ref);
|
|
1063
|
-
|
|
1064
|
-
// Then clean untracked files
|
|
1065
|
-
const result = await this.cleanUntracked(dir);
|
|
1066
|
-
return result;
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
async cleanUntracked(dir) {
|
|
1070
|
-
const status = await this.status(dir);
|
|
1071
|
-
|
|
1072
|
-
for (const file of status.not_added) {
|
|
1073
|
-
const fullPath = path.join(dir, file);
|
|
1074
|
-
if (fs.existsSync(fullPath)) {
|
|
1075
|
-
const stat = fs.statSync(fullPath);
|
|
1076
|
-
if (stat.isDirectory()) {
|
|
1077
|
-
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
1078
|
-
} else {
|
|
1079
|
-
fs.unlinkSync(fullPath);
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
return { cleaned: status.not_added };
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { MCPError, createError, mapExternalError } from "./errors.js";
|
|
5
|
+
import { getRepoNameFromPath, getProvidersEnv } from "./repoHelpers.js";
|
|
6
|
+
|
|
7
|
+
// Common locations for Git on Windows, prioritizing the actual binary over the shim
|
|
8
|
+
const GIT_CANDIDATES = [
|
|
9
|
+
"C:\\Program Files\\Git\\mingw64\\bin\\git.exe",
|
|
10
|
+
"C:\\Program Files\\Git\\bin\\git.exe",
|
|
11
|
+
"C:\\Program Files\\Git\\cmd\\git.exe",
|
|
12
|
+
"C:\\Users\\andre\\AppData\\Local\\Programs\\Git\\mingw64\\bin\\git.exe",
|
|
13
|
+
"C:\\Users\\andre\\AppData\\Local\\Programs\\Git\\bin\\git.exe",
|
|
14
|
+
"C:\\Users\\andre\\AppData\\Local\\Programs\\Git\\cmd\\git.exe",
|
|
15
|
+
"git" // Fallback to PATH
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export class GitAdapter {
|
|
19
|
+
constructor(providerManager) {
|
|
20
|
+
this.pm = providerManager;
|
|
21
|
+
this.gitPath = "git"; // Initial default
|
|
22
|
+
this.gitEnv = { ...process.env, LANG: "en_US.UTF-8" }; // Base env
|
|
23
|
+
this.resolvePromise = this._resolveGit();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async _resolveGit() {
|
|
27
|
+
console.error("[GitAdapter] Resolving git binary...");
|
|
28
|
+
|
|
29
|
+
for (const candidate of GIT_CANDIDATES) {
|
|
30
|
+
if (candidate !== "git" && !fs.existsSync(candidate)) continue;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const version = await this._runSpawn(candidate, ["--version"], ".");
|
|
34
|
+
console.error(`[GitAdapter] Found git at: ${candidate} (${version.trim()})`);
|
|
35
|
+
this.gitPath = candidate;
|
|
36
|
+
|
|
37
|
+
// If we found a specific binary, we might need to adjust PATH for standard utils (ssh, etc)
|
|
38
|
+
// But for simply running git, the absolute path is key.
|
|
39
|
+
return;
|
|
40
|
+
} catch (e) {
|
|
41
|
+
// console.error(`[GitAdapter] Failed candidate ${candidate}: ${e.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.error("[GitAdapter] WARNING: Could not resolve specific git binary. Using 'git' from PATH.");
|
|
46
|
+
this.gitPath = "git";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Low-level spawn wrapper
|
|
50
|
+
_runSpawn(cmd, args, dir, env = {}) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const cp = spawn(cmd, args, {
|
|
53
|
+
cwd: dir,
|
|
54
|
+
env: { ...this.gitEnv, ...env },
|
|
55
|
+
stdio: 'pipe'
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
let stdout = [];
|
|
59
|
+
let stderr = [];
|
|
60
|
+
|
|
61
|
+
cp.stdout.on("data", d => stdout.push(d));
|
|
62
|
+
cp.stderr.on("data", d => stderr.push(d));
|
|
63
|
+
|
|
64
|
+
cp.on("error", e => reject(new Error(`Failed to spawn ${cmd}: ${e.message}`)));
|
|
65
|
+
|
|
66
|
+
cp.on("close", code => {
|
|
67
|
+
const outStr = Buffer.concat(stdout).toString("utf8").trim();
|
|
68
|
+
const errStr = Buffer.concat(stderr).toString("utf8").trim();
|
|
69
|
+
|
|
70
|
+
if (code === 0) {
|
|
71
|
+
resolve(outStr);
|
|
72
|
+
} else {
|
|
73
|
+
const msg = errStr || outStr || `Exit code ${code}`;
|
|
74
|
+
reject(new Error(msg));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async _exec(dir, args, options = {}) {
|
|
81
|
+
await this.resolvePromise;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const output = await this._runSpawn(this.gitPath, args, dir, options.env);
|
|
85
|
+
return output;
|
|
86
|
+
} catch (e) {
|
|
87
|
+
if (options.ignoreErrors) return "";
|
|
88
|
+
// Enhance error message
|
|
89
|
+
const cmdStr = `git ${args.join(" ")}`;
|
|
90
|
+
throw new Error(`Command failed: ${cmdStr}\n${e.message}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async _ensureGitRepo(dir) {
|
|
95
|
+
if (!fs.existsSync(path.join(dir, ".git"))) {
|
|
96
|
+
throw createError("NOT_A_GIT_REPO", {
|
|
97
|
+
message: `'${dir}' não é um repositório git`,
|
|
98
|
+
suggestion: "Use git-workflow init para criar um repositório"
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// =================================================================
|
|
104
|
+
// Git Operations
|
|
105
|
+
// =================================================================
|
|
106
|
+
|
|
107
|
+
async init(dir, defaultBranch = "main") {
|
|
108
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
109
|
+
// Try modern init
|
|
110
|
+
try {
|
|
111
|
+
await this._exec(dir, ["init", "-b", defaultBranch]);
|
|
112
|
+
} catch {
|
|
113
|
+
await this._exec(dir, ["init"]);
|
|
114
|
+
await this._exec(dir, ["symbolic-ref", "HEAD", `refs/heads/${defaultBranch}`]).catch(() => { });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async status(dir) {
|
|
119
|
+
await this._ensureGitRepo(dir);
|
|
120
|
+
|
|
121
|
+
// porcelain v1 is easy to parse
|
|
122
|
+
const out = await this._exec(dir, ["status", "--porcelain=v1"]);
|
|
123
|
+
const lines = out.split("\n").filter(Boolean);
|
|
124
|
+
|
|
125
|
+
const modified = [], created = [], deleted = [], not_added = [], staged = [], files = [];
|
|
126
|
+
|
|
127
|
+
for (const line of lines) {
|
|
128
|
+
// XY PATH
|
|
129
|
+
const code = line.substring(0, 2);
|
|
130
|
+
const file = line.substring(3).trim();
|
|
131
|
+
const x = code[0], y = code[1];
|
|
132
|
+
|
|
133
|
+
const fileInfo = { path: file };
|
|
134
|
+
|
|
135
|
+
// X = Index
|
|
136
|
+
if (x !== ' ' && x !== '?') {
|
|
137
|
+
staged.push(file);
|
|
138
|
+
if (x === 'A') created.push(file);
|
|
139
|
+
if (x === 'D') deleted.push(file);
|
|
140
|
+
fileInfo.index = x === 'A' ? "new" : x === 'D' ? "deleted" : "modified";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Y = Worktree
|
|
144
|
+
if (y !== ' ') {
|
|
145
|
+
if (y === 'M') { modified.push(file); fileInfo.working_dir = "modified"; }
|
|
146
|
+
if (y === 'D') { not_added.push(file); fileInfo.working_dir = "deleted"; }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Untracked
|
|
150
|
+
if (code === '??') {
|
|
151
|
+
not_added.push(file);
|
|
152
|
+
fileInfo.working_dir = "new";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
files.push(fileInfo);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const current = await this.getCurrentBranch(dir);
|
|
159
|
+
return {
|
|
160
|
+
modified, created, deleted, renamed: [], not_added, staged, conflicted: [],
|
|
161
|
+
current, isClean: lines.length === 0, files
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async add(dir, files) {
|
|
166
|
+
if (files.includes(".")) {
|
|
167
|
+
await this._exec(dir, ["add", "."]);
|
|
168
|
+
await this._exec(dir, ["add", "--all"]); // Catch deletions if . didn't
|
|
169
|
+
} else {
|
|
170
|
+
await this._exec(dir, ["add", ...files]);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async remove(dir, files) {
|
|
175
|
+
await this._exec(dir, ["rm", "--cached", ...files]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async getAuthor(dir) {
|
|
179
|
+
try {
|
|
180
|
+
const name = await this._exec(dir, ["config", "user.name"]);
|
|
181
|
+
const email = await this._exec(dir, ["config", "user.email"]);
|
|
182
|
+
if (name && email) return { name, email };
|
|
183
|
+
} catch { }
|
|
184
|
+
|
|
185
|
+
// Fallback logic
|
|
186
|
+
const ownerGH = await this.pm.getGitHubOwner().catch(() => null);
|
|
187
|
+
const ownerGE = await this.pm.getGiteaOwner().catch(() => null);
|
|
188
|
+
const who = ownerGH || ownerGE || "Git User";
|
|
189
|
+
const email = `${who}@users.noreply`;
|
|
190
|
+
|
|
191
|
+
await this.setConfig(dir, "user.name", who);
|
|
192
|
+
await this.setConfig(dir, "user.email", email);
|
|
193
|
+
|
|
194
|
+
return { name: who, email };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async commit(dir, message) {
|
|
198
|
+
await this.getAuthor(dir);
|
|
199
|
+
await this._exec(dir, ["commit", "-m", message]);
|
|
200
|
+
return await this._exec(dir, ["rev-parse", "HEAD"]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async getCurrentBranch(dir) {
|
|
204
|
+
try {
|
|
205
|
+
return await this._exec(dir, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
206
|
+
} catch {
|
|
207
|
+
return "HEAD";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ============ REMOTES ============
|
|
212
|
+
async ensureRemotes(dir, { githubUrl, giteaUrl }) {
|
|
213
|
+
const remotesOutput = await this._exec(dir, ["remote", "-v"]);
|
|
214
|
+
const remotes = new Set(remotesOutput.split("\n").map(l => l.split("\t")[0]).filter(Boolean));
|
|
215
|
+
|
|
216
|
+
let repoName = getRepoNameFromPath(dir);
|
|
217
|
+
if (repoName === "GIT_MCP") repoName = "git-mcp";
|
|
218
|
+
|
|
219
|
+
const calcUrls = await this.pm.getRemoteUrls(repoName);
|
|
220
|
+
const targetGithub = calcUrls.github || githubUrl;
|
|
221
|
+
const targetGitea = calcUrls.gitea || giteaUrl;
|
|
222
|
+
|
|
223
|
+
const setRemote = async (name, url) => {
|
|
224
|
+
if (!url) return;
|
|
225
|
+
if (remotes.has(name)) {
|
|
226
|
+
await this._exec(dir, ["remote", "set-url", name, url]);
|
|
227
|
+
} else {
|
|
228
|
+
await this._exec(dir, ["remote", "add", name, url]);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
await setRemote("github", targetGithub);
|
|
233
|
+
await setRemote("gitea", targetGitea);
|
|
234
|
+
|
|
235
|
+
const originUrl = targetGithub || targetGitea;
|
|
236
|
+
if (originUrl) await setRemote("origin", originUrl);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
_getAuthHeader(url) {
|
|
240
|
+
const { githubToken, giteaToken } = getProvidersEnv();
|
|
241
|
+
if (url.includes("github.com") && githubToken) {
|
|
242
|
+
const basic = Buffer.from(`${githubToken}:x-oauth-basic`).toString("base64");
|
|
243
|
+
return `Authorization: Basic ${basic}`;
|
|
244
|
+
}
|
|
245
|
+
if (giteaToken) {
|
|
246
|
+
const basic = Buffer.from(`git:${giteaToken}`).toString("base64");
|
|
247
|
+
return `Authorization: Basic ${basic}`;
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async pushOne(dir, remote, branch, force = false) {
|
|
253
|
+
const remoteUrl = await this._exec(dir, ["remote", "get-url", remote]);
|
|
254
|
+
const header = this._getAuthHeader(remoteUrl);
|
|
255
|
+
|
|
256
|
+
const args = [];
|
|
257
|
+
if (header) {
|
|
258
|
+
args.push("-c", `http.extraHeader=${header}`);
|
|
259
|
+
}
|
|
260
|
+
args.push("push");
|
|
261
|
+
if (force) args.push("--force");
|
|
262
|
+
args.push(remote, branch);
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
await this._exec(dir, args);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
if (e.message.includes("rejected")) {
|
|
268
|
+
throw createError("PUSH_REJECTED", { message: e.message, remote, branch });
|
|
269
|
+
}
|
|
270
|
+
throw mapExternalError(e, { type: "push", remote, branch });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async pushParallel(dir, branch, force = false) {
|
|
275
|
+
await this.ensureRemotes(dir, {});
|
|
276
|
+
const remotesStr = await this._exec(dir, ["remote"]);
|
|
277
|
+
const remotes = remotesStr.split("\n").filter(r => ["github", "gitea"].includes(r.trim()));
|
|
278
|
+
|
|
279
|
+
if (remotes.length === 0) {
|
|
280
|
+
throw createError("REMOTE_NOT_FOUND", { message: "Nenhum remote github/gitea configurado" });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Parallel push
|
|
284
|
+
const results = await Promise.allSettled(remotes.map(r => this.pushOne(dir, r, branch, force)));
|
|
285
|
+
const errors = results.filter(r => r.status === "rejected");
|
|
286
|
+
|
|
287
|
+
if (errors.length === remotes.length) {
|
|
288
|
+
throw createError("PUSH_REJECTED", { message: "Push falhou para todos os remotes", errors: errors.map(e => e.reason.message) });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
pushed: remotes.filter((_, i) => results[i].status === "fulfilled"),
|
|
293
|
+
failed: errors.map((e, i) => ({ remote: remotes[i], error: e.reason.message }))
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ============ BRANCHES/TAGS ============
|
|
298
|
+
async listBranches(dir, remote = false) {
|
|
299
|
+
const args = ["branch", "--format=%(refname:short)"];
|
|
300
|
+
if (remote) args.push("-r");
|
|
301
|
+
const out = await this._exec(dir, args);
|
|
302
|
+
return out.split("\n").filter(Boolean).map(b => b.trim());
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async diffStats(dir, from = "HEAD", to) {
|
|
306
|
+
// git diff --shortstat from..to
|
|
307
|
+
const args = ["diff", "--shortstat", from];
|
|
308
|
+
if (to) args.push(to);
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const out = await this._exec(dir, args);
|
|
312
|
+
// Result: " 1 file changed, 1 insertion(+), 1 deletion(-)"
|
|
313
|
+
const filesChanged = (out.match(/(\d+) file(s?) changed/) || [])[1] || 0;
|
|
314
|
+
const insertions = (out.match(/(\d+) insertion(s?)/) || [])[1] || 0;
|
|
315
|
+
const deletions = (out.match(/(\d+) deletion(s?)/) || [])[1] || 0;
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
filesChanged: parseInt(filesChanged),
|
|
319
|
+
insertions: parseInt(insertions),
|
|
320
|
+
deletions: parseInt(deletions)
|
|
321
|
+
};
|
|
322
|
+
} catch {
|
|
323
|
+
return { filesChanged: 0, insertions: 0, deletions: 0 };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async createBranch(dir, name) {
|
|
328
|
+
await this._exec(dir, ["branch", name]);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async deleteBranch(dir, name) {
|
|
332
|
+
try {
|
|
333
|
+
await this._exec(dir, ["branch", "-D", name]);
|
|
334
|
+
} catch (e) {
|
|
335
|
+
throw createError("BRANCH_NOT_FOUND", { message: e.message });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async renameBranch(dir, oldName, newName) {
|
|
340
|
+
await this._exec(dir, ["branch", "-m", oldName, newName]);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async checkout(dir, ref) {
|
|
344
|
+
try {
|
|
345
|
+
await this._exec(dir, ["checkout", ref]);
|
|
346
|
+
} catch (e) {
|
|
347
|
+
throw createError("REF_NOT_FOUND", { message: e.message });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async listTags(dir) {
|
|
352
|
+
const out = await this._exec(dir, ["tag"]);
|
|
353
|
+
return out.split("\n").filter(Boolean);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async createTag(dir, tag, ref = "HEAD", message = "") {
|
|
357
|
+
if (message) {
|
|
358
|
+
await this._exec(dir, ["tag", "-a", tag, ref, "-m", message]);
|
|
359
|
+
} else {
|
|
360
|
+
await this._exec(dir, ["tag", tag, ref]);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async deleteTag(dir, tag) {
|
|
365
|
+
await this._exec(dir, ["tag", "-d", tag]);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async pushTag(dir, remote, tag) {
|
|
369
|
+
const url = await this._exec(dir, ["remote", "get-url", remote]);
|
|
370
|
+
const header = this._getAuthHeader(url);
|
|
371
|
+
const args = [];
|
|
372
|
+
if (header) args.push("-c", `http.extraHeader=${header}`);
|
|
373
|
+
args.push("push", remote, tag);
|
|
374
|
+
|
|
375
|
+
await this._exec(dir, args);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ============ STASH (Native) ============
|
|
379
|
+
async listStash(dir) {
|
|
380
|
+
const out = await this._exec(dir, ["stash", "list", "--pretty=format:%gd: %gs"]);
|
|
381
|
+
return out.split("\n").filter(Boolean).map((line, i) => ({
|
|
382
|
+
index: i,
|
|
383
|
+
message: line.split(":").slice(1).join(":").trim()
|
|
384
|
+
}));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async saveStash(dir, message = "WIP", includeUntracked = false) {
|
|
388
|
+
const args = ["stash", "push"];
|
|
389
|
+
if (includeUntracked) args.push("-u");
|
|
390
|
+
args.push("-m", message);
|
|
391
|
+
await this._exec(dir, args);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async applyStash(dir, ref = "stash@{0}") {
|
|
395
|
+
await this._exec(dir, ["stash", "apply", ref]);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async popStash(dir, ref = "stash@{0}") {
|
|
399
|
+
await this._exec(dir, ["stash", "pop", ref]);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async dropStash(dir, ref = "stash@{0}") {
|
|
403
|
+
await this._exec(dir, ["stash", "drop", ref]);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async clearStash(dir) {
|
|
407
|
+
await this._exec(dir, ["stash", "clear"]);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ============ CONFIG ============
|
|
411
|
+
async getConfig(dir, key) {
|
|
412
|
+
try {
|
|
413
|
+
return await this._exec(dir, ["config", "--get", key]);
|
|
414
|
+
} catch { return undefined; }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async setConfig(dir, key, value, scope = "local") {
|
|
418
|
+
const args = ["config"];
|
|
419
|
+
if (scope === "global") args.push("--global");
|
|
420
|
+
else if (scope === "system") args.push("--system");
|
|
421
|
+
else args.push("--local"); // default
|
|
422
|
+
args.push(key, value);
|
|
423
|
+
await this._exec(dir, args);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async unsetConfig(dir, key, scope = "local") {
|
|
427
|
+
const args = ["config"];
|
|
428
|
+
if (scope === "global") args.push("--global");
|
|
429
|
+
else if (scope === "system") args.push("--system");
|
|
430
|
+
else args.push("--local");
|
|
431
|
+
args.push("--unset", key);
|
|
432
|
+
try { await this._exec(dir, args); } catch { }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async listConfig(dir) {
|
|
436
|
+
const out = await this._exec(dir, ["config", "--list"]);
|
|
437
|
+
const items = {};
|
|
438
|
+
out.split("\n").filter(Boolean).forEach(line => {
|
|
439
|
+
const [k, ...v] = line.split("=");
|
|
440
|
+
items[k] = v.join("=");
|
|
441
|
+
});
|
|
442
|
+
return items;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ============ FILES/LOG ============
|
|
446
|
+
async listFiles(dir, ref = "HEAD") {
|
|
447
|
+
const out = await this._exec(dir, ["ls-tree", "-r", "--name-only", ref]);
|
|
448
|
+
return out.split("\n").filter(Boolean);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async readFile(dir, filepath, ref = "HEAD") {
|
|
452
|
+
return await this._exec(dir, ["show", `${ref}:${filepath}`]);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async log(dir, { ref = "HEAD", maxCount = 50 } = {}) {
|
|
456
|
+
const format = "%H|%h|%s|%an|%ae|%aI";
|
|
457
|
+
const out = await this._exec(dir, ["log", "-n", maxCount.toString(), `--pretty=format:${format}`, ref]);
|
|
458
|
+
return out.split("\n").filter(Boolean).map(line => {
|
|
459
|
+
const [sha, short, message, name, email, date] = line.split("|");
|
|
460
|
+
return { sha, message, author: { name, email, timestamp: new Date(date).getTime() / 1000 }, date };
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async fetch(dir, remote, branch) {
|
|
465
|
+
const url = await this._exec(dir, ["remote", "get-url", remote]);
|
|
466
|
+
const header = this._getAuthHeader(url);
|
|
467
|
+
const args = [];
|
|
468
|
+
if (header) args.push("-c", `http.extraHeader=${header}`);
|
|
469
|
+
args.push("fetch", remote, branch);
|
|
470
|
+
await this._exec(dir, args);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async pull(dir, remote, branch) {
|
|
474
|
+
const url = await this._exec(dir, ["remote", "get-url", remote]);
|
|
475
|
+
const header = this._getAuthHeader(url);
|
|
476
|
+
const args = [];
|
|
477
|
+
if (header) args.push("-c", `http.extraHeader=${header}`);
|
|
478
|
+
args.push("pull", remote, branch);
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
await this._exec(dir, args);
|
|
482
|
+
} catch (e) {
|
|
483
|
+
if (e.message.includes("conflict")) throw createError("MERGE_CONFLICT", { message: "Conflict on pull" });
|
|
484
|
+
throw e;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ============ RESET/MERGE ============
|
|
489
|
+
async resetSoft(dir, ref) { await this._exec(dir, ["reset", "--soft", ref]); }
|
|
490
|
+
async resetMixed(dir, ref) { await this._exec(dir, ["reset", "--mixed", ref]); }
|
|
491
|
+
async resetHard(dir, ref) { await this._exec(dir, ["reset", "--hard", ref]); }
|
|
492
|
+
|
|
493
|
+
async merge(dir, branch, options = {}) {
|
|
494
|
+
const { message, noCommit, squash } = options;
|
|
495
|
+
const args = ["merge"];
|
|
496
|
+
if (noCommit) args.push("--no-commit");
|
|
497
|
+
if (squash) args.push("--squash");
|
|
498
|
+
if (message) args.push("-m", message);
|
|
499
|
+
args.push(branch);
|
|
500
|
+
|
|
501
|
+
try {
|
|
502
|
+
await this._exec(dir, args);
|
|
503
|
+
return { merged: true };
|
|
504
|
+
} catch (e) {
|
|
505
|
+
if (e.message.includes("conflict")) {
|
|
506
|
+
const diff = await this._exec(dir, ["diff", "--name-only", "--diff-filter=U"]);
|
|
507
|
+
return { conflicts: diff.split("\n").filter(Boolean) };
|
|
508
|
+
}
|
|
509
|
+
throw e;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ============ DIFF/CLONE ============
|
|
514
|
+
async diff(dir, options = {}) { return await this._exec(dir, ["diff"]); }
|
|
515
|
+
async diffCommits(dir, from, to) { return await this._exec(dir, ["diff", from, to]); }
|
|
516
|
+
async diffStats(dir, from, to) { const out = await this._exec(dir, ["diff", "--stat", from, to]); return { message: out }; }
|
|
517
|
+
|
|
518
|
+
async clone(url, dir, options = {}) {
|
|
519
|
+
const { branch, depth, singleBranch } = options;
|
|
520
|
+
const args = ["clone"];
|
|
521
|
+
if (branch) args.push("-b", branch);
|
|
522
|
+
if (depth) args.push("--depth", depth.toString());
|
|
523
|
+
if (singleBranch) args.push("--single-branch");
|
|
524
|
+
args.push(url, ".");
|
|
525
|
+
|
|
526
|
+
const header = this._getAuthHeader(url);
|
|
527
|
+
const cmdArgs = [];
|
|
528
|
+
if (header) cmdArgs.push("-c", `http.extraHeader=${header}`);
|
|
529
|
+
cmdArgs.push(...args);
|
|
530
|
+
|
|
531
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
532
|
+
await this._exec(dir, cmdArgs);
|
|
533
|
+
return { branch: branch || "HEAD" };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async listGitignore(dir) {
|
|
537
|
+
if (fs.existsSync(path.join(dir, ".gitignore"))) {
|
|
538
|
+
return fs.readFileSync(path.join(dir, ".gitignore"), "utf8").split("\n");
|
|
539
|
+
}
|
|
540
|
+
return [];
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async createGitignore(dir, patterns) {
|
|
544
|
+
fs.writeFileSync(path.join(dir, ".gitignore"), patterns.join("\n"));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async addToGitignore(dir, patterns) {
|
|
548
|
+
fs.appendFileSync(path.join(dir, ".gitignore"), "\n" + patterns.join("\n"));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async removeFromGitignore(dir, patterns) {
|
|
552
|
+
const p = path.join(dir, ".gitignore");
|
|
553
|
+
if (!fs.existsSync(p)) return;
|
|
554
|
+
let c = fs.readFileSync(p, "utf8");
|
|
555
|
+
patterns.forEach(pat => c = c.replace(pat, ""));
|
|
556
|
+
fs.writeFileSync(p, c);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async listRemotesRaw(dir) {
|
|
560
|
+
return this.listRemotes(dir);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async cleanUntracked(dir) {
|
|
564
|
+
// dry-run first to get list
|
|
565
|
+
const dry = await this._exec(dir, ["clean", "-n", "-d"]);
|
|
566
|
+
const files = dry.split("\n").filter(Boolean).map(l => l.replace("Would remove ", "").trim());
|
|
567
|
+
|
|
568
|
+
if (files.length > 0) {
|
|
569
|
+
await this._exec(dir, ["clean", "-f", "-d"]);
|
|
570
|
+
}
|
|
571
|
+
return { cleaned: files };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async listRemotes(dir) {
|
|
575
|
+
const out = await this._exec(dir, ["remote", "-v"]);
|
|
576
|
+
const map = new Map();
|
|
577
|
+
out.split("\n").filter(Boolean).forEach(line => {
|
|
578
|
+
const [name, rest] = line.split("\t");
|
|
579
|
+
const url = rest.split(" ")[0];
|
|
580
|
+
map.set(name, { remote: name, url });
|
|
581
|
+
});
|
|
582
|
+
return Array.from(map.values());
|
|
583
|
+
}
|
|
584
|
+
}
|