Sistemas de Controle de Versão

Com a advento da editoração eletrônica, esse tipo de problema cresceu sobremaneira. A facilidade de apagar um arquivo no computador, seja intencionalmente ou não, é enorme. Algumas vezes, como no caso dos papéis jogados no lixo, é possível recuperá-los, mas na maioria delas temos que recomeçar do zero.

Quando estamos criando alguma coisa, escrevendo um livro ou um programa de computador, por exemplo, vamos constantemente modificando o texto ou o código e, não raro, lamentamos ter sobrescrito algum trecho que gostaríamos de trazer de volta.

Quem já não passou por esse tipo de situação?

Pois é exatamente para tentar resolver, ou ao menos amenizar, esse problema, que surgiram os sistemas de controle de versão (VCS, do inglês Version Control System), também conhecidos como gerenciamento de código fonte (SCM, do inglês Source Code Management), ou simplesmente versionamento, em português.

Sistema de Controle de Versão

O contrôle de versão é um sistema que registra as mudanças num arquivo ou conjunto de arquivos ao longo do tempo de forma que se possa recuperar versões específicas futuramente.

Trataremos neste artigo do controle de versão para desenvolvimento de software, embora ele possa ser usado para quase todo o tipo de arquivo que existe num computador.

Na verdade, os VCS foram desenvolvidos exatamente para controlar o desenvolvimento de software, permitindo não apenas a reversão de um projeto para um estado anterior, mas também para comparar modificações ao longo do tempo, descobrir quem introduziu determinada mudança que esteja causando problemas, quem apresentou uma sugestão ou reportou um bug, e muito mais.

O Diferencial do Git

A Wikipédia lista entre as soluções de código aberto mais comuns: CVS, Mercurial, Git e SVN; e entre as comerciais, destaca: SourceSafe, TFS, PVCS (Serena) e ClearCase.

A primeira pergunta que surge, então, é: o que torna o Git diferente dos outros VCS e qual a razão da sua crescente popularidade?

Certamente a diferença mais óbvia é que o Git é um sistema distribuído (ao contrário do SVN ou TFS, por exemplo). Isso significa que você possui um repositório local que fica numa pasta especial chamada .git e normalmente (mas não obrigatoriamente), tem um repositório remoto central onde diferentes colaboradores podem contribuir para o desenvolvimento do código. Observe que cada um desses colaboradores possui um clone exato do repositório em suas estações de trabalho locais.

Na documentação do Git há uma figura, reproduzida abaixo, que ilustra perfeitamente o controle de versão baseado num sistema distribuído.

Dqd0Kr2
Figura 1: Sistema de Contrôle de Versão Distribuído

Como podemos ver, nos sistemas distribuídos, os clientes não apenas verificam o último snapshot (instantâneo) dos arquivos, mas espelham o repositório inteiro. Uma enorme vantagem dessa solução é que, se porventura o servidor perder os dados por algum problema técnico, eles podem ser recuperados de qualquer um dos clientes que estejam colaborando no projeto, pois cada clone possui um backup completo dos dados.

Quem quiser se aprofundar mais no funcionamento cliente/servidor dos sistemas de controle de versão, poderá tomar a referência mencionada da Wikipédia como ponto de partida.

A seguir, numa adaptação do excelente artigo de Juri Strumpflohner, publicado no seu blog em abril de 2013, vamos analisar o funcionamento do Git observando o repositório Git sob o ponto de vista das árvores que ele constrói. Para isso, utilizaremos algumas funcionalidades comuns, tais como:

  • acrescentar/modificar um arquivo novo;

  • criar e mesclar uma branch (ramificação) com e sem conflitos de mesclagem;

  • ver o histórico/changelog;

  • executar um rollback até determinado commit e

  • compartilhar/sincronizar o código num repositório remoto central.

A próxima figura, editada a partir da original contida na documentação oficial do Git, ilustra com clareza as três fases principais por que passa um arquivo num diretório controlado pelo Git:

73ceAys
Figura 2: Diretório de trabalho, área de estágio e diretório Git.

