@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.
@@ -1,1087 +1,584 @@
1
- import * as git from "isomorphic-git";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import http from "isomorphic-git/http/node";
5
- import { MCPError, createError, mapExternalError } from "./errors.js";
6
- import { getProvidersEnv, getRepoNameFromPath } from "./repoHelpers.js";
7
- import { withRetry } from "./retry.js";
8
-
9
- export class GitAdapter {
10
- constructor(providerManager) {
11
- this.pm = providerManager;
12
- }
13
-
14
- async init(dir, defaultBranch = "main") {
15
- await git.init({ fs, dir, defaultBranch });
16
- }
17
-
18
- // Verifica se é um repositório git válido
19
- async _ensureGitRepo(dir) {
20
- const gitDir = path.join(dir, ".git");
21
- if (!fs.existsSync(gitDir)) {
22
- throw createError("NOT_A_GIT_REPO", {
23
- message: `'${dir}' não é um repositório git`,
24
- suggestion: "Use git-workflow init para criar um repositório"
25
- });
26
- }
27
- }
28
-
29
- async status(dir) {
30
- await this._ensureGitRepo(dir);
31
- const matrix = await git.statusMatrix({ fs, dir });
32
- const FILE = 0, HEAD = 1, WORKDIR = 2, STAGE = 3;
33
- const modified = [], created = [], deleted = [], not_added = [], files = [];
34
- for (const row of matrix) {
35
- const f = row[FILE], h = row[HEAD], w = row[WORKDIR], s = row[STAGE];
36
- if (h === 0 && w === 2 && s === 0) { not_added.push(f); files.push({ path: f, working_dir: "new" }); }
37
- else if (h === 0 && w === 2 && s === 2) { created.push(f); files.push({ path: f, index: "new", working_dir: "new" }); }
38
- else if (h === 1 && w === 2 && s === 1) { not_added.push(f); files.push({ path: f, working_dir: "modified" }); }
39
- else if (h === 1 && w === 2 && s === 2) { modified.push(f); files.push({ path: f, index: "modified", working_dir: "modified" }); }
40
- else if (h === 1 && w === 0 && s === 1) { not_added.push(f); files.push({ path: f, working_dir: "deleted" }); }
41
- else if (h === 1 && w === 0 && s === 0) { deleted.push(f); files.push({ path: f, index: "deleted", working_dir: "deleted" }); }
42
- }
43
- const current = await this.getCurrentBranch(dir);
44
- const staged = [...modified, ...created, ...deleted];
45
- const isClean = modified.length === 0 && created.length === 0 && deleted.length === 0 && not_added.length === 0;
46
- return { modified, created, deleted, renamed: [], not_added, staged, conflicted: [], current, tracking: null, ahead: 0, behind: 0, isClean, files };
47
- }
48
-
49
- async add(dir, files) {
50
- for (const fp of files) {
51
- if (fp === ".") {
52
- const matrix = await git.statusMatrix({ fs, dir });
53
- for (const row of matrix) {
54
- const file = row[0];
55
- const work = row[2];
56
- if (work === 2) await git.add({ fs, dir, filepath: file });
57
- else if (work === 0 && row[1] === 1) await git.remove({ fs, dir, filepath: file });
58
- }
59
- } else {
60
- await git.add({ fs, dir, filepath: fp });
61
- }
62
- }
63
- }
64
-
65
- async remove(dir, files) {
66
- for (const fp of files) await git.remove({ fs, dir, filepath: fp });
67
- }
68
-
69
- async getAuthor(dir) {
70
- const name = await git.getConfig({ fs, dir, path: "user.name" }).catch(() => "");
71
- const email = await git.getConfig({ fs, dir, path: "user.email" }).catch(() => "");
72
- if (name && email) return { name, email };
73
-
74
- // Tenta inferir do provider
75
- const ownerGH = await this.pm.getGitHubOwner().catch(() => null);
76
- const ownerGE = await this.pm.getGiteaOwner().catch(() => null);
77
- const who = ownerGH || ownerGE;
78
- if (who) {
79
- const inferred = { name: who, email: `${who}@users.noreply` };
80
- await git.setConfig({ fs, dir, path: "user.name", value: inferred.name }).catch(() => {});
81
- await git.setConfig({ fs, dir, path: "user.email", value: inferred.email }).catch(() => {});
82
- return inferred;
83
- }
84
-
85
- // Fallback padrão - usa valores genéricos para não bloquear operações
86
- const defaultAuthor = { name: "Git User", email: "git@localhost" };
87
- await git.setConfig({ fs, dir, path: "user.name", value: defaultAuthor.name }).catch(() => {});
88
- await git.setConfig({ fs, dir, path: "user.email", value: defaultAuthor.email }).catch(() => {});
89
- return defaultAuthor;
90
- }
91
-
92
- async commit(dir, message) {
93
- const author = await this.getAuthor(dir);
94
- const sha = await git.commit({ fs, dir, message, author: { name: author.name, email: author.email } });
95
- return sha;
96
- }
97
-
98
- async getCurrentBranch(dir) {
99
- return (await git.currentBranch({ fs, dir, fullname: false }).catch(() => "HEAD")) || "HEAD";
100
- }
101
-
102
- async ensureRemotes(dir, { githubUrl, giteaUrl }) {
103
- const remotes = await git.listRemotes({ fs, dir });
104
- // Usa o helper para normalizar o nome do repo
105
- let repoName = getRepoNameFromPath(dir);
106
- // Fix específico para seu caso: se normalizar para GIT_MCP, força git-mcp (padrão kebab-case)
107
- if (repoName === "GIT_MCP") repoName = "git-mcp";
108
-
109
- // Tenta obter URLs autenticadas/corretas do ProviderManager
110
- const calculatedUrls = await this.pm.getRemoteUrls(repoName);
111
- const targetGithub = calculatedUrls.github || githubUrl;
112
- const targetGitea = calculatedUrls.gitea || giteaUrl;
113
-
114
- const ensure = async (name, url) => {
115
- if (!url) return;
116
- const existing = remotes.find(r => r.remote === name);
117
-
118
- // SEMPRE força a atualização se a URL for diferente
119
- // Isso garante que a URL do ENV (mcp.json) tenha prioridade sobre o .git/config local
120
- if (existing) {
121
- if (existing.url !== url) {
122
- await git.deleteRemote({ fs, dir, remote: name });
123
- await git.addRemote({ fs, dir, remote: name, url });
124
- }
125
- } else {
126
- await git.addRemote({ fs, dir, remote: name, url });
127
- }
128
- };
129
-
130
- await ensure("github", targetGithub);
131
- await ensure("gitea", targetGitea);
132
-
133
- // Origin prefere GitHub, fallback para Gitea
134
- // Também força atualização do origin se necessário
135
- const originUrl = targetGithub || targetGitea;
136
- if (originUrl) await ensure("origin", originUrl);
137
- }
138
-
139
- getAuth(remoteUrl) {
140
- const { githubToken, giteaToken } = getProvidersEnv();
141
- if (remoteUrl.includes("github.com")) {
142
- if (!githubToken) throw createError("AUTH_GITHUB_INVALID", { message: "GITHUB_TOKEN não configurado" });
143
- return () => ({ username: githubToken, password: "x-oauth-basic" });
144
- }
145
- if (giteaToken) return () => ({ username: "git", password: giteaToken });
146
- throw createError("AUTH_GITEA_INVALID", { message: "GITEA_TOKEN não configurado" });
147
- }
148
-
149
- async pushOne(dir, remote, branch, force = false) {
150
- const remotes = await git.listRemotes({ fs, dir });
151
- const info = remotes.find(r => r.remote === remote);
152
- if (!info) {
153
- const available = remotes.map(r => r.remote).join(", ");
154
- throw createError("REMOTE_NOT_FOUND", {
155
- message: `Remote '${remote}' não encontrado`,
156
- remote,
157
- availableRemotes: available || "nenhum"
158
- });
159
- }
160
- const onAuth = this.getAuth(info.url);
161
- const ref = branch.startsWith("refs/") ? branch : `refs/heads/${branch}`;
162
- try {
163
- await withRetry(() => git.push({ fs, http, dir, remote, ref, remoteRef: ref, onAuth, force }));
164
- } catch (e) {
165
- if (e.message?.includes("non-fast-forward") || e.message?.includes("rejected")) {
166
- throw createError("PUSH_REJECTED", { message: e.message, remote, branch });
167
- }
168
- throw mapExternalError(e, { type: "push", remote, branch });
169
- }
170
- }
171
-
172
- async pushParallel(dir, branch, force = false) {
173
- // Auto-fix remotes antes de tentar push
174
- await this.ensureRemotes(dir, {});
175
-
176
- const remotes = await git.listRemotes({ fs, dir });
177
- const targets = remotes.filter(r => ["github", "gitea"].includes(r.remote));
178
- if (targets.length === 0) {
179
- throw createError("REMOTE_NOT_FOUND", {
180
- message: "Nenhum remote github/gitea configurado",
181
- availableRemotes: remotes.map(r => r.remote).join(", ") || "nenhum",
182
- suggestion: "Use action='ensure-remotes' para configurar os remotes"
183
- });
184
- }
185
- const results = await Promise.allSettled(targets.map(t => this.pushOne(dir, t.remote, branch, force)));
186
- const errors = results.filter(r => r.status === "rejected");
187
- if (errors.length === targets.length) {
188
- throw createError("PUSH_REJECTED", {
189
- message: "Push falhou para todos os remotes",
190
- errors: errors.map(e => e.reason?.message || String(e.reason))
191
- });
192
- }
193
- return {
194
- pushed: targets.filter((t, i) => results[i].status === "fulfilled").map(t => t.remote),
195
- failed: errors.map((e, i) => ({ remote: targets[i]?.remote, error: e.reason?.message }))
196
- };
197
- }
198
-
199
- // ============ BRANCHES ============
200
- async listBranches(dir, remote = false) {
201
- if (remote) {
202
- const remotes = await git.listRemotes({ fs, dir });
203
- const branches = [];
204
- for (const r of remotes) {
205
- try {
206
- const refs = await git.listServerRefs({ http, url: r.url, prefix: "refs/heads/", onAuth: this.getAuth(r.url) });
207
- for (const ref of refs) branches.push(`${r.remote}/${ref.ref.replace("refs/heads/", "")}`);
208
- } catch { /* ignore */ }
209
- }
210
- return branches;
211
- }
212
- return await git.listBranches({ fs, dir });
213
- }
214
-
215
- async createBranch(dir, name, ref = "HEAD") {
216
- // Verifica se há commits no repositório
217
- try {
218
- const oid = await git.resolveRef({ fs, dir, ref });
219
- await git.branch({ fs, dir, ref: name, checkout: false });
220
- } catch (e) {
221
- if (e.code === "NotFoundError" || e.message?.includes("Could not find")) {
222
- throw createError("NO_COMMITS", {
223
- message: `Não é possível criar branch '${name}': repositório não tem commits. Faça um commit primeiro.`,
224
- ref,
225
- suggestion: "Use git-workflow commit para criar o primeiro commit"
226
- });
227
- }
228
- throw e;
229
- }
230
- }
231
-
232
- async deleteBranch(dir, name, force = false) {
233
- // Verifica se está tentando deletar a branch atual
234
- const currentBranch = await this.getCurrentBranch(dir);
235
- if (currentBranch === name) {
236
- throw createError("CANNOT_DELETE_CURRENT_BRANCH", {
237
- message: `Não é possível deletar a branch atual '${name}'`,
238
- branch: name,
239
- suggestion: "Mude para outra branch antes de deletar esta"
240
- });
241
- }
242
-
243
- try {
244
- await git.deleteBranch({ fs, dir, ref: name });
245
- } catch (e) {
246
- if (e.code === "NotFoundError" || e.message?.includes("Could not find")) {
247
- throw createError("BRANCH_NOT_FOUND", {
248
- message: `Branch '${name}' não encontrada`,
249
- branch: name
250
- });
251
- }
252
- throw e;
253
- }
254
- }
255
-
256
- async renameBranch(dir, oldName, newName) {
257
- try {
258
- await git.renameBranch({ fs, dir, oldref: oldName, ref: newName });
259
- } catch (e) {
260
- if (e.code === "NotFoundError" || e.message?.includes("Could not find")) {
261
- throw createError("BRANCH_NOT_FOUND", {
262
- message: `Branch '${oldName}' não encontrada`,
263
- branch: oldName
264
- });
265
- }
266
- throw e;
267
- }
268
- }
269
-
270
- async checkout(dir, ref) {
271
- try {
272
- await git.checkout({ fs, dir, ref, force: true });
273
- } catch (e) {
274
- if (e.code === "NotFoundError" || e.message?.includes("Could not find")) {
275
- throw createError("REF_NOT_FOUND", {
276
- message: `Branch ou ref '${ref}' não encontrado`,
277
- ref,
278
- suggestion: "Verifique se o nome está correto com git-branches list"
279
- });
280
- }
281
- throw e;
282
- }
283
- }
284
-
285
- // ============ TAGS ============
286
- async listTags(dir) {
287
- return await git.listTags({ fs, dir });
288
- }
289
-
290
- async createTag(dir, tag, ref = "HEAD", message = "") {
291
- const oid = await git.resolveRef({ fs, dir, ref });
292
- if (message) {
293
- const author = await this.getAuthor(dir);
294
- await git.tag({ fs, dir, ref: tag, object: oid, tagger: { name: author.name, email: author.email }, message });
295
- } else {
296
- await git.tag({ fs, dir, ref: tag, object: oid });
297
- }
298
- }
299
-
300
- async deleteTag(dir, tag) {
301
- // Verifica se a tag existe antes de tentar deletar
302
- const tags = await this.listTags(dir);
303
- if (!tags.includes(tag)) {
304
- throw createError("TAG_NOT_FOUND", {
305
- message: `Tag '${tag}' não encontrada`,
306
- tag,
307
- availableTags: tags.slice(0, 5)
308
- });
309
- }
310
- await git.deleteTag({ fs, dir, ref: tag });
311
- }
312
-
313
- async pushTag(dir, remote, tag) {
314
- const remotes = await git.listRemotes({ fs, dir });
315
- const info = remotes.find(r => r.remote === remote);
316
- if (!info) throw new MCPError("REMOTE", `Remote '${remote}' não encontrado`);
317
- const onAuth = this.getAuth(info.url);
318
- await git.push({ fs, http, dir, remote, ref: `refs/tags/${tag}`, remoteRef: `refs/tags/${tag}`, onAuth });
319
- }
320
-
321
- // ============ STASH (simulated via JSON file) ============
322
- _getStashFile(dir) {
323
- return path.join(dir, ".git", "stash.json");
324
- }
325
-
326
- async listStash(dir) {
327
- const stashFile = this._getStashFile(dir);
328
- if (!fs.existsSync(stashFile)) return [];
329
- try {
330
- return JSON.parse(fs.readFileSync(stashFile, "utf8"));
331
- } catch { return []; }
332
- }
333
-
334
- async saveStash(dir, message = "WIP", includeUntracked = false) {
335
- const st = await this.status(dir);
336
-
337
- // Verifica se há arquivos para stash
338
- const filesToSave = [...st.not_added, ...st.modified, ...st.created];
339
- if (filesToSave.length === 0 && !includeUntracked) {
340
- throw createError("NOTHING_TO_STASH", {
341
- message: "Working tree limpa, nada para stash",
342
- status: st
343
- });
344
- }
345
-
346
- // Salva arquivos modificados/adicionados
347
- const stashData = { message, timestamp: Date.now(), files: {} };
348
-
349
- for (const file of filesToSave) {
350
- const fullPath = path.join(dir, file);
351
- if (fs.existsSync(fullPath)) {
352
- // como buffer para suportar binários
353
- const content = fs.readFileSync(fullPath);
354
- stashData.files[file] = content.toString("base64");
355
- stashData.encoding = "base64";
356
- }
357
- }
358
-
359
- // Carrega stashes existentes
360
- const stashes = await this.listStash(dir);
361
- stashes.unshift(stashData);
362
-
363
- // Salva lista de stashes
364
- fs.writeFileSync(this._getStashFile(dir), JSON.stringify(stashes, null, 2));
365
-
366
- // Remove arquivos stashados do working directory
367
- for (const file of filesToSave) {
368
- const fullPath = path.join(dir, file);
369
- if (fs.existsSync(fullPath)) {
370
- fs.unlinkSync(fullPath);
371
- }
372
- }
373
-
374
- // Tenta restaurar para estado HEAD se houver commits
375
- try {
376
- const currentBranch = await this.getCurrentBranch(dir);
377
- if (currentBranch && currentBranch !== "HEAD") {
378
- await git.checkout({ fs, dir, ref: currentBranch, force: true }).catch(() => {});
379
- } else {
380
- const oid = await git.resolveRef({ fs, dir, ref: "HEAD" }).catch(() => null);
381
- if (oid) await git.checkout({ fs, dir, ref: oid, force: true }).catch(() => {});
382
- }
383
- } catch {
384
- // Repo pode não ter commits ainda, isso é ok
385
- }
386
- }
387
-
388
- async applyStash(dir, ref = "stash@{0}") {
389
- const stashes = await this.listStash(dir);
390
- const idx = Number(ref.match(/\{(\d+)\}/)?.[1] || 0);
391
- const stash = stashes[idx];
392
- if (!stash) {
393
- throw createError("STASH_NOT_FOUND", {
394
- message: `Stash '${ref}' não encontrado`,
395
- requestedIndex: idx,
396
- availableStashes: stashes.length,
397
- stashList: stashes.map((s, i) => `stash@{${i}}: ${s.message}`).slice(0, 5)
398
- });
399
- }
400
-
401
- // Restaura arquivos do stash (suporta base64 e utf8)
402
- for (const [file, content] of Object.entries(stash.files)) {
403
- const fullPath = path.join(dir, file);
404
- fs.mkdirSync(path.dirname(fullPath), { recursive: true });
405
- if (stash.encoding === "base64") {
406
- fs.writeFileSync(fullPath, Buffer.from(content, "base64"));
407
- } else {
408
- fs.writeFileSync(fullPath, content);
409
- }
410
- }
411
- }
412
-
413
- async popStash(dir, ref = "stash@{0}") {
414
- await this.applyStash(dir, ref);
415
- await this.dropStash(dir, ref);
416
- }
417
-
418
- async dropStash(dir, ref = "stash@{0}") {
419
- const stashes = await this.listStash(dir);
420
- const idx = Number(ref.match(/\{(\d+)\}/)?.[1] || 0);
421
- stashes.splice(idx, 1);
422
-
423
- if (stashes.length === 0) {
424
- const stashFile = this._getStashFile(dir);
425
- if (fs.existsSync(stashFile)) fs.unlinkSync(stashFile);
426
- } else {
427
- fs.writeFileSync(this._getStashFile(dir), JSON.stringify(stashes, null, 2));
428
- }
429
- }
430
-
431
- async clearStash(dir) {
432
- const stashFile = this._getStashFile(dir);
433
- if (fs.existsSync(stashFile)) fs.unlinkSync(stashFile);
434
- }
435
-
436
- // ============ CONFIG ============
437
- async getConfig(dir, key, scope = "local") {
438
- // Primeiro tenta via isomorphic-git
439
- const value = await git.getConfig({ fs, dir, path: key }).catch(() => undefined);
440
- if (value !== undefined) return value;
441
-
442
- // Fallback: lê diretamente do arquivo config
443
- const configPath = path.join(dir, ".git", "config");
444
- if (!fs.existsSync(configPath)) return undefined;
445
-
446
- const content = fs.readFileSync(configPath, "utf8");
447
- const parts = key.split(".");
448
- const section = parts.slice(0, -1).join(".");
449
- const keyName = parts[parts.length - 1];
450
-
451
- // Parse manual do INI
452
- let currentSection = "";
453
- for (const line of content.split("\n")) {
454
- const secMatch = line.match(/^\[([^\]]+)\]$/);
455
- if (secMatch) {
456
- currentSection = secMatch[1].replace(/\s+"/g, ".").replace(/"/g, "");
457
- continue;
458
- }
459
-
460
- if (currentSection === section) {
461
- const kvMatch = line.match(/^\s*(\w+)\s*=\s*(.*)$/);
462
- if (kvMatch && kvMatch[1] === keyName) {
463
- return kvMatch[2].trim();
464
- }
465
- }
466
- }
467
-
468
- return undefined;
469
- }
470
-
471
- async setConfig(dir, key, value, scope = "local") {
472
- // Tenta via isomorphic-git primeiro
473
- try {
474
- await git.setConfig({ fs, dir, path: key, value });
475
-
476
- // Verifica se funcionou
477
- const check = await git.getConfig({ fs, dir, path: key }).catch(() => undefined);
478
- if (check === value) return;
479
- } catch {}
480
-
481
- // Fallback: escreve diretamente no arquivo config
482
- const configPath = path.join(dir, ".git", "config");
483
- let content = "";
484
- if (fs.existsSync(configPath)) {
485
- content = fs.readFileSync(configPath, "utf8");
486
- }
487
-
488
- const parts = key.split(".");
489
- const section = parts.slice(0, -1).join(".");
490
- const keyName = parts[parts.length - 1];
491
-
492
- // Verifica se a seção existe
493
- const sectionHeader = `[${section}]`;
494
- const sectionIndex = content.indexOf(sectionHeader);
495
-
496
- if (sectionIndex >= 0) {
497
- // Seção existe, procura se a chave já existe
498
- const lines = content.split("\n");
499
- let inSection = false;
500
- let keyFound = false;
501
-
502
- for (let i = 0; i < lines.length; i++) {
503
- const line = lines[i];
504
- if (line.trim().startsWith("[")) {
505
- inSection = line.trim() === sectionHeader;
506
- } else if (inSection) {
507
- const kvMatch = line.match(/^\s*(\w+)\s*=/);
508
- if (kvMatch && kvMatch[1] === keyName) {
509
- lines[i] = `\t${keyName} = ${value}`;
510
- keyFound = true;
511
- break;
512
- }
513
- }
514
- }
515
-
516
- if (!keyFound) {
517
- // Adiciona a chave na seção existente
518
- for (let i = 0; i < lines.length; i++) {
519
- if (lines[i].trim() === sectionHeader) {
520
- lines.splice(i + 1, 0, `\t${keyName} = ${value}`);
521
- break;
522
- }
523
- }
524
- }
525
-
526
- content = lines.join("\n");
527
- } else {
528
- // Seção não existe, adiciona no final
529
- content += `\n[${section}]\n\t${keyName} = ${value}\n`;
530
- }
531
-
532
- fs.writeFileSync(configPath, content);
533
- }
534
-
535
- async unsetConfig(dir, key, scope = "local") {
536
- // isomorphic-git não tem unset, então setamos para undefined
537
- const configPath = path.join(dir, ".git", "config");
538
- if (!fs.existsSync(configPath)) return;
539
- let content = fs.readFileSync(configPath, "utf8");
540
- const regex = new RegExp(`^\\s*${key.split(".").pop()}\\s*=.*$`, "gm");
541
- content = content.replace(regex, "");
542
- fs.writeFileSync(configPath, content);
543
- }
544
-
545
- async listConfig(dir, scope = "local") {
546
- const configPath = path.join(dir, ".git", "config");
547
- const items = {};
548
- if (!fs.existsSync(configPath)) return items;
549
- const content = fs.readFileSync(configPath, "utf8");
550
- let section = "";
551
- for (const line of content.split("\n")) {
552
- const secMatch = line.match(/^\[([^\]]+)\]$/);
553
- if (secMatch) { section = secMatch[1].replace(/\s+"/g, ".").replace(/"/g, ""); continue; }
554
- const kvMatch = line.match(/^\s*(\w+)\s*=\s*(.*)$/);
555
- if (kvMatch && section) items[`${section}.${kvMatch[1]}`] = kvMatch[2].trim();
556
- }
557
- return items;
558
- }
559
-
560
- // ============ FILES ============
561
- async listFiles(dir, ref = "HEAD") {
562
- const oid = await git.resolveRef({ fs, dir, ref });
563
- const { tree } = await git.readTree({ fs, dir, oid });
564
- const files = [];
565
- const walk = async (treePath, entries) => {
566
- for (const entry of entries) {
567
- const fullPath = treePath ? `${treePath}/${entry.path}` : entry.path;
568
- if (entry.type === "blob") files.push(fullPath);
569
- else if (entry.type === "tree") {
570
- const { tree: subTree } = await git.readTree({ fs, dir, oid: entry.oid });
571
- await walk(fullPath, subTree);
572
- }
573
- }
574
- };
575
- await walk("", tree);
576
- return files;
577
- }
578
-
579
- async readFile(dir, filepath, ref = "HEAD") {
580
- const oid = await git.resolveRef({ fs, dir, ref });
581
- const { blob } = await git.readBlob({ fs, dir, oid, filepath });
582
- return new TextDecoder().decode(blob);
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
+ }