Skip to content

cache-keys git commit is never detected in linked git worktrees (recorded as null) #19705

@Gattocrucco

Description

@Gattocrucco

Summary

(Note: written by Opus 4.8 & checked by me, though I admit I can't follow the details about "packed refs" because I don't know how that works.)

I have a project with a dynamic version derived from git (hatchling + hatch-vcs), and the documented cache-keys setting so that the editable install is rebuilt when HEAD moves:

[tool.uv]
cache-keys = [{ file = "pyproject.toml" }, { git = { commit = true, tags = true } }]

This works in a regular clone, but in a linked git worktree (git worktree add), new commits never trigger a rebuild, so the editable install's version stays stale indefinitely. The cache info recorded at install time shows why: uv writes "commit": null in uv_cache.json, so the staleness comparison is null == null forever.

In a linked worktree, .git is a file containing gitdir: <repo>/.git/worktrees/<name>. Looking at crates/uv-cache-info/src/git_info.rs (current main), git_head() follows that pointer but returns the per-worktree git dir itself without appending HEAD — the comment above it even spells out the intended <gitdir>/HEAD path — so Commit::from_repository tries to read a directory, fails, and the commit is recorded as null. Even with that fixed, the symbolic ref in HEAD (ref: refs/heads/<branch>) is resolved via git_dir.join(git_ref) relative to the worktree's .git (a file), while branch refs live in the common git dir — so a full fix also needs commondir handling. Tags are found in the worktree case because git_refs() maps the worktree git dir to <gitdir>/../../refs in the common dir.

The worktree branch of git_head(), returning the per-worktree git dir without appending HEAD:

// If `.git/HEAD` doesn't exist and `.git` is actually a file,
// then let's try to attempt to read it as a worktree. If it's
// a worktree, then its contents will look like this, e.g.:
//
// gitdir: /home/andrew/astral/uv/main/.git/worktrees/pr2
//
// And the HEAD file we want to watch will be at:
//
// /home/andrew/astral/uv/main/.git/worktrees/pr2/HEAD
let contents = fs_err::read_to_string(git_dir).ok()?;
let (label, worktree_path) = contents.split_once(':')?;
if label != "gitdir" {
return None;
}
let worktree_path = worktree_path.trim();
Some(PathBuf::from(worktree_path))
}

The symbolic-ref lookup, relative to the original .git path:

let commit = if let Some(git_ref) = git_ref_parts.next() {
let git_ref_path = git_dir.join(git_ref);
let commit = fs_err::read_to_string(git_ref_path)?;

git_refs() mapping the worktree git dir to the common refs:

let worktree_path = PathBuf::from(worktree_path.trim());
let refs_path = worktree_path.parent()?.parent()?.join("refs");
Some(refs_path)

Minimal reproducible example (macOS/Linux shell; hatchling/hatch-vcs only used to make the version dynamic, the bug is in the cache key, not the build):

cd "$(mktemp -d)"
git init --bare --quiet .bare -b main
git clone --quiet .bare seed
cd seed
git config commit.gpgsign false && git config tag.gpgsign false
mkdir -p src/cktest
cat > pyproject.toml <<'EOF'
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
name = "cktest"
dynamic = ["version"]
requires-python = ">=3.10"
[tool.hatch.version]
source = "vcs"
[tool.uv]
cache-keys = [{ file = "pyproject.toml" }, { git = { commit = true, tags = true } }]
EOF
touch src/cktest/__init__.py
git add -A && git commit --quiet -m init
git tag v0.1.0
git push --quiet origin main v0.1.0
cd .. && rm -rf seed

git -C .bare worktree add --quiet ../wt main   # linked worktree
git clone --quiet .bare plain                  # regular clone, as control

(cd wt && uv sync --quiet && cat .venv/lib/python*/site-packages/cktest-*.dist-info/uv_cache.json)
(cd plain && uv sync --quiet && cat .venv/lib/python*/site-packages/cktest-*.dist-info/uv_cache.json)

Output (pretty-printed, timestamps elided):

// wt (linked worktree): commit is null, tag found
{"timestamp": ..., "commit": null, "tags": {"v0.1.0": "c3aff651..."}, "env": {}, "directories": {}}
// plain (regular clone): commit found, tags empty
{"timestamp": ..., "commit": "c3aff651...", "tags": {}, "env": {}, "directories": {}}

Consequence in the worktree: after git commit, uv sync/uv run consider the project fresh and the hatch-vcs version never advances, while the same setup in a regular clone rebuilds as documented.

Secondary observation, visible in the same output: in the regular clone, tags is recorded as empty even though v0.1.0 exists. Both ref readers only look at loose ref files and never consult packed-refs (after a clone, refs are typically packed); in a repo with mixed refs I see only the loose tags recorded. This also affects the commit key in regular clones: running git pack-refs --all in plain and re-syncing records "commit": null there too.

The commit-side loose read is the read_to_string in the symbolic-ref snippet above; tags are likewise collected by walking loose ref files only:

for entry in WalkDir::new(&git_tags_path).contents_first(true) {

Platform

Darwin 25.4.0 arm64

Version

uv 0.11.16 (Homebrew 2026-05-21 aarch64-apple-darwin)

Python version

Python 3.14.0

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions