ADRIANOLAUREANO← Artigos

Construindo um Git do zero em Rust

Descubra como o Git controla versões construindo o Rastro, um sistema de controle de versões simplificado e completamente funcional.

AGORAArquivos editáveisREADME.mdsrc/main.rs
RASTROA oficina de versõeshash → blob → tree → commit
HISTÓRIAFotografias recuperáveisHEAD → main → commit
Não construiremos uma interface para comandos do Git: construiremos o mecanismo que torna esses comandos possíveis.
8comandos funcionais
4tipos de objeto explicados
40caracteres no SHA-1
0dependências externas

01 / O QUE ACONTECE DEPOIS DE GIT COMMIT?

O Git copia o projeto inteiro toda vez que criamos uma versão?

Você altera uma linha, executa git commit e ganha um ponto novo no histórico. Meses depois, o Git consegue recuperar exatamente os arquivos daquele instante. Parece natural concluir que ele guardou outra cópia da pasta.

PERGUNTA 1onde foi parar cada arquivo?
PERGUNTA 2como uma linha alterada é detectada?
PERGUNTA 3como versões antigas voltam?
PERGUNTA 4por que branches são instantâneas?

A resposta curta é elegante: o Git transforma conteúdos e diretórios em objetos imutáveis, identifica cada objeto pelo hash e liga essas peças até formar uma fotografia. Um commit não é uma pasta copiada. É a porta de entrada para um grafo de objetos reutilizáveis.

POR QUE RASTRO?

Cada commit deixa um rastro recuperável da evolução do projeto. Por isso batizamos nosso sistema de controle de versões de Rastro. Ele inicializa repositórios, prepara arquivos, cria commits, mostra o histórico, calcula o status, cria branches e restaura versões.

02 / TODAS AS ESTRADAS CHEGARAM AQUI

O Git reúne quase tudo o que construímos nos artigos anteriores.

ARQUIVOSconteúdo e identidadeextensão, bytes e formato
FORMATOSestruturas serializadascabeçalhos, campos e validação
ARMAZENAMENTOdiretórios e endereçossistema de arquivos e offsets
BANCO DE DADOSíndices e objetos persistentesencontrar sem percorrer tudo
GIThistórico como grafohashes ligam blobs, trees e commits

O Git é um programa executado pelo sistema operacional. Dentro da pasta.git, ele mantém um pequeno banco de dados endereçado por conteúdo: o endereço de um objeto nasce dos próprios bytes do objeto.

Também existe uma ideia nova: grafo. Um grafo é um conjunto de elementos conectados. Os elementos são os objetos; as conexões são hashes gravados dentro deles. Seguiremos essas conexões como quem segue endereços em um mapa.

03 / PRIMEIRA TENTATIVA: PASTAS NUMERADAS

Antes de inventar objetos, vamos fazer o controle de versão mais óbvio possível.

projeto/
├── versao-01/
│   ├── README.md
│   └── src/main.rs
├── versao-02/
│   ├── README.md
│   └── src/main.rs
└── versao-final-agora-vai/
    ├── README.md
    └── src/main.rs

Funciona. Cada pasta é uma fotografia completa. Mas surgem problemas: arquivos grandes e inalterados são duplicados, os nomes não provam a ordem, duas pessoas podem escolher nomes diferentes e não existe uma forma confiável de saber se algum byte foi modificado.

CÓPIAS DE PASTASfácil de imaginarduplica conteúdo e depende de nomes humanos
OBJETOS POR CONTEÚDOuma peça é gravada uma vezfotografias diferentes podem reutilizar a mesma peça

O Git escolhe a segunda estratégia. Para chegar nela, precisamos começar pela casa onde os objetos viverão.

04 / UM DIRETÓRIO COMUM VIRA REPOSITÓRIO

Nosso programa precisa guardar a história sem misturá-la aos arquivos do projeto.

Até aqui temos apenas uma pasta comum. Dentro dela ficam arquivos que podemos abrir, editar e apagar. Mas onde o Rastro guardará hashes, fotografias, nomes de branches e a posição atual? Se espalharmos essas informações pela pasta, o mecanismo de controle se confundirá com o próprio projeto.

PARTE VISÍVELo trabalho de agoraREADME.md · src/ · Cargo.toml
MEMÓRIA DO RASTROtodas as versõesobjetos · commits · branches · HEAD

A solução será criar uma central de controle escondida dentro do projeto. Chamaremos o programa de Rastro porque cada fotografia deixa um caminho recuperável até o estado anterior. Como ele é um sistema próprio, sua central de controle se chamará .rastro. O Git real usa.git; o nosso programa usa .rastro. Assim fica sempre claro qual dos dois criou a pasta.

AGORA O PRIMEIRO COMANDO FAZ SENTIDO

rastro init significa “prepare esta pasta para começar a registrar rastros”. Ele ainda não salva uma versão e não altera nenhum arquivo visível.

rastro init meu-projeto

meu-projeto/
├── .rastro/          ← memória interna criada pelo Rastro
├── README.md      ← seus arquivos continuam aqui
└── src/

O que nasce dentro da central de controle?

.rastro/
├── HEAD
├── config
├── index
├── objects/
└── refs/
    └── heads/
OBJECTSbanco imutávelblobs, trees e commits
REFS/HEADSbranchesnomes que apontam para commits
HEADposição atualqual branch ou commit está selecionado?
INDEXpróxima fotografiao que entrará no commit?
CONFIGregras locaisopções do repositório

O ponto no início de .rastro torna a pasta oculta em muitos exploradores. Para o sistema de arquivos ela continua sendo um diretório comum. Removê-la apaga branches, histórico e configurações locais, mas não apaga imediatamente os arquivos visíveis do diretório de trabalho.

pub fn init(path: &Path) -> Result<Self> {
    fs::create_dir_all(path)?;
    let root = path.canonicalize()?;
    let rastro_dir = root.join(".rastro");

    fs::create_dir_all(rastro_dir.join("objects"))?;
    fs::create_dir_all(rastro_dir.join("refs").join("heads"))?;
    fs::write(rastro_dir.join("HEAD"), "ref: refs/heads/main\n")?;
    fs::write(rastro_dir.join("config"), "[core]\nrepositoryformatversion = 0\n")?;
    index::save(&rastro_dir, &Index::new())?;
    // ...
}

canonicalize transforma o caminho em uma localização absoluta.create_dir_all cria também os diretórios intermediários. O operador? interrompe a função se alguma operação falhar e devolve o erro ao chamador.

05 / COMO DAR NOME A UM CONTEÚDO?

Em vez de inventar nomes, calcularemos uma impressão digital dos bytes.

Uma função hash recebe dados de qualquer tamanho e produz um resumo de tamanho fixo. O Git nasceu usando SHA‑1, cujo resultado possui 160 bits. Em hexadecimal, cada grupo de quatro bits vira um caractere; por isso o identificador aparece com 40 caracteres.

ENTRADAOlá, Git!qualquer quantidade de bytes
SHA‑1
SAÍDA40 caracteres9f4d...e21a

Pense no SHA‑1 como uma máquina de carimbos: entra conteúdo de qualquer tamanho; sai sempre uma etiqueta do mesmo tamanho.

Hash não é criptografia reversível. Não conseguimos pegar o resumo e reconstruir o arquivo. Para recuperar o conteúdo, o Git guarda o objeto; o hash serve como endereço e como verificação de integridade.

Um byte alterado deve bagunçar o resultado inteiro

ARQUIVO Acor = azul7c1a 86f0 11de 42b8...
UMA LETRA MUDOUcor = azuL2fd9 3b74 a10c 9e51...

Esse comportamento é chamado de efeito avalanche: uma pequena mudança na entrada produz um resumo muito diferente. O Git não precisa comparar cada caractere de dois conteúdos quando seus identificadores já são diferentes.

O Git não calcula o hash apenas sobre o conteúdo

blob 9\0Olá, Git!
└─┬──┘  └────┬────┘
  │          conteúdo
  tipo + tamanho

Antes do hash, formamos um cabeçalho com tipo e tamanho, seguido de um byte zero e do conteúdo. Assim, os mesmos bytes usados como tipos diferentes não precisam compartilhar identidade.

pub fn hash_object(kind: &str, data: &[u8]) -> String {
    let mut encoded = format!("{kind} {}\0", data.len()).into_bytes();
    encoded.extend_from_slice(data);
    sha1(&encoded)
}

Nosso módulo hash.rs implementa SHA‑1 desde as operações de bits e confirma o resultado com vetores conhecidos. O objetivo não é recomendar SHA‑1 para segurança: é tornar visível como um identificador nasce.

06 / UM BANCO SEM IDs NUMÉRICOS

A pasta objects transforma o hash no caminho físico do objeto.

Um hash como a9993e364706816aba3e25717850c26c9cd0d89d vira dois pedaços. Os dois primeiros caracteres nomeiam um diretório; os 38 restantes nomeiam o arquivo.

HASHa9 993e364706816aba3e25717850c26c9cd0d89d
CAMINHO.rastro/objects/a9/993e...d89d

É como separar uma biblioteca por corredor: a9 escolhe a gaveta; os 38 caracteres restantes encontram o objeto dentro dela.

Dividir o nome evita colocar todos os objetos no mesmo diretório. O conteúdo é imutável: se os bytes mudarem, o hash muda e nasce outro arquivo. Escrever o mesmo objeto duas vezes apenas encontra o caminho que já existe.

OBJETOcabeçalho: tipo + tamanhobyte zerodados serializadosSHA‑1 identifica o conjunto inteiro

O Git real comprime objetos soltos com zlib. Nosso Rastro mantém os bytes sem compressão para que possamos abrir cada arquivo e enxergar o cabeçalho. Essa é uma diferença de armazenamento, não de arquitetura.

07 / O PRIMEIRO TIPO DE OBJETO

Blob guarda o conteúdo de um arquivo — e propositalmente ignora seu nome.

README.md“Olá”nome diferente
copia.txt“Olá”mesmos bytes
mesmo conteúdo
UM ÚNICO BLOBe965047a...os dois nomes podem apontar para ele

O nome fica do lado de fora. Dentro do depósito de objetos, os mesmos bytes ocupam uma única prateleira.

Blob é um nome histórico para um bloco de dados binários. O blob não sabe se representa Rust, texto, imagem ou áudio. Ele recebe bytes e os devolve. Nome, diretório e permissões pertencem a outra camada.

pub fn store_file(store: &ObjectStore, path: &Path) -> Result<String> {
    store.write("blob", &std::fs::read(path)?)
}

std::fs::read devolve Vec<u8>, uma coleção de bytes. O object store calcula o hash do cabeçalho mais conteúdo e grava apenas se aquele endereço ainda não existir.

DEDUPLICAÇÃO

Dois arquivos iguais podem ter nomes e caminhos diferentes. Como o blob depende do conteúdo, os dois reutilizam o mesmo objeto.

08 / O COMMIT NÃO DEVE ADIVINHAR O QUE QUEREMOS

rastro add monta uma fotografia intermediária chamada index.

Imagine que alteramos três arquivos, mas queremos colocar apenas dois no próximo commit. O Git precisa separar “o que existe no diretório” de “o que foi escolhido para a próxima fotografia”.

MESA DE TRABALHO✓ README modificado✓ main.rs modificado· notas.txt novo
rastro addescolher dois arquivos
BANDEJA DO PRÓXIMO COMMITREADME → blob Amain.rs → blob Bnotas.txt ficou na mesa

O index funciona como uma bandeja: só entra na fotografia aquilo que colocamos nela.

O index, também chamado de staging area, não é uma lista de comandos. É a descrição da próxima fotografia. Nosso formato didático guarda uma linha por caminho:

e965047ad7c57865823c7d992b1d046ea66edf78    README.md
83baae61804e65cc73a7201a7252750c76066a30    src/main.rs

O Git real usa um index binário com metadados e otimizações. O princípio é o mesmo: associar caminhos a objetos preparados. Executar add lê o estado atual do arquivo, cria o blob imediatamente e atualiza essa associação.

let oid = blob::store_file(&self.store, absolute)?;
index.insert(relative, oid);
index::save(&self.rastro_dir, &index)?;

Se o arquivo mudar novamente depois do add, o index continua apontando para o blob anterior. Por isso status pode mostrar uma versão preparada e outra modificação ainda não preparada.

09 / BLOBS NÃO CONHECEM DIRETÓRIOS

Tree associa nomes a blobs e permite que diretórios também sejam objetos.

Já conseguimos guardar conteúdos, mas perdemos o caminho. Um blob não sabe que era src/main.rs. Precisamos de um objeto que diga: “neste diretório, o nome main.rs aponta para este blob”.

TREE RAIZ/Cargo.toml → blob CREADME.md → blob Asrc → tree S
FOLHAREADME.mdblob A
src/tree S → main.rs · hash.rs
FOLHACargo.tomlblob C

A tree raiz é o mapa da pasta principal. Uma entrada pode terminar em um arquivo ou abrir outro mapa de diretório.

Uma tree representa um diretório. Cada entrada contém nome, tipo e hash. Se a entrada for blob, chegamos ao conteúdo de um arquivo. Se for tree, entramos em outro diretório.

blob e965047ad7c57865823c7d992b1d046ea66edf78    README.md
tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904    src

Árvore, raiz, nó e folha neste contexto

A estrutura inteira é uma árvore. Cada tree ou blob conectado é um . A tree do diretório do projeto é a raiz, pois a navegação começa nela. Blobs são folhas: encerram o caminho porque não apontam para outros objetos.

RAIZ DA FOTOGRAFIATREE /
FOLHAblob README
tree src
FOLHAblob Cargo

Diretórios inalterados geram a mesma tree. Uma alteração emsrc/main.rs cria outro blob, outra tree para src e outra tree raiz; os blobs de README e Cargo continuam reutilizados.

10 / UMA FOTOGRAFIA PRECISA ENTRAR NO TEMPO

Commit aponta para a tree raiz e liga a fotografia ao histórico anterior.

tree 7ab308fc...
parent 91e4c51f...
author Ana
timestamp 1785798000

explica blobs e trees

O commit não contém os arquivos. O campo tree aponta para a raiz da fotografia. parent aponta para o commit anterior. Autor, data e mensagem explicam quando e por que aquele estado foi registrado.

COMMITc9a81f...o envelope da versão
APONTA PARA BAIXOtree 7ab308...a fotografia completa
APONTA PARA TRÁSparent 91e4c5...a versão anterior
CONTA A HISTÓRIAautor · data · mensagemquem, quando e por quê

O commit é um envelope: não carrega os arquivos; carrega endereços e contexto para encontrá-los.

Como o parent participa do conteúdo, dois commits com a mesma tree e mensagem podem receber hashes diferentes se pertencerem a histórias diferentes. Alterar a mensagem também cria outro identificador.

O fluxo completo de rastro commit

01ler o index
02criar trees
03ler o parent em HEAD
04gravar commit
05mover a branch

Atualizar a branch fica por último. Antes disso, todos os objetos já existem. Se o processo falhar durante a criação de um blob ou tree, o ponteiro público continua no commit anterior. Podem sobrar objetos sem referência, mas o histórico visível não aponta para metade de um commit.

12 / O HISTÓRICO É UMA CORRENTE DE PARENTS

rastro log começa em HEAD e segue o commit anterior.

AGORA · HEADc3parent → b2
voltar
ANTESb2parent → a1
voltar
COMEÇOa1sem parent · parar

Log não consulta uma agenda central. Ele abre o commit atual e segue a seta parent, um passo de cada vez.

while let Some(oid) = current {
    let commit = Commit::read(store, &oid)?;
    current = commit.parents.first().cloned();
    commits.push((oid, commit));
}

Não existe um arquivo central contendo todos os commits em ordem. O próprio commit guarda o endereço do ancestral. O primeiro commit encerra o caminho porque não possui parent.

Um commit comum possui um parent; um commit de merge possui dois. Nosso log segue o primeiro parent para apresentar a linha principal. Os dois endereços continuam armazenados, revelando que o histórico é um grafo, não apenas uma lista.

13 / TRÊS FOTOGRAFIAS AO MESMO TEMPO

Status compara HEAD, index e diretório de trabalho.

ONTEMHEADúltima fotografia registrada
PRÓXIMA FOTOINDEXarquivos escolhidos com add
AGORAWORKTREEarquivos que estão no disco

Status coloca três transparências sobre a mesa. Onde elas não coincidem, existe algo para mostrar.

Se HEAD e index diferem, existe alteração preparada. Se index e arquivo diferem, existe alteração não preparada. Se um caminho aparece no diretório mas não no index, ele é não rastreado.

ComparaçãoDiferença encontradaStatus
HEAD ↔ indexhash diferente ou caminho novopreparado para commit
index ↔ arquivohash diferente ou arquivo ausentemodificado, ainda sem add
arquivo sem entrada no indexcaminho desconhecidonão rastreado

O Rastro calcula novamente o hash do arquivo para comparar com o blob do index. O Git real usa também tamanho, data de modificação e outros atalhos para evitar leituras desnecessárias, confirmando o conteúdo quando necessário.

14 / RECONSTRUINDO UMA FOTOGRAFIA

Criamos a branch, mas ainda estamos olhando para os arquivos da main.

Uma branch é apenas uma etiqueta. Para trabalhar no ramomenu-novo, precisamos fazer duas coisas coordenadas: mover HEAD para essa etiqueta e colocar no diretório de trabalho os arquivos da fotografia apontada por ela. Essa operação recebe o nome checkout.

rastro checkout menu-novo

HEAD → refs/heads/menu-novo
diretório de trabalho → fotografia do commit apontado

A partir daí, um novo rastro commit moverámenu-novo, não main. Checkout não cria uma branch, não cria commit e não duplica objetos: ele seleciona uma referência e restaura seu estado.

Como os arquivos voltam para o disco?

BRANCH OU HASHmain · c9a81f
COMMITtree 7ab308
TREESnomes e diretórios
BLOBSconteúdos
ARQUIVOSfotografia restaurada

Checkout é a viagem de volta: começa num nome, encontra o mapa, recolhe os conteúdos e remonta as pastas.

Primeiro resolvemos o nome da branch ou o prefixo do hash. O commit entrega a tree raiz. Percorremos trees até produzir uma tabela caminho → blob. Por fim, criamos diretórios e escrevemos o conteúdo de cada blob.

for (path, oid) in target {
    let full = root.join(path);
    if let Some(parent) = full.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(full, blob::read(store, oid)?)?;
}

Restaurar não pode destruir trabalho silenciosamente

Antes de escrever, nosso Rastro verifica alterações preparadas ou modificadas. Também impede que um arquivo não rastreado seja sobrescrito por um caminho da fotografia. Esses bloqueios não explicam objetos, mas ensinam uma regra importante de ferramentas reais: reconstruir estado não autoriza perder dados locais sem aviso.

Depois da restauração, o index recebe a mesma fotografia. Se o alvo era uma branch, HEAD aponta para ela. Se era um hash, HEAD fica destacado naquele commit.

Por que o Rastro pode recusar o checkout?

Suponha que você editou README.md e ainda não criou um commit. Restaurar outra branch poderia substituir esse arquivo. Por isso o Rastro primeiro compara worktree, index e HEAD. Se houver alteração local ou preparada, ele interrompe a operação. Também recusa sobrescrever um arquivo não rastreado.

REGRA DE SEGURANÇA

Mudar de branch significa reescrever arquivos. Antes de fazer isso, o programa precisa provar que não apagará trabalho que existe apenas no diretório atual.

15 / QUANDO DUAS LINHAS DO TEMPO SE ENCONTRAM

A experiência funcionou. Como levamos seu resultado de volta para a main?

Agora main e menu-novo podem apontar para commits diferentes. Trocar de branch apenas escolhe um lado; não mistura as duas histórias. Para integrar o trabalho, voltamos à branch que receberá a mudança e pedimos um merge.

rastro checkout main
rastro merge menu-novo

A ordem importa: estamos dizendo “traga menu-novo para dentro da branch atualmente selecionada, que é main”.

PONTO DE PARTIDAAbase comum
MAINA → B → DIDEIAA → C → E
REENCONTROMERGE Mlembra D e E como parents

Duas trilhas saíram do mesmo lugar. O merge compara o que aconteceu em cada uma e cria um novo ponto de encontro.

Primeiro caso: main ficou parada

A ── B ← main
     └── C ── D ← menu-novo

Não existe nada exclusivo na main depois de B. O Rastro pode apenas mover a etiqueta main de B para D. Isso se chamafast-forward, ou avanço rápido. Nenhum commit de merge é necessário porque a história já é uma linha única.

Segundo caso: os dois lados avançaram

Se a main criou D enquanto menu-novo criou E, mover uma etiqueta faria um dos caminhos desaparecer da linha principal. O Rastro precisa construir uma nova fotografia M e registrar dois parents: D e E.

BASEfotografia Aestado antes da separação
OURSfotografia Dbranch que recebe o merge
THEIRSfotografia Ebranch que está chegando

Essa comparação recebe o nome de merge de três vias. Para cada caminho, o algoritmo pergunta:

SituaçãoEscolha segura
ours e theirs são iguaisusar qualquer um: não existe divergência
ours continua igual à baseusar theirs: apenas o outro lado mudou
theirs continua igual à baseusar ours: apenas o nosso lado mudou
os dois mudaram de formas diferentesparar: existe conflito
let chosen = if our_value == their_value {
    our_value
} else if our_value == base_value {
    their_value
} else if their_value == base_value {
    our_value
} else {
    match strategy {
        MergeStrategy::Ours => our_value,
        MergeStrategy::Theirs => their_value,
        MergeStrategy::Manual => {
            conflicts.push(Conflict {
                path: path.clone(),
                ours: our_value.cloned(),
                theirs: their_value.cloned(),
            });
            our_value
        }
    }
};

Se um lado criou menu.rs e o outro alterouREADME.md, as escolhas são independentes e a combinação é automática. O novo commit guarda parents: [D, E]; por isso o log consegue lembrar que duas linhas chegaram ao mesmo ponto.

Conflito não é erro do programa: é falta de informação

Se os dois lados trocaram o blob de menu.rs por conteúdos diferentes, o Rastro não pode adivinhar qual intenção é correta. Ele informa os caminhos conflitantes. Nosso projeto detecta o conflito em nível de arquivo; o Git real também tenta combinar alterações linha por linha.

Mas apenas dizer “deu conflito” deixaria o leitor sem terminar o projeto. O Rastro precisa mostrar as duas respostas e permitir uma decisão consciente. É aqui que nascem as estratégias de resolução.

