DevOps (and of course DevSecOps) are getting more and more adopted by companies: these methodologies rely on several frameworks and software skills, and working with a modern Source Code Management (SCM) such any kind of software implementing Git is certainly a must for every DevOps professional. This post is the first of a set of posts dedicated to Git and is aimed at providing a GIT Tutorial - A thorough Version Control with Git Howto on personal repositories, giving guidelines to proficient operating with it.

This post explains everything it is needed to professionally operate Version Control with GIT, it provides examples on how to deal in the most common situations you can face in the most straightforward and simple way. Anyway please mind that as usual in the IT field, even with Git there is often more than a way to do things.

Acquainting To Git

Git is a modern SCM tool that superseded Subversion (SVN) and the very old Concurrent Versions System (CVS): it is far more efficient and resilient, and one of its most appreciated features is certainly that it is a Distributed Version Control System.

It has been designed by Linus Torvalds himself, inspired by BitKeeper, ... this is one of the most interesting pages of the free software history: Linux Torvalds tought that, because of the growth of the project, the VCS that they used to develop the Linux kernel was not more suitable. He searched a lot and found that BitKeeper was the only suitable VCS - actually Larry Mc Voy, the creator of BitKeeper, specifically designed it in the hope to meet Torvalds's needs. In addition to that, since they also offered it for free to open-source projects, the kernel developers adopted it in 2002. They used it for almost three years, until Andrew Tridgell - the father of Samba - started to write an open source client of BitKeeper. There were other "incidents” with the open source community in the past, but this apparently was the last straw: BitMover rescinded the offer of free usage of BitKeeper for open source projects. Despite Torvalds's lashed out at Tridgell after this incident, this was not really completely bad for the open source world, since this pushed Torvalds to design and develop a great open source VCS: Git.

Simply put, working with GIT more or less consists into the following list of actions:

  • pull the most current copy from the remote repository
  • merge the pulled copy with the local one
  • optionally check the status of files in the local repository
  • add the local files modified since the last commit to the versioned files list 
  • commit the modify
  • push to the shared repository

These result in a list of commands such as:

git pull
git status
git add --all
git commit -m "a meaningful description of these modifications"
git push

The above list of commands covers the very basic usage of Git.

Mind that GIT is a Source Code Management: this means that it has been specifically designed to handle source files - that actually are text files. Putting binary files in GIT repositories is definitely wrong, since they cannot take advantage of the diff capabilities of Git: each time they get modified a new version of the file with totally different contents is generated. This obviously leads to a quick growth of the size of the repository, with a lot of side effects that do not impact on the performance of the initial clone only, but also on every subsequent push and pull. Even base64 encoding the binary files and then adding them to the repository is obviously a wrong way of doing.

Git Concepts

A Git repository is a directory tree whose contents are under the control of the GIT version management system.

Areas

This repository has three areas:

Working Area

in this area there are:

  • new contents
  • contents modified since the last commit or since they have been put into the Staging Area
  • contents deleted since the last commit or since they have been put into the Staging Area

You can see its contents by typing:

git status

they are listed beneath Untracked files.

Staging Area ( sometimes called Index )

in this area there are contents that were on the Working Area and that have been explicitly set to be tracked using the "git add" command.

Simply put, the Staging Area is the area where there are the contents ready to be committed.

You can see this contents by typing:

git status

they are listed beneath Changes to be committed.

Repository Area 

in this area there are only contents tracked by Git that have already been committed: committing is actually the action of permanently storing contents into the Repository Area. At each commit Git computes the hash of the modified contents and uses it as an identifier to locate it inside the log of the changes.

Note that the GIT project has decided to replace SHA1 with SHA256, although the current version is still using  SHA1.

You can see the log of the commits of the  current checked-out branch by typing:

git log

Branches

A branch basically is a name used as an identifier to a list of one or more hashes: whenever you initialize a new GIT repository a default branch, called master for historical reasons, is automatically created.

At the first commit, Git creates a file with the name of the branch beneath the ".git/refs/heads" directory, and use it to store the hash of the committed contents: each time you commit something, the contents of .git/refs/heads/master is replaced with the new hash. So, when using the legacy name "master" for the default branch, it creates the .git/refs/heads/master file.

Every command you give applies to the current branch, that at creation time is the "master" branch. Mind that the term "master" came from Bitkeeper, who referred to the always true copy of the repository as the "master repository" and  to the other copies as "slave repositories". Anyway, since the community asked for a more descriptive name, Git developers since the 2.28 version added the "init.defaultBranch" setting to enable to specify a name different from "master". Some Git providers, such as GitHub, have already started to call "main" the legacy "master" branch.
Mind that branches are bound to the Repository Area only: this means that despite when switching branches the files of the Repository Area get extracted into the directory tree, the contents of the Working Area and of the Staging Area remain unchanged. This can cause a mess if you are not aware of it and how to cope with it. We will see this in detail later on in this post when talking about stashing.

TAGS

Tags are names used to conveniently identify one and only one hash: their purpose is to provide a human-readable name to refer to a hash so as to ease checking out contents of a given point of the story.

Install Git

Install Git is really simple: if you are using a dnf based Linux distribution, such as Red Hat Enterprise Linux 8 or above, just by type:

sudo dnf install -y git

otherwise, if you are using an older Red Hat Enterprise Linux version use yum as follows:

sudo yum install -y git

Configure Git

Git stores its global per user configuration beneath the ~.gitconfig file: this is an ini file structured using multiple stanzas: you can edit it directly with an editor or type "git config" statements that store the settings into it – the advantage of using this last method is that the command line validates the syntax, avoiding to store non-existent directives inside the configuration file.

The initial thing every user must do is set up his name and surname along with his email: these are used to identify him as the committer of each modification he does to any file in the repository.

For example, to set them up issue:

git config --global user.name 'Marco Carcano'
git config --global user.email mcarcano@carcano.local

Please note the --global parameter: this marks these settings as global, that means that they are stored into the ~.gitconfig file and so they are applied to any repository you will work on.

If you are not in a production environment (or more precisely, if you do not have security concerns) and the certificate of the GIT repository is self-signed, you can configure GIT not to verify the certificate - just type:

git config --global sslVerify = false
Take in account that the best option is always to store the certificate of the Certificate Authority that signed the certificate of the GIT server into the system wide certificate trust-store.

Another handy setting is defining the default push method - my personal advice is to set it to "simple":

git config --global push.default simple

The behavior for “simple” value is to push the current branch to the corresponding upstream branch (that by the way is a best practice too). Anyway, mind that as a safety measure, the push is aborted if the upstream branch does not have the same name as the local one.

Working With Git Repositories

There are only two possible scenarios when working with Git: create a new repository or work with an existing one - that is called "clone" an existing Git repository.

Creating A New Repository

The statement to create either a personal or a shared repository is "git init" followed by the name of the repository you want to create.

Personal Git Repository

Creating an empty git repository is as easy as typing "git init" followed by the name or the repository. For example, to create the "waldo" empty repository, just type:

git init waldo

sometimes instead you have existing data that you want to version control using Git. As an example, let's create the "foo" directory and the "Readme.md" file inside it:

mkdir ~/foo
cd ~/foo
echo "# README" > Readme.md

