diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 715e926e..d24e13c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,14 +22,17 @@ jobs: python-version: '3.13' - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 - name: Install CLI tools run: | - for tool in mbake ty; do uv tool install ${tool}; done - # Pin ruff to the same version as the dev dependency group in - # pyproject.toml so the linter behaves identically across the uv tool, - # the synced virtualenv, and developer PATH. Bump both sites together. + for tool in mbake; do uv tool install ${tool}; done + # ty is not installed here: `make typecheck` runs it via + # `uv tool run ty@$(TY_VERSION)`. TY_VERSION in the Makefile is the + # sole ty version declaration; update it there. + # Pin ruff to the version the Makefile invokes (RUFF_VERSION) so the + # linter behaves identically across the uv tool, the synced + # virtualenv, and developer PATH; bump both sites together. uv tool install ruff==0.15.12 npm install -g markdownlint-cli2 @@ -85,7 +88,7 @@ jobs: # where the gate step is skipped (secret-less fork PRs, and # push-to-main where the gate is PR-only). - name: Upload coverage artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: coverage-report path: coverage.xml diff --git a/Makefile b/Makefile index 71b0c54c..4f5ec024 100644 --- a/Makefile +++ b/Makefile @@ -10,8 +10,13 @@ UV ?= $(shell command -v uv 2>/dev/null || printf '%s/.local/bin/uv' "$$HOME") RUFF_VERSION ?= 0.15.12 RUFF ?= $(UV) tool run --from ruff==$(RUFF_VERSION) ruff TYPOS_VERSION ?= 1.48.0 +# Pin ty. `make typecheck` invokes it via `uv tool run ty@$(TY_VERSION)`, so +# TY_VERSION is the sole ty version declaration (CI runs `make typecheck` and +# installs no separate ty). Bump it here to move local and CI checks together. +TY_VERSION ?= 0.0.32 +TY ?= $(UV) tool run ty@$(TY_VERSION) UV_ENV = UV_CACHE_DIR=.uv-cache UV_TOOL_DIR=.uv-tools -TOOLS = $(MDFORMAT_ALL) ty $(MDLINT) $(NIXIE) $(UV) +TOOLS = $(MDFORMAT_ALL) $(MDLINT) $(NIXIE) $(UV) PY_SOURCES := $(sort $(shell find lading scripts -type f -name '*.py' -print)) VENV_TOOLS = interrogate pytest PYLINT_PYTHON ?= pypy @@ -83,8 +88,8 @@ lint: build $(UV) interrogate ## Run linters $(UV) run interrogate --fail-under 100 lading $(PYLINT) $(PYLINT_TARGETS) -typecheck: build ty ## Run typechecking - ty check --python-version 3.13 $(PY_SOURCES) +typecheck: build $(UV) ## Run typechecking + $(UV_ENV) $(TY) check --python-version 3.13 $(PY_SOURCES) markdownlint: spelling $(MDLINT) ## Lint Markdown files and enforce spelling find . -type f -name '*.md' \ diff --git a/docs/developers-guide.md b/docs/developers-guide.md index fc439913..f8294bf2 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -381,7 +381,6 @@ manifest rewrites, while publish only probes freshness read-only via `cargo metadata --locked --manifest-path ... --format-version=1`. It returns a `LockfileFreshness` result that distinguishes fresh lockfiles, lockfiles that Cargo says need updating under `--locked`, and unrelated Cargo failures. - The publish pre-flight domain reaches both operations through the `LockfileInspectionRepository` port (issue #82) rather than holding a command runner: `_validate_lockfile_freshness` and `_collect_stale_lockfiles` in @@ -398,6 +397,11 @@ counterpart to the bump-side `bump_lockfiles.LockfileRepository`; together they complete issue #82's separation of lockfile VCS/filesystem concerns from the command domain. +`_collect_stale_lockfiles` deliberately classifies every tracked lockfile +rather than short-circuiting on the first stale result (issue #83), so the +raised error can list each stale lockfile with its repair command and the +operator repairs the workspace in a single pass. + `LockfileDiscoveryError` inherits `LadingError`; its messages include the git failure detail. diff --git a/docs/lading-design.md b/docs/lading-design.md index 852ca1e5..7ff90e11 100644 --- a/docs/lading-design.md +++ b/docs/lading-design.md @@ -506,6 +506,18 @@ Together with the bump-side `LockfileRepository`, the two ports keep VCS, filesystem, and cargo execution concerns out of the lockfile domain logic (issue #82). +`_collect_stale_lockfiles` deliberately classifies every tracked +`Cargo.lock` rather than short-circuiting on the first stale one +(issue #83). When stale lockfiles are found, `_build_stale_lockfile_message` +composes a diagnostic message that lists each offending lockfile +alongside its own `cargo generate-lockfile --manifest-path ...` repair +command; `_validate_lockfile_freshness` then raises it as a +`PublishPreflightError`, so the operator can remediate the whole +workspace in a single pass rather than re-running the pre-flight once +per lockfile. Only unexpected failures from `cargo metadata --locked` — +those not attributable to a stale lockfile — raise immediately on +first occurrence, leaving the aggregation path unaffected. + ### Publish Preflight Sequence The preflight sequence diagram illustrates the pre-flight checks that run diff --git a/docs/users-guide.md b/docs/users-guide.md index c077b8f7..0dd65946 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -114,17 +114,23 @@ lading publish ``` Before running `cargo check` and `cargo test`, `lading publish` validates that -all git-tracked `Cargo.lock` files are fresh under `--locked` mode. If any -lockfile is stale — for example after a `lading bump` that regenerated a nested -workspace lockfile — the command exits with code 1 and prints a repair command: +all git-tracked `Cargo.lock` files are fresh under `--locked` mode. It probes +every tracked lockfile rather than stopping at the first stale one — for +example after a `lading bump` that regenerated one or more nested workspace +lockfiles — so a single run reports the whole workspace. If any lockfile is +stale, the command exits with code 1 and lists each stale lockfile alongside +its own repair command: -```text +```plaintext Tracked Cargo.lock files are stale after manifest version changes. -Run the following to repair: +This commonly happens after running `lading bump`; repair each stale lockfile directly: +- /Cargo.lock cargo generate-lockfile --manifest-path /Cargo.toml +- /Cargo.lock + cargo generate-lockfile --manifest-path /Cargo.toml ``` -Run the repair command, commit the updated lockfile, then re-run +Run each repair command, commit the updated lockfiles, then re-run `lading publish`. To require a clean working tree before running the pre-flight checks, pass diff --git a/lading/commands/publish_preflight.py b/lading/commands/publish_preflight.py index f7ea5334..3ccb862d 100644 --- a/lading/commands/publish_preflight.py +++ b/lading/commands/publish_preflight.py @@ -207,6 +207,13 @@ def _collect_stale_lockfiles( ) -> list[Path]: """Classify tracked lockfiles; raise immediately on error, return stale paths. + Every tracked lockfile is classified rather than short-circuiting on the + first stale result (issue #83): the aggregated error message lists each + stale lockfile with its repair command, so the operator fixes the whole + workspace in one pass instead of replaying the pre-flight per lockfile. + The extra ``cargo metadata --locked`` probes are cheap relative to that + replay loop. Unexpected (non-stale) failures still raise immediately. + Raises ------ PublishPreflightError diff --git a/scripts/typos_rollout.py b/scripts/typos_rollout.py index 3720186f..57fddef4 100644 --- a/scripts/typos_rollout.py +++ b/scripts/typos_rollout.py @@ -54,7 +54,9 @@ def _string_list(table: cabc.Mapping[str, object], key: str) -> tuple[str, ...]: if not isinstance(value, list) or not all(isinstance(item, str) for item in value): message = f"{key!r} must be a list of strings" raise TypeError(message) - return tuple(sorted(set(value))) + # The guard above has already proven every element is a str, so cast the + # validated list directly rather than filtering it a second time. + return tuple(sorted(set(typ.cast("list[str]", value)))) def _table(document: cabc.Mapping[str, object], key: str) -> cabc.Mapping[str, object]: diff --git a/tests/bdd/features/cli.feature b/tests/bdd/features/cli.feature index 0f93285e..b83c49bb 100644 --- a/tests/bdd/features/cli.feature +++ b/tests/bdd/features/cli.feature @@ -230,6 +230,19 @@ Feature: Lading CLI scaffolding Then the CLI exits with code 1 And the stderr contains "cargo generate-lockfile --manifest-path" + Scenario: Publish pre-flight aggregates every stale Cargo.lock file + Given a workspace directory with configuration + And cargo metadata describes a sample workspace + And publish pre-flight finds multiple stale tracked Cargo.lock files + When I invoke lading publish with that workspace + Then the CLI exits with code 1 + And the stderr contains "Tracked Cargo.lock files are stale after manifest version changes." + And the stderr contains workspace path "Cargo.lock" + And the stderr contains workspace path "sub/Cargo.lock" + And the stderr contains "cargo generate-lockfile --manifest-path" + And the stderr contains workspace path "Cargo.toml" + And the stderr contains workspace path "sub/Cargo.toml" + Scenario: Publish pre-flight skips configured cargo test crates Given a workspace directory with configuration And cargo metadata describes a sample workspace diff --git a/tests/bdd/steps/test_common_steps.py b/tests/bdd/steps/test_common_steps.py index 92aa1b1f..780fda35 100644 --- a/tests/bdd/steps/test_common_steps.py +++ b/tests/bdd/steps/test_common_steps.py @@ -58,6 +58,24 @@ def then_stderr_contains(cli_run: dict[str, typ.Any], expected: str) -> None: assert expected in cli_run["stderr"] +@then(parsers.parse('the stderr contains workspace path "{relative}"')) +def then_stderr_contains_workspace_path( + cli_run: dict[str, typ.Any], relative: str +) -> None: + """Assert stderr contains the absolute path ``relative`` under the workspace. + + The workspace root is a per-test temporary directory, so scenarios cannot + hard-code its absolute path. Resolving ``relative`` against + ``cli_run["workspace"]`` lets a scenario assert a specific lockfile or + manifest path — distinguishing, for example, the workspace-root + ``Cargo.lock`` from a nested crate's — rather than a substring both share. + """ + expected = str(cli_run["workspace"] / relative) + assert expected in cli_run["stderr"], ( + f"expected workspace path {expected!r} in stderr:\n{cli_run['stderr']}" + ) + + @then(parsers.parse('the workspace manifest version is "{version}"')) def then_workspace_manifest_version( cli_run: dict[str, typ.Any], diff --git a/tests/bdd/steps/test_publish_given_steps.py b/tests/bdd/steps/test_publish_given_steps.py index bf3cf95a..1555d654 100644 --- a/tests/bdd/steps/test_publish_given_steps.py +++ b/tests/bdd/steps/test_publish_given_steps.py @@ -93,6 +93,40 @@ def given_publish_preflight_finds_stale_lockfile( ) +@given("publish pre-flight finds multiple stale tracked Cargo.lock files") +def given_publish_preflight_finds_multiple_stale_lockfiles( + workspace_directory: Path, + preflight_overrides: dict[tuple[str, ...], ResponseProvider], +) -> None: + """Simulate several tracked lockfiles that all fail locked validation. + + Two tracked lockfiles (the workspace root and a nested ``sub`` crate) are + staged on disk with adjacent manifests so discovery returns both, and a + single prefix-matched ``cargo metadata --locked`` stub reports each as + stale. This exercises the aggregated, no-short-circuit stale report. + """ + nested = workspace_directory / "sub" + nested.mkdir(parents=True, exist_ok=True) + (nested / "Cargo.toml").write_text("[package]\n", encoding="utf-8") + (workspace_directory / "Cargo.lock").write_text("# stale lock\n", encoding="utf-8") + (nested / "Cargo.lock").write_text("# stale lock\n", encoding="utf-8") + preflight_overrides["git", "ls-files", "**/Cargo.lock", "Cargo.lock"] = ( + _CommandResponse(exit_code=0, stdout="Cargo.lock\nsub/Cargo.lock\n") + ) + preflight_overrides[ + "cargo", + "metadata", + "--locked", + "--manifest-path", + ] = _CommandResponse( + exit_code=101, + stderr=( + "error: cannot update the lock file because --locked was passed " + "to prevent this" + ), + ) + + @given(parsers.parse('cargo test fails with compiletest artifact "{relative_path}"')) def given_cargo_test_fails_with_artifact( workspace_directory: Path, diff --git a/tests/unit/publish/test_preflight_lockfile_validation.py b/tests/unit/publish/test_preflight_lockfile_validation.py index e7e3ff53..1c84b00d 100644 --- a/tests/unit/publish/test_preflight_lockfile_validation.py +++ b/tests/unit/publish/test_preflight_lockfile_validation.py @@ -13,7 +13,9 @@ import typing as typ from pathlib import Path +import hypothesis.strategies as st import pytest +from hypothesis import HealthCheck, given, settings from lading.commands import lockfile, publish, publish_preflight @@ -52,6 +54,64 @@ def validate_lockfile_freshness( _STALE_DETAIL = "the lock file Cargo.lock needs to be updated but --locked was passed" +_outcome = st.sampled_from(("fresh", "stale", "error")) + + +def _repository_for_outcomes( + tmp_path: Path, outcomes: list[str] +) -> tuple[_RecordingLockfileRepository, list[Path]]: + """Build a recording repository mapping each outcome to a tracked lockfile. + + Each outcome at index ``i`` yields the lockfile ``tmp_path/pkgi/Cargo.lock`` + and a ``freshness`` entry keyed on its adjacent ``Cargo.toml``: ``fresh`` + passes, ``stale`` is flagged for repair, and ``error`` is an unexpected + cargo failure. Returns the repository and the ordered lockfile paths. + """ + freshness_for = { + "fresh": lockfile.LockfileFreshness(is_fresh=True), + "stale": lockfile.LockfileFreshness( + is_fresh=False, is_stale=True, detail=_STALE_DETAIL + ), + "error": lockfile.LockfileFreshness(is_fresh=False, detail="boom"), + } + lockfiles: list[Path] = [] + freshness: dict[Path, lockfile.LockfileFreshness] = {} + for i, outcome in enumerate(outcomes): + lockfile_path = tmp_path / f"pkg{i}" / "Cargo.lock" + lockfiles.append(lockfile_path) + freshness[lockfile_path.parent / "Cargo.toml"] = freshness_for[outcome] + repository = _RecordingLockfileRepository( + tracked=tuple(lockfiles), freshness=freshness + ) + return repository, lockfiles + + +def _stale_lockfiles_error_message(workspace_root: Path) -> str: + """Return the raised error for a root and nested stale lockfile pair. + + Drives ``_validate_lockfile_freshness`` through a recording port double + whose tracked lockfiles are all stale, and returns the resulting + ``PublishPreflightError`` message for assertion or snapshotting. + """ + root_lockfile = workspace_root / "Cargo.lock" + nested_lockfile = workspace_root / "tests" / "ui_lints" / "Cargo.lock" + repository = _RecordingLockfileRepository( + tracked=(root_lockfile, nested_lockfile), + default_freshness=lockfile.LockfileFreshness( + is_fresh=False, is_stale=True, detail=_STALE_DETAIL + ), + ) + + with pytest.raises( + publish.PublishPreflightError, + match="Tracked Cargo\\.lock files are stale", + ) as excinfo: + publish_preflight._validate_lockfile_freshness( + workspace_root, repository=repository + ) + + return str(excinfo.value) + def test_validate_lockfile_freshness_passes_when_all_lockfiles_are_fresh( tmp_path: Path, @@ -72,22 +132,10 @@ def test_validate_lockfile_freshness_passes_when_all_lockfiles_are_fresh( def test_validate_lockfile_freshness_reports_stale_lockfiles(tmp_path: Path) -> None: """Stale lockfiles are collected and reported with repair commands.""" + message = _stale_lockfiles_error_message(tmp_path) root_lockfile = tmp_path / "Cargo.lock" nested_lockfile = tmp_path / "tests" / "ui_lints" / "Cargo.lock" - repository = _RecordingLockfileRepository( - tracked=(root_lockfile, nested_lockfile), - default_freshness=lockfile.LockfileFreshness( - is_fresh=False, is_stale=True, detail=_STALE_DETAIL - ), - ) - - with pytest.raises( - publish.PublishPreflightError, - match="Tracked Cargo\\.lock files are stale", - ) as excinfo: - publish_preflight._validate_lockfile_freshness(tmp_path, repository=repository) - message = str(excinfo.value) assert str(root_lockfile) in message assert str(nested_lockfile) in message assert "lading bump" in message @@ -104,25 +152,11 @@ def test_validate_lockfile_freshness_error_snapshot( snapshot: SnapshotAssertion, ) -> None: """Stale lockfile remediation output is locked by snapshot.""" - workspace_root = Path("/workspace root") - root_lockfile = workspace_root / "Cargo.lock" - nested_lockfile = workspace_root / "tests" / "ui_lints" / "Cargo.lock" - repository = _RecordingLockfileRepository( - tracked=(root_lockfile, nested_lockfile), - default_freshness=lockfile.LockfileFreshness( - is_fresh=False, is_stale=True, detail=_STALE_DETAIL - ), - ) + message = _stale_lockfiles_error_message(Path("/workspace root")) - with pytest.raises( - publish.PublishPreflightError, - match="Tracked Cargo\\.lock files are stale", - ) as excinfo: - publish_preflight._validate_lockfile_freshness( - workspace_root, repository=repository - ) - - assert str(excinfo.value) == snapshot() + assert message == snapshot(), ( + "stale lockfile remediation message drifted from its recorded snapshot" + ) def test_validate_lockfile_freshness_surfaces_cargo_failures(tmp_path: Path) -> None: @@ -140,3 +174,155 @@ def test_validate_lockfile_freshness_surfaces_cargo_failures(tmp_path: Path) -> match="failed to download registry index", ): publish_preflight._validate_lockfile_freshness(tmp_path, repository=repository) + + +def test_validate_lockfile_freshness_classifies_every_lockfile( + tmp_path: Path, +) -> None: + """All tracked lockfiles are probed even when the first is already stale. + + Issue #83 evaluated short-circuiting on the first stale lockfile and + rejected it: every tracked lockfile must be classified so the aggregated + error lists each stale path for a single-pass repair. This pins the + full-classification behaviour by asserting every tracked manifest is + probed through the port, not just that the first stale result aborts. + """ + lockfiles = ( + tmp_path / "Cargo.lock", + tmp_path / "a" / "Cargo.lock", + tmp_path / "b" / "Cargo.lock", + ) + repository = _RecordingLockfileRepository( + tracked=lockfiles, + default_freshness=lockfile.LockfileFreshness( + is_fresh=False, is_stale=True, detail=_STALE_DETAIL + ), + ) + + with pytest.raises( + publish.PublishPreflightError, + match="Tracked Cargo\\.lock files are stale", + ) as excinfo: + publish_preflight._validate_lockfile_freshness(tmp_path, repository=repository) + + # No short-circuit: every tracked lockfile is probed despite the first + # being stale (issue #83). + expected_manifests = [path.parent / "Cargo.toml" for path in lockfiles] + assert repository.validated_manifests == expected_manifests, ( + "every tracked lockfile must be probed without short-circuiting; " + f"expected {expected_manifests}, got {repository.validated_manifests}" + ) + message = str(excinfo.value) + for path in lockfiles: + assert str(path) in message, ( + f"stale lockfile {path} missing from aggregated error: {message}" + ) + + +def _assert_no_error_outcomes( + workspace_root: Path, + repository: _RecordingLockfileRepository, + lockfiles: list[Path], + outcomes: list[str], +) -> None: + """Assert every lockfile is probed when no hard error is present. + + A purely fresh set returns cleanly; any stale lockfile still aborts with + the aggregated stale error, but only after every lockfile is probed. The + raised message must list each stale path and omit every fresh path. + """ + expected_manifests = [path.parent / "Cargo.toml" for path in lockfiles] + stale_paths = [ + path + for path, outcome in zip(lockfiles, outcomes, strict=True) + if outcome == "stale" + ] + fresh_paths = [ + path + for path, outcome in zip(lockfiles, outcomes, strict=True) + if outcome == "fresh" + ] + if stale_paths: + with pytest.raises( + publish.PublishPreflightError, + match="Tracked Cargo\\.lock files are stale", + ) as excinfo: + publish_preflight._validate_lockfile_freshness( + workspace_root, repository=repository + ) + # pkg indices stay single-digit (max_size=6), so no pkgN path is a + # substring of another and plain containment is unambiguous. Raising the + # list strategy's max_size (st.lists(_outcome, min_size=1, max_size=6)) + # to >=11 would reintroduce pkg1-vs-pkg10 ambiguity and would need + # full-line assertions instead. + message = str(excinfo.value) + for path in stale_paths: + assert str(path) in message, ( + f"stale lockfile {path} missing from aggregated error: {message}" + ) + for path in fresh_paths: + assert str(path) not in message, ( + f"fresh lockfile {path} wrongly reported as stale: {message}" + ) + else: + publish_preflight._validate_lockfile_freshness( + workspace_root, repository=repository + ) + assert repository.validated_manifests == expected_manifests, ( + "every tracked lockfile must be probed in order without " + f"short-circuiting; expected {expected_manifests}, got " + f"{repository.validated_manifests}" + ) + + +def _assert_error_aborts_classification( + workspace_root: Path, + repository: _RecordingLockfileRepository, + lockfiles: list[Path], + first_error: int, +) -> None: + """Assert classification stops at and includes the first hard error. + + An ``error`` outcome raises ``PublishPreflightError`` immediately, so only + the lockfiles up to and including the first error are probed. + """ + with pytest.raises(publish.PublishPreflightError, match="boom"): + publish_preflight._validate_lockfile_freshness( + workspace_root, repository=repository + ) + expected_prefix = [ + path.parent / "Cargo.toml" for path in lockfiles[: first_error + 1] + ] + assert repository.validated_manifests == expected_prefix, ( + f"classification must stop at the first error (index {first_error}); " + f"expected {expected_prefix}, got {repository.validated_manifests}" + ) + + +@given(outcomes=st.lists(_outcome, min_size=1, max_size=6)) +# tmp_path is used only to build Path objects (never written to), so reusing +# the same function-scoped fixture across Hypothesis examples is safe here. +@settings(max_examples=20, suppress_health_check=[HealthCheck.function_scoped_fixture]) +def test_validate_lockfile_freshness_probes_every_lockfile_until_error( + tmp_path: Path, outcomes: list[str] +) -> None: + """Property: every lockfile is classified in order until an error aborts. + + Varies the count, order, and per-lockfile outcome (fresh/stale/error). + With no hard ``error``, every tracked lockfile is probed in order and no + stale result short-circuits classification (a purely fresh set returns + cleanly; any stale lockfile still aborts with the aggregated stale error, + but only after every lockfile is probed). A hard ``error`` aborts + immediately at the first such lockfile (issue #83). + """ + repository, lockfiles = _repository_for_outcomes(tmp_path, outcomes) + first_error = next( + (i for i, outcome in enumerate(outcomes) if outcome == "error"), None + ) + + if first_error is None: + _assert_no_error_outcomes(tmp_path, repository, lockfiles, outcomes) + else: + _assert_error_aborts_classification( + tmp_path, repository, lockfiles, first_error + ) diff --git a/tests/unit/test_lockfile.py b/tests/unit/test_lockfile.py index 5f5e9f38..a52c0ef7 100644 --- a/tests/unit/test_lockfile.py +++ b/tests/unit/test_lockfile.py @@ -110,6 +110,49 @@ def runner( ) +def test_discover_tracked_lockfiles_ignores_untracked_on_disk( + tmp_path: Path, +) -> None: + """Discovery trusts ``git ls-files`` alone and never globs the filesystem. + + An untracked ``Cargo.lock`` sits on disk beside a valid manifest, so the + only reason it must be excluded is that ``git ls-files`` does not report it. + This pins the simpler, git-driven discovery path after the redundant + ``rglob`` filesystem pass was removed (issue #83): a lockfile absent from + git output is never rediscovered by walking the tree. + """ + tracked = tmp_path / "tracked" + tracked.mkdir() + (tracked / "Cargo.toml").write_text("[package]\n", encoding="utf-8") + (tracked / "Cargo.lock").write_text("", encoding="utf-8") + untracked = tmp_path / "untracked" + untracked.mkdir() + (untracked / "Cargo.toml").write_text("[package]\n", encoding="utf-8") + (untracked / "Cargo.lock").write_text("", encoding="utf-8") + + def runner( + command: cabc.Sequence[str], + *, + cwd: Path | None = None, + env: cabc.Mapping[str, str] | None = None, + ) -> tuple[int, str, str]: + """Report only the git-tracked lockfile, omitting the untracked one.""" + assert command == ("git", "ls-files", "**/Cargo.lock", "Cargo.lock"), ( + f"discovery must invoke git ls-files; got {command!r}" + ) + assert cwd == tmp_path, f"git must run in the workspace root; got {cwd!r}" + return 0, "tracked/Cargo.lock\n", "" + + # Use the real filesystem manifest probe so both manifests genuinely exist; + # the untracked lockfile is excluded solely because git omitted it. + result = lockfile.discover_tracked_lockfiles(tmp_path, runner) + + assert result == (tracked / "Cargo.lock",), ( + "discovery must return only git-tracked lockfiles and must not glob the " + f"filesystem for the untracked one; got {result!r}" + ) + + def test_discover_tracked_lockfiles_accepts_manifest_probe( tmp_path: Path, ) -> None: diff --git a/typos.toml b/typos.toml index 8c5efed1..3fb9200e 100644 --- a/typos.toml +++ b/typos.toml @@ -1699,6 +1699,24 @@ extend-ignore-re = [ "pluralizers" = "pluralizers" "pluralizes" = "pluralizes" "pluralizing" = "pluralizing" +"polymerisable" = "polymerizable" +"polymerisation" = "polymerization" +"polymerisations" = "polymerizations" +"polymerise" = "polymerize" +"polymerised" = "polymerized" +"polymeriser" = "polymerizer" +"polymerisers" = "polymerizers" +"polymerises" = "polymerizes" +"polymerising" = "polymerizing" +"polymerizable" = "polymerizable" +"polymerization" = "polymerization" +"polymerizations" = "polymerizations" +"polymerize" = "polymerize" +"polymerized" = "polymerized" +"polymerizer" = "polymerizer" +"polymerizers" = "polymerizers" +"polymerizes" = "polymerizes" +"polymerizing" = "polymerizing" "popularisable" = "popularizable" "popularisation" = "popularization" "popularisations" = "popularizations"