Pular para o conteúdo

Pronto para LLM

/tratando-dotfiles-como-software.md

Título:
Tratando dotfiles como software

Por nove dias, o backup do meu sistema não fez um único envio incremental. O script procurava o snapshot anterior no homelab com um ssh dentro de um while read, e o ssh lia o stdin: drenava o pipe do loop, só o primeiro candidato era testado, e cada execução virava um envio completo que estourava o timeout de duas horas do serviço. Quando percebi, eram 48,65 GiB num envio só e 12 snapshots acumulados no disco (o lado do homelab está aqui).

Ninguém viu porque não tinha ninguém olhando: era um timer do systemd rodando como root por volta do meio-dia. No post anterior, o dotfiles guardou e reinstalou o sistema. Aqui, o assunto é o script funcionar no dia em que você precisa dele.

Script que ninguém vê falhar

Boa parte de um dotfiles maduro não roda num terminal. No meu, roda num atalho do Niri, num timer, num hook do Git, quando o microfone liga ou quando o notebook sai da tomada. Quando um desses quebra, não aparece mensagem em lugar nenhum. A funcionalidade só para.

Hoje são 66 scripts em bin/ e quase 7.500 linhas de Bash em lib/. Nesse tamanho, "eu testo na mão quando mexo" deixa de funcionar.

Comece pelo shellcheck

O primeiro passo custa zero: o shellcheck pega aspas faltando, variável não declarada, cd sem tratamento de erro e outros clássicos do Bash. Ele está na minha lista de pacotes, então toda máquina tem, e um workflow do GitHub Actions roda ele arquivo por arquivo em bin/ e lib/:

find lib -type f -name "*.sh" | while read -r file; do
    shellcheck -e SC1091,SC2034 "$file" || exit 1
done

Ele não testa comportamento, mas tira da frente os erros que fariam o teste falhar por motivo besta.

Monte o BATS dentro do repositório

Pra comportamento, eu uso o BATS. Um script no próprio repo (tests/install-bats.sh) instala localmente o bats-core e as bibliotecas bats-support, bats-assert e bats-file, numa pasta que fica no .gitignore. Os testes seguem a estrutura do código, tests/unit/bin/ pros comandos e tests/unit/lib/ pras bibliotecas, e um runner roda tudo:

./tests/run-tests                 # todos os testes
./tests/run-tests unit            # só os unitários
./tests/run-tests tests/unit/bin/nocta-hold.bats

Hoje são 42 arquivos e 571 casos de teste.

O motivo de ter chegado nisso é o meu framework do Claude Code, que força TDD em tudo, como contei no post sobre o fluxo com três agentes. Uns 90% do código do dotfiles hoje é escrito por IA. Com a IA escrevendo quase tudo, nada mais justo que testar tudo o que eu puder, e o teste vem antes do script.

Deixe cada dependência trocável

Script de dotfiles quase sempre mexe em coisa de verdade: SSH, btrfs, o shell da barra, o PipeWire. Contra o sistema real não dá pra testar. O truque é deixar cada dependência trocável por variável de ambiente, com o valor real como padrão, e no teste apontar essa variável pra um stub que só anota o que recebeu.

O nocta-hold controla o "Não perturbe" quando vários scripts pedem ao mesmo tempo, e fala com a barra por um comando chamado nocta. A dependência fica assim:

# Overridable so tests can inject a stub; default is sibling nocta binary.
: "${NOCTA_BIN:=$SCRIPT_DIR/nocta}"

No teste, NOCTA_BIN aponta pro stub que anota cada chamada num arquivo e responde o que o teste mandar:

cat > "$STUB_DIR/nocta" <<'STUB'
#!/usr/bin/env bash
# Log every invocation; answer dnd status from FAKE_DND_STATUS (on|off).
if [ "$#" -eq 0 ]; then
    echo "nocta" >> "$CALL_LOG"
else
    echo "nocta $*" >> "$CALL_LOG"
fi
if [ "${1:-}" = "dnd" ] && [ "${2:-}" = "status" ]; then
    printf '%s\n' "${FAKE_DND_STATUS:-off}"
    exit 0
fi
exit 0
STUB

O teste então confere o log: quem pediu o "Não perturbe", quem soltou, se o último a sair desligou o que devia. Nenhuma barra de verdade é tocada.

O bug do backup, agora como teste

A correção do backup seguiu esse caminho. Toda chamada de SSH dentro de loop passou a usar uma função com -n, que impede o ssh de ler o stdin:

# ssh_rn: -n keeps ssh from reading stdin. Mandatory for every call inside a
# `while read` loop, otherwise ssh drains the loop's input pipe and the loop
# ends after a single iteration.
ssh_rn() { ssh -n "${SSH_OPTS[@]}" "$REMOTE_HOST" "$@"; }

