32

I have the following folder structure:

/foo/
/foo/test.txt
/foo/.gitkeep
/foo/bar/test.txt
/foo/bar/.gitkeep
/foo/bar/baz/test.txt
/foo/bar/baz/.gitkeep

Now I want to exclude all files in the folder "foo" and all files in its subfolders (and subsubfolders), except all the .gitkeeps (so that the folder structure is kept). This should also work for more than 3 levels of subfolders.

The following gitignore-rules work:

/foo/**
!/foo/**/
!/foo/**/.gitkeep

Can somebody explain, why this works? Is there a cleaner way of doing this?

David Vielhuber
  • 3,253
  • 3
  • 29
  • 34

4 Answers4

47

The rule is simple:

It is not possible to re-include a file if a parent directory of that file is excluded.

That is why you need to

  • ignore files and folders recursively:

    /foo/**
    

(if you only ignore /foo/, that is the folder, then no amount of '!' exclusion rule will work, since the foo/ folder itself is ignored: Git will stop there)

  • Then exclude folders from the ignore rules:

    !/foo/**/
    
  • Before whitelisting files like the .gitkeep

    !/foo/**/.gitkeep
    

That works because the .gitkeep parent folders are excluded from the ignore rules.

As opposed to Bernardo original proposal (before his edit):

/foo/** 
!**/.gitkeep

If does not exclude folders from the /** ignore rule, so the exclusion rule for files is inoperative.

You can check that with:

git check-ignore -v -- /path/to/.gitkeep

As mentioned in scm .gitignore:

  • using '*' is the same as '**'
  • using !.gitkeep (without any / anchor) would exclude that file recursively.

In both cases, 'recursively' is the key term which explains why exclusion rules can apply: if you ignore a folder (like . or /foo/), you won't be able to exclude anything inside that folder.
But if you ignore elements (files and folder) recursively (* or **), and exclude folders from gitignore rules (again through recursive rule !*), then you can exclude (white-list) files.

VonC
  • 1,262,500
  • 529
  • 4,410
  • 5,250
  • 2
    thank you for this! I've looked high and low, but all the .gitkeep solutions fail to mention the logic behind recursive ignoring and exceptions. – mattador Mar 27 '21 at 20:15
6

I've done it. It seems that what is needed is a .gitignore file inside the desired folder.

Look at this repository, which does exactly what you want.

I've tried to add any filename inside foo/, foo/bar/ and foo/bar/baz/ but it only accepts .gitkeep.

The trick is to create a .gitignore with this content, inside the folder:

*
!*/
!.gitignore
!.gitkeep

[Edit: Added based on comments]

And you should be able to shorten this to:

*
!*/
!.git*

Though it may be less clear in the future what was intended.

While this solution doesn't shorten the number of lines required from your original .gitignore, this does have several other advantages - the main one being that this will be a .gitignore file inside the folder, which means it can be a clean file that has just these lines, and is specific to just this folder.

That means this .gitignore file can be easily moved into any specific folder that requires this filter. Also, the entire folder can be easily moved around within the repo, or even moved to a different repo, without needing to modify the .gitignore. It is much cleaner and more maintainable, and not buried in the typically large .gitignore file in the repo's root folder.

LightCC
  • 9,804
  • 5
  • 52
  • 92
Bernardo Duarte
  • 4,074
  • 4
  • 19
  • 34
  • This does not work. With this rule, only the following file is ignored: **/foo/.gitkeep** I want also those files being not ignored: **/foo/bar/.gitkeep** **/foo/bar/baz/.gitkeep** – David Vielhuber Sep 21 '17 at 21:50
  • It seems that one cannot simplify the statement, which now makes sense to me. I wonder why I nowhere found that specific answer. – David Vielhuber Sep 21 '17 at 21:59
  • @DavidVielhuber I've done it check my edited solution, it includes a example repository to test. Try adding any filename inside those folders and you will see that it doesn't accept any ;D – Bernardo Duarte Sep 22 '17 at 01:47
  • Yes, that also works, but has some caveats (4 lines instead of 3 and one general ignore rule). – David Vielhuber Sep 22 '17 at 07:21
  • The thing is, with this one you put inside the desired folders, without having to put their names inside the ignore, making it agnostic and, therefore, flexible and extensible. All you need with this one is to copy and move it around to use it elsewhere in another folder or even in another project. And for the 4 rows of rules, I think if you put a general !.git* exception it will catch both !.gitignore and !.gitkeep. – Bernardo Duarte Sep 22 '17 at 11:08
  • @DavidVielhuber Also, by using the .gitignore inside the folder allows you to change names of the folder without having to change any line of the .gitignore. – Bernardo Duarte Sep 22 '17 at 15:25
  • You're right, seems like a personal choice. Thanks for your answer! – David Vielhuber Sep 22 '17 at 21:33
4

I don't have enough reputation to comment but I'd like to raise an important issue with this:

  • With mounted data folders, this causes git commands such as git status to run very slowly, because the commands still sweep through all the files (despite not committing them), which will run slowly through a network.

  • More unpredictably, this causes Oh My Zsh (if installed) to hang the shell for a while after each command, because it silently checks for the git branch (I raised this here).

One way to work around this is to do the following:

  1. Add this to your .gitignore:
data/**
!data/**/
!data/**/.gitkeep
  1. git add data

  2. Remove the second line from your .gitignore:

data/**
# !data/**/
!data/**/.gitkeep
  1. git add .gitignore

  2. git commit

This commits the data directory structure to git, and ensures that any future git calls will completely ignore the contents of your data subdirectories.

Julian Ferry
  • 305
  • 1
  • 10
0

Empty directories cannot be committed to Git. So create a README.md file or any empty file and ignore all other files (using pattern match). This way directory will be committed with placeholder file.