- Initial thoughts
- 1. Drawbacks of git GUI clients
- 2. The GitLab flow
- 3. Everyday commands when using GitLab flow
- 3. Common situations that you need to be able to manage
- 4. Going further with the command line
- 5. Git common configuration options and aliases
- Wrapping up
- Further reading
Initial thoughts
The 2022 stackoverflow developer survey found that more than 90% of developers use git for version control. Despite that, we feel that many developers are intimidated by the git CLI and use it only when their favorite version control GUI is missing a particular feature.
I, too, relied solely on GitKraken after 10+ years as a developer. Fortunately, Benoit Averty taught me everything I needed to know, through a Zenika Git training session. I’m now a happy Git CLI user across various GitLab projects 🤓.
Fast forward 4 years, and together with Benoit, we present this (self sufficient) article for those as intimidated as my past self 🤗.
Yes, the git CLI has many commands, and it can be daunting; but for everyday use you only need to know a few commands, and the git CLI is particularly good at providing guidance on what you may want to do next. In this article, we show that the CLI is not very hard to use, especially when using GitLab to host the git repository and embracing the GitLab flow that is used by many teams using GitLab.
Most of what we will cover in this article should be true if you’re using GitHub and the GitHub flow instead; but small adjustments may be necessary.
1. Drawbacks of git GUI clients
Git GUI clients are often seen as an easier way to use git. However, it is not always the case, and even when it is, it comes with several drawbacks that you need to be aware of.
Price
Some of the best git GUIs out there are paid software. If you’re paying for them, it's probably because you think they’re worth it. Nevertheless, paid software can quickly add up to a hefty price, and we all prefer a free option.
Magic
Using a git GUI is the best way to use git without understanding git. Part of what makes git appear as a hard tool to master is the complexity of its internal structure and the concepts git introduces to manage your source code. GUIs seem easier to use because they abstract away these concepts, but the drawback is that you can use git every day without understanding it. This makes it almost impossible to handle a special case when it arises or to do something that your preferred GUI can’t do.
The CLI, on the other hand, stays close to the git underlying concepts and teaches them to you almost without you noticing.
Context-specific
A notable drawback of git graphical interfaces is their context-specific nature. Each GUI represents git features in its unique fashion. The skills you gain from one client may not be directly transferable to another client. This is particularly true if your role involves helping teammates with their git usage or if you aspire to be in such a position.
On the other hand, the commands of the git CLI remain standard, regardless of the specific environment in which you’re operating.
Hard to communicate
It is intrinsically challenging to communicate about graphical interfaces when they allow complex actions (and it is often the case with git graphical interfaces). Helping a teammate perform a git action may involve taking a screenshot or describing buttons and menus, whereas command lines can be copy-pasted without effort, including the result of specific commands that can be used to diagnose the problem.
A corollary of this is that it’s easier to write and maintain documentation or best practices on the specific usage of git in your team if you’re using CLI instead of a graphical interface.
2. The GitLab flow
Many development teams today are using GitLab to collaborate on software projects. The GitLab flow is a branching workflow that takes advantage of the features offered by GitLab’s web interface. It is also a simpler alternative to the famous gitflow, introduced almost 15 years ago.
The GitLab flow can be broken down to this:
- In the GitLab web UI
- Create an issue
- Create a MR from the issue, creating a branch
- (Now everyone knows on what you will work)
- Locally
- Synchronize code and switch to the new branch
- Do your magic
- commit and push
- In the GitLab web UI
- Review with your teammates
- When satisfied, merge the code (automatically deleting the branch)
In this workflow, branches aren’t created locally, and they aren’t even merged locally.
In addition to that, many tasks related to git but not directly related to local code can be performed in the web UI:
- List/create/delete branches;
- List/create/delete tags;
- Show the repository graph.
For the tasks that still need to be done locally, let’s dive into the few commands that you need to know for everyday development.
3. Everyday commands when using GitLab flow
Prepare to make changes to the feature branch
If this is the first time on the project, get the repo locally.
In any case, the first thing that you’ll probably want to do after creating your merge request and your branch is to update your local repository and switch to this new branch.
# Switch to develop/main/master and update your local repository
$ git switch develop
$ git pull
# Switch to the new branch that has been created when you opened your MR
$ git switch your-new-feature-branch
The first two commands are used to synchronize the main branch of the project locally. This is always a good idea because it allows you to check out the state of the main branch locally. But strictly speaking, this is not mandatory because you’re going to work on your feature branch anyway. You could replace both of them with a single git fetch
.
The third command is the most interesting. Normally, git switch
in this form is used to switch to a branch that exists locally. But git is smart, and if the branch exists on the remote repository, git will create a local branch that tracks the remote branch that you’ve created previously and switch to that newly created branch. All of this is explained in the command output:
branch 'your-new-feature-branch' set up to track 'origin/your-new-feature-branch'.
Switched to a new branch 'your-new-feature-branch'
Commit your work
Once you have something that is worth sharing with your teammates, or if your code is in a state that is worth saving, it is time to commit.
The command that you’ll want to use next is the most important command when using the git CLI:
$ git status
On branch your-new-feature-branch
Your branch is up to date with 'origin/your-new-feature-branch'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: package.json
no changes added to commit (use "git add" and/or "git commit -a")
Why is it the most important command? Because it tells you everything there is to know before commiting, pushing, or doing anything else really. But also, it tells you what the next command will probably be. Who said command line interfaces were hard to use?
In the example above:
- You can see the list of changed files, and know immediately if there is something you don’t want to commit
- You have the command that you need to use to add files to the future commit
- You have the command that you need to use to discard changes that you’ve made.
Let’s add changes to the future commit.
$ git add package.json
$ git status
On branch your-new-feature-branch
Your branch is up to date with 'origin/your-new-feature-branch'.
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: package.json
Again, git status
tells you what you need to do if you want to cancel adding a file. No need to remember many commands.
When you’re ready, commit your work to store it safely inside your local repository.
$ git commit
[your-new-feature-branch 331ef24] Awesome commit message
1 file changed, 1 insertion(+), 1 deletion(-)
Push and merge your work
After each commit or after finishing your development task, it’s time to push your work to the GitLab server to begin gathering feedback from your teammates.
$ git push
Pushing is not hard when you’re using GitLab flow because there are almost never two developers working on the same branch. You will almost never need to resolve conflicts at this point or have your push rejected because the remote branch moved.
Merging is also as easy as the push of a button in the GitLab webapp.
You probably want to delete your branch once you’ve finished working on it: check the corresponding box. If you’ve made commits that you don’t want to keep while working on your feature, check the "Squash commits" box.
Note that this screenshot is taken on a project that’s configured with the "Merge commit" merge method. If you’re on a project that uses semi-linear history or fast-forward merges only, you’ll be given the option to rebase, also with the click of a button. See GitLab’s documentation about merge methods to learn more.
That’s all there is to know for everyday use when using GitLab flow:
git pull / git fetch
git switch
git status
git add
git commit
git push
The rest is done with the click of a button in GitLab.
Inspect past commits
There's one more thing that’s often needed when working on a git project: inspecting past commits. It may be to get up to speed about recent changes made by your teammates or to refresh your memory about the changes you’ve made on your branch after working on something else or taking a vacation.
Again, you could do this with the GitLab UI (in the menu, go to Code > Repository graph), but if you want to do it locally, here are a couple of solutions
- Use git CLI: We suggest the following command, that you can store in an alias if you don’t want to memorize it
$ git config --global alias.graph "log --decorate --oneline --graph"
$ git graph [--all]
* 331ef24 (HEAD -> 1-demo-issue, origin/1-demo-issue) Changing package.json
* 79c1ae8 (origin/main, origin/HEAD, main) Merge branch 'feat/previous-feature' into 'main'
|\
| * 432f691 Implement tests
| * 946d022 Add an amazing feature
|/
* 5c3c723 Hotfix for the broken app incident
* aa56183 Merge branch 'awesome-feature' into 'main'
|\
| * 10b6622 shorten even more
* | 0ac37b5 Merge branch 'fixes' into 'main'
|\|
| * 8180791 Fix various bugs
|/
* Initial commit
- Use tig. This TUI (terminal user interface) is halfway between the GUI and the command line. It basically does the same thing as the previous command, but it’s easier on the eyes while allowing you to stay in the terminal. It will also make it easier to see the diff for a particular commit.
3. Common situations that you need to be able to manage
What we’ve discussed in the previous section is the nominal case. It will be enough for most days, but there are situations that will happen often enough that we would be lying if we told you don’t need to know how to handle them.
The target branch has been updated
When merging your branch, it’s likely that someone has updated the target branch (main
) since you’ve created your branch. If these changes have no conflict with yours, you have nothing to do: git will be able to merge your branch anyway. But if there are conflicts, you’ll need to solve them before merging.
Resolve conflicts in the GitLab UI
Once again, GitLab allows to do this task right in the web UI. Click on the button, and you’ll be taken to a conflict editor that is probably enough for 99% of the conflicts you’ll have to resolve.
Read more about merge conflicts resolution in GitLab’s documentation about merge conflicts
If you prefer to resolve conflicts locally (or if GitLab can’t do it for some reason), you can do it with the following steps.
If you’re using merge commits:
# Fetch the changes so you'll be able to resolve the conflicts locally
$ git fetch
# If you're using merge commits, merge the target branch (here it is main) into your feature branch
$ git merge origin/main
# See which files are in conflict. Again, git status will tell you what to do
$ git status
On branch feature-branch
You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge --abort" to abort the merge)
Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: file1.txt
both modified: file2.txt
no changes added to commit (use "git add" and/or "git commit -a")
# ...Resolve conflicts with your favourite tool (most IDEs can do it). Don't forget to add files after resolution !
# Finalize conflict resolution and push
$ git commit
$ git push
If you’re using fast-forward merges or semi-linear history, you’ll need to rebase your branch onto the target branch:
# Fetch the changes so you'll be able to resolve the conflicts locally
$ git fetch
# If you're using merge commits, merge the target branch (here it is main) into your feature branch
$ git rebase origin/main
# See which files are in conflict. Again, git status will tell you what to do
$ git status
You are currently rebasing branch 'feature-branch' on '4a56ad3'.
Unmerged paths:
(use "git reset HEAD <file>..." to unstage)
(use "git add <file>..." to mark resolution)
both modified: file1.txt
both modified: file2.txt
no changes added to commit (use "git add" and/or "git commit -a")
# ...Resolve conflicts with your favourite tool (most IDEs can do it). Don't forget to add files after resolution !
# Continue conflict resolution
$ git rebase --continue
# ... Resolve conflicts until rebase is done
# Then push your changes
$ git push
The conflict resolution itself is beyond the scope of this article. Here are some resources you can read:
You need to work on another branch
In a professional development team, it is common to have shifting priorities or production bugs. These situations may force you to switch branches temporarily and to get back to your work later.
There are two ways to store unfinished work: stash them or commit them.
Commit your work and switch to the other branch
There is nothing wrong with making a commit that contains unfinished work. Git allows you to change the commit later and/or squash it with other commits.
# Commit your work to save it in git
$ git commit --all
# Switch to the new branch
$ git switch other_branch
# Work as usual on the other branch (dev, commit, push, merge...)
# get back to the initial branch
$ git switch initial_branch
When you’re done with your work, remember to squash your commits together when merging your merge request so that the commit with partial work doesn’t end up in the main branch history. You can do this by checking the corresponding checkbox in the GitLab UI.
Stashing your changes
If you prefer not to commit temporary work (for example, because you don’t want to squash commits together),
you can also stash your changes. The stash is a local-only area in git designed to store partial work.
# Stash your changes
$ git stash
# at this point, all your changes are removed from the working copy so you can switch branches without trouble.
$ git switch other_branch
# Work on the other branch, then get back to the initial branch
$ git switch initial branch
# apply the last stashed changes and drop the last stash entry
$ git stash pop
Stashing changes is cleaner than commiting it, but the stash entries aren’t named and not tied to any specific branch so it becomes complicated if you work on more than two branches in parallel. See the documentation for more details on using the stash
4. Going further with the command line
If you’ve mastered all the commands up to this point, you can handle almost all situations, at least if you’re using GitLab and the GitLab flow.
As you become more proficient with the command line, you may want to perform more advanced tasks directly in your terminal. Here are some examples of tasks that you can do in the command line that will give you a better understanding of git internals.
Someone has updated your (remote) feature branch
When using the GitLab Flow, feature branches usually belong to only one developer. However, there may be some cases when you want to work on the same branch with two developers.
In this situation, there may be times when you can’t push your commits because the other developer has already pushed changes on the target branch that you haven’t yet integrated into your local copy.
When this happens, you’ll need to integrate the changes into your local branch. This is similar to the case when you reintegrate changes from the main branch, except here you integrate changes from the remote version of the feature branch.
# If you try to push but the branch has changed on the remote, you'll get an error like this. notice how git tells you everything about the situation ?
$ git push
To gitlab.com:your_group/your_repo
! [rejected] 1-feature-1 -> 1-feature-1 (fetch first)
error: failed to push some refs to 'gitlab.com:your_group/your_repo'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. This is usually caused by another repository pushing to
hint: the same ref. If you want to integrate the remote changes, use
hint: 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
# Pull the changes made by the other person on your branch. The --rebase is important here, because otherwise the history will be bloated with merge commits
$ git pull --rebase
remote: Enumerating objects: 5, done.
remote: Counting objects: 100% (5/5), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 1), reused 0 (delta 0), pack-reused 0 (from 0)
Unpacking objects: 100% (3/3), 286 bytes | 286.00 KiB/s, done.
From gitlab.com:BenoitAverty/sandbox
95d6184..feefa0b 1-feature-1 -> origin/1-feature-1
Auto-merging README.md
CONFLICT (content): Merge conflict in README.md
error: could not apply 6607d50... Edit README.md locally
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
hint: Disable this message with "git config advice.mergeConflict false"
The git pull
command does two things: fetch the changes on the remote repository, updating the remote branch on your local repo (remote branches are called origin/branch), then rebasing your local commits on top of the fetched changes. Note that there may be conflicts in this step, but you can resolve them just like when you resolved conflicts when reintegrating the main branch. Again, git tells you what to do when you’re done resolving merge conflicts.
After finishing the rebase, you can push your changes, and this time the push should succeed.
As a bonus, here’s a config option that you can set to avoid having to add the --rebase
option when pulling changes. This will instruct git to always rebase instead of merging when pulling changes in a branch from the tracked remote branch.
$ git config --global branch.autosetuprebase true
# Now when you switch to a new branch that you've created in the remote repo, git tells you that it will rebase instead of merge when pulling changes
$ git switch 2-my-feature
branch '2-my-feature' set up to track 'origin/2-my-feature' by rebasing.
Switched to a new branch '2-my-feature'
Rewrite the history
No one is perfect. Sometimes you will make changes to your project, commit something, change your mind, and commit something else. Or you will have to work on something else and make a temporary commit.
Or you will want to change a commit message.
You can always squash all your commits together in GitLab when merging, but git allows you to do all of this locally by rewriting the history. There are many ways to do this, but here are two ways that should cover most of your needs.
In both cases, make sure your branch is up to date with the remote branch and your working copy is clean, because rewriting the history can get confusing it the remote has a different history than your branch.
# Update your branch
$ git pull
# Stash uncommited changes.
$ git stash
Make small changes to the history: interactive rebase
The first way to rewrite the history is to use the interactive rebase feature. Interactive rebase lets you replay all the commits that you’ve made while adjusting some of them in the process. When you launch the interactive rebase, an editor will open with a list of all the commits you’ve made on the branch, and each commit will be associated with the pick
command, meaning that the commit is kept as is.
You can change the pick command to another to perform various actions on the corresponding commit. The most commonly used commands in interactive rebase are reword
(the commit will be kept but an editor will open so you can change the commit message) and fixup
(The changes from this commit will be integrated in the previous commit).
All the available commands are details in the editor when starting the interactive rebase.
# start an interactive rebase that contains all the commits that are in your feature branch
$ git rebase -i main
Rewrite the history from scratch
If you prefer to start again, you can also remove all the commits you’ve made in the branch (but keeping the changes to the code themselves) and start again.
$ git reset --soft main
This command will put your branch back to its starting point, the main
branch. But if you perform a git status
, you will see that all your code changes are still here, staged for commit. At this point you can perform a git commit
which will create a single commit containing all your changes (this is like the Gitlab option to squash all changes, but locally), or you can start again to create several commits by adding all your changes in small iterations.
# Remove the changes from the index, this will keep them in the working copy as if you had just made the changes toi the code
$ git restore --staged .
# Re-commit all your changes by adding them in several iterations
git add some-file # add only one file
git commit
git add -p other-file # add only part of a file
git commit
git add other-file # add the rest of the file
git add . # add everything
git commit
Pushing a rewritten history
Once you’re satisfied with the new history, push it to the remote. Since you’ve rewritten the history, git will tell you that your local branch is different from the remote branch (as if someone else had made changes). In this case this is expected, so you can force push your changes. This is why it’s important to be up to date, because force pushing will replace the remote history by the local one you’ve just created. If there are commits in the remote that you didn’t include in the new history, they will be lost.
This doesn’t happen in the classic GitLab flow because branches usually belong to only one developer.
# force-with-lease instead of force will tell you if you forgot to pull and someone has updated the remote branch
$ git push --force-with-lease
Remove old local branches
After some time working on the same project, you will have a lot of branches on your local repo. These branches are often left after the merge request is merged: GitLab will delete the remote branch, but if you do nothing, they’re kept in your local repository.
Here’s a way to remove all branches that aren’t needed anymore.
# Remove local copies of remote branches
$ git fetch --prune
# For each branch that has been deleted by prune, remove the local branch if it exists
$ git branch -D <branch-name>
The second step can be done in batch if there are many branches, but that solution involves a bit of scripting and it can be fragile. The best way is still to do it often, so you don’t have to use this if you don’t fully understand the way it works.
git branch -vv | grep ': gone]' | awk '{print $1}' | xargs git branch -D
Change the user name/email in commits
To change your user name/email only in last commit:
git commit --amend --author="John Doe <jdoe@zenika.fr>"
But you may also want to change them on all commits on current branch.
- Start a rebase:
git rebase -i origin/main
Set the targeted commits to "edit"
For each commit until
git rebase
is done :
git commit --author="John Doe <jdoe@zenika.fr>" --amend --no-edit
git rebase --continue
5. Git common configuration options and aliases
Here are some widely adopted git configuration options:
# set your default user name/email
git config --global user.name "Benoit COUETIL"
git config --global user.email "bcouetil@zenika.fr"
# on fetch, delete all the internal git objects that are not reachable from the remote repository
git config --global fetch.prune true
# re-apply local changes when rebasing
git config --global rebase.autostash true
# when pulling, local new commits are rebased onto distant new commits
git config --global pull.rebase true
# reuse recorded resolution on conflicts
git config --global rerere.enabled true
And here are some aliases:
# graph alias: display git history as a graph
git config --global alias.graph "!git --no-pager log --graph --date-order --date=short -10 --pretty=format:'%C(auto)%h%d %C(reset)%s %C(bold blue)%an %C(reset)%C(green)%cr (%cd)'"
# coall alias: commit all files, including new files
git config --global alias.coall '!git add -A && git commit'
# amend-to alias: amend all changes to a specific commit
git config --global alias.amend-to '!f() { SHA=$(git rev-parse "$1"); git add -A && git commit --fixup "$SHA" && GIT_SEQUENCE_EDITOR=true git rebase --interactive --autosquash "$SHA^"; }; f'
# amend-staged-to alias: amend only staged changes to a specific commit
git config --global amend-staged-to '!f() { SHA=$(git rev-parse "$1"); git stash -k && git commit --fixup "$SHA" && GIT_SEQUENCE_EDITOR=true git rebase --interactive --autosquash "$SHA^" && git stash pop; }; f'
# clean-branches alias: remove local branches deleted from GitLab
git config --global alias.clean-branches "!git branch -vv | grep ': gone]' | awk '{print \$1}' | xargs git branch -D && echo "" && git branch -vv"
Wrapping up
In this article, we covered how the GitLab UI and the GitLab flow can simplify the tasks that need to be done locally in a git project. Given that there are fewer tasks to do locally, using the GitLab flow can make the git CLI easier to learn and use, while progressively unlocking all the potential of the git CLI 🤓
Illustrations generated locally by DiffusionBee using FLUX.1-schnell model
Top comments (2)
It's very helpful :)
Thank you for your feedback, glad we helped some fellow developers 🤗