🤖 AI 资讯

· · ↗
← 返回列表

Optimizing reference deletions in Git

Karthik Nayak2026-11-07大模型,AI应用,搜索RAG,办公效率,模型评测,提示工程,招聘HR原文 ↗

Optimizing reference deletions in Git

References, along with objects, form the backbone of Git's storage mechanism. Simply put, a reference is a named pointer to a particular object within the repository.

So how does Git store references? The

files backend
was the only backend for storing references until Git 2.45. The release saw the introduction of the newer and better
reftable backend
. The
files backend
is still the default reference backend as of Git 2.56.

Read about the newer reftable backend here, which will be the default in Git 3.0.

Files backend

Loose refs

The files backend creates a file for each new reference. So when you create

refs/heads/shiny-new-branch
, you have:

❯ git init --quiet --ref-format=files repo; cd repo

❯ git commit --allow-empty -m "new commit"
[main (root-commit) 66b401c] new commit

❯ git branch shiny-new-branch

❯ eza --tree .git/refs/
.git/refs
├── heads
│   ├── main
│   └── shiny-new-branch
└── tags

❯ cat .git/refs/heads/shiny-new-branch
66b401c8c91c975f9f126bc85d9b2eba54828d86

We call these

loose refs
, because each reference is an individual file. While simple enough, they don't scale well.

When you have millions of references, you end up cluttering the directory. Who'd have millions of references, you ask? Well, large companies with big monorepos. For instance, GitLab's gitlab repository contains over 15M references.

Back to the loose refs. Iterating over millions of references is slow, since we have to open and parse each individual file. There are other problems such as:

  • Large inode costs, since a reference maps to an individual file.
  • Large filesystem overheads when updating references, since each update needs a corresponding
    .lock
    file.
  • No atomicity when updating multiple references.
  • Tied to restrictions of the filesystem, like case sensitivity.
  • Name conflicts: you cannot have both
    refs/heads/branch/foo
    and
    refs/heads/branch
    .

Packed refs

To alleviate some of the issues with the

loose ref
data structure, Git introduced the notion of
packed-refs
back in 2006. See Linus's commit introducing the packed-refs format.

The packed-refs format is a single 'packed-refs' file which contains sorted mappings of references to object IDs. We can see this in our repository:

❯ for i in $(seq 1 5)
      git update-ref refs/heads/branch-$i HEAD
  end

❯ eza --tree .git/refs/
.git/refs
├── heads
│   ├── branch-1
│   ├── branch-2
│   ├── branch-3
│   ├── branch-4
│   ├── branch-5
│   ├── main
│   └── shiny-new-branch
└── tags

❯ git refs optimize --all

❯ eza --tree .git/refs/
.git/refs
├── heads
└── tags

❯ cat .git/packed-refs
# pack-refs with: peeled fully-peeled sorted
ec4aecaa91db6da8a280368110866935080f231c refs/heads/branch-1
ec4aecaa91db6da8a280368110866935080f231c refs/heads/branch-2
ec4aecaa91db6da8a280368110866935080f231c refs/heads/branch-3
ec4aecaa91db6da8a280368110866935080f231c refs/heads/branch-4
ec4aecaa91db6da8a280368110866935080f231c refs/heads/branch-5
ec4aecaa91db6da8a280368110866935080f231c refs/heads/main
66b401c8c91c975f9f126bc85d9b2eba54828d86 refs/heads/shiny-new-branch

Nice. This solves some of the issues we listed with the loose refs format. So Git's reference datastore eventually became a combination of the two. When loose refs accumulate, housekeeping repacks them into a single 'packed-refs' file.

New references are always created as loose refs, since a loose ref shadows the packed one during reads. When updating, if the reference exists as a loose ref, simply update the loose ref. If not, create a new loose ref with the updated value. The issue is with deletion. If the reference exists in the packed refs format, we have to delete it from the 'packed-refs' file. For large repositories, the 'packed-refs' file can easily reach several GBs in size. Every deletion of a packed ref rewrites the 'packed-refs' file. This is slow.

❯ set -l oid (git rev-parse HEAD)

❯ begin
      printf '# pack-refs with: peeled fully-peeled sorted \n'
      awk -v o=$oid 'BEGIN { for (i = 1; i <= 10000000; i++) printf "%s refs/heads/branch-%010d\n", o, i }'
  end  > .git/packed-refs

❯ eza -l .git/packed-refs
.rw-r--r-- 700M karthik  8 Nov 15:16 .git/packed-refs

❯ time git update-ref -d refs/heads/branch-0000499999
Executed in    2.14 secs    fish           external
   usr time    1.28 secs    0.00 millis    1.28 secs
   sys time    0.85 secs    2.06 millis    0.85 secs


❯ eza -l .git/packed-refs
.rw-r--r-- 700M karthik  8 Nov 15:28 .git/packed-refs

The problem lies in the design of the

files backend
. The obvious solution is to slowly migrate repositories to using the newer
reftable
backend. The
reftable
backend uses binary tables to store references, and deletions are simply tombstone markers.

Stumbling upon an optimization

The files backend has seen around 20 years of optimizations, meaning newer optimizations are generally harder to find. As our focus at GitLab was to migrate to the newer reftable backend, I wasn't ever thinking about optimizing the deletions in the files backend.

With Git 3.0, Rust will become a mandatory requirement. This means we will slowly see more Rust adoption in the Git project. To brush up on my Rust skills and to understand how Rust can slowly replace C in the Git project, I created a Rust version of the files backend entirely via a prompt to an LLM. To measure the cost of the C <> Rust ABI boundary, I decided to benchmark the two:

