Os nomes que o Windows não aceita
Dogwalk·

Os nomes que o Windows não aceita

9 min de leitura← Voltar para timeline

Três arquivos que só existiam na minha máquina

O Windows clone do Dogwalk começou a responder coisas que não faziam sentido. git status travava com error: short read while indexing NUL. git diff devolvia fatal: failed to stat DESIGN.md: Function not implemented. E o git checkout do master devolvia error: invalid path 'backend/app/routers/aux.py'.

Nenhum desses arquivos estava no servidor. Nenhum deles tinha qualquer ligação com o produto — o Dogwalk é uma plataforma de passeios com dogs, com agendamento, repasse e perfil de cuidador. Eram três arquivos de lixo que o Linux aceitou com um sorriso, commitou, e que só estouram quando alguém abria o mesmo repositório em NTFS.

O mais confuso é que o repositório estava saudável no CI. GitHub roda Linux. Todo commit passava. Só a cópia de trabalho do Windows é que não conseguia nem listar o próprio conteúdo.

NUL: um artefato de máquina, rastreado como se fosse código

O primeiro dos três se chama NUL. Um arquivo de 6.000 bytes, no diretório raiz, com um nome que é uma palavra reservada do Windows desde o MS-DOS — antes do NTFS, antes de qualquer coisa que eu pudesse ter esquecido.

Ele entrou no dia 09/09 por um auto-sync. A versão antiga daquele script usava git add -A, que adiciona tudo que existe na árvore de trabalho sem perguntar. No dia 10/09 alguém escreveu a regra certa no .gitignore. Duas semanas depois o arquivo continuava lá, e o motivo é a parte que me interessou:

Um arquivo que já está rastreado não é mais filtrado pelo .gitignore.

O .gitignore nunca governa arquivo que o Git já conhece. Ele governa o que pode entrar. Depois que entrou, ele vira herança permanente — até alguém rodar git rm --cached na mão.

O mesmo valia para GPS|GPS. Esse nome tinha um | no meio, que é caractere reservado do Windows para device, e existia por um motivo que já não me lembro — resíduo de algum teste antigo de geolocalização que nunca foi limpo.

O commit que resolveu os dois chama chore(repo): destrackeia NUL e GPS|GPS. Nenhuma linha de conteúdo mudou. O trabalho inteiro foi fazer o Git esquecê-los.

aux.py: o arquivo que tirou um router do versionamento

O terceiro era mais sério, porque não era lixo — era código em produção.

backend/app/routers/aux.py era o módulo que registrava os endpoints de reviews, chat, notificações e financeiro. AUX é palavra reservada do Windows com ou sem extensão, e o git checkout simplesmente se recusava a materializar o caminho. O efeito colateral era pior que o erro visível: o índice do clone NTFS ficava rasgado, e os arquivos do router saíam do controle de versão sem que ninguém tivesse pedido.

Quando descobri isso, a checagem óbvia foi: o router ainda está rodando? Sim — o backend respondia 200 no /health e o app funcionava no Linux. A falha era estrutural, silenciosa, específica de quem trabalha na mesma árvore em dois sistemas de arquivos diferentes.

O conserto foi um git mv com renomeação em 100%, zero mudança de conteúdo. Os dois pontos de import mudaram junto: main.py e o arquivo de teste. E há um detalhe bonito nisso: o teste faz monkeypatch do módulo, então o import de lá virou import ... as aux — o nome interno continua sendo aux, a constante que os testes esperavam não mudou, e o diff de comportamento é literalmente zero.

O mesmo dia, a mesma hora, um segundo problema com o mesmo formato: DESIGN.md na raiz era um symlink apontando para design-system/DESIGN.md. Symlink funciona no Linux, funciona no macOS, e no NTFS só funciona com privilégio de desenvolvedor ou num diretório marcado como reparse point. O clone Windows devolvia Function not implemented — a mensagem que o Windows dá para uma syscall que ele simplesmente não implementou naquele contexto. O arquivo virou conteúdo real na raiz, quatro linhas, e o canônico continua onde estava.

A prova de que renomear não mudou nada

Renomear um arquivo que carrega rotas é o tipo de coisa que a gente aceita por garantia só porque o nome é péssimo. Então a prova veio antes do commit:

openapi() -> 148 paths ANTES e DEPOIS, diff vazio (ROUTES_IDENTICAL)
pytest tests/test_reviews.py 8/8
GET /health -> 200

