CONTROLE DE VERSÃO · ESTRUTURAS DE DADOS · RUST
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.
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.
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.
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.
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.rsFunciona. 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.
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.
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.
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/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.
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
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 + tamanhoAntes 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.
É 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.
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.
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.
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”.
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.rsO 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”.
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 nó. 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.
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 treesO 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.
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
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.
11 / QUAL É A VERSÃO ATUAL?
HEAD aponta para uma branch; a branch aponta para um commit.
HEAD é o marcador “você está aqui” do mapa. Normalmente ele aponta para o nome de uma branch, e a branch aponta para um commit.
Referência, ou ref, é um nome que guarda um hash. Em vez de decorar c9a81f..., usamos main. Quando criamos outro commit, o arquivo refs/heads/main passa a guardar o novo hash.
.rastro/HEAD
ref: refs/heads/main
.rastro/refs/heads/main
c9a81f6ef9...Surge um problema: como testar uma ideia sem mexer na linha principal?
Imagine que a main aponta para o commit C, uma versão estável. Queremos experimentar um novo menu. Se continuarmos criando commits namain, a etiqueta principal avançará junto com uma ideia que talvez seja descartada. Copiar a pasta inteira resolveria, mas nos devolveria ao problema das pastas final-agora-vai.
Precisamos apenas de outra etiqueta apontando para o mesmo ponto. Essa etiqueta é uma branch. Branch significa “ramo”: uma linha de trabalho que pode crescer a partir de um commit já existente.
Uma branch é um pequeno arquivo que guarda o hash de um commit. Ela não contém arquivos, não duplica o projeto e não guarda commits dentro dela. É somente um nome móvel para a ponta de uma linha de trabalho.
Antes da experiência, main e menu-novo podem apontar para C. Quando selecionamos menu-novo e criamos D, apenas essa etiqueta avança. A main continua em C.
C ← main
↑
└── criar branch menu-novo
C ← main
└── D ← menu-novoCriar uma branch significa criar a segunda etiqueta
rastro branch menu-novo grava o hash atual em outro arquivo. Nenhum blob, tree ou commit é copiado. As branches começam juntas e divergem apenas quando novos commits movem um dos ponteiros.
rastro branch menu-novo
rastro branch
* main
menu-novoO asterisco não significa “melhor branch”. Ele mostra qual branch HEAD selecionou. Criar a etiqueta também não nos transporta para ela: ainda estamos na main. A troca será responsabilidade do checkout.
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)
}resolve_head encontra o commit selecionado. create_branchvalida o nome, impede sobrescrever uma branch existente e grava o hash em.rastro/refs/heads/menu-novo.
Detached HEAD significa HEAD destacado de uma branch
Ao selecionar diretamente um hash, HEAD guarda o commit em vez deref: .... Podemos inspecionar aquela versão, mas um novo commit não moverá automaticamente uma branch. O estado parece estranho apenas quando HEAD é tratado como “a branch”; na realidade ele é o marcador da posição selecionada.
12 / O HISTÓRICO É UMA CORRENTE DE PARENTS
rastro log começa em HEAD e segue o commit anterior.
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.
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ção | Diferença encontrada | Status |
|---|---|---|
| HEAD ↔ index | hash diferente ou caminho novo | preparado para commit |
| index ↔ arquivo | hash diferente ou arquivo ausente | modificado, ainda sem add |
| arquivo sem entrada no index | caminho desconhecido | nã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 apontadoA 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?
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.
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-novoA ordem importa: estamos dizendo “traga menu-novo para dentro da branch atualmente selecionada, que é main”.
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-novoNã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.
Essa comparação recebe o nome de merge de três vias. Para cada caminho, o algoritmo pergunta:
| Situação | Escolha segura |
|---|---|
| ours e theirs são iguais | usar qualquer um: não existe divergência |
| ours continua igual à base | usar theirs: apenas o outro lado mudou |
| theirs continua igual à base | usar ours: apenas o nosso lado mudou |
| os dois mudaram de formas diferentes | parar: 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.
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);
}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“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 --abortO 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égia | Quando faz sentido | Risco |
|---|---|---|
| manual | as duas mudanças expressam intenções úteis | combinar incorretamente o comportamento |
| --ours | a versão atual deve vencer em todos os conflitos | descartar silenciosamente a intenção recebida |
| --theirs | a outra branch é a fonte correta nesses conflitos | substituir decisões feitas na branch atual |
| --abort | a decisão exige mais contexto | nenhum conteúdo é integrado ainda |
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ídaExcluir 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.
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”.
É 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.
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.
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.
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.
É 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.
Outros sistemas escolheram modelos diferentes
Git, SVN e Mercurial preservam versões, mas organizam autoridade e histórico de maneiras diferentes:
| Sistema | Modelo mental | Consequência |
|---|---|---|
| Git | repositório distribuído e objetos por conteúdo | histórico completo local, branches leves e trabalho offline |
| SVN | repositório central com revisões globais | servidor ocupa o centro do fluxo e numera revisões |
| Mercurial | distribuído, com changesets e manifests | objetivos 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.rsBaixar o projeto completo do Rastro 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.
Cargo.tomlDefine 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]
src/lib.rsExpõ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>>;
src/main.rsInterpreta 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}");
}
}
src/hash.rsImplementa 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");
}
}
src/object_store.rsCria, 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..]))
}
}
src/blob.rsTransforma 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)
}
src/index.rsManté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(())
}
src/tree.rsConverte 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)
}
src/commit.rsSerializa 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(),
})
}
}
src/head.rsRepresenta 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(())
}
src/refs.rsResolve 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(())
}
src/log.rsCaminha 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")
}
src/merge.rsCalcula 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)
}
src/checkout.rsReconstró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(())
}
src/repository.rsCoordena 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, ¤t)? {
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, ¤t)? {
return Ok(MergeOutcome::AlreadyUpToDate);
}
if merge::is_ancestor(&self.store, ¤t, &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, ¤t, &target)?
.ok_or("as branches não possuem ancestral comum")?;
let base = self.snapshot_at(&base_oid)?;
let ours = self.snapshot_at(¤t)?;
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(())
}
tests/repository.rsProva 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
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.
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.
A CONVERSA CONTINUA
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.