we can easily "turn" this directory along with its contents into a Git repository by typing the following command inside the "foo" directory itself:

git init

when creating the repository, git creates the .git directory to store its own metadata inside the directory that must be the root of the directory tree of versioned files.

We can now add the "Readme.md" file to the Staging Area:

git add Readme.md

and eventually commit the contents of the Staging Area into the Repository Area specifying "initial commit" as the commit message:

git commit -m "initial commit"

Shared (bare) Git Repository (Remote)

The straightforward limitation of a personal Git repository is that you cannot share it with other people. To overcome this, you can create a shared bare repository on a server that can be accessed also from other users. Once created, you can either clone it only or link it as the remote of one or more personal Git repositories.

In this example, we set up a shared Git repository that can be accessed using SSH. The very first thing to do is logging-in as a sudo enabled user to the server that will hosts the repository ("scm.carcano.local" in this example) and create the account for the git user:

sudo adduser git
sudo passwd git

Now connect to any of the client hosts you want to use - if the user you are logged as on the client doesn't have an SSH key-pair, please generate it using the "ssh-keygen" command line utility.

Now authorize the SSH key of the current user to login as "git" user to the "scm.carcano.local" server as follows:

ssh-copy-id git@scm.carcano.local

of course accept the fingerprint of the host key if necessary - it is enough to type "yes", then type the password you have just assigned to the "git" user.

The command is "git init --bare --shared=true" followed by the name of the repository you want to create.

Now that the "git" user has been created, and our SSH key has been authorized, we are finally ready to create the shared Git project.

Mind that a shared Git repository must be a "bare" repository: this means that it does not have the Working Area nor the Staging Area. It just has the Repository Area, so the metadata that is usually stored within the ".git" subdirectory of a personal repository are instead stored within the root directory of the repository.

In this example we create the "foo.git" Git repository executing the statement as a remote SSH command:

ssh git@scm.carcano.local git init --bare --shared=true foo.git

the outcome is as follows:

hint: Using 'master' as the name for the initial branch. This default branch name
hint: is subject to change. To configure the initial branch name to use in all
hint: of your new repositories, which will suppress this warning, call:
hint: 
hint: git config --global init.defaultBranch <name>
hint: 
hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and
hint: 'development'. The just-created branch can be renamed via this command:
hint: 
hint: git branch -m <name>
Initialized empty shared Git repository in /home/git/foo.git/

as the messages suggest, the new repository is obviously empty.

Linking The  Personal Git Repository To A Shared Remote

So far we have:

  • a shared remote bare Git repository on the "scm.carcano.local" server
  • a personal Git repository on the local client

The next step is to link the personal Git repository to the shared remote one.

Let's change directory to the "foo" local repository:

cd ~/foo

we can now link this personal Git repository to the remote shared Git repository we have just created; this is achieved by configuring a named link (the standard name is "origin") to the remote shared Git repository. Just type the following command:

git remote add origin ssh://git@scm.carcano.local:/home/git/foo.git

Set The Upstream Of The Current Branch

We must now set a default upstream link for each of the branches of the local repository. We can list the available local branches typing:

git branch

the outcome is as follows:

* master

so we have just one branch, called "master". We can now set the link identified by the name "origin" as the default upstream remote repository for the "master" local branch:

git push -u origin master

the outcome is as follows:

Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Writing objects: 100% (3/3), 220 bytes | 220.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
To ssh://localhost:/home/git/foo.git
* [new branch] master -> master
Branch 'master' set up to track remote branch 'master' from 'origin'.

the command, besides configuring the upstream link, also performs the initial push. Git by default pushes a local branch to a branch with the same name on the remote shared repository. This means that now the "master" branch of the local personal Git repository and "master" branch of the remote shared repository have the same contents.

Please mind that this is only a very basic example, shown only to let you easily figure out the overall picture of how Git does work. This example lacks  production grade features such as a way to grant to the caller user access only to specific repositories. Despite there are softwares, such as Gitolite, that enable you to overcome this limitation, for a production environment it is mandatory to use something more modern, such as Gitea. So please don't use this procedure to create production Git repositories.

Cloning An Existing Repository

As we saw, the mandatory condition to operate with an existing shared repository is to create a clone of it:  the statement to use is "git clone" followed by

  • the URL of the git repository you want to clone
  • optionally, the name of the directory you want to use as the destination of the clone

There are several kinds of protocols that can be used to give access to the repository (provided the server hosting it supports them), for example:

file://

URL of a repository stored into the local filesystem - it is the default protocol

ssh://

URL of a repository stored on the filesystem of a server that can be reached via SSH

http:// or https://

URL of a repository that can be reached by using HTTP or HTTPS protocols

As an example, remove the "foo" personal repository we created on the client host:

rm - rf foo

we can now get it back simply by cloning it:

git clone git@scm.carcano.local:/home/git/foo.git ~/foo
When cloning a remote Git repository, its metadata (such as the list of remote branches and tags) are fetched as a whole, but only the contents of the default branch (often, but not always called "master") are copied.

Version Control Operations

Now we have a personal local repository linked to a remote shared bare repository: this means that we can now start doing something.

Populating The Working Area

This is the most straightforward thing to do: just add files and directories to the current personal Git repository, either creating or copying them from elsewhere.

For example:

cd ~/foo
mkdir -p bar/baz
touch xyzzy.txt bar/baz/qux.txt bar/baz/plugh.txt
mkdir fred
cp -dR /usr/share/doc/git/howto .

now that we have added some contents, we can see how they are seen by the Git repository perspective:

git status

the outcome is as follows:

On branch master
Your branch is up to date with 'origin/master'.

Untracked files:
  (use "git add ..." to include in what will be committed)
	bar/
	howto/
        xyzzy.txt

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

as expected the "bar" and "howto" directories are listed as ''Untracked files" - and so they are correctly in the Working Area.

You may wonder why you don-t see the "fred" directory too in the list: this is because the directory does not contain any files, so by the content versioning perspective there's no content.This means that empty directory trees are never added to a Git repository: if Git cannot see them in the Working Area it won't be able to stage neither commit them.

Staging Modified Contents

Let's now add the contents to the Staging Area.

The command to use is "git add" followed by the path of the contents we want to stage, so for example:

git add bar/baz/qux.txt

stages just the "bar/baz/qux.txt" file: if we run "git status" we get the following outcome indeed:

On branch master
Your branch is up to date with 'origin/master'.

Changes to be committed:
  (use "git restore --staged ..." to unstage)
	new file:   bar/baz/qux.txt

Untracked files:
  (use "git add ..." to include in what will be committed)
	bar/baz/plugh.txt
	howto/
        xyzzy.txt

you can of course stage a whole directory tree - for example, to stage the whole "howto" directory tree:

git add howto

if we run "git status" this time the outcome is as follows:

On branch master
Your branch is up to date with 'origin/master'.

