[BUG] Plugin install drops `.git` from the versioned cache directory for `url` git sources, so `git rev-parse HEAD` in the cache silently resolves to an enclosing repo
Environment
- Claude Code CLI: 2.1.280
- OS: macOS (Darwin 25.6.0)
- Marketplace type: a local directory marketplace added via
claude plugin marketplace add <path>, whosemarketplace.jsonlists plugin entries withurl-type git sources, for example:
{
"name": "<plugin-name>",
"source": {
"source": "url",
"url": "https://github.com/example-org/example-plugin-skill.git",
"ref": "main"
}
}
Summary
When claude plugin install installs a plugin from a url git source, the clone into the temporary staging directory has a complete .git directory. The finalize step that moves that staged content into the permanent versioned cache path does not keep .git. This happens on every install under 2.1.280, including a completely fresh install into a cache path that did not exist beforehand.
The plugin content is correct (the right remote commit is fetched every time). Only the git metadata is lost. Anything that reads the installed commit with git -C <cache-dir> rev-parse HEAD then fails. If the cache directory sits inside another git repository (for example, when ~/.claude is tracked as a personal dotfiles repo), git walks up to the parent repo and returns that repo's HEAD with no error at all.
How this was confirmed
- Git invocation capture: a
gitshim placed first onPATHlogged the exact command the CLI runs during install:
````
git clone --depth 1 --recurse-submodules --shallow-submodules --branch <ref> -- <url> ~/.claude/plugins/cache/temp_git_<timestamp>_<random>
The staging directory is created directly under ~/.claude/plugins/cache/.
- Filesystem polling at 3ms resolution across about 8 install and reinstall cycles: the
temp_git_*staging directory had an intact.giton every poll for its entire lifetime. The final path~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/never had.gitafter the move.
- Fresh install test: running
rm -rfon the plugin's entire cache directory before reinstalling still produced a final cache directory with no.git. This rules out reuse of an existing or orphaned directory as the cause. The finalize/move step itself drops.gitunconditionally.
- Determinism: the result is identical on the very first cycle, on repeated back to back cycles, and with an artificial 5 second sleep inserted between uninstall and install. This is not a race or timing issue.
Steps to Reproduce
- Add a local directory marketplace with at least one plugin whose source is a
urlgit source (see the example above):
``sh``
claude plugin marketplace add <path-to-directory-containing-.claude-plugin/marketplace.json>
- Remove any existing cache for the plugin:
``sh``
rm -rf ~/.claude/plugins/cache/<marketplace-name>/<plugin-name>
- Install it:
``sh``
claude plugin install <plugin-name>@<marketplace-name>
- Find the installed version directory and inspect it (
<version>is whateverclaude plugin installreports, or check~/.claude/plugins/installed_plugins.json):
``sh``
ls ~/.claude/plugins/cache/<marketplace-name>/<plugin-name>/
ls -a ~/.claude/plugins/cache/<marketplace-name>/<plugin-name>/<version>/
git -C ~/.claude/plugins/cache/<marketplace-name>/<plugin-name>/<version>/ rev-parse --show-toplevel
git -C ~/.claude/plugins/cache/<marketplace-name>/<plugin-name>/<version>/ rev-parse HEAD
- Optional, to see the silent wrong-answer case specifically: run
git init ~/.claudefirst (or perform steps 1 to 4 on a machine where~/.claudeis already tracked as a personal dotfiles repo), then repeat step 4'srev-parsecommands.rev-parse --show-toplevelreturns~/.claudeinstead of the plugin's cache directory, andrev-parse HEADreturns the dotfiles repo's commit with no error.
Expected behavior
The versioned cache directory keeps the .git directory from the clone (as plugins installed under earlier CLI versions do), so git rev-parse HEAD in that directory returns the commit that was actually installed.
Actual behavior
- The versioned cache directory has no
.git. - If no parent directory is a git repo,
git rev-parse HEADfails withfatal: not a git repository. - If a parent directory is a git repo (for example
~/.claudetracked as a dotfiles repo),rev-parse --show-toplevelreturns that parent, andrev-parse HEADreturns the parent repo's commit, silently and with exit code 0.
Related uninstall behavior
Separately, claude plugin uninstall also removes .git immediately at uninstall time and leaves the plugin's files in place with an .orphaned_at marker instead of deleting the directory (confirmed by inspecting the directory right after uninstall, before any install ran). This is not the cause of the install behavior above (the fresh install test reproduces it with no leftover directory), but it produces the same missing .git state.
Regression indicator
On the affected machine, all 15+ plugins installed before a point earlier this month (under an older CLI version) still have a correct .git in their cache directories. Every plugin installed or reinstalled under 2.1.280 lacks it. This points to a regression in a CLI release between those two points. The exact version that introduced it has not been pinned down, since no older CLI binary was available for a direct comparison.
Impact
- Any tooling that determines the installed plugin version by querying the cache directory with git (update checkers, drift detection, "is this plugin current with its remote" scripts) breaks.
- The failure is silent when
~/.claude, or any ancestor of the plugin cache, is itself a git repository. That setup is common among users who version their Claude Code configuration as dotfiles. Tools then receive a valid looking but wrong SHA and make wrong decisions with no error surfaced.
As a workaround, tooling can read the gitCommitSha field that Claude Code records for each plugin in ~/.claude/plugins/installed_plugins.json instead of querying the cache directory. Note per #80304's own related observation, gitCommitSha has separately been seen to go stale after claude plugin update on at least one official SHA-versioned plugin, so tooling relying on it should sanity-check it against the plugin's recorded version where possible.
Related issue
#80304 describes the same vulnerability class: a plugin related directory without .git, combined with ~/.claude being a git repo, causes git's parent directory walk to return the enclosing repo's SHA silently. The trigger there is different. In #80304 the official marketplace's own top level directory loses .git during a periodic refresh (its snapshot is stored with a .gcs-sha file instead). This report covers per-plugin url source installs from a local directory marketplace losing .git during the install finalize step, which is a separate code path. Evidence for this separate path is below (the captured git clone invocation, 3ms polling of the staging directory, and the rm -rf fresh install reproduction).
The fix approaches suggested in #80304 apply here too:
- Check that
git rev-parse --show-toplevelequals the target directory before trusting any git query against it, or setGIT_CEILING_DIRECTORIES. - Record the SHA at fetch time and rely on that record instead of querying the directory afterward.
- Or preserve
.gitwhen moving the staged clone into the versioned cache path, which restores the previous behavior.