Git should probably apologize for being confusing here, but Git never apologizes.
The "assume unchanged" and "skip worktree" flags are only for files that Git already knows about (files that are tracked).
Listing names or patterns in .gitignore
is only meaningful for files that are not tracked.
The word track, in Git, is overused: it means several different things.
To explain all this, we must carefully define what, precisely, tracked means in Git, in this context. But to do so requires that we look at Git's index, because the precise definition of a tracked file is a file that is listed in Git's index. So unless you know what the index is, none of this will make any sense anyway.
Defining Git's index
You have probably already seen that a Git repository contains—in fact, is mostly made up of—commits. A commit saves a complete snapshot of some source file(s), along with some extra information about the commit itself: who made it, when, and so on. Commits are not changes! Instead, commits carry a previous or parent commit ID, and if you ask Git to show you a commit, Git will extract both the commit itself, and its parent, and then compare the parent to the commit. Whatever is different, Git will show you that difference. This means that git show <hash>
shows a set of changes, but in fact, the <hash>
identifies a snapshot.
(The commits are identified by those big ugly hash IDs that you have no doubt also seen. These appear random, but actually they are cryptographic checksums of the entire contents of each commit. They are not very useful for humans to work with, though, so we tend to use branch names to identify the most recent commit in the branch.)
The files frozen inside Git commits are in a special, read-only, compressed format, useful only to Git itself. This means that in order to use the files, Git has to unfreeze and de-compress the files, turning them into their normal useful-on-the-computer form. This is your work-tree (or working tree or any variation on these words), where you do your work.
If Git were like other version control systems, it might stop there, with just the commits (frozen and Git-only) and work-tree (unfrozen, useful to everyone). But Git is not like other version control systems. Instead, Git inserts an intermediate location, which Git variously calls the index, the staging area, or the cache (depending on who / what part of Git is doing the calling).
Files in the index are stored in the same Git-only format as in commits, but when they are in the index, they are unfrozen. The git checkout
process essentially copies files from the frozen commit, to the index (unfreezing only), and then to the work-tree (uncompressing and making useful-format).
Why does this index even exist and require that you be aware of it? For a complete answer, you'd have to ask Linus Torvalds, but we can point out several things that it does:
It makes new commits lightning-fast. Git makes new commits by freezing the index contents. This is much faster than plodding through the work-tree, re-compressing every file. The files that are in the index are already in the special compressed Git-only form, so git commit
just has to freeze those copies.
It gives us (and Git) a way to decide which work-tree files are tracked and which are untracked.
A tracked file is one that has a copy in the index. That's all there is to it, but that's crucial, because as we just saw, Git makes new commits from whatever is in the index:
Running git add
copies a file from the work-tree into the index. If there was a copy in the index before, this overwrites the old copy with the new one from the work-tree. If there wasn't a copy before, now there is. That file, in the form just copied into the index, will go into the next commit you make.
Running git rm
removes a file from the index (and the work-tree). If there was a copy in the index before, now there isn't. The file won't be in the next commit you make.
It's not easy to see the contents of the index directly. (There is one command—git ls-files
—that will show it, but that particular command is mostly aimed at debugging and writing tools, not for everyday use.) Instead, the git status
command shows you what's different in the index. Specifically, it runs two comparisons:
First, git status
compares the current commit to the index. Whatever is different, git status
calls staged for commit. This includes files that are new in the index, removed from the index, or just different in the index from their HEAD
-commit version.
Then, git status
compares the index to the work-tree. Whatever is different, git status
calls not staged for commit. This includes files that are in the index but not the work-tree or vice versa, and of course files that are in both but are different.
Now we can define assume-unchanged
, skip-worktree
, and .gitignore
Now that we have a good idea what it means for a file to be in the index, so that that particular copy of the file goes into the next commit—or to not be in the index, so that it isn't in the next commit—we can look at what these various options mean and do.
If a file isn't in the index, but is in the work-tree, git status
will complain at you during the second comparison. It will tell you: Hey, this file is in the work-tree, maybe you should add it to the index. If you don't want git status
to complain about the untracked file, you can list the file in .gitignore
.
This listing only affects untracked files. If the file is already in the index—however it got there, whether that's from a commit or from you doing git add
on it—then the file is already tracked. Listing the file in .gitignore
will have no effect.
If the file is in the index and you've changed the work-tree copy, git status
will check on it, and tell you that the work-tree version is modified and maybe you should copy the update into the index, so that the updated version goes into the next commit. This is where assume-unchanged
and skip-worktree
come in: with either bit set, git status
will assume that the work-tree copy is not changed, or skip over it (or both) and not complain about it.
The git add
command obeys similar rules: if the file isn't in the index (but is in the work-tree) and you use an en-masse "add all the files" command, git add
will not add files that are both untracked and ignored. It will also not add files that are tracked but marked to be unchanged-or-skipped. So the untracked-and-ignored file won't become tracked and won't be in the next commit; and the tracked but skipped-over file won't be updated, so that the next commit will continue to use the old stale index copy.
Directories are a little weird
Git never stores directories (or "folders" if you prefer that word) in commits at all. Git only stores files. The index only contains files,1 and only the index entry itself has the assume-unchanged and skip-worktree bits, so you can't set this for a directory, you have to set it for all the tracked files within the directory.
The .gitignore
file, however, has a special feature, to speed up git status
. It turns out that searching through the work-tree is usually the slowest part of everything (which is probably why the index exists at all). So, if you list a directory name in .gitignore
and there are no files within that directory already in the index, Git takes a shortcut, and does not bother looking inside that directory at all.
This means that if none of the files dir/*
are currently tracked, and you list dir
in your .gitignore
, git status
and git add .
will never look inside dir
to find any of those files, so it will never add them and will never complain about them being untracked. Hence, listing a directory in .gitignore
can make Git not include any of the files within the directory. But once you have tracked at least one file inside the directory, Git feels obligated to scan through the directory anyway, so this doesn't always have this effect.
It's all a little confusing and in extreme cases, you may want to use git check-ignore -v
or even git ls-files --stage --debug
to find out which rule(s), if any, are ignoring some file(s) or exactly what's in the index, including the assume-unchanged and skip-worktree flags. But this mostly does work, and fairly well, in practice.
1Technically, the index can store some directory information, especially when using the untracked cache. However, git ls-files
doesn't show any of this, at least currently, even under --debug
.