576 lines
18 KiB
JavaScript
576 lines
18 KiB
JavaScript
/**
|
|
* 🤖 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<Object>}
|
|
*/
|
|
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<Object>}
|
|
*/
|
|
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<Map<string, string[]>>}
|
|
*/
|
|
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<Object>}
|
|
*/
|
|
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
|
|
};
|
|
}
|
|
}
|