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