E o script ganhou BTRFS_BACKUP_SOURCE e BTRFS_BACKUP_LOCAL_DIR, pro teste apontar pra uma pasta temporária em vez do meu root. Foram sete testes. O primeiro é o próprio bug escrito como teste: "prova todos os snapshots locais até achar o parent presente no homelab". Se alguém (eu ou a IA) colocar um ssh sem -n naquele loop de novo, ele fica vermelho.

Spec antes de script

Mudança grande no meu dotfiles começa num documento dentro do próprio repositório, em docs/specs/ e docs/plans/. A migração do Noctalia pra v5, por exemplo, foi quebrada em fatias, uma por plugin, e cada plano carregava a mesma regra: o plugin continuava funcionando na v4 até a v5 assumir, o que aconteceu em 21 de outubro.

Com a IA escrevendo a maior parte do código, a spec é onde eu reviso a intenção antes de existir código, e os testes são o que me deixa confiar no que foi entregue.

Uma TUI que chama os scripts

Os menus do dotfiles eram em whiptail, e o lib/interactive.sh tinha virado 42 KB de Bash. Em abril de 2026 eu fiz uma TUI em Rust com ratatui, pra aprender Rust num projeto que eu uso de verdade.

A regra principal foi não reescrever nada. A TUI chama os scripts Bash que já existem via std::process::Command, lê o links.conf e os profiles nos formatos que já tinham, e pega tema e caminhos de um config.yaml. O MVP fechou com 50 testes, num binário de 1,6 MB.

Ela virou o meu jeito principal de instalar apps. Só de vez em quando eu chamo os scripts na mão, e eles continuam lá, testados, pra quando eu precisar.

O que eu levo disso

  • Script que roda sem ninguém olhando tem teste.
  • Toda dependência externa é trocável por variável de ambiente.
  • Bug corrigido vira teste com o nome do bug.
  • Mudança grande começa numa spec dentro do repositório.
  • A interface chama o Bash; reescrever em Rust só quando o Bash atrapalhar.

./tests/run-tests

---
title: Tratando dotfiles como software
url: https://nandomoreira.dev/tratando-dotfiles-como-software
date: 2026-10-28
---

Por nove dias, o backup do meu sistema não fez um único envio incremental. O script procurava o snapshot anterior no homelab com um `ssh` dentro de um `while read`, e o `ssh` lia o stdin: drenava o pipe do loop, só o primeiro candidato era testado, e cada execução virava um envio completo que estourava o timeout de duas horas do serviço. Quando percebi, eram 48,65 GiB num envio só e 12 snapshots acumulados no disco ([o lado do homelab está aqui](/meu-homelab-num-mac-mini-de-2018)).

Ninguém viu porque não tinha ninguém olhando: era um timer do systemd rodando como root por volta do meio-dia. No [post anterior](/seu-sistema-tem-memoria-por-que-manter-um-dotfiles), o dotfiles guardou e reinstalou o sistema. Aqui, o assunto é o script funcionar no dia em que você precisa dele.

## Script que ninguém vê falhar

