Pular para o conteúdo
Node.js

Node.js: automatizando o Git, do repositório ao revert

Paloma Macetko
Ilustração colorida de um unicórnio de crina arco-íris diante de uma árvore cujos galhos formam um histórico de versões com pergaminhos brilhantes, uma coruja levando um baú de código até um castelo na nuvem e uma varinha desfazendo um galho errado

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 -v rodado 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