Five Advanced Git Concepts that Make You Look Like a Pro - Part 2

Feature image


Working with Git is one of the first and simplest skills a developer can learn. But this simplicity doesn’t prevent Git from providing solutions to certain unique scenarios in version control, as our previous tutorial on advanced Git concepts shows.

Learn to master Git with these advanced concepts - Part 1

Today, we will introduce you to five more advanced Git concepts to help you fast-track the way to becoming a Git Pro. With commands like Git reset, revert, and squash, you’ll find new ways to play around with commit history if you follow along.


Safely undo your commits with Git revert

Git revert is a way of “undoing” a commit without losing history. It works as a traditional undo operation from the outer appearance. Running the command takes the code back to the state of a given past commit by inverting subsequent changes.

Reverting commits flow

Reverting commits flow

Internally, though, Git revert doesn’t move the branch tip back to a past commit and erase the inverted ones from history like an undo command. Instead, it appends a new commit that modifies relevant files to mirror the inverted state to the tip of the branch. This mechanism allows Git revert to preserve the original commit history while seemingly going back in time. It has made Git revert one of the safest ways of inverting changes in your code.

When running this command, you should provide a reference to the commit that needs to be reverted as an input.

git revert <commit-ref>

Optionally, Git allows you to stage the inverted modifications in the branch without actually committing them. You should use the –no-commit flag with the revert command to enable this.

git revert --no-commit <commit-ref>

Move the tip of a branch to a selected commit with Git reset

Git reset is a more dangerous and decisive sibling of revert. It is primarily responsible for undoing code changes, just like the revert operation. However, rushing to use Git reset without understanding how it exactly works poses the threat of deleting your work permanently, with no going back.

In contrast to Git revert, different forms of the reset command can affect the three trees in a Git repository. That includes:

  • Commit history - The chain of commits made in the past using the git commit command.
  • Staging index - The area that holds the files staged using the git add command.
  • Working directory - The directory that stays in sync with the local file system, holding uncommitted and unstaged code changes.

The default form of Git reset inverts the changes made to the staging index and moves the branch tip to a given commit. If no commit reference is provided with the command, it considers the default value of HEAD as the input.

Therefore, if you want to unstage a file currently staged for a commit, you can use the command:

git reset <filename>

If you wish to remove all staged files and clear the staged index, simply using git reset achieves the job. To move the HEAD of the branch to a specific past commit, alter the command to this:

git reset <commit-ref>

This command naturally unstages all the staged files in the current branch. It also inverts any changes made after the given past commit, moving them to the working directory as unstaged modifications.

Git reset takes a second form when used with the –soft flag.

git reset --soft <commit-ref>

This soft reset option doesn’t modify the staging index of the branch, unlike the default mode. Instead, it simply inverts the commits made after the past commit and selects it as the HEAD. It preserves the modifications done after the final commit as they are and moves the changes introduced by inverted commits to the staging index.

Until this point, none of the reset options we used carried a real risk of losing uncommitted changes in a branch. The above forms of Git reset preserve the current work and the modifications added by inverted commits in at least the working directory. It makes them great choices for when you have to fix a bug found in a previous commit before continuing with the current work. The third form of Git reset, though, is much more lethal in handling the inverting operation.

The third form of Git reset uses the –hard flag.

git reset --hard <past-commit-ref>

It moves the tip of the branch directly to a past commit without retaining any later-added modifications in the working directory or the staging index. All the commits that came after the newly-assigned head are left dangling or orphaned in the branch. Git permanently deletes these commits during its next garbage collection.

Despite its danger, Git hard reset becomes quite valuable when you want to remove all traces of a faulty commit from the repository and commit history. It works as the perfect undo button in situations like that. But if you use this command without properly understanding the consequences, you run into the real risk of losing days’ worth of work in a second. It’s best to avoid hard resets in a branch with several collaborators to prevent something like that from happening to you and your colleagues.

Hard reset flow

Hard reset flow


Undo an accidental hard reset with the help of Git reflog

Despite the notoriety of the Git hard reset, everything won’t be doom and gloom if you accidentally undo an important commit with this command. You actually have Git reflog to thank for that.

Reflogs or reference logs are a mechanism Git uses to keep track of the changes in the tips of branches. It records references to the commits that had been branch tips in the repository’s history. You can view this log by running the git reflog command.

$ git reflog
f6250cf (HEAD -> feature-1) HEAD@{0}: commit: modify input access
cf2feee HEAD@{1}: commit: update interface

If you move the tip of the branch using a hard reset, the reflog will store a record with references to the commits involved in this operation.

$ git reset --hard HEAD~2
HEAD is now at 817a087 Add nested memeber visiting to solve IllegalAccessError

$ git reflog
817a087 (HEAD -> feature-1) HEAD@{0}: reset: moving to HEAD~2
f6250cf HEAD@{1}: reset: moving to HEAD
f6250cf HEAD@{2}: commit: modify input access
cf2fe1e HEAD@{3}: commit: update interface