Boa parte de um dotfiles maduro não roda num terminal. No meu, roda num atalho do [Niri](https://github.com/YaLTeR/niri), num timer, num hook do Git, quando o microfone liga ou quando o notebook sai da tomada. Quando um desses quebra, não aparece mensagem em lugar nenhum. A funcionalidade só para.

Hoje são 66 scripts em `bin/` e quase 7.500 linhas de Bash em `lib/`. Nesse tamanho, "eu testo na mão quando mexo" deixa de funcionar.

## Comece pelo shellcheck

O primeiro passo custa zero: o [shellcheck](https://www.shellcheck.net/) pega aspas faltando, variável não declarada, `cd` sem tratamento de erro e outros clássicos do Bash. Ele está na minha lista de pacotes, então toda máquina tem, e um workflow do GitHub Actions roda ele arquivo por arquivo em `bin/` e `lib/`:

```bash
find lib -type f -name "*.sh" | while read -r file; do
    shellcheck -e SC1091,SC2034 "$file" || exit 1
done
```

Ele não testa comportamento, mas tira da frente os erros que fariam o teste falhar por motivo besta.

## Monte o BATS dentro do repositório

Pra comportamento, eu uso o [BATS](https://github.com/bats-core/bats-core). Um script no próprio repo (`tests/install-bats.sh`) instala localmente o `bats-core` e as bibliotecas `bats-support`, `bats-assert` e `bats-file`, numa pasta que fica no `.gitignore`. Os testes seguem a estrutura do código, `tests/unit/bin/` pros comandos e `tests/unit/lib/` pras bibliotecas, e um runner roda tudo:

```bash
./tests/run-tests                 # todos os testes
./tests/run-tests unit            # só os unitários
./tests/run-tests tests/unit/bin/nocta-hold.bats
```

Hoje são 42 arquivos e 571 casos de teste.

O motivo de ter chegado nisso é o meu framework do Claude Code, que força TDD em tudo, como contei no post sobre o [fluxo com três agentes](/fluxo-rpi-claude-code-grok-codex). Uns 90% do código do dotfiles hoje é escrito por IA. Com a IA escrevendo quase tudo, nada mais justo que testar tudo o que eu puder, e o teste vem antes do script.

## Deixe cada dependência trocável

Script de dotfiles quase sempre mexe em coisa de verdade: SSH, btrfs, o shell da barra, o [PipeWire](https://pipewire.org). Contra o sistema real não dá pra testar. O truque é deixar cada dependência trocável por variável de ambiente, com o valor real como padrão, e no teste apontar essa variável pra um stub que só anota o que recebeu.

O `nocta-hold` controla o "Não perturbe" quando vários scripts pedem ao mesmo tempo, e fala com a barra por um comando chamado `nocta`. A dependência fica assim:

```bash
# Overridable so tests can inject a stub; default is sibling nocta binary.
: "${NOCTA_BIN:=$SCRIPT_DIR/nocta}"
```

No teste, `NOCTA_BIN` aponta pro stub que anota cada chamada num arquivo e responde o que o teste mandar:

```bash
cat > "$STUB_DIR/nocta" <<'STUB'
#!/usr/bin/env bash
# Log every invocation; answer dnd status from FAKE_DND_STATUS (on|off).
if [ "$#" -eq 0 ]; then
    echo "nocta" >> "$CALL_LOG"
else
    echo "nocta $*" >> "$CALL_LOG"
fi
if [ "${1:-}" = "dnd" ] && [ "${2:-}" = "status" ]; then
    printf '%s\n' "${FAKE_DND_STATUS:-off}"
    exit 0
fi
exit 0
STUB
```

O teste então confere o log: quem pediu o "Não perturbe", quem soltou, se o último a sair desligou o que devia. Nenhuma barra de verdade é tocada.

## O bug do backup, agora como teste

A correção do backup seguiu esse caminho. Toda chamada de SSH dentro de loop passou a usar uma função com `-n`, que impede o `ssh` de ler o stdin:

```bash
# ssh_rn: -n keeps ssh from reading stdin. Mandatory for every call inside a
# `while read` loop, otherwise ssh drains the loop's input pipe and the loop
# ends after a single iteration.
ssh_rn() { ssh -n "${SSH_OPTS[@]}" "$REMOTE_HOST" "$@"; }
```

E o script ganhou `BTRFS_BACKUP_SOURCE` e `BTRFS_BACKUP_LOCAL_DIR`, pro teste apontar pra uma pasta temporária em vez do meu root. Foram sete testes. O primeiro é o próprio bug escrito como teste: "prova todos os snapshots locais até achar o parent presente no homelab". Se alguém (eu ou a IA) colocar um `ssh` sem `-n` naquele loop de novo, ele fica vermelho.

## Spec antes de script

Mudança grande no meu dotfiles começa num documento dentro do próprio repositório, em `docs/specs/` e `docs/plans/`. A migração do [Noctalia](https://github.com/noctalia-dev/noctalia-shell) pra v5, por exemplo, foi quebrada em fatias, uma por plugin, e cada plano carregava a mesma regra: o plugin continuava funcionando na v4 até a v5 assumir, o que aconteceu em 21 de outubro.

Com a IA escrevendo a maior parte do código, a spec é onde eu reviso a intenção antes de existir código, e os testes são o que me deixa confiar no que foi entregue.

## Uma TUI que chama os scripts

Os menus do dotfiles eram em whiptail, e o `lib/interactive.sh` tinha virado 42 KB de Bash. Em abril de 2026 eu fiz uma TUI em Rust com [ratatui](https://ratatui.rs), pra aprender Rust num projeto que eu uso de verdade.

A regra principal foi não reescrever nada. A TUI chama os scripts Bash que já existem via `std::process::Command`, lê o `links.conf` e os profiles nos formatos que já tinham, e pega tema e caminhos de um `config.yaml`. O MVP fechou com 50 testes, num binário de 1,6 MB.

Ela virou o meu jeito principal de instalar apps. Só de vez em quando eu chamo os scripts na mão, e eles continuam lá, testados, pra quando eu precisar.

## O que eu levo disso

- Script que roda sem ninguém olhando tem teste.
- Toda dependência externa é trocável por variável de ambiente.
- Bug corrigido vira teste com o nome do bug.
- Mudança grande começa numa spec dentro do repositório.
- A interface chama o Bash; reescrever em Rust só quando o Bash atrapalhar.

`./tests/run-tests`

Agora é só compartilhar.