Terminologia Básica

Vamos começar examinando alguns termos usados no Git que são fundamentais:

  • master — a branch principal do repositório. Normalmente é onde as pessoas trabalham e na qual a integração acontece.

  • clone — copia um repositório git existente, usualmente de alguma localização remota, para o seu ambiente local.

  • commit — envio de arquivos para o repositório local.

  • fetch ou pull — pega as últimas atualizações de um repositório remoto. A diferença entre fetch e pull é que pull combina as duas coisas: pega as atualizações de código do repositório remoto e efetua sua mesclagem com o repositório local.

  • push — é usado para enviar arquivos para um repositório remoto.

  • remote — são os locais remotos do seu repositório, normalmente em algum servidor central, como o GitHub.

  • SHA — cada commit ou node na árvore Git é identificado por uma chave SHA única. Pode-se utilizá-las em vários comandos para manipular um node específico.

  • head — é uma referência a um node para o qual nosso espaço de trabalho no repositório aponta no momento.

  • branch — uma branch no Git nada mais é do que um rótulo particular em determinado node.

Configuração Elementar

Não vou entrar em detalhes aqui sobre a instalação do Git, pois isso vai depender do sistema operacional de cada um e, além disso, já existem instruções detalhadas para isso na documentação oficial do Git, com tradução em português.

Usarei a sintaxe da linha de comando dos sistemas baseados em Unix, tais como o Linux e o OS X. No Windows, além da interface gráfica, há um programa para emular um terminal tipo Unix, de forma que este tutorial poderá ser seguido sem problemas, seja qual for seu sistema operacional. O símbolo $ representa o prompt da linha de comando e não deve ser digitado.

Pressupondo, então, que o Git já está instalado no seu computador e o atalho correspondente ao diretório de sua instalação foi devidamente incluído na variável PATH do seu sistema, a primeira coisa a ser feita é configurá-lo com seu nome e endereço de email. Vamos aproveitar também para configurar um editor-padrão — vou escolher o vim, mas esteja à vontade para personalizar esta seleção de acordo com o seu gosto pessoal. Para isso, entre os seguintes comandos, com as devidas substituições pessoais:

$ git config --global user.name "J A Gaeta Mendes"
$ git config --global user.email "meu_email@example.com"
$ git config --global core.editor vim

As configurações podem ser conferidas com o comando git config --list, cujo resultado, após os comandos acima, será:

$ git config --list
user.name=J A Gaeta Mendes
user.email=meu_email@example.com
core.editor=vim

Criação de um novo repositório Git

Antes de prosseguir, vamos criar um diretório onde pretendemos instalar um novo repositório Git e entrar neste diretório, com os comandos:

$ mkdir meurepogit
$ cd meurepogit

Em seguida, vamos inicializar nosso novo repositório git:

$ git init
Initialized empty Git repository in /home/gaeta/meurepogit/.git/

Use o comando git status para verificar o estado atual do repositório:

$ git status
On branch master

Initial commit

nothing to commit (create/copy files and use "git add" to track)

Criação e commit de um novo arquivo

O próximo passo é criar e adicionar algum conteúdo ao repositório. Vamos criar um arquivo de uma forma bem simples, com os comandos:

$ touch ola.txt
$ echo Ola, mundo! > ola.txt

Uma nova verificação do estado do repositório vai apresentar agora a seguinte situação:

$ git status
On branch master

Initial commit

Untracked files:
  (use "git add <file>..." to include in what will be committed)

	ola.txt

nothing added to commit but untracked files present (use "git add" to track)

Antes de fazer um commit, precisamos registrar o arquivo, o que fazemos com o comando add:

$ git add ola.txt

Mais uma verificação do estado:

$ git status
On branch master

Initial commit

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)

	new file:   ola.txt

Podemos agora, finalmente, fazer seu commit para o repositório:

$ git commit -m "Acrescenta meu primeiro arquivo"
[master (root-commit) 3a40877] Acrescenta meu primeiro arquivo
 1 file changed, 1 insertion(+)
 create mode 100644 ola.txt
