@andre.buzeli/git-mcp 16.0.8 → 16.1.2

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