MANUALcombinar intençõesver as duas versões, editar e confirmar
OURSmanter a nossaescolher o arquivo da branch atual
THEIRSaceitar a outraescolher o arquivo da branch recebida
ABORTvoltar ao começocancelar a tentativa sem mover HEAD

Estratégia manual: o arquivo vira uma pergunta visível

No modo padrão, o Rastro abre os blobs conflitantes como texto e grava marcadores no próprio arquivo. Eles funcionam como divisórias: acima está nossa versão; abaixo está a versão que chegou.

<<<<<<< nossa branch (main)
fn titulo() { "Menu principal" }
=======
fn titulo() { "Novo menu" }
>>>>>>> branch recebida (menu-novo)

Esses sinais não fazem parte do programa final. O leitor deve apagar as linhas com <<<<<<<, ======= e>>>>>>>, preservar ou combinar as ideias e deixar apenas o conteúdo correto. Depois registra a decisão:

rastro merge menu-novo
# editar menu.rs e remover os marcadores
rastro add menu.rs
rastro commit -m "combina os dois menus"

Durante essa pausa, .rastro/MERGE_HEAD guarda o hash do commit que chegou. Quando commit é executado, o Rastro usa o commit atual eMERGE_HEAD como dois parents. Assim a resolução manual não perde a história de nenhum lado.

fs::write(".rastro/MERGE_HEAD", target_hash)?;

let mut parents = vec![current_hash];
if let Some(merge_parent) = read_merge_head()? {
    parents.push(merge_parent);
}
NOSSA PONTAHEAD → Dprimeiro parent
+
OUTRA PONTAMERGE_HEAD → Esegundo parent
DECISÃO REGISTRADAcommit Mfotografia resolvida

Ours e theirs: escolhas rápidas, mas deliberadas

Às vezes sabemos que, para todos os arquivos conflitantes, um lado deve vencer.--ours mantém a versão da branch em que estamos.--theirs aceita a versão da branch passada ao comando.

# estamos na main: manter a versão da main nos conflitos
rastro merge menu-novo --ours

# estamos na main: aceitar a versão de menu-novo nos conflitos
rastro merge menu-novo --theirs
OURS E THEIRS DEPENDEM DO PONTO DE VISTA

“Nossa” não significa “a versão que eu prefiro”. Ours é sempre a branch selecionada em HEAD; theirs é a branch que está entrando no merge. Trocar de branch antes do comando troca esses papéis.

Essas opções resolvem apenas os caminhos realmente conflitantes. Mudanças independentes continuam sendo combinadas normalmente. Em arquivos binários, como imagens, não existem linhas legíveis para marcar; por isso o Rastro pede uma escolha explícita entre --ours e --theirs.

Abortar é uma estratégia de segurança

Talvez os marcadores revelem que ainda precisamos conversar com outra pessoa ou entender melhor a mudança. Não somos obrigados a decidir imediatamente:

rastro merge --abort

O Rastro lê novamente a fotografia apontada por HEAD, restaura seus arquivos e remove MERGE_HEAD e MERGE_MSG. Como a branch só avança depois do commit, abortar devolve o diretório ao estado anterior ao merge.

EstratégiaQuando faz sentidoRisco
manualas duas mudanças expressam intenções úteiscombinar incorretamente o comportamento
--oursa versão atual deve vencer em todos os conflitosdescartar silenciosamente a intenção recebida
--theirsa outra branch é a fonte correta nesses conflitossubstituir decisões feitas na branch atual
--aborta decisão exige mais contextonenhum conteúdo é integrado ainda
MERGE SEGURO

O Rastro exige worktree e index limpos antes de começar. HEAD e as branches permanecem imóveis durante a resolução; só o commit final transforma a escolha em história.

A branch foi integrada. Podemos excluir a etiqueta?

Depois do merge, os commits continuam no banco de objetos e são alcançados pela história da main. A etiqueta menu-novo deixou de ser necessária:

rastro branch -d menu-novo

branch menu-novo excluída

Excluir uma branch remove apenas o pequeno arquivo emrefs/heads. O modo seguro -d verifica se o último commit da branch é ancestral do commit atual — em outras palavras, se ele já pode ser alcançado andando para trás pela história atual.

Se ainda houver commits exclusivos, a exclusão é recusada. A opção-D força a remoção da etiqueta e pode tornar esses commits difíceis de encontrar. Ela existe para descarte consciente, não como solução automática. Também não podemos excluir a branch atualmente selecionada: antes seria preciso fazer checkout de outra branch.

16 / FOTOGRAFIAS COMPLETAS SEM CÓPIAS COMPLETAS

O Git pensa em snapshots, mas reutiliza tudo o que não mudou.

FOTO AREADME → R1src → S1logo → L1
FOTO BREADME → R1src → S2 · mudoulogo → L1
ECONOMIA2 de 3 peças reutilizadasnova gravação apenas para src

As fotografias parecem completas, mas peças com o mesmo identificador são compartilhadas.

Cada commit aponta para uma fotografia inteira, o que simplifica checkout e comparação. A economia nasce do compartilhamento: conteúdos iguais conservam o mesmo hash. O Git não precisa salvar “a diferença” para entender a fotografia.

Então por que repositórios não ficam enormes?

Objetos soltos são o formato mais fácil de criar. Periodicamente, o Git pode agrupá-los em packfiles. Um packfile comprime vários objetos e pode guardar um objeto como delta de outro semelhante: “pegue esta versão e aplique estas diferenças”.

OBJETOS SOLTOSblob A completoblob B completoblob C completo
compactar →
PACKFILEA completoB como delta de AC comprimido

É como trocar três caixas separadas por uma mala: uma peça vai inteira e outra pode guardar apenas o que mudou.

A identidade continua sendo o hash do objeto original, não dos bytes comprimidos do pack. Isso separa significado lógico de representação física, exatamente como páginas e índices no artigo do banco de dados.

17 / E SE DOIS CONTEÚDOS TIVEREM O MESMO HASH?

Colisão é quando entradas diferentes produzem o mesmo identificador.

Como o resultado possui tamanho finito e as entradas podem ter qualquer tamanho, colisões precisam existir matematicamente. A pergunta prática é se alguém consegue encontrá-las com recursos viáveis e controlar os conteúdos envolvidos.

CONTEÚDO Abytes diferentes
CONTEÚDO Boutros bytes
? mesmo carimbo
COLISÃOduas caixas, uma etiquetaa identidade fica ambígua

O Git foi criado em 2005, quando SHA‑1 era amplamente disponível e a ameaça parecia remota para esse uso. Em 2017, pesquisadores demonstraram uma colisão prática de SHA‑1. O Git respondeu com detecção de colisões e avançou no suporte a repositórios que usam SHA‑256.

Hash não torna conteúdo confiável por autoria. Ele demonstra que os bytes correspondem à identidade esperada. Assinaturas digitais resolvem outra pergunta: quem declarou aquele commit ou tag.

NO RASTRO

Implementamos SHA‑1 para estudar a arquitetura histórica. Não reutilize essa implementação para senhas, assinaturas ou novos protocolos de segurança.

18 / CONTROLE DE VERSÃO NÃO NASCEU COM O GIT

O Git nasceu porque um projeto gigantesco precisava de outra forma de trabalhar.

Antes do Git, o kernel Linux já recebia mudanças de muitas pessoas. Durante anos, alterações circularam principalmente como patches e arquivos compactados. Em 2002, o projeto passou a usar o BitKeeper, um sistema distribuído proprietário. Ele mostrou que cada pessoa poderia trabalhar com histórico local e depois integrar mudanças sem depender de uma única fila central.

Em 2005, a relação entre a comunidade do kernel e a empresa do BitKeeper se rompeu, e o uso gratuito deixou de estar disponível. Linus Torvalds começou então uma ferramenta própria. As metas não eram abstratas: ela precisava ser rápida, simples por dentro, distribuída, eficiente com um projeto enorme e confortável com milhares de branches paralelas.

1991–2002patches e arquivosmudanças viajavam como pacotes enviados entre pessoas
2002BitKeepero kernel experimenta controle distribuído
2005nasce o Gitvelocidade, branches e histórico local viram requisitos
NO RASTROas decisões reaparecemobjetos, hashes, refs, merges e dois parents

Isso explica por que o Git parece diferente de sistemas anteriores. Ele não começou como uma pasta online para guardar código. Começou como um mecanismo local para registrar objetos e integrar muitas linhas de desenvolvimento com segurança. A própria documentação do projeto resume essa história da criação do Git.

Então Git e GitHub são a mesma coisa?

Não. Git é o sistema de controle de versões: o programa que cria commits, branches, merges e o banco de objetos no computador.GitHub é um serviço na internet que hospeda repositórios Git e acrescenta colaboração, pull requests, issues, permissões e automações.

NO SEU COMPUTADORGitcommit · branch · merge · checkout · histórico
EM UM SERVIÇOGitHubhospedagem · revisão · issues · equipes · automações

É possível usar Git sem GitHub: o Rastro que acabamos de construir prova isso, porque todo o histórico funciona localmente. Também existem outros lugares para hospedar Git, como GitLab, Codeberg, servidores próprios ou até outro computador. O GitHub explica a mesma separação: ele se apoia no Git e adiciona a camada de compartilhamento.

REPOSITÓRIO LOCAL.git ou .rastroobjetos e referências existem sem internet
REMOTOoutra cópiaum endereço para enviar e receber objetos
GITHUBum possível hospedeironão é parte obrigatória do Git

Outros sistemas escolheram modelos diferentes

Git, SVN e Mercurial preservam versões, mas organizam autoridade e histórico de maneiras diferentes:

SistemaModelo mentalConsequência
Gitrepositório distribuído e objetos por conteúdohistórico completo local, branches leves e trabalho offline
SVNrepositório central com revisões globaisservidor ocupa o centro do fluxo e numera revisões
Mercurialdistribuído, com changesets e manifestsobjetivos próximos ao Git com interfaces e formatos próprios

Distribuído significa que cada clone pode possuir o histórico e criar commits sem depender continuamente de um servidor. “Remoto” é outra cópia com a qual trocamos objetos e referências; não é a fonte mágica que faz o Git existir.

19 / O RASTRO COMPLETO

Cada módulo responde a uma pergunta que surgiu durante a investigação.

rastro/
├── Cargo.toml
├── README.md
├── src/
│   ├── lib.rs
│   ├── main.rs
│   ├── hash.rs
│   ├── blob.rs
│   ├── index.rs
│   ├── tree.rs
│   ├── commit.rs
│   ├── object_store.rs
│   ├── repository.rs
│   ├── checkout.rs
│   ├── log.rs
│   ├── merge.rs
│   ├── refs.rs
│   └── head.rs
└── tests/
    └── repository.rs
Baixar o projeto completo do Rastro
IDENTIDADEhash.rsbytes → SHA‑1
OBJETOSblob · tree · commitconteúdo → fotografia → história
PONTEIROSrefs · headnomes → commits
OPERAÇÕESrepository · checkout · merge · logcoordenar, restaurar, combinar e percorrer

Os painéis abaixo contêm todo o código, não apenas fragmentos. Leia primeiro hash e object store; depois blob, index e tree; em seguida commit, refs e HEAD; finalize com repository, checkout e merge. Essa ordem acompanha as dependências reais.

01
Cargo.toml

Define o executável e a biblioteca sem dependências externas

