Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions lading/commands/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@

import collections.abc as cabc
import dataclasses as dc
import logging
import types
import typing as typ

from lading import config as config_module
from lading.commands import bump_docs, bump_toml
from lading.commands.lockfile import discover_tracked_lockfiles, refresh_lockfile
from lading.commands.publish_execution import _CommandRunner, _invoke
from lading.utils import normalise_workspace_root

if typ.TYPE_CHECKING:
Expand All @@ -31,6 +34,8 @@
"build": "build-dependencies",
}

LOGGER = logging.getLogger(__name__)


@dc.dataclass(frozen=True, slots=True)
class BumpOptions:
Expand All @@ -43,6 +48,7 @@ class BumpOptions:
default_factory=lambda: types.MappingProxyType({})
)
include_workspace_sections: bool = False
runner: _CommandRunner | None = None


@dc.dataclass(frozen=True, slots=True)
Expand All @@ -51,6 +57,7 @@ class BumpChanges:

manifests: cabc.Sequence[Path] = ()
documents: cabc.Sequence[Path] = ()
lockfiles: cabc.Sequence[Path] = ()


@dc.dataclass(frozen=True, slots=True)
Expand All @@ -73,6 +80,8 @@ def _build_changes_description(changes: BumpChanges) -> str:
parts.append(f"{len(changes.manifests)} manifest(s)")
if changes.documents:
parts.append(f"{len(changes.documents)} documentation file(s)")
if changes.lockfiles:
parts.append(f"{len(changes.lockfiles)} lockfile(s)")
return parts[0] if len(parts) == 1 else " and ".join(parts)


Expand Down Expand Up @@ -105,7 +114,10 @@ def run(
_process_workspace_manifest(context, target_version, changed_manifests)
_process_crate_manifests(context, target_version, changed_manifests)
changed_documents = _process_documentation_files(context, target_version)
changes = _prepare_sorted_changes(context, changed_manifests, changed_documents)
refreshed_lockfiles = _refresh_lockfiles(context)
changes = _prepare_sorted_changes(
context, changed_manifests, changed_documents, refreshed_lockfiles
)
return _format_result_message(
changes,
target_version,
Expand Down Expand Up @@ -135,6 +147,7 @@ def _initialize_bump_context(
dry_run=resolved_options.dry_run,
configuration=configuration,
workspace=workspace,
runner=resolved_options.runner,
)
excluded = frozenset(configuration.bump.exclude)
updated_crate_names = frozenset(
Expand Down Expand Up @@ -204,6 +217,7 @@ def _prepare_sorted_changes(
context: _BumpContext,
changed_manifests: set[Path],
changed_documents: set[Path],
refreshed_lockfiles: cabc.Sequence[Path] = (),
) -> BumpChanges:
"""Return ordered :class:`BumpChanges` suitable for result rendering."""
ordered_manifests = tuple(
Expand All @@ -215,7 +229,29 @@ def _prepare_sorted_changes(
ordered_documents: tuple[Path, ...] = tuple(
sorted(changed_documents, key=lambda path: str(path))
)
return BumpChanges(manifests=ordered_manifests, documents=ordered_documents)
ordered_lockfiles: tuple[Path, ...] = tuple(
sorted(refreshed_lockfiles, key=lambda path: str(path))
)
return BumpChanges(
manifests=ordered_manifests,
documents=ordered_documents,
lockfiles=ordered_lockfiles,
)


def _refresh_lockfiles(context: _BumpContext) -> tuple[Path, ...]:
"""Refresh tracked lockfiles after manifest rewrites."""
runner = context.base_options.runner or _invoke
lockfiles = discover_tracked_lockfiles(context.root_path, runner)
if context.base_options.dry_run:
for lockfile_path in lockfiles:
LOGGER.info("Dry run; would refresh %s", lockfile_path)
return lockfiles

return tuple(
refresh_lockfile(lockfile_path.parent / "Cargo.toml", runner)
for lockfile_path in lockfiles
)


def _update_crate_manifest(
Expand Down Expand Up @@ -257,7 +293,7 @@ def _format_result_message(
workspace_root: Path,
) -> str:
"""Summarise the bump outcome for CLI presentation."""
if not changes.manifests and not changes.documents:
if not any((changes.manifests, changes.documents, changes.lockfiles)):
return _format_no_changes_message(target_version, dry_run)

description = _build_changes_description(changes)
Expand All @@ -270,6 +306,10 @@ def _format_result_message(
f"- {_format_manifest_path(document_path, workspace_root)} (documentation)"
for document_path in changes.documents
)
formatted_paths.extend(
f"- {_format_manifest_path(lockfile_path, workspace_root)} (lockfile)"
for lockfile_path in changes.lockfiles
)
return "\n".join([header, *formatted_paths])


Expand Down
110 changes: 110 additions & 0 deletions lading/commands/lockfile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Cargo lockfile discovery, refresh, and freshness validation helpers."""

from __future__ import annotations

import logging
import typing as typ
from pathlib import Path

if typ.TYPE_CHECKING:
from lading.commands.publish_execution import _CommandRunner

LOGGER = logging.getLogger(__name__)


class LockfileRefreshError(RuntimeError):
"""Raised when Cargo cannot regenerate a lockfile."""


def _query_git_tracked_lockfiles(
workspace_root: Path,
runner: _CommandRunner,
) -> str | None:
"""Run ``git ls-files`` and return stdout, or ``None`` on failure."""
exit_code, stdout, stderr = runner(
("git", "ls-files", "*/Cargo.lock", "Cargo.lock"),
cwd=workspace_root,
)
if exit_code == 0:
return stdout
detail = (stderr or stdout).strip()
if "not a git repository" in detail.lower():
LOGGER.warning(
"Skipping Cargo.lock discovery because %s is not a git repository",
workspace_root,
)
else:
LOGGER.warning(
"Skipping Cargo.lock discovery after git ls-files failed: %s", detail
)
return None


def _filter_lockfiles_with_manifests(
workspace_root: Path,
stdout: str,
) -> tuple[Path, ...]:
"""Return lockfile paths from ``stdout`` that have an adjacent ``Cargo.toml``."""
lockfiles: list[Path] = []
for line in stdout.splitlines():
relative_path = line.strip()
if not relative_path:
continue
lockfile_path = workspace_root / relative_path
if (lockfile_path.parent / "Cargo.toml").exists():
lockfiles.append(lockfile_path)
return tuple(lockfiles)


def discover_tracked_lockfiles(
workspace_root: Path,
runner: _CommandRunner,
) -> tuple[Path, ...]:
"""Return tracked Cargo.lock files with adjacent manifests."""
has_lockfiles = any(
"target" not in path.relative_to(workspace_root).parts
for path in workspace_root.rglob("Cargo.lock")
)
if not has_lockfiles:
return ()
stdout = _query_git_tracked_lockfiles(workspace_root, runner)
if stdout is None:
return ()
return _filter_lockfiles_with_manifests(workspace_root, stdout)


def refresh_lockfile(
manifest_path: Path,
runner: _CommandRunner,

"""Regenerate the lockfile for ``manifest_path`` and return its path."""
exit_code, stdout, stderr = runner(
("cargo", "generate-lockfile", "--manifest-path", str(manifest_path)),
cwd=manifest_path.parent,
)
if exit_code != 0:
detail = (stderr or stdout).strip()
message = f"Failed to refresh {manifest_path.parent / 'Cargo.lock'}"
if detail:
message = f"{message}: {detail}"
raise LockfileRefreshError(message)
return manifest_path.parent / "Cargo.lock"


def validate_lockfile_freshness(
manifest_path: Path,
runner: _CommandRunner,
) -> bool:
"""Return whether Cargo accepts ``manifest_path`` under ``--locked``."""
exit_code, _stdout, _stderr = runner(
(
"cargo",
"metadata",
"--locked",
"--manifest-path",
str(manifest_path),
"--format-version=1",
),
cwd=manifest_path.parent,
)
return exit_code == 0
12 changes: 6 additions & 6 deletions lading/commands/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
plans publication order, stages the workspace, and dispatches to one of two
**publish pipelines** depending on the ``live`` flag.

**Pipeline dispatch**

*Dry-run* (``live=False``) keeps the historical batched two-phase pipeline:
package every publishable crate, then ``cargo publish --dry-run`` every crate
in plan order.
Expand All @@ -16,8 +14,6 @@
then advance. This ordering lets dependent crates resolve newly uploaded
in-plan dependencies during a single release train.

**Per-crate helpers**

:func:`_package_crate` and :func:`_publish_crate` each invoke ``cargo`` in the
correct staged directory for a single crate. Both detect
index-missing-version failures (:func:`_is_index_missing_version_error`) and
Expand All @@ -33,8 +29,6 @@
failures through one catch boundary or distinguish the publish phase when
needed.

**Related modules**

* :mod:`lading.commands.publish_plan` — plan construction and formatting
* :mod:`lading.commands.publish_preflight` — ``cargo check`` / ``cargo test`` /
git-status guards
Expand Down Expand Up @@ -107,6 +101,7 @@
_normalise_test_excludes = _publish_preflight._normalise_test_excludes
_run_aux_build_commands = _publish_preflight._run_aux_build_commands
_run_cargo_preflight = _publish_preflight._run_cargo_preflight
_validate_lockfile_freshness = _publish_preflight._validate_lockfile_freshness
_verify_clean_working_tree = _publish_preflight._verify_clean_working_tree

LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -753,6 +748,11 @@ def _run_preflight_checks(
runner=command_runner,
env=base_env,
)
_validate_lockfile_freshness(
workspace_root,
runner=command_runner,
env=base_env,
)

with tempfile.TemporaryDirectory(prefix="lading-preflight-target-") as target:
target_path = Path(target)
Expand Down
53 changes: 53 additions & 0 deletions lading/commands/publish_preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
import typing as typ
from pathlib import Path

from lading.commands.lockfile import (
discover_tracked_lockfiles,
validate_lockfile_freshness,
)
from lading.commands.publish_diagnostics import _append_compiletest_diagnostics
from lading.commands.publish_errors import PublishPreflightError
from lading.commands.publish_execution import _CommandRunner, _invoke
Expand Down Expand Up @@ -133,6 +137,11 @@ def _run_preflight_checks(
runner=command_runner,
env=base_env,
)
_validate_lockfile_freshness(
workspace_root,
runner=command_runner,
env=base_env,
)

with tempfile.TemporaryDirectory(prefix="lading-preflight-target-") as target:
target_path = Path(target)
Expand Down Expand Up @@ -179,6 +188,50 @@ def _compose_preflight_arguments(
return tuple(arguments)


def _validate_lockfile_freshness(
workspace_root: Path,
*,
runner: _CommandRunner,
env: cabc.Mapping[str, str] | None = None,
) -> None:
"""Fail early when tracked Cargo.lock files are stale."""
base_env = env

def runner_with_env(
command: cabc.Sequence[str],
*,
cwd: Path | None = None,
env: cabc.Mapping[str, str] | None = None,
) -> tuple[int, str, str]:
effective_env = base_env if env is None else env
return runner(command, cwd=cwd, env=effective_env)

stale_lockfiles: list[Path] = []
for lockfile_path in discover_tracked_lockfiles(workspace_root, runner_with_env):
manifest_path = lockfile_path.parent / "Cargo.toml"
if not validate_lockfile_freshness(manifest_path, runner_with_env):
stale_lockfiles.append(lockfile_path)

if not stale_lockfiles:
return

lines = [
"Tracked Cargo.lock files are stale after manifest version changes.",
(
"This commonly happens after running `lading bump`; re-run it to "
"refresh tracked lockfiles, or repair each stale lockfile manually:"
),
]
for lockfile_path in stale_lockfiles:
manifest_path = lockfile_path.parent / "Cargo.toml"
lines.append(f"- {lockfile_path}")
lines.append(f" cargo generate-lockfile --manifest-path {manifest_path}")

message = "\n".join(lines)
LOGGER.error(message)
raise PublishPreflightError(message)


def _preflight_argument_sets(
target_dir: Path, *, unit_tests_only: bool
) -> tuple[tuple[str, ...], tuple[str, ...]]:
Expand Down
Loading
Loading