Changes to be committed:
  (use "git restore --staged ..." to unstage)
	new file:   bar/baz/qux.txt
	new file:   howto/keep-canonical-history-correct.html
	new file:   howto/keep-canonical-history-correct.txt
	new file:   howto/maintain-git.html
	new file:   howto/maintain-git.txt
	new file:   howto/new-command.html
	new file:   howto/new-command.txt
	new file:   howto/rebase-from-internal-branch.html
	new file:   howto/rebase-from-internal-branch.txt
	new file:   howto/rebuild-from-update-hook.html
	new file:   howto/rebuild-from-update-hook.txt
	new file:   howto/recover-corrupted-blob-object.html
	new file:   howto/recover-corrupted-blob-object.txt
	new file:   howto/recover-corrupted-object-harder.html
	new file:   howto/recover-corrupted-object-harder.txt
	new file:   howto/revert-a-faulty-merge.html
	new file:   howto/revert-a-faulty-merge.txt
	new file:   howto/revert-branch-rebase.html
	new file:   howto/revert-branch-rebase.txt
	new file:   howto/separating-topic-branches.html
	new file:   howto/separating-topic-branches.txt
	new file:   howto/setup-git-server-over-http.html
	new file:   howto/setup-git-server-over-http.txt
	new file:   howto/update-hook-example.html
	new file:   howto/update-hook-example.txt
	new file:   howto/use-git-daemon.html
	new file:   howto/use-git-daemon.txt
	new file:   howto/using-merge-subtree.html
	new file:   howto/using-merge-subtree.txt
	new file:   howto/using-signed-tag-in-pull-request.html
	new file:   howto/using-signed-tag-in-pull-request.txt

Untracked files:
  (use "git add ..." to include in what will be committed)
	bar/baz/plugh.txt
        xyzzy.txt

as you see not there are only "xyzzy.txt" and "bar/baz/plugh.txt" files still in the Working Area

We can stage all the files that are in the Working Area as a whole simply specify the "--all" parameter.

So, to add the remaining files just type:

git add --all

if we run "git status" again:

On branch master
Your branch is up to date with 'origin/master'.

Changes to be committed:
  (use "git restore --staged ..." to unstage)
	new file:   bar/baz/plugh.txt
	new file:   bar/baz/qux.txt
	new file:   xyzzy.txt
	new file:   howto/keep-canonical-history-correct.html
	new file:   howto/keep-canonical-history-correct.txt
	new file:   howto/maintain-git.html
	new file:   howto/maintain-git.txt
	new file:   howto/new-command.html
	new file:   howto/new-command.txt
	new file:   howto/rebase-from-internal-branch.html
	new file:   howto/rebase-from-internal-branch.txt
	new file:   howto/rebuild-from-update-hook.html
	new file:   howto/rebuild-from-update-hook.txt
	new file:   howto/recover-corrupted-blob-object.html
	new file:   howto/recover-corrupted-blob-object.txt
	new file:   howto/recover-corrupted-object-harder.html
	new file:   howto/recover-corrupted-object-harder.txt
	new file:   howto/revert-a-faulty-merge.html
	new file:   howto/revert-a-faulty-merge.txt
	new file:   howto/revert-branch-rebase.html
	new file:   howto/revert-branch-rebase.txt
	new file:   howto/separating-topic-branches.html
	new file:   howto/separating-topic-branches.txt
	new file:   howto/setup-git-server-over-http.html
	new file:   howto/setup-git-server-over-http.txt
	new file:   howto/update-hook-example.html
	new file:   howto/update-hook-example.txt
	new file:   howto/use-git-daemon.html
	new file:   howto/use-git-daemon.txt
	new file:   howto/using-merge-subtree.html
	new file:   howto/using-merge-subtree.txt
	new file:   howto/using-signed-tag-in-pull-request.html
	new file:   howto/using-signed-tag-in-pull-request.txt

this time the Working Area is empty, and all files are now in the Staging Area.

Mind that we are staging the current version of the contents: this means that if we change anything after staging something we will find two different versions of these contents together: the one in the Staging Area and the new contents in the Working Area.

As an example, let's modify the contents of the "xyzzy.txt" file:

echo '"Xyzzy" is a magic word that teleports the player between two locations ("inside building" and the "debris room").' > xyzzy.txt

let's run "git status" once again:

On branch master
Your branch is up to date with 'origin/master'.

Changes to be committed:
  (use "git restore --staged ..." to unstage)
	new file:   bar/baz/plugh.txt
	new file:   bar/baz/qux.txt
	new file:   howto/keep-canonical-history-correct.html
	new file:   howto/keep-canonical-history-correct.txt
	new file:   howto/maintain-git.html
	new file:   howto/maintain-git.txt
	new file:   howto/new-command.html
	new file:   howto/new-command.txt
	new file:   howto/rebase-from-internal-branch.html
	new file:   howto/rebase-from-internal-branch.txt
	new file:   howto/rebuild-from-update-hook.html
	new file:   howto/rebuild-from-update-hook.txt
	new file:   howto/recover-corrupted-blob-object.html
	new file:   howto/recover-corrupted-blob-object.txt
	new file:   howto/recover-corrupted-object-harder.html
	new file:   howto/recover-corrupted-object-harder.txt
	new file:   howto/revert-a-faulty-merge.html
	new file:   howto/revert-a-faulty-merge.txt
	new file:   howto/revert-branch-rebase.html
	new file:   howto/revert-branch-rebase.txt
	new file:   howto/separating-topic-branches.html
	new file:   howto/separating-topic-branches.txt
	new file:   howto/setup-git-server-over-http.html
	new file:   howto/setup-git-server-over-http.txt
	new file:   howto/update-hook-example.html
	new file:   howto/update-hook-example.txt
	new file:   howto/use-git-daemon.html
	new file:   howto/use-git-daemon.txt
	new file:   howto/using-merge-subtree.html
	new file:   howto/using-merge-subtree.txt
	new file:   howto/using-signed-tag-in-pull-request.html
	new file:   howto/using-signed-tag-in-pull-request.txt
	new file:   xyzzy.txt

Changes not staged for commit:
  (use "git add ..." to update what will be committed)
  (use "git restore ..." to discard changes in working directory)
	modified:   xyzzy.txt

as you see now "xyzzy.txt" is shown twice: we have the staged version (the empty file we initially created using the touch command) and a new version, with contents that explain the meaning of the word "xyzzy".

Let's add these precious information to the Staging Area - that of course means overwriting  the contents of "xyzzy.txt" currently in the Staging Area:

git add xyzzy.txt
You may need to prevent some contents from being version controlled by git. This can be easily achieved by adding a ".gitignore" file containing the list of files or directories that you don't want to be controlled.

Unstaging Contents

You may of course sometimes need to remove the contents from the Staging Area (this is called unstage) by using the "git reset" command.

You can remove only specific contents from the Staging Area - for example, to just remove the "bar/baz/plugh.txt" file:

git reset HEAD bar/baz/plugh.txt

An alternate way is using the "git restore" command with the --staged option. For example, to restore the "xyzzy.txt" file type:

git restore --staged xyzzy.txt

let's check the outcome running "git status":

On branch master
Your branch is up to date with 'origin/master'.

