Search code examples
gitcase-sensitivegit-remote

“Changes not staged for commit" even after git commit -am b/c origin has a file with de-capitalize filename


Problem: two files under two different name-cases in the same directory, which I didn't know at the first. So I was quite surprised to see this,

git commit -am "why"
On branch tmp
Changes not staged for commit:
    modified:   src/view/callCenter/seatReport/SeatSubstate.vue

Then I found origin has both SeatSubstate.vue & seatSubstate.vue in the path src/view/callCenter/seatReport

But on my mac

ls src/view/callCenter/seatReport/
...     seatSubstate.vue /* did NOT show SeatSubstate.vue only seatSubstate.vue */

I know there is discussion about How do I commit case-sensitive only filename changes in Git?

But I still don't understand why git can not commit this file.

Second, how do I fix this problem? For example in that SO discussion many answered mentioned git mv but I am not sure git mv can fix my problem or not.

----- update -----

I suddenly realized my mac (my HD to be exactly) was not case-sensitive (APFS), refer to https://apple.stackexchange.com/questions/71357/how-to-check-if-my-hd-is-case-sensitive-or-not.

enter image description here

Normally it should mean SeatSubstate.vue & seatSubstate.vue are the same file, but somehow git makes them 2 different files and cause the trouble. git mv seems to fix the problem but I am not 100% sure.

Refer to Changing capitalization of filenames in Git


