@andre.buzeli/git-mcp 15.12.5

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.
@@ -0,0 +1,996 @@
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, findGitRoot } from "./repoHelpers.js";
6
+
7
+ // Common locations for Git on Windows, Linux, and macOS
8
+ const GIT_CANDIDATES = [
9
+ // Windows paths
10
+ "C:\\Program Files\\Git\\mingw64\\bin\\git.exe",
11
+ "C:\\Program Files\\Git\\bin\\git.exe",
12
+ "C:\\Program Files\\Git\\cmd\\git.exe",
13
+ "C:\\Users\\andre\\AppData\\Local\\Programs\\Git\\mingw64\\bin\\git.exe",
14
+ "C:\\Users\\andre\\AppData\\Local\\Programs\\Git\\bin\\git.exe",
15
+ "C:\\Users\\andre\\AppData\\Local\\Programs\\Git\\cmd\\git.exe",
16
+ // Linux/macOS paths
17
+ "/usr/bin/git",
18
+ "/usr/local/bin/git",
19
+ "/opt/homebrew/bin/git", // macOS Homebrew ARM
20
+ // Fallback to PATH
21
+ "git"
22
+ ];
23
+
24
+ // Timeout configurável via variável de ambiente (default: 120 segundos para suportar drives de rede)
25
+ const GIT_TIMEOUT = parseInt(process.env.GIT_TIMEOUT_MS || "120000", 10);
26
+
27
+ export class GitAdapter {
28
+ constructor(providerManager) {
29
+ this.pm = providerManager;
30
+ this.gitPath = "git"; // Initial default
31
+ this.gitEnv = {
32
+ ...process.env,
33
+ LANG: "en_US.UTF-8",
34
+ GIT_OPTIONAL_LOCKS: "0", // Evita operações de background que seguram lock (importante para drives de rede)
35
+ };
36
+ this.timeout = GIT_TIMEOUT;
37
+ this.resolvePromise = this._resolveGit();
38
+ }
39
+
40
+ async _resolveGit() {
41
+ console.error("[GitAdapter] Resolving git binary...");
42
+
43
+ for (const candidate of GIT_CANDIDATES) {
44
+ if (candidate !== "git" && !fs.existsSync(candidate)) continue;
45
+
46
+ try {
47
+ const version = await this._runSpawn(candidate, ["--version"], ".");
48
+ console.error(`[GitAdapter] Found git at: ${candidate} (${version.trim()})`);
49
+ this.gitPath = candidate;
50
+
51
+ // If we found a specific binary, we might need to adjust PATH for standard utils (ssh, etc)
52
+ // But for simply running git, the absolute path is key.
53
+ return;
54
+ } catch (e) {
55
+ // console.error(`[GitAdapter] Failed candidate ${candidate}: ${e.message}`);
56
+ }
57
+ }
58
+
59
+ console.error("[GitAdapter] WARNING: Could not resolve specific git binary. Using 'git' from PATH.");
60
+ this.gitPath = "git";
61
+ }
62
+
63
+ // Low-level spawn wrapper with timeout
64
+ _runSpawn(cmd, args, dir, env = {}) {
65
+ return new Promise((resolve, reject) => {
66
+ const cp = spawn(cmd, args, {
67
+ cwd: dir,
68
+ env: { ...this.gitEnv, ...env },
69
+ stdio: 'pipe'
70
+ });
71
+
72
+ let stdout = [];
73
+ let stderr = [];
74
+ let killed = false;
75
+
76
+ // Timeout handler
77
+ const timeoutId = setTimeout(() => {
78
+ killed = true;
79
+ cp.kill('SIGTERM');
80
+ reject(new Error(`Git command timed out after ${this.timeout}ms: git ${args.join(" ")}`));
81
+ }, this.timeout);
82
+
83
+ cp.stdout.on("data", d => stdout.push(d));
84
+ cp.stderr.on("data", d => stderr.push(d));
85
+
86
+ cp.on("error", e => {
87
+ clearTimeout(timeoutId);
88
+ reject(new Error(`Failed to spawn ${cmd}: ${e.message}`));
89
+ });
90
+
91
+ cp.on("close", code => {
92
+ clearTimeout(timeoutId);
93
+ if (killed) return; // Already rejected by timeout
94
+
95
+ const outStr = Buffer.concat(stdout).toString("utf8").trim();
96
+ const errStr = Buffer.concat(stderr).toString("utf8").trim();
97
+
98
+ if (code === 0) {
99
+ resolve(outStr);
100
+ } else {
101
+ const msg = errStr || outStr || `Exit code ${code}`;
102
+ reject(new Error(msg));
103
+ }
104
+ });
105
+ });
106
+ }
107
+
108
+ async _exec(dir, args, options = {}) {
109
+ await this.resolvePromise;
110
+
111
+ try {
112
+ const output = await this._runSpawn(this.gitPath, args, dir, options.env);
113
+ return output;
114
+ } catch (e) {
115
+ const msg = e.message || "";
116
+
117
+ // Auto-fix 1: dubious ownership error (common on network shares)
118
+ if (msg.includes("dubious ownership") && !options._retried) {
119
+ console.error("[GitAdapter] Auto-fix: dubious ownership, adding safe.directory...");
120
+ try {
121
+ await this._runSpawn(this.gitPath, ["config", "--global", "--add", "safe.directory", "*"], ".");
122
+ return await this._exec(dir, args, { ...options, _retried: true });
123
+ } catch (configError) {
124
+ console.error("[GitAdapter] Failed to configure safe.directory:", configError.message);
125
+ }
126
+ }
127
+
128
+ // Auto-fix 2: Lock file presente (outro processo git pode ter crashado)
129
+ if ((msg.includes(".git/index.lock") || (msg.includes("Unable to create") && msg.includes("lock"))) && !options._lockRetried) {
130
+ const lockPath = path.join(dir, ".git", "index.lock");
131
+ let shouldDelete = false;
132
+
133
+ if (fs.existsSync(lockPath)) {
134
+ try {
135
+ const stats = fs.statSync(lockPath);
136
+ const now = Date.now();
137
+ const ageMs = now - stats.mtimeMs;
138
+ // Se arquivo tem mais de 5 minutos ou se o erro já persiste (implicado pelo fato de estarmos aqui) -> Deletar
139
+ // Para redes lentas, 5 min é seguro.
140
+ if (ageMs > 5 * 60 * 1000) {
141
+ console.error(`[GitAdapter] Lock file is old (${Math.round(ageMs / 1000)}s), deleting...`);
142
+ shouldDelete = true;
143
+ } else {
144
+ // Se é recente, pode ser um processo ativo. Mas como falhou o comando, assumimos que bloqueou.
145
+ // Em execuções agenticas sequenciais, dificilmente tem outro processo legítimo rodando ao mesmo tempo.
146
+ console.error(`[GitAdapter] Lock file found. Agentic force cleanup activated.`);
147
+ shouldDelete = true;
148
+ }
149
+
150
+ if (shouldDelete) {
151
+ console.error("[GitAdapter] Auto-fix: removing stale .git/index.lock...");
152
+ try {
153
+ fs.unlinkSync(lockPath);
154
+ } catch (unlinkErr) {
155
+ console.error("[GitAdapter] Failed to delete lock file:", unlinkErr.message);
156
+ // Set shouldDelete to false so we don't try to retry if deletion failed
157
+ shouldDelete = false;
158
+ }
159
+
160
+ if (shouldDelete) {
161
+ // Espera um pouco para o sistema de arquivos (especialmente SMB) registrar
162
+ await new Promise(r => setTimeout(r, 5000));
163
+ return await this._exec(dir, args, { ...options, _lockRetried: true });
164
+ }
165
+ }
166
+ } catch (fsError) {
167
+ console.error("[GitAdapter] Error handling lock file:", fsError.message);
168
+ // Continua para lançar o erro original
169
+ }
170
+ }
171
+ }
172
+
173
+ // Auto-fix 3: CRLF warnings (just retry with autocrlf config)
174
+ if (msg.includes("CRLF will be replaced") && !options._crlfRetried) {
175
+ console.error("[GitAdapter] Auto-fix: configuring core.autocrlf...");
176
+ try {
177
+ await this._runSpawn(this.gitPath, ["config", "--local", "core.autocrlf", "true"], dir);
178
+ return await this._exec(dir, args, { ...options, _crlfRetried: true });
179
+ } catch {
180
+ // Continue with error
181
+ }
182
+ }
183
+
184
+ if (options.ignoreErrors) return "";
185
+
186
+ // Enhance error message with context
187
+ const cmdStr = `git ${args.join(" ")}`;
188
+ const enhancedError = new Error(`Command failed: ${cmdStr}\n${msg}`);
189
+ enhancedError.gitCommand = args[0];
190
+ enhancedError.originalMessage = msg;
191
+ throw enhancedError;
192
+ }
193
+ }
194
+
195
+ async _ensureGitRepo(dir) {
196
+ if (!fs.existsSync(path.join(dir, ".git"))) {
197
+ throw createError("NOT_A_GIT_REPO", {
198
+ message: `'${dir}' não é um repositório git`,
199
+ suggestion: "Use git-workflow init para criar um repositório"
200
+ });
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Verifica se o diretório é um repositório git
206
+ * @param {string} dir - Diretório para verificar
207
+ * @returns {boolean} - true se é um repo git
208
+ */
209
+ async isRepo(dir) {
210
+ return fs.existsSync(path.join(dir, ".git"));
211
+ }
212
+
213
+ /**
214
+ * Verifica integridade do repositório git
215
+ * @param {string} dir - Diretório do repositório
216
+ * @returns {object} - Status de integridade
217
+ */
218
+ async checkIntegrity(dir) {
219
+ await this._ensureGitRepo(dir);
220
+
221
+ try {
222
+ // Verificação rápida de objetos
223
+ const output = await this._exec(dir, ["fsck", "--connectivity-only"], { ignoreErrors: true });
224
+ const hasErrors = output.includes("error") || output.includes("missing") || output.includes("broken");
225
+
226
+ return {
227
+ ok: !hasErrors,
228
+ output: output || "Repository integrity OK",
229
+ suggestion: hasErrors ? "Execute 'git gc --prune=now' ou considere re-clonar o repositório" : null
230
+ };
231
+ } catch (e) {
232
+ return {
233
+ ok: false,
234
+ error: e.message,
235
+ suggestion: "Repositório pode estar corrompido. Tente: git gc --prune=now ou re-clone"
236
+ };
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Valida formato de URL de remote
242
+ * @param {string} url - URL para validar
243
+ * @returns {object} - Resultado da validação
244
+ */
245
+ validateRemoteUrl(url) {
246
+ if (!url || typeof url !== "string") {
247
+ return { valid: false, error: "URL vazia ou inválida" };
248
+ }
249
+
250
+ // Padrões válidos de URL git
251
+ const patterns = [
252
+ /^https?:\/\/[^\s]+\.git$/i, // https://github.com/user/repo.git
253
+ /^https?:\/\/[^\s]+$/i, // https://github.com/user/repo (sem .git)
254
+ /^git@[^\s]+:[^\s]+\.git$/i, // git@github.com:user/repo.git
255
+ /^git:\/\/[^\s]+\.git$/i, // git://github.com/user/repo.git
256
+ /^ssh:\/\/[^\s]+\.git$/i, // ssh://git@github.com/user/repo.git
257
+ ];
258
+
259
+ const isValid = patterns.some(p => p.test(url));
260
+
261
+ if (!isValid) {
262
+ return {
263
+ valid: false,
264
+ error: "Formato de URL inválido",
265
+ suggestion: "Use: https://github.com/user/repo.git ou git@github.com:user/repo.git"
266
+ };
267
+ }
268
+
269
+ return { valid: true, url };
270
+ }
271
+
272
+ // =================================================================
273
+ // Git Operations
274
+ // =================================================================
275
+
276
+ async init(dir, defaultBranch = "main") {
277
+ await fs.promises.mkdir(dir, { recursive: true });
278
+ // Try modern init
279
+ try {
280
+ await this._exec(dir, ["init", "-b", defaultBranch]);
281
+ } catch {
282
+ await this._exec(dir, ["init"]);
283
+ await this._exec(dir, ["symbolic-ref", "HEAD", `refs/heads/${defaultBranch}`]).catch(() => { });
284
+ }
285
+ }
286
+
287
+ async status(dir) {
288
+ await this._ensureGitRepo(dir);
289
+
290
+ // porcelain v1 is easy to parse
291
+ const out = await this._exec(dir, ["status", "--porcelain=v1"]);
292
+ const lines = out.split("\n").filter(Boolean);
293
+
294
+ const modified = [], created = [], deleted = [], notAdded = [], staged = [], files = [];
295
+
296
+ for (const line of lines) {
297
+ // XY PATH
298
+ const code = line.substring(0, 2);
299
+ const file = line.substring(3).trim();
300
+ const x = code[0], y = code[1];
301
+
302
+ const fileInfo = { path: file };
303
+
304
+ // X = Index
305
+ if (x !== ' ' && x !== '?') {
306
+ staged.push(file);
307
+ if (x === 'A') created.push(file);
308
+ if (x === 'D') deleted.push(file);
309
+ fileInfo.index = x === 'A' ? "new" : x === 'D' ? "deleted" : "modified";
310
+ }
311
+
312
+ // Y = Worktree
313
+ if (y !== ' ') {
314
+ if (y === 'M') { modified.push(file); fileInfo.workingDir = "modified"; }
315
+ if (y === 'D') { notAdded.push(file); fileInfo.workingDir = "deleted"; }
316
+ }
317
+
318
+ // Untracked
319
+ if (code === '??') {
320
+ notAdded.push(file);
321
+ fileInfo.workingDir = "new";
322
+ }
323
+
324
+ files.push(fileInfo);
325
+ }
326
+
327
+ const currentBranch = await this.getCurrentBranch(dir);
328
+ return {
329
+ modified,
330
+ created,
331
+ deleted,
332
+ renamed: [],
333
+ notAdded,
334
+ // Aliases para compatibilidade
335
+ not_added: notAdded,
336
+ staged,
337
+ conflicted: [],
338
+ currentBranch,
339
+ current: currentBranch, // Alias para compatibilidade
340
+ isClean: lines.length === 0,
341
+ files
342
+ };
343
+ }
344
+
345
+ async add(dir, files) {
346
+ if (files.includes(".")) {
347
+ await this._exec(dir, ["add", "."]);
348
+ await this._exec(dir, ["add", "--all"]); // Catch deletions if . didn't
349
+ } else {
350
+ await this._exec(dir, ["add", ...files]);
351
+ }
352
+ }
353
+
354
+ async remove(dir, files) {
355
+ await this._exec(dir, ["rm", "--cached", ...files]);
356
+ }
357
+
358
+ async getAuthor(dir) {
359
+ // Enforce token user as author if available
360
+ const profile = await this.pm.getPrioritizedUser().catch(() => null);
361
+
362
+ if (profile && profile.username) {
363
+ const { username, email } = profile;
364
+
365
+ // 1. Enforce Username
366
+ const currentName = await this.getConfig(dir, "user.name");
367
+ if (currentName !== username) {
368
+ console.error(`[GitAdapter] Enforcing author from token: ${username} (was: ${currentName || "unset"})`);
369
+ await this.setConfig(dir, "user.name", username);
370
+ }
371
+
372
+ // 2. Enforce Email
373
+ // If we got a real email from token, use it.
374
+ // If not, and local is unset/wrong, use noreply fallback.
375
+ const targetEmail = email || `${username}@users.noreply.${profile.provider === 'github' ? 'github.com' : 'gitea'}`;
376
+
377
+ const currentEmail = await this.getConfig(dir, "user.email");
378
+ if (currentEmail !== targetEmail) {
379
+ // Only update if it's different.
380
+ // NOTE: If user has a valid private email set locally, this might overwrite it via "enforcement".
381
+ // But user requested: "author always be the name of the user of the token , the email too"
382
+ console.error(`[GitAdapter] Enforcing email from token: ${targetEmail} (was: ${currentEmail || "unset"})`);
383
+ await this.setConfig(dir, "user.email", targetEmail);
384
+ }
385
+
386
+ return { name: username, email: targetEmail };
387
+ }
388
+
389
+ // Fallback logic for when no token is available or API failed
390
+ try {
391
+ const name = await this._exec(dir, ["config", "user.name"]);
392
+ const email = await this._exec(dir, ["config", "user.email"]);
393
+ if (name && email) return { name, email };
394
+ } catch { }
395
+
396
+ // If no config and no token user, fallback to generic
397
+ const defaultWho = "Git User";
398
+ const startEmail = `${defaultWho}@users.noreply`;
399
+
400
+ await this.setConfig(dir, "user.name", defaultWho);
401
+ await this.setConfig(dir, "user.email", startEmail);
402
+
403
+ return { name: defaultWho, email: startEmail };
404
+ }
405
+
406
+ async commit(dir, message) {
407
+ await this.getAuthor(dir);
408
+ await this._exec(dir, ["commit", "-m", message]);
409
+ return await this._exec(dir, ["rev-parse", "HEAD"]);
410
+ }
411
+
412
+ async getCurrentBranch(dir) {
413
+ try {
414
+ return await this._exec(dir, ["rev-parse", "--abbrev-ref", "HEAD"]);
415
+ } catch {
416
+ return "HEAD";
417
+ }
418
+ }
419
+
420
+ // ============ REMOTES ============
421
+ async ensureRemotes(dir, { githubUrl, giteaUrl }) {
422
+ const remotesOutput = await this._exec(dir, ["remote", "-v"]);
423
+ const remotes = new Set(remotesOutput.split("\n").map(l => l.split("\t")[0]).filter(Boolean));
424
+
425
+ const repoName = getRepoNameFromPath(dir);
426
+ const calcUrls = await this.pm.getRemoteUrls(repoName);
427
+ const targetGithub = calcUrls.github || githubUrl;
428
+ const targetGitea = calcUrls.gitea || giteaUrl;
429
+
430
+ const setRemote = async (name, url) => {
431
+ if (!url) return;
432
+ if (remotes.has(name)) {
433
+ await this._exec(dir, ["remote", "set-url", name, url]);
434
+ } else {
435
+ await this._exec(dir, ["remote", "add", name, url]);
436
+ }
437
+ };
438
+
439
+ await setRemote("github", targetGithub);
440
+ await setRemote("gitea", targetGitea);
441
+
442
+ const originUrl = targetGithub || targetGitea;
443
+ if (originUrl) await setRemote("origin", originUrl);
444
+ }
445
+
446
+ _getAuthHeader(url) {
447
+ const { githubToken, giteaToken } = getProvidersEnv();
448
+ if (url.includes("github.com") && githubToken) {
449
+ const basic = Buffer.from(`${githubToken}:x-oauth-basic`).toString("base64");
450
+ return `Authorization: Basic ${basic}`;
451
+ }
452
+ if (giteaToken) {
453
+ const basic = Buffer.from(`git:${giteaToken}`).toString("base64");
454
+ return `Authorization: Basic ${basic}`;
455
+ }
456
+ return null;
457
+ }
458
+
459
+ async pushOne(dir, remote, branch, force = false, setUpstream = false) {
460
+ const remoteUrl = await this._exec(dir, ["remote", "get-url", remote]);
461
+ const header = this._getAuthHeader(remoteUrl);
462
+
463
+ const args = [];
464
+ if (header) {
465
+ args.push("-c", `http.extraHeader=${header}`);
466
+ }
467
+ args.push("push");
468
+ if (force) args.push("--force");
469
+ if (setUpstream) {
470
+ args.push("-u", remote, branch);
471
+ } else {
472
+ args.push(remote, branch);
473
+ }
474
+
475
+ try {
476
+ await this._exec(dir, args);
477
+ } catch (e) {
478
+ const msg = e.message || "";
479
+ const msgLower = msg.toLowerCase();
480
+
481
+ // Auto-correção: Se branch não existe no remote, tenta com -u (set-upstream)
482
+ const branchNotExists = msgLower.includes("has no upstream branch") ||
483
+ msgLower.includes("no upstream branch") ||
484
+ (msgLower.includes("remote branch") && msgLower.includes("does not exist")) ||
485
+ msgLower.includes("ref_not_found") ||
486
+ (msgLower.includes("fatal") && msgLower.includes("current branch") && msgLower.includes("has no upstream"));
487
+
488
+ if (branchNotExists && !setUpstream && !force) {
489
+ console.error(`[GitAdapter] Auto-fix: branch '${branch}' não existe no remote '${remote}', tentando com --set-upstream (-u)...`);
490
+ return await this.pushOne(dir, remote, branch, force, true);
491
+ }
492
+
493
+ if (msg.includes("rejected") || msgLower.includes("non-fast-forward")) {
494
+ throw createError("PUSH_REJECTED", { message: msg, remote, branch });
495
+ }
496
+ throw mapExternalError(e, { type: "push", remote, branch });
497
+ }
498
+ }
499
+
500
+ async pushParallel(dir, branch, force = false) {
501
+ await this.ensureRemotes(dir, {});
502
+ const remotesStr = await this._exec(dir, ["remote"]);
503
+ const remotes = remotesStr.split("\n").filter(r => ["github", "gitea"].includes(r.trim()));
504
+
505
+ if (remotes.length === 0) {
506
+ throw createError("REMOTE_NOT_FOUND", { message: "Nenhum remote github/gitea configurado" });
507
+ }
508
+
509
+ // Parallel push com Promise.allSettled para melhor controle e auto-correção
510
+ const pushPromises = remotes.map(async (remote) => {
511
+ try {
512
+ await this.pushOne(dir, remote, branch, force, false);
513
+ return { remote, success: true, setUpstream: false };
514
+ } catch (error) {
515
+ const errorMsg = error.message || String(error);
516
+ const errorMsgLower = errorMsg.toLowerCase();
517
+
518
+ // Auto-correção 1: Se repositório não existe, cria automaticamente
519
+ const repoNotFound = errorMsgLower.includes("repository not found") ||
520
+ errorMsgLower.includes("repo not found") ||
521
+ (errorMsgLower.includes("fatal") && errorMsgLower.includes("repository") && errorMsgLower.includes("not found")) ||
522
+ (errorMsgLower.includes("not found") && (errorMsgLower.includes("remote") || errorMsgLower.includes("404")));
523
+
524
+ if (repoNotFound && !force) {
525
+ try {
526
+ console.error(`[GitAdapter] Auto-fix: repositório não existe no remote '${remote}', criando automaticamente...`);
527
+ const repoName = getRepoNameFromPath(dir);
528
+ const ensured = await this.pm.ensureRepos({ repoName, createIfMissing: true, isPublic: false });
529
+
530
+ // Atualiza remotes após criar repo
531
+ await this.ensureRemotes(dir, {});
532
+
533
+ // Tenta push novamente
534
+ await this.pushOne(dir, remote, branch, force, true); // Usa -u para criar branch também
535
+ return { remote, success: true, repoCreated: true, setUpstream: true };
536
+ } catch (repoCreateError) {
537
+ return { remote, success: false, error: `Falhou ao criar repo: ${repoCreateError.message}`, triedCreateRepo: true };
538
+ }
539
+ }
540
+
541
+ // Auto-correção 2: Se branch não existe no remote, tenta com --set-upstream
542
+ const branchNotExists = errorMsgLower.includes("has no upstream branch") ||
543
+ errorMsgLower.includes("no upstream branch") ||
544
+ (errorMsgLower.includes("remote branch") && errorMsgLower.includes("does not exist")) ||
545
+ errorMsgLower.includes("ref_not_found");
546
+
547
+ if (branchNotExists && !force) {
548
+ try {
549
+ console.error(`[GitAdapter] Auto-fix: branch '${branch}' não existe no remote '${remote}', tentando com --set-upstream...`);
550
+ await this.pushOne(dir, remote, branch, force, true);
551
+ return { remote, success: true, setUpstream: true };
552
+ } catch (retryError) {
553
+ return { remote, success: false, error: retryError.message, triedSetUpstream: true };
554
+ }
555
+ }
556
+
557
+ return { remote, success: false, error: errorMsg };
558
+ }
559
+ });
560
+
561
+ const results = await Promise.allSettled(pushPromises);
562
+ const successful = results.filter(r => r.status === "fulfilled" && r.value.success);
563
+ const failed = results.filter(r => r.status === "rejected" || !r.value.success);
564
+
565
+ if (successful.length === 0) {
566
+ throw createError("PUSH_REJECTED", {
567
+ message: "Push falhou para todos os remotes",
568
+ errors: failed.map(f => f.value?.error || f.reason?.message || "Erro desconhecido")
569
+ });
570
+ }
571
+
572
+ return {
573
+ pushed: successful.map(s => s.value.remote),
574
+ failed: failed.map(f => ({
575
+ remote: f.value?.remote || "unknown",
576
+ error: f.value?.error || f.reason?.message || "Erro desconhecido"
577
+ }))
578
+ };
579
+ }
580
+
581
+ // ============ BRANCHES/TAGS ============
582
+ async listBranches(dir, remote = false) {
583
+ const args = ["branch", "--format=%(refname:short)"];
584
+ if (remote) args.push("-r");
585
+ const out = await this._exec(dir, args);
586
+ return out.split("\n").filter(Boolean).map(b => b.trim());
587
+ }
588
+
589
+ async diffStats(dir, from = "HEAD", to) {
590
+ // git diff --shortstat from..to
591
+ const args = ["diff", "--shortstat", from];
592
+ if (to) args.push(to);
593
+
594
+ try {
595
+ const out = await this._exec(dir, args);
596
+ // Result: " 1 file changed, 1 insertion(+), 1 deletion(-)"
597
+ const filesChanged = (out.match(/(\d+) file(s?) changed/) || [])[1] || 0;
598
+ const insertions = (out.match(/(\d+) insertion(s?)/) || [])[1] || 0;
599
+ const deletions = (out.match(/(\d+) deletion(s?)/) || [])[1] || 0;
600
+
601
+ return {
602
+ filesChanged: parseInt(filesChanged),
603
+ insertions: parseInt(insertions),
604
+ deletions: parseInt(deletions)
605
+ };
606
+ } catch {
607
+ return { filesChanged: 0, insertions: 0, deletions: 0 };
608
+ }
609
+ }
610
+
611
+ async createBranch(dir, name) {
612
+ await this._exec(dir, ["branch", name]);
613
+ }
614
+
615
+ async deleteBranch(dir, name) {
616
+ try {
617
+ await this._exec(dir, ["branch", "-D", name]);
618
+ } catch (e) {
619
+ throw createError("BRANCH_NOT_FOUND", { message: e.message });
620
+ }
621
+ }
622
+
623
+ async renameBranch(dir, oldName, newName) {
624
+ await this._exec(dir, ["branch", "-m", oldName, newName]);
625
+ }
626
+
627
+ async checkout(dir, ref) {
628
+ try {
629
+ await this._exec(dir, ["checkout", ref]);
630
+ } catch (e) {
631
+ throw createError("REF_NOT_FOUND", { message: e.message });
632
+ }
633
+ }
634
+
635
+ async listTags(dir) {
636
+ const out = await this._exec(dir, ["tag"]);
637
+ return out.split("\n").filter(Boolean);
638
+ }
639
+
640
+ async createTag(dir, tag, ref = "HEAD", message = "") {
641
+ if (message) {
642
+ await this._exec(dir, ["tag", "-a", tag, ref, "-m", message]);
643
+ } else {
644
+ await this._exec(dir, ["tag", tag, ref]);
645
+ }
646
+ }
647
+
648
+ async deleteTag(dir, tag) {
649
+ await this._exec(dir, ["tag", "-d", tag]);
650
+ }
651
+
652
+ async pushTag(dir, remote, tag) {
653
+ const url = await this._exec(dir, ["remote", "get-url", remote]);
654
+ const header = this._getAuthHeader(url);
655
+ const args = [];
656
+ if (header) args.push("-c", `http.extraHeader=${header}`);
657
+ args.push("push", remote, tag);
658
+
659
+ await this._exec(dir, args);
660
+ }
661
+
662
+ // ============ STASH (Native) ============
663
+ async listStash(dir) {
664
+ const out = await this._exec(dir, ["stash", "list", "--pretty=format:%gd: %gs"]);
665
+ return out.split("\n").filter(Boolean).map((line, i) => ({
666
+ index: i,
667
+ message: line.split(":").slice(1).join(":").trim()
668
+ }));
669
+ }
670
+
671
+ async saveStash(dir, message = "WIP", includeUntracked = false) {
672
+ const args = ["stash", "push"];
673
+ if (includeUntracked) args.push("-u");
674
+ args.push("-m", message);
675
+ await this._exec(dir, args);
676
+ }
677
+
678
+ async applyStash(dir, ref = "stash@{0}") {
679
+ await this._exec(dir, ["stash", "apply", ref]);
680
+ }
681
+
682
+ async popStash(dir, ref = "stash@{0}") {
683
+ await this._exec(dir, ["stash", "pop", ref]);
684
+ }
685
+
686
+ async dropStash(dir, ref = "stash@{0}") {
687
+ await this._exec(dir, ["stash", "drop", ref]);
688
+ }
689
+
690
+ async clearStash(dir) {
691
+ await this._exec(dir, ["stash", "clear"]);
692
+ }
693
+
694
+ // ============ CONFIG ============
695
+ async getConfig(dir, key) {
696
+ try {
697
+ return await this._exec(dir, ["config", "--get", key]);
698
+ } catch { return undefined; }
699
+ }
700
+
701
+ async setConfig(dir, key, value, scope = "local") {
702
+ const args = ["config"];
703
+ if (scope === "global") args.push("--global");
704
+ else if (scope === "system") args.push("--system");
705
+ else args.push("--local"); // default
706
+ args.push(key, value);
707
+ await this._exec(dir, args);
708
+ }
709
+
710
+ async unsetConfig(dir, key, scope = "local") {
711
+ const args = ["config"];
712
+ if (scope === "global") args.push("--global");
713
+ else if (scope === "system") args.push("--system");
714
+ else args.push("--local");
715
+ args.push("--unset", key);
716
+ try { await this._exec(dir, args); } catch { }
717
+ }
718
+
719
+ async listConfig(dir) {
720
+ const out = await this._exec(dir, ["config", "--list"]);
721
+ const items = {};
722
+ out.split("\n").filter(Boolean).forEach(line => {
723
+ const [k, ...v] = line.split("=");
724
+ items[k] = v.join("=");
725
+ });
726
+ return items;
727
+ }
728
+
729
+ // ============ FILES/LOG ============
730
+ async listFiles(dir, ref = "HEAD") {
731
+ const out = await this._exec(dir, ["ls-tree", "-r", "--name-only", ref]);
732
+ return out.split("\n").filter(Boolean);
733
+ }
734
+
735
+ async readFile(dir, filepath, ref = "HEAD") {
736
+ return await this._exec(dir, ["show", `${ref}:${filepath}`]);
737
+ }
738
+
739
+ async log(dir, { ref = "HEAD", maxCount = 50 } = {}) {
740
+ const format = "%H|%h|%s|%an|%ae|%aI";
741
+ const out = await this._exec(dir, ["log", "-n", maxCount.toString(), `--pretty=format:${format}`, ref]);
742
+ return out.split("\n").filter(Boolean).map(line => {
743
+ const [sha, short, message, name, email, date] = line.split("|");
744
+ return { sha, message, author: { name, email, timestamp: new Date(date).getTime() / 1000 }, date };
745
+ });
746
+ }
747
+
748
+ async fetch(dir, remote, branch) {
749
+ const url = await this._exec(dir, ["remote", "get-url", remote]);
750
+ const header = this._getAuthHeader(url);
751
+ const args = [];
752
+ if (header) args.push("-c", `http.extraHeader=${header}`);
753
+ args.push("fetch", remote, branch);
754
+ await this._exec(dir, args);
755
+ }
756
+
757
+ async pull(dir, remote, branch) {
758
+ const url = await this._exec(dir, ["remote", "get-url", remote]);
759
+ const header = this._getAuthHeader(url);
760
+ const args = [];
761
+ if (header) args.push("-c", `http.extraHeader=${header}`);
762
+ args.push("pull", remote, branch);
763
+
764
+ try {
765
+ await this._exec(dir, args);
766
+ } catch (e) {
767
+ if (e.message.includes("conflict")) throw createError("MERGE_CONFLICT", { message: "Conflict on pull" });
768
+ throw e;
769
+ }
770
+ }
771
+
772
+ // ============ RESET/MERGE ============
773
+ async resetSoft(dir, ref) { await this._exec(dir, ["reset", "--soft", ref]); }
774
+ async resetMixed(dir, ref) { await this._exec(dir, ["reset", "--mixed", ref]); }
775
+ async resetHard(dir, ref) { await this._exec(dir, ["reset", "--hard", ref]); }
776
+
777
+ async resetHardClean(dir, ref) {
778
+ await this._exec(dir, ["reset", "--hard", ref]);
779
+ const cleanResult = await this.cleanUntracked(dir);
780
+ return cleanResult;
781
+ }
782
+
783
+ async getMergeStatus(dir) {
784
+ const mergeHeadPath = path.join(dir, ".git", "MERGE_HEAD");
785
+ const isMerging = fs.existsSync(mergeHeadPath);
786
+
787
+ if (!isMerging) {
788
+ return { merging: false, message: "Nenhum merge em andamento" };
789
+ }
790
+
791
+ // Get conflicted files
792
+ try {
793
+ const diff = await this._exec(dir, ["diff", "--name-only", "--diff-filter=U"]);
794
+ const conflicts = diff.split("\n").filter(Boolean);
795
+ return {
796
+ merging: true,
797
+ conflicts,
798
+ message: conflicts.length > 0
799
+ ? `Merge em andamento com ${conflicts.length} conflito(s)`
800
+ : "Merge em andamento sem conflitos"
801
+ };
802
+ } catch {
803
+ return { merging: true, conflicts: [], message: "Merge em andamento" };
804
+ }
805
+ }
806
+
807
+ async abortMerge(dir) {
808
+ await this._exec(dir, ["merge", "--abort"]);
809
+ }
810
+
811
+ async merge(dir, branch, options = {}) {
812
+ const { message, noCommit, squash } = options;
813
+ const args = ["merge"];
814
+ if (noCommit) args.push("--no-commit");
815
+ if (squash) args.push("--squash");
816
+ if (message) args.push("-m", message);
817
+ args.push(branch);
818
+
819
+ try {
820
+ const result = await this._exec(dir, args);
821
+ return { merged: true, message: result };
822
+ } catch (e) {
823
+ if (e.message.includes("conflict")) {
824
+ const diff = await this._exec(dir, ["diff", "--name-only", "--diff-filter=U"]);
825
+ const conflicts = diff.split("\n").filter(Boolean);
826
+ return { merged: false, conflicts, message: "Merge conflicts detected" };
827
+ }
828
+ if (e.message.includes("Already up to date")) {
829
+ return { merged: true, message: "Already up to date" };
830
+ }
831
+ throw e;
832
+ }
833
+ }
834
+
835
+ // ============ DIFF/CLONE ============
836
+ async diff(dir, options = {}) { return await this._exec(dir, ["diff"]); }
837
+ async diffCommits(dir, from, to) { return await this._exec(dir, ["diff", from, to]); }
838
+ async diffStats(dir, from, to) { const out = await this._exec(dir, ["diff", "--stat", from, to]); return { message: out }; }
839
+
840
+ async clone(url, dir, options = {}) {
841
+ const { branch, depth, singleBranch } = options;
842
+ const args = ["clone"];
843
+ if (branch) args.push("-b", branch);
844
+ if (depth) args.push("--depth", depth.toString());
845
+ if (singleBranch) args.push("--single-branch");
846
+ args.push(url, ".");
847
+
848
+ const header = this._getAuthHeader(url);
849
+ const cmdArgs = [];
850
+ if (header) cmdArgs.push("-c", `http.extraHeader=${header}`);
851
+ cmdArgs.push(...args);
852
+
853
+ await fs.promises.mkdir(dir, { recursive: true });
854
+ await this._exec(dir, cmdArgs);
855
+ return { branch: branch || "HEAD" };
856
+ }
857
+
858
+ async listGitignore(dir) {
859
+ if (fs.existsSync(path.join(dir, ".gitignore"))) {
860
+ return fs.readFileSync(path.join(dir, ".gitignore"), "utf8").split("\n");
861
+ }
862
+ return [];
863
+ }
864
+
865
+ async createGitignore(dir, patterns) {
866
+ fs.writeFileSync(path.join(dir, ".gitignore"), patterns.join("\n"));
867
+ }
868
+
869
+ async addToGitignore(dir, patterns) {
870
+ fs.appendFileSync(path.join(dir, ".gitignore"), "\n" + patterns.join("\n"));
871
+ }
872
+
873
+ async removeFromGitignore(dir, patterns) {
874
+ const p = path.join(dir, ".gitignore");
875
+ if (!fs.existsSync(p)) return;
876
+ let c = fs.readFileSync(p, "utf8");
877
+ patterns.forEach(pat => c = c.replace(pat, ""));
878
+ fs.writeFileSync(p, c);
879
+ }
880
+
881
+ async listRemotesRaw(dir) {
882
+ return this.listRemotes(dir);
883
+ }
884
+
885
+ async cleanUntracked(dir) {
886
+ // dry-run first to get list
887
+ const dry = await this._exec(dir, ["clean", "-n", "-d"]);
888
+ const files = dry.split("\n").filter(Boolean).map(l => l.replace("Would remove ", "").trim());
889
+
890
+ if (files.length > 0) {
891
+ await this._exec(dir, ["clean", "-f", "-d"]);
892
+ }
893
+ return { cleaned: files };
894
+ }
895
+
896
+ async listRemotes(dir) {
897
+ const out = await this._exec(dir, ["remote", "-v"]);
898
+ const map = new Map();
899
+ out.split("\n").filter(Boolean).forEach(line => {
900
+ const [name, rest] = line.split("\t");
901
+ const url = rest.split(" ")[0];
902
+ map.set(name, { remote: name, url });
903
+ });
904
+ return Array.from(map.values());
905
+ }
906
+
907
+ // ============ GIT LFS SUPPORT ============
908
+
909
+ /**
910
+ * Verifica se Git LFS está instalado
911
+ */
912
+ async isLfsInstalled() {
913
+ try {
914
+ await this._exec(".", ["lfs", "version"]);
915
+ return true;
916
+ } catch {
917
+ return false;
918
+ }
919
+ }
920
+
921
+ /**
922
+ * Inicializa Git LFS no repositório
923
+ */
924
+ async lfsInstall(dir) {
925
+ await this._exec(dir, ["lfs", "install"]);
926
+ }
927
+
928
+ /**
929
+ * Rastreia arquivos com Git LFS
930
+ * @param {string} dir - Diretório do repositório
931
+ * @param {string[]} patterns - Padrões de arquivo (ex: ["*.psd", "*.zip"])
932
+ */
933
+ async lfsTrack(dir, patterns) {
934
+ for (const pattern of patterns) {
935
+ await this._exec(dir, ["lfs", "track", pattern]);
936
+ }
937
+ // Adiciona .gitattributes automaticamente
938
+ await this._exec(dir, ["add", ".gitattributes"]);
939
+ }
940
+
941
+ /**
942
+ * Remove rastreamento LFS de padrões
943
+ */
944
+ async lfsUntrack(dir, patterns) {
945
+ for (const pattern of patterns) {
946
+ await this._exec(dir, ["lfs", "untrack", pattern]);
947
+ }
948
+ }
949
+
950
+ /**
951
+ * Lista arquivos rastreados por LFS
952
+ */
953
+ async lfsList(dir) {
954
+ const out = await this._exec(dir, ["lfs", "ls-files"]);
955
+ return out.split("\n").filter(Boolean).map(line => {
956
+ const parts = line.split(" - ");
957
+ return {
958
+ oid: parts[0]?.trim(),
959
+ file: parts[1]?.trim()
960
+ };
961
+ });
962
+ }
963
+
964
+ /**
965
+ * Lista padrões rastreados por LFS
966
+ */
967
+ async lfsTrackedPatterns(dir) {
968
+ const out = await this._exec(dir, ["lfs", "track"]);
969
+ return out.split("\n")
970
+ .filter(line => line.trim().startsWith("*") || line.includes("("))
971
+ .map(line => line.trim().split(" ")[0]);
972
+ }
973
+
974
+ /**
975
+ * Baixa arquivos LFS
976
+ */
977
+ async lfsPull(dir) {
978
+ await this._exec(dir, ["lfs", "pull"]);
979
+ }
980
+
981
+ /**
982
+ * Envia arquivos LFS
983
+ */
984
+ async lfsPush(dir, remote = "origin") {
985
+ await this._exec(dir, ["lfs", "push", "--all", remote]);
986
+ }
987
+
988
+ /**
989
+ * Status do Git LFS
990
+ */
991
+ async lfsStatus(dir) {
992
+ const out = await this._exec(dir, ["lfs", "status"]);
993
+ return out;
994
+ }
995
+ }
996
+