Changes to be committed:
  (use "git restore --staged ..." to unstage)
	new file:   bar/baz/qux.txt
	new file:   howto/keep-canonical-history-correct.html
	new file:   howto/keep-canonical-history-correct.txt
	new file:   howto/maintain-git.html
	new file:   howto/maintain-git.txt
	new file:   howto/new-command.html
	new file:   howto/new-command.txt
	new file:   howto/rebase-from-internal-branch.html
	new file:   howto/rebase-from-internal-branch.txt
	new file:   howto/rebuild-from-update-hook.html
	new file:   howto/rebuild-from-update-hook.txt
	new file:   howto/recover-corrupted-blob-object.html
	new file:   howto/recover-corrupted-blob-object.txt
	new file:   howto/recover-corrupted-object-harder.html
	new file:   howto/recover-corrupted-object-harder.txt
	new file:   howto/revert-a-faulty-merge.html
	new file:   howto/revert-a-faulty-merge.txt
	new file:   howto/revert-branch-rebase.html
	new file:   howto/revert-branch-rebase.txt
	new file:   howto/separating-topic-branches.html
	new file:   howto/separating-topic-branches.txt
	new file:   howto/setup-git-server-over-http.html
	new file:   howto/setup-git-server-over-http.txt
	new file:   howto/update-hook-example.html
	new file:   howto/update-hook-example.txt
	new file:   howto/use-git-daemon.html
	new file:   howto/use-git-daemon.txt
	new file:   howto/using-merge-subtree.html
	new file:   howto/using-merge-subtree.txt
	new file:   howto/using-signed-tag-in-pull-request.html
	new file:   howto/using-signed-tag-in-pull-request.txt

Untracked files:
  (use "git add ..." to include in what will be committed)
	bar/baz/plugh.txt
	xyzzy.txt

If instead you want to restore everything back from the Staging Area to the Working Area:

git reset

we'll see more "git reset" examples very soon.

Committing Contents

When you think that has come the time to commit the changes of the versioned contents into the Repository Area you should only type "git commit" followed by -m parameter and the comment that describes the changes you are committing.

To go on with our example, first we need add again all the contents to the Staging Area:

git add --all

and now that everything we want to commit has been staged again, we can run the following statement:

git commit -m "explained the meaning of the words plugh and  xyzz"
Again, mind that commits do apply to content changes: as we have just seen,  if you created an empty directory this will never be added to the Staging Area, and so will never be included among the committed contents.

Amending A Commit

It may happen that, after a commit, you realize that you forgot something or made a mistake in the comment - for example I typed "xyzz" instead of "xyzzy" in the comment. We can sort out my mistake as follows:

git commit --amend -m "explained the meaning of the words plugh and xyzzy"

we can of course exploit the amend feature for example if we forgot something. Let-s check the contents of the "xyzzy.txt" file.

For example we certainly forgot to explain the meaning of the "plugh" word, whereas in the comment we claimed having done it. Let's add the explain right now as follows:

echo 'In the Colossal Cave Adventure game, when the player first arrives at an area known as "Y2", he receives the message A hollow voice says "plugh". The magic word takes the player between the rooms "inside building" and "Y2".' > bar/baz/plugh.txt

now let's stage it again:

git add bar/baz/plugh.txt

and commit it again amending both the contents and the comment:

git commit --amend -m "explained the meaning of the words plugh and xyzzy"

let's have a look to the log of the commits:

git log

the output is as follows:

commit e1d6060757d59356e2f3ddc311ed5f42adc65d8f (HEAD -> master)
Author: Marco Carcano <mcarcano@carcano.local>
Date:   Sat Aug 27 14:56:07 2022 +0200

    explained the meaning of the words plugh and xyzzy

commit 79da3eec749e33810c7eacdb13f6af27ee88cd05 (origin/master)
Author: Marco Carcano <mcarcano@carcano.local>
Date:   Thu Aug 18 23:59:04 2022 +0200

    initial commit

as we see that there's just one commit with the message "explained the meaning of the words plugh and xyzzy": this is because we amended each time what we forgot.

Never and ever amend commits of other peoples, or commits that have already been pushed to a remote repository: someone else may have already added another commit that has the commit you are going to amend as parent. In this case use "git revert" instead.

Reverting A Specific Commit

It may happen that you want to “undo” changes of a specific commit, without affecting the contents of the other commits  that have been made after it. In practice it extracts into the Working Area the contents of the repository skipping the changes of the commit specified in the statement.

This is particularly useful when you are the owner of a commit, but you have already pushed it to the remote repository - and so now there are chances that other people may potentially have started modifying it.

In such a situation you can simply type "git revert" followed by the hash of the commit you want to be skipped when extracting the files. Anyway, mind that the outcome may result in conflict changes that you have to manually solve. For example, if you want to skip just the last commit, type:

git revert HEAD
Mind that extracting the contents skipping the contents of the specified commit does not mean removing that commit from the git history: you are simply getting a filtered version of the repository without the specific commit, that you can use for example use to do another commit.

Rewind To An Earlier Point In History

Sometimes we do "commit" several times of modifications that lead to a dead end, eventually finding out that some of the last commits we did are not useful or maybe do not suit our needs or worse are even completely wrong.

Git provides the reset command to do exactly this: rewind the history to a specific commit.

Beware that resetting commits may cause issues to other collaborators that are using your commits – mind this, so, unless you really know what you are doing, do not use the git reset on commits you already pushed to the remote repository.

Git reset can have three distinct behaviors: "--mixed", "--soft" and "--hard".

git reset --mixed

this is the default behavior: it extracts the changes from the specified commits to the Working Area: you typically use it to make additional changes (that maybe you forgot early) to the files and eventually put everything as a whole into a larger commit.

We already saw this example, used to unstage the "bar/baz/qux.txt" file:

git reset HEAD bar/baz/qux.txt
git reset --soft

it extracts the changes from the specified commits to the Staging Area: you typically do this when you want to consolidate a group of small commits into a larger one.

git reset --hard

it:

  • resets everything in the Staging Area
  • resets everything in the Working Area
  • removes every commit did after the specified one from the Repository Area

The outcome is that we reach the state there was in the commit you specified.

For example, to go back to how we were three commits ago, just type:

git reset --hard HEAD~2

or specify the hash of the commit we want to revert to:

git reset --hard <hash>

Working With Branches

Very often it is needed to have multiple parallel lines of development, each one of them focusing on a particular topic of a project, that in the end gets merged into the main project: this need can be addressed by creating as many branches as many are the parallel developments.

These kinds of branches are often referred to as “short-living branches”, but there are also "long-living" branches, such as the "develop" and the "hotfix" branches used when adopting the GitFlow branching model.

Switching from a branch (and so from a line of development) to another is called checkout.

To see things in action, let's create and populate another Git repository so to have a playground to experiment on:

git init ~/bar
cd ~/bar
touch README.md
git add README.md
git commit -m "Initial commit"

Checkout A Branch And Managing Branches

As we saw, a default branch (called “master”, although now "main" is becoming more popular) is automatically created as soon as the repository is initialized.

We can however fork other branches from the “master” branch: for example we can fork a branch called “develop” to use this long-living branch as a collector of all the commits and merges of other branches. Each time we have a stable situation and we think is suitable for being permanently stored, we can merge the “develop” branch into the “master” branch.

We can easily list the existing local branches in a Git repository by typing:

git branch -l

since we have not other branches besides the "master", the output is as follows:

* master

Creating Branches

To create a new branch, just type a "git branch" followed by the name of the branch you want to create.

