Preparar um fixture com bytes conhecidos
O runner abaixo cria um único repositório temporário com um script shell, um ficheiro cmd e um payload binário. A primeira política usa -text para guardar os bytes iniciais, incluindo CRLF. O script e o ficheiro cmd têm conteúdo sintético; o payload inclui bytes nulos para tornar explícita a necessidade de preservar a representação. O ambiente ignora configuração global e de sistema, atributos globais e hooks pessoais. Não lê nem modifica o repositório do projeto. Guarda o código completo como run.py e executa com Python 3.13 e /usr/bin/git 2.50.1 Apple Git-155. O runner verifica esta versão antes de criar os dados. A documentação atual consultada é da linha 2.56, mas essa versão não foi executada. A primeira asserção confirma os CRLF no blob inicial para estabelecer uma base observada.
Observar normalização sem confundir estados
A política passa a definir text eol=lf para shell, text eol=crlf para cmd e -text para o binário. O runner prepara .gitattributes e executa add --renormalize nos caminhos acompanhados. Lê os conteúdos preparados com git show :caminho: shell e cmd ficam com LF no index; o binário mantém exatamente os bytes. O ficheiro untracked.txt, criado para controlo, continua fora do index. O ficheiro shell existente no disco ainda tem CRLF, porque esta operação não o reescreveu. Depois de guardar o snapshot, o exercício remove apenas os dois ficheiros conhecidos do fixture e materializa-os novamente a partir do index. Shell aparece com LF e cmd com CRLF. ls-files --eol confirma i/lf e w/crlf para cmd. Estes estados não são contraditórios: são duas representações observadas em etapas diferentes.
Identificar a origem da política efetiva
O exercício altera eol no .gitattributes do working tree sem preparar a mudança. check-attr normal passa a indicar CRLF para shell, enquanto --cached continua a indicar LF. A comparação mostra por que o estado visível no editor não substitui a consulta do index. Depois repõe a política e cria um override em .git/info/attributes: o valor local volta a prevalecer. Remover esse override faz reaparecer o resultado versionado. No caso Moinho, dois clones do mesmo commit podem divergir por este input local, sem qualquer corrupção do commit. Regista atributos efetivos e a sua origem antes de copiar configurações entre portátil e CI. O laboratório cria explicitamente a pasta info, porque o template vazio não a criou. Esta preparação pertence ao fixture isolado, não a uma alteração dos repositórios pessoais.
Rever a mudança e os consumidores
O caso Cais mistura normalização com um novo timeout. Revê a intenção funcional separadamente do formato sempre que possível e consulta a diferença preparada antes de guardar. Um diff extenso pode esconder uma alteração pequena mas relevante. Testa também a materialização exigida pelos consumidores: produzir CRLF num Mac não demonstra que o script funciona em Windows. O ensaio de bytes não executa nenhum destes scripts. Para um handover, entrega política, versão, bytes esperados no index e no destino, caminhos binários excluídos da conversão e testes funcionais pendentes. O runner continua para arquivos de release na aula seguinte e apresenta 27 verificações no total. Se uma asserção falhar, investiga o estado e a versão antes de mudar o resultado esperado. A revisão editorial não substitui validação especializada independente ou aceitação do serviço.
"""Original DR fixture: attributes, normalization and release archives.
Python 3.13; /usr/bin/git 2.50.1 (Apple Git-155). Run: python3 run.py
Uses one disposable local repository; no network, project files or credentials.
Archives are inspected in memory, never extracted into the filesystem.
"""
import hashlib
import io
import json
import os
from pathlib import Path
import subprocess
import tarfile
import tempfile
GIT = '/usr/bin/git'
version = subprocess.check_output([GIT, '--version'], text=True).strip()
assert version == 'git version 2.50.1 (Apple Git-155)', version
checks = []
def check(name, condition):
assert condition, name
checks.append(name)
with tempfile.TemporaryDirectory(prefix='dr-git-artifacts-') as directory:
root = Path(directory)
repo = root / 'repo'
hooks = root / 'empty-hooks'
template = root / 'empty-template'
hooks.mkdir()
template.mkdir()
env = {k: v for k, v in os.environ.items() if not k.startswith('GIT_')}
env.update(GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_GLOBAL=os.devnull,
GIT_ATTR_NOSYSTEM='1', GIT_ALLOW_PROTOCOL='file',
GIT_TERMINAL_PROMPT='0', LC_ALL='C',
GIT_AUTHOR_NAME='DR Synthetic', GIT_AUTHOR_EMAIL='lab@example.test',
GIT_COMMITTER_NAME='DR Synthetic', GIT_COMMITTER_EMAIL='lab@example.test',
GIT_AUTHOR_DATE='2026-10-03T12:00:00+00:00',
GIT_COMMITTER_DATE='2026-10-03T12:00:00+00:00')
base = [GIT, '-c', 'core.hooksPath=' + str(hooks), '-c', 'core.attributesFile=' + os.devnull,
'-c', 'init.templateDir=' + str(template), '-c', 'commit.gpgSign=false',
'-c', 'core.autocrlf=false', '-c', 'core.fsmonitor=false', '-c', 'tar.umask=0002']
def run(*args, cwd=None, data=None):
result = subprocess.run(base + list(args), cwd=cwd or repo, env=env,
input=data, capture_output=True, timeout=15)
assert result.returncode == 0, (args, result.stderr.decode())
return result.stdout
def text(*args):
return run(*args).decode().strip()
run('init', '--initial-branch=main', str(repo), cwd=root)
attrs = repo / '.gitattributes'
attrs.write_text('*.sh -text\n*.cmd -text\n*.bin -text\n')
shell = b'#!/bin/sh\necho synthetic\n'
windows = b'@echo off\r\necho synthetic\r\n'
binary = b'\x00\xff\r\n\x00payload\r\n'
(repo / 'deploy.sh').write_bytes(shell.replace(b'\n', b'\r\n'))
(repo / 'launch.cmd').write_bytes(windows)
(repo / 'payload.bin').write_bytes(binary)
run('add', '.')
run('commit', '-m', 'synthetic initial raw bytes')
check('baseline: tracked shell blob contains CRLF', b'\r\n' in run('show', 'HEAD:deploy.sh'))
policy = '*.sh text eol=lf\n*.cmd text eol=crlf\n*.bin -text\ndocs/** export-ignore\nVERSION export-subst\n'
attrs.write_text(policy)
(repo / 'untracked.txt').write_text('not part of release\n')
run('add', '.gitattributes')
run('add', '--renormalize', '.')
check('normalization: shell index is LF', run('show', ':deploy.sh') == shell)
check('normalization: cmd index is LF', run('show', ':launch.cmd') == windows.replace(b'\r\n', b'\n'))
check('normalization: binary index preserves exact bytes', run('show', ':payload.bin') == binary)
check('normalization: renormalize does not add untracked file', text('ls-files', '--', 'untracked.txt') == '')
check('normalization: add does not rewrite existing shell worktree bytes', b'\r\n' in (repo / 'deploy.sh').read_bytes())
run('commit', '-m', 'normalize tracked fixture')
# Remove only known fixture paths to force a fresh materialization from index.
for name in ('deploy.sh', 'launch.cmd'):
(repo / name).unlink()
run('restore', '--worktree', '--', 'deploy.sh', 'launch.cmd')
check('checkout: shell materializes with LF', (repo / 'deploy.sh').read_bytes() == shell)
check('checkout: cmd materializes with CRLF', (repo / 'launch.cmd').read_bytes() == windows)
check('checkout: ls-files separates index LF and worktree CRLF',
'i/lf' in text('ls-files', '--eol', 'launch.cmd') and 'w/crlf' in text('ls-files', '--eol', 'launch.cmd'))
attrs.write_text(policy.replace('*.sh text eol=lf', '*.sh text eol=crlf'))
check('attributes: working-tree policy reports CRLF', text('check-attr', 'eol', '--', 'deploy.sh').endswith(': crlf'))
check('attributes: cached policy still reports LF', text('check-attr', '--cached', 'eol', '--', 'deploy.sh').endswith(': lf'))
attrs.write_text(policy)
info = repo / '.git' / 'info' / 'attributes'
info.parent.mkdir(exist_ok=True)
info.write_text('deploy.sh eol=crlf\n')
check('attributes: local info override takes precedence', text('check-attr', 'eol', '--', 'deploy.sh').endswith(': crlf'))
info.unlink()
check('attributes: removing local override restores versioned policy', text('check-attr', 'eol', '--', 'deploy.sh').endswith(': lf'))
(repo / 'docs').mkdir()
(repo / 'docs' / 'internal.txt').write_text('synthetic internal notes\n')
(repo / 'VERSION').write_text('commit=$Format:%H$\n')
(repo / 'current').symlink_to('deploy.sh')
run('add', 'docs/internal.txt', 'VERSION', 'current')
run('update-index', '--chmod=+x', 'deploy.sh')
run('commit', '-m', 'synthetic release contents')
commit_id = text('rev-parse', 'HEAD')
check('release: index records executable shell mode', text('ls-files', '--stage', 'deploy.sh').startswith('100755 '))
(repo / 'deploy.sh').write_bytes(b'local draft not released\n')
archive = run('archive', '--format=tar', '--prefix=release/', commit_id)
archive_again = run('archive', '--format=tar', '--prefix=release/', commit_id)
check('archive: repeated same-commit local tar bytes match', archive == archive_again)
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:') as tar:
names = tar.getnames()
read = lambda name: tar.extractfile('release/' + name).read()
check('archive: committed shell content excludes local draft', read('deploy.sh') == shell)
check('archive: untracked file is absent', 'release/untracked.txt' not in names)
check('archive: export-ignore omits tracked internal document', 'release/docs/internal.txt' not in names)
check('archive: export-subst writes selected commit identity', read('VERSION') == ('commit=' + commit_id + '\n').encode())
check('archive: shell entry retains executable bits', bool(tar.getmember('release/deploy.sh').mode & 0o111))
member = tar.getmember('release/current')
check('archive: symlink remains a link with its target', member.issym() and member.linkname == 'deploy.sh')
check('archive: tar metadata reports selected commit ID', run('get-tar-commit-id', data=archive).decode().strip() == commit_id)
tree_archive = run('archive', '--format=tar', commit_id + '^{tree}')
with tarfile.open(fileobj=io.BytesIO(tree_archive), mode='r:') as tar:
check('archive: a tree archive leaves export-subst placeholder', tar.extractfile('VERSION').read() == b'commit=$Format:%H$\n')
attrs.write_text(policy.replace('docs/** export-ignore\n', ''))
default_archive = run('archive', '--format=tar', commit_id)
worktree_archive = run('archive', '--format=tar', '--worktree-attributes', commit_id)
with tarfile.open(fileobj=io.BytesIO(default_archive), mode='r:') as tar:
check('archive: default uses committed exclusion despite edited worktree policy', 'docs/internal.txt' not in tar.getnames())
with tarfile.open(fileobj=io.BytesIO(worktree_archive), mode='r:') as tar:
check('archive: explicit worktree attributes alter archive membership', 'docs/internal.txt' in tar.getnames())
check('archive: worktree attributes do not substitute working-tree file content', tar.extractfile('deploy.sh').read() == shell)
check('archive: source commit still contains excluded document', run('show', commit_id + ':docs/internal.txt') == b'synthetic internal notes\n')
print(json.dumps({'version': version, 'checks_passed': len(checks), 'checks': checks,
'scope': 'One disposable local repository; byte, index, checkout and in-memory tar checks. '
'No production build, deployment, network, signing, Windows runtime or Git 2.56 execution.',
'sourceSha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}, indent=2))
O index de launch.cmd contém LF, enquanto o novo checkout contém CRLF; payload.bin mantém os bytes originais.
Armadilhas comuns
Confundir eol com encoding; misturar lógica com normalização; aplicar conversão a binários; copiar overrides pessoais para CI sem revisão.
Tópicos relacionados: Estados e index · Diagnóstico e releases · Integração e revisão
Define os bytes e a política esperados em cada etapa, depois verifica a materialização e o comportamento do consumidor.
Referência: Git manual and original DR artifact experiments · Git 2.56; workflow concepts compatible with modern Git 2.x