Ver código completo de Cargo.toml
[package]
name = "rastro"
version = "0.1.0"
edition = "2021"

[lib]
name = "rastro"
path = "src/lib.rs"

[[bin]]
name = "rastro"
path = "src/main.rs"

[dependencies]
02
src/lib.rs

Expõe os módulos do Rastro

Ver código completo de src/lib.rs
pub mod blob;
pub mod checkout;
pub mod commit;
pub mod hash;
pub mod head;
pub mod index;
pub mod log;
pub mod merge;
pub mod object_store;
pub mod refs;
pub mod repository;
pub mod tree;

pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
03
src/main.rs

Interpreta init, add, commit, log, status, checkout, branch e merge

Ver código completo de src/main.rs
use rastro::{
    log, merge::MergeStrategy, refs,
    repository::{MergeOutcome, Repository},
    Result,
};
use std::{env, path::{Path, PathBuf}};

fn main() {
    if let Err(error) = run() {
        eprintln!("erro: {error}");
        std::process::exit(1);
    }
}

fn run() -> Result<()> {
    let mut args = env::args().skip(1);
    let command = args.next().ok_or("uso: rastro <comando>")?;
    if command == "init" {
        let path = args.next().unwrap_or_else(|| ".".into());
        let repository = Repository::init(Path::new(&path))?;
        println!("repositório criado em {}", repository.rastro_dir.display());
        return Ok(());
    }

    let repository = Repository::discover(&env::current_dir()?)?;
    match command.as_str() {
        "add" => {
            let paths: Vec<PathBuf> = args.map(PathBuf::from).collect();
            if paths.is_empty() {
                return Err("uso: rastro add <arquivo ou diretório>".into());
            }
            repository.add(&paths)?;
            println!("arquivos adicionados ao index");
        }
        "commit" => {
            if args.next().as_deref() != Some("-m") {
                return Err("uso: rastro commit -m \"mensagem\"".into());
            }
            let message = args.next().ok_or("mensagem ausente")?;
            let oid = repository.commit(&message)?;
            println!("[{}] {message}", &oid[..7]);
        }
        "log" => {
            let start = refs::resolve_head(&repository.rastro_dir)?;
            for (oid, commit) in log::collect(&repository.store, start)? {
                println!("commit {oid}");
                println!("Autor: {}", commit.author);
                println!("Data:  {}", log::format_timestamp(commit.timestamp));
                println!("\n    {}\n", commit.message);
            }
        }
        "status" => {
            let status = repository.status()?;
            print_group("Alterações preparadas", &status.staged);
            print_group("Alterações não preparadas", &status.modified);
            print_group("Arquivos não rastreados", &status.untracked);
            if status.staged.is_empty() && status.modified.is_empty() && status.untracked.is_empty() {
                println!("nada para fazer; diretório de trabalho limpo");
            }
        }
        "checkout" => {
            let target = args.next().ok_or("uso: rastro checkout <branch ou hash>")?;
            let oid = repository.checkout(&target)?;
            println!("arquivos restaurados a partir de {}", &oid[..7]);
        }
        "branch" => {
            match args.next().as_deref() {
                None => {
                    for (name, selected) in repository.list_branches()? {
                        println!("{} {name}", if selected { "*" } else { " " });
                    }
                }
                Some("-d") => {
                    let name = args.next().ok_or("uso: rastro branch -d <nome>")?;
                    repository.delete_branch(&name, false)?;
                    println!("branch {name} excluída");
                }
                Some("-D") => {
                    let name = args.next().ok_or("uso: rastro branch -D <nome>")?;
                    repository.delete_branch(&name, true)?;
                    println!("branch {name} excluída à força");
                }
                Some(name) => {
                    repository.create_branch(name)?;
                    println!("branch {name} criada");
                }
            }
        }
        "merge" => {
            let arguments: Vec<String> = args.collect();
            if arguments.len() == 1 && arguments[0] == "--abort" {
                repository.abort_merge()?;
                println!("merge abortado; arquivos restaurados");
                return Ok(());
            }
            if arguments.iter().filter(|arg| !arg.starts_with('-')).count() != 1 {
                return Err("uso: rastro merge <branch> [--ours|--theirs]".into());
            }
            let branch = arguments.iter().find(|arg| !arg.starts_with('-'))
                .ok_or("uso: rastro merge <branch> [--ours|--theirs]")?;
            if arguments.iter().any(|arg| {
                arg.starts_with('-') && arg != "--ours" && arg != "--theirs"
            }) {
                return Err("opção de merge desconhecida; use --ours, --theirs ou --abort".into());
            }
            if arguments.iter().any(|arg| arg == "--ours")
                && arguments.iter().any(|arg| arg == "--theirs")
            {
                return Err("escolha apenas uma estratégia: --ours ou --theirs".into());
            }
            let strategy = if arguments.iter().any(|arg| arg == "--ours") {
                MergeStrategy::Ours
            } else if arguments.iter().any(|arg| arg == "--theirs") {
                MergeStrategy::Theirs
            } else {
                MergeStrategy::Manual
            };
            match repository.merge(branch, strategy)? {
                MergeOutcome::AlreadyUpToDate => println!("já está atualizado"),
                MergeOutcome::FastForward(oid) => {
                    println!("fast-forward para {}", &oid[..7])
                }
                MergeOutcome::MergeCommit(oid) => {
                    println!("merge criado em {}", &oid[..7])
                }
                MergeOutcome::Conflicts(paths) => {
                    println!("conflitos encontrados:");
                    for path in paths {
                        println!("  {path}");
                    }
                    println!("edite os marcadores, use rastro add e finalize com rastro commit");
                }
            }
        }
        _ => return Err(format!("comando desconhecido: {command}").into()),
    }
    Ok(())
}

fn print_group(title: &str, paths: &[String]) {
    if paths.is_empty() { return; }
    println!("{title}:");
    for path in paths {
        println!("  {path}");
    }
}
04
src/hash.rs

Implementa SHA-1 e seus vetores de teste

Ver código completo de src/hash.rs
use std::fmt::Write;

pub fn sha1(input: &[u8]) -> String {
    let mut message = input.to_vec();
    let bit_len = (message.len() as u64) * 8;
    message.push(0x80);
    while message.len() % 64 != 56 {
        message.push(0);
    }
    message.extend_from_slice(&bit_len.to_be_bytes());

    let mut h0 = 0x6745_2301_u32;
    let mut h1 = 0xEFCD_AB89_u32;
    let mut h2 = 0x98BA_DCFE_u32;
    let mut h3 = 0x1032_5476_u32;
    let mut h4 = 0xC3D2_E1F0_u32;

    for chunk in message.chunks_exact(64) {
        let mut words = [0_u32; 80];
        for (index, word) in words.iter_mut().take(16).enumerate() {
            let start = index * 4;
            *word = u32::from_be_bytes(chunk[start..start + 4].try_into().unwrap());
        }
        for index in 16..80 {
            words[index] = (words[index - 3]
                ^ words[index - 8]
                ^ words[index - 14]
                ^ words[index - 16])
                .rotate_left(1);
        }

        let (mut a, mut b, mut c, mut d, mut e) = (h0, h1, h2, h3, h4);
        for (index, word) in words.iter().enumerate() {
            let (function, constant) = match index {
                0..=19 => ((b & c) | ((!b) & d), 0x5A82_7999),
                20..=39 => (b ^ c ^ d, 0x6ED9_EBA1),
                40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1B_BCDC),
                _ => (b ^ c ^ d, 0xCA62_C1D6),
            };
            let next = a
                .rotate_left(5)
                .wrapping_add(function)
                .wrapping_add(e)
                .wrapping_add(constant)
                .wrapping_add(*word);
            e = d;
            d = c;
            c = b.rotate_left(30);
            b = a;
            a = next;
        }

        h0 = h0.wrapping_add(a);
        h1 = h1.wrapping_add(b);
        h2 = h2.wrapping_add(c);
        h3 = h3.wrapping_add(d);
        h4 = h4.wrapping_add(e);
    }

    let mut output = String::with_capacity(40);
    for value in [h0, h1, h2, h3, h4] {
        write!(&mut output, "{value:08x}").unwrap();
    }
    output
}

#[cfg(test)]
mod tests {
    use super::sha1;

