Git bom não começa no commit
Um guia prático sobre como eu organizo versionamento com Git, GitHub e GitHub CLI, saindo do automático add/commit/push e tratando cada mudança como uma unidade planejada, validada e pronta para release.
O pior jeito de aprender Git é decorar add, commit e push como se isso fosse um fluxo de trabalho.
Funciona? Funciona. Do mesmo jeito que jogar tudo em uma pasta chamada final-agora-vai-2 também funciona até alguém precisar entender o que aconteceu três semanas depois.
Git não é só uma máquina de salvar código. Git é uma ferramenta de rastreabilidade. GitHub não é só um lugar para empurrar branch. GitHub é onde o trabalho ganha contexto: issue, milestone, histórico, release, discussão e validação.
O meu fluxo tenta resolver um problema simples: cada mudança precisa ser planejada antes, isolada durante, validada depois e rastreável no futuro. Parece burocracia até você precisar descobrir por que uma alteração entrou, qual issue ela resolvia, em qual release saiu e se alguém validou alguma coisa antes do merge.
Aí deixa de ser burocracia. Vira sobrevivência.
O commit não é o começo do trabalho
O erro mais comum em projetos pequenos é tratar o commit como ponto de partida.
A pessoa abre o editor, muda arquivos, testa qualquer coisa no olho, escreve um commit meio genérico e manda para o remoto. Depois cria uma issue, talvez. Ou nem cria. Quando muito, aparece um fix stuff, update, changes, aquele tipo de commit que não informa nada além do fato triste de que alguém encostou no teclado.
No meu fluxo, o commit é consequência. Antes dele existe uma unidade de trabalho.
Essa unidade começa em uma Issue do GitHub. A issue define o escopo. O milestone define o alvo de release. A branch local isola a implementação. O commit registra a mudança. A validação decide se ela pode entrar. O merge coloca a mudança na integração. A release só acontece quando o conjunto planejado ficou pronto.
A ordem importa.
Sem isso, o repositório vira um diário de bordo escrito depois do naufrágio.
Antes dos comandos: branch, working tree e index
Para esse fluxo fazer sentido, vale alinhar três conceitos que muita gente passa por cima porque o tutorial tinha pressa de chegar no git push.
Uma branch é basicamente um nome apontando para uma linha de desenvolvimento. A própria documentação do Pro Git descreve branches como ponteiros leves e móveis para commits, o que explica por que criar branch no Git é barato e rápido: você não está copiando o projeto inteiro, está criando uma nova referência para continuar o histórico dali.
O working tree é a cópia dos arquivos que você está editando no disco. É o diretório do projeto do jeito que ele está agora, com arquivos modificados, criados ou removidos.
O index, também chamado de staging area, é a área intermediária entre o que você editou e o que vai entrar no próximo commit. Quando você roda git add, você não está “salvando no Git” ainda. Você está dizendo: “esta parte aqui deve entrar no próximo snapshot”.
É por isso que eu gosto de começar olhando o estado do repositório:
git status
A documentação do git status é bem direta: ele mostra diferenças entre HEAD, index e working tree, além de arquivos ainda não rastreados. Em português menos manual: ele mostra o que já está preparado para commit, o que foi alterado mas ainda não está preparado e o que o Git nem está rastreando.
Esse comando é simples, mas evita muita besteira. Antes de criar branch, antes de rebase, antes de merge, antes de qualquer cirurgia: git status.
O modelo mental: main, dev e stage
Eu uso duas branches longas:
main
dev
main é a branch estável. Ela não é playground, não é lugar de teste e não é onde eu implemento coisa “rapidinho”. Código só chega em main durante release.
dev é a branch de integração. É onde o trabalho concluído se junta antes de virar release. Ela pode andar mais rápido que main, mas não pode virar lixão. dev precisa continuar buildável, testável e minimamente confiável.
Para implementação, eu uso branches locais com prefixo stage/:
stage/<issue-number>-<short-english-slug>
Exemplos:
stage/12-configure-fastify
stage/18-add-terminal-renderer
stage/21-fix-ascii-width-validation
O detalhe importante: essas branches são locais.
Eu não empurro (“push” - parecia melhor na minha cabeça) stage/* para o GitHub. Para o meu fluxo pessoal, elas são apenas uma área de trabalho isolada. O que vai para o remoto durante o desenvolvimento regular é dev.
Em times, dá para adaptar isso para PRs, revisão obrigatória e branch remota. Mas o princípio continua o mesmo: uma mudança, uma unidade de trabalho, um escopo claro. O problema não é usar PR. O problema é transformar PR em depósito de “aproveitei e mexi nisso também”.
Esse “aproveitei” é uma fábrica de histórico ruim.
git switch não é só frescura nova
Nos comandos do meu fluxo, eu uso git switch em vez de git checkout para mudar de branch:
git switch dev
A documentação do git switch diz que ele muda para uma branch específica e atualiza o working tree e o index para corresponder a ela. É exatamente isso que eu quero expressar quando escrevo o comando: trocar de branch.
O git checkout também consegue trocar de branch, e a documentação dele ainda descreve esse comportamento. O problema é que checkout virou um canivete suíço histórico: ele serve para trocar branch, restaurar arquivo, lidar com pathspec, detached HEAD e outras coisas que fazem sentido para o Git, mas não necessariamente para a cabeça de quem só quer ler um fluxo de trabalho.
Por isso eu prefiro:
git switch dev
em vez de:
git checkout dev
E para criar uma branch nova já entrando nela:
git switch -c stage/12-configure-fastify
Isso substitui o velho:
git checkout -b stage/12-configure-fastify
Não é que checkout esteja proibido. Ele ainda existe, ainda funciona e muita documentação antiga usa. Mas switch comunica melhor a intenção. Quando o comando é sobre branch, uso switch. Quando a intenção é restaurar arquivos, faz mais sentido olhar para git restore, que foi separado justamente para esse tipo de operação.
Ferramenta explícita reduz confusão. E confusão em Git costuma virar meia hora olhando para o terminal com cara de quem mexeu em coisa que não devia.
O GitHub CLI entra para tirar atrito
Eu gosto de usar o GitHub CLI porque ele tira uma parte chata do fluxo: ficar alternando entre terminal, navegador, formulário, milestone, labels e release.
A interface web é boa, mas para trabalho repetível o terminal ganha. O gh deixa issue, milestone e release mais próximos do código. E quando essas etapas ficam mais próximas, a chance de você pular alguma diminui.
Antes de começar, vale garantir que o CLI está autenticado:
gh auth status
Também gosto de conferir o repositório atual:
gh repo view
Não é glamour. É só o equivalente técnico de olhar para os dois lados antes de atravessar a rua.
Milestone não é enfeite de Kanban
Milestone é uma promessa de release.
Não no sentido corporativo de promessa bonita que ninguém cumpre, mas no sentido prático: “este conjunto de issues forma a versão v0.1.0”.
Eu uso nomes no formato:
v0.1.0
v0.2.0
v1.0.0
Para criar um milestone pelo GitHub CLI, uso a API:
gh api repos/{owner}/{repo}/milestones \
-f title="v0.1.0" \
-f description="Initial MVP release"
O gh api é o comando do GitHub CLI para fazer chamadas à API do GitHub direto pelo terminal. A documentação do gh api mostra o uso de campos com -f/--raw-field e endpoints no formato repos/{owner}/{repo}/..., o que encaixa bem para criar milestones quando não há um comando dedicado tão confortável quanto gh issue create.
O milestone precisa ter uma descrição minimamente decente. Não precisa virar documento acadêmico, mas precisa explicar o objetivo daquela release. “Initial MVP release” já é melhor que nada. “Stuff” não é descrição, é um pedido de socorro.
Antes de implementar qualquer coisa, a pergunta é:
essa mudança pertence a qual release?
Se a resposta for “não sei”, provavelmente você ainda não planejou o suficiente.
Issue antes do código
Toda mudança começa em uma issue.
Feature, correção, refatoração, documentação, ajuste interno, organização de repositório: tudo precisa ter uma unidade rastreável. Não porque o GitHub precisa estar feliz, mas porque o futuro você precisa entender por que aquilo existe.
Eu separo os tipos em três categorias:
task
enhancement
bug
Uso task para manutenção, documentação, setup, organização e refatorações internas.
Uso enhancement para melhoria de produto, comportamento novo ou capacidade visível para quem usa o projeto.
Uso bug para comportamento incorreto, regressão, fluxo quebrado ou saída inválida.
O GitHub CLI permite criar issues direto pelo terminal, adicionando labels por nome, milestone por nome e template por arquivo de template configurado no repositório. É aqui que o fluxo começa a ficar menos “vou lembrar de organizar depois” e mais “a organização já nasce junto com a tarefa”.
Exemplo de issue para tarefa:
gh issue create \
--template task.yml \
--title "task: configure initial project structure" \
--label task \
--milestone "v0.1.0"
Exemplo de melhoria:
gh issue create \
--template enhancement.yml \
--title "enhancement: add markdown readme generation endpoint" \
--label enhancement \
--milestone "v0.1.0"
Exemplo de bug:
gh issue create \
--template bug.yml \
--title "bug: fix ascii rendering for invalid width" \
--label bug \
--milestone "v0.1.0"
Se o repositório estiver usando Issue Types do GitHub, também dá para usar --type:
gh issue create \
--template bug.yml \
--title "bug: fix ascii rendering for invalid width" \
--type bug \
--label bug \
--milestone "v0.1.0"
Eu ainda manteria label mesmo usando type em alguns casos, porque label continua sendo útil para filtros, automações e leitura rápida. Mas isso depende do repositório.
O ponto é: issue solta, sem template, sem milestone e sem tipo, é quase sempre dívida organizacional disfarçada de agilidade.
Criando a branch de trabalho
Com a issue criada, a implementação começa a partir de dev.
git switch dev
git pull origin dev
git switch -c stage/12-configure-fastify
Aqui tem três movimentos.
Primeiro, eu entro em dev:
git switch dev
Depois, atualizo minha cópia local com o remoto:
git pull origin dev
Por fim, crio uma branch local para aquela issue:
git switch -c stage/12-configure-fastify
O -c cria a branch e já troca para ela. Essa parte é pequena, mas importante: eu não quero implementar em dev sem querer. A branch local existe para conter a mudança e deixar claro qual issue está sendo trabalhada.
A branch carrega o número da issue e um slug curto em inglês. Isso já resolve uma parte enorme da rastreabilidade.
Quando eu vejo:
stage/12-configure-fastify
eu sei três coisas:
- existe uma issue
#12; - o escopo é configurar Fastify;
- essa branch não deveria conter refatoração de tema, ajuste de README e troca de Dockerfile no mesmo pacote.
Branch boa não é só nome bonito. É contrato de escopo.
Commit não é poesia, é registro técnico
Eu uso commits em inglês e com formato rígido:
[type]/[update-scope]: summary
Exemplos bons:
chore/readme: update project setup instructions
feat/render: add terminal markdown renderer
fix/ascii: handle missing avatar image
docs/api: document query parameters
test/github: add github client tests
refactor/theme: extract theme registry
ci/docker: add docker build check
build/package: configure typescript output
perf/cache: reduce repeated github requests
O tipo diz a natureza da mudança. O escopo diz onde ela mexe. O resumo diz o que aconteceu.
O que eu evito:
fix/api: fixes
chore/docs: update stuff
feat/app: changes
Também evito escopos genéricos:
chore/update: summary
feat/update: summary
fix/update: summary
E, neste fluxo, evito artefatos de Git/GitHub em português:
chore/atualizacao: ...
feat/ajuste: ...
O post está em português. O repositório, não necessariamente. Para comandos, issues, branch names, release notes e commits, eu prefiro inglês. É mais consistente com ferramentas, ecossistema, documentação e colaboração externa.
E principalmente: evita aquele híbrido estranho em que metade do histórico parece documentação técnica e metade parece anotação pessoal.
Implementação tem limite
A regra mais difícil não é decorar comando. É não sair mexendo em coisa fora do escopo.
Se a issue é:
bug: fix ascii rendering for invalid width
a implementação não deveria também reorganizar o README, trocar dependência, renomear módulo, alterar tema e mexer no Docker.
“Já que eu estava aqui” é uma das frases mais perigosas da engenharia de software.
Cada mudança fora do escopo aumenta o custo de revisão, dificulta rollback, suja o histórico e transforma uma issue simples em um pacote misterioso.
Quando uma nova necessidade aparece durante a implementação, eu prefiro criar outra issue. Pode parecer mais lento, mas é mais honesto. E histórico honesto vale muito quando algo quebra.
Validação antes do merge
Antes de juntar a branch local em dev, eu valido.
Em projetos Node.js ou TypeScript, meu fluxo esperado é:
npm run lint
npm run typecheck
npm test
npm run build
Se algum script não existe, eu não simplesmente ignoro. Primeiro olho o package.json:
cat package.json
Depois decido se faz sentido criar o script ou documentar por que ele não existe.
O que não dá é fingir que validação passou porque o comando nem existia. Isso é o equivalente técnico de desligar o alarme de incêndio para melhorar o clima do prédio.
Validação não garante ausência de bug. Mas reduz a chance de mergear erro besta. E erro besta é o tipo de erro mais irritante, porque ele geralmente não precisava ter chegado longe.
Merge em dev
Depois de validar, volto para dev, atualizo a branch e faço merge com --no-ff:
git switch dev
git pull origin dev
git merge --no-ff stage/12-configure-fastify
git push origin dev
Merge é o ato de integrar uma linha de desenvolvimento em outra. A documentação do git merge descreve o comando como o mecanismo usado para incorporar mudanças de outra branch ao histórico atual. Em português prático: eu estou dizendo para dev receber o trabalho feito em stage/12-configure-fastify.
Eu uso --no-ff para preservar o ponto de integração. Sem entrar em aula longa de grafo: quando o Git consegue fazer um fast-forward, ele pode simplesmente mover o ponteiro da branch para frente, sem criar um commit de merge. Isso deixa o histórico mais linear, mas também pode esconder visualmente que aquele conjunto de commits entrou como uma unidade de trabalho.
Com --no-ff, eu prefiro pagar o custo de um commit de merge explícito para manter a história da integração mais clara. É uma escolha. Em muitos projetos, histórico linear é preferível. No meu fluxo, a rastreabilidade da unidade de trabalho pesa mais.
Depois do push, fecho a issue:
gh issue close 12 --comment "Implemented and merged into dev."
E apago a branch local:
git branch -d stage/12-configure-fastify
Isso encerra o ciclo da unidade de trabalho.
A branch cumpriu sua função. A issue foi resolvida. dev recebeu a mudança. O histórico sabe contar a história.
Como conferir o que falta no milestone
Antes de pensar em release, eu olho o que ainda está aberto no milestone:
gh issue list \
--milestone "v0.1.0" \
--state open
O gh issue list lista issues do repositório e aceita filtros como milestone e estado. É uma forma simples de responder uma pergunta que deveria ser obrigatória antes de qualquer release:
ainda falta alguma coisa planejada para esta versão?
Se ainda existe issue aberta, a release não está pronta.
Dá para discutir exceções? Claro. Sempre dá. Mas exceção precisa ser consciente. O problema é quando a exceção vira fluxo padrão e ninguém mais sabe se a versão representa um conjunto fechado de trabalho ou só “o que tinha na branch naquela hora”.
Release boa tem intenção.
Release sai de main
Quando todas as issues do milestone estão fechadas e dev está estável, preparo a release.
O fluxo é:
git switch main
git pull origin main
git merge --no-ff dev
git tag -a v0.1.0 -m "v0.1.0"
git push origin main
git push origin v0.1.0
Aqui eu prefiro tag anotada:
git tag -a v0.1.0 -m "v0.1.0"
A documentação do Git diferencia tags anotadas de tags leves: tags anotadas carregam metadados como tagger, data e mensagem, enquanto tags leves são basicamente um nome apontando para um objeto. A própria documentação recomenda tags anotadas para releases e tags leves para marcações privadas ou temporárias.
Depois crio a release no GitHub:
gh release create v0.1.0 \
--title "v0.1.0" \
--notes "Initial MVP release" \
--verify-tag
O --verify-tag é proposital. A documentação do gh release create explica que, se uma tag correspondente não existir, o GitHub CLI pode criar essa tag automaticamente a partir do estado mais recente da branch padrão. Com --verify-tag, o comando falha se a tag ainda não existir.
E eu quero que falhe.
Se eu esqueci de criar ou enviar a tag a partir de main, não quero que uma release seja criada automaticamente do lugar errado. Release é um daqueles momentos em que “deixa que a ferramenta resolve” pode virar uma pequena bomba com interface bonita.
Para releases com notas maiores, prefiro arquivo:
gh release create v0.1.0 \
--title "v0.1.0" \
--notes-file release-notes.md \
--verify-tag
Release note não precisa virar livro, mas precisa explicar o que saiu. Idealmente, ela deve nascer do milestone: quais issues foram concluídas, quais mudanças importam, se houve breaking change e o que a pessoa precisa saber para atualizar.
Corrigindo commits errados
Às vezes você erra o commit.
Acontece. O importante é saber se é seguro reescrever o histórico.
Antes de mexer nisso, eu olho o estado do repositório:
git status
git branch --show-current
git log --oneline --decorate --max-count=30
Se os commits estão locais ou se é seguro reescrever, uso rebase interativo:
git rebase -i dev
Ou, quando estou em dev comparando com o remoto:
git rebase -i origin/dev
Rebase é uma ferramenta poderosa justamente porque mexe na base de uma sequência de commits. A documentação oficial resume a ideia como “transplantar uma série de commits para um ponto inicial diferente”. Isso parece uma frase de manual porque é mesmo, então vamos traduzir.
Imagine que você criou três commits em cima de uma branch. Depois percebeu que quer reorganizar esses commits, mudar mensagens, juntar dois commits pequenos demais ou colocar sua sequência em cima de uma base mais nova. O rebase permite reescrever essa sequência.
No modo interativo, o Git abre uma lista de commits e deixa você decidir o que fazer com cada um. Para corrigir mensagem, marco o commit como:
reword
E reescrevo no formato certo:
[type]/[update-scope]: summary
Também dá para usar rebase interativo para juntar commits com squash ou fixup, reordenar commits ou remover um commit local que não deveria existir. Mas esse poder vem com a parte chata: rebase reescreve histórico.
Por isso a regra é simples: rebase em commit local é ferramenta de higiene. Rebase em histórico compartilhado é cirurgia. E cirurgia sem necessidade costuma terminar com alguém perguntando “por que meu branch explodiu?”.
Se a branch já foi enviada e ainda assim for seguro reescrever, o push deve ser:
git push --force-with-lease
Não uso:
git push --force
--force-with-lease ainda é uma ferramenta que precisa de cuidado, mas pelo menos tenta evitar sobrescrever trabalho remoto que você ainda não viu. --force é a motosserra sem óculos de proteção.
Se reescrever histórico for inseguro, eu prefiro criar um commit corretivo:
docs/versioning: correct commit convention workflow
Histórico limpo é bom. Histórico destruído para parecer limpo é só vaidade com efeitos colaterais.
Documentação pública e documentação interna não são a mesma coisa
Outra regra que eu sigo: README não é depósito de planejamento interno.
O README.md precisa ser público, prático e amigável. Ele deve explicar o que o projeto faz, como instalar, como rodar, como usar, quais exemplos importam e quais configurações existem.
Já documentação interna, plano de implementação, notas de arquitetura, rascunhos e checklists de desenvolvimento ficam em:
/specs
E esse diretório não deve ser versionado:
/specs/
Isso ajuda a separar o que é documentação do produto do que é documentação do processo.
O usuário do projeto não precisa ler minhas notas de implementação. E eu não preciso fingir que todo rascunho interno é documentação pública.
Comentário em código não substitui clareza
Eu também evito comentários explicativos comuns no código.
Não porque comentário seja proibido por religião, mas porque comentário muitas vezes vira curativo para código confuso.
Em vez disso, prefiro nomes melhores, funções menores, tipos explícitos, limites de módulo claros e estrutura legível.
Documentação mora em Markdown. Código deve tentar se explicar primeiro.
Existem exceções: headers legais, diretivas exigidas por ferramenta, comentários raros para comportamento externo estranho. Mas exceção não deveria virar estilo.
Se toda função precisa de um parágrafo explicando o que ela faz, talvez o problema não seja falta de comentário. Talvez seja a função.
Checklist do fluxo completo
Para cada unidade de trabalho, o caminho fica assim:
O fluxo separa planejamento, implementação local, integração e publicação para manter rastreabilidade.
1. Identificar a tarefa
2. Criar ou confirmar o milestone
3. Criar a issue com template usando GitHub CLI
4. Criar uma branch local stage/* a partir de dev
5. Implementar apenas o escopo da issue
6. Commitar usando [type]/[update-scope]: summary
7. Rodar lint, typecheck, testes e build
8. Corrigir falhas de validação
9. Fazer merge em dev com --no-ff
10. Enviar apenas dev
11. Fechar a issue
12. Apagar a branch local
E antes de uma release:
1. Confirmar que não há issues abertas no milestone
2. Validar que dev está estável
3. Fazer merge de dev em main
4. Criar tag anotada vX.Y.Z em main
5. Enviar main e a tag
6. Criar GitHub Release a partir da tag
7. Escrever release notes em inglês
Esse fluxo não é o único possível. Nem deveria ser vendido como religião universal. Projetos maiores podem exigir PR obrigatório, revisão, branch protection, ambientes de staging, CI mais forte, changelog automatizado e aprovação formal.
Mas o princípio escala: mudança sem planejamento vira ruído. Mudança sem validação vira risco. Mudança sem release vira amontoado. Mudança sem rastreabilidade vira arqueologia.
O objetivo não é complicar o Git
O objetivo desse fluxo não é transformar projeto pequeno em repartição pública com YAML.
É o contrário.
A ideia é tirar ambiguidade. Cada coisa tem lugar: issue planeja, milestone agrupa, branch isola, commit registra, validação filtra, dev integra, main estabiliza e release publica.
O add/commit/push continua existindo. Ele só deixa de ser o fluxo inteiro.
Porque versionamento bom não é apertar três comandos em sequência. É conseguir olhar para o histórico daqui a seis meses e entender o que aconteceu sem precisar abrir uma sessão espírita com seu eu do passado.