This record allows you to acquire the reference to the discarded branch head, which is now dangling without being a part of the commit history. If you had used Git log instead of Git reflog in this case, it would not have shown these abandoned commits.

With the reference to the previous HEAD acquired, you can now run a hard git reset to assign it as the new tip of the branch.

$ git reset --hard f6250cf
HEAD is now at f6250cf modify input access

You can see that this process allows you to undo a hard reset and bring the removed commits back to the commit history. However, it still won’t recover any uncommitted work you lost due to a hard reset. If you had made any new commits after moving the branch tip to the past commit, you’d also lose those changes during this process.

Restoring a commit abandoned due to a hard reset with the help of reflog can be a lifesaver for developers. Its effectiveness, however, comes with one condition. Since Git deletes any abandoned commit in a repository during garbage collection, you only have a limited time window to run the recovery operation. It usually takes about a month for Git to perform a new round of garbage collection. So, if you want to take advantage of this lifeline Git relog provides, the quicker you are, the higher your chance of success will be.


Clean your commit history by combining redundant commits with interactive Git rebase

Suppose you’ve made several messy or redundant commits in your repo during development. Then, you might want to have a way to squash them together to give the commit history a cleaner flow. But does Git support such a command that bundles multiple commits into one?

Though not directly, the answer is yes. You need the help of interactive Git rebase to achieve this.

Let’s see how we can squash a few commits with an example. We’ll try to squash the commits from HEAD~4 to HEAD~2 into one with this method.

As the first step, you must start a Git rebase operation in the interactive mode at HEAD~5.

git rebase -i HEAD~5

It opens the following editor window where you can select which function to perform on each commit from HEAD~4 to the current HEAD.

hint: Waiting for your editor to close the file... 
pick a76599adc4 Roll Flutter Engine from e771729efdde to 7d0f6d2f11df (2 revisions) (#108757)
pick c7eb5b943e Enable conditional_uri_does_not_exist (#108652)
pick 2d4f208a66 Roll Flutter Engine from 7d0f6d2f11df to b257966d8daa (3 revisions) (#108763)
pick d31dd0f69a Roll Flutter Engine from b257966d8daa to 60e5eb6f3c2c (3 revisions) (#108766)
pick 611514886b Reland `Linux_samsung_a02 openpay_benchmarks__scroll_perf` (#108466) (#108769)

# Rebase e499530152..611514886b onto e499530152 (5 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
#                    commit's log message, unless -C is used, in which case
#                    keep only this commit's message; -c is same as -C but
#                    opens the editor
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]

To squash commits from HEAD~4 to HEAD~2, we have to change the commands for HEAD~3 and HEAD~2 from “pick” to “squash.”

hint: Waiting for your editor to close the file... 
pick a76599adc4 Roll Flutter Engine from e771729efdde to 7d0f6d2f11df (2 revisions) (#108757)
squash c7eb5b943e Enable conditional_uri_does_not_exist (#108652)
squash 2d4f208a66 Roll Flutter Engine from 7d0f6d2f11df to b257966d8daa (3 revisions) (#108763)
pick d31dd0f69a Roll Flutter Engine from b257966d8daa to 60e5eb6f3c2c (3 revisions) (#108766)
pick 611514886b Reland `Linux_samsung_a02 openpay_benchmarks__scroll_perf` (#108466) (#108769)

# Rebase e499530152..611514886b onto e499530152 (5 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
#                    commit's log message, unless -C is used, in which case
#                    keep only this commit's message; -c is same as -C but
#                    opens the editor
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]

After saving the changes, Git executes the commands and combines the three commits. As a final step before completing the process, Git also allows you to enter a new commit message to the squashed commit.


Search through your Git logs to find what you want

You can search when a particular term was introduced to a branch with Git by looking through its commit history logs. For example, if you want to figure out which commits introduced and modified a variable, you can use the following command.

git log -S <search-term> --oneline

e.g.

$ git log -S "flutter_driver" --oneline

0cf9d41fc9 [flutter_driver] support send text input action (#106561)
98f0c3694b add old gallery page transition (#106701)
0dd0c2edca [platform_view]Send platform message when platform view is focused (#105050)
d647755ee3 Revert "Adding linux VM staging tests to .ci.yaml" (#103658)
041999ca1b Adding linux VM staging tests to .ci.yaml (#103165)
425aef22ac Adding two staging tests to ci.yaml (#103003)
83a88058d6 use dart pub deps to analyze consumer dependencies (#99079)

Additionally, you can use Git log line search option to view the change history of a particular line or a function in the code. The command uses the -L flag followed by comma-separated starting and ending line numbers and the file name under inspection.

git log -L <start>,<end>:file

For example:

git log -L 63,65:Collector.java

It outputs the history of the changes made to those lines under different commits. If you want to observe the history of a particular function, you can use the command in the following way.

git log -L :addDesc:Collector.java

Wrapping up

From revert and reset to searching through logs, this post introduced you to five advanced Git concepts. We hope you found something useful and new to add to your Git arsenal from the ones on this list. If you have some impressive Git tricks that have made your developer life easier, don’t hesitate to share them in the comments section.