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):
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
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:
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": nullinuv_cache.json, so the staleness comparison isnull == nullforever.In a linked worktree,
.gitis a file containinggitdir: <repo>/.git/worktrees/<name>. Looking atcrates/uv-cache-info/src/git_info.rs(currentmain),git_head()follows that pointer but returns the per-worktree git dir itself without appendingHEAD— the comment above it even spells out the intended<gitdir>/HEADpath — soCommit::from_repositorytries to read a directory, fails, and the commit is recorded asnull. Even with that fixed, the symbolic ref inHEAD(ref: refs/heads/<branch>) is resolved viagit_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 needscommondirhandling. Tags are found in the worktree case becausegit_refs()maps the worktree git dir to<gitdir>/../../refsin the common dir.The worktree branch of
git_head(), returning the per-worktree git dir without appendingHEAD:uv/crates/uv-cache-info/src/git_info.rs
Lines 134 to 150 in 6feeacc
The symbolic-ref lookup, relative to the original
.gitpath:uv/crates/uv-cache-info/src/git_info.rs
Lines 52 to 54 in 6feeacc
git_refs()mapping the worktree git dir to the commonrefs:uv/crates/uv-cache-info/src/git_info.rs
Lines 176 to 178 in 6feeacc
Minimal reproducible example (macOS/Linux shell;
hatchling/hatch-vcsonly used to make the version dynamic, the bug is in the cache key, not the build):Output (pretty-printed, timestamps elided):
Consequence in the worktree: after
git commit,uv sync/uv runconsider 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,
tagsis recorded as empty even thoughv0.1.0exists. Both ref readers only look at loose ref files and never consultpacked-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: runninggit pack-refs --allinplainand re-syncing records"commit": nullthere too.The commit-side loose read is the
read_to_stringin the symbolic-ref snippet above; tags are likewise collected by walking loose ref files only:uv/crates/uv-cache-info/src/git_info.rs
Line 93 in 6feeacc
Platform
Darwin 25.4.0 arm64
Version
uv 0.11.16 (Homebrew 2026-05-21 aarch64-apple-darwin)
Python version
Python 3.14.0