Repository navigation
Composer vendor copies a --prefer-source package's .git into .socket/vendor, so git commits it as an embedded repo and a fresh clone installs an empty package #355
Description
[agent] Found by the scheduled Composer bug-hunt routine (ledger #321).
Summary
When a Composer package was installed from source, vendor/<vendor>/<name>/ is a git clone with its own .git/. That happens with --prefer-source, with config.preferred-install: "source", and on a dist download failure where Composer falls back to source. The composer vendor backend copies the installed tree into .socket/vendor/composer/<uuid>/<vendor>/<name>@<version>/ with no exclusion, so the .git/ directory is copied too.
The vendored copy is then a nested git repository. git add records it as a gitlink (mode 160000) that points at the pristine upstream commit, and warns "adding embedded git repository". None of the patched files are committed. In a fresh clone the copy directory is empty. composer install then exits 0 while "mirroring" the empty path dist, so vendor/psr/log has no files and nothing reports the problem.
vendor itself reports success (applied: 1, exit 0), and the local tree looks fine. The breakage only appears on CI or on a teammate's machine.
Impact
- The patched package never reaches anyone else: the commit contains a gitlink, not the files.
- The fresh install is silently broken (an empty package,
composer installexit 0). Autoloading the package then fails at runtime. - The routine's own run confirms the mechanism: two repo capstones fail locally whenever the fixture's psr/log gets installed from source.
composer_vendor_keeps_files_mirror_filters_would_dropandcomposer_vendor_fast_path_heals_legacy_copyboth fail with "composer's path mirror dropped or changed .git/HEAD", because the vendored copy contains.git/HEAD. The capstones don't catch the git half:composer_e2e_common::fresh_checkoutcopies.socket/with the filesystem, not through git. vexon the fresh clone correctly refuses (omitting pkg:composer/psr/log@3.0.2 from VEX: a patched file is missing), so there's no false attestation.
Repro
Offline vendor with a staged manifest, as in e2e_vendor_composer_build.rs:
export COMPOSER_ALLOW_SUPERUSER=1 # sandbox runs as root
mkdir -p src/proj && cd src/proj && git init -q
cat > composer.json <<'J'
{ "name": "acme/app", "require": {"psr/log": "3.0.2"} }
J
composer update -n -q --prefer-source # or "config": {"preferred-install": "source"}
ls -d vendor/psr/log/.git # present
# stage .socket/manifest.json + blob for pkg:composer/psr/log@3.0.2 patching src/LoggerInterface.php
socket-patch vendor --json --offline --cwd . # exit 0, summary.applied = 1
ls -d .socket/vendor/composer/<uuid>/psr/log@3.0.2/.git # present (276K of upstream history)
printf '/vendor/\n' > .gitignore
git add -A # warning: adding embedded git repository: .socket/vendor/composer/<uuid>/psr/log@3.0.2
git commit -qm vendored
git ls-files -s .socket/vendor/composer/
# 160000 f16e1d5863e37f8d8c2a01719f5b34baa2b714d3 0 .socket/vendor/composer/<uuid>/psr/log@3.0.2
cd .. && git clone -q proj fresh && cd fresh
ls -A .socket/vendor/composer/<uuid>/psr/log@3.0.2 # empty
composer install -n # "Installing psr/log (3.0.2): Mirroring from .socket/…" exit 0
ls vendor/psr/log # No such file or directory: nothing installed, patched file absentThis reproduced 3 out of 3 times: --prefer-source, config.preferred-install: "source", and a --prefer-dist install that fell back to source in the sandbox.
Expected vs actual
- Expected: CLI_CONTRACT.md's composer vendor row says
composer installworks "from the lock alone, real copy not symlink", and docs/testing/composer-compatibility.md says "The patched copy is committed under.socket/vendor/composer/" and "A fresh checkout installs the patched bytes on every version". The copy should hold the package files only. A VCS metadata dir (.git, and by the same logic.svn/.hg) is not part of the package. Composer's own path mirror andcomposer archiveskip it, which is exactly why the capstone sees.git/HEADas "dropped". - Actual:
.git/is copied verbatim, the vendored copy becomes an embedded repo, and a fresh clone installs an empty package with exit 0.
Matrix
| OS | Composer | PHP | Install from source → vendor → git clone → install |
|---|---|---|---|
| Linux | 2.8.12 | 8.4.19 | fail (3/3) |
| Linux | 2.8.12 | 8.4.19, dist install | pass in upstream CI. No .git in the installed tree, so not affected. |
| macOS / Windows | — | — | not run. The copy is OS-independent; git's embedded-repo handling is the same everywhere. |
| Composer 1.x | — | — | not run. --prefer-source clones with .git there too, so it's expected to behave the same. |
Tested on main f6b7fb9 (CLI 4.0.0, the latest release).
Suspect code
crates/socket-patch-core/src/vendor/composer_lock.rs:750:installed_dir.stage_into(&stage, None)copies the whole installed tree. The skip parameter only supports one file name (cargo uses it for.cargo-checksum.json,vendor/cargo.rs:507), so there's no VCS-dir exclusion.- The service path extracts a dist zip, so it's unaffected. Only the local build from an installed source checkout is.
- Also worth checking: any heal/verify that inventories the copy (a
.git/would change the copy's file set when git gc repacks objects).
Activity
mikolalysenko commentedon Sep 30, 2026
[agent] Triaged:
priority:p2(Composer). This isn't a duplicate, and I found no existing fix PR. The cause is the Composer vendor backend's tree copy, which has no exclusion for VCS metadata.Generated by Claude Code
mikolalysenko commentedon Sep 30, 2026
[agent] One more trigger, from the Composer bug-hunt ledger (#321). This still reproduces on main
f6b7fb9(4.0.0) with Composer 2.8.12 on PHP 8.3, on Linux.The bug doesn't need
--prefer-sourceorpreferred-install: source. A source-only lock entry hits it with no preference at all: atype: vcsrepository with no dist, such as a plain git or self-hosted GitLab remote, gives a lock entry withsourceand nodist, and Composer always installs that from git. On a fresh clone the package directory is also absent, not just empty, andcomposer installstill exits 0.For contrast, the hosted rewriter refuses the same entry and fails closed (
redirect_composer_no_dist), so only vendored mode is affected.Generated by Claude Code
mikolalysenko commentedon Oct 1, 2026
[agent] Probe results for the source-only variant in my previous comment (ledger #321), from run https://github.com/SocketDev/socket-patch/actions/runs/36793579033 on main
f6b7fb9. Each cell is: atype: vcsrepository with no dist →socket-patch vendor --offline→git add(embedded repository warning plus a160000gitlink) →git clone→composer install.In every cell
composer installprintsInstalling acme/lib (v1.2.3): Mirroring from .socket/vendor/composer/<uuid>/acme/lib@1.2.3and exits 0, but the patched file isn't invendor/on the clone. As a control, a path-repository package installed as a plain copy (no.git) vendors, fresh-installs patched and reverts byte for byte in all 9 cells.Generated by Claude Code
mikolalysenko commentedon Oct 1, 2026
[agent] Re-triaged after the source-only variant: still
priority:p2(Composer). A source-only lock entry (type: vcsrepo, no dist) reaches the same Composer vendor tree copy with no VCS-metadata exclusion, so it is the same root cause and in scope for this issue; no separate issue is needed.Generated by Claude Code
mikolalysenko commentedon Oct 1, 2026
[agent] Fixed on main
2463257(the v5 consolidation, #277, which includes #300 "Require server artifacts and exact redownload for vendoring"). Vendored mode no longer copies the installed tree. It extracts the patch service's prebuilt dist zip into.socket/vendor/…, so no.gitcan come along. Re-checked on Linux with Composer 2.8.12 / PHP 8.3, against a local mock patch service that serves the patched zip. Each variant ran twice in fresh directories:.gitunder.socket/vendor160000gitlinks aftergit addcomposer installvexon the clonecomposer install --prefer-source(lock has source + dist)Mirroring from .socket/vendor/composer/<uuid>/acme/tool@1.0.0, file patched (also with--prefer-sourceon the clone)type: vcs, no dist,vendor/acme/tool/.gitpresent)Installing acme/tool (v1.0.0): Mirroring from .socket/vendor/…, file patchedThe rewired lock entry is
dist: {type: path, url: .socket/vendor/composer/<uuid>/acme/tool@1.0.0, reference: <uuid>}withsourceremoved. The change is path/extraction logic, not OS-specific. Closing as completed.Generated by Claude Code
composer: run 4 ledger (#463 filed, #355 closed)