Controlo de versões com Git

Índice, commits, branches, integração e um build Java reproduzível com Gradle.

TLDR

Perguntar sobre esta página

ChatGPTClaudePerplexityGrokGeminiCopiar e abrirDeepSeekCopiar e abrir

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çãoFicheiroÍndiceCommit
AntesAAA
Editar BBAA
Preparar BBBA
Editar CCBA
Criar commitCBB

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 de movimento, um fast-forward move a referência de main para 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:

Abrir no YouTube

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.

ComandoEfeito
git fetch originObtém objetos e atualiza referências como origin/main, sem integrar no branch atual.
git pull --ff-onlyObtém e integra apenas se for possível avançar sem divergência.
git push -u origin movimentoPublica o branch e configura a referência remota acompanhada.
git log --oneline --graph --allMostra 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:

Abrir no YouTube

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?

A tua resposta

Pista
Distingue a versão no ficheiro da versão no índice.
Mais uma pista
O add copiou B; a edição para C não repetiu essa cópia.
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

Não confundas guardar no editor com preparar no índice.
Voltar à explicação
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  movimento

Estás em main e executas git merge movimento, sem --no-ff.

  1. É possível fazer fast-forward? Justifica pelo grafo.
  2. Se a integração cria M, quais são os seus pais diretos e que commits ficam alcançáveis a partir de main?
  3. O que mudaria se main ainda apontasse para B?

Pista
Um fast-forward exige que a ponta atual seja antepassada da ponta a integrar.
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.
Voltar à explicação
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
Pergunta em que conjunto de fontes aparecem os imports.
Mais uma pista
Os imports do JUnit estão em src/test/java, não em src/main/java.
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.

Voltar à explicação

À tua maneira

Aparência

Modo de cor
Tema
Cor de destaque

Leitura

Fonte de leitura

Álgebra, lógica e uma ideia de cada vez.

Código

Fonte do código

Navegação

Cursor de leitura, sem edição. h, j, k, l movem; / pesquisa nesta página. v seleciona caracteres; V seleciona linhas; y copia. Tab permite destacar ou comentar. Espaço ou Enter abre blocos recolhíveis. Nos cartões, h, j, k, l mudam o foco.

CSS personalizado

Ativa uma sugestão ou guarda o teu CSS. Só muda este navegador.

Usa --page, --text e --accent para acompanhar o tema.

Seletores e recuperação

.site-header, .prose, .course-card, [data-reading-history], [data-playground] e .page-actions.

Abre a aparência com a tecla vírgula ou no rodapé. Se o CSS esconder os controlos, abre esta página sem CSS personalizado.

Pesquisar

Escreve para pesquisar em todo o site.

para escolher · Enter para abrir · Esc para fechar

Copiar fórmula

A cópia automática não está disponível. Copia o LaTeX abaixo.

Pesquisar nesta página

Enter procura · n / N repetem · Esc cancela. Ctrl / Cmd K pesquisa todo o site.

Atalhos de teclado

Clica numa tecla para a mudar. Esc cancela. Backspace desativa.

PesquisarCtrl / Cmd K

Os atalhos não interferem enquanto escreves. Tab e Enter funcionam sempre.