git-housekeeping · v0.1.0 · 2026-10-20 · sha256 9888a2383a96c3ca

git-housekeeping v0.1.0A

Immutable. This exact content never changes and is served at /api/v1/blob/9888a2383a96c3ca.

---
name: git-housekeeping
description: "Safely deletes merged git branches, local and remote, and their worktrees. A read-only scan proves each merge by merge commit or by the merged PR's head sha; only the approved plan is deleted, with sha leases and two confirmations. Triggers — merged branches, branch cleanup, delete merged branches, stale worktrees, git housekeeping, prune branches."
metadata:
  version: 0.1.0
---

# git-housekeeping

Apaga as branches que já foram mergeadas, locais e no remoto, e os worktrees delas, sem perder
trabalho. Quem decide é o dado: um script que só lê prova cada merge, e outro só apaga o que o
usuário aprovou, provando de novo na hora.

## Armadilhas

O que faz a limpeza feita à mão perder trabalho, e que os scripts já tratam. Os quatro últimos itens
foram medidos no git 2.43.0 em 2026-10-20, num repo descartável.

- `git branch --merged` não vê squash merge, e `-D` numa branch squash-mergeada apaga junto o commit
  que entrou depois do merge.
- Apagar no remoto a branch que é gatilho de deploy (`on.push.branches`) ou head de PR aberto quebra
  o deploy ou fecha o PR.
- `git worktree remove`, sem `--force`, apaga os arquivos ignorados (`.env.local`, `node_modules/`)
  com rc 0, sem aviso.
- `%(upstream:track)` não diz `[gone]` enquanto a ref remote-tracking velha existe. O "sumiu do
  remoto" só sai confiável do `git ls-remote`.
- `git remote get-url` aplica o `insteadOf`. O `owner/repo` do GitHub sai da URL crua
  (`git config --get remote.<r>.url`).
- `git push --porcelain` com pre-push que recusa não imprime linha de ref nenhuma. A ausência da
  linha é o sinal.
- A config do usuário muda o git "padrão": com `fetch.prune`/`fetch.pruneTags`, um `git fetch` comum
  apaga a ref órfã e as tags locais; com `status.showUntrackedFiles=no`, o `git status` não mostra o
  arquivo não rastreado, e o `git worktree remove` o apaga. Os scripts passam os flags explícitos
  (`--no-prune --no-prune-tags`, `--untracked-files=normal`).

## O que a limpeza libera de verdade

Diga ao usuário antes de prometer disco: **apagar uma branch libera quase nada.** A ref tem bytes, e
os objetos só saem com `gc` depois que o reflog expira. O espaço sai dos **worktrees** que vão
embora, com tudo o que tinham dentro. O relatório mostra os dois números separados.

## Os scripts

`SCRIPTS` é a pasta `scripts/` ao lado deste arquivo (o Claude Code mostra a pasta da skill ao
carregá-la, em "Base directory for this skill"). Precisam de Python 3.9+ (só stdlib; o código usa
`str.removeprefix`), git 2.31+ (`rev-parse --path-format=absolute`) e, para provar squash merge, o
`gh` autenticado. As regras de classificação moram em `housekeeping.py`, que as duas CLIs importam:
o `apply_plan.py` reclassifica com o mesmo código que gerou o plano.

| Script | O que faz | rc |
|---|---|---|
| `analyze.py --repo <repo> --remote origin --plan <arquivo>` | `git fetch` (sem prune), consulta o remoto e o gh, grava o plano e imprime o relatório. Não apaga nada. | 0 ok; 2 remoto que não responde, repo inválido ou plano dentro do repo |
| `analyze.py --scan <pasta>` | Uma linha por repo da pasta. Sem rede, sem plano, sem escrever nada. | 0 |
| `apply_plan.py --plan <arquivo> --scope local\|remote [--dry-run]` | Reclassifica cada item `delete` do escopo com os fatos de agora e apaga o que ainda passa. | 0 tudo feito; 1 algo recusado; 2 plano inválido |

## Fluxo (um repo)

1. **Analisar.** Grave o plano **fora do repo**: no Claude Code, no scratchpad da sessão; fora dele,
   numa pasta temporária. Dentro do repo, o plano sujaria a árvore, e o script recusa com rc 2.

   ```bash
   python3 "$SCRIPTS/analyze.py" --repo <repo> --remote origin --plan <scratchpad>/plano.json
   ```

   rc 2: mostre o erro e pare. Sem o remoto não há prova, e um plano com dado velho é pior que nenhum.

2. **Mostrar o relatório inteiro**: os três grupos (`SAI`, `NÃO PROVADO`, `FICA`) e o disco. Não
   resuma o que fica: é ali que o usuário vê o que a skill não vai apagar, e por quê. Do worktree que
   sai, leia em voz alta os ignorados que vão junto, sobretudo os marcados com ⚠ (`.env*`). Cite só
   o nome; nunca abra nem imprima o conteúdo.

