Wednesday, April 12, 2023

5 Useful Tricks To Rewrite Your Git History

 












Photo by Yancy Min on Unsplash

I believe nowadays every development should be done along with Git, a powerful collaboration tool to track every single change that occurs in a project so that developers can have a long history of things that have been done. This is very useful, especially if we are working in a team. But I personally will add it to all my projects even though I am working alone. Why? Because I can easily keep track of what I did in the past and quite quickly trace the bugs and mistakes.

In this article, we are going to discuss the useful tricks that we can use to manage our Git commits cleanly.

  1. Combine multiple commits (squash)
  2. Edit old commits message
  3. Undo the latest N commits
  4. Remove old commits (in the middle of the history list)
  5. Push modified commits (already pushed before) to your remote repository

As mentioned in the Git document, it basically does not provide a modify-history tool, but we can make use of the rebase tool to interactively modifying the old commits.

Do I Need To Modify My Old Commits?

First, let’s understand some scenarios.

Scenario 1.

Most of the time as a developer, when updating the code, I always want to keep it in Git so I can track the changes even though they are very small (to easily track which code is working and which has bugs). So I put a simple message just for me to know what was changed. I keep adding more commits for every small change. After a while, when the task is completed, I need to combine all those small changes into a single commit. Yes, that requires a trick to accomplish.

Scenario 2.

In the second scenario, I need to edit some of my commit messages (which I already added before). For example, it’s a development practice to always prepend the commit message with a ticket issue number such as “issue-781: Fixed glitches on button animation in Setting page” or “#541: Resolved constraint error.”

Be careful

Modifying commits that have already been pushed to a server is not recommended. It will confuse other developers with the history changes. Avoid doing it unless it is very important and other team members are aware of the modification.

So, how can we manage the old commits?

1. Combine Multiple Commits (squash)

As mentioned in the first scenario above, we don’t want to miss any changes, so we keep making Git commits for small updates. Let’s say we have a task “Create profile page,” and we have five commits doing the subtasks. In the end, once a module is complete, we want to combine all commits into a single commit and rephrase the message.

First, open Terminal and go to the specific project directory. Run git log to check the current commits that are already done. In the image below, I have five of the latest commits related to the same task that I want to combine.

$ git log












Here is the simple output using the glog command of zsh.










Step 1.

Run this command to modify the previous N commits. In this case, I have five commits to be squashed and combined into a single commit. The 5 at the end of the command indicates how many commits we are going to rebase. It may differ, depending on how many commits you are modifying.

$ git rebase -i HEAD~5

You will get similar output as shown below interactively. It will show the common terminal editor you always use (in my case is the vi editor). The five commits displayed at the top are ready to be modified. The bottom (in the yellow box) is the available operation we can perform.










Step 2.

Change the word pick to s or squash to squash those four commits and keep the oldest commit at the top. Then save the changes (depends on the editor — for vi editor, it’s Esc, then :wq”).



After saving, you will be prompted with the message asking you to write the commit message.









Step 3.

There are three options to update the message.

1) Immediately save without changing anything. The result that you will get is a commit with all messages (written in multiple lines).

2) Remove all previous messages, and write a new one. (Sometimes I use this to simplify the commit message.)

3) You can amend the new message at the top and leave other commit messages (recommended). For example, a new commit added is “Module-134: Completed Profile module.” So we can still keep all previous messages. The final commit message will look similar to what is shown below.

Module-134: Completed Profile module // newly addedAdded Profile page and custom HeaderViewModified Profile image with shadowAdded edit Profile buttonAdded API service for profileFixed API service error handling

