Read more

Git restore vs. reset for reverting previous revisions

Felix Eschey
September 14, 2023Software engineer at makandra GmbH

The git doc states Show archive.org snapshot on the difference of these two commands:

  • git-restore[1] is about restoring files in the working tree from either the index or another commit. This command does not update your branch. The command can also be used to restore files in the index from another commit.
  • git-reset[1] is about updating your branch, moving the tip in order to add or remove commits from the branch. This operation changes the commit history.

git reset can also be used to restore the index, overlapping with git restore.

Background

git restore

Illustration web development

Do you need DevOps-experts?

Your development team has a full backlog? No time for infrastructure architecture? Our DevOps team is ready to support you!

  • We build reliable cloud solutions with Infrastructure as code
  • We are experts in security, Linux and databases
  • We support your dev team to perform
Read more Show archive.org snapshot

Regarding the git doc Show archive.org snapshot restore does by default:

The command can also be used to restore the content in the index with --staged, or restore both the working tree and the index with --staged --worktree.

By default, if --staged is given, the contents are restored from HEAD, otherwise from the index.

What does this mean?

  • git restore --staged <path> reverts changes from the staging area to match the content of HEAD.
  • git restore --worktree <path> reverts changes from the working tree to match the index from the current revision of HEAD including all files added to the index in previous commits.
  • git restore --staged --worktree <path> discards local changes and overwrites both to match the current HEAD.

But what about --source?
As the doc states

Use --source to restore from a different commit.
it is a hint that it acts as if it would restore changes from the checked out state of that commit.

The git doc Show archive.org snapshot also states on the source option:

If not specified, the contents are restored from HEAD if --staged is given, otherwise from the index.

This means it will use the index and the HEAD as if that commit was checked out, such that git restore --staged --worktree --source <commit hash> uses the index and the HEAD of the checked out state:

  • worktree: For the working directory it will simply restore the working directory to the given commit.

  • staged: For the staging area it will restore the changes within the staging area and apply the diff to working directory to keep the changes of the source commit.

    The combination of --staged --source is especially unique about this command, because it allows you to compare the index (or the staging area) of two commits, inspect the differences and apply or revert them selectively for your current need of change.

    For a more detailed example on this behavior compared to the --worktree option you can have a look on the example within the addendum of this card.

git reset

git reset <commit hash> moves HEAD to a specified commit and resets the state of the repository to that commit. The different options let you decide how to deal with changed differences compared to the command, i.e. reverting the index/working directory or not.

--mixed (default)

Resets to the state of that commit by setting the working tree to the state of the given commit and discarding changes in the staging area. It keeps changes in the working directory untouched.

--soft

Resets to the state of that commit by setting the staging area to the state of that commit and keeping changes in the working and in the index untouched.

--hard

Resets to the state of the given commit by resetting all introduced changes in the working tree and the staging area.

Difference

So as we've seen both can be used to revert revisions within the index and the working directory. Here is a short overview of the main differences:

  • It is especially unique about git restore that you can revert the working directory compared to a given index.
  • git restore does not move the HEAD, this is especially useful if changing the HEAD may lead to side effects, since commits may be lost after resetting and pushing.
    • If you use git reset with a path it also does not move the HEAD, so in this case you can use these commands quite similar.

Also see

Addendum

Example on git restore with --source for the usage --staged compared to --worktree

To get a better idea, why this behavior might be useful I will add an example for both commands.

Let's say we refactored the code to match a simple default controller implementation and we lost some controller logic due to refactoring. We might want to revert some of the changes or at least compare them with a previous revision.

Let's say we have the following staged changes in our repository:

git status

  Changes to be committed:
    (use "git restore --staged <file>..." to unstage)
  	modified:   app/controllers/actors_controller.rb
  	modified:   app/controllers/movies/merges_controller.rb
  	modified:   app/controllers/movies_controller.rb
  	modified:   app/controllers/roles_controller.rb
  	modified:   app/controllers/sessions_controller.rb
  	modified:   app/controllers/users_controller.rb