    #[test]
    fn matches_known_sha1_vectors() {
        assert_eq!(sha1(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709");
        assert_eq!(sha1(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d");
    }
}
05
src/object_store.rs

Cria, valida, grava e encontra objetos pelo hash

Ver código completo de src/object_store.rs
use crate::{hash::sha1, Result};
use std::{fs, path::{Path, PathBuf}};

#[derive(Debug, Clone)]
pub struct Object {
    pub kind: String,
    pub data: Vec<u8>,
}

#[derive(Debug, Clone)]
pub struct ObjectStore {
    root: PathBuf,
}

impl ObjectStore {
    pub fn new(rastro_dir: &Path) -> Self {
        Self { root: rastro_dir.join("objects") }
    }

    pub fn hash_object(kind: &str, data: &[u8]) -> String {
        let mut encoded = format!("{kind} {}\0", data.len()).into_bytes();
        encoded.extend_from_slice(data);
        sha1(&encoded)
    }

    pub fn write(&self, kind: &str, data: &[u8]) -> Result<String> {
        let oid = Self::hash_object(kind, data);
        let path = self.path_for(&oid)?;
        if !path.exists() {
            if let Some(parent) = path.parent() {
                fs::create_dir_all(parent)?;
            }
            let mut encoded = format!("{kind} {}\0", data.len()).into_bytes();
            encoded.extend_from_slice(data);
            fs::write(path, encoded)?;
        }
        Ok(oid)
    }

    pub fn read(&self, oid: &str) -> Result<Object> {
        let encoded = fs::read(self.path_for(oid)?)?;
        let nul = encoded.iter().position(|byte| *byte == 0)
            .ok_or("objeto sem cabeçalho")?;
        let header = std::str::from_utf8(&encoded[..nul])?;
        let (kind, length) = header.split_once(' ').ok_or("cabeçalho inválido")?;
        let expected: usize = length.parse()?;
        let data = encoded[nul + 1..].to_vec();
        if data.len() != expected {
            return Err("tamanho do objeto não confere".into());
        }
        if Self::hash_object(kind, &data) != oid {
            return Err("hash do objeto não confere".into());
        }
        Ok(Object { kind: kind.to_string(), data })
    }

    pub fn resolve_prefix(&self, prefix: &str) -> Result<String> {
        if prefix.len() >= 40 {
            return Ok(prefix.to_string());
        }
        if prefix.len() < 2 {
            return Err("use pelo menos dois caracteres do hash".into());
        }
        let directory = self.root.join(&prefix[..2]);
        let suffix = &prefix[2..];
        let mut matches = Vec::new();
        if directory.exists() {
            for entry in fs::read_dir(directory)? {
                let name = entry?.file_name().to_string_lossy().to_string();
                if name.starts_with(suffix) {
                    matches.push(format!("{}{}", &prefix[..2], name));
                }
            }
        }
        match matches.as_slice() {
            [oid] => Ok(oid.clone()),
            [] => Err("objeto não encontrado".into()),
            _ => Err("prefixo de hash ambíguo".into()),
        }
    }

    fn path_for(&self, oid: &str) -> Result<PathBuf> {
        if oid.len() != 40 || !oid.bytes().all(|byte| byte.is_ascii_hexdigit()) {
            return Err("hash inválido".into());
        }
        Ok(self.root.join(&oid[..2]).join(&oid[2..]))
    }
}
06
src/blob.rs

Transforma conteúdo de arquivo em blob

Ver código completo de src/blob.rs
use crate::{object_store::ObjectStore, Result};
use std::path::Path;

pub fn store_file(store: &ObjectStore, path: &Path) -> Result<String> {
    store.write("blob", &std::fs::read(path)?)
}

pub fn read(store: &ObjectStore, oid: &str) -> Result<Vec<u8>> {
    let object = store.read(oid)?;
    if object.kind != "blob" {
        return Err(format!("esperava blob, encontrei {}", object.kind).into());
    }
    Ok(object.data)
}
07
src/index.rs

Mantém a fotografia preparada para o próximo commit

Ver código completo de src/index.rs
use crate::Result;
use std::{collections::BTreeMap, fs, path::Path};

pub type Index = BTreeMap<String, String>;

pub fn load(rastro_dir: &Path) -> Result<Index> {
    let path = rastro_dir.join("index");
    if !path.exists() {
        return Ok(Index::new());
    }
    let text = fs::read_to_string(path)?;
    let mut index = Index::new();
    for line in text.lines() {
        let (oid, path) = line.split_once('\t').ok_or("linha inválida no index")?;
        index.insert(path.to_string(), oid.to_string());
    }
    Ok(index)
}

pub fn save(rastro_dir: &Path, index: &Index) -> Result<()> {
    let mut text = String::new();
    for (path, oid) in index {
        text.push_str(oid);
        text.push('\t');
        text.push_str(path);
        text.push('\n');
    }
    fs::write(rastro_dir.join("index"), text)?;
    Ok(())
}
08
src/tree.rs

Converte caminhos do index em diretórios imutáveis

Ver código completo de src/tree.rs
use crate::{index::Index, object_store::ObjectStore, Result};
use std::collections::{BTreeMap, BTreeSet};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeEntry {
    pub kind: String,
    pub oid: String,
    pub name: String,
}

pub fn write_from_index(store: &ObjectStore, index: &Index) -> Result<String> {
    write_level(store, index, "")
}

fn write_level(store: &ObjectStore, index: &Index, prefix: &str) -> Result<String> {
    let mut files = BTreeMap::new();
    let mut directories = BTreeSet::new();

    for (path, oid) in index {
        let Some(relative) = path.strip_prefix(prefix) else { continue };
        if let Some((directory, _)) = relative.split_once('/') {
            directories.insert(directory.to_string());
        } else if !relative.is_empty() {
            files.insert(relative.to_string(), oid.to_string());
        }
    }

    let mut entries = Vec::new();
    for (name, oid) in files {
        entries.push(TreeEntry { kind: "blob".into(), oid, name });
    }
    for name in directories {
        let child_prefix = format!("{prefix}{name}/");
        let oid = write_level(store, index, &child_prefix)?;
        entries.push(TreeEntry { kind: "tree".into(), oid, name });
    }
    entries.sort_by(|a, b| a.name.cmp(&b.name));
    store.write("tree", &encode(&entries))
}

pub fn read(store: &ObjectStore, oid: &str) -> Result<Vec<TreeEntry>> {
    let object = store.read(oid)?;
    if object.kind != "tree" {
        return Err(format!("esperava tree, encontrei {}", object.kind).into());
    }
    decode(&object.data)
}

pub fn flatten(store: &ObjectStore, tree_oid: &str) -> Result<Index> {
    let mut output = Index::new();
    flatten_level(store, tree_oid, "", &mut output)?;
    Ok(output)
}

fn flatten_level(store: &ObjectStore, oid: &str, prefix: &str, output: &mut Index) -> Result<()> {
    for entry in read(store, oid)? {
        let path = if prefix.is_empty() {
            entry.name.clone()
        } else {
            format!("{prefix}/{}", entry.name)
        };
        match entry.kind.as_str() {
            "blob" => { output.insert(path, entry.oid); }
            "tree" => flatten_level(store, &entry.oid, &path, output)?,
            other => return Err(format!("tipo {other} não permitido em tree").into()),
        }
    }
    Ok(())
}

fn encode(entries: &[TreeEntry]) -> Vec<u8> {
    let mut text = String::new();
    for entry in entries {
        text.push_str(&entry.kind);
        text.push(' ');
        text.push_str(&entry.oid);
        text.push('\t');
        text.push_str(&entry.name);
        text.push('\n');
    }
    text.into_bytes()
}

fn decode(data: &[u8]) -> Result<Vec<TreeEntry>> {
    let text = std::str::from_utf8(data)?;
    let mut entries = Vec::new();
    for line in text.lines() {
        let (left, name) = line.split_once('\t').ok_or("entrada de tree inválida")?;
        let (kind, oid) = left.split_once(' ').ok_or("entrada de tree inválida")?;
        entries.push(TreeEntry { kind: kind.into(), oid: oid.into(), name: name.into() });
    }
    Ok(entries)
}
09
src/commit.rs

Serializa e interpreta commits ligados por parent

Ver código completo de src/commit.rs
use crate::{object_store::ObjectStore, Result};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Commit {
    pub tree: String,
    pub parents: Vec<String>,
    pub author: String,
    pub timestamp: u64,
    pub message: String,
}

impl Commit {
    pub fn write(&self, store: &ObjectStore) -> Result<String> {
        let mut text = format!("tree {}\n", self.tree);
        for parent in &self.parents {
            text.push_str(&format!("parent {parent}\n"));
        }
        text.push_str(&format!("author {}\n", self.author));
        text.push_str(&format!("timestamp {}\n\n", self.timestamp));
        text.push_str(self.message.trim());
        text.push('\n');
        store.write("commit", text.as_bytes())
    }

    pub fn read(store: &ObjectStore, oid: &str) -> Result<Self> {
        let object = store.read(oid)?;
        if object.kind != "commit" {
            return Err(format!("esperava commit, encontrei {}", object.kind).into());
        }
        let text = std::str::from_utf8(&object.data)?;
        let (headers, message) = text.split_once("\n\n").ok_or("commit inválido")?;
        let mut tree = None;
        let mut parents = Vec::new();
        let mut author = None;
        let mut timestamp = None;
        for line in headers.lines() {
            let (name, value) = line.split_once(' ').ok_or("cabeçalho de commit inválido")?;
            match name {
                "tree" => tree = Some(value.to_string()),
                "parent" => parents.push(value.to_string()),
                "author" => author = Some(value.to_string()),
                "timestamp" => timestamp = Some(value.parse()?),
                _ => {}
            }
        }
        Ok(Self {
            tree: tree.ok_or("commit sem tree")?,
            parents,
            author: author.ok_or("commit sem autor")?,
            timestamp: timestamp.ok_or("commit sem data")?,
            message: message.trim_end().to_string(),
        })
    }
}
10
src/head.rs

Representa HEAD simbólico ou destacado

Ver código completo de src/head.rs
use crate::Result;
use std::{fs, path::Path};

#[derive(Debug, Clone)]
pub enum Head {
    Symbolic(String),
    Detached(String),
}

pub fn read(rastro_dir: &Path) -> Result<Head> {
    let value = fs::read_to_string(rastro_dir.join("HEAD"))?;
    let value = value.trim();
    if let Some(reference) = value.strip_prefix("ref: ") {
        Ok(Head::Symbolic(reference.to_string()))
    } else {
        Ok(Head::Detached(value.to_string()))
    }
}

pub fn write(rastro_dir: &Path, head: &Head) -> Result<()> {
    let value = match head {
        Head::Symbolic(reference) => format!("ref: {reference}\n"),
        Head::Detached(oid) => format!("{oid}\n"),
    };
    fs::write(rastro_dir.join("HEAD"), value)?;
    Ok(())
}
11
src/refs.rs

Resolve e atualiza branches

Ver código completo de src/refs.rs
use crate::{head::{self, Head}, Result};
use std::{fs, path::Path};

pub fn validate_branch_name(name: &str) -> Result<()> {
    if name.is_empty()
        || name.starts_with('.')
        || name.ends_with('.')
        || name.contains("..")
        || name.chars().any(|c| matches!(c, '/' | '\\' | ' ' | '\t' | '\n'))
    {
        return Err("nome de branch inválido".into());
    }
    Ok(())
}

pub fn read_ref(rastro_dir: &Path, name: &str) -> Result<Option<String>> {
    let path = rastro_dir.join(name);
    if !path.exists() {
        return Ok(None);
    }
    Ok(Some(fs::read_to_string(path)?.trim().to_string()))
}

pub fn resolve_head(rastro_dir: &Path) -> Result<Option<String>> {
    match head::read(rastro_dir)? {
        Head::Symbolic(reference) => read_ref(rastro_dir, &reference),
        Head::Detached(oid) if oid.is_empty() => Ok(None),
        Head::Detached(oid) => Ok(Some(oid)),
    }
}

pub fn update_head(rastro_dir: &Path, oid: &str) -> Result<()> {
    match head::read(rastro_dir)? {
        Head::Symbolic(reference) => {
            let path = rastro_dir.join(reference);
            if let Some(parent) = path.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::write(path, format!("{oid}\n"))?;
        }
        Head::Detached(_) => head::write(rastro_dir, &Head::Detached(oid.to_string()))?,
    }
    Ok(())
}

pub fn create_branch(rastro_dir: &Path, name: &str, oid: &str) -> Result<()> {
    validate_branch_name(name)?;
    let path = rastro_dir.join("refs").join("heads").join(name);
    if path.exists() {
        return Err(format!("a branch {name} já existe").into());
    }
    fs::write(path, format!("{oid}\n"))?;
    Ok(())
}

pub fn current_branch(rastro_dir: &Path) -> Result<Option<String>> {
    match head::read(rastro_dir)? {
        Head::Symbolic(reference) => Ok(reference
            .strip_prefix("refs/heads/")
            .map(ToString::to_string)),
        Head::Detached(_) => Ok(None),
    }
}

pub fn list_branches(rastro_dir: &Path) -> Result<Vec<String>> {
    let directory = rastro_dir.join("refs").join("heads");
    let mut branches = Vec::new();
    for entry in fs::read_dir(directory)? {
        let entry = entry?;
        if entry.file_type()?.is_file() {
            branches.push(entry.file_name().to_string_lossy().to_string());
        }
    }
    branches.sort();
    Ok(branches)
}

pub fn delete_branch(rastro_dir: &Path, name: &str) -> Result<()> {
    validate_branch_name(name)?;
    let path = rastro_dir.join("refs").join("heads").join(name);
    if !path.exists() {
        return Err(format!("a branch {name} não existe").into());
    }
    fs::remove_file(path)?;
    Ok(())
}
12
src/log.rs

Caminha do commit atual até seus ancestrais

Ver código completo de src/log.rs
use crate::{commit::Commit, object_store::ObjectStore, Result};

pub fn collect(store: &ObjectStore, start: Option<String>) -> Result<Vec<(String, Commit)>> {
    let mut commits = Vec::new();
    let mut current = start;
    while let Some(oid) = current {
        let commit = Commit::read(store, &oid)?;
        current = commit.parents.first().cloned();
        commits.push((oid, commit));
    }
    Ok(commits)
}

pub fn format_timestamp(seconds: u64) -> String {
    format!("{seconds} segundos desde 1970-01-01 UTC")
}
13
src/merge.rs

Calcula a base, encontra conflitos e aplica as estratégias manual, ours ou theirs

Ver código completo de src/merge.rs
use crate::{commit::Commit, index::Index, object_store::ObjectStore, Result};
use std::collections::{BTreeSet, VecDeque};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeStrategy {
    Manual,
    Ours,
    Theirs,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Conflict {
    pub path: String,
    pub ours: Option<String>,
    pub theirs: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MergePlan {
    pub index: Index,
    pub conflicts: Vec<Conflict>,
}

pub fn is_ancestor(store: &ObjectStore, ancestor: &str, descendant: &str) -> Result<bool> {
    Ok(ancestors(store, descendant)?.contains(ancestor))
}

pub fn merge_base(store: &ObjectStore, left: &str, right: &str) -> Result<Option<String>> {
    let left_ancestors = ancestors(store, left)?;
    let mut queue = VecDeque::from([right.to_string()]);
    let mut visited = BTreeSet::new();

    while let Some(oid) = queue.pop_front() {
        if !visited.insert(oid.clone()) {
            continue;
        }
        if left_ancestors.contains(&oid) {
            return Ok(Some(oid));
        }
        queue.extend(Commit::read(store, &oid)?.parents);
    }
    Ok(None)
}

pub fn three_way(
    base: &Index,
    ours: &Index,
    theirs: &Index,
    strategy: MergeStrategy,
) -> MergePlan {
    let paths: BTreeSet<_> = base.keys()
        .chain(ours.keys())
        .chain(theirs.keys())
        .cloned()
        .collect();
    let mut merged = Index::new();
    let mut conflicts = Vec::new();

    for path in paths {
        let base_value = base.get(&path);
        let our_value = ours.get(&path);
        let their_value = theirs.get(&path);

        let chosen = if our_value == their_value {
            our_value
        } else if our_value == base_value {
            their_value
        } else if their_value == base_value {
            our_value
        } else {
            match strategy {
                MergeStrategy::Ours => our_value,
                MergeStrategy::Theirs => their_value,
                MergeStrategy::Manual => {
                    conflicts.push(Conflict {
                        path: path.clone(),
                        ours: our_value.cloned(),
                        theirs: their_value.cloned(),
                    });
                    our_value
                }
            }
        };

        if let Some(oid) = chosen {
            merged.insert(path, oid.clone());
        }
    }

    MergePlan { index: merged, conflicts }
}

fn ancestors(store: &ObjectStore, start: &str) -> Result<BTreeSet<String>> {
    let mut found = BTreeSet::new();
    let mut queue = VecDeque::from([start.to_string()]);
    while let Some(oid) = queue.pop_front() {
        if found.insert(oid.clone()) {
            queue.extend(Commit::read(store, &oid)?.parents);
        }
    }
    Ok(found)
}
14
src/checkout.rs

Reconstrói arquivos a partir de blobs

Ver código completo de src/checkout.rs
use crate::{blob, index::{self, Index}, object_store::ObjectStore, Result};
use std::{fs, path::Path};

pub fn restore(root: &Path, rastro_dir: &Path, store: &ObjectStore, target: &Index) -> Result<()> {
    let current = index::load(rastro_dir)?;
    for path in current.keys() {
        if !target.contains_key(path) {
            let full = root.join(path);
            if full.is_file() {
                fs::remove_file(full)?;
            }
        }
    }

    for (path, oid) in target {
        let full = root.join(path);
        if let Some(parent) = full.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::write(full, blob::read(store, oid)?)?;
    }
    remove_empty_directories(root)?;
    index::save(rastro_dir, target)?;
    Ok(())
}

fn remove_empty_directories(root: &Path) -> Result<()> {
    fn visit(path: &Path) -> std::io::Result<bool> {
        let mut empty = true;
        for entry in std::fs::read_dir(path)? {
            let entry = entry?;
            let child = entry.path();
            if entry.file_name().to_string_lossy() == ".rastro" {
                empty = false;
            } else if child.is_dir() {
                if visit(&child)? {
                    std::fs::remove_dir(&child)?;
                } else {
                    empty = false;
                }
            } else {
                empty = false;
            }
        }
        Ok(empty)
    }
    let _ = visit(root)?;
    Ok(())
}
15
src/repository.rs

Coordena todas as operações e protege alterações locais

Ver código completo de src/repository.rs
use crate::{
    blob, checkout, commit::Commit, head::{self, Head}, index::{self, Index},
    merge, object_store::ObjectStore, refs, tree, Result,
};
use std::{
    collections::BTreeSet,
    env, fs,
    path::{Path, PathBuf},
    time::{SystemTime, UNIX_EPOCH},
};

#[derive(Debug, Clone)]
pub struct Repository {
    pub root: PathBuf,
    pub rastro_dir: PathBuf,
    pub store: ObjectStore,
}

#[derive(Debug, Default)]
pub struct Status {
    pub staged: Vec<String>,
    pub modified: Vec<String>,
    pub untracked: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MergeOutcome {
    AlreadyUpToDate,
    FastForward(String),
    MergeCommit(String),
    Conflicts(Vec<String>),
}

impl Repository {
    pub fn init(path: &Path) -> Result<Self> {
        fs::create_dir_all(path)?;
        let root = path.canonicalize()?;
        let rastro_dir = root.join(".rastro");
        if rastro_dir.exists() {
            return Err("este diretório já contém um repositório do Rastro".into());
        }
        fs::create_dir_all(rastro_dir.join("objects"))?;
        fs::create_dir_all(rastro_dir.join("refs").join("heads"))?;
        fs::write(rastro_dir.join("HEAD"), "ref: refs/heads/main\n")?;
        fs::write(rastro_dir.join("config"), "[core]\nrepositoryformatversion = 0\n")?;
        index::save(&rastro_dir, &Index::new())?;
        Ok(Self { root, store: ObjectStore::new(&rastro_dir), rastro_dir })
    }

    pub fn discover(start: &Path) -> Result<Self> {
        let mut current = start.canonicalize()?;
        loop {
            let rastro_dir = current.join(".rastro");
            if rastro_dir.is_dir() {
                return Ok(Self {
                    root: current,
                    store: ObjectStore::new(&rastro_dir),
                    rastro_dir,
                });
            }
            if !current.pop() {
                return Err("não estamos dentro de um repositório do Rastro".into());
            }
        }
    }

    pub fn add(&self, paths: &[PathBuf]) -> Result<()> {
        let mut index = index::load(&self.rastro_dir)?;
        for path in paths {
            let absolute = if path.is_absolute() { path.clone() } else { self.root.join(path) };
            self.add_path(&absolute, &mut index)?;
        }
        index::save(&self.rastro_dir, &index)
    }

    pub fn commit(&self, message: &str) -> Result<String> {
        if message.trim().is_empty() {
            return Err("a mensagem do commit não pode ficar vazia".into());
        }
        let index = index::load(&self.rastro_dir)?;
        if index.is_empty() {
            return Err("o index está vazio; use rastro add".into());
        }
        let tree = tree::write_from_index(&self.store, &index)?;
        let merge_parent = self.read_merge_head()?;
        if let Some(parent) = refs::resolve_head(&self.rastro_dir)? {
            let previous = Commit::read(&self.store, &parent)?;
            if previous.tree == tree && merge_parent.is_none() {
                return Err("nenhuma alteração adicionada ao commit".into());
            }
        }
        let author = env::var("RASTRO_AUTHOR")
            .or_else(|_| env::var("USER"))
            .unwrap_or_else(|_| "Pessoa autora".into());
        let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
        let mut parents: Vec<String> =
            refs::resolve_head(&self.rastro_dir)?.into_iter().collect();
        if let Some(parent) = merge_parent {
            parents.push(parent);
        }
        let commit = Commit {
            tree,
            parents,
            author,
            timestamp,
            message: message.trim().to_string(),
        };
        let oid = commit.write(&self.store)?;
        refs::update_head(&self.rastro_dir, &oid)?;
        self.clear_merge_state()?;
        Ok(oid)
    }

    pub fn status(&self) -> Result<Status> {
        let index = index::load(&self.rastro_dir)?;
        let head_snapshot = self.head_snapshot()?;
        let mut status = Status::default();

        let all_paths: BTreeSet<_> = index.keys().chain(head_snapshot.keys()).cloned().collect();
        for path in all_paths {
            if index.get(&path) != head_snapshot.get(&path) {
                status.staged.push(path);
            }
        }
        for (path, oid) in &index {
            let full = self.root.join(path);
            if !full.exists() || ObjectStore::hash_object("blob", &fs::read(full)?) != oid.as_str() {
                status.modified.push(path.clone());
            }
        }
        let mut working = Vec::new();
        collect_files(&self.root, &self.root, &mut working)?;
        for path in working {
            if !index.contains_key(&path) {
                status.untracked.push(path);
            }
        }
        Ok(status)
    }

    pub fn checkout(&self, target: &str) -> Result<String> {
        let status = self.status()?;
        if !status.modified.is_empty() || !status.staged.is_empty() {
            return Err("checkout cancelado: existem alterações locais ou preparadas".into());
        }
        let branch_ref = format!("refs/heads/{target}");
        let (oid, new_head) = if let Some(oid) = refs::read_ref(&self.rastro_dir, &branch_ref)? {
            (oid, Head::Symbolic(branch_ref))
        } else {
            let oid = self.store.resolve_prefix(target)?;
            (oid.clone(), Head::Detached(oid))
        };
        let commit = Commit::read(&self.store, &oid)?;
        let snapshot = tree::flatten(&self.store, &commit.tree)?;
        if let Some(path) = status.untracked.iter().find(|path| snapshot.contains_key(*path)) {
            return Err(format!("checkout sobrescreveria o arquivo não rastreado {path}").into());
        }
        checkout::restore(&self.root, &self.rastro_dir, &self.store, &snapshot)?;
        head::write(&self.rastro_dir, &new_head)?;
        Ok(oid)
    }

    pub fn create_branch(&self, name: &str) -> Result<()> {
        let oid = refs::resolve_head(&self.rastro_dir)?.ok_or("ainda não existe commit")?;
        refs::create_branch(&self.rastro_dir, name, &oid)
    }

    pub fn list_branches(&self) -> Result<Vec<(String, bool)>> {
        let current = refs::current_branch(&self.rastro_dir)?;
        Ok(refs::list_branches(&self.rastro_dir)?
            .into_iter()
            .map(|name| {
                let selected = current.as_deref() == Some(name.as_str());
                (name, selected)
            })
            .collect())
    }

    pub fn delete_branch(&self, name: &str, force: bool) -> Result<()> {
        if refs::current_branch(&self.rastro_dir)?.as_deref() == Some(name) {
            return Err("não é possível excluir a branch atualmente selecionada".into());
        }
        let reference = format!("refs/heads/{name}");
        let tip = refs::read_ref(&self.rastro_dir, &reference)?
            .ok_or_else(|| format!("a branch {name} não existe"))?;
        let current = refs::resolve_head(&self.rastro_dir)?
            .ok_or("a branch atual ainda não possui commit")?;
        if !force && !merge::is_ancestor(&self.store, &tip, &current)? {
            return Err(format!(
                "a branch {name} possui commits não integrados; use -D para forçar"
            ).into());
        }
        refs::delete_branch(&self.rastro_dir, name)
    }

    pub fn merge(&self, branch: &str, strategy: merge::MergeStrategy) -> Result<MergeOutcome> {
        if self.read_merge_head()?.is_some() {
            return Err("já existe um merge em andamento; conclua o commit ou use merge --abort".into());
        }
        self.ensure_clean("merge")?;
        let current_branch = refs::current_branch(&self.rastro_dir)?
            .ok_or("merge exige HEAD apontando para uma branch")?;
        if current_branch == branch {
            return Err("não é possível fazer merge de uma branch nela mesma".into());
        }

        let current = refs::resolve_head(&self.rastro_dir)?
            .ok_or("a branch atual ainda não possui commit")?;
        let target_ref = format!("refs/heads/{branch}");
        let target = refs::read_ref(&self.rastro_dir, &target_ref)?
            .ok_or_else(|| format!("a branch {branch} não existe"))?;

        if merge::is_ancestor(&self.store, &target, &current)? {
            return Ok(MergeOutcome::AlreadyUpToDate);
        }
        if merge::is_ancestor(&self.store, &current, &target)? {
            let snapshot = self.snapshot_at(&target)?;
            self.protect_untracked(&snapshot)?;
            checkout::restore(&self.root, &self.rastro_dir, &self.store, &snapshot)?;
            refs::update_head(&self.rastro_dir, &target)?;
            return Ok(MergeOutcome::FastForward(target));
        }

        let base_oid = merge::merge_base(&self.store, &current, &target)?
            .ok_or("as branches não possuem ancestral comum")?;
        let base = self.snapshot_at(&base_oid)?;
        let ours = self.snapshot_at(&current)?;
        let theirs = self.snapshot_at(&target)?;
        let mut plan = merge::three_way(&base, &ours, &theirs, strategy);
        if strategy == merge::MergeStrategy::Manual && !plan.conflicts.is_empty() {
            for conflict in &plan.conflicts {
                let ours = self.conflict_text(conflict.ours.as_deref(), "arquivo ausente")?;
                let theirs = self.conflict_text(conflict.theirs.as_deref(), "arquivo ausente")?;
                let marked = format!(
                    "<<<<<<< nossa branch ({current_branch})\n{ours}=======\n{theirs}>>>>>>> branch recebida ({branch})\n"
                );
                let oid = self.store.write("blob", marked.as_bytes())?;
                plan.index.insert(conflict.path.clone(), oid);
            }
            self.protect_untracked(&plan.index)?;
            checkout::restore(&self.root, &self.rastro_dir, &self.store, &plan.index)?;
            fs::write(self.rastro_dir.join("MERGE_HEAD"), format!("{target}\n"))?;
            fs::write(
                self.rastro_dir.join("MERGE_MSG"),
                format!("merge da branch {branch}; resolva os conflitos, use add e commit\n"),
            )?;
            return Ok(MergeOutcome::Conflicts(
                plan.conflicts.into_iter().map(|conflict| conflict.path).collect(),
            ));
        }
        self.protect_untracked(&plan.index)?;
        checkout::restore(&self.root, &self.rastro_dir, &self.store, &plan.index)?;

        let tree = tree::write_from_index(&self.store, &plan.index)?;
        let author = env::var("RASTRO_AUTHOR")
            .or_else(|_| env::var("USER"))
            .unwrap_or_else(|_| "Pessoa autora".into());
        let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
        let commit = Commit {
            tree,
            parents: vec![current, target],
            author,
            timestamp,
            message: format!("merge da branch {branch}"),
        };
        let oid = commit.write(&self.store)?;
        refs::update_head(&self.rastro_dir, &oid)?;
        Ok(MergeOutcome::MergeCommit(oid))
    }

    pub fn abort_merge(&self) -> Result<()> {
        if self.read_merge_head()?.is_none() {
            return Err("não existe merge em andamento".into());
        }
        let snapshot = self.head_snapshot()?;
        checkout::restore(&self.root, &self.rastro_dir, &self.store, &snapshot)?;
        self.clear_merge_state()
    }

    pub fn head_snapshot(&self) -> Result<Index> {
        let Some(oid) = refs::resolve_head(&self.rastro_dir)? else {
            return Ok(Index::new());
        };
        let commit = Commit::read(&self.store, &oid)?;
        tree::flatten(&self.store, &commit.tree)
    }

    fn snapshot_at(&self, oid: &str) -> Result<Index> {
        let commit = Commit::read(&self.store, oid)?;
        tree::flatten(&self.store, &commit.tree)
    }

    fn ensure_clean(&self, operation: &str) -> Result<()> {
        let status = self.status()?;
        if status.modified.is_empty() && status.staged.is_empty() {
            Ok(())
        } else {
            Err(format!(
                "{operation} cancelado: existem alterações locais ou preparadas"
            ).into())
        }
    }

    fn protect_untracked(&self, snapshot: &Index) -> Result<()> {
        let status = self.status()?;
        if let Some(path) = status.untracked.iter().find(|path| snapshot.contains_key(*path)) {
            return Err(format!("a operação sobrescreveria o arquivo não rastreado {path}").into());
        }
        Ok(())
    }

    fn read_merge_head(&self) -> Result<Option<String>> {
        let path = self.rastro_dir.join("MERGE_HEAD");
        if !path.exists() {
            return Ok(None);
        }
        Ok(Some(fs::read_to_string(path)?.trim().to_string()))
    }

    fn clear_merge_state(&self) -> Result<()> {
        for name in ["MERGE_HEAD", "MERGE_MSG"] {
            let path = self.rastro_dir.join(name);
            if path.exists() {
                fs::remove_file(path)?;
            }
        }
        Ok(())
    }

    fn conflict_text(&self, oid: Option<&str>, missing: &str) -> Result<String> {
        let bytes = match oid {
            Some(oid) => blob::read(&self.store, oid)?,
            None => return Ok(format!("[{missing}]\n")),
        };
        let mut text = String::from_utf8(bytes)
            .map_err(|_| "conflito em arquivo binário; repita com --ours ou --theirs")?;
        if !text.ends_with('\n') {
            text.push('\n');
        }
        Ok(text)
    }

    fn add_path(&self, absolute: &Path, index: &mut Index) -> Result<()> {
        if absolute == self.rastro_dir || absolute.starts_with(&self.rastro_dir) {
            return Ok(());
        }
        if absolute.is_dir() {
            for entry in fs::read_dir(absolute)? {
                self.add_path(&entry?.path(), index)?;
            }
        } else if absolute.is_file() {
            let relative = absolute.strip_prefix(&self.root)?
                .to_string_lossy().replace('\\', "/");
            if relative.contains('\n') || relative.contains('\t') {
                return Err("o Rastro não aceita tab ou quebra de linha no caminho".into());
            }
            let oid = blob::store_file(&self.store, absolute)?;
            index.insert(relative, oid);
        } else {
            return Err(format!("caminho não encontrado: {}", absolute.display()).into());
        }
        Ok(())
    }
}

fn collect_files(root: &Path, current: &Path, output: &mut Vec<String>) -> Result<()> {
    for entry in fs::read_dir(current)? {
        let path = entry?.path();
        if path == root.join(".rastro") {
            continue;
        }
        if path.is_dir() {
            collect_files(root, &path, output)?;
        } else if path.is_file() {
            output.push(path.strip_prefix(root)?.to_string_lossy().replace('\\', "/"));
        }
    }
    output.sort();
    Ok(())
}
16
tests/repository.rs

Prova deduplicação, histórico e restauração

Ver código completo de tests/repository.rs
use rastro::{
    commit::Commit, log, merge::MergeStrategy, refs,
    repository::{MergeOutcome, Repository},
};
use std::{fs, path::PathBuf, time::{SystemTime, UNIX_EPOCH}};

fn temporary_directory() -> PathBuf {
    let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
    std::env::temp_dir().join(format!("rastro-test-{}-{unique}", std::process::id()))
}

#[test]
fn commit_log_status_and_checkout_complete_a_round_trip() {
    let root = temporary_directory();
    let repository = Repository::init(&root).unwrap();

    fs::write(root.join("README.md"), "primeira versão\n").unwrap();
    fs::create_dir(root.join("src")).unwrap();
    fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
    repository.add(&[PathBuf::from(".")]).unwrap();
    let first = repository.commit("primeira versão").unwrap();

    fs::write(root.join("README.md"), "segunda versão\n").unwrap();
    let before_add = repository.status().unwrap();
    assert_eq!(before_add.modified, vec!["README.md"]);

    repository.add(&[PathBuf::from("README.md")]).unwrap();
    let second = repository.commit("atualiza README").unwrap();
    assert_ne!(first, second);

    let history = log::collect(&repository.store, refs::resolve_head(&repository.rastro_dir).unwrap()).unwrap();
    assert_eq!(history.len(), 2);
    assert_eq!(history[0].1.message, "atualiza README");
    assert_eq!(history[1].1.message, "primeira versão");

    repository.checkout(&first[..8]).unwrap();
    assert_eq!(fs::read_to_string(root.join("README.md")).unwrap(), "primeira versão\n");
    assert!(matches!(rastro::head::read(&repository.rastro_dir).unwrap(), rastro::head::Head::Detached(_)));

    fs::remove_dir_all(root).unwrap();
}

#[test]
fn equal_file_contents_share_the_same_blob() {
    let root = temporary_directory();
    let repository = Repository::init(&root).unwrap();
    fs::write(root.join("a.txt"), "igual").unwrap();
    fs::write(root.join("b.txt"), "igual").unwrap();
    repository.add(&[PathBuf::from(".")]).unwrap();

    let index = rastro::index::load(&repository.rastro_dir).unwrap();
    assert_eq!(index["a.txt"], index["b.txt"]);
    fs::remove_dir_all(root).unwrap();
}

#[test]
fn commit_points_to_a_tree_and_to_its_parent() {
    let root = temporary_directory();
    let repository = Repository::init(&root).unwrap();
    fs::write(root.join("nota.txt"), "um").unwrap();
    repository.add(&[PathBuf::from(".")]).unwrap();
    let first = repository.commit("um").unwrap();

    fs::write(root.join("nota.txt"), "dois").unwrap();
    repository.add(&[PathBuf::from(".")]).unwrap();
    let second = repository.commit("dois").unwrap();
    let parsed = Commit::read(&repository.store, &second).unwrap();

    assert_eq!(parsed.parents, vec![first]);
    assert_eq!(repository.store.read(&parsed.tree).unwrap().kind, "tree");
    fs::remove_dir_all(root).unwrap();
}

#[test]
fn branch_checkout_merge_and_delete_complete_the_workflow() {
    let root = temporary_directory();
    let repository = Repository::init(&root).unwrap();
    fs::write(root.join("base.txt"), "base\n").unwrap();
    repository.add(&[PathBuf::from(".")]).unwrap();
    repository.commit("base").unwrap();

    repository.create_branch("ideia").unwrap();
    repository.checkout("ideia").unwrap();
    fs::write(root.join("ideia.txt"), "feito na ideia\n").unwrap();
    repository.add(&[PathBuf::from("ideia.txt")]).unwrap();
    let idea_tip = repository.commit("trabalho paralelo").unwrap();

    repository.checkout("main").unwrap();
    fs::write(root.join("main.txt"), "feito na main\n").unwrap();
    repository.add(&[PathBuf::from("main.txt")]).unwrap();
    let main_tip = repository.commit("trabalho principal").unwrap();

    let outcome = repository.merge("ideia", MergeStrategy::Manual).unwrap();
    let MergeOutcome::MergeCommit(merge_oid) = outcome else {
        panic!("esperava um commit de merge");
    };
    let merge_commit = Commit::read(&repository.store, &merge_oid).unwrap();
    assert_eq!(merge_commit.parents, vec![main_tip, idea_tip]);
    assert_eq!(fs::read_to_string(root.join("ideia.txt")).unwrap(), "feito na ideia\n");
    assert_eq!(fs::read_to_string(root.join("main.txt")).unwrap(), "feito na main\n");

    repository.delete_branch("ideia", false).unwrap();
    assert_eq!(repository.list_branches().unwrap(), vec![("main".into(), true)]);
    fs::remove_dir_all(root).unwrap();
}

#[test]
fn merge_uses_fast_forward_when_current_branch_has_not_diverged() {
    let root = temporary_directory();
    let repository = Repository::init(&root).unwrap();
    fs::write(root.join("nota.txt"), "base\n").unwrap();
    repository.add(&[PathBuf::from(".")]).unwrap();
    repository.commit("base").unwrap();

    repository.create_branch("ideia").unwrap();
    repository.checkout("ideia").unwrap();
    fs::write(root.join("nota.txt"), "ideia pronta\n").unwrap();
    repository.add(&[PathBuf::from("nota.txt")]).unwrap();
    let target = repository.commit("termina ideia").unwrap();
    repository.checkout("main").unwrap();

    assert_eq!(
        repository.merge("ideia", MergeStrategy::Manual).unwrap(),
        MergeOutcome::FastForward(target)
    );
    assert_eq!(fs::read_to_string(root.join("nota.txt")).unwrap(), "ideia pronta\n");
    fs::remove_dir_all(root).unwrap();
}

#[test]
fn manual_conflict_writes_markers_and_commit_keeps_two_parents() {
    let root = temporary_directory();
    let repository = Repository::init(&root).unwrap();
    fs::write(root.join("nota.txt"), "texto original\n").unwrap();
    repository.add(&[PathBuf::from(".")]).unwrap();
    repository.commit("base").unwrap();

    repository.create_branch("ideia").unwrap();
    repository.checkout("ideia").unwrap();
    fs::write(root.join("nota.txt"), "texto da ideia\n").unwrap();
    repository.add(&[PathBuf::from("nota.txt")]).unwrap();
    let idea_tip = repository.commit("altera na ideia").unwrap();

    repository.checkout("main").unwrap();
    fs::write(root.join("nota.txt"), "texto da main\n").unwrap();
    repository.add(&[PathBuf::from("nota.txt")]).unwrap();
    let main_tip = repository.commit("altera na main").unwrap();

    assert_eq!(
        repository.merge("ideia", MergeStrategy::Manual).unwrap(),
        MergeOutcome::Conflicts(vec!["nota.txt".into()])
    );
    let marked = fs::read_to_string(root.join("nota.txt")).unwrap();
    assert!(marked.contains("<<<<<<< nossa branch (main)"));
    assert!(marked.contains("texto da main"));
    assert!(marked.contains("texto da ideia"));

    fs::write(root.join("nota.txt"), "texto combinado\n").unwrap();
    repository.add(&[PathBuf::from("nota.txt")]).unwrap();
    let merge_oid = repository.commit("resolve conflito").unwrap();
    let merge_commit = Commit::read(&repository.store, &merge_oid).unwrap();
    assert_eq!(merge_commit.parents, vec![main_tip, idea_tip]);
    assert!(!repository.rastro_dir.join("MERGE_HEAD").exists());
    fs::remove_dir_all(root).unwrap();
}

#[test]
fn ours_theirs_and_abort_are_explicit_conflict_strategies() {
    fn divergent_repository() -> (PathBuf, Repository) {
        let root = temporary_directory();
        let repository = Repository::init(&root).unwrap();
        fs::write(root.join("nota.txt"), "base\n").unwrap();
        repository.add(&[PathBuf::from(".")]).unwrap();
        repository.commit("base").unwrap();
        repository.create_branch("ideia").unwrap();
        repository.checkout("ideia").unwrap();
        fs::write(root.join("nota.txt"), "theirs\n").unwrap();
        repository.add(&[PathBuf::from("nota.txt")]).unwrap();
        repository.commit("theirs").unwrap();
        repository.checkout("main").unwrap();
        fs::write(root.join("nota.txt"), "ours\n").unwrap();
        repository.add(&[PathBuf::from("nota.txt")]).unwrap();
        repository.commit("ours").unwrap();
        (root, repository)
    }

    let (ours_root, ours_repository) = divergent_repository();
    ours_repository.merge("ideia", MergeStrategy::Ours).unwrap();
    assert_eq!(fs::read_to_string(ours_root.join("nota.txt")).unwrap(), "ours\n");
    fs::remove_dir_all(ours_root).unwrap();

    let (theirs_root, theirs_repository) = divergent_repository();
    theirs_repository.merge("ideia", MergeStrategy::Theirs).unwrap();
    assert_eq!(fs::read_to_string(theirs_root.join("nota.txt")).unwrap(), "theirs\n");
    fs::remove_dir_all(theirs_root).unwrap();

    let (abort_root, abort_repository) = divergent_repository();
    abort_repository.merge("ideia", MergeStrategy::Manual).unwrap();
    abort_repository.abort_merge().unwrap();
    assert_eq!(fs::read_to_string(abort_root.join("nota.txt")).unwrap(), "ours\n");
    assert!(!abort_repository.rastro_dir.join("MERGE_HEAD").exists());
    fs::remove_dir_all(abort_root).unwrap();
}

20 / DO DIRETÓRIO VAZIO AO CHECKOUT

Agora podemos observar cada objeto aparecendo no disco.

cargo build
cargo test

cargo run -- init laboratorio
cd laboratorio

echo "primeira versão" > README.md
../target/debug/rastro add README.md
../target/debug/rastro commit -m "cria README"

echo "segunda versão" > README.md
../target/debug/rastro status
../target/debug/rastro add README.md
../target/debug/rastro commit -m "atualiza README"

../target/debug/rastro branch menu-novo
../target/debug/rastro checkout menu-novo
echo "menu experimental" > menu.txt
../target/debug/rastro add menu.txt
../target/debug/rastro commit -m "cria novo menu"

../target/debug/rastro checkout main
../target/debug/rastro merge menu-novo
../target/debug/rastro branch -d menu-novo

../target/debug/rastro log
../target/debug/rastro checkout <hash-do-primeiro-commit>

Depois de add, abra .rastro/objects. Depois do commit, examine .rastro/refs/heads/main e .rastro/HEAD. O projeto foi desenhado para que a explicação possa ser confirmada com um editor de texto.

Experimentos que transformam leitura em entendimento

01dois arquivos iguaisconfirme que apontam para o mesmo blob
02mude uma letraobserve outro hash surgir
03crie uma branchcompare os pequenos arquivos em refs/heads
04restaure um commitacompanhe tree e blobs reconstruindo os caminhos
05corrompa um objetoveja a validação do hash recusar o conteúdo
06edite após addstatus separa preparado de não preparado

Chegamos à resposta inicial. O Git descreve fotografias completas, mas não copia cegamente o projeto. Conteúdos viram blobs, diretórios viram trees, commits apontam para a raiz e para seus parents, branches dão nomes a commits e HEAD informa qual nome estamos usando. Checkout apenas percorre o mapa no sentido inverso.

A IDEIA CENTRAL

O Git transforma arquivos mutáveis em objetos imutáveis e transforma histórico em conexões entre hashes. Quase todo o restante nasce dessa decisão.

Para continuar investigando: Git Book — objetos do Git, Git Book — breve história do Git e documentação da transição para SHA‑256.

21 / PERGUNTAS FREQUENTES

Dúvidas que deixam de parecer mágicas quando enxergamos o banco de objetos.

O Git copia o projeto inteiro em cada commit?

Um commit descreve uma fotografia completa, mas reutiliza objetos que já existem. Arquivos com o mesmo conteúdo apontam para o mesmo blob, e diretórios inalterados podem reutilizar as mesmas trees.

O que é um blob no Git?

Blob é o objeto que guarda o conteúdo de um arquivo. Ele não guarda o nome nem o caminho; essas informações pertencem às trees.

O que é uma tree?

Tree é o objeto que representa um diretório. Ela associa nomes a blobs de arquivos ou a outras trees de subdiretórios.

O que existe dentro de um commit?

Um commit aponta para a tree raiz da fotografia, para o commit anterior quando existe e contém autor, data e mensagem. Seu hash identifica esse conteúdo completo.

O que é HEAD?

HEAD informa qual referência ou commit está selecionado no momento. Normalmente aponta para uma branch; em detached HEAD, aponta diretamente para um commit.

O que é uma branch no Git?

Branch é um nome móvel que aponta para o commit mais recente de uma linha de trabalho. Ela não copia arquivos nem contém commits: no disco, pode ser apenas um pequeno arquivo guardando um hash.

O que acontece quando fazemos checkout de uma branch?

HEAD passa a apontar para a branch escolhida e o Rastro reconstrói no diretório de trabalho a fotografia indicada pelo commit daquela branch.

O que é fast-forward em um merge?

É o caso em que a branch atual não possui trabalho próprio depois do ponto de separação. Não é necessário criar um commit de merge: basta mover o ponteiro para a frente.

Quando um merge gera conflito?

Quando as duas branches modificaram de maneiras diferentes o mesmo caminho desde a base comum. O Rastro marca as duas versões em arquivos de texto para resolução manual, permite escolher --ours ou --theirs e oferece merge --abort.

Por que não posso excluir qualquer branch?

A exclusão segura verifica se o último commit da branch já faz parte da história atual. Isso evita apagar o único nome que ainda alcança commits não integrados.

Por que dois arquivos iguais geram o mesmo blob?

O identificador é calculado a partir do tipo, tamanho e conteúdo. Entradas idênticas produzem o mesmo hash e, portanto, compartilham o mesmo objeto.

Por que dois commits podem ter trees iguais e hashes diferentes?

O hash do commit inclui também parent, autor, data e mensagem. Mesmo apontando para a mesma fotografia, qualquer mudança nesses metadados altera o hash.

O que acontece se um objeto desaparecer?

Trees e commits que apontam para ele ficam incompletos. Comandos de verificação conseguem detectar a referência quebrada, mas recuperar o conteúdo exige outra cópia, backup ou repositório remoto.

Por que o Git usou SHA-1?

Quando o Git nasceu, SHA-1 oferecia identificadores compactos e amplamente disponíveis. Depois de avanços em ataques de colisão, o Git adicionou proteções e iniciou suporte a repositórios com SHA-256.

O Rastro é compatível com o Git real?

Não. Ele preserva as ideias principais para ensino, mas usa trees em texto, objetos sem zlib e um index simplificado. O Git real possui formatos e protocolos mais sofisticados.

Git e GitHub são a mesma coisa?

Não. Git é o sistema de controle de versões que funciona localmente. GitHub é um serviço que hospeda repositórios Git e acrescenta colaboração, revisão, permissões e automações.

O que este artigo fez você pensar?

Dúvidas, experiências e contrapontos ajudam a próxima pessoa a enxergar o assunto por outro ângulo.

Todos passam por moderação. Ao enviar, você concorda com a política de privacidade.

Receba os próximos artigos.

Uma mensagem quando uma nova investigação estiver pronta. Só isso.