Skip to content

uv rollback, remove and vendor --revert leave an empty [tool.uv.sources] header behind when the project's sources are written as [tool.uv.sources.<name>] sub-tables #524

Description

[agent] Found by the scheduled uv bug-hunt routine (ledger #310).

Summary

Some uv projects write their sources only as sub-tables ([tool.uv.sources.idna] with url = … beneath it) and have no [tool.uv.sources] header. On those projects, hosted scan and vendored scan add an explicit [tool.uv.sources] header that holds the new six = { … } entry. rollback, remove and vendor --revert then remove the entry but keep the header and a blank line. So after a full round trip, pyproject.toml isn't byte-identical to the original: two lines ([tool.uv.sources] and an empty line) are left over.

Impact

The impact is low. The table is empty, uv parses it, uv lock --locked still passes, uv.lock is restored byte for byte, and the install is unchanged. But the tree is left dirty after an unwind, and that breaks "rollback and check for a clean git status" CI flows. It also breaks the restore contract. The residue is stable: a second scan → rollback cycle doesn't add more.

Repro (Linux, real uv 0.8.17 and 0.5.31, main 61cfb9b)

cat > pyproject.toml <<'EOF'
[project]
name = "app"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["six==1.16.0", "idna==3.7", "certifi==2024.2.2"]

[tool.uv.sources.idna]
url = "https://files.pythonhosted.org/packages/e5/3e/741d8c82801c347547f8a2a06aa57dbb1992be9e948df2ea0eda2c8b79e8/idna-3.7-py3-none-any.whl"
EOF
uv lock && uv sync && cp pyproject.toml pyproject.orig
socket-patch scan --mode hosted --yes $API      # adds "[tool.uv.sources]\nsix = { url = … }\n\n" above the sub-table
uv sync --locked                                 # six is patched; vex says not_affected (redirected)
socket-patch rollback --yes $API                 # exit 0, status success
diff pyproject.orig pyproject.toml
# > [tool.uv.sources]
# >

The same residue appears with socket-patch remove <uuid> after a hosted scan, and with scan --mode vendored followed by vendor --revert. The patch API was a local mock serving a free six 1.16.0 patch (SRI integrity, deterministic wheel), and SOCKET_PYPI_JSON_API pointed at a pass-through to pypi.org.

Spellings that come back byte-identical, checked alongside: [tool.uv] + sources.idna = {…} (dotted key), a root-level tool.uv.sources.idna = {…}, sources = { idna = {…} } (inline table), and no sources at all.

Expected vs actual

  • Expected: CLI_CONTRACT.md, "Hosted unwind coverage" → "What a restore does": "only the hosted entries change and every other byte stays the file's own". The [tool.uv.sources] header is hosted-mode bytes, so it should go when its last hosted entry goes, the same way it already does when hosted mode created the whole table.
  • Actual: the header and a blank line stay. Exit code 0, with no warning.

OS × version

Cell uv 0.5.31 uv 0.8.17
hosted scan → rollback (Linux) ❌ residue ❌ residue
hosted scan → remove (Linux) – ❌ residue
vendored scan → vendor --revert (Linux) – ❌ residue

The rewrite is a pure text and TOML edit on the CLI side, independent of OS or uv release; uv only has to accept the result. Not bisected: v5 is the first release with the upstream restore.

Suspect code

  • crates/socket-patch-core/src/vendor/pypi_uv.rs:498-502: created_sources_table is false whenever tool.uv.sources exists, including when it's only implied by a [tool.uv.sources.<name>] sub-table. So the remove_table_if_empty(…, "[tool.uv.sources]") call at :887-890 never runs, even though toml_edit printed a new explicit header.
  • crates/socket-patch-core/src/patch/redirect/upstream/uv.rs:1111-1128 (restore_metadata): it removes the key, but the sources table isn't empty (it still holds the idna sub-table), and the table stays explicit, so the header is printed. One possible fix is to mark the table implicit again when only sub-tables remain and it wasn't explicit before the scan.

Activity

  1. mikolalysenko commented on Oct 2, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triaged as priority:p1 (uv). Confirmed on main 61cfb9b: created_sources_table in crates/socket-patch-core/src/vendor/pypi_uv.rs is computed from whether tool.uv.sources exists at all, and a [tool.uv.sources.<name>] sub-table makes it exist implicitly, so the explicit header hosted/vendored mode adds is never removed on unwind. No open PR or duplicate found.


    Generated by Claude Code

  2. mikolalysenko commented on Oct 2, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Shares root cause with #544: the uv [tool.uv.sources] wiring assumes a standalone, explicit [tool.uv.sources] header with plain name = {…} lines, but toml_edit writes into whatever spelling the project already has (a dotted sources.<pkg> key under [tool.uv], or a header-less parent implied by [tool.uv.sources.<pkg>] sub-tables). The vendored ledger then records a line the file never contains (#544), and the unwind never removes the header the scan made explicit (#524). Will be fixed together.


    Generated by Claude Code

  3. mikolalysenko commented on Oct 2, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Claiming this issue (with #544; shared root cause: uv sources wiring ignores the project's existing sources-table spelling). Branch: agent/fix-uv-sources-table-spelling. Claim-ID: 2026-10-02T09:20:46Z-d304db


    Generated by Claude Code

  4. mikolalysenko commented on Oct 2, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft PR: #545


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions