I have come across the following three ways in order to unstage the files that were staged by the command 'git add'
git rm --cached <file>
git restore --staged <file>
git reset <file>
Their behaviors looked completely same when I ran those commands one by one. What exactly are the differences between them?
Two are the same; one is not, except under particular circumstances.
To understand this, remember that:
git addmeans make the copy in the index/staging-area/cache match the copy in my working tree (by copying from the working tree if the working tree copy is updated, or by removing from the index if the working tree copy is removed).So the index / staging-area contains, at all times, your proposed next commit, and was initially seeded from your current commit when you did a
git checkoutorgit switchto obtain that commit.1 Your working tree thus contains a third copy2 of each file, with the first two copies being the one in the current commit akaHEAD, and the one in the index.With that in mind, here's what each of your commands does:
git rm --cached file: removes the copy of the file from the index / staging-area, without touching the working tree copy. The proposed next commit now lacks the file. If the current commit has the file, and you do in fact make a next commit at this point, the difference between the previous commit and the new commit is that the file is gone.git restore --staged file: Git copies the file from theHEADcommit into the index, without touching the working tree copy. The index copy and theHEADcopy now match, whether or not they matched before. A new commit made now will have the same copy of the file as the current commit.If the current commit lacks the file, this has the effect of removing the file from the index. So in this case it does the same thing as
git rm --cached.git reset file: this copies theHEADversion of the file to the index, just likegit restore --staged file.(Note that
git restore, unlike this particular form ofgit reset, can overwrite the working tree copy of some file, if you ask it to do so. The--stagedoption, without the--worktreeoption, directs it to write only to the index.)Side note: many people initially think that the index / staging-area contains only changes, or only changed files. This is not the case, but if you were thinking of it this way,
git rm --cachedwould appear to be the same as the other two. Since that's not how the index works, it's not.1There are some quirky edge cases when you stage something, then do a new
git checkout. Essentially, if it's possible to keep a different staged copy in place, Git will do so. For the gory details see Checkout another branch when there are uncommitted changes on the current branch.2The committed copy, and any staged copy, are actually kept in the form of an internal Git blob object, which de-duplicates contents. So if these two match, they literally just share one underlying copy. If the staged copy differs from the
HEADcopy, but matches any—perhaps even many—other existing committed copy or copies, the staged copy shares the underlying storage with all those other commits. So calling each one a "copy" is overkill. But as a mental model, it works well enough: none can ever be overwritten; a newgit addwill make a new blob object if needed, and if nobody uses some blob object in the end, Git eventually discards it.A specific example
In a comment, pavel_orekhov says:
Let's check out a specific commit in the Git repository for Git itself (clone it first if needed, e.g., from https://github.com/git/git.git):
Your working tree will contain files named
Makefile,README.md,git.c, and so on.Let's now modify some existing file in the working tree:
The
>signs are from the shell asking for input; the two numbers are the byte counts of the fileMakefile. Note the output fromgit statusisSPACEMSPACEMakefile, indicating that the index or staging area copy ofMakefilematches theHEADcopy ofMakefile, while the working tree copy ofMakefilediffers from the index copy ofMakefile.(Aside: I accidentally added two
foolines while preparing the cut and paste text. I'm not going to go back and fix it, but if you do this experiment yourself, expect slightly different outputs.)Let's now
git addthis updated file, then replacefooin the first line withbar:Note that the
Mhas moved left one column, M-space-space-Makefile, indicating that the index copy ofMakefilediffers from theHEADcopy, but now the index and working tree copies match. Now we do the foo-to-bar replacement:We now have two
Ms: theHEADcopy ofMakefilediffers from the index copy ofMakefile, which differs from the working tree copy ofMakefile. Runninggit diff --cachedandgit diffwill show you exactly how each pairing compares.Now, if we run
git rm --cached Makefile, this will remove the index copy of the fileMakefileentirely, andgit statuswill change accordingly. Because we have all these modifications going around Git demands the "force" flag as well:We now have no file named
Makefilein our proposed next commit in the index / staging-area. However, the fileMakefilestill appears (with the first line readingbar) in the working tree (inspect the file yourself to see). ThisMakefileis an untracked file so we get two output lines fromgit status --short, one to announce the impending demise of fileMakefilein the next commit, and the other to announce the existence of the untracked fileMakefile.Without making any commit, we now use
git restore --staged Makefile:The status is now space-M again, indicating that
Makefileexists in the index (and therefore will be in the next commit), and furthermore, matches theHEADcopy of Makefile, sogit diff --staged—which is another way to spellgit diff --cached—will not show it (and indeed will show nothing). The working tree copy remains undisturbed, and still contains the extra linebar, asgit diffshows:Again, the key to understanding all of this is:
Every commit holds a full snapshot of every file that Git knows about.
This snapshot exists, at all times, in Git's index, which Git also calls the staging area, or occasionally—now mostly in the
--cachedflag—the cache. The--stagedor--cachedflag3 generally means do something with this index / staging-area. Commands likegit reset,git rm, andgit addimplicitly work with the index / staging-area, although flags may modify this behavior somewhat; thegit restorecommand has the explicit--stagedand--worktreeflags.Meanwhile, your working tree contains ordinary everyday files. These are the only files you can see and work with directly (with your editor for instance); only Git commands can see and work with the committed and index copies of files.
Committed copies of files can never be changed. They are in those commits forever (or as long as those commits continue to exist): they are read-only. However, the index copy of a file can be replaced wholesale, with
git add, or patched, withgit add -p, or removed entirely, withgit rmorgit rm --cached.Ordinary files are, well, ordinary files: all your ordinary commands work ordinarily on the ordinary files. (And isn't it extraordinary how the ordinary word "ordinary" is now amusing?)
Running
git committakes all the index copies and freezes them into a new snapshot. So what you do, as you work in Git, is:git addthem to update Git's index copy, to prepare the freeze; andgit committhe result, to freeze them for all time.This is the process for making a new commit, and if you change your mind and decide not to make a new commit,
git restore --stagedorgit resetcan be used to re-extract a committed copy into the index copy. Butgit rmremoves an index copy entirely.So if and only if removing the index copy entirely puts things back the way they were (which can happen when some file is new), then "make the index copy match the nonexistent
HEADcopy, by removing it" is a correct way to do what you want. But if theHEADcommit contains a copy of the file in question,git rm --cached the-fileis wrong.3Note that
--cachedand--stagedhave the same meaning forgit diff. Forgit rm, however, there's simply no--stagedoption at all. Why? That's a question for the Git developers, but we can note that historically, in the distant past,git diffdid not have--stagedeither. My best guess is therefore that it was an oversight: when whoever added--stagedtogit diffdid it, they forgot to add--stagedtogit rmtoo.