In this tutorial, we will use the third option. You add a new message at the top as a combined message. The output will be like this. (Take note that the text after # will not appear in the commit message, so you can ignore it.)



Once it is successfully saved, the Terminal output will be similar to that below.

$ git rebase -i HEAD~5
[detached HEAD c4aeecc] Module-134: Completed Profile module
Date: Sun May 9 10:05:08 2021 +0800
5 files changed, 5 insertions(+)
create mode 100644 ProfileViewController.swift
create mode 100644 ProfileHeaderView.swift
create mode 100644 ProfileApiService.swift
Successfully rebased and updated refs/heads/master.

Cool, let’s check what happens in the commit that we have modified. You can check using git log or Sourcetree.









Yay, we did it. Now it has become a single commit with all five messages.

How about this?






Take a look at the image above. It shows the common commits we always do when we try to fix some nasty bugs. This trick is very useful to make it clean. Run this command and follow the same steps as discussed above. Awesome!

$ Git rebase -i HEAD~4

2. Edit Old Commits Message

“Oh no, I just need to update the typo in my commit message!”

Yes, that’s also a common thing we do, make a Git commit with a typo. In this part, we will just learn how to simply edit the commit message. This is necessary when we need to prepend the old message with the ticket/issue number or it might have a typo.

The command is the same as before, except the option that you need to choose is r, or reword.

In this example, I need to prepend all of these four commits with a Jira-specific issue number, namely “WR-453.”






Step 1.

Run the same Git rebase command, in which N equal to 4.

$ git rebase -i HEAD~4

Step 2.

Change the pick to r or reword, and save.



Next you will be prompted to edit those messages interactively one by one four times. Nice. Keep editing each message and saving until you reach the end of the steps.

Take a look at the screen below. It shows at the top of the message that we are editing, and highlighted at the bottom are notes on the steps that are done and remaining.



Once done, the result shown in the terminal output will be similar to that below. Also, we may check using git log to make sure the commit messages were updated accordingly.

$ git rebase -i HEAD~4
[detached HEAD 71ebada] WR-453: Fix crashes cause by improper nil handler
Date: Sun May 9 17:31:47 2021 +0800
1 file changed, 1 insertion(+)
create mode 100644 ViewController.swift
[detached HEAD d48428a] WR-453: Again fix as previous
Date: Sun May 9 17:32:19 2021 +0800
1 file changed, 1 insertion(+)
[detached HEAD 4c74ecc] WR-453: Added viewModel for Auth page
Date: Sun May 9 18:06:45 2021 +0800
1 file changed, 1 insertion(+)
create mode 100644 AuthViewModel.swift
[detached HEAD a45b5fc] WR-453: Added auth vc and viewModel
Date: Sun May 9 20:16:50 2021 +0800
2 files changed, 2 insertions(+)
Successfully rebased and updated refs/heads/main.

3. Undo the Latest Commit

The undo process is a bit dangerous, especially if you want to undo an important or big commit. Generally, there are two ways to undo a commit (other than using rebase).

Undo a commit and keep the files in stages (recommended)

Using this method, you won’t lose the changes and you can revise the files again for future modification or maybe simply throw them into the stash. The “stages” normally happen when we call the Git add command (remember the stage and unstage).

The process behind this command is to reset the Git HEAD to the previously pointed commit and move the affected files from the commits after the HEAD into stages.

For example, in my case here, I have two commits to undo. You may change the number at the back depending on how many commits you want to undo. If you need to undo just the last commit, so change it to 1.



Run the below command to execute with --soft flag to keep the files on staging.

$ git reset --soft HEAD~2

Then, when you run git status to check the result, you will see that the undone file is still there in the stages. Great!



Undo a commit and discard all changes

$ git reset --hard HEAD~2

Be careful and make sure you specify the right number to undo at the end of the command. It is quite similar to the previous safe undo, except this will replace the--soft with--hard flag to ignore the changes in undo commit. You may refer to this detailed discussion on undo commit in Stack Overflow.

4. Remove Old Commits (in the Middle of the History List)

This is rarely implemented because we should keep the old commits although there were mistakes in the commits. But for the commits that are still in local development and need some cleanup, we could use this trick.

Again we need to check which targeted commit we want to remove and count the N from the latest commit. Then we use the same git rebase command as demonstrated in the first trick, but now with the drop option.

In the below example, I want to remove the old commit (“Added parallax header…”) that is having bugs and currently not needed.



Run this command and change pick for the targeted commit to d or drop. In this case, we have to make sure we rebase until six commits are done (count from top).

$ git rebase -i HEAD~6

The terminal output will interactively prompt for which commit to modify.



Great! Once done, the terminal will show the success message:

Successfully rebased and updated refs/heads/main.

Let’s run the Git log to make sure the commit has disappeared from this world. Awesome!



5. Push Modified Commits (Already Pushed Before) To Remote Repository

There are a few cases where we need to overwrite the remote Git record. For example, we might need to edit our commit message (prepending a Jira ticket number or rephrasing), remove an unnecessary commit that caused an issue for the project, or clean up the Git history.

Thus we could pull all recent commits and begin modifying in local. However, in order to push to a remote server, we need to add a flag to inform the server that we are making the changes by force. Otherwise, the server will reply with an error on updating a different history than that already recorded.

$ git push
To github.com:xmhafiz/GitExercise.git
! [rejected] main -> main (non-fast-forward)
error: failed to push some refs to 'github.com:xmhafiz/GitExercise.git'

Therefore, we need to run the push command with the --force-with-lease flag, as shown below (success output).

$ git push --force-with-lease
...
...
To github.com:xmhafiz/GitExercise.git
+ 183f370...b33950e main -> main (forced update)

Awesome. Now you understand how to properly modify old commits. Try to implement these techniques carefully, and please do some practice before trying them in a real project.

Hopefully, this article will help you improve your development skills. Thanks for reading. Feedback is most welcome. Happy Git-ing!

Thursday, March 9, 2023

Git ignore

 Git sees every file in your working copy as one of three things:

  1. tracked - a file which has been previously staged or committed;
  2. untracked - a file which has not been staged or committed; or
  3. ignored - a file which Git has been explicitly told to ignore.

Ignored files are usually build artifacts and machine generated files that can be derived from your repository source or should otherwise not be committed. Some common examples are:

  • dependency caches, such as the contents of /node_modules or /packages
  • compiled code, such as .o, .pyc, and .class files
  • build output directories, such as /bin, /out, or /target
  • files generated at runtime, such as .log, .lock, or .tmp
  • hidden system files, such as .DS_Store or Thumbs.db
  • personal IDE config files, such as .idea/workspace.xml

Ignored files are tracked in a special file named .gitignore that is checked in at the root of your repository. There is no explicit git ignore command: instead the .gitignore file must be edited and committed by hand when you have new files that you wish to ignore. .gitignore files contain patterns that are matched against file names in your repository to determine whether or not they should be ignored.

Git ignore patterns

.gitignore uses globbing patterns to match against file names. You can construct your patterns using various symbols:

PatternExample matchesExplanation*
**/logslogs/debug.log
logs/monday/foo.bar
build/logs/debug.log
You can prepend a pattern with a double asterisk to match directories anywhere in the repository.
**/logs/debug.loglogs/debug.log
build/logs/debug.log
but not
logs/build/debug.log
You can also use a double asterisk to match files based on their name and the name of their parent directory.
*.logdebug.log
foo.log
.log
logs/debug.log
An asterisk is a wildcard that matches zero or more characters.
*.log
!important.log
debug.log
trace.log
but not
important.log
logs/important.log
Prepending an exclamation mark to a pattern negates it. If a file matches a pattern, but also matches a negating pattern defined later in the file, it will not be ignored.
*.log
!important/*.log
trace.*
debug.log
important/trace.log
but not
important/debug.log
Patterns defined after a negating pattern will re-ignore any previously negated files.
/debug.logdebug.log
but not
logs/debug.log
Prepending a slash matches files only in the repository root.
debug.logdebug.log
logs/debug.log
By default, patterns match files in any directory
debug?.logdebug0.log
debugg.log
but not
debug10.log
A question mark matches exactly one character.
debug[0-9].logdebug0.log
debug1.log
but not
debug10.log
Square brackets can also be used to match a single character from a specified range.
debug[01].logdebug0.log
debug1.log
but not
debug2.log
debug01.log
Square brackets match a single character form the specified set.
debug[!01].logdebug2.log
but not
debug0.log
debug1.log
debug01.log
An exclamation mark can be used to match any character except one from the specified set.
debug[a-z].logdebuga.log
debugb.log
but not
debug1.log
Ranges can be numeric or alphabetic.
logslogs
logs/debug.log
logs/latest/foo.bar
build/logs
build/logs/debug.log
If you don't append a slash, the pattern will match both files and the contents of directories with that name. In the example matches on the left, both directories and files named logs are ignored
logs/logs/debug.log
logs/latest/foo.bar
build/logs/foo.bar
build/logs/latest/debug.log
Appending a slash indicates the pattern is a directory. The entire contents of any directory in the repository matching that name – including all of its files and subdirectories – will be ignored
logs/
!logs/important.log
logs/debug.log
logs/important.log
Wait a minute! Shouldn't logs/important.log be negated in the example on the left

Nope! Due to a performance-related quirk in Git, you can not negate a file that is ignored due to a pattern matching a directory
logs/**/debug.loglogs/debug.log
logs/monday/debug.log
logs/monday/pm/debug.log
A double asterisk matches zero or more directories.
logs/*day/debug.loglogs/monday/debug.log
logs/tuesday/debug.log
but not
logs/latest/debug.log
Wildcards can be used in directory names as well.
logs/debug.loglogs/debug.log
but not
debug.log
build/logs/debug.log
Patterns specifying a file in a particular directory are relative to the repository root. (You can prepend a slash if you like, but it doesn't do anything special.)

** these explanations assume your .gitignore file is in the top level directory of your repository, as is the convention. If your repository has multiple .gitignore files, simply mentally replace "repository root" with "directory containing the .gitignore file" (and consider unifying them, for the sanity of your team).*

In addition to these characters, you can use # to include comments in your .gitignore file:

# ignore all logs
*.log

You can use \ to escape .gitignore pattern characters if you have files or directories containing them:

# ignore the file literally named foo[01].txt
foo\[01\].txt

Shared .gitignore files in your repository

Git ignore rules are usually defined in a .gitignore file at the root of your repository. However, you can choose to define multiple .gitignore files in different directories in your repository. Each pattern in a particular .gitignore file is tested relative to the directory containing that file. However the convention, and simplest approach, is to define a single .gitignore file in the root. As your .gitignore file is checked in, it is versioned like any other file in your repository and shared with your teammates when you push. Typically you should only include patterns in .gitignore that will benefit other users of the repository.

Personal Git ignore rules

You can also define personal ignore patterns for a particular repository in a special file at .git/info/exclude. These are not versioned, and not distributed with your repository, so it's an appropriate place to include patterns that will likely only benefit you. For example if you have a custom logging setup, or special development tools that produce files in your repository's working directory, you could consider adding them to .git/info/exclude to prevent them from being accidentally committed to your repository.

Global Git ignore rules

In addition, you can define global Git ignore patterns for all repositories on your local system by setting the Git core.excludesFile property. You'll have to create this file yourself. If you're unsure where to put your global .gitignore file, your home directory isn't a bad choice (and makes it easy to find later). Once you've created the file, you'll need to configure its location with git config:

$ touch ~/.gitignore
$ git config --global core.excludesFile ~/.gitignore

You should be careful what patterns you choose to globally ignore, as different file types are relevant for different projects. Special operating system files (e.g. .DS_Store and thumbs.db) or temporary files created by some developer tools are typical candidates for ignoring globally.

Ignoring a previously committed file

If you want to ignore a file that you've committed in the past, you'll need to delete the file from your repository and then add a .gitignore rule for it. Using the --cached option with git rm means that the file will be deleted from your repository, but will remain in your working directory as an ignored file.

$ echo debug.log >> .gitignore
  
$ git rm --cached debug.log
rm 'debug.log'
  
$ git commit -m "Start ignoring debug.log"

You can omit the --cached option if you want to delete the file from both the repository and your local file system.

Committing an ignored file

It is possible to force an ignored file to be committed to the repository using the -f (or --force) option with git add:

$ cat .gitignore
*.log
  
$ git add -f debug.log
  
$ git commit -m "Force adding debug.log"

You might consider doing this if you have a general pattern (like *.log) defined, but you want to commit a specific file. However a better solution is to define an exception to the general rule:

$ echo !debug.log >> .gitignore
  
$ cat .gitignore
*.log
!debug.log
  
$ git add debug.log
  
$ git commit -m "Adding debug.log"

This approach is more obvious, and less confusing, for your teammates.

Stashing an ignored file

git stash is a powerful Git feature for temporarily shelving and reverting local changes, allowing you to re-apply them later on. As you'd expect, by default git stash ignores ignored files and only stashes changes to files that are tracked by Git. However, you can invoke git stash with the --all option to stash changes to ignored and untracked files as well.

Debugging .gitignore files

If you have complicated .gitignore patterns, or patterns spread over multiple .gitignore files, it can be difficult to track down why a particular file is being ignored. You can use the git check-ignore command with the -v (or --verbose) option to determine which pattern is causing a particular file to be ignored:

$ git check-ignore -v debug.log
.gitignore:3:*.log  debug.log

The output shows:

<file containing the pattern> : <line number of the pattern> : <pattern>    <file name>

You can pass multiple file names to git check-ignore if you like, and the names themselves don't even have to correspond to files that exist in your repository.

Source

Drop all tables in mysql

This sql clause will generate drop clauses

SELECT concat('DROP TABLE IF EXISTS `', table_name, '`;') FROM information_schema.tables WHERE table_schema = 'MyDatabaseName';

Get result and paste to command line