Why doesn't `git update-ref -d` delete empty parent directories?

200 views Asked by At

There are various types of refs in git, some of the most common of which are branches (stored in .git/refs/heads), remote-tracking branches (.git/refs/remotes), and tags (.git/refs/tags).

But it's also possible to create and use arbitrary non-standard refs that live elsewhere under .git/refs. This can be useful for storing custom metadata in the repository that you don't expect users will want to interact with directly. For example, GitHub uses these kinds of refs to expose references to pull request branches, and the Emacs git client Magit uses them to save uncommitted changes periodically, when the appropriate setting is enabled. Such refs would generally need to be manipulated using the so-called "plumbing" commands of git, since the user-facing "porcelain" commands don't know about or support them.

I was playing around with non-standard refs using the plumbing command git update-ref and found some odd behavior:

$ git init foo && cd foo
$ touch a && git add a && git commit -m init
$ tree .git/refs
.git/refs
├── heads
│   └── master
└── tags

2 directories, 1 file
$ git update-ref refs/foo/bar/baz HEAD
$ tree .git/refs
.git/refs
├── foo
│   └── bar
│       └── baz
├── heads
│   └── master
└── tags

4 directories, 2 files
$ git update-ref -d refs/foo/bar/baz
$ tree .git/refs
.git/refs
├── foo
├── heads
│   └── master
└── tags

3 directories, 1 file

When I created the ref refs/foo/bar/baz, git update-ref created the necessary parent directories. When I deleted the ref, it was smart enough to remove the parent directory bar, which had now become empty. However, it wasn't smart enough to remove the "grandparent" directory foo, which was also now empty after removing bar.

Is this a bug?

2

There are 2 answers

1
kini On BEST ANSWER

No, it's by design. Here is a comment from the source code:

/*
 * Remove empty parent directories associated with the specified
 * reference and/or its reflog, but spare [logs/]refs/ and immediate
 * subdirs. flags is a combination of REMOVE_EMPTY_PARENTS_REF and/or
 * REMOVE_EMPTY_PARENTS_REFLOG.
 */
static void try_remove_empty_parents(struct files_ref_store *refs,
                     const char *refname,
                     unsigned int flags)
{

If I'd have nested my non-standard ref one level deeper, e.g. refs/foo/bar/baz/xyzzy, I'd have noticed that both the parent and grandparent directories were removed, but the great-grandparent wasn't, making it more obvious that this behavior is intentional.

I guess the idea is that the top level subdirectories under .git/refs/ (like foo in my example) represent a type of ref rather than being part of the name of the ref, so treating them differently from directories further down the tree (like bar in my example) makes sense.

3
VonC On

This is formally addressed (for refs/heads only) with Git 2.32 (Q2 2021): when "git update-ref -d"(man) removes a ref that is packed, it left empty directories under $GIT_DIR/refs/.

See commit 5f03e51 (08 May 2021) by Will Chandler (wlvchandler).
(Merged by Junio C Hamano -- gitster -- in commit 16f9145, 16 May 2021)

refs: cleanup directories when deleting packed ref

Signed-off-by: Will Chandler
Reviewed-by: Jeff King

When deleting a packed ref via 'update-ref -d', a lockfile is made in the directory that would contain the loose copy of that ref, creating any directories in the ref's path that do not exist.
When the transaction completes, the lockfile is deleted, but any empty parent directories made when creating the lockfile are left in place.
These empty directories are not removed by 'pack-refs' or other housekeeping tasks and will accumulate over time.

When deleting a loose ref, we remove all empty parent directories at the end of the transaction.

This commit applies the parent directory cleanup logic used when deleting loose refs to packed refs as well.

The test shows:

directory not created deleting packed ref':

git branch d1/d2/r1 HEAD &&
git pack-refs --all &&
test_path_is_missing .git/refs/heads/d1/d2 &&
git update-ref -d refs/heads/d1/d2/r1 &&
test_path_is_missing .git/refs/heads/d1/d2 &&
test_path_is_missing .git/refs/heads/d1  <==== grand-parent is gone

However, this would not apply to refs/foo, as in the OP: git update-ref -d refs/foo/bar/baz does remove bar/baz, but not foo/.