After a significant amount of testing, reading documentation (quite good), and asking other people, I finally found a solution. (And yes it does involve sprinkling a generous quantity of *'s everywhere). I'll do my best to explain what I think was going on, and how it can be avoided.
Short answer:
Git was not ignoring the files in Subfolder B1 because it expected B1 to be in the root directory. Changing the .gitignore entries to **/B1/**, !**/B1/B1b.ft0, and !**/B1/B1e.ft2 fixed the problem.
Long(er) answer:
The key to understanding this problem is to understand some oddities of the .gitignore syntax. Fortunately, there is not much of it to learn, even though some of it is frustratingly arcane - for instance, you may know that gitignore files are read downwards, leading to this behaviour:
foo is not ignored |
foo is ignored completely |
foo !foo |
!foo foo |
My issue was related to another arcane subtlety in the .gitignore syntax: how it parses filepaths. The gitignore directory separator is /, but depending on its location in a line, or how many of them there are, it behaves in quite different ways:
For entries with only one /:
/ at end of line, as in foo/ |
/ in middle of line, as in foo/bar |
/ at start of line, as in /foo |
Git ignores every directory named foo, no matter how many subdirectories deep it is. |
Git ignores any folders and files named bar in the filepath <.gitignore_directory>/foo/. Folders (or files) named foo/bar will only be ignored if they have that specific filepath, i.e, the path starts in the same directory as the .gitignore file. |
Git ignores any files and folders named foo, but only if they are in the same directory as the .gitignore file. |
For entries with multiple /s, the rules are a combination of the above rules. A few examples demonstrate:
/foo/ |
foo/bar/ |
/foo/bar |
This pattern matches only one item: a subfolder in the same folder as the .gitignore, named foo. (Of course, everything inside it is ignored too.) |
This pattern also only matches one item, a subsubfolder named bar found inside the folder foo, which itself must be a subfolder of the folder that also contains the .gitignore file. |
This pattern is identical to one in the middle example, except it can also ignore a file named bar, not just a directory. |
In all the above cases, files and subdirectories within any ignored directory foo can not be unignored by e.g. !foo/bar.
The devious subtlety is that a / at the end of a line like foo/ will cause any directory named foo to be ignored, no matter of its depth, but putting any character (including wildcards!) after that /, or putting any other / in the same line will mean it matches only relative to the path where the .gitignore is found. So then, how can files inside a subdirectory be unignored?
Wildcards
The fix is to make better use of the wildcards. There are three wildcards in .gitignore syntax:
? - matches 1 or more characters, except /. Almost only used for filename matching.
* - matches 0 or more characters, except /
** - matches 0 or more characters, including /.
? or * can be used to ignore all files in a directory, but not subfolders. The following examples will both ignore all files in the subdirectory foo, if foo resides in the same directory as .gitignore.
foo/*
foo/? This one will not ignore files with 0-character names.
However, note that these will not ignore files in e.g. <.gitignore_directory>/filepath/foo.
Since ** matches directories, it can solve the above problem like so: **/foo/. Even though there is a non-whitespace character after the /, this will ignore all directories and subdirectories named foo, because **/ matches all filepaths.
**/foo/? Ignores all named files inside any directory foo.
foo/**/bar.txt Ignores every file named bar.txt at any depth inside a folder called foo, where foo is in the <.gitignore_directory>.
**/foo/** Ignores everything inside every directory foo, but does not ignore the directory itself, and therefore I can unignore a select few files inside it!
Yay! My question is answered - almost.
Regarding SubSubfolder B0a:
For the entry in my .gitignore to ignore SubSubfolder B0a, I actually had something slightly different from what I asked. In my actual project, B0a is called .generated_files. I was not entirely sure what git would think of a .gitignore entry starting with a dot, so I had prefixed it with a slash, like so: /.generated_files/. (I forgot about this when I wrote the question.) As I have now learnt, this caused git to expect it only in the root directory, and therefore miss it.
I also learnt that git does not care in the slightest if an entry begins with a ..
Final notes
Although It was not causing a problem in my case, I also discovered that an entry in a .gitignore file will match both files and folders. So if you had a file named foo and a folder named foo, putting foo in the .gitignore would cause them both to be ignored.
Hopefully this answer was helpful, I'm afraid it is so long that reading the official documentation might actually be easier to understand.