git diff
        
     diff --git a/app/controllers/movies/merges_controller.rb b/app/controllers/movies/merges_controller.rb
       
         def merge_params
      -    params.require(:movie_merge).permit(:source_movie_id, :target_movie_id)
      -
      -  rescue ActionController::ParameterMissing
      -    {}
      +    merge_params = params[:movie_merge]
      +    merge_params ? merge_params.permit(:source_movie_id, :target_movie_id) : {}
         end
       end   
       
       
       diff --git a/app/controllers/movies_controller.rb b/app/controllers/movies_controller.rb
      
       def create
       -  build_movie
       -  save_movie or render :new
       
       + if @movie.save
       +   redirect_to @movie
       + else
       +   build_showtimes
       +   render :new
       + end
       end
      
       .... # more diffs

git restore --staged

Now when we want to revert HEAD to a commit before the changes were introduced to overwrite the staged changes:

git restore --staged --source HEAD~1 app/controllers

git status

  Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	modified:   app/controllers/application_controller.rb
	modified:   app/controllers/movies/merges_controller.rb
	modified:   app/controllers/movies_controller.rb
	modified:   app/controllers/roles_controller.rb

  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:   app/controllers/application_controller.rb
  	modified:   app/controllers/movies_controller.rb

So the first difference we can see, is that some changes are within the index and some are within the working directory. This seems strange it first, because we expected the command to restore the staging area. Let's dig a little bit deeper here.

Now the staging area contains all staged changes without conflicts and applies introduced changes from HEAD~1 by overwriting the staging area:

git diff --cached

  diff --git a/app/controllers/movies/merges_controller.rb b/app/controllers/movies/merges_controller.rb

     def merge_params
  -    params.require(:movie_merge).permit(:source_movie_id, :target_movie_id)
  -
  -  rescue ActionController::ParameterMissing
  -    {}
  +    merge_params = params[:movie_merge]
  +    merge_params ? merge_params.permit(:source_movie_id, :target_movie_id) : {}
     end
   end
   
   
   diff --git a/app/controllers/movies_controller.rb b/app/controllers/movies_controller.rb

    +  def create
    +    build_movie
    +    save_movie or render :new
    +  end
    
     .... # more diffs

But what about the working directory?

git diff 

  diff --git a/app/controllers/movies_controller.rb b/app/controllers/movies_controller.rb
  
  -  def create
  -    build_movie
  -    save_movie or render :new
  -  end
    
  +  def create
  +    build_movie
  +    if @movie.save
  +      redirect_to @movie
  +    else
  +      build_showtimes
  +      render :new
  +    end
  + end
  
  .... # more diffs

So it contains all changes which had a conflict in applying HEAD~1, but keeps the state of HEAD.

git restore --worktree

Let's say we have the same state, but all changes now are within the working directory and not within the staging area:

git status

  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:   app/controllers/movies/merges_controller.rb
  	modified:   app/controllers/movies_controller.rb
  	modified:   app/controllers/roles_controller.rb

Now we apply git restore:

git restore --worktree --source HEAD~1 app/controllers

git status

  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:   app/controllers/application_controller.rb
  	modified:   app/controllers/movies/merges_controller.rb
  	modified:   app/controllers/movies_controller.rb
  	modified:   app/controllers/roles_controller.rb

So the first difference we see that all files are now within the working directory.
If we inspect the actual differences:

git diff

  diff --git a/app/controllers/movies/merges_controller.rb b/app/controllers/movies/merges_controller.rb
  
       def merge_params
    -    params.require(:movie_merge).permit(:source_movie_id, :target_movie_id)
    -
    -  rescue ActionController::ParameterMissing
    -    {}
    +    merge_params = params[:movie_merge]
    +    merge_params ? merge_params.permit(:source_movie_id, :target_movie_id) : {}
       end
     end

  diff --git a/app/controllers/movies_controller.rb b/app/controllers/movies_controller.rb
  
  +  def create
  +    build_movie
  +    save_movie or render :new
  +  end
  
  -  def create
  -    build_movie
  -    if @movie.save
  -      redirect_to @movie
  -    else
  -      render :new
  -    end
  -  end
  
  .... # more diffs

We can see that it directly restores all changes made within the working directory to the state of HEAD~1.

Felix Eschey
September 14, 2023Software engineer at makandra GmbH
Posted by Felix Eschey to makandra dev (2023-09-14 11:34)