For example, to create "develop" branch:

git branch dev

this command creates the new head file ".git/refs/heads/dev" and stores into it the same hash of the branch you are currently working on.

To use the new branch you must then checkout to it as follows:

git checkout dev
Mind that the previous two statements can be run as a whole in just one statement by providing the -b option to "git checkout". So in this example it would be enough to type "git checkout -b dev" to get the same outcome.

by now until the next time you checkout a different branch, every commit you do gets stored beneath the list of commits of the "dev" branch. The hash of the last commit, called the HEAD, is stored into the ".git/refs/heads/dev" file, and gets replaced at each new commit.

As already seen, despite when cloning the repository the metadata containing information of all the branches and tags defined on the remote server are fetched as a whole, only the contents of the default branch are copied and extracted: that's why you must always mind to checkout the branch you want to use before going on, or you'll work on the contents of the default branch ("master" or "main").

Renaming A Branch

If you change your mind on the name of the current branch, you can change it to a new one by using -M option.

For example, to change the name of the current branch (the "dev" branch) into "develop" type:

git branch -M develop

Deleting A Branch

If you are working with a short-lived branch (for example a branch dedicated to developing a specific feature) you can delete it right after merging back to the parent.

To delete a branch type "git branch -d" followed by the name of the branch you want to delete.

For example, to delete the "develop", first checkout the master:

git checkout master

then type issue:

git branch -d develop

Mind that if the branch you want to delete has been partially merged because of conflicts, you must delete using the uppercase "d". So, for example, to delete the "f-feature" branch you must type "git branch -D f-foofeature".

Stashing

Checking out an existing branch requires to have both the Working Area and the Staging Area clean: if they aren't, you are very likely everything to wind up into a mess, since if their contents are still there when you want to stage other contents that make sense only in this other branch or even worse when committing to this other branch. There are only two way to clear them:

  • resetting both areas, with the straightforward downside of losing everything you did since the last commit
  • committing, but this may run counter to the good practice of committing only when the contents make sense overall: you may be indeed asked to immediately switch to another branch to fix something urgent when you are in the middle of developing, and so you would actually commit something somehow incomplete. This probably is not in the "true" spirit of using Git.

For this specific use case, Git provides the "stash" feature: by stashing you are actually saving into a stack the content of the Staging Area.

Mind that stashing saves only the contents of the Staging Area: if necessary, you can either include the contents in the Working Area by providing the "-u" command line switch, or save only the contents of the Working Area by providing the "--keep-index" command line switch.

Let's see this in action: let's start by checking out the new "f-fantasia" branch, and then checkout the master branch again:

git checkout -b f-fantasia
git checkout master

now let's modify the contents of the "master" branch:

echo "# Readme" > README.md
git add README.md
touch master.txt

let's check the status then:

git status

we expect the "README.md" file to be in the Staging Area, while "master.txt" must still be in the Working Area.

On branch master
Changes to be committed:
  (use "git restore --staged ..." to unstage)
	modified:   README.md

Untracked files:
  (use "git add ..." to include in what will be committed)
	master.txt

If for example we would checkout to the "f-fantasia" branch to modify its contents now, both the Staging Area and Working Area are unclean: operating on this other branch in such a scenario has a high risk to wind up into a mess, unless we are not very careful and most of all fully aware of what we are doing.

Stashing Contents

So it is exactly here where we do have to use the stash command: since also the Working Area is dirty, by using the "-u" command line switch we stash its contents along the one of the Staging Area as follows:

git stash -u

the output is as follows:

Saved working directory and index state WIP on master: 9658bbb Initial commit

You can now safely checkout the "f-fantasia" branch:

git checkout f-fantasia

and do whatever we wish, adding contents and committing them or even stashing again: mind that you can add contents to the stash stack as necessary, even before jumping from branch to branch.

So for example:

echo "# Fantasia" > fantasia.txt
git add fantasia.txt
git stash

this time the "-u" command line switch is not necessary since the Working Area is still clean.

Listing The Stashes In The Stack

Let's checkout to the master now:

git checkout master

we can list the available stashes in the stack by typing:

git stash list

the output is as follows:

stash@{0}: WIP on f-fantasia: 9658bbb Initial commit
stash@{1}: WIP on master: 9658bbb Initial commit

mind that:

  • stash{n} is the identifier of the stash put in the stack
  • stash{0} is the identifier of the last stash put in the stack
  • the remaining text is a description that helps you to guess when it has been taken, and from what branch

this is obviously vital to know when you have more entries on the stack of stashes.

We can apply any stash back to the Staging Area and Working Area as follows - mind that this command does not remove the stash from the stack:

git stash apply stash@{1}

we applied the stash "stash@{1}" since we are in the "master" branch, and that stash was taken from the "master" branch indeed.

On branch master
Changes not staged for commit:
  (use "git add ..." to update what will be committed)
  (use "git restore ..." to discard changes in working directory)
	modified:   README.md

Untracked files:
  (use "git add ..." to include in what will be committed)
	master.txt

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

As you can see from the output, everything has been restored as it was initially. You can now add everything to the Staging Area and commit:

git add --all
git commit -m "learning git stash"

it may happen that you got merging conflicts while applying a stash: in this case, you can save the contents of stash to a new branch, and then merge it as usual (more on this topic later on):

git stash branch f-tmp stash@{0}

the output is as follows:

Switched to a new branch 'f-tmp'
On branch f-tmp
Changes to be committed:
  (use "git restore --staged ..." to unstage)
	new file:   fantasia.txt

Dropped stash@{0} (3861babedb7226e65112410eae639afdc8e7c7b7)

as you see, this command does also remove the entry from the stack of the stashes:

git stash list

since we have not branches but the "master", the output is as follows:

stash@{0}: WIP on master: 9658bbb Initial commit

we can of course remove the last entry from the stack of stashes as follows:

git stash drop stash@{0}

Mind that if you want to apply the last stash, you can just type "git stash pop": in addition to apply the stash, it gets also removed from the stack of stashes.

Let's cleanup everything:

git checkout master
git branch -d f-fantasia
git reset --hard

Merging Branches

When working with branches sooner or later you will need to melt the contents of one branch into another - a typical use case of branches are feature branches: you checkout a branch dedicated to develop a new feature, and when you are done you merge it back into the branch you generated the feature branch from.

Whenever you commit to a branch, you are making it diverge from its ancestor: this means that its "head" is a certain number of commits ahead of its ancestor.

When merging you have the following subject and object:

  • Target branch: the branch you checked out and you are currently in, that is the target of the merge operation indeed
  • Source branch: the branch you want to melt the contents from into the target branch. Mind that the source branch does not get deleted after the merge, unless you explicitly do it

Branch Merging Strategies

Git provides several merging strategies: in this post we see in detail the most used ones: fast-forward, recursive and squash, but mind that there do exist other strategies such as the resolve strategy, the octopus strategy and so on: simply it is not possible to describe them within a single post, so I decided to describe the ones that it is more likely to use.

As usual I want to show things  in action, so let's start by creating  and populating another Git repository so to have a playground to experiment with:

git init ~/baz
cd ~/baz
touch README.md
git add README.md
git commit -m "Initial commit"

