Controlo de versões com Git
Índice, commits, branches, integração e um build Java reproduzível com Gradle.
Um colega altera o movimento do herói e tu alteras a pontuação. O Git guarda versões do código e permite combinar os dois históricos. O Gradle descreve como compilar e testar esse código, para a equipa reproduzir o build.
As três áreas do Git
O working tree contém os ficheiros que estás a editar. O índice, ou staging area, contém o próximo snapshot que escolheste com git add. Um commit guarda esse snapshot no histórico local, com mensagem e referência aos seus pais.
ficheiros editados --git add--> índice --git commit--> histórico
git add copia o conteúdo daquele instante para o índice. Se voltares a editar depois, o próximo commit continua a usar a versão preparada, até repetires o add.
Segue um ficheiro já guardado num commit, antes de experimentar os comandos:
| Ação | Ficheiro | Índice | Commit |
|---|---|---|---|
| Antes | A | A | A |
| Editar B | B | A | A |
| Preparar B | B | B | A |
| Editar C | C | B | A |
| Criar commit | C | B | B |
A coluna Ficheiro representa o working tree; Commit é a última versão registada. Preparar B executa git add regra.txt. Criar commit executa git commit sem novo git add: C continua por preparar. O commit não volta a ler o ficheiro do working tree.
Experimenta numa pasta nova:
mkdir treino-git
cd treino-git
git init -b main
printf 'versao 1\n' > regra.txt
git add regra.txt
printf 'versao 2\n' > regra.txt
git diff
git diff --cached
git commit -m "Regista a primeira regra"
git diff compara working tree com índice, por isso mostra a passagem de versão 1 para versão 2. git diff --cached compara índice com o commit atual, por isso mostra o ficheiro novo com versão 1. O commit guarda versão 1; versão 2 continua como alteração local.
No estado curto, a primeira coluna descreve o índice e a segunda o working tree. M regra.txt significa alteração preparada; M regra.txt, alteração por preparar; MM regra.txt, as duas. ?? identifica um ficheiro ainda não seguido.
Antes de um commit, consulta git status --short, git diff e git diff --cached. Prepara caminhos concretos quando há trabalho de várias tarefas na mesma pasta. Um commit deve explicar uma alteração e o seu motivo.
Branches e merges
Um branch é uma referência móvel para um commit: HEAD costuma indicar o branch atual, embora num checkout de um commit isolado possa estar destacado, sem branch. Criar um branch não copia os ficheiros para outra pasta.
git switch -c movimento
# edita e guarda os ficheiros da funcionalidade
git add regra.txt
git commit -m "Permite mover o heroi para cima"
git switch main
git merge movimento
O merge integra movimento no branch em que estás, aqui main. Há dois casos:
- Se
mainé antepassado demovimento, um fast-forward move a referência demainpara a frente. Não precisa de commit de merge. - Se ambos avançaram a partir de um antepassado comum, o merge combina as mudanças. Um commit de merge tem os dois históricos como pais.
C---D movimento
/ \
A---B-------E-------M main
Neste desenho, M integra D e E. Os commits C e D não desaparecem. O branch é o nome que aponta para o seu último commit.
Este vídeo mostra a criação e a integração de branches:
Acompanha o desenho do histórico com commits próprios. Antes de integrar duas branches, prevê quais commits ficarão alcançáveis a partir de cada nome.
Resolver um conflito
Um conflito surge quando Git não consegue decidir o conteúdo final, como em linhas sobrepostas ou quando um branch remove um ficheiro que outro altera.
<<<<<<< HEAD
int velocidade = 2;
=======
int velocidade = 3;
>>>>>>> movimento
HEAD é a versão do branch atual. A outra parte vem do branch integrado. Escolhe o comportamento certo, edita o ficheiro e remove todos os marcadores. Depois corre os testes, prepara o ficheiro com git add regra.txt e conclui com git commit. A solução pode combinar as duas versões, não tem de escolher uma por inteiro.
Se precisas de abandonar este merge ainda por concluir, git merge --abort tenta repor o estado anterior ao merge. Se havia alterações por guardar quando o merge começou, essa reposição pode falhar; começa a integração com o working tree limpo para não misturar uma resolução com trabalho por guardar.
Repositório local e remoto
O remoto é outro repositório, frequentemente alojado no GitHub. Os commits existem localmente antes do push. origin é o nome convencional do remoto criado por clone, não uma regra sobre qual repositório manda.
| Comando | Efeito |
|---|---|
git fetch origin | Obtém objetos e atualiza referências como origin/main, sem integrar no branch atual. |
git pull --ff-only | Obtém e integra apenas se for possível avançar sem divergência. |
git push -u origin movimento | Publica o branch e configura a referência remota acompanhada. |
git log --oneline --graph --all | Mostra o histórico e os ramos conhecidos. |
Se pull --ff-only recusar, os históricos divergiram. Inspeciona o grafo e combina com merge ou rebase segundo o workflow da equipa. Rebase reaplica commits sobre outra base e cria identificadores novos; reescrever commits já partilhados exige coordenação.
Workflows em equipa
Uma opção simples para o projeto é usar branches curtos e pull requests. Atualiza main, cria um branch, implementa um comportamento, testa e pede revisão. Integra quando o resultado está compreendido e o build passa.
O Git regista e combina o histórico; o GitHub aloja repositórios e permite discutir propostas de integração em pull requests. Um commit local ainda não está publicado, e abrir um pull request não integra o branch.
Como complemento, podes acompanhar Rodrigo Dias:
A revisão deve confirmar o contrato, os casos limite e as dependências, não apenas a formatação. Evita incluir ficheiros gerados ou alterações de outra tarefa no mesmo commit. No .gitignore, build/, .gradle/ e *.class são exclusões habituais; ficheiros já seguidos continuam seguidos mesmo depois de acrescentares uma regra.
Desfazer sem perder a história
git revert <commit> cria um commit que inverte as mudanças de outro. É adequado para um commit já partilhado, porque preserva a história.
git restore regra.txt descarta mudanças desse ficheiro no working tree e recupera o conteúdo do índice. git restore --staged regra.txt retira a mudança do índice sem apagar a edição local. Para ficheiros ainda não seguidos, estas variantes não têm versão anterior para recuperar.
git reset move uma referência e pode alterar índice e ficheiros conforme a opção; --hard descarta mudanças locais. Identifica primeiro o que queres recuperar e onde existe a versão a preservar.
Dependências com Gradle
Um build deve especificar versões e tarefas, para não depender de ficheiros .jar copiados à mão. O Gradle Wrapper, gradlew e gradle/wrapper/, fixa a versão do Gradle do projeto. Guarda-o no Git e usa ./gradlew, ou gradlew.bat no Windows, em vez de uma instalação global diferente.
O projeto completo que usaremos nos testes recorre localmente a JDK 17 para correr Gradle 8.14.3 e compilar para Java 8. A versão de Java que corre a ferramenta pode ser diferente da versão alvo do programa: o Gradle corre num JDK, o código compila para outro alvo. Usa o wrapper e o JDK indicados no projeto da cadeira quando forem diferentes.
sourceCompatibility e targetCompatibility ajustam sintaxe e bytecode, mas não impedem o uso de APIs de versões posteriores. Para impor mesmo o alvo, usa options.release = 8, que configura sintaxe, bytecode e APIs da plataforma em conjunto.
Guarda este build.gradle na raiz:
plugins {
id 'java'
id 'application'
id 'jacoco'
}
repositories {
mavenCentral()
}
tasks.withType(JavaCompile).configureEach {
options.release = 8
}
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.13.4'
testImplementation 'org.mockito:mockito-core:4.11.0'
}
application {
mainClass = 'Main'
}
tasks.named('test') {
useJUnitPlatform()
}
tasks.named('jacocoTestReport') {
dependsOn test
reports {
html.required = true
}
}
implementation seria uma dependência do programa; testImplementation só participa nos testes. testRuntimeOnly fornece código necessário ao executar testes, sem o pôr na compilação do programa. JUnit Jupiter escreve os testes; useJUnitPlatform() seleciona a plataforma JUnit para os executar, com as dependências e o motor Jupiter declarados no build.gradle do projeto.
A estrutura é:
build.gradle
settings.gradle
src/main/java/Main.java
src/test/java/LimiteTest.java
src/main/resources/
src/test/resources/
Em settings.gradle, escreve rootProject.name = 'treino-ldts'. Se ainda não tens wrapper e dispões de Gradle 8.14.3, corre gradle wrapper --gradle-version 8.14.3 uma vez. Num projeto recebido da cadeira, o wrapper já deve existir.
Cria primeiro o programa mínimo, porque run precisa dele. Guarda em src/main/java/Main.java:
public class Main {
public static void main(String[] args) {
System.out.println("treino pronto");
}
}
Só depois corre, por esta ordem:
./gradlew test
./gradlew run
./gradlew jacocoTestReport
test compila e executa os testes; o relatório HTML fica em build/reports/tests/test/index.html. run executa a classe principal configurada em mainClass, que no exemplo da cadeira é Main. O relatório de cobertura fica em build/reports/jacoco/test/html/index.html. Se run falhar com classe principal em falta, confirma que criaste o ficheiro da classe configurada no caminho exato antes de culpar o Gradle.
No IntelliJ, abre a pasta que contém settings.gradle, seleciona o JDK e sincroniza o projeto Gradle. Se falhar, lê a primeira causa indicada no erro e compara JDK, wrapper, repositórios e dependências. Uma falha de descarga e um erro de compilação pedem verificações diferentes.
Exercícios
O que entra no commit?
O ficheiro seguido contém A. Editas para B, fazes
git add regra.txt, editas para C e fazes commit sem outro add.
Que conteúdo fica no novo commit?
Pista
Mais uma pista
Solução
O commit guarda B. C continua como mudança local. Percorre os estados: após o add, índice tem B; após editar para C, ficheiro tem C e índice continua B. O diff normal compara C com B; o diff preparado compara B com A.
- A, a versão anterior ao add. O add substituiu o conteúdo preparado por B.
- B, a versão preparada com add. O commit usa o índice, que continua com B.
- C, a última edição do ficheiro. A edição posterior ficou apenas no working tree.
Erros frequentes
Integrar dois históricos
O histórico tem os commits seguintes. main aponta para E e movimento para D:
A---B---E main
\
C---D movimentoEstás em main e executas git merge movimento, sem --no-ff.
- É possível fazer fast-forward? Justifica pelo grafo.
- Se a integração cria M, quais são os seus pais diretos e que commits ficam alcançáveis a partir de
main? - O que mudaria se
mainainda apontasse para B?
Pista
Solução
E não é antepassado de D: os ramos divergiram depois de B. M tem E e D como pais diretos, e permite alcançar A, B, C, D e E, além do próprio M. Os pais não são todos os antepassados. Se main estivesse em B, poderia avançar para D sem criar M. Nesse caso, main e movimento passariam a apontar para D.
Confere a tua resposta:
- Identifiquei E e D como pais diretos de M.
- Expliquei por que main não pode chegar a D por fast-forward.
- Distingui o caso em que main ainda apontava para B.
Escolher o âmbito da dependência
JUnit só é usado pelos testes. Justifica em que configuração colocarias a dependência e por que o programa não deve precisar dela para arrancar.
Pista
Mais uma pista
Solução
JUnit Jupiter fica em testImplementation porque só src/test/java importa
JUnit. A plataforma e o motor têm de estar disponíveis para executar os
testes. O programa distribuído não precisa de carregar JUnit só porque tem
testes.
Confere a tua resposta:
- Indica testImplementation para compilar os testes.
- Distingue dependências dos testes das do programa.
- Refere o motor/plataforma necessário em runtime dos testes.
Erros frequentes
Uma dependência de runtime dos testes não é necessariamente uma dependência de runtime do programa.