É uma prática comum indicar “presença” nas mensagens de commit. Assim, ao invés de escrever “acrescentado meu primeiro arquivo”, escrevemos “acrescenta meu primeiro arquivo”.

Se examinarmos neste instante a árvore do repositório, teríamos a seguinte situação:

dKj4HK4

Há um node para o qual o "rótulo" master aponta.

Acrescentando outro arquivo

Vamos agora adicionar um outro arquivo ao repositório:

$ echo "Oi, sou um outro arquivo" > outro_arquivo.txt
$ git add .
$ git commit -m "acrescenta outro arquivo com outro conteúdo"
[master ad5c381] acrescenta outro arquivo com outro conteúdo
 1 file changed, 1 insertion(+)
 create mode 100644 outro_arquivo.txt

Observe que agora usamos git add ., que acrescenta todos os arquivos do diretório corrente (.). Do ponto de vista da árvore temos agora outro node e master moveu-se para ele.

meAuxz4

Criando uma ramificação (de recursos)

Ramificação (branching) e mesclagem (merging) são as duas coisas que tornam o Git tão poderoso, e é para isso que ele foi otimizado, em se tratando de um sistema de controle de versão distribuído (VCS). De fato, as ramificações de recursos (feature branches) são criadas para cada novo tipo de funcionalidade que você acrescentar ao seu sistema e são normalmente apagadas mais tarde, depois que o recurso tenha sido mesclado outra vez na ramificação principal de integração (usualmente a ramificação master). A grande vantagem disso é que você pode experimentar a nova funcionalidade num “playground” separado e isolado, movendo-se rapidamente para frente ou para trás da ramificação “master” original quando necessário. Ademais, ela poderá ser facilmente descartada outra vez (no caso de não ser mais necessária), bastando eliminar a ramificação do recurso. Há um ótimo artigo para entender as ramificações no Git, que você definitivamente deve ler.

Mas vamos começar. Primeiro de tudo, criamos uma nova ramificação de recurso:

$ git branch minha-feature-branch

Executando o comando:

$ git branch
* master
  minha-feature-branch

obtemos uma lista das ramificações. O * na frente de master indica que no momento estamos naquele ramo. Vamos, a invés disso, mudar para o ramo minha-feature-branch.

$ git checkout minha-feature-branch
Switched to branch 'minha-feature-branch'

Listando novamente as ramificações:

$ git branch
  master
* minha-feature-branch
Pode-se usar o comando direto git checkout -b minha-feature-branch para criar e ativar uma nova ramificação num único passo.

A diferença de outros VCS é que há apenas um diretório de trabalho. Todas as ramificações ficam nele e não há uma pasta separada para cada ramificação criada. Ao invés disso, quando se alterna entre as ramificações, o Git atualiza o conteúdo do diretório de trabalho para refletir aquele da ramificação para a qual se está mudando.

Vamos modificar um dos nossos arquivos existentes:

$ echo "Oi" >> ola.txt
$ cat ola.txt
Ola, mundo!
Oi

…​e então vamos fazer um commit dele para nossa nova ramificação:

$ git commit -a -m "modifica arquivo acrescentando oi"
[minha-feature-branch 8cff170] modifica arquivo acrescentando oi
 1 file changed, 1 insertion(+)
Dessa vez usamos o comando git commit -a -m para fazer o add e commit da modificação num único passo. Isso funciona apenas nos arquivos que já tenham sido adicionados previamente ao repositório git. Arquivos novos não podem ser adicionados dessa maneira e precisam ser inseridos com o comando git add como visto antes.

Como ficou nossa árvore?

rp32PbY

Até aqui tudo parece bem normal e ainda temos uma linha reta na árvore, mas observe que agora o master continuou onde estava e movemos adiante minha-feature-branch.

Vamos trocar para master e modificar o mesmo arquivo lá também.

$ git checkout master
Switched to branch 'master'

Como era de se esperar, alo.txt não foi modificado:

$ cat ola.txt
Ola, mundo!

