Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
42e0150
Refresh stale Cargo lockfiles during bump (#61)
leynos Jun 1, 2026
b8c4f76
Extract Cargo lockfile discovery helpers (#61)
leynos Jun 6, 2026
4df983b
Flatten Cargo lockfile discovery flow (#61)
leynos Jun 6, 2026
9bfdbb8
Format lockfile unit helper signature (#61)
leynos Jun 6, 2026
56fa799
Filter git-discovered target lockfiles (#61)
leynos Jun 7, 2026
c913204
Address lockfile review findings (#61)
leynos Jun 7, 2026
76802a9
Add package base exception (#61)
leynos Jun 7, 2026
bac4059
Document lockfile refresh workflow (#61)
leynos Jun 7, 2026
fc3ec4f
Tighten lockfile follow-up handling (#61)
leynos Jun 7, 2026
a59fe95
Address lockfile review follow-ups (#61)
leynos Jun 7, 2026
d0da65a
Fix lockfile preflight review items (#61)
leynos Jun 7, 2026
0b50f42
Clarify lockfile refresh recovery (#61)
leynos Jun 7, 2026
c7a4dde
Make bump output dry-run explicit (#61)
leynos Jun 7, 2026
5f74e99
Cover bump CLI runner default (#61)
leynos Jun 8, 2026
2f9712d
Fix nested lockfile discovery (#61)
leynos Jun 8, 2026
f35aff9
Address lockfile review checks (#61)
leynos Jun 8, 2026
ad565e9
Document LadingError reuse guidance (#61)
leynos Jun 8, 2026
4466c9c
Split publish preflight tests (#61)
leynos Jun 8, 2026
411d633
Parametrize cargo preflight argument tests (#61)
leynos Jun 8, 2026
62558d6
Document exception and bump internals (#61)
leynos Jun 8, 2026
100bd3d
Document lockfile test runner stub (#61)
leynos Jun 8, 2026
328c248
Extract shared command runner boundary (#61)
leynos Jun 8, 2026
03efe7c
Add lockfile filtering property tests (#61)
leynos Jun 8, 2026
527c7a8
Resolve runner wiring after rebase (#61)
leynos Jun 8, 2026
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
110 changes: 104 additions & 6 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,33 @@ publish runs with stub mode enabled.

## Workspace discovery helpers

### Lockfile helpers (`lading/commands/lockfile.py`)

`discover_tracked_lockfiles(workspace_root, runner)` filters git-tracked
`Cargo.lock` files outside `target/` with adjacent `Cargo.toml` manifests.
Private helpers `_handle_git_ls_files_failure` and
`_lockfiles_with_manifests` perform the error-handling and path-filtering
passes respectively.

`refresh_lockfile(manifest_path, runner)` runs
`cargo generate-lockfile --manifest-path` for the supplied manifest and raises
`LockfileRefreshError` on non-zero exit. `_refresh_lockfiles` in `bump.py`
calls it after manifest rewrites. The refresh loop is intentionally not
transactional: if a later lockfile refresh fails, previously rewritten
manifests and refreshed lockfiles remain on disk, and the operator should fix
the Cargo error, then run
`cargo generate-lockfile --manifest-path <path>/Cargo.toml` for each affected
crate manifest. Rerunning `lading bump` will not refresh lockfiles once the
manifests are already rewritten.

`validate_lockfile_freshness(manifest_path, runner)` runs
`cargo metadata --locked --manifest-path ... --format-version=1`. It returns
`True` on success and `False` otherwise. `_validate_lockfile_freshness` in
`publish_preflight.py` calls it before the cargo check/test pre-flight.

`LockfileRefreshError` inherits `LadingError`; its message includes Cargo
stderr.

### `load_cargo_metadata`

Import `lading.workspace.load_cargo_metadata` to execute `cargo metadata` with
Expand Down Expand Up @@ -153,6 +180,21 @@ Examples:
- `PublishOptions(allow_dirty=False)` — require a clean git working tree before
proceeding with publish preparation.

## Bump command internals

`BumpOptions` carries the dependency-injection points used by
`lading.commands.bump.run`. The `runner` field accepts an optional
`_CommandRunner`, matching the command-runner protocol used by publish
execution. When `runner` is `None`, bump falls back to the default subprocess
runner. Tests pass a runner explicitly so lockfile refresh commands can be
observed without invoking real Cargo processes.

`BumpChanges` records the user-visible files touched by a bump run. Its
`lockfiles` field contains the git-tracked `Cargo.lock` files refreshed after
manifest rewrites. The output formatter treats these paths like manifests and
documentation files, listing each refreshed lockfile with a `(lockfile)` suffix
so operators can see which generated files need review and commit.

## Publish command internals

`PublishOptions.allow_unpublished_workspace_deps` is a dry-run-only override
Expand All @@ -162,11 +204,55 @@ yet. When enabled, `lading publish` downgrades that specific index-lookup
failure to a warning and continues. The option is rejected at runtime when
`live=True`, so it cannot mask a real upload failure.

### Exception hierarchy (`publish_errors`)

`lading.commands.publish_errors` defines the public error boundary for
publish orchestration. Both classes inherit from `RuntimeError` and carry
their message through the standard `args` tuple.
### Exception hierarchy (`lading.exceptions`)

`lading.exceptions.LadingError` is the package-level base class for domain
failures raised by lading itself. It extends `Exception` directly and gives
callers one stable type to catch when they want to handle expected lading
failures without also catching unrelated runtime errors from Python, Cargo, git
wrappers, or test doubles.

Every root domain exception should inherit from `LadingError`. More specific
exceptions should continue to inherit from the local root for their feature
area so existing handling remains precise:

| Root exception | Module | Notes |
| --- | --- | --- |
| `ConfigurationError` | `lading.config` | Base for configuration loading and validation failures. |
| `WorkspaceModelError` | `lading.workspace.models` | Base for workspace graph/model validation failures. |
| `CargoMetadataError` | `lading.workspace.metadata` | Base for cargo metadata execution and parsing failures. |
| `LockfileRefreshError` | `lading.commands.lockfile` | Raised when `cargo generate-lockfile` fails. |
| `PublishPlanError` | `lading.commands.publish_plan` | Raised when a publish plan cannot be constructed. |
| `PublishPreparationError` | `lading.commands.publish_manifest` | Raised when staged publish manifests or workspace assets cannot be prepared. |
| `PublishPreflightError` | `lading.commands.publish_errors` | Raised for local publish validation and pre-flight failures. |

Reuse plan:

- New command, workspace, and configuration modules should define exactly one
local root exception that subclasses `LadingError` when they introduce a new
failure family.
- Subclasses should inherit from that local root, not from `LadingError`
directly, unless they are themselves the root of a new family.
- Do not inherit lading domain exceptions from `RuntimeError`; reserve
`RuntimeError` for unexpected programming errors or third-party APIs that
already expose it.
- Keep messages useful at the boundary where they are raised. Include the
relevant path, crate name, command, or configuration key so CLI handlers can
report the exception without reconstructing context.

Usage guidance:

- CLI and integration boundaries may catch `LadingError` to render expected
lading failures as user-facing diagnostics.
- Feature code should catch the narrowest local exception it can handle, such
as `PublishPreflightError` or `LockfileRefreshError`, and let unrelated
`LadingError` subclasses propagate.
- Tests should assert the specific exception type for the behaviour under test,
then use `LadingError` only when verifying common boundary handling.

`lading.commands.publish_errors` defines the public error boundary for publish
orchestration. Both publish exceptions inherit from the package-level
`LadingError` base and carry their message through the standard `args` tuple.

| Exception | Raised when |
| --- | --- |
Expand Down Expand Up @@ -310,6 +396,18 @@ includes all four values. Using a single function for message construction keeps
the error format consistent across the packaging and publish phases and makes
snapshot testing straightforward.

### Command runners (`lading.runtime`)

`lading.runtime` owns the shared `CommandRunner` protocol and the production
`subprocess_runner` adapter. Command modules type against this protocol so tests
can inject cmd-mox or recording runners without depending on publish-specific
infrastructure.

`lading.commands.publish_execution` still owns publish-specific error mapping
around command execution. `lading bump` uses the runtime runner directly for
lockfile refreshes, while `lading publish` uses `_invoke` where failures should
surface as `PublishPreflightError`.

### Pre-flight validation (`publish_preflight`)

`lading.commands.publish_preflight` performs workspace validation before
Expand All @@ -321,7 +419,7 @@ _run_preflight_checks(
*,
allow_dirty: bool,
configuration: LadingConfig,
runner: _CommandRunner | None = None,
runner: CommandRunner | None = None,
) -> None
```

Expand Down
7 changes: 6 additions & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ workspace and member crate manifests.
- [x] **Implement `--dry-run` Mode for Bumping:**

- **Outcome:** The `--dry-run` flag prevents any file modifications and
instead prints a summary of intended changes.
instead prints a summary of intended changes. Tracked `Cargo.lock` files
are refreshed after manifest rewrites in live mode; in dry-run mode they
are reported but not modified (closes `#61`).
- **Completion Criteria:** Running `lading bump 1.2.3 --dry-run` produces a
report of all files that would be changed, and a subsequent check confirms
no files were actually modified.
Expand Down Expand Up @@ -177,6 +179,9 @@ occur.
- **Completion Criteria:** A test confirms that the publish command fails if
either of the pre-flight checks fails. The `--forbid-dirty` flag restores
the cleanliness guard when operators need to enforce it.
- Tracked `Cargo.lock` files are validated for freshness under `--locked`
before the `cargo check`/`cargo test` pre-flight; stale lockfiles surface
an actionable `cargo generate-lockfile` repair command (closes `#61`).

______________________________________________________________________

Expand Down
20 changes: 20 additions & 0 deletions docs/users-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ To preview changes without writing any files:
lading bump 1.2.3 --dry-run
```

After updating manifest versions, `lading bump` automatically refreshes any
git-tracked `Cargo.lock` files (excluding those under `target/`) that have an
adjacent `Cargo.toml`. Refreshed lockfiles are listed in the command output
with a `(lockfile)` suffix. In dry-run mode the lockfiles are listed but not
modified.

If `bump.documentation.globs` is configured, `lading` also searches those
Markdown files for TOML code fences and updates version values that refer to
workspace crates.
Expand All @@ -86,6 +92,20 @@ be validated without uploading crates.
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:

```text
Tracked Cargo.lock files are stale after manifest version changes.
Run the following to repair:
cargo generate-lockfile --manifest-path <path>/Cargo.toml
```

Run the repair command, commit the updated lockfile, then re-run
`lading publish`.

To require a clean working tree before running the pre-flight checks, pass
`--forbid-dirty`:

Expand Down
3 changes: 2 additions & 1 deletion lading/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
from __future__ import annotations

from .cli import app, main
from .exceptions import LadingError

__all__ = ["app", "main"]
__all__ = ["LadingError", "app", "main"]
1 change: 1 addition & 0 deletions lading/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ def bump(
dry_run=dry_run,
configuration=configuration,
workspace=workspace,
runner=command_runner,
),
),
)
Expand Down
66 changes: 59 additions & 7 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.runtime import CommandRunner, subprocess_runner
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,10 +80,12 @@ 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)


def _format_no_changes_message(target_version: str, dry_run: bool) -> str: # noqa: FBT001
def _format_no_changes_message(target_version: str, *, dry_run: bool) -> str:
"""Format message when no changes are required."""
if dry_run:
return (
Expand All @@ -86,7 +95,7 @@ def _format_no_changes_message(target_version: str, dry_run: bool) -> str: # no
return f"No manifest changes required; all versions already {target_version}."


def _format_header(description: str, target_version: str, dry_run: bool) -> str: # noqa: FBT001
def _format_header(description: str, target_version: str, *, dry_run: bool) -> str:
"""Format the summary header line."""
if dry_run:
return f"Dry run; would update version to {target_version} in {description}:"
Expand All @@ -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) if changed_manifests else ()
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,41 @@ 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.

Refresh is intentionally not transactional. If one lockfile refresh fails,
manifests already rewritten to the target version remain on disk; after
fixing the Cargo error, rerunning ``lading bump`` will not refresh
lockfiles because refresh only runs when manifests are rewritten. Run
``cargo generate-lockfile --manifest-path <path>/Cargo.toml``
for each stale lockfile.
"""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
runner = context.base_options.runner or subprocess_runner
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

for lockfile_path in lockfiles:
refresh_lockfile(lockfile_path.parent / "Cargo.toml", runner)
LOGGER.info(
"Refreshed %d tracked lockfile(s) in %s",
len(lockfiles),
context.root_path,
)
return lockfiles


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

description = _build_changes_description(changes)
header = _format_header(description, target_version, dry_run)
header = _format_header(description, target_version, dry_run=dry_run)
formatted_paths = [
f"- {_format_manifest_path(manifest_path, workspace_root)}"
for manifest_path in changes.manifests
Expand All @@ -270,6 +318,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
Loading
Loading