You can write your own code to do this, but you cannot do this with Git "out of the box". The closest you can get is to do what you said you don't want to do:
The git update-index --assume-unchanged [<file>...]
will assume the file unchanged forever.
Actually, you probably should use --skip-worktree
here. (See Git - Difference Between 'assume-unchanged' and 'skip-worktree'.) Use --no-assume-unchanged
or --no-skip-worktree
to clear the bit you just set. Make a list of files on which you have set the assume-unchanged or skip-worktree bits, and you can clear and then re-set these bits whenever you like; that's the closest you get from Git as it is today.
Long: why Git is so stubborn
In particular, let's look at the summary subject line:
How [can I] selectively remove certain files from the local change list (not from the staged list) without removing from the index?
That's like asking how you can convince the Pope to switch from Islam to Buddhism. It starts from a wrong premise!
Fundamentally, there isn't any "local change list", nor is there is "staged list". There are only commits, the index, and the work-tree.
Each such entity contains some set of files:
The files in any given commit are frozen, and compressed into a special Git-only format to save space. You can never change any file saved inside a commit: all you can do is make a new commit that's very much like the old one, but with some different frozen files, and switch from your current commit (that had frozen version 17 of file A) to the new commit (that now has frozen version 18 of file A), for instance.
(And of course you can't add a file to a frozen commit, or remove one from it. Again, you can only make a new commit that's mostly, but not exactly, like the previous one.)
The files in your work-tree are unfrozen, and in ordinary format. You, and all your computer programs, can work with these (hence the name "work-tree" or "working tree"). You can read them, write them, destroy them, do whatever you like.
The index—also called, variously, the staging area or sometimes the cache, depending on who or what is doing the calling—lives these two positions. Files in the index are in the special Git-only format, but they are not frozen. You can replace them wholesale any time you like.
Let's add as a side note here that HEAD
always represents your current commit. (This is true even in a new, empty repository that has no commits: HEAD
still represents the current commit, which doesn't exist. This is what makes several Git commands act a little squirrelly at this point.)
Initially, the copies in the index match the copies in the current (and frozen) commit—and if you switch from that commit to some other commit, the copies in the index still match the frozen commit. Updating from commit a123456...
to fedcba9...
makes Git switch out your index from "copy of a123456
" to "copy of fedcba9
". In general—there are a few specific exceptions with plumbing commands—when Git does switch out the index copies, Git also switches out the work-tree copies.
What git add
does is replace the index copy of some file with one you take from the work-tree. So the normal process, in Git, is to git checkout
some commit, so that HEAD
has the frozen copies, the index has them thawed out (but the same as HEAD
), and your work-tree has them expanded into useful form (and the same as in the index). Then you change one in the work-tree and run git add
to copy it from the work-tree back into the index.
Now the index copy doesn't match the HEAD
copy, but does match the work-tree copy. So it's pretty common, in Git, to have the index copy of every file match one other active copy: either the HEAD
copy, or the work-tree copy. But it is, of course, possible to have the index copy match neither, because we can modify a work-tree file, copy it into the index, then modify it again. Now all three copies will differ.
At any time, we can run git commit
. This flash-freezes the index copies—which are already in the Git-only format, so they are all already ready—and deposits the frozen copies into the repository as the files for our new commit. The new commit then becomes the current (HEAD
) commit, and now the commit and the index match.1
(It's also possible, of course, to have files in one or more places that aren't in the others. If we create an all-new work-tree file, it's not in the index now. Using git add
will copy it into the index, in the special Git-only compressed form, but not frozen. Or, if a file is already in the index and work-tree, we can use git rm
to remove it from both. Of course, none of these can ever affect any frozen copy in any existing commit.)
We can, at any time, ask Git to compare the current commit—more precisely, all of HEAD
's frozen files—to the index:
git diff --cached
If we only want the names of "M"odified, newly-"A"dded, or "D"eleted" files, maybe including the letter-code for this state, we tell git diff
to print only the names, or the names and letter-code, for each file:
git diff --cached --name-only
git diff --cached --name-status
In all cases Git finds the answer by comparing the frozen HEAD
to the slushy index.
Likewise, we can ask Git to compare the index to the work-tree:
git diff
git diff --name-only
git diff --name-status
This does the same as before, except instead of comparing frozen HEAD
to slushy index, it compares slushy index to liquid work-tree.
What git status
does, to tell you about files, is to run both of these two git diff
operations. These produce those lists you mentioned above, but only for the duration of git status
running: once git status
has printed the lists and quit, those lists no longer exist.
What --assume-unchanged
and --skip-worktree
do is set a particular flag bit on some internal flags kept with each file-entry stored in the index. These flags tell Git: Don't bother comparing the index and work-tree copies, just assume they are the same. So at this point, git status
won't say anything about the file. Moreover, git add
will assume that the index copy is good, and not update it either—updating the index copy requires re-compressing the work-tree copy, after all, which takes a noticeable amount of time. (That's a significant part of why Git keeps the slushy, not-quite-frozen copy ready to go in the index. Having the file ready to go, in the next commit you will make, makes git commit
amazingly faster than older version control systems.)
Note that, since there is a separate copy in the index, new commits you make will have the file frozen into them. They just have the index copy—same as the old HEAD
copy—in them. But if you use git checkout
to switch from this commit to some other commit, and that other commit has a different copy of the frozen file, your index copy will be updated to match the other commit's copy. This is where Git - Difference Between 'assume-unchanged' and 'skip-worktree' comes in again.