Fast Forward

This is the easiest to understand merging strategy - it requires that:

  • both branches have a common commit, that is an ancestor of the source branch
  • the ancestor is the last commit of the target branch

Unfortunately, unless you are working on local branches, this is quite a rare use case.

These are the statements you would type to merge the "source-branch" branch into the "target-branch" branch:

git checkout target-branch
git merge --ff-only source-branch

Let's see thin in action on the "bar" git repo we created as a playground:

echo "# Readme" > README.md
git add README.md 
git commit -m "added the heading

let's have a look at the log:

git log --all --decorate --oneline --graph

the output is as follows:

* 8add783 (HEAD -> master) added the heading
* 86cb5d7 Initial commit

let's now create the "f-amazing" branch, modify the contents a few times committing each single change:

git checkout -b f-amazing
echo "1st change" > amazing.txt
git add amazing.txt; git commit -m "amazing - 1st commit"
echo "2nd change" >> amazing.txt
git add amazing.txt; git commit -m "amazing - 2nd commit"
echo "3rd change" >> amazing.txt
git add amazing.txt; git commit -m "amazing - 3rd commit"

let's checkout the "master" branch:

git checkout master

and have a look at the log again:

git log --all --decorate --oneline --graph

this time the output is as follows:

* 96e6c84 (f-amazing) amazing - 3rd commit
* 471bf17 amazing - 2nd commit
* 9d46249 amazing - 1st commit
* 8add783 (HEAD -> master) added the heading
* 86cb5d7 Initial commit

as you see, commits "9d46249", "471bf17" and "96e6c84" are listed into the "f-amazing" branch.

Let's merge the "f-amazing" feature branch into the current branch forcing the use of the fast-forward strategy:

git merge --ff-only f-amazing

the output is as follows:

Updating 8add783..96e6c84
Fast-forward
amazing.txt | 3 +++
1 file changed, 3 insertions(+)
create mode 100644 amazing.txt

if the development of the feature is over, we can of course delete the "f-amazing" branch:

git branch -d f-amazing

and have a last look at the log:

git log --all --decorate --oneline --graph

this time the output is as follows:

* 96e6c84 (HEAD -> master) amazing - 3rd commit
* 471bf17 amazing - 2nd commit
* 9d46249 amazing - 1st commit
* 8add783 added the heading
* 86cb5d7 Initial commit

as you see, now commits "9d46249", "471bf17" and "96e6c84" are embedded into the "master" branch, and the "f-amazing" branch has been deleted

Rebase And Fast Forward

As we saw, one of the two mandatory conditions for being able to do a fast-forward merge is that the ancestor commit of the branches must also be the last commit of the target branch. Unfortunately, this condition is only very seldom met.

Anyway, if at least both the branches have a common ancestor, if it make sense with the layout of the history you want to keep in your repository you can exploit the rebase feature to relink the source branch to the tip of the target branch:

git checkout source-branch
git rebase target-branch

then we can actually proceed with a fast-forward merge:

git checkout target-branch
git merge --ff-only source-branch

let's see in action using the "bar" repository we created as a playground.

We start from having look at the log:

git checkout master
git log --all --decorate --oneline --graph

this time the output is as follows:

* 96e6c84 (HEAD -> master) amazing - 3rd commit
* 471bf17 amazing - 2nd commit
* 9d46249 amazing - 1st commit
* 8add783 added the heading
* 86cb5d7 Initial commit

this time we create the "f-nice" branch and modify the contents a few times committing at each single change:

git checkout -b f-nice
echo "1st change" > nice.txt
git add nice.txt; git commit -m "nice - 1st commit"
echo "2nd change" >> nice.txt
git add nice.txt; git commit -m "nice - 2nd commit"

then checkout the "master" branch again, modify the contents a few times committing at each single change:

git checkout master
echo "4th change" > amazing.txt
git add amazing.txt; git commit -m "master - 1st commit"
echo "5th change" >> amazing.txt
git add amazing.txt; git commit -m "master - 2nd commit"

lastly, let's checkout the "f-nice" branch again, modify the contents and commit the change:

git checkout f-nice
echo "3rd change" > nice.txt
git add nice.txt; git commit -m "nice - 3rd commit"

we made enough changes to provide a good example for this merge, but first let's have a look to the history - let's checkout the master branch:

git checkout master

and have a look to the log again:

git log --all --decorate --oneline --graph

this time the output is as follows:

* 91f5b11 (f-nice) nice - 3rd commit
* 56189ce nice - 2nd commit
* f8657ac nice - 1st commit
| * 91a331f (HEAD -> master) master - 2nd commit
| * b10d323 master - 1st commit
|/ 
* 96e6c84 amazing - 3rd commit
* 471bf17 amazing - 2nd commit
* 9d46249 amazing - 1st commit
* 8add783 added the heading
* 86cb5d7 Initial commit

as you see:

  • the common ancestor of the branches is the "96e6c84" commit
  • "f8657ac", "56189ce" and "91f5b11" commits belong to the "f-nice" branch
  • "b10d323" and "91a331f" commits belong to the "master" branch

so both branches have the "96e6c84" commit as ancestor, but they diverged.

Indeed, if we check the difference in number of commits from the "96e6c84" and the HEAD of the "master" branch:

git rev-list --left-right --count 96e6c84...master

we got:

0	2

that means that the "96e6c84" commit is 2 commits behind the head of the "master" branch.

This actually prevents us from being able to do a fast-forward merge:

git merge --ff-only f-nice

the outcome is the following error:

fatal: Not possible to fast-forward, aborting.

let's retry after rebasing "f-nice" branch on the "master" branch:

git checkout f-nice
git rebase master
This is the point where maybe conflicts of content version arise, and if Git is not able to automatically solve them you must manually reconcile them.

checkout the "master" branch and have a look at the log again:

git checkout master
git log --all --decorate --oneline --graph

this time the output is as follows:

* 0cac194 (f-nice) nice - 3rd commit
* f7804be nice - 2nd commit
* 78400b2 nice - 1st commit
* 91a331f (HEAD -> master) master - 2nd commit
* b10d323 master - 1st commit
* 96e6c84 amazing - 3rd commit
* 471bf17 amazing - 2nd commit
* 9d46249 amazing - 1st commit
* 8add783 added the heading
* 86cb5d7 Initial commit

this way the "91a331f" commit has become the ancestor of the "f-nice" branch, and the ancestor is not behind the HEAD of the "master" branch:

git rev-list --left-right --count 91a331f...master

we got:

0	0

so let's eventually try the fast-forward merge:

git merge --ff-only f-nice

this time it works like a charm:

Updating 91a331f..0cac194
Fast-forward
nice.txt | 1 +
1 file changed, 1 insertion(+)
create mode 100644 nice.txt

we can of course delete the "f-nice" branch:

git branch -d f-nice

and have a last look at the log:

git log --all --decorate --oneline --graph

this time the output is as follows:

* 0cac194 (HEAD -> master) nice - 3rd commit
* f7804be nice - 2nd commit
* 78400b2 nice - 1st commit
* 91a331f master - 2nd commit
* b10d323 master - 1st commit
* 96e6c84 amazing - 3rd commit
* 471bf17 amazing - 2nd commit
* 9d46249 amazing - 1st commit
* 8add783 added the heading
* 86cb5d7 Initial commit