❯ ./delete-ref/run --revisions ~/code/git v2.56.0,rust-packed-refs --build-system meson --parameter-list refformat files --parameter-list refcount 100000
Benchmark 1: update-ref: delete ref (refformat = files, refcount = 100000, revision = v2.56.0) (32 runs)
                mean     ±       σ          min     …     max
  Wall Time     37.1 ms  ±     1.6 ms      35.2 ms  …    42.4 ms
  Memory        11.8 MiB ±     0.1 MiB     11.5 MiB …    12.0 MiB

Benchmark 2: update-ref: delete ref (refformat = files, refcount = 100000, revision = rust-packed-refs) (39 runs)
                mean     ±       σ          min     …     max
  Wall Time     30.6 ms  ±     1.7 ms      28.4 ms  …    35.4 ms           -17.7%
  Memory        11.8 MiB ±     0.1 MiB     11.4 MiB …    12.0 MiB           +0.1%

Wait. So the Rust version is faster? How? This is the Rust code to iterate and apply reference updates:

let (rec_name, rec_start, rec_end) = match existing_rec {
    Some(r) => r,
    None => break,
};

// Check for an update that exactly matches this existing record.
if ui < updates.len() {
    let u_name = unsafe { CStr::from_ptr(updates[ui].refname).to_bytes() };
    if u_name == rec_name {
        if !updates[ui].oid_hex.is_null() {
            // Replace.
            unsafe { write_update_line(&mut out, &updates[ui])? };
        }
        // else: delete – skip the existing record.
        ui += 1;
        continue;
    }
}

// No update touches this record – copy it verbatim.
out.write_all(&records[rec_start..rec_end])?;

Here's the C code in comparison:

static int write_packed_entry(FILE *fh, const char *refname,
               const struct object_id *oid,
               const struct object_id *peeled)
{
   if (fprintf(fh, "%s %s\n", oid_to_hex(oid), refname) < 0 ||
       (peeled && fprintf(fh, "^%s\n", oid_to_hex(peeled)) < 0))
      return -1;
   return 0;
}

// And the caller...

if (cmp < 0) {
	/* Pass the old reference through. */
	if (write_packed_entry(out, iter->ref.name,
			       iter->ref.oid, iter->ref.peeled_oid))
		goto write_error;

	if ((ok = ref_iterator_advance(iter)) != ITER_OK) {
		ref_iterator_free(iter);
		iter = NULL;
	}
}

The C version iterates through the references and writes any unmodified reference to the 'packed-refs' file. But unlike the Rust version, which writes it verbatim, it calls

write_packed_entry()
. The function rebuilds the information with
fprintf()
and
oid_to_hex()
. This is simply unnecessary, since there were no modifications made to the reference.

The fix? Track and write the data verbatim using

fwrite()
:

@@ -1530,9 +1534,15 @@ static enum ref_transaction_error write_with_updates(struct packed_ref_store *re
 		}
 
 		if (cmp < 0) {
-			/* Pass the old reference through. */
-			if (write_packed_entry(out, iter->ref.name,
-					       iter->ref.oid, iter->ref.peeled_oid))
+			const struct packed_ref_iterator *packed_iter =
+				(const struct packed_ref_iterator *)iter;
+			size_t len = packed_iter->pos - packed_iter->record_start;
+
+			/*
+			 * Skip any formatting and directly write to the packed-refs file
+			 * when deleting references and writing the remaining refs verbatim.
+			 */
+			if (fwrite(packed_iter->record_start, len, 1, out) != 1)
 				goto write_error;
 
 			if ((ok = ref_iterator_advance(iter)) != ITER_OK) {

Now, with the patch applied:

❯ ./delete-ref/run --revisions ~/code/git v2.56.0,fix,rust-packed-refs --build-system meson --parameter-list refformat files --parameter-list refcount 100000
Benchmark 1: update-ref: delete ref (refformat = files, refcount = 100000, revision = v2.56.0) (19 runs)
                mean     ±       σ          min     …     max
  Wall Time     66.5 ms  ±     2.1 ms      63.7 ms  …    70.0 ms
  Memory        11.7 MiB ±     0.2 MiB     11.2 MiB …    12.0 MiB

Benchmark 2: update-ref: delete ref (refformat = files, refcount = 100000, revision = fix) (23 runs)
                mean     ±       σ          min     …     max
  Wall Time     54.9 ms  ±     2.1 ms      52.1 ms  …    61.0 ms           -17.4%
  Memory        11.8 MiB ±     0.1 MiB     11.5 MiB …    11.9 MiB           +0.1%

Benchmark 3: update-ref: delete ref (refformat = files, refcount = 100000, revision = rust-packed-refs) (23 runs)
                mean     ±       σ          min     …     max
  Wall Time     55.2 ms  ±     2.5 ms      52.5 ms  …    62.1 ms           -17.0%
  Memory        11.8 MiB ±     0.2 MiB     11.4 MiB …    12.0 MiB           +0.4%

Yay!! 🎉 That's an 18% improvement in raw speed while deleting references and comparable performance to the Rust version.

I chalked up a patch and sent it to the mailing list. It's now merged into

master
: fc70f528aa1927012538052014d7ea784b5a38fa and will be a part of Git v2.98. Oh yeah, we are jumping to v2.98 after v2.56. This is in preparation of Git 3.0, which will follow 2 releases after.