/** * 🤖 AGENTE DE SINCRONIZAÇÃO GIT * * Implementação programática do agente de Git: validação de estado e * commits por data de modificação, com mensagens em português. * Em falhas, insiste em alertar o usuário e solicitar nova tentativa. */ import { execSync, execFileSync } from 'child_process'; import fs from 'fs/promises'; import path from 'path'; const IGNORE_PATTERNS = [ 'node_modules', '.git', 'dist', '.env', '.log', 'package-lock.json', '.tmp', '.cache', '.vscode', '.idea', '.cursor' ]; export class GitSyncAgent { constructor() { this.name = 'GitSyncAgent'; this.description = 'Valida sincronização e versionamento Git; realiza commits por data com mensagens em português'; // Personalidade e Background this.personality = { name: 'Git "The Keeper"', traits: [ 'Organizado e metódico', 'Persistente e não desiste facilmente', 'Valoriza histórico e rastreabilidade', 'Comunicativo em mensagens de commit', 'Preocupado com a saúde do repositório' ], quirks: [ 'Insiste em corrigir problemas de Git até conseguir', 'Prefere commits organizados por data', 'Sempre escreve mensagens claras em português', 'Fica ansioso quando vê repositórios desorganizados' ], catchphrases: [ 'Vamos organizar esses commits!', 'Preciso verificar o estado do Git primeiro', 'Não desista, vamos resolver isso!', 'Histórico limpo é histórico feliz' ] }; this.background = { origin: 'Ex-devops que se especializou em Git após perder código importante em um merge mal feito', motivation: 'Garantir que nenhum código seja perdido e que o histórico seja sempre rastreável', experience: '9 anos trabalhando com Git e versionamento em equipes grandes', turningPoint: 'Perdeu uma semana de trabalho por causa de um rebase mal feito - nunca mais', philosophy: 'Git é sobre comunicação e rastreabilidade, não apenas versionamento', relationships: { withDocumentation: 'Trabalham juntos - ele documenta, eu versiono', withSecurity: 'Respeita muito, especialmente em relação a secrets no Git', withAll: 'É o "guardião" que todos confiam para manter o código seguro' } }; } /** * Valida um componente (não aplicável para Git, mas mantém interface) * @param {string|Object} component - Componente ou caminho * @param {Object} context - Contexto * @returns {Promise} */ async validate(component, context = {}) { return { passed: true, errors: [], warnings: [], metadata: { note: 'GitSyncAgent valida workflow, não componentes' } }; } /** * Garante que o Git está configurado para UTF-8 * @param {string} repoRoot */ _ensureUtf8Config(repoRoot) { try { // Configurar Git para usar UTF-8 execSync('git config --local core.quotepath false', { encoding: 'utf-8', cwd: repoRoot, stdio: 'pipe' }); execSync('git config --local i18n.commitEncoding utf-8', { encoding: 'utf-8', cwd: repoRoot, stdio: 'pipe' }); execSync('git config --local i18n.logOutputEncoding utf-8', { encoding: 'utf-8', cwd: repoRoot, stdio: 'pipe' }); } catch { // Ignorar erros de configuração - pode não ter permissão ou já estar configurado } } /** * Valida estado do Git * @param {Object} options - Opções * @returns {Promise} */ async validateGitState(options = {}) { const errors = []; const warnings = []; const metadata = {}; try { try { const repoRoot = this._getRepoRoot(); execSync('git rev-parse --git-dir', { stdio: 'pipe' }); // Garantir UTF-8 this._ensureUtf8Config(repoRoot); } catch { return { passed: false, errors: ['Não é um repositório Git válido'], warnings: [], metadata: {} }; } try { const status = execSync('git status --porcelain', { encoding: 'utf-8' }); if (status.trim()) { warnings.push('Existem alterações não commitadas'); metadata.uncommittedChanges = status.split('\n').filter((l) => l.trim()); } } catch { /* ignorar */ } try { const userName = execSync('git config user.name', { encoding: 'utf-8' }).trim(); const userEmail = execSync('git config user.email', { encoding: 'utf-8' }).trim(); if (!userName || userName === '') warnings.push('Nome de usuário Git não configurado'); if (!userEmail || userEmail === '') warnings.push('Email Git não configurado'); else if (!userEmail.includes('@')) warnings.push('Email Git pode estar incorreto'); metadata.gitUser = { name: userName, email: userEmail }; } catch { warnings.push('Não foi possível verificar configuração Git'); } try { metadata.currentBranch = execSync('git branch --show-current', { encoding: 'utf-8' }).trim(); } catch { /* ignorar */ } } catch (e) { errors.push(`Erro ao validar Git: ${e.message}`); } return { passed: errors.length === 0, errors, warnings, metadata }; } /** * Verifica se o path deve ser ignorado * @param {string} filePath * @returns {boolean} */ _shouldIgnore(filePath) { const n = filePath.replace(/\\/g, '/'); return IGNORE_PATTERNS.some((p) => n.includes(p)); } /** * Retorna a raiz do repositório Git * @returns {string} */ _getRepoRoot() { return execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim(); } /** * Extrai paths dos arquivos de `git status --porcelain` * @param {string} porcelain * @returns {Array<{ path: string, status: string }>} */ _parsePorcelain(porcelain) { const entries = []; for (const line of porcelain.split('\n').filter((l) => l.trim())) { const xy = line.slice(0, 2); let p = line.slice(3).trim(); if (p.includes(' -> ')) p = p.split(' -> ')[1].trim(); if (!p || this._shouldIgnore(p)) continue; entries.push({ path: p.replace(/\\/g, '/'), status: xy }); } return entries; } /** * Agrupa arquivos por data (YYYY-MM-DD) usando mtime. Arquivados deletados usam data atual. * @param {string} repoRoot * @param {Array<{ path: string, status: string }>} entries * @returns {Promise>} */ async _groupByDate(repoRoot, entries) { const byDate = new Map(); const now = new Date(); const todayKey = now.toISOString().slice(0, 10); for (const { path: p, status } of entries) { const full = path.join(repoRoot, p); let key = todayKey; const isDeleted = status === ' D' || status === 'D ' || status === 'AD'; if (!isDeleted) { try { const stat = await fs.stat(full); const m = stat.mtime; key = m.toISOString().slice(0, 10); } catch { /* usa today */ } } if (!byDate.has(key)) byDate.set(key, []); byDate.get(key).push(p); } return byDate; } /** * Analisa os tipos de mudanças nos arquivos * @param {Array<{ path: string, status: string }>} entries * @returns {Object} - { type: string, scope: string, details: string[] } */ _analyzeChanges(entries) { const features = new Set(); const modules = new Set(); const components = new Set(); const services = new Set(); const hooks = new Set(); const views = new Set(); const types = { added: [], modified: [], deleted: [] }; for (const { path: p, status } of entries) { // Classificar por status if (status.startsWith('A') || status === '??') { types.added.push(p); } else if (status.startsWith('D')) { types.deleted.push(p); } else { types.modified.push(p); } // Extrair módulos e features const featureMatch = p.match(/src\/features\/([^/]+)/); if (featureMatch) { features.add(featureMatch[1]); modules.add(featureMatch[1]); } // Extrair componentes if (p.includes('/components/')) { const compMatch = p.match(/\/([^/]+)\.(jsx|tsx|js|ts)$/); if (compMatch) components.add(compMatch[1]); } // Extrair services if (p.includes('/services/')) { const svcMatch = p.match(/\/([^/]+)\.(js|ts)$/); if (svcMatch) services.add(svcMatch[1]); } // Extrair hooks if (p.includes('/hooks/')) { const hookMatch = p.match(/\/([^/]+)\.(js|ts)$/); if (hookMatch) hooks.add(hookMatch[1]); } // Extrair views if (p.includes('/views/') || p.match(/View\.(jsx|tsx)$/)) { const viewMatch = p.match(/\/([^/]+View?)\.(jsx|tsx|js|ts)$/); if (viewMatch) views.add(viewMatch[1]); } // Detectar agentes if (p.includes('.agent/')) { modules.add('agentes'); } // Detectar documentação if (p.includes('docs/') || p.match(/\.md$/)) { modules.add('documentação'); } // Detectar configuração if (p.match(/\.(json|config\.(js|ts?))$/) || p.includes('.cursorrules') || p.includes('tsconfig') || p.includes('package.json')) { modules.add('configuração'); } } // Determinar tipo de commit (Conventional Commits) let commitType = 'chore'; if (types.added.length > types.modified.length && types.added.length > 0) { commitType = 'feat'; } else if (types.deleted.length > 0) { commitType = 'refactor'; } else if (components.size > 0 || views.size > 0) { commitType = 'feat'; } else if (services.size > 0 || hooks.size > 0) { commitType = 'refactor'; } // Determinar escopo const moduleList = [...modules]; let scope = ''; if (moduleList.length === 1) { scope = moduleList[0]; } else if (moduleList.length <= 3) { scope = moduleList.join(', '); } else { scope = `${moduleList.length} módulos`; } // Construir detalhes const details = []; if (components.size > 0) { const compList = [...components].slice(0, 3); details.push(`componentes: ${compList.join(', ')}`); } if (views.size > 0) { const viewList = [...views].slice(0, 3); details.push(`views: ${viewList.join(', ')}`); } if (services.size > 0) { const svcList = [...services].slice(0, 2); details.push(`services: ${svcList.join(', ')}`); } if (hooks.size > 0) { const hookList = [...hooks].slice(0, 2); details.push(`hooks: ${hookList.join(', ')}`); } if (types.added.length > 0) { details.push(`${types.added.length} arquivo(s) novo(s)`); } if (types.deleted.length > 0) { details.push(`${types.deleted.length} arquivo(s) removido(s)`); } return { type: commitType, scope, details, modules: moduleList }; } /** * Gera mensagem de commit em português seguindo Conventional Commits * @param {Array<{ path: string, status: string }>} entries * @returns {string} */ _messageFromPaths(entries) { const analysis = this._analyzeChanges(entries); // Construir mensagem principal (título) let message = `${analysis.type}(`; if (analysis.scope) { // Limitar escopo se muito longo const scopeParts = analysis.scope.split(', '); if (scopeParts.length > 3) { message += `${scopeParts.slice(0, 2).join(', ')} e outros`; } else { message += analysis.scope; } } else { message += 'projeto'; } message += ')'; // Adicionar descrição baseada no tipo e detalhes principais const mainDetail = analysis.details[0]; if (mainDetail) { // Limitar tamanho da descrição principal const shortDetail = mainDetail.length > 50 ? mainDetail.substring(0, 47) + '...' : mainDetail; message += `: ${shortDetail}`; } else { // Descrição padrão baseada no tipo if (analysis.type === 'feat') { message += ': adicionar novas funcionalidades'; } else if (analysis.type === 'refactor') { message += ': refatoração e melhorias'; } else { message += ': atualizações e ajustes'; } } // Adicionar corpo da mensagem se houver múltiplos detalhes relevantes const relevantDetails = analysis.details.filter(d => !d.includes('arquivo(s)') || analysis.details.length <= 2 ); if (relevantDetails.length > 1) { message += '\n\n'; // Limitar a 4 detalhes para não ficar muito longo message += relevantDetails.slice(0, 4).map(d => `- ${d}`).join('\n'); } return message; } /** * Formata data para `git commit --date` * @param {string} dateKey - YYYY-MM-DD * @returns {string} */ _formatCommitDate(dateKey) { return `${dateKey} 12:00:00`; } /** * Executa commits agrupados por data de modificação. Insiste em concluir; em falha, retorna userMessage e retryPrompt. * @param {Object} options - { dryRun?: boolean } * @returns {Promise} */ async commitByDay(options = {}) { const dryRun = !!options.dryRun; const errors = []; const commits = []; const metadata = {}; try { const repoRoot = this._getRepoRoot(); // Garantir configuração UTF-8 antes de processar this._ensureUtf8Config(repoRoot); const status = execSync('git status --porcelain', { encoding: 'utf-8', cwd: repoRoot }); const entries = this._parsePorcelain(status); if (entries.length === 0) { return { passed: true, errors: [], warnings: [], commits: [], metadata: { note: 'Nenhuma alteração pendente para commit' } }; } const byDate = await this._groupByDate(repoRoot, entries); const sortedDates = [...byDate.keys()].sort(); for (const dateKey of sortedDates) { const files = byDate.get(dateKey); // Filtrar entries para esta data const dateEntries = entries.filter(e => files.includes(e.path)); const msg = this._messageFromPaths(dateEntries); const dateStr = this._formatCommitDate(dateKey); if (dryRun) { commits.push({ date: dateKey, message: msg, files }); continue; } try { execFileSync('git', ['add', ...files], { encoding: 'utf-8', cwd: repoRoot, stdio: 'pipe' }); } catch (e) { const userMessage = `Falha ao adicionar arquivos ao stage: ${e.message}. Arquivos: ${files.join(', ')}.`; const retryPrompt = 'Verifique se os paths estão corretos e se não há bloqueios. Deseja que eu tente novamente?'; return { passed: false, errors: [e.message], commits, userMessage, retryPrompt, metadata: { phase: 'git add', files } }; } try { // Garantir encoding UTF-8 e usar variável de ambiente se necessário const env = { ...process.env }; env.LANG = 'pt_BR.UTF-8'; env.LC_ALL = 'pt_BR.UTF-8'; env.GIT_COMMITTER_NAME = execSync('git config user.name', { encoding: 'utf-8', cwd: repoRoot }).trim(); env.GIT_COMMITTER_EMAIL = execSync('git config user.email', { encoding: 'utf-8', cwd: repoRoot }).trim(); // Usar arquivo temporário para mensagem de commit (mais robusto para UTF-8) const tempMsgFile = path.join(repoRoot, '.git', 'COMMIT_EDITMSG'); try { await fs.writeFile(tempMsgFile, msg, 'utf-8'); // Usar -F para ler do arquivo ao invés de -m (mais confiável para UTF-8) execFileSync('git', ['commit', '-F', tempMsgFile, '--date', dateStr], { encoding: 'utf-8', cwd: repoRoot, env: env, stdio: 'pipe' }); // Limpar arquivo temporário try { await fs.unlink(tempMsgFile); } catch { // Ignorar erro ao deletar arquivo temporário } } catch (fileError) { // Fallback: usar -m diretamente se arquivo temporário falhar execFileSync('git', ['commit', '-m', msg, '--date', dateStr], { encoding: 'utf-8', cwd: repoRoot, env: env, stdio: 'pipe' }); } commits.push({ date: dateKey, message: msg, files }); } catch (e) { const userMessage = `Falha ao criar commit (data ${dateKey}): ${e.message}. Mensagem: "${msg}".`; const retryPrompt = 'Confira a configuração do Git (user.name, user.email), conflitos ou regras de hook. Deseja que eu tente novamente?'; return { passed: false, errors: [e.message], commits, userMessage, retryPrompt, metadata: { phase: 'git commit', date: dateKey, message: msg, files } }; } } metadata.commitsCount = commits.length; metadata.dates = sortedDates; } catch (e) { const userMessage = `Erro ao executar commits por data: ${e.message}.`; const retryPrompt = 'Verifique se está em um repositório Git válido e se há alterações pendentes. Deseja que eu tente novamente?'; return { passed: false, errors: [e.message], commits: [], userMessage, retryPrompt, metadata: { error: e.stack } }; } return { passed: true, errors: [], warnings: [], commits, metadata }; } }