Managing git ignores across branches

Viewed 77

Often I find myself in this situation:

  1. I am working in branch Foo, which has whole folders of ignored files, that do not exist in branch Bar.
  2. I checkout branch Bar to do other work.
  3. I see thousands of new/untracked files in my working copy for everything that is git ignored in branch Foo but not here in branch Bar.
  4. If during work in Bar, i try to use stash, it pulls all these files into it -- making my stashed changes impossible to read (and its really really slow).

While I could add the git ignores for those folders, those folders do not even exist outside of Foo, so this seems awkward.

Does git have any other solution? Ideally the files would only be there when the branch that uses them is checked out, but i do not want to git add them as there are too many and they are modified too frequently; it would slow git to a crawl. It's like I want them to behave as if git ignored while in their branch, and for them to be stashed when not.

2 Answers

Git reads ignore patterns from files other that .gitignore:

  • Patterns in .git/info/exclude will be ignored in any branch that is checked out in that repository
  • Patterns in ~/.config/git/ignore will be ignored system-wide

Copying the .gitignore from your branch Foo to one of those locations will cause those files to be ignored when Bar is checked out.

You might also consider merging only the .gitignore of branch Foo into branch Bar.

Branches—or more precisely, branch names—are essentially irrelevant here. All that matters are the commits. A branch name serves to find one particular commit, but two or more branch names can identify the same commit.

Does git have any other solution?

There are multiple approaches you can use here. If your Git version is at least 2.5 (and preferably at least 2.15), I'd suggest starting with git worktree. But first, let's define the problem in enough detail, and with some Git-specific terminology, that the solutions themselves will make sense.

Ideally the files would only be there when the branch that uses them is checked out ...

Git isn't about files either, though of course you need files to get work done. But the files you see and work with, in your working tree or work-tree, aren't even in Git, in a quite significant sense. The commits—which are in Git—contain files, but in a special, read-only, compressed and de-duplicated form. Read-only files won't let you get work done unless the work never writes any files; but since the committed files are in a Git-only format, you probably can't get your software to read them either.

Hence, to use Git, you have Git copy its files—in the freeze-dried, compressed and de-duplicated format—into ordinary everyday read/write files. These read/write files are in your work-tree. The act of checking out a particular commit—usually by branch name—selects that particular commit as the current commit. Git copies the committed files into your work-tree so that you can see and use them.

Because your work-tree is yours, you can then create many files that aren't in the commit, and by listing them in a .gitignore file or similar, you can tell Git two things:

  1. Don't complain about these so-called untracked files; and
  2. don't add them to Git's index or staging area.

The index / staging-area—two terms for the same thing—is how Git makes new commits: from pre-freeze-dried files copied into Git's index. The git add command tells Git: make a freeze-dried / de-duplicated copy of this work-tree file and put it into your index.1

Of course, a primary .gitignore file itself is generally committed, so the one in your work-tree has come out of the current commit. If you then switch to a different commit, that has a different committed .gitignore file, Git will swap out the work-tree copy for the copy from the other commit. That of course changes the set of files on which the two actions above occur.


1Note that the de-duplication means that the index never actually contains a copy of a file. Instead, it holds the file's name and a blob hash ID for the file's content. If the content matches any existing committed file—which every index "copy" does initially, as Git fills in its index from the commit you choose—then these are all already de-duplicated, so that the index simply lists the files to go into the next commit. That's how git commit works: it just packages up the listing that is in the index, at the time you run git commit.

Note too that git add really means make the index copy match the work-tree copy. In particular, if you have removed the work-tree copy, git add will remove the index copy. (Or, you can use git rm to remove both copies at the same time and hence not need to git add the removed file to get Git to see the removal.)


Solution 1: git worktree add

A Git repository comes with a single default work-tree.2 You can, however, add more work-trees, at least since Git version 2.5, using git worktree add. Each work-tree gets its own index.

Having a separate work-tree, outside the main / default one, means that you can have more than one branch checked out. Since each work-tree is independent, any untracked (ignored or not) files in work-tree W1 never affect, in any way, any untracked files in work-tree W2. So the entire problem simply vanishes: use the main work-tree W1 to work on your main branch B1, and use added work-tree W2 to work on branch B2. If you need to do work on even more branches, keep adding more work-trees.

When you're done with a work-tree, just remove it. You can remove it manually and use git worktree prune, or if your git worktree has the sub-command, use git worktree remove. Note that there's a fairly nasty bug in git worktree in Git 2.5, not fixed until Git 2.15, that can cause loss of data if you let an added work-tree sit idle for more than two weeks. I recommend upgrading the Git version, but creating the work-tree, getting everything done, and then removing it within that two-week span will suffice, or you can raise the minimum object prune protection age gc.pruneExpire from its default 2 week value, using git config, to extend the protection time.


2A bare repository omits the work-tree. We won't cover the point of this here, though.


.git/info/exclude

Besides the .gitattributes file within the work-tree, Git will also read and obey exclusions in .git/info/exclude. This file is not in any commit—it cannot be; Git will not add files within .git to commits—so you can list untracted files here and have them ignored regardless of which commit you have checked out.

git clean

The git clean command has options to remove untracked files (-x or -X). Using these before switching to some other branch will remove build products and the like. Be careful with these options since untracked files are not in Git—by definition, an untracked file is not in Git's index, and therefore won't be in the next commit—and thus cannot be recovered using Git.

git stash -u or git stash -a

The git stash command can be told to make a three-commit stash, instead of the usual two-commit stash, in which the third commit holds either untracked-but-not-ignored files (-u) or all untracked files even if ignored (-a). Once git stash has made this third commit, it uses git clean or equivalent to remove the files saved in this third commit.

I dislike this method because restoring a three-commit stash requires that the work-tree in question be "clean", i.e., not contain any of the files that are in that third commit. It's too easy to use git stash push -a to save build products, change some things (including making build products and config files that required a of time and/or effort), try to use git stash apply to restore the saved ones, and then use git clean to clean up so that the git stash apply can work ... only to discover that one of the files you just cleaned is new and is not in the saved stash, and it will now take a lot of time and/or effort to construct it again.

Related