Sitemap
Creditas Tech

Nossas tecnologias, inovações, gestão de produtos digitais, cultura e muito mais!

O que aprendi empilhando Pull Requests com GitHub Stack

Renato Filippelli · Staff Software Engineer @ Creditas

8 min read1 hour ago

--

Press enter or click to view image in full size

Toda mudança grande vira o mesmo dilema: um PR gigante, que ninguém revisa de verdade, ou vários PRs pequenos que dependem um do outro e passam a vida sendo rebaseados. Em dois dias eu vivi a segunda opção com dezenas de PRs, usando stacked pull requests do GitHub e o nosso harness de desenvolvimento. Comecei de um jeito, deu errado, e terminei de outro. É esse caminho que quero contar.

Em resumo, o que ficou:

  • Uma pilha só por entrega, ordenada pelos arquivos compartilhados.
  • Todo PR dependente dentro da stack, com gh stack link desde o primeiro.
  • Rebase da cadeia como unidade, local, com build só no topo; push atômico com lease, feito por uma pessoa.
  • Review e merge pela fila, com a cadeia limpa; por contexto, se quiser validar no meio.

Contexto

O serviço de originação de crédito era um microsserviço stateless que reagia aos eventos do orquestrador da esteira e direcionava cada etapa para os serviços especialistas adequados (validação cadastral, documentos, formalização, entre outros). Funcionava bem. O problema era que as regras de um produto específico estavam enraizadas no serviço, nos pacotes, nas filas e nas configurações.

Com a evolução da arquitetura, desenhamos uma estrutura multi-produto para centralizar as regras comuns da plataforma: um núcleo compartilhado com módulos dedicados para particularidades de cada produto. Migrar nosso produto legado para essa nova estrutura significava separar regra comum de regra específica, modularizar domínio, use cases, adapters, handlers e observabilidade, virando o fluxo de processamento de forma gradual.

Para uma mudança desse tamanho, stacked pull requests, recurso nativo do GitHub em public preview, eram o caminho natural. A pilha é operada pela extensão gh stack do GitHub CLI (gh extension install github/gh-stack). Daqui em diante chamo só de stack. Se você nunca usou: o primeiro PR tem como base a main, o segundo tem como base a branch do primeiro, e assim por diante. Quem revisa vê só o diff daquela camada. O gh stack link liga PRs que já existem em uma pilha, e o nosso harness já faz isso por padrão quando abre um PR por fatia.

Press enter or click to view image in full size
GitHub Stack: cada PR tem como base o PR de baixo

Desafio

A migração foi dividida em vários contextos de negócio: documentos, biometria, contrato, etc. Cada contexto virou uma stack, fatiada por camada: domínio, use cases, infra, handlers, observabilidade. Um commit por branch, um PR por fatia, todas as stacks partindo da mesma base.

A ideia fazia sentido. Os contextos eram independentes, então dava para rodar em paralelo: um agente de IA em cada contexto, cada um no seu worktree, todos no mesmo plano. Foi isso que tornou as stacks baratas. Em uma manhã eu tinha oito stacks abertas, todas sobre o mesmo topo, e o custo de escrever o código tinha deixado de ser o problema. O problema passou a ser coordenar como aquilo entrava na main.

Quatro coisas deram errado, nesta ordem.

1. Os contextos eram independentes. Os arquivos, não.

Toda stack precisava registrar o que trazia nos mesmos lugares: perfis de configuração, o YAML central, o README do pacote, o teste de contexto do framework, os docs de ambiente. Cada uma acrescentava a sua linha na mesma tabela e a sua chave no mesmo arquivo. Em um caso, duas stacks criaram as mesmas classes utilitárias, cada uma do seu jeito. Não é conflito de lógica, é conflito de união. Mas ele aparece toda vez que uma stack é rebaseada sobre a outra.

2. Stack sobre stack não é uma stack.

Como elas não podiam entrar ao mesmo tempo, tentei o meio-termo: manter uma stack por contexto, mas referenciá-las em cadeia, a stack de documentos com base no topo da de formalização, ou a stack C dependendo da stack B. Para o GitHub, uma stack com base no topo de outra continua sendo duas stacks. Quando a de baixo entra na main por squash, só os PRs dela são re-apontados. A de cima fica sobre commits que não existem mais, e o primeiro PR dela aparece em conflito. A aprovação cai junto: o GitHub derruba o approve dos PRs de cima com “the merge-base changed after approval”. Ainda de manhã, antes de encadear, eu já estava pensando em qual stack pedir aprovação primeiro, porque se aprovassem tudo de uma vez eu ia ter que pedir de novo.

3. PR fora da stack.

Esse foi o mais chato, e eu só entendi depois. O GitHub mergeia a pilha de baixo para cima, um squash por PR, e apaga as branches. Os commits originais não vão para a main; o que vai é um commit novo por PR. Para quem está dentro da stack isso é transparente: o GitHub sabe a cadeia, re-aponta a base de cada PR e mantém o diff só daquela fatia. Agora pense num PR aberto com base na branch do último PR da stack, mas sem o gh stack link. Para o GitHub ele é um PR comum. Quando a stack entra, a base some, o PR é re-apontado para a main, e o git compara os commits dele com a main: os commits originais da pilha inteira ainda estão na branch dele e não existem lá. O diff vira a pilha toda, os bots de label e cobertura reclassificam o PR, e ele fica CONFLICTING sem ter um conflito real. Isso aconteceu com uma cadeia inteira que eu tinha cortado sobre o topo de outra stack sem linkar, e de novo no dia seguinte com um PR pequeno aberto minutos antes do merge.

Press enter or click to view image in full size
O que o squash da stack faz com o PR de cima