Cento e quarenta e oito rotas antes. Cento e quarenta e oito depois. Diff vazio. O renomeio foi uma mudança de nome de arquivo, e o teste crawling completo provou que a superfície pública da API não se moveu um milímetro. É o tipo de verificação que custa dois minutos e vale um ano de confiança.

O guard que impede a próxima vez

Corrigir os três casos resolve esta semana. A pergunta que interessa é o que acontece no próximo git add -A que eu rodar com a cabeça em outro lugar.

O guard é um script curto que lê a lista de arquivos rastreados e classifica cada caminho contra três regras do NTFS:

RESERVED = re.compile(r"^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(\..*)?$", re.I)
ILLEGAL_CHARS = set('<>":|?*')

def offender(path):
    base = path.rsplit("/", 1)[-1]
    if RESERVED.match(base):
        return "reserved-windows-name"
    if ILLEGAL_CHARS & set(path):
        return "illegal-windows-char"
    if base != base.rstrip(". ") and base.strip(". "):
        return "trailing-dot-or-space"
    return None

Três checagens, na ordem certa: nome reservado (incluindo COM3 e LPT9, que quase ninguém lembra que existem), caractere ilegal em qualquer ponto do caminho, e ponto ou espaço no fim do nome — outra regra do Windows que o Linux não tem e que também quebra checkout.

O detalhe que eu mais gosto é a segunda função. O erro de um nome é invalid path; o efeito real é que outro arquivo, não-ofensivo, sai do versionamento sem aviso. O script não olha o caminho que está errado — ele declara que o caminho que está errado é crime de todos os clones Windows que existem.

E ele não é um script que alguém lembra de rodar. Está no workflow, como primeiro passo depois do checkout:

- name: Guard — nenhum path invalido no Windows (15/09)
  working-directory: .
  run: python3 scripts/check-win-invalid-paths.py

Exit 0 passa, exit 1 derruba o pipeline com a lista dos ofensores no stdout. Mais um log dedicado com timestamp, escrevendo OK tracked=<n> ou VIOLATION <motivo>: <caminho>, porque a prova de que um problema recorre é ter o registro das vezes em que ele não recorreram.

O que três nomes enseñam

  • .gitignore é filtro de entrada, não de permanência. Ele nunca expulsa. Se um arquivo perigoso entrou uma vez, ele só sai com git rm --cached — e essa é uma tarefa de manutenção periódica.
  • A plataforma onde o CI roda não é a plataforma onde todo mundo trabalha. Um repositório pode passar todas as validações e ainda assim quebrar para um terço das pessoas do time. “Funciona no CI” é uma afirmação sobre o CI.
  • Erro de caminho nomeado tem efeito lateral silencioso. O sintoma que você vê (invalid path) não é o estrago que houve. O estrago é o índice rasgado e os arquivos que sumiram do tracking.
  • Renomear arquivo que carrega rota não é operação de baixo risco sem prova. Duas páginas do OpenAPI antes e depois, com diff, é o que separa um renomeio de um incidente.

Um repositório é uma coleção de decisões sobre quais sistemas de arquivos ele promete suportar. O Dogwalk promete Linux no servidor, NTFS na mesa e macOS no notebook — e essa promessa agora tem um teste rodando em CI, que é a única forma de promessa não virar espero.

O que vem depois

O mesmo commit limpou uma última categoria do mesmo tipo: evidência de verificação ao vivo. O commit chore: nao versionar evidencias de verificacao ao vivo destrackeia PNGs de prova (output/prod-*) que tinham entrado por resíduo de um loop automático. Não quebravam clone nenhum, mas entulham o histórico com arquivos que ninguém vai abrir.

A fila natural agora é o inverso: um pre-commit que rode o mesmo guard antes do commit existir, e não só no CI. O CI garante que ninguém consiga empurrar caminho inválido para o master; o hook garante que a pessoa descubra no terminal, em vez de alguém descobrir em produção.

~/lifelog — bash
$cat about.txt
╔══════════════════════════════════════╗
║  Samuel Medeiros                    ║
║  Senior Software Engineer           ║
║  Stack: Python · TypeScript · Rust  ║
║  Projetos: Arachne, Dogwalk,        ║
║            Capivara, TatuEngine      ║
╚══════════════════════════════════════╝
      
$