Git nothing added to commit but untracked files present – even with those files added to gitignore

The list of untracked files reads (this is just a straight quote of your own quote):

.gitignore
.vs/
3dengine_headeronly.sln
3dengine_headeronly/
CMakeLists.txt
Debug/
Tests/
cmake-build-debug/
packages/
resources/

Now, note that there are only three names here that do not end with a slash:

  1. .gitignoreYou probably should add and commit this file, so you probably do want it to be untracked at the moment, until you add and commit it.
  2. 3dengine_headeronly.slnThis file is not listed as “do not auto-add nor complain about untracked-ness”. Check it out yourself: search through your .gitignore contents for anything resmbling 3dengine_headeronly.sln. One entry that comes close is the last line in this group:# JetBrains Rider .idea/ *.sln.iml which lists *.sln.iml as “do not auto-add nor complain”. But 3dengine_headeronly.sln ends with .sln, not .sln.iml. Another is the line *.sln.docstates, but again, that ends with .sln.docstates, and the file’s actual name ends with just .sln.
  3. CMakeLists.txtAs before, there is no entry that would make Git shut up about this.

That leaves unexplained the entries ending with slashes. The problem with diagnosing this is that Git has summarized the contents of those directories, rather than listing the individual files. One can only explain this if one has the actual file names, which requires running git status with the -u or --untracked-files= flag, and giving this flag the word all to tell Git to list each untracked file individually.

Without this flag … well, let’s say, for instance, that Tests contains:

Tests/a.b
Tests/c.d
Tests/e.obj

One of these three files matches the *.obj pattern, but the other two match no pattern, so Git will complain that Tests/a.b and Tests/c.d are untracked. But without -uall, the way Git will complain about this is to say that Tests/ is untracked. This is true even if every file within Tests/ is marked as “don’t complain” except for one file in Tests or some subdirectory within Tests: Git will summarize the one file by printing Tests/ because there are no tracked files within Tests/, but there is at least one untracked file within Tests/ that Git has not been told “shut up about this file”.

There is one that is pretty mysterious, and that one is Debug/. You do have this line in your .gitignore:

[Dd]ebug/

This should cause all files within Debug and any subdirectory inside Debug to be not-complained-about. Using -uall will probably shed some light on what is going on, though. (See below for why this one is such a mystery.)

Long: how git status scans the work-tree

It’s important to realize that Git never tracks a directory itself. What listing a directory in .gitignore does is give Git permission not to examine the directory.

To understand this we need to define what it means to track a file in the first place, and how Git examines your work-tree when it wants to collect up a list of untracked files for git status to complain about.

tracked file, in Git, is simply any file whose name is currently listed in Git’s index. To see which files are currently in the index, you can run git ls-files --stage (but note that in a big directory this tends to list a lot of files). These are the files that will be in the next commit you make. Initially, the index is full of all the files that were in the commit you most recently checked out. The next commit will contain all those same files. If you overwrite one of the index copies, the next commit will contain the updated index copy—and that’s what git add does, when you git add a file that’s already in the index.

Hence, an untracked file is simply any file that exists in the work-tree, but that is not currently in the index. Using git rm—with or without --cached—you can remove any file from the index, if it’s currently in the index. Once it’s gone from the index, if you’ve kept the work-tree copy, it has become untracked.

What git status does is to run two comparisons—two git diffs, in effect. The first one compares the current commit frozen files to the index’s unfrozen (but Git-ified) files: whatever is the same, Git says nothing about, and whatever is different, Git says: file with pathname P is staged for commit. This is way better than listing every file in the index: it tells you about file P only if it’s going to be different in the next commit.

The second comparison is the tricky one, and is where .gitignore comes in. To run the second comparison, Git compares the Git-ified but not-yet-frozen copy of a file that’s in the index with the regular, non-Git-ified copy in the work-tree. When the two are different, Git tells you that this file is not staged for commit. You can run git add to copy the work-tree copy over top of the index copy, so that the index and work-tree match—and now, presumably, the HEAD commit and index copies of the file differ, so that the next git status will say staged for commit.

That works great for files that are in both the index and the work-tree, but fails to alert you to files that you forgot to git add to copy into the index. So, having collected up the list of comparison results, Git also has to scan through the work-tree to find every file that’s actually there. Any file that is in the work-tree, but does not have a corresponding copy in the index, is untracked.

This scanning is one of the slower parts of git status (though the actual speed depends a lot on the speed of your operating system). Git starts by reading through every file and sub-directory (“folder”) name within the top level work-tree directory. It has to read this directory entirely, of course, because it’s the top level of the work-tree. Any file name in this directory represents a file in the work-tree, and the file is either tracked—in the index—or not. If the file is untracked, Git will complain about it unless you also suppress the complaint, using a .gitignore entry.

For directories found in the top level, though, git status does not check whether the directory is in the index, because directories are never in the index. Instead, Git just looks inside the directory to see what files and directories it contains. Any files within that directory have to be checked to see if they’re untracked, and if so, whether git status should gripe about them. If the directory contains its own sub-directories, those sub-directories must be scanned in the same way, recursively.

What listing a directory in .gitignore does is give Git permission to not look inside it. Let’s consider the Debug/ directory specifically, for instance. Let’s assume that there are no tracked files inside Debug/, and that you have run git status.

Git will start by reading the top level directory. In this directory, it finds files named .gitignore3dengine_headeronly.sln, and CMakeLists.txt. Those files are not in the index and not listed as a name or pattern in .gitignore, so git status will complain about them (at the end, when it lists untracked files). But it also finds .vs/3dengine_headeronly/Debug/Tests/cmake-build-debug/packages/, and resources/.

Now, if there are some tracked files within (say) packages, Git’s going to have to descend into packages anyway, to compare the index copies of those files with their work-tree copies. But we’ve assumed here that there are no tracked files in Debug. So git status should be able to run through the .gitignore entries and see that [Dd]ebug (Git removes the trailing slash for comparison purposes) matches Debug. This would permit git status to skip over Debug entirely, not reading it at all.

The fact that Debug/ comes out at the end, with just the trailing slash, means that Git must have opened and scanned the Debug directory. The only reason Git would do that is if there is at least one tracked file inside Debug. Hence:

git ls-files --stage | grep Debug/

will probably show at least one tracked file. Even so, it’s not clear why the [Dd]ebug/ rule did not match the untracked files contained in the directory, so getting a list of the exact names, and using git --no-index check-ignore -v on any tracked files inside Debug/, might be helpful.

(This optimization, where git status sometimes doesn’t open a directory at all, is especially tricky, and is where a lot of .gitignore issues start. There’s not much to be done about this until and unless someone teaches Git better ways to skip, or not skip, entire directories. However, using git status -uall definitely helps some of the other tricky cases.)

Leave a Comment

tech