Node.js: automatizando o Git, do repositório ao revert
Olá meus Unicórnios! 🦄✨
Sabe quando o seu programa gera arquivos — um site, um relatório, um monte de configuração — e você precisa guardar cada versão daquilo? 😅 Foi exatamente o problema que caiu no meu colo num sistema que mantenho: o código escrevia a pasta do projeto, e alguém precisava conseguir olhar para trás e dizer "essa versão de terça estava boa, volta para ela".
Minha primeira reação foi pensar em banco de dados, tabela de versões, guardar o arquivo inteiro em cada linha... 🤯 Até cair a ficha do óbvio: isso já existe e se chama Git. Ele guarda versão, diferença, autor, data e mensagem desde 2005. Eu não precisava inventar nada — precisava só dirigir o Git a partir do Node.
Este artigo é o ciclo completo dessa automação: criar o repositório, criar o repositório lá no GitHub, apontar o remoto, salvar uma versão, dar push, listar o que já foi salvo e reverter uma versão. Node puro, sem nenhuma biblioteca — o child_process chama o git e o fetch embutido fala com a API do GitHub.
🌱 Começando: git init e a armadilha do master
O primeiro passo é criar o repositório dentro da pasta do projeto. Parece a parte chata e sem graça, e foi justamente onde eu tropecei primeiro. 🙃
function iniciarRepositorio() {
if (!fs.existsSync(PASTA)) {
fs.mkdirSync(PASTA, { recursive: true });
}
if (fs.existsSync(path.join(PASTA, '.git'))) {
console.log('Ja existe um repositorio nesta pasta.');
return;
}
// O -b main nomeia a branch na criacao. Sem ele, o git antigo cria
// "master" e o push para "main" falha com "src refspec main does not match any".
rodarGit(['init', '-b', 'main']);
// Identidade so deste repositorio. Sem isso o commit falha com
// "Please tell me who you are" em maquina recem-instalada.
rodarGit(['config', 'user.name', 'Robo do Deploy']);
rodarGit(['config', 'user.email', '[email protected]']);
fs.writeFileSync(path.join(PASTA, '.gitignore'), 'node_modules/\n.env\n');
console.log('Repositorio criado em ' + PASTA);
}
São três detalhes nessas poucas linhas, e cada um me custou tempo:
O -b main não é frescura. Dependendo da versão do Git instalada na máquina, o git init sozinho cria a branch chamada master. Aí o seu código, todo educado, manda git push origin main — e leva um src refspec main does not match any na cara. A mensagem é péssima: parece problema de conexão ou de permissão, e é só o nome da branch. Nomear na criação encerra o assunto.
A identidade é por repositório, de propósito. O git config sem --global grava no .git/config daquela pasta só. Isso importa quando é um programa criando repositórios: você não quer que o robô saia mexendo na configuração global da máquina. E sem nome e e-mail o commit simplesmente não acontece — o Git responde "Please tell me who you are" e desiste.
O .gitignore vem antes do primeiro commit. Depois é tarde: se o node_modules ou o .env entrarem em um commit, eles ficam no histórico para sempre, e tirar de lá dá um trabalho horrível. Escreva o arquivo antes de rodar o primeiro git add.
⚙️ Rodando comandos: a diferença entre exec e execFile
Todo o resto do artigo depende de uma função só, que chama o git e devolve a saída. Aqui vai a que eu uso — e ela tem uma escolha que não é a mais óbvia:
const { execFileSync } = require('child_process');
// Roda um comando git e devolve a saida como texto.
// execFileSync recebe os argumentos em ARRAY: assim uma mensagem de commit
// com aspas ou espaco nao vira um segundo comando do shell.
function rodarGit(argumentos) {
const saida = execFileSync('git', argumentos, {
cwd: PASTA,
encoding: 'utf8',
});
return saida.trim();
}
Repare que eu passo uma lista de argumentos, não uma string. Quase todo exemplo que você acha por aí monta o comando como texto:
exec(`git commit -m "${mensagem}"`);
Isso funciona lindamente até o dia em que a mensagem tem aspas dentro. Aí as aspas fecham cedo, o resto da mensagem vira comando do shell, e o que acontece depois depende de quanta sorte você tem. Uma mensagem inocente como Corrige o "preço" da home já basta para bagunçar tudo. 😬
Com execFileSync e argumentos em array, não existe shell no meio: o Node entrega os argumentos direto para o programa. A mensagem pode ter aspas, espaço, acento, cifrão — chega inteira do outro lado. É uma linha de diferença e resolve uma classe inteira de problema.
O cwd é o outro detalhe importante: ele diz em qual pasta o comando roda. Sem ele, o Git tenta operar na pasta de onde o Node foi iniciado, que raramente é a que você quer.
☁️ Criando o repositório lá no GitHub
O git init criou o repositório na sua máquina. O GitHub não sabe de nada ainda. Para criar o repositório de lá sem abrir o navegador, é um POST na API — e o fetch já vem embutido no Node, então não precisa instalar nada:
async function criarRepositorioNoGithub(nome) {
const resposta = await fetch('https://api.github.com/user/repos', {
method: 'POST',
headers: {
Authorization: 'token ' + TOKEN,
Accept: 'application/vnd.github+json',
'User-Agent': 'versionar-exemplo',
},
body: JSON.stringify({ name: nome, private: true, auto_init: false }),
});
const dados = await resposta.json();
// O GitHub responde 422 quando o repositorio ja existe. Isso nao e erro
// para nos: seguimos usando o que ja esta la.
if (resposta.status === 422) {
console.log('O repositorio ' + nome + ' ja existia. Vamos usar ele.');
return nome;
}
if (!resposta.ok) {
throw new Error('GitHub respondeu ' + resposta.status + ': ' + dados.message);
}
console.log('Repositorio criado no GitHub: ' + nome);
return nome;
}
Três coisas para guardar sobre essa chamada:
O User-Agent é obrigatório. A API do GitHub recusa requisição sem ele, e o erro que volta não é nada didático. Pode ser qualquer texto — o nome do seu programa serve.
O auto_init: false importa mais do que parece. Se você deixar o GitHub criar o repositório já com um README dentro, ele nasce com um commit que a sua máquina não conhece. Aí o primeiro push é recusado, porque os dois históricos não têm nada em comum — e a saída para isso é --force ou --allow-unrelated-histories, dois comandos que a gente não quer no caminho normal. Repositório vazio evita o problema inteiro.
O 422 não é um erro. Essa é a parte que eu levei um tempinho para tratar direito. Quando o repositório já existe, o GitHub responde 422 com "name already exists on this account". Se o seu código tratar isso como falha, a automação quebra na segunda vez que rodar — o que é péssimo, porque rodar de novo é justamente o normal. Aqui eu trato o 422 como "beleza, ele já está lá" e sigo em frente.
🔑 O token no remoto — e por que ele vaza no log
Agora o repositório local precisa saber para onde mandar as coisas. O jeito de autenticar sem prompt de senha é embutir o token na URL do remoto:
// Credencial SO por variavel de ambiente. Nunca escreva o token aqui dentro.
const TOKEN = process.env.GITHUB_TOKEN;
const USUARIO = process.env.GITHUB_USUARIO;
// Monta a URL com o token embutido, do jeito que o git entende.
function urlComToken(nomeDoRepositorio) {
return 'https://' + TOKEN + '@github.com/' + USUARIO + '/' + nomeDoRepositorio + '.git';
}
// Esconde o token de qualquer texto antes de imprimir na tela ou no log.
function esconderToken(texto) {
if (!TOKEN) {
return texto;
}
return texto.split(TOKEN).join('***');
}
function apontarORemoto(nome) {
const url = urlComToken(nome);
const remotos = rodarGit(['remote']);
// "remote add" falha se o origin ja existe. Entao decidimos antes qual usar.
if (remotos.split('\n').indexOf('origin') === -1) {
rodarGit(['remote', 'add', 'origin', url]);
} else {
rodarGit(['remote', 'set-url', 'origin', url]);
}
console.log('Remoto apontado para ' + esconderToken(url));
}
Se você só for ler um pedaço deste artigo, leia este. 🙏 Aquele token é uma senha, e ele vai parar em lugares que você não imagina.
O primeiro lugar é o seu próprio console.log. É automático demais escrever console.log('Remoto: ' + url) para conferir se deu certo — e pronto, a senha está no terminal, no arquivo de log, e no print que você mandou para alguém pedindo ajuda. Por isso a esconderToken(): tudo que for impresso passa por ela primeiro. Veja o que sai na tela:
Remoto apontado para https://***@github.com/minha-conta/site-da-loja.git
Mas tem um segundo lugar, e esse é mais traiçoeiro: o Git grava a URL inteira no .git/config. Com o token dentro, em texto puro. Olha o que o próprio Git me mostrou depois de apontar o remoto:
origin https://[email protected]/minha-conta/site-da-loja.git (fetch)
origin https://[email protected]/minha-conta/site-da-loja.git (push)
Ou seja: mascarar o log resolve o terminal, não resolve o disco. 😳 Duas consequências práticas para quem for usar isso de verdade — e as duas são chatas de descobrir depois:
- Um
git remote -vrodado por qualquer pessoa naquela máquina mostra o token completo. - Se você copiar a pasta do projeto para outro lugar, o token viaja junto dentro do
.git.
Por isso a variável de ambiente não é preciosismo. O token nunca é escrito no código — ele entra por process.env, e o .env está no .gitignore desde a primeira linha do artigo. Eu já vi token de acesso hardcoded no meio de um arquivo de configuração, e a partir daí ele está no histórico do Git para sempre, mesmo que alguém apague a linha depois.
💾 Salvando uma versão — e o commit vazio que derruba o script
Com o repositório pronto, salvar uma versão é add mais commit. Só que tem um detalhe que quebra automação:
function salvarVersao(mensagem) {
rodarGit(['add', '.']);
// --porcelain devolve texto vazio quando nao ha nada mudado. Sem esta
// checagem, o commit sai com codigo 1 e derruba o script sem motivo.
const mudancas = rodarGit(['status', '--porcelain']);
if (mudancas === '') {
console.log('Nada mudou desde a ultima versao.');
return;
}
rodarGit(['commit', '-m', mensagem]);
const hash = rodarGit(['rev-parse', '--short', 'HEAD']);
console.log('Versao salva: ' + hash + ' - ' + mensagem);
}
Quando não há nada para commitar, o git commit sai com código de erro 1. Do ponto de vista do Git faz todo sentido — ele não fez nada, então avisa. Do ponto de vista do execFileSync, código diferente de zero é exceção: o seu script morre com um erro feio, no meio de uma execução que estava perfeitamente saudável.
E "nada mudou" é comum demais em automação. É o deploy que rodou duas vezes, o usuário que salvou sem editar nada, o robô que roda de hora em hora. Não pode ser tratado como falha.
A solução é perguntar antes. O git status --porcelain existe exatamente para isso: ele tem um formato estável, feito para programa ler (é o que "porcelain" quer dizer aqui), e devolve texto vazio quando não há nada pendente. É a diferença entre um script que sobrevive à rotina e um que quebra na segunda execução.
Rodando a sequência inteira em uma pasta de teste, com um commit repetido no meio de propósito:
Versao salva: 7a12cb7 - Primeira versao do site
Nada mudou desde a ultima versao.
Versao salva: 268e29f - Adiciona o rodape
Versao salva: 3475105 - Adiciona o preco errado
Repare na segunda linha: o script não quebrou, avisou e seguiu. Era isso que eu queria. 🎉
🚀 O push, e o -u que você só precisa uma vez
Mandar para o GitHub é uma linha:
function enviarParaOGithub() {
// O -u grava o rastreio da branch; nas proximas vezes um "git push" basta.
const saida = rodarGit(['push', '-u', 'origin', 'main']);
console.log(esconderToken(saida) || 'Push concluido.');
}
O -u (que é o apelido de --set-upstream) faz o Git anotar que a sua branch main local corresponde à main do origin. Depois disso, um git push pelado já sabe para onde ir. Passar o -u sempre não faz mal nenhum — ele só reescreve a mesma anotação.
E olha um detalhe que eu quase deixei passar: a saída do push também passa pela esconderToken(). É que o Git costuma imprimir a URL do destino na resposta — e a URL, como vimos, tem o token dentro. Seria irônico proteger o log na hora de configurar o remoto e vazar tudo na hora de usar. 🙈
Uma coisa que eu não faço aqui, e que aparece em muito exemplo por aí, é git push --force. Force resolve qualquer conflito porque ele simplesmente atropela o que está no servidor. Em automação isso é uma bomba-relógio: no dia em que alguém commitar algo direto pelo site do GitHub, o seu robô apaga o trabalho da pessoa sem perguntar e sem deixar rastro fácil de recuperar.
📜 Listando as versões de um jeito que dá para programar
Guardar versão só vale se dá para consultar depois. O git log normal é bonito para humano ler e péssimo para programa: vem com cabeçalho, linha em branco, mensagem indentada. O jeito certo é pedir o formato que você quer:
function listarVersoes() {
// Separador improvavel de aparecer numa mensagem de commit.
const formato = '%h|%an|%ad|%s';
const saida = rodarGit(['log', '--pretty=format:' + formato, '--date=short', '-n', '20']);
if (saida === '') {
console.log('Nenhuma versao ainda.');
return;
}
const linhas = saida.split('\n');
for (let i = 0; i < linhas.length; i++) {
const partes = linhas[i].split('|');
console.log(partes[0] + ' ' + partes[2] + ' ' + partes[3] + ' (' + partes[1] + ')');
}
}
Cada código do --pretty=format: é um pedaço do commit: %h é o hash curto, %an o nome do autor, %ad a data e %s a mensagem. O --date=short faz a data sair como 2026-07-24, em vez do formato comprido e cheio de fuso que ninguém quer processar.
A escolha do separador merece um segundo de atenção. Eu uso |, que é razoável — mas repare que ele pode aparecer numa mensagem de commit, e aí o split parte no lugar errado. Como o %s (a mensagem) é o último campo, o estrago fica contido: só a mensagem é cortada. Se o campo do meio fosse o texto livre, uma barra perdida embaralharia tudo.
O -n 20 é o limite. Sem ele, um repositório com anos de histórico devolve milhares de linhas para você processar sem necessidade.
A saída na minha pasta de teste, já formatada:
3475105 2026-07-24 Adiciona o preco errado (Robo do Deploy)
268e29f 2026-07-24 Adiciona o rodape (Robo do Deploy)
7a12cb7 2026-07-24 Primeira versao do site (Robo do Deploy)
↩️ Reverter: a decisão mais importante do artigo
Chegamos na parte que me fez escrever este texto. Alguém olha a lista, aponta uma versão e diz "volta". E aí você tem duas opções que parecem fazer a mesma coisa e não fazem nem de longe. 🤯
function reverterVersao(hash) {
try {
// revert CRIA um commit novo que desfaz o antigo. O historico continua
// inteiro. Nao use "reset --hard" aqui: ele apaga commits, e depois de
// um push voce so consegue subir com --force, reescrevendo o que os
// outros ja baixaram.
rodarGit(['revert', hash, '--no-edit']);
console.log('Versao ' + hash + ' revertida.');
} catch (erro) {
// Conflito deixa o repositorio no meio do revert. Sem o abort, todo
// comando seguinte reclama que ha um revert em andamento.
try {
rodarGit(['revert', '--abort']);
} catch (erroDoAbort) {
// Nao havia revert em andamento. Tudo bem.
}
throw new Error('Nao deu para reverter ' + hash + ' (provavel conflito).');
}
}
O --no-edit evita que o Git abra um editor de texto pedindo a mensagem do commit de revert — em automação, um editor esperando alguém digitar significa um processo travado para sempre. Ele usa a mensagem padrão, que já é ótima: Revert "mensagem original".
O que o revert faz de fato
Melhor mostrar. Eu tinha esta sequência de versões, com um erro na última:
<h1>Loja da Vitrine</h1>
<p>Rodape novo</p>
<p>Preco: R$ 10,00</p>
Mandei reverter o commit do preço. Depois disso, o arquivo:
<h1>Loja da Vitrine</h1>
<p>Rodape novo</p>
A linha errada sumiu, como esperado. Mas veja o histórico:
22785d7 2026-07-24 Revert "Adiciona o preco errado" (Robo do Deploy)
3475105 2026-07-24 Adiciona o preco errado (Robo do Deploy)
268e29f 2026-07-24 Adiciona o rodape (Robo do Deploy)
7a12cb7 2026-07-24 Primeira versao do site (Robo do Deploy)
Os quatro commits estão lá. O commit errado continua no histórico, e ganhou um irmão em cima que o desfaz. Nada foi apagado — só foi acrescentado um passo que anula o anterior. É essa a mágica: dá para ver que houve um erro, quando ele foi corrigido, e voltar atrás do "voltar atrás" se for o caso.
E o que o reset --hard faz
Agora a comparação, no mesmo tipo de cenário. Histórico antes:
9fac17e muda para C
219f5fa muda para B
8675f15 base
Um git reset --hard HEAD~2 depois:
8675f15 base
Dois commits desapareceram. 😳 O arquivo voltou ao estado certo, sim — mas o registro de que aquilo existiu foi embora junto. E é aí que mora o perigo real: se aqueles commits já tinham subido para o GitHub, agora o seu local e o remoto discordam sobre a história, e o único jeito de subir é com --force, reescrevendo o que os outros já baixaram.
O conflito, e por que o --abort não é opcional
Reverter nem sempre dá certo. Se o trecho que o commit antigo mexeu já foi alterado de novo depois, o Git não sabe o que fazer e para no meio, com o conflito na sua mão.
Eu montei esse cenário de propósito: três commits mexendo na mesma linha, e um pedido para reverter o do meio. O resultado:
error: could not revert 219f5fa... muda para B
hint: After resolving the conflicts, mark them with
hint: "git add/rm <pathspec>", then run
hint: "git revert --continue".
Erro: Nao deu para reverter 219f5fa (provavel conflito).
codigo de saida: 1
Falhou do jeito certo: mensagem clara em português e código de saída 1. Mas o importante é o que aconteceu depois da falha. Sem o git revert --abort no catch, o repositório fica no meio do revert — com o conflito escrito dentro dos arquivos e um estado pendente que faz todo comando seguinte reclamar. A próxima execução da sua automação chegaria numa pasta bagunçada e falharia por um motivo completamente diferente, deixando você caçando o bug errado.
Com o --abort, o repositório volta exatamente ao que era antes da tentativa:
$ git status --short --branch
## main
Limpo. Nenhum arquivo modificado, nenhuma operação pendente. É a mesma ideia da limpeza no catch que eu já defendi aqui em outros artigos: quando algo falha no meio, o código tem que devolver o mundo ao estado anterior. Falhar é aceitável; falhar deixando lixo para trás, não.
O try dentro do catch parece exagero, mas tem motivo: se a falha do revert não foi por conflito (um hash que não existe, por exemplo), não há revert nenhum para abortar, e o --abort também falha. Sem esse segundo try, esse erro secundário esconderia o erro de verdade — que é justamente o que você precisa ler.
🧩 Juntando tudo
O programa inteiro cabe em um main() que escolhe a função pelo primeiro argumento da linha de comando. Nada de framework de CLI, nada de biblioteca:
async function main() {
const comando = process.argv[2];
const argumento = process.argv[3];
if (comando === 'iniciar') {
iniciarRepositorio();
} else if (comando === 'remoto') {
await criarRepositorioNoGithub(argumento);
apontarORemoto(argumento);
} else if (comando === 'salvar') {
salvarVersao(argumento || 'Alteracoes do dia');
} else if (comando === 'enviar') {
enviarParaOGithub();
} else if (comando === 'versoes') {
listarVersoes();
} else if (comando === 'reverter') {
reverterVersao(argumento);
} else {
console.log('Use: iniciar | remoto <nome> | salvar "<mensagem>" | enviar | versoes | reverter <hash>');
}
}
main().catch(function (erro) {
console.error('Erro: ' + esconderToken(erro.message));
process.exit(1);
});
Note o .catch() no fim: qualquer erro que escapar de qualquer função cai ali, é impresso com o token escondido e o processo sai com código 1. Esse código de saída é o que permite encadear a chamada num script de deploy — node versionar.js enviar && echo "subiu" só imprime se realmente subiu.
E o uso, do começo ao fim:
export GITHUB_TOKEN="o_seu_token_aqui"
export GITHUB_USUARIO="a_sua_conta"
node versionar.js iniciar
node versionar.js remoto site-da-loja
node versionar.js salvar "Primeira versao do site"
node versionar.js enviar
node versionar.js versoes
node versionar.js reverter 3475105
🎯 O que eu levo desse ciclo
O que mais me surpreendeu montando isso foi quanta coisa o Git já resolve. Histórico, diferença entre versões, autor, data, desfazer — nada disso precisou de uma linha de lógica minha. O meu código só monta os comandos certos e lê a saída.
E as armadilhas todas moram nos mesmos lugares de sempre: no que sai com código de erro sem ser erro (o commit vazio, o 422 do GitHub), no que vaza sem você ver (o token no log e no .git/config) e no que fica sujo quando algo falha no meio (o revert com conflito). Três categorias que valem para qualquer automação, não só para Git.
A escolha entre revert e reset --hard é a que eu mais recomendo levar daqui. As duas "desfazem". Só que uma conta a história e a outra apaga a história — e num repositório que já subiu para o servidor, apagar a história é apagar o trabalho de outra pessoa.
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.