3. **1ª confirmação: o local** (branches locais, worktrees e refs remote-tracking). Pergunte com a
   lista na frente do usuário.

4. **Apagar o local:**

   ```bash
   python3 "$SCRIPTS/apply_plan.py" --plan <scratchpad>/plano.json --scope local
   ```

5. **2ª confirmação: o remoto.** Mostre de novo a lista das branches remotas que saem, com o autor do
   último commit de cada uma: apagar no remoto mexe com quem usa o repo.

6. **Apagar o remoto:** o mesmo comando com `--scope remote`.

7. **Provar.** Rode o `analyze.py` de novo: o grupo `SAI` tem de vir vazio (ou só com o que foi
   recusado). Mostre o disco antes e depois e as linhas `desfazer:` que o `apply_plan.py` imprimiu.

Confirmação negada no passo 3: pare tudo. Negada no passo 5: o local fica feito e o remoto não.

**Autorização dada na própria mensagem.** "Pode apagar o local sem perguntar" é a confirmação do
passo 3: mostre o relatório e siga. Ela não cobre o passo 5. "Do remoto, só me mostra" quer dizer
parar no passo 5 com a lista.

## Quando o apply recusa

rc 1 é um resultado, e não um erro a contornar. Cada `recusado:` diz o que mudou desde o plano: a
ponta andou, o worktree sujou, apareceu PR aberto, o pre-push barrou. Mostre a linha ao usuário. Não
apague o item por outro caminho (`git branch -D`, `git push --delete`, `--force`, `--no-verify`):
a recusa existe porque a prova caiu, e o outro caminho apaga sem prova. Se o usuário quiser o item
fora mesmo assim, rode o `analyze.py` de novo e mostre o motivo novo antes.

## Como ler um "não provado"

| Motivo no relatório | O que quer dizer | O que o usuário pode fazer |
|---|---|---|
| `a ponta andou depois do merge: N commit(s) depois` | Há commit na branch que não está na default. | Abrir PR com o que sobrou, ou decidir que é descartável. |
| `mergeada na staging, não na main` | O código só está na branch de integração. | Esperar a promoção para a default. |
| `a ponta é um commit da main: branch nunca usada, ou fast-forward sem PR` | Não dá para distinguir branch recém-criada de merge fast-forward. | Apagar à mão, se ele souber qual é. |
| `sem prova por PR (...)` | Sem `gh` (ou remoto fora do GitHub), o squash não se prova. | `gh auth login` e analisar de novo. |
| `o PR aberto não pode ser conferido (...)` | Sem `gh`, nenhuma branch remota sai, nem a mergeada: apagá-la fecharia um PR aberto. | `gh auth login` e analisar de novo; a local mergeada sai assim mesmo. |
| `esta ref é o único ponteiro local para esses commits` | O remoto apagou a branch sem merge, e o `fetch --prune` apagaria a última cópia. | Criar uma branch na ponta (`git branch <nome> <sha>`), se o trabalho importa. |

## Proteções

Ficam sempre: a branch default, a branch atual, `main`, `master`, `develop`, `staging`,
`production`, `release/*`, as citadas em `branches:` de algum workflow, as que são head ou base de
PR aberto (sem `gh` para conferir, nenhuma remota sai), a branch de um rebase em andamento, e as do
padrão local do usuário. Também fica o worktree onde a sessão está (o diretório atual), o que tem
rebase, merge, bisect, cherry-pick ou revert parado, e o que ganhou arquivo ignorado novo depois do
plano. O padrão local:

```bash
git config --add housekeeping.protect 'deploy/*'
```

## Varredura de vários repos

Para "quais repos têm mais branch velha", rode `analyze.py --scan <pasta>` e mostre a tabela. Ela
usa o último fetch de cada repo (a data aparece na linha) e conta só o merge commit como prova. Daí
ofereça o fluxo completo no repo que o usuário escolher, um por vez.

## Desfazer

Cada item apagado deixa uma linha `desfazer:` (`git branch <b> <sha>`,
`git push origin <sha>:refs/heads/<b>`, `git worktree add <path> <b>`), impressas do último item
apagado ao primeiro. Rode-as nessa ordem: o worktree só volta depois da branch dele. Elas funcionam
enquanto o `gc` não levar os objetos, e por isso esta skill nunca roda `gc` nem `reflog expire`. O
worktree volta sem os ignorados: esses não têm cópia.

## Manutenção

`tests/` roda com `python3 -m unittest discover -s tests` (remoto bare e `gh` falso, sem rede).
`assets/trigger-evals.json` é o eval set de gatilho da description, no formato do otimizador da
`skill-creator`: rode-o quando a description mudar.