4. Cada rebase era uma chuva de comentários.

Rebase é force-push em cada branch, e cada force-push faz o bot de review rodar de novo em cada PR. Entre a manhã das oito stacks em paralelo e a tarde do re-encadeamento, foram mais de 140 force-pushes em 56 PRs, em ondas de até 32 branches de uma vez, e 72 aprovações derrubadas. Muitos dos comentários novos eram repetidos.

Get renato filippelli’s stories in your inbox

Join Medium for free to get updates from this writer.

No fim da manhã parei de abrir frentes novas. Não porque algo tinha quebrado, mas porque eu não conseguia mais acompanhar o que estava acontecendo. A tarde foi re-encadear o que já existia, e foi aí que o problema 2 apareceu.

Solução

A resposta veio como uma restrição antes de virar solução. As stacks não eram independentes: estavam interligadas pelos arquivos que compartilhavam. Então o que não podia existir era PR fora da pilha. Juntei tudo que estava pronto em uma cadeia só, linear, com base na main, e dali em diante segui algumas regras.

Uma cadeia por entrega, ordenada pelos arquivos compartilhados. Quem mexe no núcleo vai embaixo; quem só acrescenta handler vai em cima. Nos arquivos que todo mundo toca, acrescentar sem reordenar nem reformatar, para o rebase de quem vem depois ser trivial.

Linkar na stack desde o primeiro PR, inclusive quando a cadeia nasce sobre o topo de outra. Dentro da stack, o GitHub cuida da base no merge. Fora dela, você cuida. Se precisar de um PR sobre o topo de uma stack prestes a entrar, espere o merge e abra sobre a main. Se já abriu, guarde o SHA do topo antes do merge; a correção é:

git rebase - onto origin/main <sha-do-topo-antigo> <sua-branch>
git push - force-with-lease=<sua-branch>:<sha-remoto-atual> origin <sua-branch>

Rebasear a cadeia como unidade, em cópias locais. Uma branch de trabalho por fatia, git rebase — onto encadeado, a árvore de cada fatia conferida com git merge-tree contra a base real, e o build uma vez, só no topo. Nada toca o remoto até o fim. Na prática:

git fetch origin # os SHAs do lease, mais adiante, dependem disto
# conferir antes: com a base real, há conflito de verdade?
git merge-tree - write-tree - merge-base <topo-antigo-da-base> origin/main origin/fatia-1
# fatia 1 sai da main; cada fatia seguinte sai da cópia rebaseada da anterior
git branch -f rebase/fatia-1 origin/fatia-1
git rebase - onto origin/main <topo-antigo-da-base> rebase/fatia-1
git branch -f rebase/fatia-2 origin/fatia-2
git rebase - onto rebase/fatia-1 origin/fatia-1 rebase/fatia-2
# … até o topo; build só no topo

Publicar com lease, de uma vez, por uma pessoa.
git push — atomic — force-with-lease apontando o SHA remoto de cada branch, depois de um — dry-run. Se um lease falha, nada sobe: ou alguém empurrou algo que você não viu, ou um bot commitou na branch. A sessão de IA deixa o script pronto; eu rodo.

git push - atomic - dry-run origin \
- force-with-lease=refs/heads/fatia-1:3f1c2ab refs/heads/rebase/fatia-1:refs/heads/fatia-1 \
- force-with-lease=refs/heads/fatia-2:9b7e0cd refs/heads/rebase/fatia-2:refs/heads/fatia-2
# conferiu? repete sem - dry-run

Estender no fim. PR novo entra no topo com gh stack link, sem mexer nos outros.

Mergear pela fila. O review humano vem com zero threads abertas na cadeia, as aprovações entram juntas, e a merge queue recebe a pilha inteira.

Press enter or click to view image in full size
Três jeitos de empilhar a mesma entrega

Resultado

Foram duas cadeias. A primeira é a que juntei naquela tarde, com tudo que já estava pronto: 18 PRs. A segunda nasceu em cima dela, com os contextos que ainda faltavam, e cresceu até o fim do dia seguinte: mais 18. As duas entraram na main de uma vez, pela merge queue, um squash por PR, todos no mesmo segundo.

Os números do antes e depois são o que me convenceu. Na fase de várias stacks: mais de 140 force-pushes em 56 PRs e 72 aprovações derrubadas em um dia. Na segunda cadeia, feita já como pilha única: um rebase de 17 fatias (o 18º PR entrou depois, no topo) com um único conflito real, uma linha de README; 17 force-pushes, num push só; um build no topo; 46 threads do bot de review respondidas e resolvidas antes do review humano; duas aprovações em cinco minutos nos 18 PRs; nenhuma re-aprovação.

No final das contas eu mergeei as duas cadeias inteiras, cada uma de uma vez. Mas não precisava ser assim: uma cadeia linear também permite mergear por contexto, da base até o último PR de um contexto, validar em staging, e só então seguir para o próximo. A ordem continua fixa, mas o ritmo de merge fica com você. Se eu fizesse de novo, provavelmente iria por aí.

Sobre os agentes: eles fizeram o que ninguém faria à mão numa manhã, que foi abrir oito stacks em paralelo. O que ficou comigo foi o que não deve ficar com eles: o force-push, o gh stack link, a aprovação, a ordem de merge. Com código barato, o trabalho da pessoa vira coordenar como ele entra.

O problema nunca foi empilhar. Foi empilhar em paralelo sobre os mesmos arquivos, e deixar PR dependente fora da pilha. Uma cadeia só custa uma coisa: a ordem de merge deixa de ser livre. Em troca, o rebase é um, o build é um, o review é um, e o GitHub faz o resto.

--

--