@andre.buzeli/git-mcp 16.0.8 → 16.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,456 +1,605 @@
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
- // Build URLs from actual ensureRepos results (handles GitHub fallback to personal account)
296
- const githubUrl = ensured.github?.ok && ensured.github.repo
297
- ? `https://github.com/${ensured.github.repo}.git` : "";
298
- const giteaBase = pm.giteaUrl?.replace(/\/$/, "");
299
- const giteaUrl = ensured.gitea?.ok && ensured.gitea.repo && giteaBase
300
- ? `${giteaBase}/${ensured.gitea.repo}.git` : "";
301
- await git.ensureRemotes(projectPath, { githubUrl, giteaUrl, organization });
302
- const remotes = await git.listRemotes(projectPath);
303
- return asToolResult({ success: true, ensured, remotes, isPrivate: !isPublic, organization: organization || undefined }, { tool: 'workflow', action: 'ensure-remotes' });
304
- }
305
- if (action === "push") {
306
- const branch = await git.getCurrentBranch(projectPath);
307
- const force = !!args.force;
308
-
309
- if (args.dryRun) {
310
- return asToolResult({
311
- success: true,
312
- dryRun: true,
313
- message: `DRY RUN: Push seria executado na branch '${branch}'${force ? ' (force)' : ''}`,
314
- branch,
315
- force
316
- });
317
- }
318
-
319
- const organization = args.organization || undefined;
320
-
321
- // Retry logic for push (often fails due to network or concurrent updates)
322
- const result = await withRetry(
323
- () => git.pushParallel(projectPath, branch, force, organization),
324
- 3,
325
- "push"
326
- );
327
-
328
- return asToolResult({ success: true, branch, ...result });
329
- }
330
-
331
- // ============ UPDATE - FLUXO AUTOMATIZADO COMPLETO ============
332
- if (action === "update") {
333
- if (!args.message) {
334
- return asToolError("MISSING_PARAMETER", "message é obrigatório para update", { parameter: "message" });
335
- }
336
-
337
- const files = Array.isArray(args.files) && args.files.length ? args.files : ["."];
338
- const force = !!args.force;
339
- const skipIfClean = !!args.skipIfClean;
340
- const gitignorePatterns = Array.isArray(args.gitignore) ? args.gitignore : [];
341
-
342
- // 0. Gitignore (se solicitado)
343
- let gitignored = [];
344
- if (gitignorePatterns.length > 0) {
345
- if (args.dryRun) {
346
- gitignored = gitignorePatterns;
347
- } else {
348
- await git.addToGitignore(projectPath, gitignorePatterns);
349
- gitignored = gitignorePatterns;
350
- }
351
- }
352
-
353
- // 1. Status para ver o que mudou
354
- const status = await git.status(projectPath);
355
-
356
- const hasChanges = !status.isClean ||
357
- (status.not_added?.length > 0) ||
358
- (status.modified?.length > 0) ||
359
- (status.created?.length > 0) ||
360
- (status.deleted?.length > 0) ||
361
- (status.staged?.length > 0);
362
-
363
- if (!hasChanges && skipIfClean && gitignored.length === 0) {
364
- return asToolResult({
365
- success: true,
366
- skipped: true,
367
- reason: "Working tree limpa, nada para atualizar",
368
- status
369
- });
370
- }
371
-
372
- if (!hasChanges && gitignored.length === 0) {
373
- return asToolResult({
374
- success: false,
375
- error: "NOTHING_TO_UPDATE",
376
- message: "Nenhuma mudança para atualizar. Use skipIfClean=true para pular silenciosamente.",
377
- status
378
- });
379
- }
380
-
381
- if (args.dryRun) {
382
- return asToolResult({
383
- success: true,
384
- dryRun: true,
385
- message: "DRY RUN: Update seria executado",
386
- wouldExecute: ["gitignore (opcional)", "status", "add", "commit", "push"],
387
- files,
388
- commitMessage: args.message,
389
- gitignored,
390
- status
391
- });
392
- }
393
-
394
- // 2. Add
395
- await git.add(projectPath, files);
396
-
397
- // 3. Commit
398
- const sha = await git.commit(projectPath, args.message);
399
-
400
- // 3.5. Garantir remotes com organization (se fornecida)
401
- const organization = args.organization || undefined;
402
- if (organization) {
403
- const repo = getRepoNameFromPath(projectPath);
404
- const isPublic = args.isPublic === true;
405
- const ensured = await pm.ensureRepos({ repoName: repo, createIfMissing: true, isPublic, organization });
406
- // Build URLs from actual ensureRepos results (handles GitHub fallback to personal account)
407
- const githubUrl = ensured.github?.ok && ensured.github.repo
408
- ? `https://github.com/${ensured.github.repo}.git` : "";
409
- const giteaBase = pm.giteaUrl?.replace(/\/$/, "");
410
- const giteaUrl = ensured.gitea?.ok && ensured.gitea.repo && giteaBase
411
- ? `${giteaBase}/${ensured.gitea.repo}.git` : "";
412
- await git.ensureRemotes(projectPath, { githubUrl, giteaUrl, organization });
413
- }
414
-
415
- // 4. Push
416
- const branch = await git.getCurrentBranch(projectPath);
417
- const pushResult = await withRetry(
418
- () => git.pushParallel(projectPath, branch, force, organization),
419
- 3,
420
- "push"
421
- );
422
-
423
- return asToolResult({
424
- success: true,
425
- action: "update",
426
- steps: ["status", "add", "commit", "push"],
427
- sha,
428
- message: args.message,
429
- branch,
430
- filesAdded: files,
431
- gitignored,
432
- organization: organization || undefined,
433
- push: pushResult,
434
- _aiContext: {
435
- completed: true,
436
- message: "Ciclo completo: arquivos adicionados, commit criado e push realizado" + (organization ? ` [org: ${organization}]` : "")
437
- }
438
- }, { tool: 'workflow', action: 'update' });
439
- }
440
-
441
- return asToolError("VALIDATION_ERROR", `Ação '${action}' não suportada`, {
442
- availableActions: ["init", "status", "add", "remove", "commit", "push", "ensure-remotes", "clean", "update"],
443
- suggestion: "Use action='update' para fluxo completo automatizado"
444
- });
445
- } catch (e) {
446
- return errorToResponse(e);
447
- }
448
- }
449
-
450
- return {
451
- name: "git-workflow",
452
- description,
453
- inputSchema,
454
- handle
455
- };
456
- }
1
+ import Ajv from "ajv";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { asToolError, asToolResult, errorToResponse, createError } from "../utils/errors.js";
5
+ import { getRepoNameFromPath, detectProjectType, GITIGNORE_TEMPLATES, validateProjectPath, withRetry } from "../utils/repoHelpers.js";
6
+ import { sendLog } from "../utils/mcpNotify.js";
7
+
8
+ const ajv = new Ajv({ allErrors: true });
9
+
10
+ function resolveRemoteBranch(branchName, channel) {
11
+ if (channel === "production") return branchName;
12
+ if (channel === "beta") return `${branchName}-beta`;
13
+ if (channel === "alpha") return `${branchName}-alpha`;
14
+ return branchName;
15
+ }
16
+
17
+ export function createGitWorkflowTool(pm, git, server) {
18
+ const inputSchema = {
19
+ type: "object",
20
+ properties: {
21
+ projectPath: {
22
+ type: "string",
23
+ description: "Caminho absoluto do diretório do projeto no IDE (ex: '/home/user/meu-projeto' ou 'C:/Users/user/meu-projeto'). IMPORTANTE: este valor não pode ser inferido automaticamente pelo servidor — o agente deve fornecer o path real do projeto sendo trabalhado, não o diretório home do usuário."
24
+ },
25
+ action: {
26
+ type: "string",
27
+ enum: ["init", "status", "add", "remove", "commit", "push", "ensure-remotes", "clean", "update", "init-branch"],
28
+ description: `Ação a executar:
29
+ - init: Inicializa repositório git local E cria repos no GitHub/Gitea automaticamente
30
+ - status: Retorna arquivos modificados, staged, untracked (use ANTES de commit para ver o que mudou)
31
+ - add: Adiciona arquivos ao staging (use ANTES de commit)
32
+ - remove: Remove arquivos do staging
33
+ - commit: Cria commit com os arquivos staged (use DEPOIS de add)
34
+ - push: Envia commits para GitHub E Gitea em paralelo (use DEPOIS de commit)
35
+ - ensure-remotes: Configura remotes GitHub e Gitea (use se push falhar por falta de remote)
36
+ - clean: Remove arquivos não rastreados do working directory
37
+ - update: RECOMENDADO - Executa fluxo completo automatizado (status → add → commit → push) em uma única chamada
38
+ - init-branch: Atalho para criar um novo worktree/branch (use git-worktree add preferencialmente)`
39
+ },
40
+ files: {
41
+ type: "array",
42
+ items: { type: "string" },
43
+ default: ["."],
44
+ description: "Lista de arquivos para add/remove. Default: ['.'] (todos). Ex: ['src/index.js', 'package.json']"
45
+ },
46
+ message: {
47
+ type: "string",
48
+ description: "Mensagem do commit. Obrigatório para action='commit'. Ex: 'feat: adiciona nova funcionalidade'"
49
+ },
50
+ branch: {
51
+ type: "string",
52
+ description: "Nome da branch (para action='init-branch')"
53
+ },
54
+ channel: {
55
+ type: "string",
56
+ enum: ["production", "beta", "alpha"],
57
+ description: "Canal de deploy para push (action='update'). Sobrescreve o channel salvo para este push."
58
+ },
59
+ syncBranches: {
60
+ type: "boolean",
61
+ description: "Se true e executado no principal (action='update'), propaga commits para todos os worktrees registrados."
62
+ },
63
+ force: {
64
+ type: "boolean",
65
+ default: false,
66
+ description: "Force push (use apenas se push normal falhar com erro de histórico divergente). Default: false"
67
+ },
68
+ createGitignore: {
69
+ type: "boolean",
70
+ default: true,
71
+ description: "Se true, cria .gitignore padrão baseado no tipo de projeto (action='init'). Default: true"
72
+ },
73
+ isPublic: {
74
+ type: "boolean",
75
+ default: false,
76
+ description: "Se true, repositório será PÚBLICO. Default: false (privado). Aplica-se a action='init' e 'ensure-remotes'"
77
+ },
78
+ dryRun: {
79
+ type: "boolean",
80
+ default: false,
81
+ description: "Se true, simula a operação sem executar (útil para testes). Default: false"
82
+ },
83
+ skipIfClean: {
84
+ type: "boolean",
85
+ default: false,
86
+ description: "Para action='update': se true, pula silenciosamente se não houver mudanças. Default: false"
87
+ },
88
+ gitignore: {
89
+ type: "array",
90
+ items: {
91
+ type: "string"
92
+ },
93
+ description: "Para action='update': lista de padrões para adicionar ao .gitignore antes de atualizar"
94
+ },
95
+ organization: {
96
+ type: "string",
97
+ 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'"
98
+ }
99
+ },
100
+ required: ["projectPath", "action"],
101
+ additionalProperties: false
102
+ };
103
+
104
+ const description = `IMPORTANTE — projectPath:
105
+ Informe o caminho absoluto do projeto aberto no IDE. O servidor MCP não tem acesso ao
106
+ contexto do IDE e não consegue detectar automaticamente qual projeto está sendo trabalhado.
107
+
108
+ Operações Git essenciais com sincronização automática GitHub + Gitea.
109
+
110
+ RECOMENDADO - FLUXO AUTOMATIZADO:
111
+ action="update" → Executa status, add, commit e push em uma única chamada
112
+ Exemplo: { "projectPath": "/path", "action": "update", "message": "feat: nova feature" }
113
+
114
+ FLUXO MANUAL (se preferir controle individual):
115
+ 1. git-workflow status → ver arquivos modificados
116
+ 2. git-workflow add → adicionar arquivos ao staging
117
+ 3. git-workflow commit → criar commit
118
+ 4. git-workflow push → enviar para GitHub e Gitea
119
+
120
+ QUANDO USAR CADA ACTION:
121
+ - update: ⭐ RECOMENDADO - Fluxo completo automatizado (status → add → commit → push)
122
+ - status: Para verificar estado atual do repositório
123
+ - add: Quando há arquivos modificados para commitar
124
+ - commit: Após add, para salvar as mudanças
125
+ - push: Após commit, para sincronizar com remotes
126
+ - init: Apenas uma vez, para novos projetos (cria .gitignore automaticamente)
127
+ - ensure-remotes: Se push falhar por falta de configuração
128
+ - clean: Limpar arquivos não rastreados
129
+
130
+ EXEMPLOS DE USO:
131
+ • ⭐ Atualizar tudo: { "projectPath": "/path/to/project", "action": "update", "message": "feat: descrição" }
132
+ • Iniciar projeto: { "projectPath": "/path/to/project", "action": "init" }
133
+ Ver mudanças: { "projectPath": "/path/to/project", "action": "status" }`;
134
+
135
+ async function handle(args) {
136
+ const validate = ajv.compile(inputSchema);
137
+ if (!validate(args || {})) {
138
+ return asToolError("VALIDATION_ERROR", "Parâmetros inválidos", validate.errors);
139
+ }
140
+ const { projectPath, action } = args;
141
+ try {
142
+ validateProjectPath(projectPath);
143
+ if (action === "init") {
144
+ const shouldCreateGitignore = args.createGitignore !== false;
145
+
146
+ if (args.dryRun) {
147
+ return asToolResult({
148
+ success: true,
149
+ dryRun: true,
150
+ message: "DRY RUN: Repositório seria inicializado localmente e nos providers",
151
+ repoName: getRepoNameFromPath(projectPath),
152
+ gitignoreCreated: shouldCreateGitignore
153
+ });
154
+ }
155
+
156
+ const isRepo = await git.isRepo(projectPath);
157
+ if (!isRepo) {
158
+ await git.init(projectPath);
159
+ }
160
+
161
+ // Criar .gitignore baseado no tipo de projeto (apenas se não existir ou se for novo repo)
162
+ let gitignoreCreated = false;
163
+
164
+ if (shouldCreateGitignore) {
165
+ const hasGitignore = fs.existsSync(path.join(projectPath, ".gitignore"));
166
+ if (!hasGitignore) {
167
+ const projectType = detectProjectType(projectPath);
168
+ const patterns = GITIGNORE_TEMPLATES[projectType] || GITIGNORE_TEMPLATES.general;
169
+ await git.createGitignore(projectPath, patterns);
170
+ gitignoreCreated = true;
171
+ }
172
+ }
173
+
174
+ const repo = getRepoNameFromPath(projectPath);
175
+ const isPublic = args.isPublic === true; // Default: privado
176
+ const organization = args.organization || undefined;
177
+ const ensured = await pm.ensureRepos({ repoName: repo, createIfMissing: true, isPublic, organization });
178
+
179
+ // Configurar remotes com org se fornecida
180
+ const urls = await pm.getRemoteUrls(repo, organization);
181
+ await git.ensureRemotes(projectPath, { githubUrl: urls.github || "", giteaUrl: urls.gitea || "" });
182
+
183
+ return asToolResult({
184
+ success: true,
185
+ ensured,
186
+ isPrivate: !isPublic,
187
+ organization: organization || undefined,
188
+ gitignoreCreated,
189
+ message: "Repositório inicializado localmente e nos providers" + (organization ? ` [org: ${organization}]` : "") + (gitignoreCreated ? " (.gitignore criado)" : "") + (!isPublic ? " [PRIVADO]" : " [PÚBLICO]")
190
+ });
191
+ }
192
+ if (action === "status") {
193
+ const st = await git.status(projectPath);
194
+
195
+ if (args.dryRun) {
196
+ return asToolResult({
197
+ success: true,
198
+ dryRun: true,
199
+ message: "DRY RUN: Status seria verificado",
200
+ ...st
201
+ });
202
+ }
203
+
204
+ // Adicionar contexto para AI Agent decidir próximo passo
205
+ const hasUnstaged = st.not_added?.length > 0 || st.modified?.length > 0 || st.created?.length > 0;
206
+ const hasStaged = st.staged?.length > 0;
207
+ const hasConflicts = st.conflicted?.length > 0;
208
+
209
+ let _aiContext = {
210
+ needsAdd: hasUnstaged && !hasStaged,
211
+ readyToCommit: hasStaged && !hasConflicts,
212
+ hasConflicts: hasConflicts,
213
+ isClean: st.isClean,
214
+ suggestedAction: null
215
+ };
216
+
217
+ if (hasConflicts) {
218
+ _aiContext.suggestedAction = "Resolva os conflitos manualmente antes de continuar";
219
+ } else if (hasStaged) {
220
+ _aiContext.suggestedAction = "Use action='commit' com uma mensagem descritiva";
221
+ } else if (hasUnstaged) {
222
+ _aiContext.suggestedAction = "Use action='add' com files=['.'] para adicionar todas as mudanças";
223
+ } else {
224
+ _aiContext.suggestedAction = "Working tree limpa. Modifique arquivos ou use action='push' se há commits pendentes";
225
+ }
226
+
227
+ return asToolResult({ ...st, _aiContext }, { tool: 'workflow', action: 'status' });
228
+ }
229
+ if (action === "add") {
230
+ const files = Array.isArray(args.files) && args.files.length ? args.files : ["."];
231
+
232
+ if (args.dryRun) {
233
+ return asToolResult({
234
+ success: true,
235
+ dryRun: true,
236
+ message: `DRY RUN: Arquivos seriam adicionados: ${files.join(", ")}`,
237
+ files
238
+ });
239
+ }
240
+
241
+ await git.add(projectPath, files);
242
+ return asToolResult({ success: true, files }, { tool: 'workflow', action: 'add' });
243
+ }
244
+ if (action === "remove") {
245
+ const files = Array.isArray(args.files) ? args.files : [];
246
+ await git.remove(projectPath, files);
247
+ return asToolResult({ success: true, files });
248
+ }
249
+ if (action === "commit") {
250
+ if (!args.message) {
251
+ return asToolError("MISSING_PARAMETER", "message é obrigatório para commit", { parameter: "message" });
252
+ }
253
+
254
+ if (args.dryRun) {
255
+ return asToolResult({
256
+ success: true,
257
+ dryRun: true,
258
+ message: `DRY RUN: Commit seria criado com mensagem: "${args.message}"`
259
+ });
260
+ }
261
+
262
+ const sha = await git.commit(projectPath, args.message);
263
+ return asToolResult({ success: true, sha, message: args.message }, { tool: 'workflow', action: 'commit' });
264
+ }
265
+ if (action === "clean") {
266
+ if (args.dryRun) {
267
+ const result = await git.cleanUntracked(projectPath);
268
+ return asToolResult({
269
+ success: true,
270
+ dryRun: true,
271
+ message: `DRY RUN: ${result.cleaned.length} arquivo(s) seriam removidos`,
272
+ wouldClean: result.cleaned
273
+ });
274
+ }
275
+
276
+ const result = await git.cleanUntracked(projectPath);
277
+ return asToolResult({
278
+ success: true,
279
+ ...result,
280
+ message: result.cleaned.length > 0
281
+ ? `${result.cleaned.length} arquivo(s) não rastreados removidos`
282
+ : "Nenhum arquivo para limpar"
283
+ });
284
+ }
285
+ if (action === "ensure-remotes") {
286
+ const repo = getRepoNameFromPath(projectPath);
287
+ const isPublic = args.isPublic === true; // Default: privado
288
+ const organization = args.organization || undefined;
289
+
290
+ if (args.dryRun) {
291
+ const ensured = await pm.ensureRepos({ repoName: repo, createIfMissing: false, isPublic, organization }); // Don't create for dry run
292
+ const urls = await pm.getRemoteUrls(repo, organization);
293
+
294
+ return asToolResult({
295
+ success: true,
296
+ dryRun: true,
297
+ message: "DRY RUN: Remotes seriam configurados" + (organization ? ` [org: ${organization}]` : ""),
298
+ repo,
299
+ organization: organization || undefined,
300
+ githubUrl: urls.github || "",
301
+ giteaUrl: urls.gitea || "",
302
+ ensured
303
+ });
304
+ }
305
+
306
+ const ensured = await pm.ensureRepos({ repoName: repo, createIfMissing: true, isPublic, organization });
307
+ // Build URLs from actual ensureRepos results (handles GitHub fallback to personal account)
308
+ const githubUrl = ensured.github?.ok && ensured.github.repo
309
+ ? `https://github.com/${ensured.github.repo}.git` : "";
310
+ const giteaBase = pm.giteaUrl?.replace(/\/$/, "");
311
+ const giteaUrl = ensured.gitea?.ok && ensured.gitea.repo && giteaBase
312
+ ? `${giteaBase}/${ensured.gitea.repo}.git` : "";
313
+ await git.ensureRemotes(projectPath, { githubUrl, giteaUrl, organization });
314
+ const remotes = await git.listRemotes(projectPath);
315
+ return asToolResult({ success: true, ensured, remotes, isPrivate: !isPublic, organization: organization || undefined }, { tool: 'workflow', action: 'ensure-remotes' });
316
+ }
317
+ if (action === "push") {
318
+ const branch = await git.getCurrentBranch(projectPath);
319
+ const force = !!args.force;
320
+
321
+ if (args.dryRun) {
322
+ return asToolResult({
323
+ success: true,
324
+ dryRun: true,
325
+ message: `DRY RUN: Push seria executado na branch '${branch}'${force ? ' (force)' : ''}`,
326
+ branch,
327
+ force
328
+ });
329
+ }
330
+
331
+ const organization = args.organization || undefined;
332
+
333
+ // Log push start
334
+ await sendLog(server, "info", "push iniciado", { branch, force, organization });
335
+
336
+ // Retry logic for push (often fails due to network or concurrent updates)
337
+ const result = await withRetry(
338
+ () => git.pushParallel(projectPath, branch, force, organization),
339
+ 3,
340
+ "push"
341
+ );
342
+
343
+ return asToolResult({ success: true, branch, ...result });
344
+ }
345
+
346
+ if (action === "init-branch") {
347
+ const branch = args.branch;
348
+ if (!branch) {
349
+ return asToolError("MISSING_PARAMETER", "Parâmetro 'branch' é obrigatório para action='init-branch'");
350
+ }
351
+
352
+ const channel = args.channel || "production";
353
+ const wtPath = path.join(projectPath, branch);
354
+
355
+ if (fs.existsSync(wtPath)) {
356
+ return asToolError("WORKTREE_PATH_EXISTS", `Diretório '${wtPath}' já existe.`, { suggestion: "Use git-worktree add se quiser configurar path customizado" });
357
+ }
358
+
359
+ try {
360
+ await git.addWorktree(projectPath, branch, wtPath);
361
+ await git.setWorktreeConfig(projectPath, branch, { path: wtPath, channel });
362
+
363
+ return asToolResult({
364
+ success: true,
365
+ branch,
366
+ path: wtPath,
367
+ channel,
368
+ message: `Worktree '${branch}' criado com sucesso no canal '${channel}'`
369
+ });
370
+ } catch (e) {
371
+ return errorToResponse(e);
372
+ }
373
+ }
374
+
375
+ // ============ UPDATE - FLUXO AUTOMATIZADO COMPLETO ============
376
+ if (action === "update") {
377
+ if (!args.message) {
378
+ return asToolError("MISSING_PARAMETER", "message é obrigatório para update", { parameter: "message" });
379
+ }
380
+
381
+ const files = Array.isArray(args.files) && args.files.length ? args.files : ["."];
382
+ const force = !!args.force;
383
+ const skipIfClean = !!args.skipIfClean;
384
+ const gitignorePatterns = Array.isArray(args.gitignore) ? args.gitignore : [];
385
+ const syncBranches = !!args.syncBranches;
386
+
387
+ // 0. Gitignore (se solicitado)
388
+ let gitignored = [];
389
+ if (gitignorePatterns.length > 0) {
390
+ if (args.dryRun) {
391
+ gitignored = gitignorePatterns;
392
+ } else {
393
+ await git.addToGitignore(projectPath, gitignorePatterns);
394
+ gitignored = gitignorePatterns;
395
+ }
396
+ }
397
+
398
+ // 1. Status para ver o que mudou
399
+ const status = await git.status(projectPath);
400
+
401
+ const hasChanges = !status.isClean ||
402
+ (status.not_added?.length > 0) ||
403
+ (status.modified?.length > 0) ||
404
+ (status.created?.length > 0) ||
405
+ (status.deleted?.length > 0) ||
406
+ (status.staged?.length > 0);
407
+
408
+ if (!hasChanges && skipIfClean && gitignored.length === 0) {
409
+ return asToolResult({
410
+ success: true,
411
+ skipped: true,
412
+ reason: "Working tree limpa, nada para atualizar",
413
+ status
414
+ });
415
+ }
416
+
417
+ if (!hasChanges && gitignored.length === 0) {
418
+ return asToolResult({
419
+ success: false,
420
+ error: "NOTHING_TO_UPDATE",
421
+ message: "Nenhuma mudança para atualizar. Use skipIfClean=true para pular silenciosamente.",
422
+ status
423
+ });
424
+ }
425
+
426
+ if (args.dryRun) {
427
+ return asToolResult({
428
+ success: true,
429
+ dryRun: true,
430
+ message: "DRY RUN: Update seria executado",
431
+ wouldExecute: ["gitignore (opcional)", "status", "add", "commit", "push" + (syncBranches ? " (sync all worktrees)" : "")],
432
+ files,
433
+ commitMessage: args.message,
434
+ gitignored,
435
+ status,
436
+ syncBranches
437
+ });
438
+ }
439
+
440
+ // 2. Add
441
+ await git.add(projectPath, files);
442
+
443
+ // 3. Commit
444
+ const sha = await git.commit(projectPath, args.message);
445
+
446
+ // 3.5. Garantir remotes com organization (se fornecida)
447
+ const organization = args.organization || undefined;
448
+ if (organization) {
449
+ const repo = getRepoNameFromPath(projectPath);
450
+ const isPublic = args.isPublic === true;
451
+ const ensured = await pm.ensureRepos({ repoName: repo, createIfMissing: true, isPublic, organization });
452
+ // Build URLs from actual ensureRepos results (handles GitHub fallback to personal account)
453
+ const githubUrl = ensured.github?.ok && ensured.github.repo
454
+ ? `https://github.com/${ensured.github.repo}.git` : "";
455
+ const giteaBase = pm.giteaUrl?.replace(/\/$/, "");
456
+ const giteaUrl = ensured.gitea?.ok && ensured.gitea.repo && giteaBase
457
+ ? `${giteaBase}/${ensured.gitea.repo}.git` : "";
458
+ await git.ensureRemotes(projectPath, { githubUrl, giteaUrl, organization });
459
+ }
460
+
461
+ // 4. Push Strategy
462
+ const branch = await git.getCurrentBranch(projectPath);
463
+ let pushResult = {};
464
+ let synced = [];
465
+ let errors = [];
466
+
467
+ // Determine Channel
468
+ let channel = args.channel;
469
+ if (!channel) {
470
+ const storedChannel = await git.getConfig(projectPath, `worktree-branch.${branch}.channel`);
471
+ if (storedChannel) channel = storedChannel;
472
+ }
473
+
474
+ // Default channel
475
+ if (!channel) channel = "production";
476
+
477
+ // SYNC BRANCHES
478
+ if (syncBranches) {
479
+ // Só permitido no principal (onde .git é diretório)
480
+ if (fs.existsSync(path.join(projectPath, ".git")) && fs.statSync(path.join(projectPath, ".git")).isFile()) {
481
+ return asToolError("INVALID_OPERATION", "syncBranches=true só pode ser executado a partir do repositório principal, não de um worktree.");
482
+ }
483
+
484
+ // 1. Push do principal (current)
485
+ const remoteBranch = resolveRemoteBranch(branch, channel);
486
+ const remotes = await git.listRemotes(projectPath);
487
+
488
+ await sendLog(server, "info", "update: push iniciado (sync)", { branch, remoteBranch, force });
489
+
490
+ const mainPushed = [];
491
+ const mainFailed = [];
492
+ for (const r of remotes) {
493
+ try {
494
+ await git.pushRefspec(projectPath, r.remote, branch, remoteBranch, force);
495
+ mainPushed.push(r.remote);
496
+ } catch(e) {
497
+ mainFailed.push({ remote: r.remote, error: e.message });
498
+ }
499
+ }
500
+
501
+ // 2. Propagar para worktrees
502
+ // Req 8.3: synced.length + errors.length === N (número de worktrees registrados)
503
+ // O principal NÃO entra no contador — só os worktrees registrados
504
+ const configs = await git.getWorktreeConfigs(projectPath);
505
+
506
+ for (const wt of configs) {
507
+ // Pula se for a própria branch principal (caso esteja registrada)
508
+ if (wt.branch === branch) continue;
509
+
510
+ try {
511
+ // Merge main -> worktree
512
+ await git.merge(wt.path, branch);
513
+
514
+ // Resolve remote branch do worktree
515
+ const wtRemoteBranch = resolveRemoteBranch(wt.branch, wt.channel);
516
+
517
+ // Push worktree
518
+ const wtRemotes = await git.listRemotes(wt.path);
519
+ for (const r of wtRemotes) {
520
+ await git.pushRefspec(wt.path, r.remote, wt.branch, wtRemoteBranch, false); // Nunca force no sync
521
+ }
522
+ synced.push({ branch: wt.branch, remoteBranch: wtRemoteBranch, channel: wt.channel });
523
+ } catch (e) {
524
+ errors.push({ branch: wt.branch, error: e.message });
525
+ }
526
+ }
527
+
528
+ pushResult = {
529
+ main: { branch, remoteBranch, channel, pushed: mainPushed, failed: mainFailed },
530
+ synced,
531
+ errors
532
+ };
533
+ } else {
534
+ // NORMAL UPDATE (SINGLE BRANCH)
535
+ const remoteBranch = resolveRemoteBranch(branch, channel);
536
+
537
+ if (remoteBranch === branch) {
538
+ // Comportamento padrão (production)
539
+ await sendLog(server, "info", "update: push iniciado", { branch, force });
540
+ pushResult = await withRetry(
541
+ () => git.pushParallel(projectPath, branch, force, organization),
542
+ 3,
543
+ "push"
544
+ );
545
+ } else {
546
+ // Comportamento customizado (beta/alpha) -> pushRefspec
547
+ const remotes = await git.listRemotes(projectPath);
548
+
549
+ const pushed = [];
550
+ const failed = [];
551
+
552
+ await sendLog(server, "info", "update: push iniciado (refspec)", { branch, remoteBranch, force });
553
+
554
+ for (const r of remotes) {
555
+ try {
556
+ await git.pushRefspec(projectPath, r.remote, branch, remoteBranch, force);
557
+ pushed.push(r.remote);
558
+ } catch (e) {
559
+ failed.push({ remote: r.remote, error: e.message });
560
+ }
561
+ }
562
+
563
+ if (pushed.length === 0 && failed.length > 0) {
564
+ // Melhor retornar erro se TODOS falharem
565
+ throw createError("PUSH_REJECTED", { message: "Push falhou para todos os remotes customizados", errors: failed });
566
+ }
567
+ pushResult = { pushed, failed, remoteBranch };
568
+ }
569
+ }
570
+
571
+ return asToolResult({
572
+ success: true,
573
+ action: "update",
574
+ steps: ["status", "add", "commit", "push"],
575
+ sha,
576
+ message: args.message,
577
+ branch,
578
+ filesAdded: files,
579
+ gitignored,
580
+ organization: organization || undefined,
581
+ push: pushResult,
582
+ _aiContext: {
583
+ completed: true,
584
+ message: "Ciclo completo: arquivos adicionados, commit criado e push realizado" + (organization ? ` [org: ${organization}]` : "")
585
+ }
586
+ }, { tool: 'workflow', action: 'update' });
587
+ }
588
+
589
+ return asToolError("VALIDATION_ERROR", `Ação '${action}' não suportada`, {
590
+ availableActions: ["init", "status", "add", "remove", "commit", "push", "ensure-remotes", "clean", "update", "init-branch"],
591
+ suggestion: "Use action='update' para fluxo completo automatizado"
592
+ });
593
+ } catch (e) {
594
+ return errorToResponse(e);
595
+ }
596
+ }
597
+
598
+ return {
599
+ name: "git-workflow",
600
+ description,
601
+ inputSchema,
602
+ handle,
603
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
604
+ };
605
+ }