Vamos modificá-lo e dar um commit no master também (isso vai gerar um belo conflito mais tarde):

$ echo "Oi, fui modificado no master" >> ola.txt
$ git commit -a -m "acrescenta linha em ola.txt"
[master 33b65ea] acrescenta linha em ola.txt
 1 file changed, 1 insertion(+)

Nossa árvore agora visualiza a ramificação:

LObvuwo

Mesclando e resolvendo conflitos

O próximo passo será reintegrar nossa feature branch de volta em master. Isso é feito usando o comando merge :

$ git merge minha-feature-branch
Auto-merging ola.txt
CONFLICT (content): Merge conflict in ola.txt
Automatic merge failed; fix conflicts and then commit the result.

Como esperado, temos um conflito de mesclagem em ola.txt.

$ cat ola.txt
Ola, mundo!
<<<<<<< HEAD
Oi, fui modificado no master
=======
Oi
>>>>>>> minha-feature-branch

Vamos consertar isso:

Ola, mundo!
Oi, fui modificado no master
Oi

…​e fazer o commit novamente:

$ git commit -a -m "resolve conflitos de merge"
[master 838d26a] resolve conflitos de merge

A árvore reflete nosso merge:

2gMboCl

Saltando para um determinado Commit

Suponhamos que queremos saltar de volta a um dado commit. Podemos usar o comando git log para obter todos os identificadores SHA que identificam de forma única cada node da árvore.

Escolha um dos identificadores (mesmo que não seja o número completo, pouco importa) e pule para aquele node usando o comando checkout:

$ git checkout 33b65ea
Note: checking out '33b65ea'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b new_branch_name

HEAD is now at 33b65ea... acrescenta linha em ola.txt

Observe o comentário exibido pelo git. O que ele significa? Detached head significa que a head não está mais apontando para um rótulo de branch, mas, ao invés disso, para um commit específico da árvore.

Pense na HEAD como a “branch atual”. Quando você troca de branches com git checkout, a revisão HEAD muda para apontar a dica da nova branch.(…​) É possível para a HEAD referir-se a uma revisão específica que não esteja associada com um nome de branch. Esta situação é chamada detached HEAD. (Postagem no Stackoverflow)

Basicamente, quando eu agora modifico ola.txt e faço commit da mudança, a árvore se parece com o seguinte:

CFkTxem

Como você pode ver, o node recem-criado não possui nenhum rótulo. A única referência que atualmente aponta para ele é a head. Todavia, se trocarmos para master novamente, o commit anterior será perdido, pois não teremos como saltar de volta para aquele node.

$ git checkout master
Warning: you are leaving 1 commit behind, not connected to
any of your branches:

  576bcb8 change file undoing previous changes

If you want to keep them by creating a new branch, this may be a good time
to do so with:

 git branch new_branch_name 576bcb8239e0ef49d3a6d5a227ff2d1eb73eee55

Switched to branch 'master'

Como se observa, o git nos alerta desse fato.

Rollback

Saltar para trás é bom, mas e se quisermos desfazer tudo de volta ao estado antes do merge da feature branch? Muito fácil:

$ git reset --hard 33b65ea
HEAD is now at 33b65ea acrescenta linha em ola.txt
LObvuwo

A sintaxe genérica aqui é git reset --hard <tag/branch/commit id>.

Usando “revert” para fazer rollback das mudanças do jeito fácil

Se você precisar fazer o rollback de um commit inteiro e (o que é pior) você já sincronizou com um repositório remoto, então usar git reset --hard pode não ser adequado, uma vez que dessa forma você estaria reescrevendo a história, o que não é permitido se a sincronização com o servidor remoto já foi efetuada.

Em tais situações pode-se usar o comando revert, o qual basicamente cria um novo commit desfazendo todas as mudanças de um determinado commit que for especificado. Considere, por exemplo, que você queira fazer o rollback de um commit com o ID 41b8684:

$ git revert 41b8684

Desfazendo mudanças quando não foi feito Commit

