@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,443 @@
1
+ import Ajv from "ajv";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { MCPError, asToolError, asToolResult, errorToResponse, createError } from "../utils/errors.js";
5
+ import { getRepoNameFromPath, detectProjectType, GITIGNORE_TEMPLATES, validateProjectPath, withRetry } from "../utils/repoHelpers.js";
6
+
7
+ const ajv = new Ajv({ allErrors: true });
8
+
9
+ export function createGitWorkflowTool(pm, git) {
10
+ const inputSchema = {
11
+ type: "object",
12
+ properties: {
13
+ projectPath: {
14
+ type: "string",
15
+ description: "Caminho absoluto do diretório do projeto (ex: 'C:/Users/user/projeto' ou '/home/user/projeto')"
16
+ },
17
+ action: {
18
+ type: "string",
19
+ enum: ["init", "status", "add", "remove", "commit", "push", "ensure-remotes", "clean", "update"],
20
+ description: `Ação a executar:
21
+ - init: Inicializa repositório git local E cria repos no GitHub/Gitea automaticamente
22
+ - status: Retorna arquivos modificados, staged, untracked (use ANTES de commit para ver o que mudou)
23
+ - add: Adiciona arquivos ao staging (use ANTES de commit)
24
+ - remove: Remove arquivos do staging
25
+ - commit: Cria commit com os arquivos staged (use DEPOIS de add)
26
+ - push: Envia commits para GitHub E Gitea em paralelo (use DEPOIS de commit)
27
+ - ensure-remotes: Configura remotes GitHub e Gitea (use se push falhar por falta de remote)
28
+ - clean: Remove arquivos não rastreados do working directory
29
+ - update: RECOMENDADO - Executa fluxo completo automatizado (status → add → commit → push) em uma única chamada`
30
+ },
31
+ files: {
32
+ type: "array",
33
+ items: { type: "string" },
34
+ description: "Lista de arquivos para add/remove. Use ['.'] para todos os arquivos. Ex: ['src/index.js', 'package.json']"
35
+ },
36
+ message: {
37
+ type: "string",
38
+ description: "Mensagem do commit. Obrigatório para action='commit'. Ex: 'feat: adiciona nova funcionalidade'"
39
+ },
40
+ force: {
41
+ type: "boolean",
42
+ description: "Force push (use apenas se push normal falhar com erro de histórico divergente). Default: false"
43
+ },
44
+ createGitignore: {
45
+ type: "boolean",
46
+ description: "Se true, cria .gitignore padrão baseado no tipo de projeto (action='init'). Default: true"
47
+ },
48
+ isPublic: {
49
+ type: "boolean",
50
+ description: "Se true, repositório será PÚBLICO. Default: false (privado). Aplica-se a action='init' e 'ensure-remotes'"
51
+ },
52
+ dryRun: {
53
+ type: "boolean",
54
+ description: "Se true, simula a operação sem executar (útil para testes). Default: false"
55
+ },
56
+ skipIfClean: {
57
+ type: "boolean",
58
+ description: "Para action='update': se true, pula silenciosamente se não houver mudanças. Default: false"
59
+ },
60
+ gitignore: {
61
+ type: "array",
62
+ items: {
63
+ type: "string"
64
+ },
65
+ description: "Para action='update': lista de padrões para adicionar ao .gitignore antes de atualizar"
66
+ },
67
+ organization: {
68
+ type: "string",
69
+ description: "Opcional. Nome da organização no GitHub/Gitea onde o repositório será criado/gerenciado. Se não informado, usa a conta pessoal. Ex: 'automacao-casa', 'cliente-joao'"
70
+ }
71
+ },
72
+ required: ["projectPath", "action"],
73
+ additionalProperties: false
74
+ };
75
+
76
+ const description = `Operações Git essenciais com sincronização automática GitHub + Gitea.
77
+
78
+ ⭐ RECOMENDADO - FLUXO AUTOMATIZADO:
79
+ • action="update" → Executa status, add, commit e push em uma única chamada
80
+ • Exemplo: { "projectPath": "/path", "action": "update", "message": "feat: nova feature" }
81
+
82
+ FLUXO MANUAL (se preferir controle individual):
83
+ 1. git-workflow status → ver arquivos modificados
84
+ 2. git-workflow add → adicionar arquivos ao staging
85
+ 3. git-workflow commit → criar commit
86
+ 4. git-workflow push → enviar para GitHub e Gitea
87
+
88
+ QUANDO USAR CADA ACTION:
89
+ - update: ⭐ RECOMENDADO - Fluxo completo automatizado (status → add → commit → push)
90
+ - status: Para verificar estado atual do repositório
91
+ - add: Quando há arquivos modificados para commitar
92
+ - commit: Após add, para salvar as mudanças
93
+ - push: Após commit, para sincronizar com remotes
94
+ - init: Apenas uma vez, para novos projetos (cria .gitignore automaticamente)
95
+ - ensure-remotes: Se push falhar por falta de configuração
96
+ - clean: Limpar arquivos não rastreados
97
+
98
+ EXEMPLOS DE USO:
99
+ • ⭐ Atualizar tudo: { "projectPath": "/path/to/project", "action": "update", "message": "feat: descrição" }
100
+ • Iniciar projeto: { "projectPath": "/path/to/project", "action": "init" }
101
+ • Ver mudanças: { "projectPath": "/path/to/project", "action": "status" }`;
102
+
103
+ async function handle(args) {
104
+ const validate = ajv.compile(inputSchema);
105
+ if (!validate(args || {})) {
106
+ return asToolError("VALIDATION_ERROR", "Parâmetros inválidos", validate.errors);
107
+ }
108
+ const { projectPath, action } = args;
109
+ try {
110
+ // #region agent log
111
+ if (process.env.DEBUG_AGENT_LOG) {
112
+ fetch('http://127.0.0.1:8243/ingest/a7114eec-653b-43b0-9f09-7073baee17bf', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: 'debug-session', runId: 'run1', hypothesisId: 'H1', location: 'git-workflow.js:handle-entry', message: 'handle start', data: { action, projectPath }, timestamp: Date.now() }) }).catch(() => { });
113
+ }
114
+ // #endregion
115
+
116
+ validateProjectPath(projectPath);
117
+ // #region agent log
118
+ if (process.env.DEBUG_AGENT_LOG) {
119
+ fetch('http://127.0.0.1:8243/ingest/a7114eec-653b-43b0-9f09-7073baee17bf', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: 'debug-session', runId: 'run1', hypothesisId: 'H1', location: 'git-workflow.js:handle-validated', message: 'projectPath validated', data: { action }, timestamp: Date.now() }) }).catch(() => { });
120
+ }
121
+ // #endregion
122
+ if (action === "init") {
123
+ if (args.dryRun) {
124
+ return asToolResult({
125
+ success: true,
126
+ dryRun: true,
127
+ message: "DRY RUN: Repositório seria inicializado localmente e nos providers",
128
+ repoName: getRepoNameFromPath(projectPath),
129
+ gitignoreCreated: shouldCreateGitignore
130
+ });
131
+ }
132
+
133
+ const isRepo = await git.isRepo(projectPath);
134
+ if (!isRepo) {
135
+ await git.init(projectPath);
136
+ }
137
+
138
+ // Criar .gitignore baseado no tipo de projeto (apenas se não existir ou se for novo repo)
139
+ const shouldCreateGitignore = args.createGitignore !== false;
140
+ let gitignoreCreated = false;
141
+
142
+ if (shouldCreateGitignore) {
143
+ const hasGitignore = fs.existsSync(path.join(projectPath, ".gitignore"));
144
+ if (!hasGitignore) {
145
+ const projectType = detectProjectType(projectPath);
146
+ const patterns = GITIGNORE_TEMPLATES[projectType] || GITIGNORE_TEMPLATES.general;
147
+ await git.createGitignore(projectPath, patterns);
148
+ gitignoreCreated = true;
149
+ }
150
+ }
151
+
152
+ const repo = getRepoNameFromPath(projectPath);
153
+ const isPublic = args.isPublic === true; // Default: privado
154
+ const organization = args.organization || undefined;
155
+ const ensured = await pm.ensureRepos({ repoName: repo, createIfMissing: true, isPublic, organization });
156
+
157
+ // Configurar remotes com org se fornecida
158
+ const urls = await pm.getRemoteUrls(repo, organization);
159
+ await git.ensureRemotes(projectPath, { githubUrl: urls.github || "", giteaUrl: urls.gitea || "" });
160
+
161
+ return asToolResult({
162
+ success: true,
163
+ ensured,
164
+ isPrivate: !isPublic,
165
+ organization: organization || undefined,
166
+ gitignoreCreated,
167
+ message: "Repositório inicializado localmente e nos providers" + (organization ? ` [org: ${organization}]` : "") + (gitignoreCreated ? " (.gitignore criado)" : "") + (!isPublic ? " [PRIVADO]" : " [PÚBLICO]")
168
+ });
169
+ }
170
+ if (action === "status") {
171
+ const st = await git.status(projectPath);
172
+ // #region agent log
173
+ if (process.env.DEBUG_AGENT_LOG) {
174
+ fetch('http://127.0.0.1:8243/ingest/a7114eec-653b-43b0-9f09-7073baee17bf', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: 'debug-session', runId: 'run1', hypothesisId: 'H2', location: 'git-workflow.js:status', message: 'status result', data: { modified: st.modified.length, created: st.created.length, deleted: st.deleted.length, notAdded: st.not_added.length, isClean: st.isClean }, timestamp: Date.now() }) }).catch(() => { });
175
+ }
176
+ // #endregion
177
+
178
+ if (args.dryRun) {
179
+ return asToolResult({
180
+ success: true,
181
+ dryRun: true,
182
+ message: "DRY RUN: Status seria verificado",
183
+ ...st
184
+ });
185
+ }
186
+
187
+ // Adicionar contexto para AI Agent decidir próximo passo
188
+ const hasUnstaged = st.not_added?.length > 0 || st.modified?.length > 0 || st.created?.length > 0;
189
+ const hasStaged = st.staged?.length > 0;
190
+ const hasConflicts = st.conflicted?.length > 0;
191
+
192
+ let _aiContext = {
193
+ needsAdd: hasUnstaged && !hasStaged,
194
+ readyToCommit: hasStaged && !hasConflicts,
195
+ hasConflicts: hasConflicts,
196
+ isClean: st.isClean,
197
+ suggestedAction: null
198
+ };
199
+
200
+ if (hasConflicts) {
201
+ _aiContext.suggestedAction = "Resolva os conflitos manualmente antes de continuar";
202
+ } else if (hasStaged) {
203
+ _aiContext.suggestedAction = "Use action='commit' com uma mensagem descritiva";
204
+ } else if (hasUnstaged) {
205
+ _aiContext.suggestedAction = "Use action='add' com files=['.'] para adicionar todas as mudanças";
206
+ } else {
207
+ _aiContext.suggestedAction = "Working tree limpa. Modifique arquivos ou use action='push' se há commits pendentes";
208
+ }
209
+
210
+ return asToolResult({ ...st, _aiContext }, { tool: 'workflow', action: 'status' });
211
+ }
212
+ if (action === "add") {
213
+ const files = Array.isArray(args.files) && args.files.length ? args.files : ["."];
214
+
215
+ if (args.dryRun) {
216
+ return asToolResult({
217
+ success: true,
218
+ dryRun: true,
219
+ message: `DRY RUN: Arquivos seriam adicionados: ${files.join(", ")}`,
220
+ files
221
+ });
222
+ }
223
+
224
+ await git.add(projectPath, files);
225
+ return asToolResult({ success: true, files }, { tool: 'workflow', action: 'add' });
226
+ }
227
+ if (action === "remove") {
228
+ const files = Array.isArray(args.files) ? args.files : [];
229
+ await git.remove(projectPath, files);
230
+ return asToolResult({ success: true, files });
231
+ }
232
+ if (action === "commit") {
233
+ if (!args.message) {
234
+ return asToolError("MISSING_PARAMETER", "message é obrigatório para commit", { parameter: "message" });
235
+ }
236
+
237
+ if (args.dryRun) {
238
+ return asToolResult({
239
+ success: true,
240
+ dryRun: true,
241
+ message: `DRY RUN: Commit seria criado com mensagem: "${args.message}"`
242
+ });
243
+ }
244
+
245
+ const sha = await git.commit(projectPath, args.message);
246
+ // #region agent log
247
+ if (process.env.DEBUG_AGENT_LOG) {
248
+ fetch('http://127.0.0.1:8243/ingest/a7114eec-653b-43b0-9f09-7073baee17bf', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: 'debug-session', runId: 'run1', hypothesisId: 'H3', location: 'git-workflow.js:commit', message: 'commit created', data: { sha, message: args.message }, timestamp: Date.now() }) }).catch(() => { });
249
+ }
250
+ // #endregion
251
+ return asToolResult({ success: true, sha, message: args.message }, { tool: 'workflow', action: 'commit' });
252
+ }
253
+ if (action === "clean") {
254
+ if (args.dryRun) {
255
+ const result = await git.cleanUntracked(projectPath);
256
+ return asToolResult({
257
+ success: true,
258
+ dryRun: true,
259
+ message: `DRY RUN: ${result.cleaned.length} arquivo(s) seriam removidos`,
260
+ wouldClean: result.cleaned
261
+ });
262
+ }
263
+
264
+ const result = await git.cleanUntracked(projectPath);
265
+ return asToolResult({
266
+ success: true,
267
+ ...result,
268
+ message: result.cleaned.length > 0
269
+ ? `${result.cleaned.length} arquivo(s) não rastreados removidos`
270
+ : "Nenhum arquivo para limpar"
271
+ });
272
+ }
273
+ if (action === "ensure-remotes") {
274
+ const repo = getRepoNameFromPath(projectPath);
275
+ const isPublic = args.isPublic === true; // Default: privado
276
+ const organization = args.organization || undefined;
277
+
278
+ if (args.dryRun) {
279
+ const ensured = await pm.ensureRepos({ repoName: repo, createIfMissing: false, isPublic, organization }); // Don't create for dry run
280
+ const urls = await pm.getRemoteUrls(repo, organization);
281
+
282
+ return asToolResult({
283
+ success: true,
284
+ dryRun: true,
285
+ message: "DRY RUN: Remotes seriam configurados" + (organization ? ` [org: ${organization}]` : ""),
286
+ repo,
287
+ organization: organization || undefined,
288
+ githubUrl: urls.github || "",
289
+ giteaUrl: urls.gitea || "",
290
+ ensured
291
+ });
292
+ }
293
+
294
+ const ensured = await pm.ensureRepos({ repoName: repo, createIfMissing: true, isPublic, organization });
295
+ const urls = await pm.getRemoteUrls(repo, organization);
296
+ await git.ensureRemotes(projectPath, { githubUrl: urls.github || "", giteaUrl: urls.gitea || "" });
297
+ const remotes = await git.listRemotes(projectPath);
298
+ return asToolResult({ success: true, ensured, remotes, isPrivate: !isPublic, organization: organization || undefined }, { tool: 'workflow', action: 'ensure-remotes' });
299
+ }
300
+ if (action === "push") {
301
+ const branch = await git.getCurrentBranch(projectPath);
302
+ const force = !!args.force;
303
+
304
+ if (args.dryRun) {
305
+ return asToolResult({
306
+ success: true,
307
+ dryRun: true,
308
+ message: `DRY RUN: Push seria executado na branch '${branch}'${force ? ' (force)' : ''}`,
309
+ branch,
310
+ force
311
+ });
312
+ }
313
+
314
+ // Retry logic for push (often fails due to network or concurrent updates)
315
+ const result = await withRetry(
316
+ () => git.pushParallel(projectPath, branch, force),
317
+ 3,
318
+ "push"
319
+ );
320
+
321
+ return asToolResult({ success: true, branch, ...result });
322
+ }
323
+
324
+ // ============ UPDATE - FLUXO AUTOMATIZADO COMPLETO ============
325
+ if (action === "update") {
326
+ if (!args.message) {
327
+ return asToolError("MISSING_PARAMETER", "message é obrigatório para update", { parameter: "message" });
328
+ }
329
+
330
+ const files = Array.isArray(args.files) && args.files.length ? args.files : ["."];
331
+ const force = !!args.force;
332
+ const skipIfClean = !!args.skipIfClean;
333
+ const gitignorePatterns = Array.isArray(args.gitignore) ? args.gitignore : [];
334
+
335
+ // 0. Gitignore (se solicitado)
336
+ let gitignored = [];
337
+ if (gitignorePatterns.length > 0) {
338
+ if (args.dryRun) {
339
+ gitignored = gitignorePatterns;
340
+ } else {
341
+ await git.addToGitignore(projectPath, gitignorePatterns);
342
+ gitignored = gitignorePatterns;
343
+ }
344
+ }
345
+
346
+ // 1. Status para ver o que mudou
347
+ const status = await git.status(projectPath);
348
+
349
+ const hasChanges = !status.isClean ||
350
+ (status.not_added?.length > 0) ||
351
+ (status.modified?.length > 0) ||
352
+ (status.created?.length > 0) ||
353
+ (status.deleted?.length > 0) ||
354
+ (status.staged?.length > 0);
355
+
356
+ if (!hasChanges && skipIfClean && gitignored.length === 0) {
357
+ return asToolResult({
358
+ success: true,
359
+ skipped: true,
360
+ reason: "Working tree limpa, nada para atualizar",
361
+ status
362
+ });
363
+ }
364
+
365
+ if (!hasChanges && gitignored.length === 0) {
366
+ return asToolResult({
367
+ success: false,
368
+ error: "NOTHING_TO_UPDATE",
369
+ message: "Nenhuma mudança para atualizar. Use skipIfClean=true para pular silenciosamente.",
370
+ status
371
+ });
372
+ }
373
+
374
+ if (args.dryRun) {
375
+ return asToolResult({
376
+ success: true,
377
+ dryRun: true,
378
+ message: "DRY RUN: Update seria executado",
379
+ wouldExecute: ["gitignore (opcional)", "status", "add", "commit", "push"],
380
+ files,
381
+ commitMessage: args.message,
382
+ gitignored,
383
+ status
384
+ });
385
+ }
386
+
387
+ // 2. Add
388
+ await git.add(projectPath, files);
389
+
390
+ // 3. Commit
391
+ const sha = await git.commit(projectPath, args.message);
392
+
393
+ // 3.5. Garantir remotes com organization (se fornecida)
394
+ const organization = args.organization || undefined;
395
+ if (organization) {
396
+ const repo = getRepoNameFromPath(projectPath);
397
+ await pm.ensureRepos({ repoName: repo, createIfMissing: true, organization });
398
+ const urls = await pm.getRemoteUrls(repo, organization);
399
+ await git.ensureRemotes(projectPath, { githubUrl: urls.github || "", giteaUrl: urls.gitea || "" });
400
+ }
401
+
402
+ // 4. Push
403
+ const branch = await git.getCurrentBranch(projectPath);
404
+ const pushResult = await withRetry(
405
+ () => git.pushParallel(projectPath, branch, force),
406
+ 3,
407
+ "push"
408
+ );
409
+
410
+ return asToolResult({
411
+ success: true,
412
+ action: "update",
413
+ steps: ["status", "add", "commit", "push"],
414
+ sha,
415
+ message: args.message,
416
+ branch,
417
+ filesAdded: files,
418
+ gitignored,
419
+ organization: organization || undefined,
420
+ push: pushResult,
421
+ _aiContext: {
422
+ completed: true,
423
+ message: "Ciclo completo: arquivos adicionados, commit criado e push realizado" + (organization ? ` [org: ${organization}]` : "")
424
+ }
425
+ }, { tool: 'workflow', action: 'update' });
426
+ }
427
+
428
+ return asToolError("VALIDATION_ERROR", `Ação '${action}' não suportada`, {
429
+ availableActions: ["init", "status", "add", "remove", "commit", "push", "ensure-remotes", "clean", "update"],
430
+ suggestion: "Use action='update' para fluxo completo automatizado"
431
+ });
432
+ } catch (e) {
433
+ return errorToResponse(e);
434
+ }
435
+ }
436
+
437
+ return {
438
+ name: "git-workflow",
439
+ description,
440
+ inputSchema,
441
+ handle
442
+ };
443
+ }
@@ -0,0 +1,104 @@
1
+ // Suporte a arquivo .env
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ /**
6
+ * Carrega variáveis de ambiente de um arquivo .env
7
+ * @param {string} envPath - Caminho para o arquivo .env (opcional)
8
+ */
9
+ export function loadEnv(envPath = null) {
10
+ // Tenta encontrar .env no diretório do projeto ou no cwd
11
+ const possiblePaths = [
12
+ envPath,
13
+ path.join(process.cwd(), ".env"),
14
+ path.join(process.cwd(), ".env.local"),
15
+ // Para quando executado como pacote npm
16
+ path.resolve(import.meta.url.replace("file:///", "").replace(/\/[^/]+$/, ""), "../../.env")
17
+ ].filter(Boolean);
18
+
19
+ for (const p of possiblePaths) {
20
+ try {
21
+ const normalizedPath = p.replace(/\\/g, "/").replace("file:///", "");
22
+ if (fs.existsSync(normalizedPath)) {
23
+ const content = fs.readFileSync(normalizedPath, "utf8");
24
+ parseEnvFile(content);
25
+ console.error(`[git-mcp] Loaded .env from: ${normalizedPath}`);
26
+ return true;
27
+ }
28
+ } catch (e) {
29
+ // Ignora erros de leitura
30
+ }
31
+ }
32
+
33
+ return false;
34
+ }
35
+
36
+ /**
37
+ * Parse do conteúdo de um arquivo .env
38
+ * @param {string} content - Conteúdo do arquivo
39
+ */
40
+ function parseEnvFile(content) {
41
+ const lines = content.split("\n");
42
+
43
+ for (const line of lines) {
44
+ // Ignora linhas vazias e comentários
45
+ const trimmed = line.trim();
46
+ if (!trimmed || trimmed.startsWith("#")) continue;
47
+
48
+ // Parse KEY=VALUE
49
+ const match = trimmed.match(/^([^=]+)=(.*)$/);
50
+ if (match) {
51
+ const key = match[1].trim();
52
+ let value = match[2].trim();
53
+
54
+ // Remove aspas ao redor do valor
55
+ if ((value.startsWith('"') && value.endsWith('"')) ||
56
+ (value.startsWith("'") && value.endsWith("'"))) {
57
+ value = value.slice(1, -1);
58
+ }
59
+
60
+ // Só define se não existir (variáveis de ambiente têm precedência)
61
+ if (!(key in process.env)) {
62
+ process.env[key] = value;
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Retorna uma variável de ambiente com valor padrão opcional
70
+ * @param {string} key - Nome da variável
71
+ * @param {string} defaultValue - Valor padrão se não existir
72
+ */
73
+ export function getEnvVar(key, defaultValue = "") {
74
+ return process.env[key] || defaultValue;
75
+ }
76
+
77
+ /**
78
+ * Verifica se todas as variáveis obrigatórias estão definidas
79
+ * @param {string[]} requiredVars - Lista de variáveis obrigatórias
80
+ * @returns {{ valid: boolean, missing: string[] }}
81
+ */
82
+ export function validateEnv(requiredVars) {
83
+ const missing = requiredVars.filter(v => !process.env[v]);
84
+ return {
85
+ valid: missing.length === 0,
86
+ missing
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Configuração padrão do git-mcp
92
+ */
93
+ export const ENV_CONFIG = {
94
+ // Providers
95
+ GITHUB_TOKEN: "Token de autenticação do GitHub",
96
+ GITEA_URL: "URL do servidor Gitea",
97
+ GITEA_TOKEN: "Token de autenticação do Gitea",
98
+
99
+ // Performance
100
+ GIT_TIMEOUT_MS: "Timeout para operações Git em ms (default: 120000)",
101
+
102
+ // Debug
103
+ DEBUG_AGENT_LOG: "Habilita logging de debug"
104
+ };