Recursive Merge

The downside of the fast-forward merging strategy is that, since it turns the graph into linear, actually hides the real track of the commits: this applies also if you rebase the branch before merging obviously.

If your goal is to keep the real track of the commits, you must use the recursive merge strategy: let's see in action using the "bar" repository we created as a playground:

git checkout master

Let's checkout the "f-new" feature from the "master" branch , modify the contents a few times and commit at each single change:

git checkout -b f-new
echo "1st change" > new.txt
git add new.txt; git commit -m "new - 1st commit"
echo "2nd change" >> new.txt
git add new.txt; git commit -m "new - 2nd commit"

then checkout the "master" branch, modify the contents a few times and commit at each single change:

git checkout master
echo "6th change" > amazing.txt
git add amazing.txt; git commit -m "master - 3rd commit"
echo "7th change" >> amazing.txt
git add amazing.txt; git commit -m "master - 4th commit"

lastly, let's checkout the "f-new" branch again, modify the contents and commit them:

git checkout f-new
echo "3rd change" > new.txt
git add new.txt; git commit -m "new - 3rd commit"

let's checkout the "master" branch:

git checkout master

and have a look to the log again:

git log --all --decorate --oneline --graph

this time the output is as follows:

* 37ee1e7 (f-new) new - 3rd commit
* e4d4279 new - 2nd commit
* a68267a new - 1st commit
| * 91f5697 (HEAD -> master) master - 4th commit
| * 639af9f master - 3rd commit
|/  
* 0cac194 nice - 3rd commit
* f7804be nice - 2nd commit
* 78400b2 nice - 1st commit
* 91a331f master - 2nd commit
* b10d323 master - 1st commit
* 96e6c84 amazing - 3rd commit
* 471bf17 amazing - 2nd commit
* 9d46249 amazing - 1st commit
* 8add783 added the heading
* 86cb5d7 Initial commit

that is exactly the same use case we had in the rebase and fast-forward merge:

  • the common ancestor of the branches is the "0cac194" commit
  • "a68267a", "e4d4279" and "37ee1e7" commits belongs to the "f-new" branch
  • "639af9f" and "91f5697" commits belongs to the "master" branch

so both branches have the "0cac194" commit as ancestor.

If we check the difference in number of commits from the "0cac194" and the HEAD of the "master" branch:

git rev-list --left-right --count 0cac194...master

we got:

0	2

that means that the "0cac194" is 2 commits behind the head of the "master" branch.

In such a scenario the fast-forward strategy is not viable, so let's proceed with the recursive merge as follows:

git merge f-new

the output is: 

Merge made by the 'recursive' strategy.
 new.txt | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 new.txt

as you see, the outcome of this merge is a new commit with the melted contents of the two branches.

This is the point where maybe conflicts of content version arise, and if Git is not able to automatically solve them you must manually reconcile them.

Mind that if the common ancestor on the target branch is not behind the head of the target branch, Git will prefer the fast-forward strategy, but you can force it to use the recursive strategy by providing the "no-ff" command line switch. For example:

git merge --no-ff f-source-branch

Now we can of course delete the "f-new" branch:

git branch -d f-new

and have a last look at the log:

git log --all --decorate --oneline --graph

this time the output is as follows:

*   0fd7834 (HEAD -> master) Merge branch 'f-new'
|\  
| * 37ee1e7 new - 3rd commit
| * e4d4279 new - 2nd commit
| * a68267a new - 1st commit
* | 91f5697 master - 4th commit
* | 639af9f master - 3rd commit
|/  
* 0cac194 nice - 3rd commit
* f7804be nice - 2nd commit
* 78400b2 nice - 1st commit
* 91a331f master - 2nd commit
* b10d323 master - 1st commit
* 96e6c84 amazing - 3rd commit
* 471bf17 amazing - 2nd commit
* 9d46249 amazing - 1st commit
* 8add783 added the heading
* 86cb5d7 Initial commit

as you can see, now the graph clearly highlights the ancestor of the "f-new" branch, the commits that have been made inside the branch, as well as the ones made in the master branch, and eventually the commit used to merge the "f-new" branch into the "master" branch.

Squashed Merge

This is the last and probably less used of the three merging strategies: it is called like so since it squashes all of the commits of the source branch (in other words, it packs them all into a single commit), melting everything into the target branch.

Please note that it stops right after melting: it is up to you then to proceed with a new commit that permanently adds the squashed changes into the Repository Area. Then you can safely remove the squashed branch.

Let's see an example with two diverged branches, first let's checkout the master branch:

git checkout master

then checkout the new "f-super" branch from the master:

git checkout -b f-super
echo "1st change" > super.txt
git add super.txt; git commit -m "super - 1st commit"
echo "2nd change" >> super.txt
git add super.txt; git commit -m "super - 2nd commit"

then checkout the "master" feature again, modify the contents and commit:

git checkout master
echo "8th change" > amazing.txt
git add amazing.txt; git commit -m "master - 5th commit"

lastly, let's checkout the "f-super" branch again, modify the contents and commit:

git checkout f-super
echo "3rd change" >> super.txt
git add super.txt; git commit -m "super - 3rd commit"

let's checkout the master branch:

git checkout master

and have a look at the log again - this time, to limit the output,  we set the boundaries focusing only on the commits between the "master" branch and the "f-super" branch:

git log --decorate --oneline --graph --boundary master...f-super

the output is as follows:

* 7ad24d8 (f-super) super - 3rd commit
* 53cac2a super - 2nd commit
* f6960ce super - 1st commit
| * e9ac190 (HEAD -> master) master - 5th commit
|/  
o 0fd7834 Merge branch 'f-new'

as you see, "0fd7834", that is the ancestor of the "f-super" branch, is behind the "master", indeed:

git rev-list --left-right --count 0fd7834...master

returns the following output:

0	1

let's check the files currently extracted from the repository:

ls -1

the output is as follows:

README.md
amazing.txt
new.txt
nice.txt

since we are in the "master" branch, it is right that the "super.txt" file is not listed, since it has been committed (three times) into the "f-secure" branch.

We can now squash all the commits currently in the "f-super" branch into the "master" branch as follows:

git merge --squash f-super

the output is as follows:

Squash commit -- not updating HEAD
Automatic merge went well; stopped before committing as requested
This is the point where maybe conflicts of content version arise, and if Git is not able to automatically solve them you must manually reconcile them.

Let's see the contents of the Staging Area:

git status

the output is as follows:

On branch master
Changes to be committed:
  (use "git restore --staged ..." to unstage)
	new file:   super.txt

so the "super.txt" file, coming from the "f-super" branch, has been added to the Staging Area. As you can easily argue, it has been added to the Working Area too:

ls -1

the output is as follows indeed:

README.md
amazing.txt
new.txt
nice.txt
super.txt

as for the contents of the "super.txt" file:

cat super.txt

the output is as follows:

1st change
2nd change
3rd change

so all of the three commits have been melted down into the "master" branch, ... but the changes are still uncommitted.

Let's commit them now:

git commit -m "squashed f-super branch into master branch"

this time the output is as follows:

[master 7244c80] squashed f-super branch into master branch
 1 file changed, 3 insertions(+)
 create mode 100644 super.txt

if now we try to delete the "f-super" branch:

git branch -d f-super

we get the following error:

error: The branch 'f-super' is not fully merged.
If you are sure you want to delete it, run 'git branch -D f-super'.

since we squashed all the commits as a single change into the "master" branch, Git points out that there are commits that have never been merged, and so asks if we are sure of what we are doing.

Since in this case it is expected, we can force the deletion of the branch using the capital "d" as suggested by the message:

git branch -D f-super

if we have a look to the log again:

git log --all --decorate --oneline --graph

the output is as follows:

* 7244c80 (HEAD -> master) squashed f-super branch into master branch
* e9ac190 master - 5th commit
*   0fd7834 Merge branch 'f-new'
|\  
| * 37ee1e7 new - 3rd commit
| * e4d4279 new - 2nd commit
| * a68267a new - 1st commit
* | 91f5697 master - 4th commit
* | 639af9f master - 3rd commit
|/  
* 0cac194 nice - 3rd commit
* f7804be nice - 2nd commit
* 78400b2 nice - 1st commit
* 91a331f master - 2nd commit
* b10d323 master - 1st commit
* 96e6c84 amazing - 3rd commit
* 471bf17 amazing - 2nd commit
* 9d46249 amazing - 1st commit
* 8add783 added the heading
* 86cb5d7 Initial commit

we see that there's only the "7244c80" commit that contains all the changes of the "f-super" branch.

Revert A Merge

As an example, let's see how to revert a merge of branches we made by mistake right after having done that merge.

Let's start having a look to the git log:

git log

the outcome is as follows:

commit 643621b6330bf5e4e45934a603edfa4b9a666786 (HEAD -> master, origin/master, origin/HEAD)
Merge: 1155e73 e474561
Author: Marco Carcano <marco@carcano.local>
Date:   Thu May 11 18:14:16 2022 +0200

    Merge branch 'f-foo' into 'master'
    
    Foo feature: added a fancy feature

from the log we can guess that the "f-foo" branch (e474561) has been merged into the "master" (1155e73) branch.

We can revert the "master" branch as it were before the merge: first checkout the "master" branch:

git checkout master

then type:

git reset --hard 1155e73

Tagging

As we already saw, tags are names used to conveniently identify one and only one hash: their purpose is to provide a human-readable name to refer to a specific hash so as to ease checking out contents of a given point of the story. This means that tags have a repository-wide scope (they do not belong to any branch), and so their names must be unique across every branch.

Besides being handy to clearly identify software releases, tags are valuable allied also in the configuration management field: when it comes to promote changes across an environment based workflow, they enable you refer to a given state of the contents of the repository of the configurations every time you need, without having to worry if the repo gets modified in the meantime.

Listing Tags

You can list the available tags by typing:

git tag

Creating A Tag

To tag the current commit simply type "git tag" followed by the name you want to assign to the tag and an optional descriptive message.

For example, to tag as "0.1.3" with the message "Release 0.1.3" type:

git tag 0.1.3 -m "Release 0.1.3"

Deleting A Tag

If instead you want to delete a tag, type "git tag -d" followed by the name you want to delete.

For example, to delete "0.1.3" tag type:

git tag -d 0.1.3
Mind that when deleting the tag, you are not removing the hash of the commit it is pointing to, so the contents of the commit are still in the specific branch of the Git repository.

Checking Out A Tag

To use the tagged commit you must checkout its contents: so if you want to use tag "0.1.3", type:

git checkout tags/0.1.3

Hooks

GIT provides a set of hooks that can be exploited to intercept events and perform additional actions. You can find sample files beneath the ".git/hooks" directory.

A really handy hook is the "pre-commit" hook that can be used for example to run checks before your code is actually committed: you can exploit it for example to validate the syntax of the contents of the file before your code is actually committed.

For example when dealing with Ansible I like to create into my repositories a ".git/hooks/pre-commit" file with the following contents:

#!/bin/bash
exit_status=0
for file in $(git diff-index --name-only --cached HEAD --)
do
  if [[ ${file} == *.yml ]] && ! ansible-lint ${file}
  then
    echo "Commit failed: ansible-lint cannot parse ${file}"
    exit_status=1
  fi
done
exit ${exit_status}
Mind that the ".git/hooks/pre-commit" file needs execution rights, and that git hooks are not synchronised with remotes.

Of course this hook requires having already installed the ansible-lint: this is a utility that checks the compliance of the Ansible YAML files to several best practices.

The above lines of code basically make a list of modified files: if the name end by ".yml" it verifies its syntax parsing it with ansible-lint. The first time a file does not pass the validation, the hook exits printing an error message and returning "1", causing the commit to fail.

The straightforward benefit of such an hook is preventing you from committing not compliant or invalid code.

Let's try it as follows: create a trivial Ansible playbook with the following contents, and save it as facts.yml:

---
- name: An example playbook
  hosts: localhost
  tasks:
    - ansible.builtin.debug:
        var: ansible_fact

Now add the file to the Staging Area and try to commit it:

git add --all
git commit -m "precommit hook test"

the output is:

WARNING  Overriding detected file kind 'yaml' with 'playbook' for given positional argument: facts.yml
WARNING  Listing 1 violation(s) that are fatal
name: All tasks should be named. (name[missing])
facts.yml:5 Task/Handler: debug var=ansible_facts

You can skip specific rules or tags by adding them to your configuration file:
# .config/ansible-lint.yml
warn_list:  # or 'skip_list' to silence them completely
  - name[missing]  # Rule for checking task and play names.

Finished with 1 failure(s), 0 warning(s) on 1 files.
Commit failed: ansible-lint cannot parse facts.yml

As you see the playbook is not in compliance with the best practices: to fix it, just add the "name" attribute to the task as suggested by yaml-lint.

It must now look like as follows:

---
- name: An example playbook
  hosts: localhost
  tasks:
    - name: Print every fact of this host
      ansible.builtin.debug:
        var: ansible_fact

To retry, we must add it  to the Staging Area again and then commit:

git add --all
git commit -m "precommit hook test"

the output is:

[master 1b1a7d6] precommit hook test
 1 file changed, 7 insertions(+)
 create mode 100644 facts.yml

Since ansible-lint succeeded, this time the commit worked. 

Footnotes

Here ends this first post on using Git for version control: we saw thoroughly everything you may face in your daily work when operating version control with it. In the next post we'll explore in detail how to work with remote repositories. Stay tuned.

Writing a post like this takes a lot of hours. I'm doing it for the only pleasure of sharing knowledge and thoughts, but all of this does not come for free: it is a time consuming volunteering task. This blog is not affiliated to anybody, does not show advertisements nor sells data of visitors. The only goal of this blog is to make ideas flow. So please, if you liked this post, spend a little of your time to share it on Linkedin or Twitter using the buttons below: seeing that posts are actually read is the only way I have to understand if I'm really sharing thought or if I'm just wasting time and I'd better quit.

1 thought on “GIT Tutorial – A thorough Version Control with Git Howto

Leave a Reply

Your email address will not be published.

You may use these HTML tags and attributes:

<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>