Solution

  • Properly defining the problem

    Git is always capable of storing—in commits, and in Git's index, that is—two files under two different name-cases (e.g., both README and readme) in the same directory, because Git doesn't store files in operating-system directories at all. Files are either frozen in commits,1 which means they retain their form no matter whether they're on Linux or Windows or MacOS or any other system, or they are in Git's index, which is actually just a data file.2

    The problem occurs because you, the human operating Git, want to use the OS-provided file system, where your computer stores files in their normal everyday form so that the rest of your computer can work with them too. This is not an unreasonable demand—Git's internal files are stored in a Git-only internal form, that only Git can use. You need to be able to use Git to get something done, not just to play with Git all day.

    MacOS has the ability to provide case-sensitive file systems (that can hold both README and readme in the same directory) but does not do so by default. So, either by either by not using MacOS at all, or by using this ability, someone—not you—has done this sort of thing:

    Then I found origin has both SeatSubstate.vue & seatSubstate.vue in the path src/view/callCenter/seatReport

    In other words, you have both files in some existing commit. As we just said, Git is perfectly capable of handling this. It's your OS that isn't.

    So if you run git checkout and select that commit, Git will copy both files to your index, which now has both spellings, SeatSubstate.vue and seatSubstate.vue. It also copies both files (with both spellings!) to your work-tree, but your OS can only hold one spelling, so one file erases the other and you're left with just one file with one spelling.

    When Git compares the index's files and their contents to the work-tree files and their contents, Git will:

    • see that, according to the index, there are two files;
    • try comparing each index file to the work-tree file Git gets when it opens that name;
    • complain that one of them is modified.

    Here's an example, which I made by creating a repository on a Unix-y system and giving it two files, README and readme, with different contents, then cloning that to a Mac:

    sh-3.2$ git clone ssh://[path]/caseissue
    ...
    Receiving objects: 100% (4/4), done.
    sh-3.2$ cd caseissue
    sh-3.2$ ls
    readme
    

    Let's have a look at what is in the index:

    sh-3.2$ git ls-files --stage
    100644 a931371bf02ce4048b623c56beadb9a926138516 0       README
    100644 418440c534135db897251cc3ceca362fe83c2117 0       readme
    

    Sure enough, it has two files, differing only in case. Let's see what's in those files, and what's in the work-tree:

    sh-3.2$ git show :0:README
    I AM AN UPPERCASE FILE
    sh-3.2$ git show :0:readme
    i am a lowercase file
    sh-3.2$ cat readme 
    i am a lowercase file
    

    And our status:

    sh-3.2$ git status
    On branch master
    Your branch is up to date with 'origin/master'.
    
    Changes not staged for commit:
      (use "git add <file>..." to update what will be committed)
      (use "git checkout -- <file>..." to discard changes in working directory)
    
            modified:   README
    
    no changes added to commit (use "git add" and/or "git commit -a")
    

    Depending on what we need to do, we may be able to do it while only knowing about the index, or we may need to work directly with the index, which is more painful.


    1Technically, the frozen files' contents are stored in blob objects, their names are stored in tree objects, and the commits are commit objects that refer to tree objects that refer to the blob objects. But from the user point of view, the files are frozen into the commit, so we can just use that phrasing here.

    2The index can actually be multiple different data files, and you can point Git at alternative index files and do all kinds of fancy tricks with this. That's how git stash works, for instance. But "the" index is where Git builds the next commit you will make and for our purposes that's just the file .git/index.


    What to do about this if you don't need either file

    Let's assume that you don't need to work with either file. If you need to work with both files in a case-sensitive manner, so that you can fuss with the contents of the two separate files name SeatSubstate.vue and seatSubstate.vue, you will, obviously, need to set up a case-sensitive file system. But whatever you're doing, we can assume that you don't need either file to do the job.

    The trick to use here is to start by removing the one remaining file from your work-tree, and then ignore the fact that Git is telling you that you have two changes that are not staged for commit. That is, Git will tell you that you have removed both files.

    sh-3.2$ rm readme
    sh-3.2$ git status
    On branch master
    Your branch is up to date with 'origin/master'.
    
    Changes not staged for commit:
      (use "git add/rm <file>..." to update what will be committed)
      (use "git checkout -- <file>..." to discard changes in working directory)
    
            deleted:    README
            deleted:    readme
    
    no changes added to commit (use "git add" and/or "git commit -a")
    

    Now, simply don't use git commit -a at all, because that will stage both removals. Instead, work with the remaining files (in my case, none at all), do whatever you need to do, and stage—git add—only those files that you modified, without touching either deleted file in any way.

    You can now git commit the result without affecting the two files that are missing from your work-tree, but still present in the new commit you make:

    sh-3.2$ echo 'this file is independent of the READMEs' > newfile
    sh-3.2$ git add newfile
    sh-3.2$ git commit -m 'add new file'
    [master 6d5d8fc] add new file
     1 file changed, 1 insertion(+)
     create mode 100644 newfile
    sh-3.2$ git push origin master
    Counting objects: 3, done.
    ...
       2dee30f..6d5d8fc  master -> master
    

    Over on the other (case-sensitive file system) machine, after updating to this commit:

    $ ls
    newfile readme  README
    $ for i in *; do echo -n ${i}: && cat $i; done
    newfile:this file is independent of the READMEs
    readme:i am a lowercase file
    README:I AM AN UPPERCASE FILE
    

    So we're quite capable of working, on our Mac (or Windows!) system, with these commits: we just delete the unwanted files and carefully avoid staging the deletions.

    What to do about this if you do need one of the files but don't need to change it

    Now the problem is a little bit harder, because cannot hold both files with both spellings in our case-insensitive work-tree on our Mac or Windows system.

    But we can pick and choose which file we get! Let's say we need the README file. We can see that we got instead the readme file above. So we'll remove the wrong one (well, we already did), and then:

    sh-3.2$ git checkout -- README
    sh-3.2$ ls
    README  newfile
    sh-3.2$ cat README 
    I AM AN UPPERCASE FILE
    

    If we need, instead, the lowercase one:

    sh-3.2$ rm README 
    sh-3.2$ git checkout -- readme
    sh-3.2$ ls
    newfile readme
    sh-3.2$ cat readme
    i am a lowercase file
    

    That is, we remove the wrong one, then use the grab one file from the index operation—git checkout -- path—to get the one file with the one case that we do want. We can now work with this file. But we can't add or change it.

    What if you need both files, or need to work on one of them?

    If you need both at the same time with the fancy naming, you're in trouble, because your OS literally can't do that—at least, not on this file system; you'll need to create a case-sensitive file system, after which this whole problem goes away. But if you need just one at a time, to make some sort of change, that's something we can manage, albeit very awkwardly.

    First, let's note that you can get one or both files' contents easily enough:

    sh-3.2$ git show :README
    I AM AN UPPERCASE FILE
    sh-3.2$ git show :readme
    i am a lowercase file
    

    (Side note: the strings :0:README and :README mean exactly the same thing to git show: get the file from index slot zero under path name README. You can redirect the output from git show to any file name you like, so that you can get both contents into two files with names your OS considers "different". You can use :README or :0:README as the argument to git show. I'm not always consistent about whether I use the index number in the :-prefixed form here. The reason there is a :0: form is that there are also stage 1, 2, and 3 slots in the index, used only during merging. That is, if there is a :1:README in the index, that's the merge base copy of README; you will have this during a conflicted merge.)

    As we saw above, you can also remove the work-tree file and use git checkout -- <path> to get one of them, with your chosen case, into your work-tree with the same case. Unfortunately, if you want to modify and re-add the file, this doesn't always work:

    sh-3.2$ rm readme
    sh-3.2$ git checkout -- README
    sh-3.2$ echo UPPERCASE IS LIKE SHOUTING >> README
    sh-3.2$ git add README 
    sh-3.2$ git status
    On branch master
    Your branch is up to date with 'origin/master'.
    
    Changes to be committed:
      (use "git reset HEAD <file>..." to unstage)
    
        modified:   readme
    
    Changes not staged for commit:
      (use "git add <file>..." to update what will be committed)
      (use "git checkout -- <file>..." to discard changes in working directory)
    
        modified:   README
    

    Yikes! It seems as though Git has decided that the README file in the work-tree should update the stage-zero readme file in the index! And sure enough, that's exactly what Git did:

    sh-3.2$ git show :0:README
    I AM AN UPPERCASE FILE
    sh-3.2$ git show :0:readme
    I AM AN UPPERCASE FILE
    UPPERCASE IS LIKE SHOUTING
    

    So now we have to resort to the tool that lets us write directly to the index. First, let's erase this change and get back to the "clean-ish" state where we have no work-tree copy. NOTE: if your actual work is more complicated than mine, you may want to save all of it somewhere else before git reset wipes it out!

    sh-3.2$ git reset --hard
    HEAD is now at 6d5d8fc add new file
    sh-3.2$ rm readme 
    sh-3.2$ git status --short
     D README
     D readme
    

    The --short output here, which has the D character in the second position, shows that both files are missing from the work-tree, but that the index copy matches the HEAD copy. So now we can get the file we want, whichever one that is—I'll pick the uppercase one again since it went wrong last time:

    sh-3.2$ git checkout -- README
    sh-3.2$ cat README 
    I AM AN UPPERCASE FILE
    

    Now we use the normal computer tools to work with the file:

    sh-3.2$ echo UPPERCASE IS LIKE SHOUTING >> README
    

    When we need to add it back, though, we must use git hash-object -w and git update-index:

    sh-3.2$ blob=$(git hash-object -w README)
    sh-3.2$ echo $blob
    fd109721431e207046a4daefc9712f1424d7f38f
    

    (the echo here is just for illustration, to show that we got a hash ID). Now we need to make a correctly-formatted index entry, a la git ls-files --stage --full-name. That is, we need the full path to the file, relative to the top of the tree. Since my README and readme files are in the top of the tree, in my case here that just means README or readme. For your example, where your two files were in src/view/callCenter/seatReport, you would need to include that in the path name.

    In any case, having written the blob object to the Git database, we now need to update the index entry:

    sh-3.2$ printf '100644 %s 0\tREADME\n' $blob | git update-index --index-info
    sh-3.2$ git status --short
    M  README
     M readme
    

    This shows that we have one change staged for commit—to README—and one not, to readme. Here's the longer git status if you prefer it:

    sh-3.2$ git status
    On branch master
    Your branch is up to date with 'origin/master'.
    
    Changes to be committed:
      (use "git reset HEAD <file>..." to unstage)
    
            modified:   README
    
    Changes not staged for commit:
      (use "git add <file>..." to update what will be committed)
      (use "git checkout -- <file>..." to discard changes in working directory)
    
            modified:   readme
    

    More directly, we can use git show to view what's in the index:

    sh-3.2$ git show :README
    I AM AN UPPERCASE FILE
    UPPERCASE IS LIKE SHOUTING
    sh-3.2$ git show :readme
    i am a lowercase file
    

    That's what we want! So now we can git commit the result:

    sh-3.2$ git commit -m 'annotate README'
    [master ff51464] annotate README
     1 file changed, 1 insertion(+)
    sh-3.2$ git push origin master
    Counting objects: 3, done.
    ...
       6d5d8fc..ff51464  master -> master
    

    Over on the Unix-like system:

    $ for i in *; do echo -n ${i}: && cat $i; done
    newfile:this file is independent of the READMEs
    readme:i am a lowercase file
    README:I AM AN UPPERCASE FILE
    UPPERCASE IS LIKE SHOUTING
    

    You can always use git hash-object -w and git update-index --index-info

    If your OS is incapable of spelling a file or path name the way Git's index spells it, you can still work with the files' contents, under whatever names you can use. Having done so, you can use git hash-object -w to turn the contents into a frozen blob, ready for commit, then use git update-index --index-info to write that blob hash into the index—at the desired staging slot, usually zero—under the path-name that Git needs.

    What you give up in this process is the ability to use git status sensibly, to use git add on problematic file names, and to use git commit -a at all. What Git needs to make this more convenient—though it will never be 100% convenient; for that, you need your OS to behave instead—is the ability to re-map Git index paths to (different) local OS paths, in both directions: an index file named IP, for some index path IP, should not be assumed to have the same name in the work-tree, but rather its mapped name. The mapped name must map uniquely back to the index path. (That is, the mapping should be a bijection on paths.)

    This is needed not only for case-folding issues but also for Unicode issues: MacOS stores file names in one form, having normalized them, while Linux allows storing file names in each form. A file named agréable can have two names on Linux, but only one on MacOS.