Outro cenário comum no terreno de “desfazer coisas” é simplesmente descartar mudanças locais quando ainda não foi feito o commit.

Arquivos não estagiados para um Commit

Vamos pressupor que você tenha modificado um arquivo. A execução do comando git status resultaria:

$ git status
On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

	modified:   ola.txt

no changes added to commit (use "git add" and/or "git commit -a")

Até aqui nada foi adicionado ao seu repositório Git, nem foi estagiado (registrado) para fazer commit. O que significa descartar aquelas mudanças? Pense na árvore Git. Basta pegar (checkout) a última versão daquele arquivo, certo?

Então:

$ git checkout ola.txt

desfaz a mudança, como o comprova um novo git status:

$ git status
On branch master
nothing to commit, working directory clean

Arquivos estagiados para um Commit

A outra situação é quando você modificou o arquivo e já o estagiou para dar um commit através do comando git add.

$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

	modified:   ola.txt

O comando git checkout não teria nenhum efeito neste caso, mas, ao invés disso, (se você leu o que o git escreveu na saída do parâmetro de status) temos que fazer um reset. Por que? Porque o comando git add já criou um node na árvore Git (na verdade isso não é 100% correto: veja Git index vs. working tree para mais detalhes) que, todavia, ainda não foi objeto de um commit. Assim sendo, precisamos resetar o ponteiro corrente para HEAD que é o topo da nossa branch corrente.

$ git reset HEAD ola.txt
Unstaged changes after reset:
M	ola.txt

e consequentemente:

$ git status
On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

	modified:   ola.txt

no changes added to commit (use "git add" and/or "git commit -a")

Estamos novamente no estado em que temos mudanças locais ainda não estagiadas para um commit e podemos então usar o comando checkout para descartá-las. Uma maneira rápida de fazer isso é usar:

$ git reset --hard HEAD

juntando num único comando a retirada de estágio e checkout das mudanças.

Compartilhando/Sincronizando seu Repositório

Ao fim e ao cabo, vamos querer compartilhar nosso código, normalmente sincronizando-o com um repositório central. Para fazer isso, temos que adicionar um remote.

$ git remote add origin git@github.com:ja-gaeta/exemplo.git

Para verificar se obteve êxito, basta digitar:

$ git remote -v

o qual lista todos os remotes. Agora é preciso publicar nossa branch master local para o repositório remoto. Isso é feito da seguinte forma:

$ git push -u origin master

E terminamos.

Uma coisa realmente poderosa é que pode-se acrescentar repositórios remotos múltiplos. Isso é usado frequentemente em combinação com soluções de hospedagem em nuvem para distribuição do código no servidor. Por exemplo, você pode acrescentar um remoto chamado deploy que aponta para algum servidor de repositório hospedado na nuvem, tal como:

$ git remote add deploy git@somecloudserver.com:ja-gaeta/meuprojeto

e então, sempre que você quiser publicar sua branch, basta executar:

$ git push deploy

Clonagem

Tudo funciona da mesma forma se você pretende iniciar a partir de um repositório remoto já existente. O primeiro passo é fazer um checkout do código-fonte, o que é chamado clonagem (cloning) na terminologia do Git. Deve-se, então, fazer algo assim:

$ git clone git@github.com:ja-gaeta/exemplo.git
Cloning into 'exemplo'...
remote: Counting objects: 430, done.
remote: Compressing objects: 100% (293/293), done.
remote: Total 430 (delta 184), reused 363 (delta 128)
Receiving objects: 100% (430/430), 419.70 KiB | 102 KiB/s, done.
Resolving deltas: 100% (184/184), done.

Isso vai criar uma pasta (neste caso) chamada “exemplo” e se entrarmos nela:

$ cd exemplo/

e verificarmos os repositórios remotos, constatamos que a informação para seu rastreamento já está configurada:

$ git remote -v
origin  git@github.com:juristr/intro.js.git (fetch)
origin  git@github.com:juristr/intro.js.git (push)

Podemos começar agora o ciclo commit/branch/push normalmente.


comments powered by Disqus