From 47d911792d984f80996869b5bf061272d2e086d9 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 8 Jul 2026 19:50:48 +0200 Subject: [PATCH 01/26] Add ExecPlan for ref-pinned suite installation (#271) Plan the addition of a `--ref` flag to `whitaker-installer`, allowing the lint suite to be installed at a specific commit SHA or tag while keeping the rolling release as the default. The plan covers CLI plumbing, git helpers for detached checkout and default-branch recovery, prebuilt SHA matching, dry-run output, tests, and documentation. Pre-implementation; no code changes yet. --- .../issue-271-ref-pinned-installation.md | 469 ++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 docs/execplans/issue-271-ref-pinned-installation.md diff --git a/docs/execplans/issue-271-ref-pinned-installation.md b/docs/execplans/issue-271-ref-pinned-installation.md new file mode 100644 index 00000000..ec5c8485 --- /dev/null +++ b/docs/execplans/issue-271-ref-pinned-installation.md @@ -0,0 +1,469 @@ +# Add a `--ref` flag to whitaker-installer for pinned suite installation + +This ExecPlan (execution plan) is a living document. The sections +`Constraints`, `Tolerances`, `Risks`, `Progress`, `Surprises & Discoveries`, +`Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work +proceeds. + +Status: DRAFT + +Issue: [leynos/whitaker#271](https://github.com/leynos/whitaker/issues/271) + +## Purpose / big picture + +`whitaker-installer` currently installs the lint suite from a mutable source: +the prebuilt path downloads from the `rolling` GitHub release tag, and the +source-build fallback clones or pulls the default branch of +`leynos/whitaker`. Consuming repositories pin the *installer* version in CI +(for example `cargo binstall whitaker-installer@0.2.5`) but cannot pin the +*suite*, so a push to `main` can change lint behaviour across the whole +estate at once. + +After this change, a user can run: + +```bash +whitaker-installer --ref v0.2.5 # a tag +whitaker-installer --ref 1a2b3c4d… # a commit SHA +``` + +and the installer will build and stage the lint suite from exactly that +commit of `leynos/whitaker`. Running `whitaker-installer` with no `--ref` +behaves exactly as today (rolling prebuilt artefacts, default-branch source +fallback). Rolling remains the intentional default; the maintainer does not +want to cut a new installer release for every suite update. This plan +deliberately implements only the explicit-pin part of issue #271, not the +version-matched default proposed there. + +Observable success: `whitaker-installer --ref --build-only` on a clean +machine stages libraries built from the tagged commit (verifiable because the +staged filenames embed the toolchain channel recorded in that commit's +`rust-toolchain.toml`), `whitaker-installer --dry-run --ref ` reports the +ref, and a subsequent un-pinned `whitaker-installer` run still works (the +clone recovers from the detached checkout). + +## Constraints + +- Default behaviour with no `--ref` must be byte-for-byte unchanged: rolling + prebuilt download first, default-branch clone/pull fallback. +- The public crate API additions must be additive; no existing public + function signatures in `whitaker_installer` may change in a way that breaks + the behaviour tests' existing imports, except where the plan names the + change explicitly (see `Interfaces and dependencies`). +- The installer must never mutate a user's own working tree: if the current + directory is itself a Whitaker workspace, `--ref` must fail with a clear + error rather than checking anything out. +- No new external dependencies. Git operations continue to go through + `installer/src/git.rs` with the existing 5-minute timeout discipline. +- All work follows the repository gates: `make check-fmt`, `make lint`, + `make test`, `make markdownlint` must pass before each commit. +- Commit messages follow the file-based workflow (`git commit -F`), no AI + attribution trailers, en-GB-oxendict prose. +- This shell exports a stray `WHITAKER=true`; run every make gate with + `env -u WHITAKER` to avoid silently skipping the whitaker lint target. + +## Tolerances (exception triggers) + +- Scope: if implementation (excluding tests and docs) exceeds ~400 net lines + or touches more than 12 source files, stop and escalate. +- Interface: if pinning turns out to require changing the signature of a + public function other than those listed in `Interfaces and dependencies`, + stop and escalate. +- Dependencies: if a new crate dependency appears necessary, stop and + escalate. +- Iterations: if a gate still fails after 3 fix attempts on the same failure, + stop and escalate. +- Ambiguity: if `--ref` semantics interact with an existing flag in a way not + settled by the Decision Log (for example a new conflict with + `--no-update`), stop and present options. + +## Risks + +- Risk: a detached-HEAD checkout left by `--ref` breaks the next un-pinned + install, because `ensure_workspace` runs plain `git pull`, which fails on a + detached HEAD ("You are not currently on a branch"). + Severity: high. Likelihood: certain without mitigation. + Mitigation: the update path must reattach the clone to the default branch + before pulling (Stage C step 3). This is a required behaviour, tested. +- Risk: the prebuilt manifest's `git_sha` format (full versus abbreviated + SHA) is not yet confirmed, so SHA comparison against a resolved ref could + mis-match. + Severity: medium. Likelihood: medium. + Mitigation: Stage A confirms the format from + `installer/src/artefact/git_sha.rs` and the release workflow; comparison + uses prefix-tolerant matching only if the manifest stores an abbreviated + SHA, otherwise exact equality. +- Risk: tags in the whitaker repository may not exist for every released + installer version, making `--ref v0.2.5` fail for users. + Severity: low (documentation issue, not a code defect). + Mitigation: document that `--ref` accepts any commit-ish that exists in the + repository; error messages surface the git failure verbatim. +- Risk: behaviour tests that construct `InstallArgs` literally will fail to + compile when a field is added. + Severity: low. Likelihood: high. + Mitigation: most construction sites use `..InstallArgs::default()`; sweep + with `env -u WHITAKER cargo check --workspace --all-targets` early in + Stage C. + +## Progress + +- [x] (2026-07-08 12:20Z) Worktree created at + `~/Projects/whitaker.worktrees/issue-271-ref-pinned-installation`, branch + `issue-271-ref-pinned-installation` from `origin/main` (b1c69c2). +- [x] (2026-07-08 12:40Z) Read the installer source: `cli.rs`, `main.rs`, + `install_flow.rs`, `workspace.rs`, `git.rs`, `prebuilt.rs`, + `artefact/download.rs`; mapped the install flow and identified the + detached-HEAD recovery requirement. +- [x] (2026-07-08 12:50Z) Drafted this ExecPlan. +- [ ] Await user approval of the plan (approval gate). +- [ ] Stage A: confirm manifest `git_sha` format and default-branch discovery + command; record findings here. +- [ ] Stage B: red tests (CLI parsing, git ref operations, workspace + decision, prebuilt SHA validation, BDD scenarios). +- [ ] Stage C: implementation (CLI field, git helpers, workspace plumbing, + prebuilt validation, dry-run output). +- [ ] Stage D: docs (users-guide, README, `--help` text), refactor, full + gates, commit-by-commit delivery. +- [ ] Manual end-to-end validation transcript recorded under `Artifacts`. + +## Surprises & discoveries + +- Observation: none yet (pre-implementation). + +## Decision log + +- Decision: keep `rolling` as the default; `--ref` is an explicit opt-in pin. + Rationale: maintainer direction — a version-matched default would force an + installer release for every suite update. This supersedes the "default to + the installer's own version tag" proposal in issue #271. + Date/Author: 2026-07-08, user (pmcintosh). +- Decision: name the flag `--ref` (CLI field `git_ref`, since `ref` is a Rust + keyword), accepting any commit-ish (SHA, tag, or branch name), documented + with SHA and tag as the supported use cases. + Rationale: user asked for "--ref … allowing a SHA or tag"; git resolves all + commit-ish forms identically, so restricting the value would add validation + code without adding safety. Branch names resolve at install time and are + therefore not reproducible pins; the documentation says so. + Date/Author: 2026-07-08, agent. +- Decision: `--ref` composes with `--no-update` rather than conflicting. + With both flags, the installer resolves the ref against the existing clone + without fetching; it fetches only when the ref cannot be resolved locally. + Rationale: "use my existing clone, offline, at this ref" is a coherent and + useful request; fetch-on-miss keeps the common pinned-CI path working. + Date/Author: 2026-07-08, agent. +- Decision: with `--ref`, the prebuilt fast path is attempted only when the + resolved commit SHA matches the rolling manifest's `git_sha`; any mismatch + falls back to a source build of the pinned commit. Without `--ref`, + prebuilt behaviour is unchanged. + Rationale: the rolling release only ever carries artefacts for one commit, + so a pinned install can use it only when the pin happens to match; the + existing `PrebuiltResult::Fallback` mechanism makes the mismatch case free. + Date/Author: 2026-07-08, agent. +- Decision: `--ref` while the current directory is itself a Whitaker + workspace is an error (`InstallerError::WorkspaceNotFound` is wrong here; a + new `InstallerError::RefUnsupported`-style variant carries the message). + Rationale: checking out a ref in the user's own working tree could destroy + uncommitted work; refusing is the only safe behaviour. + Date/Author: 2026-07-08, agent. + +## Outcomes & retrospective + +To be completed at milestones and at the end. + +## Context and orientation + +The repository is a Cargo workspace (`Cargo.toml` members: `common`, +`crates/*`, `installer`, `suite`). The installer lives in `installer/` and +publishes to crates.io as `whitaker-installer`. Key modules, all paths +relative to the repository root: + +- `installer/src/cli.rs` — clap definitions. `InstallArgs` is the flag + struct; it has a hand-written `Default` impl and is flattened into `Cli` + for the default (no-subcommand) install. Tests in + `installer/src/cli_tests.rs`. +- `installer/src/main.rs` — orchestration. `run_install` performs: (1) + dependency check, (2) `ensure_whitaker_workspace` (clone or update the + platform clone at `~/.local/share/whitaker` on Linux), (3) crate and + toolchain resolution (reads `rust-toolchain.toml` from the workspace root — + note this happens *after* the workspace step, so a checked-out ref + naturally supplies its own toolchain pin), (3.5) + `try_fast_path_installation` (prebuilt download, then a test-only staged + suite path), (4) source build via `pipeline::perform_build`, (5) wrapper + scripts and metrics. +- `installer/src/workspace.rs` — `WHITAKER_REPO_URL`, `WorkspaceAction` + (`UseCurrentDir` / `CloneTo` / `UpdateAt` / `UseExisting`), + `decide_workspace_action(cwd, clone_dir, update)`, and + `ensure_workspace(dirs, update)` which executes the action. +- `installer/src/git.rs` — `clone_repository`, `update_repository`, and the + private `run_git_with_timeout(args, working_dir, operation)` helper (5-min + timeout, threaded pipe draining). All new git operations reuse this helper. +- `installer/src/prebuilt.rs` — `PrebuiltConfig` (target, toolchain, + destination, quiet), `attempt_prebuilt`, and the private `run_pipeline` + that downloads the manifest, validates toolchain and target, downloads and + verifies the archive, and extracts. Validation failures become + `PrebuiltResult::Fallback` — never fatal. +- `installer/src/artefact/download.rs` — `ROLLING_TAG = "rolling"` and URL + construction; `installer/src/artefact/manifest.rs` and + `artefact/git_sha.rs` — the manifest carries a `git_sha()` accessor whose + exact width Stage A confirms. +- `installer/src/install_flow.rs` — `PrebuiltInstallationContext` and + `try_prebuilt_installation`, the seam through which `main.rs` passes data + into `prebuilt.rs`. +- `installer/src/output.rs` — `DryRunInfo` renders `--dry-run` output. +- Behaviour tests: `installer/tests/behaviour_*.rs` with Gherkin features in + `installer/tests/features/*.feature`, driven by `rstest-bdd`. CLI-facing + scenarios live in `installer.feature`; prebuilt scenarios in + `prebuilt_download.feature`. +- Docs: `docs/users-guide.md` (user-facing flags), `README.md`. + +Terms: a *commit-ish* is any expression git can resolve to a commit (SHA, +tag, branch, `HEAD~2`, …). A *detached HEAD* is a checkout of a commit rather +than a branch; `git pull` refuses to run on one. The *platform clone* is the +installer-managed copy of this repository under the user's data directory. + +## Plan of work + +Stage A — confirm two facts, no code changes. First, read +`installer/src/artefact/git_sha.rs`, `artefact/manifest.rs`, and the release +workflow under `.github/workflows/` to establish whether the manifest +`git_sha` is a full 40-hex SHA or abbreviated; record the answer in +`Surprises & Discoveries` and fix the comparison rule (exact match for full, +`starts_with` for abbreviated). Second, decide the default-branch discovery +command for detached-HEAD recovery: prefer +`git rev-parse --abbrev-ref origin/HEAD` (strips to `origin/main`), falling +back to `git remote set-head origin --auto` once if the symbolic ref is +absent from an old clone. + +Stage B — red tests. Add failing tests before each implementation slice: + +1. `installer/src/cli_tests.rs`: parsing `--ref v0.2.5` populates + `InstallArgs::git_ref`; absence leaves it `None`; `--ref` is accepted both + bare and under the `install` subcommand. +2. `installer/src/git.rs` tests (new, using real `git` against `TempDir` + fixtures — create a source repository with two commits and a tag, clone + it, then exercise the new functions): `resolve_commit` resolves a tag and + a SHA and errors on garbage; `checkout_detached` leaves HEAD at the + commit; `ensure_default_branch` reattaches a detached clone so that a + subsequent `update_repository` succeeds. +3. `installer/src/workspace.rs` tests: `decide_workspace_action` outcomes + are unchanged; a new decision-level test that a ref plus + `UseCurrentDir` yields the refusal error. +4. `installer/src/prebuilt_tests.rs`: with `expected_git_sha: Some(...)` + mismatching the mocked manifest, the pipeline returns + `PrebuiltResult::Fallback` whose reason mentions the SHA mismatch; with a + matching SHA it succeeds as before; with `None` behaviour is unchanged. +5. BDD: extend `installer/tests/features/installer.feature` (and + `behaviour_cli` steps) with scenarios: "Pin the suite to a tag" (parsing + plus dry-run output contains the ref) and "Refuse --ref inside a Whitaker + workspace". Extend `prebuilt_download.feature` with "Prebuilt is skipped + when the pinned ref does not match the rolling manifest". + +Each red test is run first and must fail for the expected reason (missing +field / missing function / missing validation) before its green slice lands. + +Stage C — implementation, in five small commits: + +1. CLI: add `git_ref: Option` to `InstallArgs` in + `installer/src/cli.rs` as `#[arg(long = "ref", value_name = "REF")]`, doc + comment "Install the lint suite at a specific commit SHA or tag [default: + rolling]". Update `Default`, the `after_help` examples, and any literal + constructors that fail to compile. +2. Git helpers in `installer/src/git.rs`: `resolve_commit(repo, refspec) -> + Result` (`git rev-parse --verify ^{commit}`), + `fetch_ref(repo, refspec)` (`git fetch origin --tags`), + `checkout_detached(repo, commit)` (`git checkout --detach `), and + `ensure_default_branch(repo)` (no-op when already on a branch; otherwise + discover the default branch per Stage A and check it out). All through + `run_git_with_timeout`. +3. Workspace plumbing: extend `ensure_workspace` to + `ensure_workspace(dirs, update, git_ref: Option<&str>)` (public signature + change, named in `Interfaces and dependencies`). Behaviour: on + `UseCurrentDir` with a ref → the refusal error; on `CloneTo` → clone then + pin; on `UpdateAt` → `ensure_default_branch` then pull then pin; on + `UseExisting` with a ref → pin without pulling. "Pin" means: try + `resolve_commit`; on failure `fetch_ref` once and retry; then + `checkout_detached`. Crucially, `UpdateAt` with *no* ref also calls + `ensure_default_branch` first, fixing the recovery risk. Return both the + workspace path and the resolved commit SHA (a small + `WorkspaceCheckout { root: Utf8PathBuf, pinned_commit: Option }` + struct) so `main.rs` can hand the SHA to the prebuilt path. +4. Prebuilt: add `expected_git_sha: Option<&'a str>` to `PrebuiltConfig`, + validate in `run_pipeline` after the target check using the Stage A + comparison rule, and thread the value from `run_install` through + `PrebuiltInstallationContext` and `try_prebuilt_installation`. Update the + fixed-signature `AttemptPrebuiltFn` type alias and hooks in + `install_flow.rs` as needed. +5. Dry run and messaging: add the ref to `DryRunInfo` and its + `display_text`; in `ensure_whitaker_workspace`'s progress messages, print + `Pinning Whitaker suite to REF (SHORT_SHA)...` when a ref is given. + +Stage D — documentation and closure: document `--ref` in +`docs/users-guide.md` (a "Pinning the suite" subsection: what it accepts, +that branch pins are not reproducible, prebuilt-match behaviour, interplay +with `--no-update`) and in `README.md`; refresh the `--help` examples; run +the full gate suite via the scrutineer subagent; update this plan's living +sections; comment on issue #271 describing what shipped and what was +deliberately not changed (the rolling default). + +## Concrete steps + +All commands run from +`~/Projects/whitaker.worktrees/issue-271-ref-pinned-installation`. + +Red example (Stage B slice 1): + +```bash +env -u WHITAKER cargo nextest run -p whitaker-installer cli_tests 2>&1 \ + | tee /tmp/test-whitaker-issue-271.out +# expect: compile error E0609 (no field `git_ref`) or a failing +# `parses_ref_flag` assertion — the red reason must be the missing field. +``` + +Green example (after Stage C slice 1): + +```bash +env -u WHITAKER cargo nextest run -p whitaker-installer cli_tests 2>&1 \ + | tee /tmp/test-whitaker-issue-271.out +# expect: all cli tests pass. +``` + +Full gates before each commit (delegate to the scrutineer subagent, which +logs to /tmp and returns a bounded report): + +```bash +env -u WHITAKER make check-fmt +env -u WHITAKER make lint +env -u WHITAKER make test +env -u WHITAKER make markdownlint +``` + +Manual end-to-end check (Stage D, uses an older tag known to exist): + +```bash +git tag --list 'v*' | tail -3 # pick a real tag, e.g. v0.2.4 +cargo run -p whitaker-installer -- --dry-run --ref v0.2.4 +# expect: dry-run output includes the ref and the workspace path +cargo run -p whitaker-installer -- --ref v0.2.4 --build-only \ + --target-dir /tmp/whitaker-ref-smoke +# expect: staged libraries under /tmp/whitaker-ref-smoke named for the +# toolchain channel pinned by v0.2.4's rust-toolchain.toml +cargo run -p whitaker-installer -- --dry-run +# expect: default behaviour unchanged; then verify the platform clone +# recovers: run a default install and confirm no detached-HEAD pull error +``` + +## Validation and acceptance + +Acceptance is behavioural: + +1. `whitaker-installer --ref --build-only` stages libraries built from + the tagged commit; `whitaker-installer --dry-run --ref ` prints the + ref. +2. `whitaker-installer --ref ` exits non-zero with a git error + naming the ref; nothing is staged. +3. `whitaker-installer --ref ` run inside a Whitaker workspace + checkout exits non-zero with the refusal message and does not touch the + working tree. +4. After a pinned install, a plain `whitaker-installer` run succeeds (the + clone reattaches to the default branch and pulls). +5. With `--ref` set and the rolling manifest's `git_sha` differing from the + resolved commit, the installer prints the fallback message and builds + from source; when they match, the prebuilt path is used. +6. `make test` passes with the new unit and behaviour tests; each new test + demonstrably failed first (Red evidence retained in the tee'd logs under + `/tmp/test-whitaker-issue-271.out` and summarized in `Artifacts`). +7. `make check-fmt`, `make lint` (clippy plus the dylint suite), and + `make markdownlint` pass. + +BDD specification driving slice 5 of Stage B (final wording may be adjusted +to the step vocabulary in `installer/tests/behaviour_cli/`): + +```gherkin +Scenario: Pin the suite to a tag + Given the --ref flag is set to "v0.2.5" + When the CLI arguments are parsed + Then the install arguments carry the ref "v0.2.5" + +Scenario: Refuse --ref inside a Whitaker workspace + Given the current directory is a Whitaker workspace + And the --ref flag is set to "v0.2.5" + When the workspace is prepared + Then preparation fails with a ref-unsupported error + +Scenario: Prebuilt is skipped when the pinned ref does not match + Given a manifest whose git SHA differs from the pinned commit + When a prebuilt installation is attempted + Then the result is a fallback mentioning the SHA mismatch +``` + +## Idempotence and recovery + +All steps are re-runnable. The worktree is disposable; `git worktree remove` +plus branch deletion resets everything. Test git repositories live in +`TempDir`s and clean themselves up. The manual smoke test writes only to +`/tmp/whitaker-ref-smoke` (delete freely) and the platform clone at +`~/.local/share/whitaker`, which the recovery behaviour (acceptance item 4) +restores to the default branch; if a mid-implementation failure leaves it +detached, `git -C ~/.local/share/whitaker checkout main` restores it by +hand. If a gate fails mid-commit, fix and re-run the gate; never commit over +a red gate. + +## Artifacts and notes + +Red/Green transcripts and the manual smoke-test transcript will be added +here as the work proceeds. + +## Interfaces and dependencies + +No new crate dependencies. At completion the following must exist: + +In `installer/src/cli.rs`: + +```rust +pub struct InstallArgs { + // …existing fields… + /// Install the lint suite at a specific commit SHA or tag. + #[arg(long = "ref", value_name = "REF")] + pub git_ref: Option, +} +``` + +In `installer/src/git.rs` (all `pub`, all routed through +`run_git_with_timeout`): + +```rust +pub fn resolve_commit(repo: &Utf8Path, refspec: &str) -> Result; +pub fn fetch_ref(repo: &Utf8Path, refspec: &str) -> Result<()>; +pub fn checkout_detached(repo: &Utf8Path, commit: &str) -> Result<()>; +pub fn ensure_default_branch(repo: &Utf8Path) -> Result<()>; +``` + +In `installer/src/workspace.rs` (signature change, the one sanctioned +breaking change; all in-repo callers updated in the same commit): + +```rust +pub struct WorkspaceCheckout { + pub root: Utf8PathBuf, + pub pinned_commit: Option, +} + +pub fn ensure_workspace( + dirs: &dyn BaseDirs, + update: bool, + git_ref: Option<&str>, +) -> Result; +``` + +In `installer/src/prebuilt.rs`: + +```rust +pub struct PrebuiltConfig<'a> { + // …existing fields… + /// When set, require the manifest git SHA to match this commit. + pub expected_git_sha: Option<&'a str>, +} +``` + +`installer/src/install_flow.rs` threads `pinned_commit` from +`WorkspaceCheckout` into `PrebuiltInstallationContext` and on into +`PrebuiltConfig`. `installer/src/output.rs`'s `DryRunInfo` gains a +`git_ref: Option<&'a str>` field rendered in `display_text`. From 02e04f36e0fd378ba4be0ec1cf7382b435a0418c Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 8 Jul 2026 23:19:27 +0200 Subject: [PATCH 02/26] Add --ref flag to installer CLI for pinned installs Introduce an optional `git_ref` field on `InstallArgs`, exposed as `--ref `, so a user can name a commit SHA or tag to pin the lint suite to. The flag defaults to `None`, preserving the rolling default, and is accepted both bare and under the `install` subcommand. Record the Stage A findings (abbreviated manifest git SHA, default-branch discovery command) in the execution plan. --- .../issue-271-ref-pinned-installation.md | 28 ++++++++++++++++--- installer/src/cli.rs | 7 +++++ installer/src/cli_tests.rs | 16 ++++++++++- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/docs/execplans/issue-271-ref-pinned-installation.md b/docs/execplans/issue-271-ref-pinned-installation.md index ec5c8485..a25c5b2c 100644 --- a/docs/execplans/issue-271-ref-pinned-installation.md +++ b/docs/execplans/issue-271-ref-pinned-installation.md @@ -114,9 +114,10 @@ clone recovers from the detached checkout). `artefact/download.rs`; mapped the install flow and identified the detached-HEAD recovery requirement. - [x] (2026-07-08 12:50Z) Drafted this ExecPlan. -- [ ] Await user approval of the plan (approval gate). -- [ ] Stage A: confirm manifest `git_sha` format and default-branch discovery - command; record findings here. +- [x] (2026-07-08) User approved the plan; proceeding through all stages. +- [x] (2026-07-08) Stage A: confirmed manifest `git_sha` is abbreviated + (`git rev-parse --short HEAD`) and default-branch discovery command + (`git rev-parse --abbrev-ref origin/HEAD`); findings recorded below. - [ ] Stage B: red tests (CLI parsing, git ref operations, workspace decision, prebuilt SHA validation, BDD scenarios). - [ ] Stage C: implementation (CLI field, git helpers, workspace plumbing, @@ -127,7 +128,26 @@ clone recovers from the detached checkout). ## Surprises & discoveries -- Observation: none yet (pre-implementation). +- (2026-07-08, Stage A) The manifest `git_sha` is **abbreviated**, not a full + 40-hex SHA. `.github/workflows/rolling-release.yml` line 142 writes it from + `git rev-parse --short HEAD` into the package tool's `--git-sha`. Git's + `--short` yields the shortest unambiguous prefix (7+ hex). The `GitSha` + newtype in `installer/src/artefact/git_sha.rs` accepts 7–40 hex chars, + consistent with this. **Comparison rule fixed:** a pinned install may use the + rolling prebuilt only when the resolved *full* commit SHA `starts_with` the + manifest's abbreviated `git_sha` (prefix-tolerant), not exact equality. +- (2026-07-08, Stage A) Default-branch discovery: the platform clone carries + `refs/remotes/origin/HEAD` symbolic ref (`git symbolic-ref + refs/remotes/origin/HEAD` → `refs/remotes/origin/main`; `git rev-parse + --abbrev-ref origin/HEAD` → `origin/main`). `ensure_default_branch` uses + `git rev-parse --abbrev-ref origin/HEAD`, strips the `origin/` prefix to get + the branch name, and falls back to `git remote set-head origin --auto` once + when the symbolic ref is absent from an older clone. The repository default + branch is `main`. +- (2026-07-08, Stage A) The shared test manifest helper + `test_utils::prebuilt_manifest_json` hardcodes `"git_sha":"abc1234"`. The + prebuilt SHA-match tests therefore key off that literal: a full SHA beginning + `abc1234…` matches; any other value is a mismatch. ## Decision log diff --git a/installer/src/cli.rs b/installer/src/cli.rs index 0ef5fbc5..ad64949e 100644 --- a/installer/src/cli.rs +++ b/installer/src/cli.rs @@ -46,6 +46,8 @@ use clap::{Parser, Subcommand}; " $ whitaker-installer --individual-lints\n\n", " Include experimental lints in the suite:\n", " $ whitaker-installer --experimental\n\n", + " Pin the suite to a specific commit SHA or tag:\n", + " $ whitaker-installer --ref v0.2.5\n\n", " List installed lints:\n", " $ whitaker-installer list\n\n", " Preview without building:\n", @@ -136,6 +138,10 @@ pub struct InstallArgs { /// Skip prebuilt artefact download and build from source. #[arg(long = "build-only")] pub is_build_only: bool, + + /// Install the lint suite at a specific commit SHA or tag [default: rolling]. + #[arg(long = "ref", value_name = "REF")] + pub git_ref: Option, } /// Arguments for the list command. @@ -219,6 +225,7 @@ impl Default for InstallArgs { skip_wrapper: false, no_update: false, is_build_only: false, + git_ref: None, } } } diff --git a/installer/src/cli_tests.rs b/installer/src/cli_tests.rs index 8b732bd2..4a685be3 100644 --- a/installer/src/cli_tests.rs +++ b/installer/src/cli_tests.rs @@ -19,9 +19,23 @@ fn cli_parses_defaults() { assert!(!cli.install.skip_wrapper); assert!(!cli.install.no_update); assert!(!cli.install.is_build_only); + assert!(cli.install.git_ref.is_none()); } -#[test] +fn cli_parses_ref_flag_bare() { + let cli = Cli::parse_from(["whitaker-installer", "--ref", "v0.2.5"]); + assert_eq!(cli.install.git_ref.as_deref(), Some("v0.2.5")); +} + +fn cli_parses_ref_flag_under_install_subcommand() { + let cli = Cli::parse_from(["whitaker-installer", "install", "--ref", "1a2b3c4d"]); + match cli.command { + Some(Command::Install(args)) => { + assert_eq!(args.git_ref.as_deref(), Some("1a2b3c4d")); + } + _ => panic!("expected Install command"), + } +} fn cli_parses_target_dir() { let cli = Cli::parse_from(["whitaker-installer", "-t", "/tmp/dylint"]); assert_eq!( From ae6266d90d8edd367e50ea2448c716635267a565 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 8 Jul 2026 23:23:05 +0200 Subject: [PATCH 03/26] Add git helpers for resolving and pinning refs Add four repository operations, all routed through the existing timeout-guarded git helper: `resolve_commit` peels a commit-ish to a full SHA, `fetch_ref` retrieves a ref and its tags from origin, `checkout_detached` pins the working tree to a commit, and `ensure_default_branch` reattaches a detached clone to the branch named by `origin/HEAD` so a later pull succeeds. Cover them with tests exercising real git repositories in temporary directories. --- installer/src/git.rs | 292 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 292 insertions(+) diff --git a/installer/src/git.rs b/installer/src/git.rs index f59a00e1..8241d12d 100644 --- a/installer/src/git.rs +++ b/installer/src/git.rs @@ -67,6 +67,150 @@ pub fn update_repository(repo: &Utf8Path) -> Result<()> { Ok(()) } +/// Resolves a commit-ish (SHA, tag, or branch) to a full commit SHA. +/// +/// Runs `git rev-parse --verify ^{commit}` in the repository so that +/// only expressions naming an existing commit succeed; annotated tags are +/// peeled to the commit they point at. +/// +/// # Errors +/// +/// Returns `InstallerError::Git` if the ref cannot be resolved or the command +/// times out. +pub fn resolve_commit(repo: &Utf8Path, refspec: &str) -> Result { + let peeled = format!("{refspec}^{{commit}}"); + let output = + run_git_with_timeout(&["rev-parse", "--verify", &peeled], Some(repo), "rev-parse")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(InstallerError::Git { + operation: "rev-parse", + message: format!("could not resolve ref '{refspec}': {}", stderr.trim()), + }); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) +} + +/// Fetches a specific ref (and all tags) from `origin` into the repository. +/// +/// Used to recover when a pinned ref cannot be resolved from the existing +/// clone. Runs `git fetch origin --tags`. +/// +/// # Errors +/// +/// Returns `InstallerError::Git` if the fetch fails or times out. +pub fn fetch_ref(repo: &Utf8Path, refspec: &str) -> Result<()> { + let output = + run_git_with_timeout(&["fetch", "origin", refspec, "--tags"], Some(repo), "fetch")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(InstallerError::Git { + operation: "fetch", + message: stderr.trim().to_owned(), + }); + } + + Ok(()) +} + +/// Checks out a commit as a detached HEAD. +/// +/// Runs `git checkout --detach `, leaving the working tree at exactly +/// the given commit without moving any branch. +/// +/// # Errors +/// +/// Returns `InstallerError::Git` if the checkout fails or times out. +pub fn checkout_detached(repo: &Utf8Path, commit: &str) -> Result<()> { + let output = run_git_with_timeout(&["checkout", "--detach", commit], Some(repo), "checkout")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(InstallerError::Git { + operation: "checkout", + message: stderr.trim().to_owned(), + }); + } + + Ok(()) +} + +/// Reattaches the repository to its default branch when HEAD is detached. +/// +/// A previous pinned install may leave the platform clone on a detached HEAD, +/// which makes a later `git pull` fail. This restores a branch checkout so that +/// subsequent updates succeed. It is a no-op when HEAD is already on a branch. +/// +/// The default branch is discovered from `origin/HEAD` +/// (`git rev-parse --abbrev-ref origin/HEAD`, e.g. `origin/main`); when an older +/// clone lacks that symbolic ref, `git remote set-head origin --auto` restores +/// it before retrying. +/// +/// # Errors +/// +/// Returns `InstallerError::Git` if the default branch cannot be determined or +/// checked out. +pub fn ensure_default_branch(repo: &Utf8Path) -> Result<()> { + // When HEAD already names a branch, there is nothing to reattach. + let symbolic = + run_git_with_timeout(&["symbolic-ref", "-q", "HEAD"], Some(repo), "symbolic-ref")?; + if symbolic.status.success() { + return Ok(()); + } + + let branch = default_branch_name(repo)?; + let output = run_git_with_timeout(&["checkout", &branch], Some(repo), "checkout")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(InstallerError::Git { + operation: "checkout", + message: stderr.trim().to_owned(), + }); + } + + Ok(()) +} + +/// Discovers the remote default branch name (without the `origin/` prefix). +fn default_branch_name(repo: &Utf8Path) -> Result { + if let Some(branch) = read_default_branch(repo)? { + return Ok(branch); + } + + // An older clone may lack origin/HEAD; ask git to repopulate it, then retry. + let _ = run_git_with_timeout( + &["remote", "set-head", "origin", "--auto"], + Some(repo), + "remote", + )?; + + read_default_branch(repo)?.ok_or_else(|| InstallerError::Git { + operation: "rev-parse", + message: "could not determine default branch from origin/HEAD".to_owned(), + }) +} + +/// Reads `origin/HEAD` and returns the bare branch name, if present. +fn read_default_branch(repo: &Utf8Path) -> Result> { + let output = run_git_with_timeout( + &["rev-parse", "--abbrev-ref", "origin/HEAD"], + Some(repo), + "rev-parse", + )?; + if !output.status.success() { + return Ok(None); + } + + let value = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + Ok(value + .strip_prefix("origin/") + .map(ToOwned::to_owned) + .filter(|branch| !branch.is_empty())) +} + /// Runs a git command with a timeout. /// /// Returns the command output if it completes within the timeout, or an error @@ -151,6 +295,154 @@ fn run_git_with_timeout( #[cfg(test)] mod tests { use super::*; + use camino::Utf8PathBuf; + use std::process::Command; + use tempfile::TempDir; + + /// Run a git command in `dir`, asserting success, and return trimmed stdout. + fn git(dir: &Utf8Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(dir.as_std_path()) + .output() + .expect("failed to spawn git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_owned() + } + + /// Commit the given file content in `dir` and return the resulting SHA. + fn commit_file(dir: &Utf8Path, name: &str, contents: &str, message: &str) -> String { + std::fs::write(dir.join(name).as_std_path(), contents).expect("write fixture file"); + git(dir, &["add", "."]); + git( + dir, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + message, + ], + ); + git(dir, &["rev-parse", "HEAD"]) + } + + /// A source repository plus a clone of it, with recorded commit SHAs. + struct GitFixture { + _source: TempDir, + _clone: TempDir, + clone: Utf8PathBuf, + first: String, + second: String, + } + + /// Build a source repo (two commits, tag `v1` on the first) and clone it. + fn git_fixture() -> GitFixture { + let source = TempDir::new().expect("source temp dir"); + let source_path = + Utf8PathBuf::try_from(source.path().to_owned()).expect("UTF-8 source path"); + git(&source_path, &["init", "-b", "main"]); + let first = commit_file(&source_path, "a.txt", "one", "first"); + git(&source_path, &["tag", "v1"]); + let second = commit_file(&source_path, "b.txt", "two", "second"); + + let clone = TempDir::new().expect("clone temp dir"); + let clone_path = Utf8PathBuf::try_from(clone.path().to_owned()).expect("UTF-8 clone path"); + let output = Command::new("git") + .args(["clone", source_path.as_str(), clone_path.as_str()]) + .output() + .expect("failed to spawn git clone"); + assert!( + output.status.success(), + "git clone failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + GitFixture { + _source: source, + _clone: clone, + clone: clone_path, + first, + second, + } + } + + #[test] + fn resolve_commit_resolves_tag_branch_and_sha() { + let fx = git_fixture(); + assert_eq!( + resolve_commit(&fx.clone, "v1").expect("resolve tag"), + fx.first + ); + assert_eq!( + resolve_commit(&fx.clone, "main").expect("resolve branch"), + fx.second + ); + assert_eq!( + resolve_commit(&fx.clone, &fx.second).expect("resolve sha"), + fx.second + ); + } + + #[test] + fn resolve_commit_errors_on_garbage() { + let fx = git_fixture(); + let err = resolve_commit(&fx.clone, "definitely-not-a-ref").expect_err("expected error"); + assert!(matches!(err, InstallerError::Git { .. }), "got {err:?}"); + } + + #[test] + fn checkout_detached_leaves_head_at_commit() { + let fx = git_fixture(); + checkout_detached(&fx.clone, &fx.first).expect("checkout detached"); + assert_eq!(git(&fx.clone, &["rev-parse", "HEAD"]), fx.first); + // A detached HEAD has no symbolic ref. + let symbolic = Command::new("git") + .args(["symbolic-ref", "-q", "HEAD"]) + .current_dir(fx.clone.as_std_path()) + .output() + .expect("spawn symbolic-ref"); + assert!(!symbolic.status.success(), "expected detached HEAD"); + } + + #[test] + fn ensure_default_branch_reattaches_so_update_succeeds() { + let fx = git_fixture(); + checkout_detached(&fx.clone, &fx.first).expect("checkout detached"); + ensure_default_branch(&fx.clone).expect("reattach to default branch"); + assert_eq!(git(&fx.clone, &["symbolic-ref", "HEAD"]), "refs/heads/main"); + // A pull now succeeds because HEAD is on a branch again. + update_repository(&fx.clone).expect("update after reattach"); + } + + #[test] + fn ensure_default_branch_is_noop_on_a_branch() { + let fx = git_fixture(); + ensure_default_branch(&fx.clone).expect("noop on branch"); + assert_eq!(git(&fx.clone, &["symbolic-ref", "HEAD"]), "refs/heads/main"); + } + + #[test] + fn fetch_ref_retrieves_a_new_tag() { + let fx = git_fixture(); + // Add a third commit and tag it in the source, after the clone was made. + let source = Utf8PathBuf::try_from(fx._source.path().to_owned()).expect("UTF-8 path"); + let third = commit_file(&source, "c.txt", "three", "third"); + git(&source, &["tag", "v2"]); + + // The clone cannot resolve the new tag until it fetches. + assert!(resolve_commit(&fx.clone, "v2").is_err()); + fetch_ref(&fx.clone, "v2").expect("fetch new tag"); + assert_eq!(resolve_commit(&fx.clone, "v2").expect("resolve v2"), third); + } #[test] fn clone_repository_error_includes_operation() { From 91bcd35c51aa06a383295ea3649b07177506d5f5 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 8 Jul 2026 23:27:09 +0200 Subject: [PATCH 04/26] Pin the managed clone to a requested ref Extend `ensure_workspace` to accept an optional ref and return a `WorkspaceCheckout` carrying the workspace root and the resolved pinned commit. When a ref is given, the managed clone is resolved (fetching once on a miss) and checked out as a detached HEAD; pinning is refused for a current-directory workspace via a new `RefUnsupported` error. The update path now reattaches a detached clone to its default branch before pulling, so a previous pin cannot break a later un-pinned install. The installer confirms the pin to the user. --- installer/src/error.rs | 10 +++ installer/src/main.rs | 39 ++++++++---- installer/src/workspace.rs | 123 ++++++++++++++++++++++++++++++++++--- 3 files changed, 154 insertions(+), 18 deletions(-) diff --git a/installer/src/error.rs b/installer/src/error.rs index 93e45cdc..390c444f 100644 --- a/installer/src/error.rs +++ b/installer/src/error.rs @@ -105,6 +105,13 @@ pub enum InstallerError { reason: String, }, + /// Pinning a ref is not supported for the current directory workspace. + #[error("{message}")] + RefUnsupported { + /// The refusal message, naming the requested ref. + message: String, + }, + /// A Cargo.toml file could not be parsed during workspace detection. #[error("invalid Cargo.toml at {path}: {reason}")] InvalidCargoToml { @@ -231,6 +238,9 @@ impl Clone for InstallerError { Self::WorkspaceNotFound { reason } => Self::WorkspaceNotFound { reason: reason.clone(), }, + Self::RefUnsupported { message } => Self::RefUnsupported { + message: message.clone(), + }, Self::InvalidCargoToml { path, reason } => Self::InvalidCargoToml { path: path.clone(), reason: reason.clone(), diff --git a/installer/src/main.rs b/installer/src/main.rs index 1e9c2665..884833c3 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -31,6 +31,7 @@ use whitaker_installer::resolution::{ CrateResolutionOptions, resolve_crates, validate_crate_names, }; use whitaker_installer::toolchain::Toolchain; +use whitaker_installer::workspace::WorkspaceCheckout; use whitaker_installer::wrapper::{generate_wrapper_scripts, path_instructions}; fn main() { @@ -111,7 +112,8 @@ fn run_install(args: &InstallArgs, stderr: &mut dyn Write) -> Result<()> { ensure_dylint_tools(args.quiet, stderr)?; } // Step 2: Ensure workspace is available (clone if needed) - let workspace_root = ensure_whitaker_workspace(args, &dirs, stderr)?; + let workspace = ensure_whitaker_workspace(args, &dirs, stderr)?; + let workspace_root = workspace.root; // Step 3: Resolve crates and toolchain let requested_crates = resolve_requested_crates(args)?; let toolchain = resolve_toolchain(&workspace_root, args.toolchain.as_deref())?; @@ -217,22 +219,19 @@ fn ensure_whitaker_workspace( args: &InstallArgs, dirs: &dyn BaseDirs, stderr: &mut dyn Write, -) -> Result { +) -> Result { use whitaker_installer::workspace::{ WorkspaceAction, clone_directory, decide_workspace_action, ensure_workspace, }; + let git_ref = args.git_ref.as_deref(); + if !args.quiet && let Some(clone_dir) = clone_directory(dirs) - { - let cwd = std::env::current_dir() + && let Some(cwd) = std::env::current_dir() .ok() - .and_then(|p| Utf8PathBuf::try_from(p).ok()); - - let Some(cwd) = cwd else { - return ensure_workspace(dirs, !args.no_update); - }; - + .and_then(|p| Utf8PathBuf::try_from(p).ok()) + { match decide_workspace_action(&cwd, &clone_dir, !args.no_update) { WorkspaceAction::CloneTo(dir) => { write_stderr_line(stderr, format!("Cloning Whitaker repository to {dir}...")); @@ -244,9 +243,27 @@ fn ensure_whitaker_workspace( } } - ensure_workspace(dirs, !args.no_update) + let checkout = ensure_workspace(dirs, !args.no_update, git_ref)?; + if !args.quiet + && let Some(commit) = &checkout.pinned_commit + { + write_stderr_line( + stderr, + format!( + "Pinned Whitaker suite to {} ({}).", + git_ref.unwrap_or(commit.as_str()), + short_commit(commit) + ), + ); + } + Ok(checkout) } +/// Abbreviates a commit SHA to its leading 12 characters for display. +fn short_commit(commit: &str) -> &str { + let end = commit.len().min(12); + &commit[..end] +} /// Detects or overrides the toolchain, then verifies it is installed. fn resolve_toolchain( workspace_root: &Utf8Path, diff --git a/installer/src/workspace.rs b/installer/src/workspace.rs index 3d06bbf5..dc4df117 100644 --- a/installer/src/workspace.rs +++ b/installer/src/workspace.rs @@ -98,35 +98,119 @@ pub fn decide_workspace_action( } } +/// The prepared workspace and, when a ref was pinned, its resolved commit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceCheckout { + /// Path to the workspace root the install should build from. + pub root: Utf8PathBuf, + /// The full commit SHA a `--ref` pin resolved to, if any. + pub pinned_commit: Option, +} + /// Ensures a Whitaker workspace is available, cloning if necessary. /// /// If the current directory is already a Whitaker workspace, returns its path. /// Otherwise, clones or updates the repository in the platform-specific data /// directory. Set `update` to `true` to run `git pull` on existing clones. /// +/// When `git_ref` is `Some`, the managed clone is pinned to that commit-ish +/// (SHA, tag, or branch): the ref is resolved locally, fetched once on a +/// resolve-miss, and checked out as a detached HEAD. Pinning is refused when +/// the current directory is itself a Whitaker workspace, since that would +/// mutate the user's own working tree. The update path first reattaches a +/// detached clone to its default branch, so a previous pin never breaks a +/// later un-pinned install. +/// /// # Errors /// -/// Returns an error if the clone directory cannot be determined or if -/// cloning/updating fails. -pub fn ensure_workspace(dirs: &dyn BaseDirs, update: bool) -> Result { +/// Returns an error if the clone directory cannot be determined, if +/// cloning/updating fails, or if `git_ref` is requested for the current +/// directory workspace. +pub fn ensure_workspace( + dirs: &dyn BaseDirs, + update: bool, + git_ref: Option<&str>, +) -> Result { let cwd = current_dir_utf8()?; let clone_dir = clone_directory(dirs).ok_or_else(|| InstallerError::WorkspaceNotFound { reason: "could not determine data directory for cloning".to_owned(), })?; - match decide_workspace_action(&cwd, &clone_dir, update) { - WorkspaceAction::UseCurrentDir(dir) | WorkspaceAction::UseExisting(dir) => Ok(dir), + let action = decide_workspace_action(&cwd, &clone_dir, update); + ensure_ref_allowed(&action, git_ref)?; + + match action { + WorkspaceAction::UseCurrentDir(dir) | WorkspaceAction::UseExisting(dir) => { + // UseCurrentDir is guaranteed refless by `ensure_ref_allowed`; + // UseExisting pins without pulling, per the `--no-update` contract. + let pinned_commit = pin_if_requested(&dir, git_ref)?; + Ok(WorkspaceCheckout { + root: dir, + pinned_commit, + }) + } WorkspaceAction::CloneTo(dir) => { crate::git::clone_repository(&dir)?; - Ok(dir) + let pinned_commit = pin_if_requested(&dir, git_ref)?; + Ok(WorkspaceCheckout { + root: dir, + pinned_commit, + }) } WorkspaceAction::UpdateAt(dir) => { + // Reattach before pulling so a prior detached pin cannot break the + // update, even when no new ref is requested. + crate::git::ensure_default_branch(&dir)?; crate::git::update_repository(&dir)?; - Ok(dir) + let pinned_commit = pin_if_requested(&dir, git_ref)?; + Ok(WorkspaceCheckout { + root: dir, + pinned_commit, + }) } } } +/// Refuses `--ref` when the current directory is itself a Whitaker workspace. +/// +/// Pinning checks out a commit; doing so in the user's own working tree could +/// destroy uncommitted work, so it is rejected rather than attempted. +fn ensure_ref_allowed(action: &WorkspaceAction, git_ref: Option<&str>) -> Result<()> { + if let (WorkspaceAction::UseCurrentDir(_), Some(git_ref)) = (action, git_ref) { + return Err(InstallerError::RefUnsupported { + message: format!( + "cannot pin --ref {git_ref}: the current directory is itself a Whitaker \ + workspace; run the installer from outside a checkout to pin the suite" + ), + }); + } + Ok(()) +} + +/// Pins the managed clone to `git_ref` when one is requested. +/// +/// Returns the resolved commit SHA, or `None` when no ref was requested. +fn pin_if_requested(repo: &Utf8Path, git_ref: Option<&str>) -> Result> { + match git_ref { + Some(git_ref) => Ok(Some(pin_to_ref(repo, git_ref)?)), + None => Ok(None), + } +} + +/// Resolves and checks out `git_ref` as a detached HEAD, fetching on a miss. +fn pin_to_ref(repo: &Utf8Path, git_ref: &str) -> Result { + let commit = match crate::git::resolve_commit(repo, git_ref) { + Ok(commit) => commit, + Err(_) => { + // The ref is not known locally; fetch it once, then resolve again. + crate::git::fetch_ref(repo, git_ref)?; + crate::git::resolve_commit(repo, git_ref)? + } + }; + crate::git::checkout_detached(repo, &commit)?; + Ok(commit) +} + /// Returns the workspace path without performing any side effects. /// /// If the current directory is a Whitaker workspace, returns it. Otherwise @@ -295,6 +379,31 @@ mod tests { assert_eq!(action, WorkspaceAction::UseExisting(clone_dir)); } + #[test] + fn ensure_ref_allowed_refuses_current_dir_workspace() { + let action = WorkspaceAction::UseCurrentDir(Utf8PathBuf::from("/some/whitaker")); + let err = ensure_ref_allowed(&action, Some("v0.2.5")).expect_err("expected refusal"); + assert!( + matches!(err, InstallerError::RefUnsupported { .. }), + "expected RefUnsupported, got {err:?}" + ); + assert!(err.to_string().contains("v0.2.5")); + } + + #[test] + fn ensure_ref_allowed_permits_current_dir_without_ref() { + let action = WorkspaceAction::UseCurrentDir(Utf8PathBuf::from("/some/whitaker")); + assert!(ensure_ref_allowed(&action, None).is_ok()); + } + + #[rstest] + #[case::clone(WorkspaceAction::CloneTo(Utf8PathBuf::from("/clone")))] + #[case::update(WorkspaceAction::UpdateAt(Utf8PathBuf::from("/clone")))] + #[case::existing(WorkspaceAction::UseExisting(Utf8PathBuf::from("/clone")))] + fn ensure_ref_allowed_permits_ref_for_managed_clones(#[case] action: WorkspaceAction) { + assert!(ensure_ref_allowed(&action, Some("v0.2.5")).is_ok()); + } + // ------------------------------------------------------------------------- // Behavioural tests for workspace orchestration with mocked dependencies // ------------------------------------------------------------------------- From 27e48e6a13182891c4e1fa0b4526afbb8f9cbd71 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 8 Jul 2026 23:31:47 +0200 Subject: [PATCH 05/26] Gate the prebuilt fast path on the pinned commit Add `expected_git_sha` to `PrebuiltConfig` and validate it in the download pipeline: because the rolling manifest records an abbreviated SHA, a pinned install reuses the prebuilt artefact only when the resolved full commit begins with the manifest's SHA, otherwise it falls back to a source build. Thread the resolved pin from the workspace checkout through the fast-path and prebuilt contexts. Cover the match, mismatch, and un-pinned cases with unit tests and a behaviour scenario. --- installer/src/install_flow.rs | 3 + installer/src/install_flow/tests.rs | 1 + installer/src/main.rs | 3 + installer/src/prebuilt.rs | 30 ++++++++ installer/src/prebuilt_tests.rs | 70 +++++++++++++++++++ installer/src/tests/fast_path.rs | 1 + installer/tests/behaviour_prebuilt.rs | 12 +++- .../tests/features/prebuilt_download.feature | 8 +++ 8 files changed, 127 insertions(+), 1 deletion(-) diff --git a/installer/src/install_flow.rs b/installer/src/install_flow.rs index 54bf69a1..c20fd9e4 100644 --- a/installer/src/install_flow.rs +++ b/installer/src/install_flow.rs @@ -83,6 +83,8 @@ pub(crate) struct PrebuiltInstallationContext<'a> { pub(crate) requested_crates: &'a [CrateName], /// Toolchain channel resolved for this install. pub(crate) toolchain_channel: &'a str, + /// Resolved pinned commit SHA, when installing at a specific `--ref`. + pub(crate) expected_git_sha: Option<&'a str>, } /// Context for recording one successful install in aggregate metrics. @@ -181,6 +183,7 @@ fn try_prebuilt_installation_with( toolchain: context.toolchain_channel, destination_dir: &destination_dir, quiet: context.args.quiet, + expected_git_sha: context.expected_git_sha, }; let PrebuiltResult::Success { staging_path } = attempt_prebuilt(&prebuilt_config, stderr) diff --git a/installer/src/install_flow/tests.rs b/installer/src/install_flow/tests.rs index c520829a..320da44e 100644 --- a/installer/src/install_flow/tests.rs +++ b/installer/src/install_flow/tests.rs @@ -173,6 +173,7 @@ fn try_prebuilt_installation_prune_error_falls_back_to_local_build() { dirs: &dirs, requested_crates: &requested_crates, toolchain_channel: "nightly-2026-05-28", + expected_git_sha: None, }; let mut stderr = Vec::new(); diff --git a/installer/src/main.rs b/installer/src/main.rs index 884833c3..3ec77c9e 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -76,6 +76,7 @@ fn try_fast_path_installation( dirs: context.dirs, requested_crates: context.requested_crates, toolchain_channel: context.toolchain.channel(), + expected_git_sha: context.expected_git_sha, }; if let Some(staging_path) = try_prebuilt_installation(&prebuilt_context, stderr)? { return Ok(Some((staging_path, InstallMode::Download))); @@ -131,6 +132,7 @@ fn run_install(args: &InstallArgs, stderr: &mut dyn Write) -> Result<()> { requested_crates: &requested_crates, toolchain: &toolchain, target_dir: &target_dir, + expected_git_sha: workspace.pinned_commit.as_deref(), }; if let Some((staging_path, install_mode)) = try_fast_path_installation(&fast_path_context, stderr)? @@ -324,6 +326,7 @@ struct FastPathContext<'a> { requested_crates: &'a [CrateName], toolchain: &'a Toolchain, target_dir: &'a Utf8PathBuf, + expected_git_sha: Option<&'a str>, } /// Finalize installation and record aggregate installer metrics. diff --git a/installer/src/prebuilt.rs b/installer/src/prebuilt.rs index 3e4fab24..fd95c0ce 100644 --- a/installer/src/prebuilt.rs +++ b/installer/src/prebuilt.rs @@ -52,6 +52,13 @@ pub struct PrebuiltConfig<'a> { pub destination_dir: &'a Utf8Path, /// When true, suppress progress output. pub quiet: bool, + /// When set, require the manifest git SHA to prefix this pinned commit. + /// + /// The rolling manifest records an abbreviated SHA, so a pinned install may + /// only reuse the prebuilt artefact when the resolved full commit SHA + /// begins with the manifest's abbreviated SHA; any mismatch falls back to a + /// source build of the pinned commit. + pub expected_git_sha: Option<&'a str>, } /// Internal error type for the prebuilt pipeline. @@ -72,6 +79,9 @@ enum PrebuiltError { #[error("target mismatch: manifest has {manifest}, expected {expected}")] TargetMismatch { manifest: String, expected: String }, + #[error("git SHA mismatch: manifest has {manifest}, pinned commit is {expected}")] + GitShaMismatch { manifest: String, expected: String }, + #[error("checksum mismatch: manifest={expected}, actual={actual}")] ChecksumMismatch { expected: String, actual: String }, @@ -138,6 +148,7 @@ fn run_pipeline( let manifest = parse_manifest(&manifest_json)?; validate_toolchain(&manifest, config.toolchain)?; validate_target(&manifest, config.target)?; + validate_git_sha(&manifest, config.expected_git_sha)?; // Step 3: Derive archive filename and download. let archive_filename = derive_archive_filename(&manifest); @@ -197,6 +208,25 @@ fn validate_target(manifest: &Manifest, expected: &str) -> Result<(), PrebuiltEr Ok(()) } +/// Validate that the manifest git SHA prefixes the pinned commit, if pinned. +/// +/// The rolling manifest records an abbreviated SHA, so a pinned install may +/// reuse the prebuilt artefact only when the resolved full commit begins with +/// the manifest's abbreviated SHA. When no commit is pinned this is a no-op, +/// leaving the default rolling behaviour unchanged. +fn validate_git_sha(manifest: &Manifest, expected: Option<&str>) -> Result<(), PrebuiltError> { + let Some(expected) = expected else { + return Ok(()); + }; + let manifest_sha = manifest.git_sha().as_str(); + if !expected.starts_with(manifest_sha) { + return Err(PrebuiltError::GitShaMismatch { + manifest: manifest_sha.to_owned(), + expected: expected.to_owned(), + }); + } + Ok(()) +} /// Rename extracted files into the staged `lib@.` format. fn apply_staging_filenames( extracted_files: &[String], diff --git a/installer/src/prebuilt_tests.rs b/installer/src/prebuilt_tests.rs index 7dd75a04..618b8792 100644 --- a/installer/src/prebuilt_tests.rs +++ b/installer/src/prebuilt_tests.rs @@ -10,15 +10,85 @@ const FAKE_ARCHIVE: &[u8] = b"fake archive content"; const TARGET: &str = "x86_64-unknown-linux-gnu"; const TOOLCHAIN: &str = "nightly-2026-05-28"; +/// A full 40-hex commit SHA beginning with the test manifest's `abc1234`. +const MATCHING_COMMIT: &str = "abc1234000000000000000000000000000000ab"; + +/// A full 40-hex commit SHA that does not share the manifest's prefix. +const MISMATCHED_COMMIT: &str = "deadbeef00000000000000000000000000000000"; fn base_config(destination_dir: &Utf8Path) -> PrebuiltConfig<'_> { PrebuiltConfig { target: TARGET, toolchain: TOOLCHAIN, destination_dir, quiet: true, + expected_git_sha: None, + } +} + +/// Build a happy-path config plus mocks, overriding only `expected_git_sha`. +fn success_mocks() -> (MockArtefactDownloader, MockArtefactExtractor) { + let fake_sha = sha256_hex(FAKE_ARCHIVE); + let manifest_json = prebuilt_manifest_json(TOOLCHAIN, TARGET, &fake_sha); + let mut downloader = MockArtefactDownloader::new(); + downloader + .expect_download_manifest() + .returning(move |_| Ok(manifest_json.clone())); + downloader + .expect_download_archive() + .returning(|_filename, dest| std::fs::write(dest, FAKE_ARCHIVE).map_err(DownloadError::Io)); + let mut extractor = MockArtefactExtractor::new(); + extractor.expect_extract().returning(|_archive, dest| { + let source_name = "libwhitaker_suite.so".to_owned(); + std::fs::write(dest.join(&source_name), b"fake").expect("write extracted file"); + Ok(vec![source_name]) + }); + (downloader, extractor) +} + +fn expected_git_sha_mismatch_returns_fallback() { + let (_temp, destination_dir) = destination_dir(); + let config = PrebuiltConfig { + expected_git_sha: Some(MISMATCHED_COMMIT), + ..base_config(&destination_dir) + }; + let (downloader, extractor) = success_mocks(); + let mut stderr = Vec::new(); + let result = attempt_prebuilt_with(&config, &downloader, &extractor, &mut stderr); + match result { + PrebuiltResult::Fallback { reason } => { + assert!(reason.contains("SHA mismatch"), "reason: {reason}"); + } + other => panic!("expected Fallback, got {other:?}"), } } +fn expected_git_sha_match_returns_success() { + let (_temp, destination_dir) = destination_dir(); + let config = PrebuiltConfig { + expected_git_sha: Some(MATCHING_COMMIT), + ..base_config(&destination_dir) + }; + let (downloader, extractor) = success_mocks(); + let mut stderr = Vec::new(); + let result = attempt_prebuilt_with(&config, &downloader, &extractor, &mut stderr); + assert!( + matches!(result, PrebuiltResult::Success { .. }), + "expected Success, got {result:?}" + ); +} + +fn expected_git_sha_none_leaves_behaviour_unchanged() { + let (_temp, destination_dir) = destination_dir(); + let config = base_config(&destination_dir); + assert!(config.expected_git_sha.is_none()); + let (downloader, extractor) = success_mocks(); + let mut stderr = Vec::new(); + let result = attempt_prebuilt_with(&config, &downloader, &extractor, &mut stderr); + assert!( + matches!(result, PrebuiltResult::Success { .. }), + "expected Success, got {result:?}" + ); +} fn destination_dir() -> (tempfile::TempDir, Utf8PathBuf) { let temp = tempfile::tempdir().expect("temp dir"); let root = Utf8PathBuf::try_from(temp.path().to_path_buf()).expect("UTF-8 path"); diff --git a/installer/src/tests/fast_path.rs b/installer/src/tests/fast_path.rs index 4dd9aa08..d40b0f16 100644 --- a/installer/src/tests/fast_path.rs +++ b/installer/src/tests/fast_path.rs @@ -24,6 +24,7 @@ impl FastPathFixture { requested_crates: &self.requested_crates, toolchain: &self.toolchain, target_dir: &self.target_dir, + expected_git_sha: None, } } } diff --git a/installer/tests/behaviour_prebuilt.rs b/installer/tests/behaviour_prebuilt.rs index b51185f8..55ad55ca 100644 --- a/installer/tests/behaviour_prebuilt.rs +++ b/installer/tests/behaviour_prebuilt.rs @@ -115,6 +115,7 @@ struct PrebuiltWorld { should_attempt_prebuilt: Option, force_destination_conflict: bool, attempted_destination: Option, + expected_git_sha: Option, } #[fixture] @@ -204,7 +205,11 @@ fn given_destination_path_conflict(world: &mut PrebuiltWorld) { world.force_destination_conflict = true; } -#[when("prebuilt download is attempted")] +fn given_pinned_commit_mismatch(world: &mut PrebuiltWorld) { + // The shared test manifest records git_sha "abc1234"; this full SHA does + // not share that prefix, so the pinned install cannot reuse the artefact. + world.expected_git_sha = Some("deadbeef00000000000000000000000000000000".to_owned()); +} fn when_prebuilt_attempted(world: &mut PrebuiltWorld) { let toolchain = world .expected_toolchain @@ -229,6 +234,7 @@ fn when_prebuilt_attempted(world: &mut PrebuiltWorld) { toolchain, destination_dir: &destination_dir, quiet: true, + expected_git_sha: world.expected_git_sha.as_deref(), }; let manifest_behaviour = world @@ -387,3 +393,7 @@ fn scenario_toolchain_mismatch(world: PrebuiltWorld) { fn scenario_build_only(world: PrebuiltWorld) { let _ = world; } + +fn scenario_pinned_ref_mismatch(world: PrebuiltWorld) { + let _ = world; +} diff --git a/installer/tests/features/prebuilt_download.feature b/installer/tests/features/prebuilt_download.feature index dfa33f70..14ebd2b4 100644 --- a/installer/tests/features/prebuilt_download.feature +++ b/installer/tests/features/prebuilt_download.feature @@ -50,3 +50,11 @@ Feature: Prebuilt artefact download and verification Given the build-only flag is set When the install configuration is checked Then no prebuilt download is attempted + + Scenario: Prebuilt is skipped when the pinned ref does not match + Given a valid manifest for target "x86_64-unknown-linux-gnu" + And a matching archive with correct checksum + And the pinned commit does not match the manifest git SHA + When prebuilt download is attempted + Then the prebuilt result is fallback + And the fallback reason mentions "SHA mismatch" From c0b9ef3f1848ecd8367b7699ecfd33ec6818e0f9 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 8 Jul 2026 23:35:21 +0200 Subject: [PATCH 06/26] Report the pinned ref in dry-run and progress output Add the pinned ref to `DryRunInfo` so `--dry-run --ref` reports the pin, and emit a "Pinning Whitaker suite to REF..." progress line before the checkout. Cover the dry-run rendering with unit tests and add CLI behaviour scenarios for a pinned dry-run and for refusing `--ref` inside a Whitaker workspace. --- installer/src/main.rs | 5 ++ installer/src/output.rs | 37 ++++++++++++++ installer/tests/behaviour_cli.rs | 24 ++++++++- installer/tests/behaviour_cli/scenarios.rs | 10 ++++ installer/tests/behaviour_cli/support.rs | 57 ++++++++++++++++++++++ installer/tests/features/installer.feature | 12 +++++ 6 files changed, 144 insertions(+), 1 deletion(-) diff --git a/installer/src/main.rs b/installer/src/main.rs index 3ec77c9e..e3227e88 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -189,6 +189,7 @@ fn run_dry(args: &InstallArgs, dirs: &dyn BaseDirs, stderr: &mut dyn Write) -> R no_update: args.no_update, jobs: args.jobs, crates: &requested_crates, + git_ref: args.git_ref.as_deref(), }; write_stderr_line(stderr, info.display_text()); Ok(()) @@ -243,6 +244,10 @@ fn ensure_whitaker_workspace( } WorkspaceAction::UseCurrentDir(_) | WorkspaceAction::UseExisting(_) => {} } + + if let Some(git_ref) = git_ref { + write_stderr_line(stderr, format!("Pinning Whitaker suite to {git_ref}...")); + } } let checkout = ensure_workspace(dirs, !args.no_update, git_ref)?; diff --git a/installer/src/output.rs b/installer/src/output.rs index 53bc593f..5c85c969 100644 --- a/installer/src/output.rs +++ b/installer/src/output.rs @@ -104,6 +104,7 @@ pub fn success_message(count: usize, target_dir: &Utf8Path) -> String { /// no_update: false, /// jobs: None, /// crates: &crates, +/// git_ref: None, /// }; /// /// let output = info.display_text(); @@ -132,6 +133,8 @@ pub struct DryRunInfo<'a> { pub jobs: Option, /// Crates to be built. pub crates: &'a [CrateName], + /// The commit SHA or tag the suite is pinned to, if any. + pub git_ref: Option<&'a str>, } impl DryRunInfo<'_> { @@ -151,6 +154,10 @@ impl DryRunInfo<'_> { format!("No update: {}", self.no_update), ]; + if let Some(git_ref) = self.git_ref { + lines.push(format!("Pinned ref: {git_ref}")); + } + if let Some(jobs) = self.jobs { lines.push(format!("Parallel jobs: {jobs}")); } @@ -211,6 +218,36 @@ mod tests { assert!(display.contains("PowerShell")); } + fn dry_run_info<'a>(git_ref: Option<&'a str>, crates: &'a [CrateName]) -> DryRunInfo<'a> { + DryRunInfo { + workspace_root: Utf8Path::new("/home/user/whitaker"), + toolchain: "nightly-2025-01-15", + target_dir: Utf8Path::new("/home/user/.local/share/dylint/lib"), + verbosity: 0, + quiet: false, + skip_deps: false, + skip_wrapper: false, + no_update: false, + jobs: None, + crates, + git_ref, + } + } + + #[rstest] + fn dry_run_display_includes_ref_when_pinned() { + let crates = vec![CrateName::from("whitaker_suite")]; + let text = dry_run_info(Some("v0.2.5"), &crates).display_text(); + assert!(text.contains("Pinned ref: v0.2.5"), "output: {text}"); + } + + #[rstest] + fn dry_run_display_omits_ref_when_unpinned() { + let crates = vec![CrateName::from("whitaker_suite")]; + let text = dry_run_info(None, &crates).display_text(); + assert!(!text.contains("Pinned ref:"), "output: {text}"); + } + #[rstest] #[case::singular(1, "1 lint library")] #[case::plural(5, "5 lint libraries")] diff --git a/installer/tests/behaviour_cli.rs b/installer/tests/behaviour_cli.rs index 0bd1f57c..9d4c3e5e 100644 --- a/installer/tests/behaviour_cli.rs +++ b/installer/tests/behaviour_cli.rs @@ -16,9 +16,11 @@ use support::{ assert_cli_exits_successfully, assert_cli_exits_with_error, assert_dry_run_output_is_shown, assert_experimental_lint_dry_run_output_is_shown, assert_experimental_lint_opt_in_message_is_shown, assert_installation_succeeds_or_is_skipped, + assert_pinned_ref_output_is_shown, assert_ref_unsupported_message_is_shown, assert_suite_library_is_staged, assert_unknown_lint_message_is_shown, configure_dry_run_experimental_lint, configure_dry_run_experimental_lint_with_opt_in, - configure_dry_run_unknown_lint, configure_dry_run_with_target_dir, configure_suite_install, + configure_dry_run_unknown_lint, configure_dry_run_with_pinned_ref, + configure_dry_run_with_target_dir, configure_ref_in_workspace, configure_suite_install, is_toolchain_installed, pinned_toolchain_channel, run_installer_cli, workspace_root, }; @@ -49,6 +51,16 @@ fn given_suite_install(cli_world: &CliWorld) { configure_suite_install(cli_world); } +#[given("the installer is invoked with dry-run and a pinned ref")] +fn given_dry_run_with_pinned_ref(cli_world: &CliWorld) { + configure_dry_run_with_pinned_ref(cli_world); +} + +#[given("the installer is invoked with a ref from a Whitaker workspace")] +fn given_ref_in_workspace(cli_world: &CliWorld) { + configure_ref_in_workspace(cli_world); +} + #[when("the installer CLI is run")] fn when_installer_cli_run(cli_world: &CliWorld) { run_installer_cli(cli_world); @@ -94,6 +106,16 @@ fn then_suite_library_is_staged(cli_world: &CliWorld) { assert_suite_library_is_staged(cli_world); } +#[then("dry-run output shows the pinned ref")] +fn then_pinned_ref_output_is_shown(cli_world: &CliWorld) { + assert_pinned_ref_output_is_shown(cli_world); +} + +#[then("a ref-unsupported message is shown")] +fn then_ref_unsupported_message_is_shown(cli_world: &CliWorld) { + assert_ref_unsupported_message_is_shown(cli_world); +} + #[test] fn dry_run_reports_verbosity_levels() { let channel = pinned_toolchain_channel(); diff --git a/installer/tests/behaviour_cli/scenarios.rs b/installer/tests/behaviour_cli/scenarios.rs index c95291d8..e86e500f 100644 --- a/installer/tests/behaviour_cli/scenarios.rs +++ b/installer/tests/behaviour_cli/scenarios.rs @@ -29,3 +29,13 @@ fn scenario_dry_run_rejects_experimental_lint_without_opt_in(cli_world: CliWorld fn scenario_dry_run_accepts_experimental_lint_with_opt_in(cli_world: CliWorld) { let _ = cli_world; } + +#[scenario(path = "tests/features/installer.feature", index = 23)] +fn scenario_pin_the_suite_to_a_ref_in_dry_run(cli_world: CliWorld) { + let _ = cli_world; +} + +#[scenario(path = "tests/features/installer.feature", index = 24)] +fn scenario_refuse_ref_inside_a_whitaker_workspace(cli_world: CliWorld) { + let _ = cli_world; +} diff --git a/installer/tests/behaviour_cli/support.rs b/installer/tests/behaviour_cli/support.rs index 1bae4eac..c5665803 100644 --- a/installer/tests/behaviour_cli/support.rs +++ b/installer/tests/behaviour_cli/support.rs @@ -144,6 +144,63 @@ pub(super) fn configure_dry_run_with_target_dir(cli_world: &CliWorld) { ]); } +/// The ref used by the pinned-install CLI scenarios. +pub(super) const SCENARIO_REF: &str = "v0.2.5"; + +pub(super) fn configure_dry_run_with_pinned_ref(cli_world: &CliWorld) { + let Some(channel) = ensure_required_toolchain_available(cli_world) else { + return; + }; + + let target_dir = setup_temp_dir(cli_world); + cli_world.args.replace(vec![ + "--dry-run".to_owned(), + "--toolchain".to_owned(), + channel, + "--target-dir".to_owned(), + target_dir, + "--ref".to_owned(), + SCENARIO_REF.to_owned(), + ]); +} + +pub(super) fn configure_ref_in_workspace(cli_world: &CliWorld) { + // The harness runs the installer from the whitaker workspace root, so a + // `--ref` must be refused. `--skip-deps` keeps the refusal ahead of any + // dependency installation, and no toolchain is required to reach it. + cli_world.args.replace(vec![ + "--ref".to_owned(), + SCENARIO_REF.to_owned(), + "--skip-deps".to_owned(), + ]); +} + +pub(super) fn assert_pinned_ref_output_is_shown(cli_world: &CliWorld) { + if cli_world.skip_assertions.get() { + return; + } + + let output = get_output(cli_world); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(&format!("Pinned ref: {SCENARIO_REF}")), + "expected dry-run output to report the pinned ref, stderr: {stderr}" + ); +} + +pub(super) fn assert_ref_unsupported_message_is_shown(cli_world: &CliWorld) { + if cli_world.skip_assertions.get() { + return; + } + + let output = get_output(cli_world); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("the current directory is itself a Whitaker workspace"), + "expected a ref-unsupported refusal, stderr: {stderr}" + ); +} + pub(super) fn configure_dry_run_unknown_lint(cli_world: &CliWorld) { cli_world.args.replace(vec![ "--dry-run".to_owned(), diff --git a/installer/tests/features/installer.feature b/installer/tests/features/installer.feature index b0b3626a..3f4cb03b 100644 --- a/installer/tests/features/installer.feature +++ b/installer/tests/features/installer.feature @@ -134,3 +134,15 @@ Feature: Whitaker lint library installer When the installer CLI is run Then the CLI exits successfully And experimental lint dry-run output is shown + + Scenario: Pin the suite to a ref in dry-run + Given the installer is invoked with dry-run and a pinned ref + When the installer CLI is run + Then the CLI exits successfully + And dry-run output shows the pinned ref + + Scenario: Refuse --ref inside a Whitaker workspace + Given the installer is invoked with a ref from a Whitaker workspace + When the installer CLI is run + Then the CLI exits with an error + And a ref-unsupported message is shown From cc10422e35bd0ae941d8c52d960360d4109b79eb Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 8 Jul 2026 23:39:32 +0200 Subject: [PATCH 07/26] Document the --ref pinned-install flag Add a "Pinning the suite" subsection to the user's guide covering what `--ref` accepts, that branch pins are not reproducible, the prebuilt-match behaviour, the interplay with `--no-update`, and the in-workspace refusal and detached-HEAD recovery. Note the feature in the README, and close out the execution plan's living sections with the smoke-test transcript. --- README.md | 4 + .../issue-271-ref-pinned-installation.md | 109 ++++++++++++++++-- docs/users-guide.md | 44 +++++++ 3 files changed, 146 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index b3c06533..74d865d4 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,10 @@ suite flows opt in with `--experimental` or the corresponding suite feature. - **Configurable thresholds** — Adjust limits like `module_max_lines.max_lines` to match your team's standards. - **Modular design** — Use individual lints or load the whole suite. +- **Pinnable suite** — The standalone installer's `--ref ` builds and + stages the lint suite from an exact commit of `leynos/whitaker`, for + reproducible lint behaviour across CI. See + [Pinning the suite](docs/users-guide.md#pinning-the-suite). ## Project Status diff --git a/docs/execplans/issue-271-ref-pinned-installation.md b/docs/execplans/issue-271-ref-pinned-installation.md index a25c5b2c..f6ab993d 100644 --- a/docs/execplans/issue-271-ref-pinned-installation.md +++ b/docs/execplans/issue-271-ref-pinned-installation.md @@ -5,7 +5,7 @@ This ExecPlan (execution plan) is a living document. The sections `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. -Status: DRAFT +Status: IMPLEMENTED Issue: [leynos/whitaker#271](https://github.com/leynos/whitaker/issues/271) @@ -118,13 +118,26 @@ clone recovers from the detached checkout). - [x] (2026-07-08) Stage A: confirmed manifest `git_sha` is abbreviated (`git rev-parse --short HEAD`) and default-branch discovery command (`git rev-parse --abbrev-ref origin/HEAD`); findings recorded below. -- [ ] Stage B: red tests (CLI parsing, git ref operations, workspace - decision, prebuilt SHA validation, BDD scenarios). -- [ ] Stage C: implementation (CLI field, git helpers, workspace plumbing, - prebuilt validation, dry-run output). -- [ ] Stage D: docs (users-guide, README, `--help` text), refactor, full - gates, commit-by-commit delivery. -- [ ] Manual end-to-end validation transcript recorded under `Artifacts`. +- [x] (2026-07-08) Stage B/C slice 1: `--ref` CLI field. Red (E0609 no field + `git_ref`) → green (26 cli tests). Commit 6e36c2d. +- [x] (2026-07-08) Stage B/C slice 2: git helpers `resolve_commit`, + `fetch_ref`, `checkout_detached`, `ensure_default_branch`. Red (E0425 missing + functions) → green (8 real-git TempDir tests). Commit a7de0ba. +- [x] (2026-07-08) Stage B/C slice 3: workspace plumbing — `WorkspaceCheckout`, + `ensure_workspace(dirs, update, git_ref)`, `RefUnsupported`, pin-on-miss + fetch, `ensure_default_branch` before every update. Red (E0425/E0599) → green. + Commit ded3ac5. +- [x] (2026-07-08) Stage B/C slice 4: prebuilt `expected_git_sha` validation + (prefix-tolerant) threaded through the fast path. Red (mismatch returned + Success) → green (3 unit + 1 BDD). Commit c65281c. +- [x] (2026-07-08) Stage B/C slice 5: dry-run `git_ref` field, pinning progress + message, 2 CLI BDD scenarios (pinned dry-run, refuse in workspace). Red + (missing "Pinned ref:" line) → green. Commit 4caa7d6. +- [x] (2026-07-08) Stage D: documented `--ref` in `docs/users-guide.md` + (new "Pinning the suite" subsection) and `README.md`; `--help` examples + refreshed in `cli.rs`. Full gates green. +- [x] (2026-07-08) Manual end-to-end validation transcript recorded under + `Artifacts`; platform clone restored to `main` by the recovery run. ## Surprises & discoveries @@ -184,10 +197,50 @@ clone recovers from the detached checkout). Rationale: checking out a ref in the user's own working tree could destroy uncommitted work; refusing is the only safe behaviour. Date/Author: 2026-07-08, agent. +- Decision (implementation): the prebuilt SHA match is *prefix-tolerant in one + direction* — the resolved full commit must `starts_with` the manifest's + abbreviated `git_sha` (confirmed abbreviated in Stage A). Exact equality would + never match, since the manifest stores a short SHA and `resolve_commit` + returns the full one. + Date/Author: 2026-07-08, agent. +- Decision (process): BDD scenarios were landed with their related + implementation slice (prebuilt-mismatch with slice 4; pinned dry-run and the + in-workspace refusal with slice 5) rather than as a separate Stage B pass, so + each commit stays self-consistent and green. Every new test still failed first + for the intended reason (recorded in Progress and Artifacts). + Date/Author: 2026-07-08, agent. +- Decision (implementation): the user sees two messages when pinning — a + pre-checkout `Pinning Whitaker suite to REF...` progress line and, after the + detached checkout resolves, a `Pinned Whitaker suite to REF (SHORT_SHA).` + confirmation. The plan's single "Pinning ... (SHORT_SHA)" line was split + because the short SHA is only known after the pin resolves. + Date/Author: 2026-07-08, agent. ## Outcomes & retrospective -To be completed at milestones and at the end. +Delivered across six commits on `issue-271-ref-pinned-installation`: the CLI +`--ref` field, four git helpers, workspace pinning plumbing +(`WorkspaceCheckout` + `ensure_workspace(dirs, update, git_ref)`), prebuilt +`expected_git_sha` gating, dry-run/progress messaging, and documentation. All +gates (`check-fmt`, `lint`, `test`, `markdownlint`) passed before each commit; +the final `make test` ran 1472 tests (1472 passed, 3 skipped). Every new test +failed first for the intended reason (see `Artifacts`). The manual smoke test +exercised the pin, the in-workspace refusal, the toolchain-embedded staged +filename, and the detached-HEAD recovery end-to-end against tag `v0.2.4`. + +Scope stayed within tolerances: the implementation touched the CLI, git, +workspace, prebuilt, install-flow, output, error, and main modules (well under +the 12-file limit) with no new dependencies and only the one sanctioned public +signature change (`ensure_workspace`). Rolling remains the default; only the +explicit-pin part of issue #271 is implemented, and the version-matched default +proposed there was deliberately not built (Decision Log). + +Lessons: (1) confirming the manifest SHA width in Stage A was load-bearing — the +match is prefix-tolerant, and exact equality would have silently disabled the +prebuilt fast path for every pin. (2) Adding a field to `PrebuiltConfig` and the +context structs surfaced literal constructors in the binary's own test modules +(`install_flow/tests.rs`, `tests/fast_path.rs`) that `cargo test --lib` did not +compile; an early `cargo check --all-targets` sweep catches these. ## Context and orientation @@ -429,8 +482,42 @@ a red gate. ## Artifacts and notes -Red/Green transcripts and the manual smoke-test transcript will be added -here as the work proceeds. +Red/Green evidence (gate logs under `/tmp/*-whitaker-issue-271*.out`): + +- Slice 1 (CLI): red — `E0609: no field git_ref on InstallArgs`; green 26 cli + tests pass. +- Slice 2 (git helpers): red — `E0425: cannot find function resolve_commit` + (and the other three helpers); green 8 tests pass against real git TempDir + repos. +- Slice 3 (workspace): red — `E0425: cannot find function ensure_ref_allowed` + plus `E0599: no variant named RefUnsupported`; green 20 workspace tests pass. +- Slice 4 (prebuilt): red — `expected_git_sha_mismatch_returns_fallback` got + `Success` when validation was absent; green 3 unit tests + the + `Prebuilt is skipped when the pinned ref does not match` BDD scenario pass. +- Slice 5 (dry-run/messaging): red — `dry_run_display_includes_ref_when_pinned` + failed (no `Pinned ref:` line); green 2 output tests + the pinned dry-run and + in-workspace refusal CLI BDD scenarios pass. +- Full suite green each slice: final `make test` reported 1472 tests run, + 1472 passed, 3 skipped. + +Manual smoke test (Stage D), run against tag `v0.2.4` +(commit `8512ee63a212abd175c31501a57e10a713881873`): + +- `whitaker-installer --dry-run --ref v0.2.4` (from the worktree) printed + `Pinned ref: v0.2.4` alongside the workspace root and toolchain. +- `whitaker-installer --ref v0.2.4 --skip-deps` (from the worktree, itself a + Whitaker workspace) exited 1 with `cannot pin --ref v0.2.4: the current + directory is itself a Whitaker workspace...`. +- `whitaker-installer --ref v0.2.4 --build-only --skip-deps --skip-wrapper + --target-dir /tmp/whitaker-ref-smoke` (run from `/tmp/smoke-cwd`, outside any + workspace) updated then pinned the platform clone to a detached HEAD at + `8512ee63a212`, built the suite under that commit's `nightly-2025-09-18` + toolchain, and staged `libwhitaker_suite@nightly-2025-09-18.so` (filename + embeds the pinned toolchain channel). Exit 0. +- Recovery: a subsequent un-pinned `whitaker-installer --skip-deps + --skip-wrapper` reattached the detached clone to `refs/heads/main` before + pulling (no detached-HEAD error), then used the prebuilt fast path. Exit 0. + The platform clone at `~/.local/share/whitaker` is back on `main`. ## Interfaces and dependencies diff --git a/docs/users-guide.md b/docs/users-guide.md index a84c8333..454a7fdd 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -75,6 +75,9 @@ environment-variable workaround. - `--skip-wrapper` — Skip wrapper script generation (prints `DYLINT_LIBRARY_PATH` instructions instead) - `--no-update` — Don't update existing repository clone +- `--ref ` — Build and stage the lint suite from a specific commit + of `leynos/whitaker` instead of the rolling default (see + [Pinning the suite](#pinning-the-suite)) ### Adding Whitaker to a project @@ -174,6 +177,47 @@ build experimental lints as individual libraries, combine it with require `--experimental`; without that opt-in the installer rejects the request before building anything. + +### Pinning the suite + +By default the standalone installer tracks the moving `leynos/whitaker` default +branch (and its rolling prebuilt artefacts), so a push to `main` can change +lint behaviour on the next install. To hold the lint suite steady, pin it to an +explicit commit with `--ref`: + +```sh +whitaker-installer --ref v0.2.5 # a release tag +whitaker-installer --ref 1a2b3c4d # a commit SHA +``` + +`--ref` accepts any commit-ish that exists in the repository — a tag, a full or +abbreviated commit SHA, or a branch name. Tags and commit SHAs give +reproducible pins. A branch name is resolved to whatever commit it points at +when the installer runs, so it is *not* a reproducible pin; prefer a tag or SHA +for CI. Note that the installer version and the suite version are independent: +`cargo binstall whitaker-installer@0.2.5` pins only the installer, whereas +`--ref` pins the suite the installer builds. Tags exist for released versions, +but any commit-ish the repository contains is accepted; an unknown ref fails +with the underlying git error and stages nothing. + +The pin composes with the other flags: + +- **Prebuilt artefacts.** The rolling prebuilt archive only ever carries one + commit's libraries, so a pinned install reuses it only when the resolved + commit matches the archive's recorded commit; otherwise the installer builds + the pinned commit from source. Because the pinned commit supplies its own + `rust-toolchain.toml`, the staged libraries are named for that commit's + toolchain channel. +- **`--no-update`.** With `--ref --no-update` the installer resolves the ref + against the existing clone without fetching, and only fetches when the ref + cannot be resolved locally. This keeps offline, pinned installs working. + +`--ref` is refused when the current directory is itself a Whitaker workspace, +because checking out a commit there could destroy uncommitted work. Run the +installer from outside a checkout to pin the suite. A pinned install leaves the +managed clone on a detached commit; a later un-pinned `whitaker-installer` run +reattaches it to the default branch automatically before updating. + ## Lint Configuration Configure lint behaviour in `dylint.toml` at the workspace root: From 15323e07cd354eff1e88050f35905b7525583ddc Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 18 Jul 2026 18:53:24 +0200 Subject: [PATCH 08/26] Address pinned installer review feedback (#271) Centralize checked Git commands and separate workspace progress reporting from checkout mutation. Split real-Git and pinned-ref test support into focused modules so touched files remain below the 400-line limit. Parameterize duplicate prebuilt success cases, share BDD output handling, and restore test and scenario attributes lost during the rebase. Repair the branch documentation so spelling, Markdown, and helper ownership contracts pass repository gates. --- docs/developers-guide.md | 19 ++ .../issue-271-ref-pinned-installation.md | 12 +- docs/users-guide.md | 1 - installer/src/cli_tests.rs | 4 + installer/src/git.rs | 255 ++---------------- installer/src/git_tests.rs | 172 ++++++++++++ installer/src/main.rs | 45 +--- installer/src/prebuilt_tests.rs | 21 +- installer/src/workspace_progress.rs | 77 ++++++ installer/tests/behaviour_cli/support.rs | 105 ++------ .../tests/behaviour_cli/support/pinned_ref.rs | 58 ++++ installer/tests/behaviour_prebuilt.rs | 7 + 12 files changed, 408 insertions(+), 368 deletions(-) create mode 100644 installer/src/git_tests.rs create mode 100644 installer/src/workspace_progress.rs create mode 100644 installer/tests/behaviour_cli/support/pinned_ref.rs diff --git a/docs/developers-guide.md b/docs/developers-guide.md index fb6add6d..5a943c98 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -1872,6 +1872,25 @@ This skips building entirely, providing faster lint runs during development. set of focused private helpers. Understanding them is useful when extending the installation pipeline. + +#### Private helper boundaries + +Installer helpers remain private to the module that owns their side effects. +`git::run_git_checked` is only for Git commands whose successful output is +discarded and whose non-zero exit status maps directly to `InstallerError::Git`; +commands that inspect output or interpret a non-zero status must continue to use +`run_git_with_timeout` directly. Real-Git regression fixtures and tests belong +in `git_tests.rs`, keeping the production adapter focused. The helpers in +`workspace_progress.rs` only render operator messages at the CLI edge and must +not clone, update, or pin the checkout themselves. + +Behaviour-test support follows the same ownership rule. +`behaviour_cli::support::output_for_assertions` combines scenario-skip handling +with borrowing the captured process output. Assertion-specific expectations +remain in their step functions. Pinned-ref scenario setup and assertions stay +in `support/pinned_ref.rs`; support for other behaviour suites should stay local +unless multiple suites need exactly the same contract. + #### `resolve_additional_components` ```rust diff --git a/docs/execplans/issue-271-ref-pinned-installation.md b/docs/execplans/issue-271-ref-pinned-installation.md index f6ab993d..331bb094 100644 --- a/docs/execplans/issue-271-ref-pinned-installation.md +++ b/docs/execplans/issue-271-ref-pinned-installation.md @@ -86,7 +86,7 @@ clone recovers from the detached checkout). before pulling (Stage C step 3). This is a required behaviour, tested. - Risk: the prebuilt manifest's `git_sha` format (full versus abbreviated SHA) is not yet confirmed, so SHA comparison against a resolved ref could - mis-match. + mismatch. Severity: medium. Likelihood: medium. Mitigation: Stage A confirms the format from `installer/src/artefact/git_sha.rs` and the release workflow; comparison @@ -122,11 +122,13 @@ clone recovers from the detached checkout). `git_ref`) → green (26 cli tests). Commit 6e36c2d. - [x] (2026-07-08) Stage B/C slice 2: git helpers `resolve_commit`, `fetch_ref`, `checkout_detached`, `ensure_default_branch`. Red (E0425 missing - functions) → green (8 real-git TempDir tests). Commit a7de0ba. + functions) → green (8 real-git TempDir tests). See + . - [x] (2026-07-08) Stage B/C slice 3: workspace plumbing — `WorkspaceCheckout`, `ensure_workspace(dirs, update, git_ref)`, `RefUnsupported`, pin-on-miss fetch, `ensure_default_branch` before every update. Red (E0425/E0599) → green. - Commit ded3ac5. + See + . - [x] (2026-07-08) Stage B/C slice 4: prebuilt `expected_git_sha` validation (prefix-tolerant) threaded through the fast path. Red (mismatch returned Success) → green (3 unit + 1 BDD). Commit c65281c. @@ -207,7 +209,7 @@ clone recovers from the detached checkout). implementation slice (prebuilt-mismatch with slice 4; pinned dry-run and the in-workspace refusal with slice 5) rather than as a separate Stage B pass, so each commit stays self-consistent and green. Every new test still failed first - for the intended reason (recorded in Progress and Artifacts). + for the intended reason (recorded in Progress and Artefacts). Date/Author: 2026-07-08, agent. - Decision (implementation): the user sees two messages when pinning — a pre-checkout `Pinning Whitaker suite to REF...` progress line and, after the @@ -250,7 +252,7 @@ publishes to crates.io as `whitaker-installer`. Key modules, all paths relative to the repository root: - `installer/src/cli.rs` — clap definitions. `InstallArgs` is the flag - struct; it has a hand-written `Default` impl and is flattened into `Cli` + struct; it has a handwritten `Default` impl and is flattened into `Cli` for the default (no-subcommand) install. Tests in `installer/src/cli_tests.rs`. - `installer/src/main.rs` — orchestration. `run_install` performs: (1) diff --git a/docs/users-guide.md b/docs/users-guide.md index 454a7fdd..83450c2a 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -177,7 +177,6 @@ build experimental lints as individual libraries, combine it with require `--experimental`; without that opt-in the installer rejects the request before building anything. - ### Pinning the suite By default the standalone installer tracks the moving `leynos/whitaker` default diff --git a/installer/src/cli_tests.rs b/installer/src/cli_tests.rs index 4a685be3..f733edfa 100644 --- a/installer/src/cli_tests.rs +++ b/installer/src/cli_tests.rs @@ -22,11 +22,13 @@ fn cli_parses_defaults() { assert!(cli.install.git_ref.is_none()); } +#[test] fn cli_parses_ref_flag_bare() { let cli = Cli::parse_from(["whitaker-installer", "--ref", "v0.2.5"]); assert_eq!(cli.install.git_ref.as_deref(), Some("v0.2.5")); } +#[test] fn cli_parses_ref_flag_under_install_subcommand() { let cli = Cli::parse_from(["whitaker-installer", "install", "--ref", "1a2b3c4d"]); match cli.command { @@ -36,6 +38,8 @@ fn cli_parses_ref_flag_under_install_subcommand() { _ => panic!("expected Install command"), } } + +#[test] fn cli_parses_target_dir() { let cli = Cli::parse_from(["whitaker-installer", "-t", "/tmp/dylint"]); assert_eq!( diff --git a/installer/src/git.rs b/installer/src/git.rs index 8241d12d..5dbfd8d7 100644 --- a/installer/src/git.rs +++ b/installer/src/git.rs @@ -28,21 +28,11 @@ pub fn clone_repository(target: &Utf8Path) -> Result<()> { std::fs::create_dir_all(parent)?; } - let output = run_git_with_timeout( + run_git_checked( &["clone", WHITAKER_REPO_URL, target.as_str()], None, "clone", - )?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(InstallerError::Git { - operation: "clone", - message: stderr.trim().to_owned(), - }); - } - - Ok(()) + ) } /// Updates an existing Whitaker repository by pulling the latest changes. @@ -54,17 +44,7 @@ pub fn clone_repository(target: &Utf8Path) -> Result<()> { /// /// Returns `InstallerError::Git` if the pull fails or times out. pub fn update_repository(repo: &Utf8Path) -> Result<()> { - let output = run_git_with_timeout(&["pull"], Some(repo), "pull")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(InstallerError::Git { - operation: "pull", - message: stderr.trim().to_owned(), - }); - } - - Ok(()) + run_git_checked(&["pull"], Some(repo), "pull") } /// Resolves a commit-ish (SHA, tag, or branch) to a full commit SHA. @@ -102,18 +82,7 @@ pub fn resolve_commit(repo: &Utf8Path, refspec: &str) -> Result { /// /// Returns `InstallerError::Git` if the fetch fails or times out. pub fn fetch_ref(repo: &Utf8Path, refspec: &str) -> Result<()> { - let output = - run_git_with_timeout(&["fetch", "origin", refspec, "--tags"], Some(repo), "fetch")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(InstallerError::Git { - operation: "fetch", - message: stderr.trim().to_owned(), - }); - } - - Ok(()) + run_git_checked(&["fetch", "origin", refspec, "--tags"], Some(repo), "fetch") } /// Checks out a commit as a detached HEAD. @@ -125,17 +94,7 @@ pub fn fetch_ref(repo: &Utf8Path, refspec: &str) -> Result<()> { /// /// Returns `InstallerError::Git` if the checkout fails or times out. pub fn checkout_detached(repo: &Utf8Path, commit: &str) -> Result<()> { - let output = run_git_with_timeout(&["checkout", "--detach", commit], Some(repo), "checkout")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(InstallerError::Git { - operation: "checkout", - message: stderr.trim().to_owned(), - }); - } - - Ok(()) + run_git_checked(&["checkout", "--detach", commit], Some(repo), "checkout") } /// Reattaches the repository to its default branch when HEAD is detached. @@ -162,16 +121,7 @@ pub fn ensure_default_branch(repo: &Utf8Path) -> Result<()> { } let branch = default_branch_name(repo)?; - let output = run_git_with_timeout(&["checkout", &branch], Some(repo), "checkout")?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(InstallerError::Git { - operation: "checkout", - message: stderr.trim().to_owned(), - }); - } - - Ok(()) + run_git_checked(&["checkout", &branch], Some(repo), "checkout") } /// Discovers the remote default branch name (without the `origin/` prefix). @@ -211,6 +161,24 @@ fn read_default_branch(repo: &Utf8Path) -> Result> { .filter(|branch| !branch.is_empty())) } +/// Runs a Git command whose successful output is intentionally discarded. +fn run_git_checked( + args: &[&str], + working_dir: Option<&Utf8Path>, + operation: &'static str, +) -> Result<()> { + let output = run_git_with_timeout(args, working_dir, operation)?; + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + Err(InstallerError::Git { + operation, + message: stderr.trim().to_owned(), + }) +} + /// Runs a git command with a timeout. /// /// Returns the command output if it completes within the timeout, or an error @@ -293,176 +261,5 @@ fn run_git_with_timeout( } #[cfg(test)] -mod tests { - use super::*; - use camino::Utf8PathBuf; - use std::process::Command; - use tempfile::TempDir; - - /// Run a git command in `dir`, asserting success, and return trimmed stdout. - fn git(dir: &Utf8Path, args: &[&str]) -> String { - let output = Command::new("git") - .args(args) - .current_dir(dir.as_std_path()) - .output() - .expect("failed to spawn git"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8_lossy(&output.stdout).trim().to_owned() - } - - /// Commit the given file content in `dir` and return the resulting SHA. - fn commit_file(dir: &Utf8Path, name: &str, contents: &str, message: &str) -> String { - std::fs::write(dir.join(name).as_std_path(), contents).expect("write fixture file"); - git(dir, &["add", "."]); - git( - dir, - &[ - "-c", - "user.name=Test", - "-c", - "user.email=test@example.com", - "-c", - "commit.gpgsign=false", - "commit", - "-m", - message, - ], - ); - git(dir, &["rev-parse", "HEAD"]) - } - - /// A source repository plus a clone of it, with recorded commit SHAs. - struct GitFixture { - _source: TempDir, - _clone: TempDir, - clone: Utf8PathBuf, - first: String, - second: String, - } - - /// Build a source repo (two commits, tag `v1` on the first) and clone it. - fn git_fixture() -> GitFixture { - let source = TempDir::new().expect("source temp dir"); - let source_path = - Utf8PathBuf::try_from(source.path().to_owned()).expect("UTF-8 source path"); - git(&source_path, &["init", "-b", "main"]); - let first = commit_file(&source_path, "a.txt", "one", "first"); - git(&source_path, &["tag", "v1"]); - let second = commit_file(&source_path, "b.txt", "two", "second"); - - let clone = TempDir::new().expect("clone temp dir"); - let clone_path = Utf8PathBuf::try_from(clone.path().to_owned()).expect("UTF-8 clone path"); - let output = Command::new("git") - .args(["clone", source_path.as_str(), clone_path.as_str()]) - .output() - .expect("failed to spawn git clone"); - assert!( - output.status.success(), - "git clone failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - - GitFixture { - _source: source, - _clone: clone, - clone: clone_path, - first, - second, - } - } - - #[test] - fn resolve_commit_resolves_tag_branch_and_sha() { - let fx = git_fixture(); - assert_eq!( - resolve_commit(&fx.clone, "v1").expect("resolve tag"), - fx.first - ); - assert_eq!( - resolve_commit(&fx.clone, "main").expect("resolve branch"), - fx.second - ); - assert_eq!( - resolve_commit(&fx.clone, &fx.second).expect("resolve sha"), - fx.second - ); - } - - #[test] - fn resolve_commit_errors_on_garbage() { - let fx = git_fixture(); - let err = resolve_commit(&fx.clone, "definitely-not-a-ref").expect_err("expected error"); - assert!(matches!(err, InstallerError::Git { .. }), "got {err:?}"); - } - - #[test] - fn checkout_detached_leaves_head_at_commit() { - let fx = git_fixture(); - checkout_detached(&fx.clone, &fx.first).expect("checkout detached"); - assert_eq!(git(&fx.clone, &["rev-parse", "HEAD"]), fx.first); - // A detached HEAD has no symbolic ref. - let symbolic = Command::new("git") - .args(["symbolic-ref", "-q", "HEAD"]) - .current_dir(fx.clone.as_std_path()) - .output() - .expect("spawn symbolic-ref"); - assert!(!symbolic.status.success(), "expected detached HEAD"); - } - - #[test] - fn ensure_default_branch_reattaches_so_update_succeeds() { - let fx = git_fixture(); - checkout_detached(&fx.clone, &fx.first).expect("checkout detached"); - ensure_default_branch(&fx.clone).expect("reattach to default branch"); - assert_eq!(git(&fx.clone, &["symbolic-ref", "HEAD"]), "refs/heads/main"); - // A pull now succeeds because HEAD is on a branch again. - update_repository(&fx.clone).expect("update after reattach"); - } - - #[test] - fn ensure_default_branch_is_noop_on_a_branch() { - let fx = git_fixture(); - ensure_default_branch(&fx.clone).expect("noop on branch"); - assert_eq!(git(&fx.clone, &["symbolic-ref", "HEAD"]), "refs/heads/main"); - } - - #[test] - fn fetch_ref_retrieves_a_new_tag() { - let fx = git_fixture(); - // Add a third commit and tag it in the source, after the clone was made. - let source = Utf8PathBuf::try_from(fx._source.path().to_owned()).expect("UTF-8 path"); - let third = commit_file(&source, "c.txt", "three", "third"); - git(&source, &["tag", "v2"]); - - // The clone cannot resolve the new tag until it fetches. - assert!(resolve_commit(&fx.clone, "v2").is_err()); - fetch_ref(&fx.clone, "v2").expect("fetch new tag"); - assert_eq!(resolve_commit(&fx.clone, "v2").expect("resolve v2"), third); - } - - #[test] - fn clone_repository_error_includes_operation() { - let err = InstallerError::Git { - operation: "clone", - message: "test error".to_owned(), - }; - let msg = err.to_string(); - assert!(msg.contains("clone")); - assert!(msg.contains("test error")); - } - - #[test] - fn update_repository_error_includes_operation() { - let err = InstallerError::Git { - operation: "pull", - message: "not a git repository".to_owned(), - }; - let msg = err.to_string(); - assert!(msg.contains("pull")); - assert!(msg.contains("not a git repository")); - } -} +#[path = "git_tests.rs"] +mod tests; diff --git a/installer/src/git_tests.rs b/installer/src/git_tests.rs new file mode 100644 index 00000000..e917e11d --- /dev/null +++ b/installer/src/git_tests.rs @@ -0,0 +1,172 @@ +//! Real-Git regression tests for clone updates and pinned checkouts. + +use super::*; +use camino::Utf8PathBuf; +use std::process::Command; +use tempfile::TempDir; + +/// Run a Git command in `dir`, asserting success, and return trimmed stdout. +fn git(dir: &Utf8Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(dir.as_std_path()) + .output() + .expect("failed to spawn git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_owned() +} + +/// Commit the given file content in `dir` and return the resulting SHA. +fn commit_file(dir: &Utf8Path, name: &str, contents: &str, message: &str) -> String { + std::fs::write(dir.join(name).as_std_path(), contents).expect("write fixture file"); + git(dir, &["add", "."]); + git( + dir, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + message, + ], + ); + git(dir, &["rev-parse", "HEAD"]) +} + +/// A source repository plus a clone of it, with recorded commit SHAs. +struct GitFixture { + _source: TempDir, + _clone: TempDir, + clone: Utf8PathBuf, + first: String, + second: String, +} + +/// Build a source repo (two commits, tag `v1` on the first) and clone it. +fn git_fixture() -> GitFixture { + let source = TempDir::new().expect("source temp dir"); + let source_path = Utf8PathBuf::try_from(source.path().to_owned()).expect("UTF-8 source path"); + git(&source_path, &["init", "-b", "main"]); + let first = commit_file(&source_path, "a.txt", "one", "first"); + git(&source_path, &["tag", "v1"]); + let second = commit_file(&source_path, "b.txt", "two", "second"); + + let clone = TempDir::new().expect("clone temp dir"); + let clone_path = Utf8PathBuf::try_from(clone.path().to_owned()).expect("UTF-8 clone path"); + let output = Command::new("git") + .args(["clone", source_path.as_str(), clone_path.as_str()]) + .output() + .expect("failed to spawn git clone"); + assert!( + output.status.success(), + "git clone failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + GitFixture { + _source: source, + _clone: clone, + clone: clone_path, + first, + second, + } +} + +#[test] +fn resolve_commit_resolves_tag_branch_and_sha() { + let fx = git_fixture(); + assert_eq!( + resolve_commit(&fx.clone, "v1").expect("resolve tag"), + fx.first + ); + assert_eq!( + resolve_commit(&fx.clone, "main").expect("resolve branch"), + fx.second + ); + assert_eq!( + resolve_commit(&fx.clone, &fx.second).expect("resolve sha"), + fx.second + ); +} + +#[test] +fn resolve_commit_errors_on_garbage() { + let fx = git_fixture(); + let err = resolve_commit(&fx.clone, "definitely-not-a-ref").expect_err("expected error"); + assert!(matches!(err, InstallerError::Git { .. }), "got {err:?}"); +} + +#[test] +fn checkout_detached_leaves_head_at_commit() { + let fx = git_fixture(); + checkout_detached(&fx.clone, &fx.first).expect("checkout detached"); + assert_eq!(git(&fx.clone, &["rev-parse", "HEAD"]), fx.first); + // A detached HEAD has no symbolic ref. + let symbolic = Command::new("git") + .args(["symbolic-ref", "-q", "HEAD"]) + .current_dir(fx.clone.as_std_path()) + .output() + .expect("spawn symbolic-ref"); + assert!(!symbolic.status.success(), "expected detached HEAD"); +} + +#[test] +fn ensure_default_branch_reattaches_so_update_succeeds() { + let fx = git_fixture(); + checkout_detached(&fx.clone, &fx.first).expect("checkout detached"); + ensure_default_branch(&fx.clone).expect("reattach to default branch"); + assert_eq!(git(&fx.clone, &["symbolic-ref", "HEAD"]), "refs/heads/main"); + // A pull now succeeds because HEAD is on a branch again. + update_repository(&fx.clone).expect("update after reattach"); +} + +#[test] +fn ensure_default_branch_is_noop_on_a_branch() { + let fx = git_fixture(); + ensure_default_branch(&fx.clone).expect("noop on branch"); + assert_eq!(git(&fx.clone, &["symbolic-ref", "HEAD"]), "refs/heads/main"); +} + +#[test] +fn fetch_ref_retrieves_a_new_tag() { + let fx = git_fixture(); + // Add a third commit and tag it in the source, after the clone was made. + let source = Utf8PathBuf::try_from(fx._source.path().to_owned()).expect("UTF-8 path"); + let third = commit_file(&source, "c.txt", "three", "third"); + git(&source, &["tag", "v2"]); + + // The clone cannot resolve the new tag until it fetches. + assert!(resolve_commit(&fx.clone, "v2").is_err()); + fetch_ref(&fx.clone, "v2").expect("fetch new tag"); + assert_eq!(resolve_commit(&fx.clone, "v2").expect("resolve v2"), third); +} + +#[test] +fn clone_repository_error_includes_operation() { + let err = InstallerError::Git { + operation: "clone", + message: "test error".to_owned(), + }; + let msg = err.to_string(); + assert!(msg.contains("clone")); + assert!(msg.contains("test error")); +} + +#[test] +fn update_repository_error_includes_operation() { + let err = InstallerError::Git { + operation: "pull", + message: "not a git repository".to_owned(), + }; + let msg = err.to_string(); + assert!(msg.contains("pull")); + assert!(msg.contains("not a git repository")); +} diff --git a/installer/src/main.rs b/installer/src/main.rs index e3227e88..7d551d0d 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -6,6 +6,7 @@ mod install_flow; mod staged_suite; +mod workspace_progress; #[cfg(test)] use crate::install_flow::ensure_dylint_tools_with_options; @@ -223,54 +224,16 @@ fn ensure_whitaker_workspace( dirs: &dyn BaseDirs, stderr: &mut dyn Write, ) -> Result { - use whitaker_installer::workspace::{ - WorkspaceAction, clone_directory, decide_workspace_action, ensure_workspace, - }; + use whitaker_installer::workspace::ensure_workspace; let git_ref = args.git_ref.as_deref(); - - if !args.quiet - && let Some(clone_dir) = clone_directory(dirs) - && let Some(cwd) = std::env::current_dir() - .ok() - .and_then(|p| Utf8PathBuf::try_from(p).ok()) - { - match decide_workspace_action(&cwd, &clone_dir, !args.no_update) { - WorkspaceAction::CloneTo(dir) => { - write_stderr_line(stderr, format!("Cloning Whitaker repository to {dir}...")); - } - WorkspaceAction::UpdateAt(dir) => { - write_stderr_line(stderr, format!("Updating Whitaker repository at {dir}...")); - } - WorkspaceAction::UseCurrentDir(_) | WorkspaceAction::UseExisting(_) => {} - } - - if let Some(git_ref) = git_ref { - write_stderr_line(stderr, format!("Pinning Whitaker suite to {git_ref}...")); - } - } + workspace_progress::report_workspace_progress(args, dirs, stderr); let checkout = ensure_workspace(dirs, !args.no_update, git_ref)?; - if !args.quiet - && let Some(commit) = &checkout.pinned_commit - { - write_stderr_line( - stderr, - format!( - "Pinned Whitaker suite to {} ({}).", - git_ref.unwrap_or(commit.as_str()), - short_commit(commit) - ), - ); - } + workspace_progress::report_pinned_checkout(args.quiet, git_ref, &checkout, stderr); Ok(checkout) } -/// Abbreviates a commit SHA to its leading 12 characters for display. -fn short_commit(commit: &str) -> &str { - let end = commit.len().min(12); - &commit[..end] -} /// Detects or overrides the toolchain, then verifies it is installed. fn resolve_toolchain( workspace_root: &Utf8Path, diff --git a/installer/src/prebuilt_tests.rs b/installer/src/prebuilt_tests.rs index 618b8792..d6a36b84 100644 --- a/installer/src/prebuilt_tests.rs +++ b/installer/src/prebuilt_tests.rs @@ -15,6 +15,7 @@ const MATCHING_COMMIT: &str = "abc1234000000000000000000000000000000ab"; /// A full 40-hex commit SHA that does not share the manifest's prefix. const MISMATCHED_COMMIT: &str = "deadbeef00000000000000000000000000000000"; + fn base_config(destination_dir: &Utf8Path) -> PrebuiltConfig<'_> { PrebuiltConfig { target: TARGET, @@ -45,6 +46,7 @@ fn success_mocks() -> (MockArtefactDownloader, MockArtefactExtractor) { (downloader, extractor) } +#[test] fn expected_git_sha_mismatch_returns_fallback() { let (_temp, destination_dir) = destination_dir(); let config = PrebuiltConfig { @@ -62,10 +64,13 @@ fn expected_git_sha_mismatch_returns_fallback() { } } -fn expected_git_sha_match_returns_success() { +#[rstest] +#[case::matching_commit(Some(MATCHING_COMMIT))] +#[case::unpinned(None)] +fn matching_or_unpinned_git_sha_returns_success(#[case] expected_git_sha: Option<&str>) { let (_temp, destination_dir) = destination_dir(); let config = PrebuiltConfig { - expected_git_sha: Some(MATCHING_COMMIT), + expected_git_sha, ..base_config(&destination_dir) }; let (downloader, extractor) = success_mocks(); @@ -77,18 +82,6 @@ fn expected_git_sha_match_returns_success() { ); } -fn expected_git_sha_none_leaves_behaviour_unchanged() { - let (_temp, destination_dir) = destination_dir(); - let config = base_config(&destination_dir); - assert!(config.expected_git_sha.is_none()); - let (downloader, extractor) = success_mocks(); - let mut stderr = Vec::new(); - let result = attempt_prebuilt_with(&config, &downloader, &extractor, &mut stderr); - assert!( - matches!(result, PrebuiltResult::Success { .. }), - "expected Success, got {result:?}" - ); -} fn destination_dir() -> (tempfile::TempDir, Utf8PathBuf) { let temp = tempfile::tempdir().expect("temp dir"); let root = Utf8PathBuf::try_from(temp.path().to_path_buf()).expect("UTF-8 path"); diff --git a/installer/src/workspace_progress.rs b/installer/src/workspace_progress.rs new file mode 100644 index 00000000..7ef1bee8 --- /dev/null +++ b/installer/src/workspace_progress.rs @@ -0,0 +1,77 @@ +//! Operator-facing progress messages for managed workspace operations. +//! +//! This module keeps CLI reporting separate from checkout mutation. It predicts +//! the action before installation and reports the resolved pin afterwards. + +use camino::Utf8PathBuf; +use std::io::Write; +use whitaker_installer::cli::InstallArgs; +use whitaker_installer::dirs::BaseDirs; +use whitaker_installer::output::write_stderr_line; +use whitaker_installer::workspace::{ + WorkspaceAction, WorkspaceCheckout, clone_directory, decide_workspace_action, +}; + +/// Reports the workspace action and requested pin before the operation starts. +pub(super) fn report_workspace_progress( + args: &InstallArgs, + dirs: &dyn BaseDirs, + stderr: &mut dyn Write, +) { + if args.quiet { + return; + } + let Some(clone_dir) = clone_directory(dirs) else { + return; + }; + let Some(cwd) = std::env::current_dir() + .ok() + .and_then(|path| Utf8PathBuf::try_from(path).ok()) + else { + return; + }; + + match decide_workspace_action(&cwd, &clone_dir, !args.no_update) { + WorkspaceAction::CloneTo(dir) => { + write_stderr_line(stderr, format!("Cloning Whitaker repository to {dir}...")); + } + WorkspaceAction::UpdateAt(dir) => { + write_stderr_line(stderr, format!("Updating Whitaker repository at {dir}...")); + } + WorkspaceAction::UseCurrentDir(_) | WorkspaceAction::UseExisting(_) => {} + } + + if let Some(git_ref) = &args.git_ref { + write_stderr_line(stderr, format!("Pinning Whitaker suite to {git_ref}...")); + } +} + +/// Reports the resolved commit after a pinned checkout succeeds. +pub(super) fn report_pinned_checkout( + quiet: bool, + git_ref: Option<&str>, + checkout: &WorkspaceCheckout, + stderr: &mut dyn Write, +) { + if quiet { + return; + } + let Some(commit) = &checkout.pinned_commit else { + return; + }; + + write_stderr_line( + stderr, + format!( + "Pinned Whitaker suite to {} ({}).", + git_ref.unwrap_or(commit.as_str()), + short_commit(commit) + ), + ); +} + +/// Abbreviates a commit SHA to its leading 12 characters for display. +fn short_commit(commit: &str) -> &str { + let end = commit.len().min(12); + &commit[..end] +} diff --git a/installer/tests/behaviour_cli/support.rs b/installer/tests/behaviour_cli/support.rs index c5665803..c6373524 100644 --- a/installer/tests/behaviour_cli/support.rs +++ b/installer/tests/behaviour_cli/support.rs @@ -1,5 +1,13 @@ //! Shared fixtures, command helpers, and assertions for CLI behaviour tests. +#[path = "support/pinned_ref.rs"] +mod pinned_ref; + +pub(super) use pinned_ref::{ + assert_pinned_ref_output_is_shown, assert_ref_unsupported_message_is_shown, + configure_dry_run_with_pinned_ref, configure_ref_in_workspace, +}; + use super::prebuilt_markers::PREBUILT_INSTALL_MARKER; use rstest::fixture; use std::cell::{Cell, Ref, RefCell}; @@ -144,63 +152,6 @@ pub(super) fn configure_dry_run_with_target_dir(cli_world: &CliWorld) { ]); } -/// The ref used by the pinned-install CLI scenarios. -pub(super) const SCENARIO_REF: &str = "v0.2.5"; - -pub(super) fn configure_dry_run_with_pinned_ref(cli_world: &CliWorld) { - let Some(channel) = ensure_required_toolchain_available(cli_world) else { - return; - }; - - let target_dir = setup_temp_dir(cli_world); - cli_world.args.replace(vec![ - "--dry-run".to_owned(), - "--toolchain".to_owned(), - channel, - "--target-dir".to_owned(), - target_dir, - "--ref".to_owned(), - SCENARIO_REF.to_owned(), - ]); -} - -pub(super) fn configure_ref_in_workspace(cli_world: &CliWorld) { - // The harness runs the installer from the whitaker workspace root, so a - // `--ref` must be refused. `--skip-deps` keeps the refusal ahead of any - // dependency installation, and no toolchain is required to reach it. - cli_world.args.replace(vec![ - "--ref".to_owned(), - SCENARIO_REF.to_owned(), - "--skip-deps".to_owned(), - ]); -} - -pub(super) fn assert_pinned_ref_output_is_shown(cli_world: &CliWorld) { - if cli_world.skip_assertions.get() { - return; - } - - let output = get_output(cli_world); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains(&format!("Pinned ref: {SCENARIO_REF}")), - "expected dry-run output to report the pinned ref, stderr: {stderr}" - ); -} - -pub(super) fn assert_ref_unsupported_message_is_shown(cli_world: &CliWorld) { - if cli_world.skip_assertions.get() { - return; - } - - let output = get_output(cli_world); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("the current directory is itself a Whitaker workspace"), - "expected a ref-unsupported refusal, stderr: {stderr}" - ); -} - pub(super) fn configure_dry_run_unknown_lint(cli_world: &CliWorld) { cli_world.args.replace(vec![ "--dry-run".to_owned(), @@ -272,12 +223,16 @@ pub(super) fn get_output(cli_world: &CliWorld) -> Ref<'_, Output> { Ref::map(output, |opt| opt.as_ref().expect("output not set")) } +/// Borrows command output when a scenario has not been skipped. +fn output_for_assertions(cli_world: &CliWorld) -> Option> { + (!cli_world.skip_assertions.get()).then(|| get_output(cli_world)) +} + fn assert_exit_status(cli_world: &CliWorld, expected_success: bool) { - if cli_world.skip_assertions.get() { + let Some(output) = output_for_assertions(cli_world) else { return; - } + }; - let output = get_output(cli_world); let stderr = String::from_utf8_lossy(&output.stderr); assert_eq!( output.status.success(), @@ -292,14 +247,13 @@ pub(super) fn assert_cli_exits_successfully(cli_world: &CliWorld) { } pub(super) fn assert_dry_run_output_is_shown(cli_world: &CliWorld) { - if cli_world.skip_assertions.get() { + let Some(output) = output_for_assertions(cli_world) else { return; - } + }; let toolchain = cli_world.toolchain.borrow(); let toolchain = toolchain.as_ref().expect("toolchain not set"); - let output = get_output(cli_world); let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("Dry run - no files will be modified")); @@ -324,11 +278,10 @@ pub(super) fn assert_cli_exits_with_error(cli_world: &CliWorld) { } pub(super) fn assert_unknown_lint_message_is_shown(cli_world: &CliWorld) { - if cli_world.skip_assertions.get() { + let Some(output) = output_for_assertions(cli_world) else { return; - } + }; - let output = get_output(cli_world); let stderr = String::from_utf8_lossy(&output.stderr); assert!( @@ -346,11 +299,10 @@ pub(super) fn assert_unknown_lint_message_is_shown(cli_world: &CliWorld) { } pub(super) fn assert_experimental_lint_opt_in_message_is_shown(cli_world: &CliWorld) { - if cli_world.skip_assertions.get() { + let Some(output) = output_for_assertions(cli_world) else { return; - } + }; - let output = get_output(cli_world); let stderr = String::from_utf8_lossy(&output.stderr); assert!( @@ -370,11 +322,10 @@ pub(super) fn assert_experimental_lint_opt_in_message_is_shown(cli_world: &CliWo } pub(super) fn assert_experimental_lint_dry_run_output_is_shown(cli_world: &CliWorld) { - if cli_world.skip_assertions.get() { + let Some(output) = output_for_assertions(cli_world) else { return; - } + }; - let output = get_output(cli_world); let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("Dry run - no files will be modified")); @@ -389,11 +340,10 @@ pub(super) fn assert_experimental_lint_dry_run_output_is_shown(cli_world: &CliWo } pub(super) fn assert_installation_succeeds_or_is_skipped(cli_world: &CliWorld) { - if cli_world.skip_assertions.get() { + let Some(output) = output_for_assertions(cli_world) else { return; - } + }; - let output = get_output(cli_world); assert!( output.status.success(), "installation failed: {}", @@ -402,11 +352,10 @@ pub(super) fn assert_installation_succeeds_or_is_skipped(cli_world: &CliWorld) { } pub(super) fn assert_suite_library_is_staged(cli_world: &CliWorld) { - if cli_world.skip_assertions.get() { + let Some(output) = output_for_assertions(cli_world) else { return; - } + }; - let output = get_output(cli_world); let stderr = String::from_utf8_lossy(&output.stderr); let channel = cli_world.toolchain.borrow(); let channel = channel.as_ref().expect("toolchain not set"); diff --git a/installer/tests/behaviour_cli/support/pinned_ref.rs b/installer/tests/behaviour_cli/support/pinned_ref.rs new file mode 100644 index 00000000..9cf9a151 --- /dev/null +++ b/installer/tests/behaviour_cli/support/pinned_ref.rs @@ -0,0 +1,58 @@ +//! Pinned-ref setup and assertions for installer CLI behaviour scenarios. + +use super::{CliWorld, ensure_required_toolchain_available, output_for_assertions, setup_temp_dir}; + +/// The ref used by the pinned-install CLI scenarios. +const SCENARIO_REF: &str = "v0.2.5"; + +pub(crate) fn configure_dry_run_with_pinned_ref(cli_world: &CliWorld) { + let Some(channel) = ensure_required_toolchain_available(cli_world) else { + return; + }; + + let target_dir = setup_temp_dir(cli_world); + cli_world.args.replace(vec![ + "--dry-run".to_owned(), + "--toolchain".to_owned(), + channel, + "--target-dir".to_owned(), + target_dir, + "--ref".to_owned(), + SCENARIO_REF.to_owned(), + ]); +} + +pub(crate) fn configure_ref_in_workspace(cli_world: &CliWorld) { + // The harness runs the installer from the Whitaker workspace root, so a + // `--ref` must be refused. `--skip-deps` keeps the refusal ahead of any + // dependency installation, and no toolchain is required to reach it. + cli_world.args.replace(vec![ + "--ref".to_owned(), + SCENARIO_REF.to_owned(), + "--skip-deps".to_owned(), + ]); +} + +pub(crate) fn assert_pinned_ref_output_is_shown(cli_world: &CliWorld) { + let Some(output) = output_for_assertions(cli_world) else { + return; + }; + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(&format!("Pinned ref: {SCENARIO_REF}")), + "expected dry-run output to report the pinned ref, stderr: {stderr}" + ); +} + +pub(crate) fn assert_ref_unsupported_message_is_shown(cli_world: &CliWorld) { + let Some(output) = output_for_assertions(cli_world) else { + return; + }; + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("the current directory is itself a Whitaker workspace"), + "expected a ref-unsupported refusal, stderr: {stderr}" + ); +} diff --git a/installer/tests/behaviour_prebuilt.rs b/installer/tests/behaviour_prebuilt.rs index 55ad55ca..cf2de281 100644 --- a/installer/tests/behaviour_prebuilt.rs +++ b/installer/tests/behaviour_prebuilt.rs @@ -205,11 +205,14 @@ fn given_destination_path_conflict(world: &mut PrebuiltWorld) { world.force_destination_conflict = true; } +#[given("the pinned commit does not match the manifest git SHA")] fn given_pinned_commit_mismatch(world: &mut PrebuiltWorld) { // The shared test manifest records git_sha "abc1234"; this full SHA does // not share that prefix, so the pinned install cannot reuse the artefact. world.expected_git_sha = Some("deadbeef00000000000000000000000000000000".to_owned()); } + +#[when("prebuilt download is attempted")] fn when_prebuilt_attempted(world: &mut PrebuiltWorld) { let toolchain = world .expected_toolchain @@ -394,6 +397,10 @@ fn scenario_build_only(world: PrebuiltWorld) { let _ = world; } +#[scenario( + path = "tests/features/prebuilt_download.feature", + name = "Prebuilt is skipped when the pinned ref does not match" +)] fn scenario_pinned_ref_mismatch(world: PrebuiltWorld) { let _ = world; } From 1304b5b50020e0549c3bf39b63ca3596bf4af6e9 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 19:32:15 +0200 Subject: [PATCH 09/26] Resolve fetched branch pins via FETCH_HEAD (#271) Return the commit recorded in `FETCH_HEAD` from `fetch_ref` and use it directly in the workspace pinning path. This allows remote-only branch names to resolve without creating a local branch. Cover the fetch contract with a real-Git regression for a branch created after the managed clone. --- installer/src/git.rs | 8 +++++--- installer/src/git_tests.rs | 14 ++++++++++++++ installer/src/workspace.rs | 6 +++--- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/installer/src/git.rs b/installer/src/git.rs index 5dbfd8d7..d817c9aa 100644 --- a/installer/src/git.rs +++ b/installer/src/git.rs @@ -76,13 +76,15 @@ pub fn resolve_commit(repo: &Utf8Path, refspec: &str) -> Result { /// Fetches a specific ref (and all tags) from `origin` into the repository. /// /// Used to recover when a pinned ref cannot be resolved from the existing -/// clone. Runs `git fetch origin --tags`. +/// clone. Runs `git fetch origin --tags`, then resolves `FETCH_HEAD` +/// to return the exact commit fetched for the requested ref. /// /// # Errors /// /// Returns `InstallerError::Git` if the fetch fails or times out. -pub fn fetch_ref(repo: &Utf8Path, refspec: &str) -> Result<()> { - run_git_checked(&["fetch", "origin", refspec, "--tags"], Some(repo), "fetch") +pub fn fetch_ref(repo: &Utf8Path, refspec: &str) -> Result { + run_git_checked(&["fetch", "origin", refspec, "--tags"], Some(repo), "fetch")?; + resolve_commit(repo, "FETCH_HEAD") } /// Checks out a commit as a detached HEAD. diff --git a/installer/src/git_tests.rs b/installer/src/git_tests.rs index e917e11d..e92779a4 100644 --- a/installer/src/git_tests.rs +++ b/installer/src/git_tests.rs @@ -149,6 +149,20 @@ fn fetch_ref_retrieves_a_new_tag() { assert_eq!(resolve_commit(&fx.clone, "v2").expect("resolve v2"), third); } +#[test] +fn fetch_ref_resolves_a_new_remote_branch() { + let fx = git_fixture(); + let source = Utf8PathBuf::try_from(fx._source.path().to_owned()).expect("UTF-8 path"); + git(&source, &["checkout", "-b", "release-candidate"]); + let branch_commit = commit_file(&source, "c.txt", "three", "branch commit"); + + assert!(resolve_commit(&fx.clone, "release-candidate").is_err()); + let fetched_commit = + fetch_ref(&fx.clone, "release-candidate").expect("fetch new remote branch"); + + assert_eq!(fetched_commit, branch_commit); +} + #[test] fn clone_repository_error_includes_operation() { let err = InstallerError::Git { diff --git a/installer/src/workspace.rs b/installer/src/workspace.rs index dc4df117..a7aa635d 100644 --- a/installer/src/workspace.rs +++ b/installer/src/workspace.rs @@ -202,9 +202,9 @@ fn pin_to_ref(repo: &Utf8Path, git_ref: &str) -> Result { let commit = match crate::git::resolve_commit(repo, git_ref) { Ok(commit) => commit, Err(_) => { - // The ref is not known locally; fetch it once, then resolve again. - crate::git::fetch_ref(repo, git_ref)?; - crate::git::resolve_commit(repo, git_ref)? + // A fetched branch may exist only as a remote-tracking ref, so use + // the commit Git recorded for the explicit fetch. + crate::git::fetch_ref(repo, git_ref)? } }; crate::git::checkout_detached(repo, &commit)?; From b1955f735aa9c6fe94b3727ecfd928c8d73ec538 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 1 Aug 2026 14:05:59 +0200 Subject: [PATCH 10/26] Strengthen pinned installation review coverage (#271) Drive Git error assertions through real command failures and cover the pin-to-update lifecycle with local repositories. Add generated coverage for every supported manifest SHA prefix length. Move repository URL ownership into the Git module while retaining the workspace compatibility re-export. Record the verification boundary and validated results in the execution plan. --- Cargo.lock | 1 + .../issue-271-ref-pinned-installation.md | 102 ++++++++++++++---- installer/Cargo.toml | 1 + installer/src/git.rs | 4 +- installer/src/git_tests.rs | 52 ++++++--- installer/src/prebuilt_tests.rs | 61 +++++++++++ installer/src/workspace.rs | 6 +- 7 files changed, 189 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e17731ee..683c9c0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3667,6 +3667,7 @@ dependencies = [ "libc", "log", "mockall", + "proptest", "rstest", "rstest-bdd", "rstest-bdd-macros", diff --git a/docs/execplans/issue-271-ref-pinned-installation.md b/docs/execplans/issue-271-ref-pinned-installation.md index 331bb094..f23456b3 100644 --- a/docs/execplans/issue-271-ref-pinned-installation.md +++ b/docs/execplans/issue-271-ref-pinned-installation.md @@ -140,6 +140,14 @@ clone recovers from the detached checkout). refreshed in `cli.rs`. Full gates green. - [x] (2026-07-08) Manual end-to-end validation transcript recorded under `Artifacts`; platform clone restored to `main` by the recovery run. +- [x] (2026-08-01) Review follow-up replaced direct `InstallerError::Git` + construction checks with failures driven through `clone_repository` and + `update_repository`, and added a real-Git pin, detach, reattach, and update + lifecycle test. +- [x] (2026-08-01) Added two proptest properties for accepted 7–40 character + SHA prefixes and rejection after changing a prefix nibble. +- [x] (2026-08-01) Full review gates passed: formatting, tests, type-checking, + linting, Markdown, and Mermaid validation. ## Surprises & discoveries @@ -217,6 +225,23 @@ clone recovers from the detached checkout). confirmation. The plan's single "Pinning ... (SHORT_SHA)" line was split because the short SHA is only known after the pin resolves. Date/Author: 2026-07-08, agent. +- Decision (architecture): `installer/src/git.rs` owns + `WHITAKER_REPO_URL`, because it is the only module that uses the value to + perform repository operations. `installer/src/workspace.rs` re-exports the + constant only to preserve its existing public path; new internal callers + import it from `git.rs`. + Rationale: this keeps the dependency direction one-way: workspace + orchestration calls git operations, while the git module does not depend on + workspace orchestration. + Date/Author: 2026-08-01, agent. +- Decision (verification): use proptest for the pure SHA-prefix invariant and + real Git repositories for detached-HEAD recovery. Do not add Kani or Verus + coverage for Git subprocess state. + Rationale: Kani cannot directly model the external Git process and + filesystem lifecycle, so a harness would test a stand-in rather than the + shipped implementation. No mathematical lemma is claimed, so Verus is not + applicable. The real-Git lifecycle test exercises the production functions. + Date/Author: 2026-08-01, agent. ## Outcomes & retrospective @@ -244,6 +269,13 @@ context structs surfaced literal constructors in the binary's own test modules (`install_flow/tests.rs`, `tests/fast_path.rs`) that `cargo test --lib` did not compile; an early `cargo check --all-targets` sweep catches these. +The 2026-08-01 review follow-up strengthens the real-Git failure and lifecycle +coverage, adds SHA-prefix property tests, and removes the workspace-to-git +module cycle. `make check-fmt`, `make typecheck`, and `make lint` passed; lint +included rustdoc and Clippy with warnings denied. `make test` passed all 1,481 +tests, with 3 skipped and 1 slow. `make markdownlint` checked 70 files with 0 +errors, and `make nixie` reported all diagrams valid. + ## Context and orientation The repository is a Cargo workspace (`Cargo.toml` members: `common`, @@ -264,13 +296,15 @@ relative to the repository root: `try_fast_path_installation` (prebuilt download, then a test-only staged suite path), (4) source build via `pipeline::perform_build`, (5) wrapper scripts and metrics. -- `installer/src/workspace.rs` — `WHITAKER_REPO_URL`, `WorkspaceAction` - (`UseCurrentDir` / `CloneTo` / `UpdateAt` / `UseExisting`), +- `installer/src/workspace.rs` — the compatibility re-export of + `WHITAKER_REPO_URL`, `WorkspaceAction` (`UseCurrentDir` / `CloneTo` / + `UpdateAt` / `UseExisting`), `decide_workspace_action(cwd, clone_dir, update)`, and `ensure_workspace(dirs, update)` which executes the action. -- `installer/src/git.rs` — `clone_repository`, `update_repository`, and the - private `run_git_with_timeout(args, working_dir, operation)` helper (5-min - timeout, threaded pipe draining). All new git operations reuse this helper. +- `installer/src/git.rs` — the owning definition of `WHITAKER_REPO_URL`, + `clone_repository`, `update_repository`, and the private + `run_git_with_timeout(args, working_dir, operation)` helper (5-min timeout, + threaded pipe draining). All new git operations reuse this helper. - `installer/src/prebuilt.rs` — `PrebuiltConfig` (target, toolchain, destination, quiet), `attempt_prebuilt`, and the private `run_pipeline` that downloads the manifest, validates toolchain and target, downloads and @@ -313,12 +347,11 @@ Stage B — red tests. Add failing tests before each implementation slice: 1. `installer/src/cli_tests.rs`: parsing `--ref v0.2.5` populates `InstallArgs::git_ref`; absence leaves it `None`; `--ref` is accepted both bare and under the `install` subcommand. -2. `installer/src/git.rs` tests (new, using real `git` against `TempDir` - fixtures — create a source repository with two commits and a tag, clone - it, then exercise the new functions): `resolve_commit` resolves a tag and - a SHA and errors on garbage; `checkout_detached` leaves HEAD at the - commit; `ensure_default_branch` reattaches a detached clone so that a - subsequent `update_repository` succeeds. +2. `installer/src/git_tests.rs` tests use real `git` against `TempDir` + fixtures. They cover tag, branch, and SHA resolution; invalid refs; detached + checkout; tag and remote-branch fetches; branch no-op behaviour; real clone + and pull failures; and the full pin, detach, remote advance, reattach, and + update lifecycle. 3. `installer/src/workspace.rs` tests: `decide_workspace_action` outcomes are unchanged; a new decision-level test that a ref plus `UseCurrentDir` yields the refusal error. @@ -344,7 +377,8 @@ Stage C — implementation, in five small commits: constructors that fail to compile. 2. Git helpers in `installer/src/git.rs`: `resolve_commit(repo, refspec) -> Result` (`git rev-parse --verify ^{commit}`), - `fetch_ref(repo, refspec)` (`git fetch origin --tags`), + `fetch_ref(repo, refspec) -> Result` + (`git fetch origin --tags`, then resolve `FETCH_HEAD`), `checkout_detached(repo, commit)` (`git checkout --detach `), and `ensure_default_branch(repo)` (no-op when already on a branch; otherwise discover the default branch per Stage A and check it out). All through @@ -355,10 +389,10 @@ Stage C — implementation, in five small commits: `UseCurrentDir` with a ref → the refusal error; on `CloneTo` → clone then pin; on `UpdateAt` → `ensure_default_branch` then pull then pin; on `UseExisting` with a ref → pin without pulling. "Pin" means: try - `resolve_commit`; on failure `fetch_ref` once and retry; then - `checkout_detached`. Crucially, `UpdateAt` with *no* ref also calls - `ensure_default_branch` first, fixing the recovery risk. Return both the - workspace path and the resolved commit SHA (a small + `resolve_commit`; on failure `fetch_ref` once and use its resolved + `FETCH_HEAD`; then `checkout_detached`. Crucially, even an unpinned + `UpdateAt` calls `ensure_default_branch` first, fixing the recovery risk. + Return both the workspace path and the resolved commit SHA (a small `WorkspaceCheckout { root: Utf8PathBuf, pinned_commit: Option }` struct) so `main.rs` can hand the SHA to the prebuilt path. 4. Prebuilt: add `expected_git_sha: Option<&'a str>` to `PrebuiltConfig`, @@ -446,7 +480,9 @@ Acceptance is behavioural: 6. `make test` passes with the new unit and behaviour tests; each new test demonstrably failed first (Red evidence retained in the tee'd logs under `/tmp/test-whitaker-issue-271.out` and summarized in `Artifacts`). -7. `make check-fmt`, `make lint` (clippy plus the dylint suite), and +7. The SHA-prefix proptest properties accept every generated valid 7–40 + character prefix and reject a generated prefix with one changed nibble. +8. `make check-fmt`, `make lint` (clippy plus the dylint suite), and `make markdownlint` pass. BDD specification driving slice 5 of Stage B (final wording may be adjusted @@ -502,6 +538,18 @@ Red/Green evidence (gate logs under `/tmp/*-whitaker-issue-271*.out`): - Full suite green each slice: final `make test` reported 1472 tests run, 1472 passed, 3 skipped. +Review follow-up test inventory (full gates passed): + +- `installer/src/git_tests.rs` has nine real-Git tests. The lifecycle test pins + through `workspace::pin_to_ref`, confirms detached HEAD, advances the remote, + reattaches through `ensure_default_branch`, pulls through + `update_repository`, and confirms the new commit on `main`. +- The clone and update error tests invoke `clone_repository` and + `update_repository` against failing repositories before inspecting their + semantic `InstallerError::Git` fields. +- `installer/src/prebuilt_tests.rs` has two proptest properties covering valid + manifest prefixes and a mismatched prefix across the supported SHA lengths. + Manual smoke test (Stage D), run against tag `v0.2.4` (commit `8512ee63a212abd175c31501a57e10a713881873`): @@ -523,7 +571,9 @@ Manual smoke test (Stage D), run against tag `v0.2.4` ## Interfaces and dependencies -No new crate dependencies. At completion the following must exist: +No new third-party package is introduced. The installer adds the workspace's +existing `proptest` package as a development dependency. At completion the +following must exist: In `installer/src/cli.rs`: @@ -540,12 +590,17 @@ In `installer/src/git.rs` (all `pub`, all routed through `run_git_with_timeout`): ```rust +pub const WHITAKER_REPO_URL: &str = "https://github.com/leynos/whitaker"; pub fn resolve_commit(repo: &Utf8Path, refspec: &str) -> Result; -pub fn fetch_ref(repo: &Utf8Path, refspec: &str) -> Result<()>; +pub fn fetch_ref(repo: &Utf8Path, refspec: &str) -> Result; pub fn checkout_detached(repo: &Utf8Path, commit: &str) -> Result<()>; pub fn ensure_default_branch(repo: &Utf8Path) -> Result<()>; ``` +`installer/src/workspace.rs` publicly re-exports `WHITAKER_REPO_URL` for +compatibility. The git module remains the owner, and new internal callers must +use `crate::git::WHITAKER_REPO_URL`. + In `installer/src/workspace.rs` (signature change, the one sanctioned breaking change; all in-repo callers updated in the same commit): @@ -576,3 +631,12 @@ pub struct PrebuiltConfig<'a> { `WorkspaceCheckout` into `PrebuiltInstallationContext` and on into `PrebuiltConfig`. `installer/src/output.rs`'s `DryRunInfo` gains a `git_ref: Option<&'a str>` field rendered in `display_text`. + +## Revision note (2026-08-01) + +Review follow-up clarified that `installer/src/git.rs` owns the repository URL +and `installer/src/workspace.rs` keeps only a compatibility re-export. It also +records the live real-Git lifecycle and failure tests, the two SHA-prefix +properties, the Kani and Verus scope decision, and `fetch_ref`'s +commit-returning signature. Full-gate validation passed with the results +recorded in `Outcomes & retrospective`. diff --git a/installer/Cargo.toml b/installer/Cargo.toml index 6d514e9e..16ca039e 100644 --- a/installer/Cargo.toml +++ b/installer/Cargo.toml @@ -80,6 +80,7 @@ zstd = { workspace = true } [dev-dependencies] libc = { workspace = true } mockall = { workspace = true } +proptest = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } diff --git a/installer/src/git.rs b/installer/src/git.rs index d817c9aa..34177b1b 100644 --- a/installer/src/git.rs +++ b/installer/src/git.rs @@ -5,7 +5,6 @@ //! configurable timeout to prevent hangs on network issues. use crate::error::{InstallerError, Result}; -use crate::workspace::WHITAKER_REPO_URL; use camino::Utf8Path; use std::process::{Command, Output, Stdio}; use std::time::Duration; @@ -14,6 +13,9 @@ use wait_timeout::ChildExt; /// Default timeout for git operations (5 minutes). const GIT_TIMEOUT: Duration = Duration::from_secs(300); +/// Repository URL for cloning Whitaker. +pub const WHITAKER_REPO_URL: &str = "https://github.com/leynos/whitaker"; + /// Clones the Whitaker repository to the specified target directory. /// /// Creates the parent directories if they do not exist. The operation has diff --git a/installer/src/git_tests.rs b/installer/src/git_tests.rs index e92779a4..b6e758cb 100644 --- a/installer/src/git_tests.rs +++ b/installer/src/git_tests.rs @@ -119,13 +119,23 @@ fn checkout_detached_leaves_head_at_commit() { } #[test] -fn ensure_default_branch_reattaches_so_update_succeeds() { +fn pinned_checkout_reattaches_for_unpinned_update() { let fx = git_fixture(); - checkout_detached(&fx.clone, &fx.first).expect("checkout detached"); + let pinned_commit = crate::workspace::pin_to_ref(&fx.clone, "v1").expect("pin checkout to v1"); + assert_eq!(pinned_commit, fx.first); + assert_eq!( + git(&fx.clone, &["rev-parse", "--abbrev-ref", "HEAD"]), + "HEAD" + ); + + let source = Utf8PathBuf::try_from(fx._source.path().to_owned()).expect("UTF-8 path"); + let third = commit_file(&source, "c.txt", "three", "third"); + ensure_default_branch(&fx.clone).expect("reattach to default branch"); - assert_eq!(git(&fx.clone, &["symbolic-ref", "HEAD"]), "refs/heads/main"); - // A pull now succeeds because HEAD is on a branch again. update_repository(&fx.clone).expect("update after reattach"); + + assert_eq!(git(&fx.clone, &["symbolic-ref", "HEAD"]), "refs/heads/main"); + assert_eq!(git(&fx.clone, &["rev-parse", "HEAD"]), third); } #[test] @@ -165,22 +175,32 @@ fn fetch_ref_resolves_a_new_remote_branch() { #[test] fn clone_repository_error_includes_operation() { - let err = InstallerError::Git { - operation: "clone", - message: "test error".to_owned(), + let target = TempDir::new().expect("clone target temp dir"); + std::fs::write(target.path().join("occupied"), b"occupied").expect("write target file"); + let target_path = Utf8PathBuf::try_from(target.path().to_owned()).expect("UTF-8 path"); + + let err = clone_repository(&target_path).expect_err("clone into non-empty target should fail"); + + let InstallerError::Git { operation, message } = err else { + panic!("expected Git error, got {err:?}"); }; - let msg = err.to_string(); - assert!(msg.contains("clone")); - assert!(msg.contains("test error")); + assert_eq!(operation, "clone"); + assert!(!message.is_empty(), "expected Git stderr"); } #[test] fn update_repository_error_includes_operation() { - let err = InstallerError::Git { - operation: "pull", - message: "not a git repository".to_owned(), + let repo = TempDir::new().expect("non-repository temp dir"); + let repo_path = Utf8PathBuf::try_from(repo.path().to_owned()).expect("UTF-8 path"); + + let err = update_repository(&repo_path).expect_err("pull outside a repository should fail"); + + let InstallerError::Git { operation, message } = err else { + panic!("expected Git error, got {err:?}"); }; - let msg = err.to_string(); - assert!(msg.contains("pull")); - assert!(msg.contains("not a git repository")); + assert_eq!(operation, "pull"); + assert!( + message.contains("not a git repository"), + "stderr: {message}" + ); } diff --git a/installer/src/prebuilt_tests.rs b/installer/src/prebuilt_tests.rs index d6a36b84..02fd98a2 100644 --- a/installer/src/prebuilt_tests.rs +++ b/installer/src/prebuilt_tests.rs @@ -4,6 +4,8 @@ use super::*; use crate::artefact::download::MockArtefactDownloader; use crate::artefact::extraction::MockArtefactExtractor; use crate::test_utils::{prebuilt_manifest_json, sha256_hex}; +use proptest::prelude::*; +use proptest::test_runner::TestCaseError; use rstest::rstest; const FAKE_ARCHIVE: &[u8] = b"fake archive content"; @@ -16,6 +18,65 @@ const MATCHING_COMMIT: &str = "abc1234000000000000000000000000000000ab"; /// A full 40-hex commit SHA that does not share the manifest's prefix. const MISMATCHED_COMMIT: &str = "deadbeef00000000000000000000000000000000"; +fn commit_sha_strategy() -> impl Strategy { + const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef"; + prop::collection::vec(0_u8..16, 40).prop_map(|nibbles| { + nibbles + .into_iter() + .map(|nibble| char::from(HEX_DIGITS[usize::from(nibble)])) + .collect() + }) +} + +fn manifest_with_git_sha(git_sha: &str) -> serde_json::Result { + serde_json::from_value(serde_json::json!({ + "git_sha": git_sha, + "schema_version": 1, + "toolchain": TOOLCHAIN, + "target": TARGET, + "generated_at": "2026-02-03T00:00:00Z", + "files": ["libwhitaker_suite.so"], + "sha256": "a".repeat(64), + })) +} + +proptest! { + #[test] + fn git_sha_prefixes_across_supported_lengths_are_accepted( + commit in commit_sha_strategy(), + prefix_len in 7_usize..=40, + ) { + let manifest = manifest_with_git_sha(&commit[..prefix_len]) + .map_err(|error| TestCaseError::fail(error.to_string()))?; + + prop_assert!(validate_git_sha(&manifest, Some(&commit)).is_ok()); + } + + #[test] + fn changed_nibble_in_git_sha_prefix_is_rejected( + commit in commit_sha_strategy(), + prefix_len in 7_usize..=40, + ) { + let mut manifest_sha = commit[..prefix_len].to_owned(); + let replacement = if manifest_sha.starts_with('0') { "1" } else { "0" }; + manifest_sha.replace_range(..1, replacement); + let manifest = manifest_with_git_sha(&manifest_sha) + .map_err(|error| TestCaseError::fail(error.to_string()))?; + + match validate_git_sha(&manifest, Some(&commit)) { + Err(PrebuiltError::GitShaMismatch { manifest, expected }) => { + prop_assert_eq!(manifest, manifest_sha); + prop_assert_eq!(expected, commit); + } + other => { + return Err(TestCaseError::fail(format!( + "expected GitShaMismatch, got {other:?}" + ))); + } + } + } +} + fn base_config(destination_dir: &Utf8Path) -> PrebuiltConfig<'_> { PrebuiltConfig { target: TARGET, diff --git a/installer/src/workspace.rs b/installer/src/workspace.rs index a7aa635d..6b890c8f 100644 --- a/installer/src/workspace.rs +++ b/installer/src/workspace.rs @@ -8,7 +8,9 @@ use crate::error::{InstallerError, Result}; use camino::{Utf8Path, Utf8PathBuf}; /// Repository URL for cloning Whitaker. -pub const WHITAKER_REPO_URL: &str = "https://github.com/leynos/whitaker"; +/// +/// Re-exported from [`crate::git`] to preserve the existing public path. +pub use crate::git::WHITAKER_REPO_URL; /// Expected package name in Cargo.toml to identify a Whitaker workspace. const WHITAKER_PACKAGE_NAME: &str = "whitaker"; @@ -198,7 +200,7 @@ fn pin_if_requested(repo: &Utf8Path, git_ref: Option<&str>) -> Result Result { +pub(super) fn pin_to_ref(repo: &Utf8Path, git_ref: &str) -> Result { let commit = match crate::git::resolve_commit(repo, git_ref) { Ok(commit) => commit, Err(_) => { From e32f12be0aab30ac0cc8dcb2af18873d62971b15 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 1 Aug 2026 14:26:52 +0200 Subject: [PATCH 11/26] Strengthen pinned-ref review coverage (#271) Exercise remote-only branch pinning and missing default-branch repair with real Git repositories, and reuse the shared repository fixture through `rstest` injection. Keep workspace orchestration explicit while removing repeated checkout construction, scope prebuilt fixture writes through a directory capability, and align the user and execution documentation with the supported behaviour. Retain prefix-tolerant provenance checks because rolling manifests continue to publish abbreviated commit IDs. --- Cargo.lock | 568 ++++++------------ docs/developers-guide.md | 4 + .../issue-271-ref-pinned-installation.md | 83 ++- docs/users-guide.md | 6 +- installer/Cargo.toml | 1 + installer/src/git_tests.rs | 140 +++-- installer/src/output.rs | 2 +- installer/src/prebuilt_tests.rs | 26 +- installer/src/workspace.rs | 33 +- 9 files changed, 409 insertions(+), 454 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 683c9c0f..48f478cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" dependencies = [ "cipher", "cpubits", @@ -21,9 +21,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -207,7 +207,7 @@ dependencies = [ "rustc_span", "serde", "tempfile", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "whitaker", "whitaker-common", ] @@ -229,9 +229,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" dependencies = [ "serde_core", ] @@ -324,9 +324,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.67" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "jobserver", @@ -358,9 +358,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.4" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" dependencies = [ "clap_builder", "clap_derive", @@ -368,9 +368,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" dependencies = [ "anstream", "anstyle", @@ -701,13 +701,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -724,9 +724,9 @@ checksum = "9bda8e21c04aca2ae33ffc2fd8c23134f3cac46db123ba97bd9d3f3b8a4a85e1" [[package]] name = "dylint" -version = "6.0.1" +version = "6.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c738fb72ea7d248df2995a31b3698beb63d0f1f9ca5a5dc0188c4b64cd0e86e4" +checksum = "da5d2f27acc9d395eabf8b7001ca856d72e49d4c1c5c3c427c60473fb6112a08" dependencies = [ "anstyle", "anyhow", @@ -742,9 +742,9 @@ dependencies = [ [[package]] name = "dylint_internal" -version = "6.0.1" +version = "6.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9796d3441d7894cbaf4992640799efffc1661978f0cc1266ec788c32254fdfb3" +checksum = "e56d36d5cf7a909a48854ea667b94421643ae0658868b37e4a842fb3d2c6d30e" dependencies = [ "anstyle", "anyhow", @@ -757,14 +757,14 @@ dependencies = [ "serde", "tar", "thiserror 2.0.19", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", ] [[package]] name = "dylint_linting" -version = "6.0.1" +version = "6.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c213635b3eaa84f43b9b497377f17d9a8d4feb413bab1d82e03ad1c73e4f6d8c" +checksum = "561e06d4cbeeaf506d1bce9e92dc737a83f143e8121d09db825b6d4b4bf55728" dependencies = [ "cargo_metadata", "dylint_internal", @@ -772,14 +772,14 @@ dependencies = [ "rustversion", "serde", "thiserror 2.0.19", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", ] [[package]] name = "dylint_testing" -version = "6.0.1" +version = "6.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f5f50ee06be9ebfb5b9538ba0d7bb08adc33e8f31c901e7d398de2a9b1ae290" +checksum = "616d3ae583c7daa06c035fcb4e9c3f1c84e14dc1f8bfc05aefcd57fe72a1902e" dependencies = [ "anyhow", "cargo_metadata", @@ -795,9 +795,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "encode_unicode" @@ -846,9 +846,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "filetime" @@ -984,15 +984,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - [[package]] name = "fragile" version = "2.1.0" @@ -1047,15 +1038,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -1064,9 +1055,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", @@ -1075,9 +1066,9 @@ dependencies = [ [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-timer" @@ -1087,9 +1078,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-macro", @@ -1162,17 +1153,14 @@ dependencies = [ [[package]] name = "git2" -version = "0.20.4" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ "bitflags 2.13.1", "libc", "libgit2-sys", "log", - "openssl-probe", - "openssl-sys", - "url", ] [[package]] @@ -1255,9 +1243,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1271,9 +1259,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", ] @@ -1325,114 +1313,11 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "ignore" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ffa3a0547a138e59ddd6fa3b7c672ed47e6ad6a3cd177984ff1116aa5ba742" +checksum = "0b17771570a2b94107741a7b033f19132c2eee21d59d21b24d2ced26500bd66e" dependencies = [ "crossbeam-deque", "globset", @@ -1521,7 +1406,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" dependencies = [ "io-lifetimes 3.0.1", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1538,9 +1423,9 @@ checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is_terminal_polyfill" @@ -1565,11 +1450,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.32" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ "defmt", + "jiff-core", "jiff-static", "log", "portable-atomic", @@ -1577,12 +1463,22 @@ dependencies = [ "serde_core", ] +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + [[package]] name = "jiff-static" -version = "0.2.32" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" dependencies = [ + "jiff-core", "proc-macro2", "quote", "syn 2.0.119", @@ -1635,39 +1531,23 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libgit2-sys" -version = "0.18.5+1.9.4" +version = "0.18.7+1.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" +checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89" dependencies = [ "cc", "libc", - "libssh2-sys", "libz-sys", - "openssl-sys", "pkg-config", ] [[package]] name = "libredox" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" -dependencies = [ - "libc", -] - -[[package]] -name = "libssh2-sys" -version = "0.3.2" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c04141a07bb0c0bc461cb657808764de571702a59bc5c726c400ac9a7625e3ab" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" dependencies = [ - "cc", "libc", - "libz-sys", - "openssl-sys", - "pkg-config", - "vcpkg", ] [[package]] @@ -1688,12 +1568,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - [[package]] name = "lock_api" version = "0.4.14" @@ -1910,7 +1784,7 @@ dependencies = [ "serde_json", "serial_test", "tempfile", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "whitaker", "whitaker-common", ] @@ -1975,24 +1849,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -2079,9 +1935,9 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "portable-atomic-util" @@ -2092,15 +1948,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - [[package]] name = "powerfmt" version = "0.2.0" @@ -2189,9 +2036,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -2223,9 +2070,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2523,7 +2370,7 @@ dependencies = [ "rustc_session", "rustc_span", "serde", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "trybuild", "whitaker", "whitaker-common", @@ -2683,9 +2530,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "log", "once_cell", @@ -2698,9 +2545,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] @@ -2919,12 +2766,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "strsim" version = "0.11.1" @@ -2969,17 +2810,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "sys-locale" version = "0.3.2" @@ -3002,9 +2832,9 @@ dependencies = [ [[package]] name = "target-triple" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" +checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171" [[package]] name = "temp-env" @@ -3145,9 +2975,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.53" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "js-sys", @@ -3186,13 +3016,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -3206,9 +3036,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.3+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", @@ -3242,9 +3072,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow 1.0.4", ] @@ -3294,9 +3124,9 @@ checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" [[package]] name = "trybuild" -version = "1.0.118" +version = "1.0.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06649c6f63d86604ba0c8950d5a1829fc9a17afd70fc6629f481d75b6a624c78" +checksum = "1e605bf6b39357663d8ba4e984f8be8da8df6bb32e81031d6889024ea8fd68e4" dependencies = [ "glob", "serde", @@ -3304,7 +3134,7 @@ dependencies = [ "serde_json", "target-triple", "termcolor", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", ] [[package]] @@ -3475,30 +3305,12 @@ dependencies = [ "log", ] -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - [[package]] name = "utf8-zero" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "utf8parse" version = "0.2.2" @@ -3507,9 +3319,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "value-bag" -version = "1.13.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd4ec1eb1d240636e354a30110a1dfcb37047169a4d9bd6d9d3469df574b5c4" +checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" [[package]] name = "vcpkg" @@ -3604,9 +3416,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -3629,7 +3441,7 @@ dependencies = [ "rustc_span", "serde", "thiserror 2.0.19", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "whitaker-common", "whitaker-installer", ] @@ -3678,7 +3490,7 @@ dependencies = [ "temp-env", "tempfile", "thiserror 2.0.19", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "tracing", "trybuild", "ureq", @@ -3706,7 +3518,7 @@ dependencies = [ "sha2", "tempfile", "thiserror 2.0.19", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "tracing", "whitaker_sarif", ] @@ -3792,7 +3604,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -3801,7 +3613,16 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -3819,14 +3640,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -3835,48 +3673,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.15" @@ -3908,12 +3794,6 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - [[package]] name = "xattr" version = "1.6.1" @@ -3924,43 +3804,20 @@ dependencies = [ "rustix", ] -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - [[package]] name = "zerocopy" -version = "0.8.54" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.54" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", @@ -3972,21 +3829,6 @@ name = "zerofrom" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] [[package]] name = "zeroize" @@ -3994,17 +3836,6 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - [[package]] name = "zerovec" version = "0.11.6" @@ -4012,20 +3843,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "serde", - "yoke", "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", ] [[package]] @@ -4057,9 +3875,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 5a943c98..42dc1090 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -1883,6 +1883,10 @@ commands that inspect output or interpret a non-zero status must continue to use in `git_tests.rs`, keeping the production adapter focused. The helpers in `workspace_progress.rs` only render operator messages at the CLI edge and must not clone, update, or pin the checkout themselves. +`workspace::finalize_workspace_checkout` is called only from +`ensure_workspace` action arms after each arm's current-directory, clone, or +update setup. It may combine optional pinning with `WorkspaceCheckout` +construction; it must not clone, update, or reattach a repository. Behaviour-test support follows the same ownership rule. `behaviour_cli::support::output_for_assertions` combines scenario-skip handling diff --git a/docs/execplans/issue-271-ref-pinned-installation.md b/docs/execplans/issue-271-ref-pinned-installation.md index f23456b3..fb3e65a1 100644 --- a/docs/execplans/issue-271-ref-pinned-installation.md +++ b/docs/execplans/issue-271-ref-pinned-installation.md @@ -43,8 +43,10 @@ clone recovers from the detached checkout). ## Constraints -- Default behaviour with no `--ref` must be byte-for-byte unchanged: rolling - prebuilt download first, default-branch clone/pull fallback. +- With no `--ref`, ordinary operation must continue to try the rolling prebuilt + download first, then use the default-branch clone/pull fallback. If a prior + pinned installation left the managed clone detached, the no-ref path may + reattach it to the default branch before performing the unpinned update. - The public crate API additions must be additive; no existing public function signatures in `whitaker_installer` may change in a way that breaks the behaviour tests' existing imports, except where the plan names the @@ -52,8 +54,10 @@ clone recovers from the detached checkout). - The installer must never mutate a user's own working tree: if the current directory is itself a Whitaker workspace, `--ref` must fail with a clear error rather than checking anything out. -- No new external dependencies. Git operations continue to go through - `installer/src/git.rs` with the existing 5-minute timeout discipline. +- No new runtime external dependencies. Test support may directly declare the + already-resolved `cap-std` crate solely for capability-scoped fixture writes. + Git operations continue to go through `installer/src/git.rs` with the + existing 5-minute timeout discipline. - All work follows the repository gates: `make check-fmt`, `make lint`, `make test`, `make markdownlint` must pass before each commit. - Commit messages follow the file-based workflow (`git commit -F`), no AI @@ -139,7 +143,7 @@ clone recovers from the detached checkout). (new "Pinning the suite" subsection) and `README.md`; `--help` examples refreshed in `cli.rs`. Full gates green. - [x] (2026-07-08) Manual end-to-end validation transcript recorded under - `Artifacts`; platform clone restored to `main` by the recovery run. + `Artefacts`; platform clone restored to `main` by the recovery run. - [x] (2026-08-01) Review follow-up replaced direct `InstallerError::Git` construction checks with failures driven through `clone_repository` and `update_repository`, and added a real-Git pin, detach, reattach, and update @@ -148,6 +152,26 @@ clone recovers from the detached checkout). SHA prefixes and rejection after changing a prefix nibble. - [x] (2026-08-01) Full review gates passed: formatting, tests, type-checking, linting, Markdown, and Mermaid validation. +- [x] (2026-08-01) Verified the latest review findings. The full-SHA-only + proposal was rejected because rolling producers record abbreviated SHAs; the + fixture, Git recovery and pinning, capability-write, helper-boundary, and + documentation findings remained valid. +- [x] (2026-08-01) Converted `git_fixture` to an injected `rstest` fixture; + added real-Git coverage for missing `origin/HEAD` repair and fetch-on-miss + pinning; extracted `finalize_workspace_checkout`; routed prebuilt test writes + through a directory capability; and applied the documentation corrections. +- [x] (2026-08-01) Final review validation passed: `make check-fmt` + (`/tmp/final-current-check-fmt-7b3c543c-issue-271-ref-pinned-installation.out`); + `make test`, 1,482/1,482 passed and 3 skipped + (`/tmp/final-current-test-7b3c543c-issue-271-ref-pinned-installation.out`); + `make typecheck` + (`/tmp/final-current-typecheck-7b3c543c-issue-271-ref-pinned-installation.out`); + and `make lint` (`cargo doc` + Clippy) + (`/tmp/final-current-lint-7b3c543c-issue-271-ref-pinned-installation.out`). + Markdownlint reported 0 errors + (`/tmp/final-current-markdownlint-7b3c543c-issue-271-ref-pinned-installation.out`), + and Nixie validated all diagrams + (`/tmp/final-current-nixie-7b3c543c-issue-271-ref-pinned-installation.out`). ## Surprises & discoveries @@ -242,6 +266,29 @@ clone recovers from the detached checkout). shipped implementation. No mathematical lemma is claimed, so Verus is not applicable. The real-Git lifecycle test exercises the production functions. Date/Author: 2026-08-01, agent. +- Decision (review scope): `workspace::finalize_workspace_checkout` is called + only from `ensure_workspace` action arms after their setup operations. It may + combine optional pinning with `WorkspaceCheckout` construction only. Clone, + update, and detached-head reattachment operations remain in + `ensure_workspace` so its match arms continue to make repository state + transitions explicit. + Rationale: this removes repeated result construction without hiding the + orchestration that distinguishes the workspace setup paths. + Date/Author: 2026-08-01, review feedback. +- Decision (review provenance): retain prefix-tolerant pinned-prebuilt + validation and its existing proptest properties; do not require a full + manifest object ID in this review pass. + Rationale: both rolling-release producers record `git rev-parse --short HEAD` + and `GitSha` accepts 7–40 hexadecimal characters. Exact equality would reject + every current abbreviated rolling manifest, disabling valid prebuilt reuse. + Date/Author: 2026-08-01, agent. +- Decision (review tests): inject the shared real-Git repository through an + `rstest` fixture, exercise missing-default-ref recovery and remote-only + pinning through production flows, and use a `cap_std::fs::Dir` rooted at each + test file's parent for mock writes. + Rationale: these changes test Git's actual ref behaviour, remove repeated + fixture setup, and keep filesystem authority scoped to temporary test data. + Date/Author: 2026-08-01, agent. ## Outcomes & retrospective @@ -251,7 +298,7 @@ Delivered across six commits on `issue-271-ref-pinned-installation`: the CLI `expected_git_sha` gating, dry-run/progress messaging, and documentation. All gates (`check-fmt`, `lint`, `test`, `markdownlint`) passed before each commit; the final `make test` ran 1472 tests (1472 passed, 3 skipped). Every new test -failed first for the intended reason (see `Artifacts`). The manual smoke test +failed first for the intended reason (see `Artefacts`). The manual smoke test exercised the pin, the in-workspace refusal, the toolchain-embedded staged filename, and the detached-HEAD recovery end-to-end against tag `v0.2.4`. @@ -269,6 +316,17 @@ context structs surfaced literal constructors in the binary's own test modules (`install_flow/tests.rs`, `tests/fast_path.rs`) that `cargo test --lib` did not compile; an early `cargo check --all-targets` sweep catches these. +The latest review follow-up now contains the minimal valid changes: reusable +`rstest` Git setup, real-Git regressions for default-branch repair and +fetch-on-miss pinning, capability-scoped prebuilt fixture writes, a private +checkout finalizer with an explicit orchestration boundary, and terminology +corrections. The test-only `cap-std` declaration makes an already-resolved +crate available directly to the installer tests; no runtime dependency was +added. The requested full-SHA-only comparison remains deliberately +unimplemented because it conflicts with the rolling manifest format. Final +validation passed: 1,482/1,482 tests passed with 3 skipped, and formatting, +type-checking, Cargo documentation, Clippy, Markdownlint, and Nixie were clean. + The 2026-08-01 review follow-up strengthens the real-Git failure and lifecycle coverage, adds SHA-prefix property tests, and removes the workspace-to-git module cycle. `make check-fmt`, `make typecheck`, and `make lint` passed; lint @@ -479,7 +537,7 @@ Acceptance is behavioural: from source; when they match, the prebuilt path is used. 6. `make test` passes with the new unit and behaviour tests; each new test demonstrably failed first (Red evidence retained in the tee'd logs under - `/tmp/test-whitaker-issue-271.out` and summarized in `Artifacts`). + `/tmp/test-whitaker-issue-271.out` and summarized in `Artefacts`). 7. The SHA-prefix proptest properties accept every generated valid 7–40 character prefix and reject a generated prefix with one changed nibble. 8. `make check-fmt`, `make lint` (clippy plus the dylint suite), and @@ -518,7 +576,7 @@ detached, `git -C ~/.local/share/whitaker checkout main` restores it by hand. If a gate fails mid-commit, fix and re-run the gate; never commit over a red gate. -## Artifacts and notes +## Artefacts and notes Red/Green evidence (gate logs under `/tmp/*-whitaker-issue-271*.out`): @@ -640,3 +698,12 @@ records the live real-Git lifecycle and failure tests, the two SHA-prefix properties, the Kani and Verus scope decision, and `fetch_ref`'s commit-returning signature. Full-gate validation passed with the results recorded in `Outcomes & retrospective`. + +## Revision note (2026-08-01, latest review pass) + +This revision uses “artefacts” consistently and clarifies that ordinary no-ref +behaviour remains rolling while allowing recovery from a detached clone left +by a prior pin. It records the verified finding dispositions, the implemented +test and helper changes, and the reason full-SHA-only validation was rejected. +Final validation passed, including 1,482/1,482 tests with 3 skipped, formatting, +type-checking, Cargo documentation, Clippy, Markdownlint, and Nixie. diff --git a/docs/users-guide.md b/docs/users-guide.md index 83450c2a..e5ddb3fe 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -207,9 +207,9 @@ The pin composes with the other flags: the pinned commit from source. Because the pinned commit supplies its own `rust-toolchain.toml`, the staged libraries are named for that commit's toolchain channel. -- **`--no-update`.** With `--ref --no-update` the installer resolves the ref - against the existing clone without fetching, and only fetches when the ref - cannot be resolved locally. This keeps offline, pinned installs working. +- **`--no-update`.** With `--ref --no-update` the installer resolves the + ref against the existing clone without fetching, and only fetches when the + ref cannot be resolved locally. This keeps offline, pinned installs working. `--ref` is refused when the current directory is itself a Whitaker workspace, because checking out a commit there could destroy uncommitted work. Run the diff --git a/installer/Cargo.toml b/installer/Cargo.toml index 16ca039e..2d6e033e 100644 --- a/installer/Cargo.toml +++ b/installer/Cargo.toml @@ -78,6 +78,7 @@ zip = { workspace = true } zstd = { workspace = true } [dev-dependencies] +cap-std = { workspace = true } libc = { workspace = true } mockall = { workspace = true } proptest = { workspace = true } diff --git a/installer/src/git_tests.rs b/installer/src/git_tests.rs index b6e758cb..08d79417 100644 --- a/installer/src/git_tests.rs +++ b/installer/src/git_tests.rs @@ -2,6 +2,7 @@ use super::*; use camino::Utf8PathBuf; +use rstest::{fixture, rstest}; use std::process::Command; use tempfile::TempDir; @@ -51,6 +52,7 @@ struct GitFixture { } /// Build a source repo (two commits, tag `v1` on the first) and clone it. +#[fixture] fn git_fixture() -> GitFixture { let source = TempDir::new().expect("source temp dir"); let source_path = Utf8PathBuf::try_from(source.path().to_owned()).expect("UTF-8 source path"); @@ -80,97 +82,135 @@ fn git_fixture() -> GitFixture { } } -#[test] -fn resolve_commit_resolves_tag_branch_and_sha() { - let fx = git_fixture(); +#[rstest] +fn resolve_commit_resolves_tag_branch_and_sha(git_fixture: GitFixture) { assert_eq!( - resolve_commit(&fx.clone, "v1").expect("resolve tag"), - fx.first + resolve_commit(&git_fixture.clone, "v1").expect("resolve tag"), + git_fixture.first ); assert_eq!( - resolve_commit(&fx.clone, "main").expect("resolve branch"), - fx.second + resolve_commit(&git_fixture.clone, "main").expect("resolve branch"), + git_fixture.second ); assert_eq!( - resolve_commit(&fx.clone, &fx.second).expect("resolve sha"), - fx.second + resolve_commit(&git_fixture.clone, &git_fixture.second).expect("resolve sha"), + git_fixture.second ); } -#[test] -fn resolve_commit_errors_on_garbage() { - let fx = git_fixture(); - let err = resolve_commit(&fx.clone, "definitely-not-a-ref").expect_err("expected error"); +#[rstest] +fn resolve_commit_errors_on_garbage(git_fixture: GitFixture) { + let err = + resolve_commit(&git_fixture.clone, "definitely-not-a-ref").expect_err("expected error"); assert!(matches!(err, InstallerError::Git { .. }), "got {err:?}"); } -#[test] -fn checkout_detached_leaves_head_at_commit() { - let fx = git_fixture(); - checkout_detached(&fx.clone, &fx.first).expect("checkout detached"); - assert_eq!(git(&fx.clone, &["rev-parse", "HEAD"]), fx.first); +#[rstest] +fn checkout_detached_leaves_head_at_commit(git_fixture: GitFixture) { + checkout_detached(&git_fixture.clone, &git_fixture.first).expect("checkout detached"); + assert_eq!( + git(&git_fixture.clone, &["rev-parse", "HEAD"]), + git_fixture.first + ); // A detached HEAD has no symbolic ref. let symbolic = Command::new("git") .args(["symbolic-ref", "-q", "HEAD"]) - .current_dir(fx.clone.as_std_path()) + .current_dir(git_fixture.clone.as_std_path()) .output() .expect("spawn symbolic-ref"); assert!(!symbolic.status.success(), "expected detached HEAD"); } -#[test] -fn pinned_checkout_reattaches_for_unpinned_update() { - let fx = git_fixture(); - let pinned_commit = crate::workspace::pin_to_ref(&fx.clone, "v1").expect("pin checkout to v1"); - assert_eq!(pinned_commit, fx.first); +#[rstest] +fn pinned_checkout_reattaches_for_unpinned_update(git_fixture: GitFixture) { + let pinned_commit = + crate::workspace::pin_to_ref(&git_fixture.clone, "v1").expect("pin checkout to v1"); + assert_eq!(pinned_commit, git_fixture.first); assert_eq!( - git(&fx.clone, &["rev-parse", "--abbrev-ref", "HEAD"]), + git(&git_fixture.clone, &["rev-parse", "--abbrev-ref", "HEAD"]), "HEAD" ); - let source = Utf8PathBuf::try_from(fx._source.path().to_owned()).expect("UTF-8 path"); + let source = Utf8PathBuf::try_from(git_fixture._source.path().to_owned()).expect("UTF-8 path"); let third = commit_file(&source, "c.txt", "three", "third"); - ensure_default_branch(&fx.clone).expect("reattach to default branch"); - update_repository(&fx.clone).expect("update after reattach"); + ensure_default_branch(&git_fixture.clone).expect("reattach to default branch"); + update_repository(&git_fixture.clone).expect("update after reattach"); - assert_eq!(git(&fx.clone, &["symbolic-ref", "HEAD"]), "refs/heads/main"); - assert_eq!(git(&fx.clone, &["rev-parse", "HEAD"]), third); + assert_eq!( + git(&git_fixture.clone, &["symbolic-ref", "HEAD"]), + "refs/heads/main" + ); + assert_eq!(git(&git_fixture.clone, &["rev-parse", "HEAD"]), third); } -#[test] -fn ensure_default_branch_is_noop_on_a_branch() { - let fx = git_fixture(); - ensure_default_branch(&fx.clone).expect("noop on branch"); - assert_eq!(git(&fx.clone, &["symbolic-ref", "HEAD"]), "refs/heads/main"); +#[rstest] +fn ensure_default_branch_is_noop_on_a_branch(git_fixture: GitFixture) { + ensure_default_branch(&git_fixture.clone).expect("noop on branch"); + assert_eq!( + git(&git_fixture.clone, &["symbolic-ref", "HEAD"]), + "refs/heads/main" + ); } -#[test] -fn fetch_ref_retrieves_a_new_tag() { - let fx = git_fixture(); +#[rstest] +fn ensure_default_branch_repairs_missing_remote_head(git_fixture: GitFixture) { + checkout_detached(&git_fixture.clone, &git_fixture.first).expect("checkout detached"); + git( + &git_fixture.clone, + &["symbolic-ref", "--delete", "refs/remotes/origin/HEAD"], + ); + + ensure_default_branch(&git_fixture.clone).expect("repair and reattach default branch"); + + assert_eq!( + git(&git_fixture.clone, &["symbolic-ref", "HEAD"]), + "refs/heads/main" + ); + assert_eq!( + git( + &git_fixture.clone, + &["symbolic-ref", "refs/remotes/origin/HEAD"] + ), + "refs/remotes/origin/main" + ); +} + +#[rstest] +fn fetch_ref_retrieves_a_new_tag(git_fixture: GitFixture) { // Add a third commit and tag it in the source, after the clone was made. - let source = Utf8PathBuf::try_from(fx._source.path().to_owned()).expect("UTF-8 path"); + let source = Utf8PathBuf::try_from(git_fixture._source.path().to_owned()).expect("UTF-8 path"); let third = commit_file(&source, "c.txt", "three", "third"); git(&source, &["tag", "v2"]); // The clone cannot resolve the new tag until it fetches. - assert!(resolve_commit(&fx.clone, "v2").is_err()); - fetch_ref(&fx.clone, "v2").expect("fetch new tag"); - assert_eq!(resolve_commit(&fx.clone, "v2").expect("resolve v2"), third); + assert!(resolve_commit(&git_fixture.clone, "v2").is_err()); + fetch_ref(&git_fixture.clone, "v2").expect("fetch new tag"); + assert_eq!( + resolve_commit(&git_fixture.clone, "v2").expect("resolve v2"), + third + ); } -#[test] -fn fetch_ref_resolves_a_new_remote_branch() { - let fx = git_fixture(); - let source = Utf8PathBuf::try_from(fx._source.path().to_owned()).expect("UTF-8 path"); +#[rstest] +fn pin_to_ref_fetches_and_checks_out_a_new_remote_branch(git_fixture: GitFixture) { + let source = Utf8PathBuf::try_from(git_fixture._source.path().to_owned()).expect("UTF-8 path"); git(&source, &["checkout", "-b", "release-candidate"]); let branch_commit = commit_file(&source, "c.txt", "three", "branch commit"); - assert!(resolve_commit(&fx.clone, "release-candidate").is_err()); - let fetched_commit = - fetch_ref(&fx.clone, "release-candidate").expect("fetch new remote branch"); + assert!(resolve_commit(&git_fixture.clone, "release-candidate").is_err()); + let pinned_commit = crate::workspace::pin_to_ref(&git_fixture.clone, "release-candidate") + .expect("fetch and pin new remote branch"); - assert_eq!(fetched_commit, branch_commit); + assert_eq!(pinned_commit, branch_commit); + assert_eq!( + git(&git_fixture.clone, &["rev-parse", "HEAD"]), + branch_commit + ); + assert_eq!( + git(&git_fixture.clone, &["rev-parse", "--abbrev-ref", "HEAD"]), + "HEAD" + ); } #[test] diff --git a/installer/src/output.rs b/installer/src/output.rs index 5c85c969..9ab8f058 100644 --- a/installer/src/output.rs +++ b/installer/src/output.rs @@ -133,7 +133,7 @@ pub struct DryRunInfo<'a> { pub jobs: Option, /// Crates to be built. pub crates: &'a [CrateName], - /// The commit SHA or tag the suite is pinned to, if any. + /// The commit-ish the suite is pinned to, if any. pub git_ref: Option<&'a str>, } diff --git a/installer/src/prebuilt_tests.rs b/installer/src/prebuilt_tests.rs index 02fd98a2..6354d59a 100644 --- a/installer/src/prebuilt_tests.rs +++ b/installer/src/prebuilt_tests.rs @@ -4,9 +4,11 @@ use super::*; use crate::artefact::download::MockArtefactDownloader; use crate::artefact::extraction::MockArtefactExtractor; use crate::test_utils::{prebuilt_manifest_json, sha256_hex}; +use cap_std::{ambient_authority, fs::Dir}; use proptest::prelude::*; use proptest::test_runner::TestCaseError; use rstest::rstest; +use std::path::Path; const FAKE_ARCHIVE: &[u8] = b"fake archive content"; const TARGET: &str = "x86_64-unknown-linux-gnu"; @@ -40,6 +42,24 @@ fn manifest_with_git_sha(git_sha: &str) -> serde_json::Result { })) } +/// Writes a test file relative to a capability rooted at its parent directory. +fn write_test_file(path: &Path, contents: &[u8]) -> std::io::Result<()> { + let parent = path.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "test file path has no parent directory", + ) + })?; + let file_name = path.file_name().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "test file path has no file name", + ) + })?; + let dir = Dir::open_ambient_dir(parent, ambient_authority())?; + dir.write(file_name, contents) +} + proptest! { #[test] fn git_sha_prefixes_across_supported_lengths_are_accepted( @@ -97,11 +117,13 @@ fn success_mocks() -> (MockArtefactDownloader, MockArtefactExtractor) { .returning(move |_| Ok(manifest_json.clone())); downloader .expect_download_archive() - .returning(|_filename, dest| std::fs::write(dest, FAKE_ARCHIVE).map_err(DownloadError::Io)); + .returning(|_filename, dest| { + write_test_file(dest, FAKE_ARCHIVE).map_err(DownloadError::Io) + }); let mut extractor = MockArtefactExtractor::new(); extractor.expect_extract().returning(|_archive, dest| { let source_name = "libwhitaker_suite.so".to_owned(); - std::fs::write(dest.join(&source_name), b"fake").expect("write extracted file"); + write_test_file(&dest.join(&source_name), b"fake").expect("write extracted file"); Ok(vec![source_name]) }); (downloader, extractor) diff --git a/installer/src/workspace.rs b/installer/src/workspace.rs index 6b890c8f..c7829c7b 100644 --- a/installer/src/workspace.rs +++ b/installer/src/workspace.rs @@ -145,34 +145,37 @@ pub fn ensure_workspace( WorkspaceAction::UseCurrentDir(dir) | WorkspaceAction::UseExisting(dir) => { // UseCurrentDir is guaranteed refless by `ensure_ref_allowed`; // UseExisting pins without pulling, per the `--no-update` contract. - let pinned_commit = pin_if_requested(&dir, git_ref)?; - Ok(WorkspaceCheckout { - root: dir, - pinned_commit, - }) + finalize_workspace_checkout(dir, git_ref) } WorkspaceAction::CloneTo(dir) => { crate::git::clone_repository(&dir)?; - let pinned_commit = pin_if_requested(&dir, git_ref)?; - Ok(WorkspaceCheckout { - root: dir, - pinned_commit, - }) + finalize_workspace_checkout(dir, git_ref) } WorkspaceAction::UpdateAt(dir) => { // Reattach before pulling so a prior detached pin cannot break the // update, even when no new ref is requested. crate::git::ensure_default_branch(&dir)?; crate::git::update_repository(&dir)?; - let pinned_commit = pin_if_requested(&dir, git_ref)?; - Ok(WorkspaceCheckout { - root: dir, - pinned_commit, - }) + finalize_workspace_checkout(dir, git_ref) } } } +/// Applies an optional pin and constructs the resulting workspace checkout. +/// +/// This helper only owns the common tail after workspace setup. Cloning, +/// updating, and reattachment remain responsibilities of [`ensure_workspace`]. +fn finalize_workspace_checkout( + root: Utf8PathBuf, + git_ref: Option<&str>, +) -> Result { + let pinned_commit = pin_if_requested(&root, git_ref)?; + Ok(WorkspaceCheckout { + root, + pinned_commit, + }) +} + /// Refuses `--ref` when the current directory is itself a Whitaker workspace. /// /// Pinning checks out a commit; doing so in the user's own working tree could From c82530a2fa9713eff20400d706aea9a92c428e8c Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 4 Aug 2026 02:36:32 +0200 Subject: [PATCH 12/26] Harden pinned ref resolution and reporting (#271) Validate commit-ish values at the CLI boundary and keep unsupported-ref errors structured so dry-run and real installation reject unsafe pins in the same way. Fetch requested refs into a dedicated local ref before falling back to local state, and report the exact workspace action selected by preparation instead of recomputing filesystem state. Add real-Git, unit, and behaviour coverage for online refresh, offline fallback, matching prebuilt provenance, and operator output. Retain prefix-tolerant provenance matching while rolling producers emit abbreviated commit IDs. --- Cargo.lock | 568 ++++++++++++------ docs/developers-guide.md | 10 +- .../issue-271-ref-pinned-installation.md | 163 +++-- docs/users-guide.md | 7 +- installer/Cargo.toml | 1 - installer/src/cli.rs | 16 +- installer/src/cli_tests.rs | 23 + installer/src/error.rs | 12 +- installer/src/git.rs | 16 +- installer/src/git_tests.rs | 35 +- installer/src/main.rs | 16 +- installer/src/prebuilt_tests.rs | 22 +- installer/src/workspace.rs | 112 ++-- installer/src/workspace_progress.rs | 69 ++- installer/tests/behaviour_cli.rs | 12 +- installer/tests/behaviour_cli/scenarios.rs | 5 + installer/tests/behaviour_cli/support.rs | 18 +- .../tests/behaviour_cli/support/pinned_ref.rs | 14 +- installer/tests/behaviour_prebuilt.rs | 15 + installer/tests/features/installer.feature | 6 + .../tests/features/prebuilt_download.feature | 8 + 21 files changed, 796 insertions(+), 352 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 48f478cb..683c9c0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.9.2" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" dependencies = [ "cipher", "cpubits", @@ -21,9 +21,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -207,7 +207,7 @@ dependencies = [ "rustc_span", "serde", "tempfile", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", "whitaker", "whitaker-common", ] @@ -229,9 +229,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.5" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" dependencies = [ "serde_core", ] @@ -324,9 +324,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.0" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -358,9 +358,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.5" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" dependencies = [ "clap_builder", "clap_derive", @@ -368,9 +368,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.5" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -701,13 +701,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.7" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.119", ] [[package]] @@ -724,9 +724,9 @@ checksum = "9bda8e21c04aca2ae33ffc2fd8c23134f3cac46db123ba97bd9d3f3b8a4a85e1" [[package]] name = "dylint" -version = "6.0.3" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5d2f27acc9d395eabf8b7001ca856d72e49d4c1c5c3c427c60473fb6112a08" +checksum = "c738fb72ea7d248df2995a31b3698beb63d0f1f9ca5a5dc0188c4b64cd0e86e4" dependencies = [ "anstyle", "anyhow", @@ -742,9 +742,9 @@ dependencies = [ [[package]] name = "dylint_internal" -version = "6.0.3" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e56d36d5cf7a909a48854ea667b94421643ae0658868b37e4a842fb3d2c6d30e" +checksum = "9796d3441d7894cbaf4992640799efffc1661978f0cc1266ec788c32254fdfb3" dependencies = [ "anstyle", "anyhow", @@ -757,14 +757,14 @@ dependencies = [ "serde", "tar", "thiserror 2.0.19", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", ] [[package]] name = "dylint_linting" -version = "6.0.3" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "561e06d4cbeeaf506d1bce9e92dc737a83f143e8121d09db825b6d4b4bf55728" +checksum = "c213635b3eaa84f43b9b497377f17d9a8d4feb413bab1d82e03ad1c73e4f6d8c" dependencies = [ "cargo_metadata", "dylint_internal", @@ -772,14 +772,14 @@ dependencies = [ "rustversion", "serde", "thiserror 2.0.19", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", ] [[package]] name = "dylint_testing" -version = "6.0.3" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616d3ae583c7daa06c035fcb4e9c3f1c84e14dc1f8bfc05aefcd57fe72a1902e" +checksum = "4f5f50ee06be9ebfb5b9538ba0d7bb08adc33e8f31c901e7d398de2a9b1ae290" dependencies = [ "anyhow", "cargo_metadata", @@ -795,9 +795,9 @@ dependencies = [ [[package]] name = "either" -version = "1.17.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "encode_unicode" @@ -846,9 +846,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.5.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filetime" @@ -984,6 +984,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fragile" version = "2.1.0" @@ -1038,15 +1047,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1055,9 +1064,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -1066,9 +1075,9 @@ dependencies = [ [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" @@ -1078,9 +1087,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-macro", @@ -1153,14 +1162,17 @@ dependencies = [ [[package]] name = "git2" -version = "0.21.0" +version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" dependencies = [ "bitflags 2.13.1", "libc", "libgit2-sys", "log", + "openssl-probe", + "openssl-sys", + "url", ] [[package]] @@ -1243,9 +1255,9 @@ dependencies = [ [[package]] name = "http" -version = "1.5.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -1259,9 +1271,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] @@ -1313,11 +1325,114 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "ignore" -version = "0.4.32" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b17771570a2b94107741a7b033f19132c2eee21d59d21b24d2ced26500bd66e" +checksum = "d4ffa3a0547a138e59ddd6fa3b7c672ed47e6ad6a3cd177984ff1116aa5ba742" dependencies = [ "crossbeam-deque", "globset", @@ -1406,7 +1521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" dependencies = [ "io-lifetimes 3.0.1", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -1423,9 +1538,9 @@ checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is_terminal_polyfill" @@ -1450,12 +1565,11 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.35" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" dependencies = [ "defmt", - "jiff-core", "jiff-static", "log", "portable-atomic", @@ -1463,22 +1577,12 @@ dependencies = [ "serde_core", ] -[[package]] -name = "jiff-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" -dependencies = [ - "defmt", -] - [[package]] name = "jiff-static" -version = "0.2.35" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" dependencies = [ - "jiff-core", "proc-macro2", "quote", "syn 2.0.119", @@ -1531,23 +1635,39 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libgit2-sys" -version = "0.18.7+1.9.6" +version = "0.18.5+1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89" +checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" dependencies = [ "cc", "libc", + "libssh2-sys", "libz-sys", + "openssl-sys", "pkg-config", ] [[package]] name = "libredox" -version = "0.1.19" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +checksum = "c04141a07bb0c0bc461cb657808764de571702a59bc5c726c400ac9a7625e3ab" dependencies = [ + "cc", "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", ] [[package]] @@ -1568,6 +1688,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -1784,7 +1910,7 @@ dependencies = [ "serde_json", "serial_test", "tempfile", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", "whitaker", "whitaker-common", ] @@ -1849,6 +1975,24 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1935,9 +2079,9 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" @@ -1948,6 +2092,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2036,9 +2189,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.107" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -2070,9 +2223,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.47" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -2370,7 +2523,7 @@ dependencies = [ "rustc_session", "rustc_span", "serde", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", "trybuild", "whitaker", "whitaker-common", @@ -2530,9 +2683,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "log", "once_cell", @@ -2545,9 +2698,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "zeroize", ] @@ -2766,6 +2919,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -2810,6 +2969,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "sys-locale" version = "0.3.2" @@ -2832,9 +3002,9 @@ dependencies = [ [[package]] name = "target-triple" -version = "1.0.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" [[package]] name = "temp-env" @@ -2975,9 +3145,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.55" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", "js-sys", @@ -3016,13 +3186,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.2" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.119", ] [[package]] @@ -3036,9 +3206,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", @@ -3072,9 +3242,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow 1.0.4", ] @@ -3124,9 +3294,9 @@ checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" [[package]] name = "trybuild" -version = "1.0.120" +version = "1.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e605bf6b39357663d8ba4e984f8be8da8df6bb32e81031d6889024ea8fd68e4" +checksum = "06649c6f63d86604ba0c8950d5a1829fc9a17afd70fc6629f481d75b6a624c78" dependencies = [ "glob", "serde", @@ -3134,7 +3304,7 @@ dependencies = [ "serde_json", "target-triple", "termcolor", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", ] [[package]] @@ -3305,12 +3475,30 @@ dependencies = [ "log", ] +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf8-zero" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -3319,9 +3507,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "value-bag" -version = "1.13.2" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" +checksum = "5dd4ec1eb1d240636e354a30110a1dfcb37047169a4d9bd6d9d3469df574b5c4" [[package]] name = "vcpkg" @@ -3416,9 +3604,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -3441,7 +3629,7 @@ dependencies = [ "rustc_span", "serde", "thiserror 2.0.19", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", "whitaker-common", "whitaker-installer", ] @@ -3490,7 +3678,7 @@ dependencies = [ "temp-env", "tempfile", "thiserror 2.0.19", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", "tracing", "trybuild", "ureq", @@ -3518,7 +3706,7 @@ dependencies = [ "sha2", "tempfile", "thiserror 2.0.19", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", "tracing", "whitaker_sarif", ] @@ -3604,7 +3792,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -3613,16 +3801,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -3640,31 +3819,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -3673,96 +3835,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.15" @@ -3794,6 +3908,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "xattr" version = "1.6.1" @@ -3804,20 +3924,43 @@ dependencies = [ "rustix", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", @@ -3829,6 +3972,21 @@ name = "zerofrom" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] [[package]] name = "zeroize" @@ -3836,6 +3994,17 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + [[package]] name = "zerovec" version = "0.11.6" @@ -3843,7 +4012,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "serde", + "yoke", "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -3875,9 +4057,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.7" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" [[package]] name = "zmij" diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 42dc1090..ad8cf93a 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -1872,7 +1872,6 @@ This skips building entirely, providing faster lint runs during development. set of focused private helpers. Understanding them is useful when extending the installation pipeline. - #### Private helper boundaries Installer helpers remain private to the module that owns their side effects. @@ -1882,11 +1881,16 @@ commands that inspect output or interpret a non-zero status must continue to use `run_git_with_timeout` directly. Real-Git regression fixtures and tests belong in `git_tests.rs`, keeping the production adapter focused. The helpers in `workspace_progress.rs` only render operator messages at the CLI edge and must -not clone, update, or pin the checkout themselves. +not clone, update, pin, or rediscover the checkout themselves. +`workspace::resolve_workspace_action` owns the environment-dependent action +selection and is shared with dry-run validation. `ensure_workspace` performs +that action and returns it in `WorkspaceCheckout`; `report_workspace_progress` +must render from this recorded action instead of recomputing repository state. `workspace::finalize_workspace_checkout` is called only from `ensure_workspace` action arms after each arm's current-directory, clone, or update setup. It may combine optional pinning with `WorkspaceCheckout` -construction; it must not clone, update, or reattach a repository. +construction and record the supplied action; it must not select an action or +clone, update, or reattach a repository. Behaviour-test support follows the same ownership rule. `behaviour_cli::support::output_for_assertions` combines scenario-skip handling diff --git a/docs/execplans/issue-271-ref-pinned-installation.md b/docs/execplans/issue-271-ref-pinned-installation.md index fb3e65a1..c7eded3c 100644 --- a/docs/execplans/issue-271-ref-pinned-installation.md +++ b/docs/execplans/issue-271-ref-pinned-installation.md @@ -88,14 +88,13 @@ clone recovers from the detached checkout). Severity: high. Likelihood: certain without mitigation. Mitigation: the update path must reattach the clone to the default branch before pulling (Stage C step 3). This is a required behaviour, tested. -- Risk: the prebuilt manifest's `git_sha` format (full versus abbreviated - SHA) is not yet confirmed, so SHA comparison against a resolved ref could - mismatch. - Severity: medium. Likelihood: medium. - Mitigation: Stage A confirms the format from - `installer/src/artefact/git_sha.rs` and the release workflow; comparison - uses prefix-tolerant matching only if the manifest stores an abbreviated - SHA, otherwise exact equality. +- Risk (resolved in Stage A): the prebuilt manifest's `git_sha` format could + have made comparison against a resolved ref incorrect. Stage A confirmed + that current rolling producers emit abbreviated SHAs. + Severity: medium. Likelihood: resolved. + Resolution: require the resolved full commit SHA to start with the + manifest's abbreviated SHA. Exact full-SHA equality remains rejected while + the producer contract is abbreviated. - Risk: tags in the whitaker repository may not exist for every released installer version, making `--ref v0.2.5` fail for users. Severity: low (documentation issue, not a code defect). @@ -129,15 +128,16 @@ clone recovers from the detached checkout). functions) → green (8 real-git TempDir tests). See . - [x] (2026-07-08) Stage B/C slice 3: workspace plumbing — `WorkspaceCheckout`, - `ensure_workspace(dirs, update, git_ref)`, `RefUnsupported`, pin-on-miss - fetch, `ensure_default_branch` before every update. Red (E0425/E0599) → green. + `ensure_workspace(dirs, update, git_ref)`, `RefUnsupported`, requested-ref + fetching with local fallback, and `ensure_default_branch` before every + update. Red (E0425/E0599) → green. See . - [x] (2026-07-08) Stage B/C slice 4: prebuilt `expected_git_sha` validation (prefix-tolerant) threaded through the fast path. Red (mismatch returned Success) → green (3 unit + 1 BDD). Commit c65281c. - [x] (2026-07-08) Stage B/C slice 5: dry-run `git_ref` field, pinning progress - message, 2 CLI BDD scenarios (pinned dry-run, refuse in workspace). Red + messages, 2 CLI BDD scenarios (pinned dry-run, refuse in workspace). Red (missing "Pinned ref:" line) → green. Commit 4caa7d6. - [x] (2026-07-08) Stage D: documented `--ref` in `docs/users-guide.md` (new "Pinning the suite" subsection) and `README.md`; `--help` examples @@ -157,10 +157,10 @@ clone recovers from the detached checkout). fixture, Git recovery and pinning, capability-write, helper-boundary, and documentation findings remained valid. - [x] (2026-08-01) Converted `git_fixture` to an injected `rstest` fixture; - added real-Git coverage for missing `origin/HEAD` repair and fetch-on-miss - pinning; extracted `finalize_workspace_checkout`; routed prebuilt test writes - through a directory capability; and applied the documentation corrections. -- [x] (2026-08-01) Final review validation passed: `make check-fmt` + added real-Git coverage for missing `origin/HEAD` repair and fetched pinning; + extracted `finalize_workspace_checkout`; routed prebuilt test writes through + a directory capability; and applied the documentation corrections. +- [x] (2026-08-01) Historical review validation passed: `make check-fmt` (`/tmp/final-current-check-fmt-7b3c543c-issue-271-ref-pinned-installation.out`); `make test`, 1,482/1,482 passed and 3 skipped (`/tmp/final-current-test-7b3c543c-issue-271-ref-pinned-installation.out`); @@ -172,6 +172,20 @@ clone recovers from the detached checkout). (`/tmp/final-current-markdownlint-7b3c543c-issue-271-ref-pinned-installation.out`), and Nixie validated all diagrams (`/tmp/final-current-nixie-7b3c543c-issue-271-ref-pinned-installation.out`). +- [x] (2026-08-04) Rebased onto `origin/main` and verified the latest review + findings. The implementation now fetches a requested ref into a dedicated + local ref before resolving it, falling back to local resolution only when + fetch fails; `WorkspaceCheckout` carries the selected `WorkspaceAction` so + progress reporting uses the action that was actually performed. +- [x] (2026-08-04) Final post-rebase validation passed: `make check-fmt` + (`/tmp/issue271-rebase-final-check-fmt.log`); `make test`, 1,688/1,688 + passed, 5 skipped, and 7 slow (`/tmp/issue271-rebase-final-test.log`); + `make typecheck` (`/tmp/issue271-rebase-final-typecheck.log`); and + `make lint`, including Cargo documentation and Clippy + (`/tmp/issue271-rebase-final-lint.log`). Markdownlint initially found one + extra blank line; after the Scribe correction it passed 76 files with 0 + errors (`/tmp/issue271-rebase-docfix-markdownlint.log`). Nixie passed 76 + files and 15 Mermaid diagrams (`/tmp/issue271-rebase-docfix-nixie.log`). ## Surprises & discoveries @@ -195,6 +209,10 @@ clone recovers from the detached checkout). `test_utils::prebuilt_manifest_json` hardcodes `"git_sha":"abc1234"`. The prebuilt SHA-match tests therefore key off that literal: a full SHA beginning `abc1234…` matches; any other value is a mismatch. +- (2026-08-04, review pass) Resolving `FETCH_HEAD` after a fetch is ambiguous + when Git records more than one fetched object. Fetching the requested + refspec into `refs/whitaker/pinned-ref` gives pinning a stable name to resolve + regardless of unrelated tags. ## Decision log @@ -212,11 +230,13 @@ clone recovers from the detached checkout). therefore not reproducible pins; the documentation says so. Date/Author: 2026-07-08, agent. - Decision: `--ref` composes with `--no-update` rather than conflicting. - With both flags, the installer resolves the ref against the existing clone - without fetching; it fetches only when the ref cannot be resolved locally. + With both flags, the installer first attempts to fetch the requested ref into + its dedicated local ref. If fetching fails, it resolves an already-available + local ref or SHA, preserving offline pinned installs. Rationale: "use my existing clone, offline, at this ref" is a coherent and - useful request; fetch-on-miss keeps the common pinned-CI path working. - Date/Author: 2026-07-08, agent. + useful request; fetch-first avoids silently pinning a stale local branch, + while local fallback preserves the offline case. + Date/Author: 2026-08-04, review feedback. - Decision: with `--ref`, the prebuilt fast path is attempted only when the resolved commit SHA matches the rolling manifest's `git_sha`; any mismatch falls back to a source build of the pinned commit. Without `--ref`, @@ -244,11 +264,11 @@ clone recovers from the detached checkout). for the intended reason (recorded in Progress and Artefacts). Date/Author: 2026-07-08, agent. - Decision (implementation): the user sees two messages when pinning — a - pre-checkout `Pinning Whitaker suite to REF...` progress line and, after the - detached checkout resolves, a `Pinned Whitaker suite to REF (SHORT_SHA).` - confirmation. The plan's single "Pinning ... (SHORT_SHA)" line was split - because the short SHA is only known after the pin resolves. - Date/Author: 2026-07-08, agent. + `Pinning Whitaker suite to REF...` progress line followed by a `Pinned + Whitaker suite to REF (SHORT_SHA).` confirmation. Workspace preparation + resolves the pin before reporting; the messages remain separate to preserve + the established progress output while confirming the resolved short SHA. + Date/Author: 2026-08-04, review feedback. - Decision (architecture): `installer/src/git.rs` owns `WHITAKER_REPO_URL`, because it is the only module that uses the value to perform repository operations. `installer/src/workspace.rs` re-exports the @@ -289,6 +309,14 @@ clone recovers from the detached checkout). Rationale: these changes test Git's actual ref behaviour, remove repeated fixture setup, and keep filesystem authority scoped to temporary test data. Date/Author: 2026-08-01, agent. +- Decision (review progress): `ensure_workspace` returns the selected + `WorkspaceAction` in `WorkspaceCheckout`; the reporter renders that recorded + action after setup rather than recomputing it from the current directory and + clone path. Pinning retains its two messages: `Pinning Whitaker suite to + REF...` and, after resolution, `Pinned Whitaker suite to REF (SHORT_SHA).` + Rationale: reporting the executed action prevents filesystem state changes + from making the displayed action diverge from the action that was selected. + Date/Author: 2026-08-04, review feedback. ## Outcomes & retrospective @@ -297,8 +325,9 @@ Delivered across six commits on `issue-271-ref-pinned-installation`: the CLI (`WorkspaceCheckout` + `ensure_workspace(dirs, update, git_ref)`), prebuilt `expected_git_sha` gating, dry-run/progress messaging, and documentation. All gates (`check-fmt`, `lint`, `test`, `markdownlint`) passed before each commit; -the final `make test` ran 1472 tests (1472 passed, 3 skipped). Every new test -failed first for the intended reason (see `Artefacts`). The manual smoke test +the historical initial-implementation `make test` snapshot ran 1,472 tests +(1,472 passed, 3 skipped). Every new test failed first for the intended reason +(see `Artefacts`). The manual smoke test exercised the pin, the in-workspace refusal, the toolchain-embedded staged filename, and the detached-HEAD recovery end-to-end against tag `v0.2.4`. @@ -317,22 +346,35 @@ context structs surfaced literal constructors in the binary's own test modules compile; an early `cargo check --all-targets` sweep catches these. The latest review follow-up now contains the minimal valid changes: reusable -`rstest` Git setup, real-Git regressions for default-branch repair and -fetch-on-miss pinning, capability-scoped prebuilt fixture writes, a private +`rstest` Git setup, real-Git regressions for default-branch repair and fetched +pinning, capability-scoped prebuilt fixture writes, a private checkout finalizer with an explicit orchestration boundary, and terminology corrections. The test-only `cap-std` declaration makes an already-resolved crate available directly to the installer tests; no runtime dependency was added. The requested full-SHA-only comparison remains deliberately -unimplemented because it conflicts with the rolling manifest format. Final -validation passed: 1,482/1,482 tests passed with 3 skipped, and formatting, -type-checking, Cargo documentation, Clippy, Markdownlint, and Nixie were clean. +unimplemented because it conflicts with the rolling manifest format. A +historical review snapshot reported 1,482/1,482 tests passed with 3 skipped; +formatting, type-checking, Cargo documentation, Clippy, Markdownlint, and Nixie +were clean. The 2026-08-01 review follow-up strengthens the real-Git failure and lifecycle coverage, adds SHA-prefix property tests, and removes the workspace-to-git module cycle. `make check-fmt`, `make typecheck`, and `make lint` passed; lint -included rustdoc and Clippy with warnings denied. `make test` passed all 1,481 -tests, with 3 skipped and 1 slow. `make markdownlint` checked 70 files with 0 -errors, and `make nixie` reported all diagrams valid. +included rustdoc and Clippy with warnings denied. That historical pre-rebase +`make test` result was 1,481 tests passed, with 3 skipped and 1 slow. +`make markdownlint` checked 70 files with 0 errors, and `make nixie` reported +all diagrams valid. + +The 2026-08-04 review pass makes pinning fetch the requested ref into a +dedicated local ref before resolving it, with local resolution as the offline +fallback. It also records the selected `WorkspaceAction` in +`WorkspaceCheckout`, so progress output describes the operation that +`ensure_workspace` actually selected. The exact-full-SHA proposal remains +rejected because current rolling producers emit abbreviated SHAs. The +authoritative final `make test` result is 1,688/1,688 passed, with 5 skipped +and 7 slow. Formatting, type-checking, Cargo documentation, Clippy, +Markdownlint, and Nixie also passed; the final documentation checks covered 76 +files and 15 Mermaid diagrams. ## Context and orientation @@ -358,7 +400,10 @@ relative to the repository root: `WHITAKER_REPO_URL`, `WorkspaceAction` (`UseCurrentDir` / `CloneTo` / `UpdateAt` / `UseExisting`), `decide_workspace_action(cwd, clone_dir, update)`, and - `ensure_workspace(dirs, update)` which executes the action. + `resolve_workspace_action(dirs, update)`. The latter selects from the live + environment, while `ensure_workspace(dirs, update, git_ref)` executes that + action and returns it in `WorkspaceCheckout` with the root and optional + pinned commit. - `installer/src/git.rs` — the owning definition of `WHITAKER_REPO_URL`, `clone_repository`, `update_repository`, and the private `run_git_with_timeout(args, working_dir, operation)` helper (5-min timeout, @@ -436,7 +481,8 @@ Stage C — implementation, in five small commits: 2. Git helpers in `installer/src/git.rs`: `resolve_commit(repo, refspec) -> Result` (`git rev-parse --verify ^{commit}`), `fetch_ref(repo, refspec) -> Result` - (`git fetch origin --tags`, then resolve `FETCH_HEAD`), + (fetch the requested refspec into `refs/whitaker/pinned-ref`, then resolve + that dedicated ref), `checkout_detached(repo, commit)` (`git checkout --detach `), and `ensure_default_branch(repo)` (no-op when already on a branch; otherwise discover the default branch per Stage A and check it out). All through @@ -447,21 +493,23 @@ Stage C — implementation, in five small commits: `UseCurrentDir` with a ref → the refusal error; on `CloneTo` → clone then pin; on `UpdateAt` → `ensure_default_branch` then pull then pin; on `UseExisting` with a ref → pin without pulling. "Pin" means: try - `resolve_commit`; on failure `fetch_ref` once and use its resolved - `FETCH_HEAD`; then `checkout_detached`. Crucially, even an unpinned + `fetch_ref` first; if fetching fails, try `resolve_commit` locally; then + `checkout_detached`. Crucially, even an unpinned `UpdateAt` calls `ensure_default_branch` first, fixing the recovery risk. - Return both the workspace path and the resolved commit SHA (a small - `WorkspaceCheckout { root: Utf8PathBuf, pinned_commit: Option }` - struct) so `main.rs` can hand the SHA to the prebuilt path. + Return the selected action, workspace path, and resolved commit SHA in + `WorkspaceCheckout` so `main.rs` can hand the SHA to the prebuilt path and + render progress from the action that was actually selected. 4. Prebuilt: add `expected_git_sha: Option<&'a str>` to `PrebuiltConfig`, validate in `run_pipeline` after the target check using the Stage A comparison rule, and thread the value from `run_install` through `PrebuiltInstallationContext` and `try_prebuilt_installation`. Update the fixed-signature `AttemptPrebuiltFn` type alias and hooks in `install_flow.rs` as needed. -5. Dry run and messaging: add the ref to `DryRunInfo` and its - `display_text`; in `ensure_whitaker_workspace`'s progress messages, print - `Pinning Whitaker suite to REF (SHORT_SHA)...` when a ref is given. +5. Dry run and messaging: add the ref to `DryRunInfo` and its `display_text`. + After workspace setup, pinning progress prints `Pinning Whitaker suite to + REF...`, followed by `Pinned Whitaker suite to REF (SHORT_SHA).` + Workspace-action progress is rendered from `WorkspaceCheckout.action` + rather than by repeating the action-selection inputs. Stage D — documentation and closure: document `--ref` in `docs/users-guide.md` (a "Pinning the suite" subsection: what it accepts, @@ -593,8 +641,8 @@ Red/Green evidence (gate logs under `/tmp/*-whitaker-issue-271*.out`): - Slice 5 (dry-run/messaging): red — `dry_run_display_includes_ref_when_pinned` failed (no `Pinned ref:` line); green 2 output tests + the pinned dry-run and in-workspace refusal CLI BDD scenarios pass. -- Full suite green each slice: final `make test` reported 1472 tests run, - 1472 passed, 3 skipped. +- Full suite green each slice: the historical initial-implementation + `make test` snapshot reported 1,472 tests run, 1,472 passed, and 3 skipped. Review follow-up test inventory (full gates passed): @@ -666,8 +714,14 @@ breaking change; all in-repo callers updated in the same commit): pub struct WorkspaceCheckout { pub root: Utf8PathBuf, pub pinned_commit: Option, + pub action: WorkspaceAction, } +pub fn resolve_workspace_action( + dirs: &dyn BaseDirs, + update: bool, +) -> Result; + pub fn ensure_workspace( dirs: &dyn BaseDirs, update: bool, @@ -705,5 +759,18 @@ This revision uses “artefacts” consistently and clarifies that ordinary no-r behaviour remains rolling while allowing recovery from a detached clone left by a prior pin. It records the verified finding dispositions, the implemented test and helper changes, and the reason full-SHA-only validation was rejected. -Final validation passed, including 1,482/1,482 tests with 3 skipped, formatting, -type-checking, Cargo documentation, Clippy, Markdownlint, and Nixie. +That historical review validation included 1,482/1,482 tests with 3 skipped, +plus formatting, type-checking, Cargo documentation, Clippy, Markdownlint, and +Nixie. + +## Revision note (2026-08-04, rebase review pass) + +This revision marks the abbreviated-SHA risk resolved by Stage A, labels the +1,472, 1,482, and 1,481 test snapshots as historical, and records the +authoritative final result of 1,688/1,688 passed, with 5 skipped and 7 slow. It +synchronizes the plan with fetch-first pinning through a dedicated local ref +and local fallback on fetch failure, action-carrying `WorkspaceCheckout`, and +progress rendered from the selected action. It also records the implemented +two-message pinning output, reaffirms that exact full-SHA validation is +incompatible with abbreviated rolling producers, and records the clean final +formatting, type-checking, lint, Markdownlint, and Nixie results. diff --git a/docs/users-guide.md b/docs/users-guide.md index e5ddb3fe..3f110c7b 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -207,9 +207,10 @@ The pin composes with the other flags: the pinned commit from source. Because the pinned commit supplies its own `rust-toolchain.toml`, the staged libraries are named for that commit's toolchain channel. -- **`--no-update`.** With `--ref --no-update` the installer resolves the - ref against the existing clone without fetching, and only fetches when the - ref cannot be resolved locally. This keeps offline, pinned installs working. +- **`--no-update`.** With `--ref --no-update` the installer skips the + default-branch update but still attempts to fetch the requested ref. If that + fetch fails, it falls back to a ref or SHA already available in the existing + clone, so offline pinned installs continue to work. `--ref` is refused when the current directory is itself a Whitaker workspace, because checking out a commit there could destroy uncommitted work. Run the diff --git a/installer/Cargo.toml b/installer/Cargo.toml index 2d6e033e..16ca039e 100644 --- a/installer/Cargo.toml +++ b/installer/Cargo.toml @@ -78,7 +78,6 @@ zip = { workspace = true } zstd = { workspace = true } [dev-dependencies] -cap-std = { workspace = true } libc = { workspace = true } mockall = { workspace = true } proptest = { workspace = true } diff --git a/installer/src/cli.rs b/installer/src/cli.rs index ad64949e..9bb92bb1 100644 --- a/installer/src/cli.rs +++ b/installer/src/cli.rs @@ -9,6 +9,20 @@ use crate::resolution::EXPERIMENTAL_LINT_CRATES; use camino::Utf8PathBuf; use clap::{Parser, Subcommand}; +/// Validate a commit-ish supplied to `--ref` without normalizing it. +fn parse_git_ref(value: &str) -> Result { + if value.is_empty() { + return Err("ref must not be empty".to_owned()); + } + if value.starts_with('-') { + return Err("ref must not begin with '-'".to_owned()); + } + if value.chars().any(char::is_whitespace) { + return Err("ref must not contain whitespace".to_owned()); + } + Ok(value.to_owned()) +} + /// Install Whitaker Dylint lint libraries. #[derive(Parser, Debug)] #[command(name = "whitaker-installer")] @@ -140,7 +154,7 @@ pub struct InstallArgs { pub is_build_only: bool, /// Install the lint suite at a specific commit SHA or tag [default: rolling]. - #[arg(long = "ref", value_name = "REF")] + #[arg(long = "ref", value_name = "REF", value_parser = parse_git_ref)] pub git_ref: Option, } diff --git a/installer/src/cli_tests.rs b/installer/src/cli_tests.rs index f733edfa..3a31523a 100644 --- a/installer/src/cli_tests.rs +++ b/installer/src/cli_tests.rs @@ -39,6 +39,29 @@ fn cli_parses_ref_flag_under_install_subcommand() { } } +#[rstest] +#[case::empty("--ref=")] +#[case::leading_hyphen("--ref=-release")] +#[case::embedded_space("--ref=release candidate")] +#[case::leading_space("--ref= release-candidate")] +fn cli_rejects_invalid_ref_values(#[case] git_ref: &str) { + assert!( + Cli::try_parse_from(["whitaker-installer", git_ref]).is_err(), + "expected {git_ref:?} to be rejected" + ); +} + +#[rstest] +#[case::tag("v0.2.5")] +#[case::sha("1a2b3c4d")] +#[case::branch("release/candidate")] +#[case::revision_expression("main~2")] +fn cli_preserves_valid_ref_values(#[case] git_ref: &str) { + let cli = Cli::try_parse_from(["whitaker-installer", "--ref", git_ref]) + .expect("valid commit-ish should parse"); + assert_eq!(cli.install.git_ref.as_deref(), Some(git_ref)); +} + #[test] fn cli_parses_target_dir() { let cli = Cli::parse_from(["whitaker-installer", "-t", "/tmp/dylint"]); diff --git a/installer/src/error.rs b/installer/src/error.rs index 390c444f..8a488250 100644 --- a/installer/src/error.rs +++ b/installer/src/error.rs @@ -106,10 +106,12 @@ pub enum InstallerError { }, /// Pinning a ref is not supported for the current directory workspace. - #[error("{message}")] + #[error( + "cannot pin --ref {git_ref}: the current directory is itself a Whitaker workspace; run the installer from outside a checkout to pin the suite" + )] RefUnsupported { - /// The refusal message, naming the requested ref. - message: String, + /// The requested ref that cannot be pinned in the current workspace. + git_ref: String, }, /// A Cargo.toml file could not be parsed during workspace detection. @@ -238,8 +240,8 @@ impl Clone for InstallerError { Self::WorkspaceNotFound { reason } => Self::WorkspaceNotFound { reason: reason.clone(), }, - Self::RefUnsupported { message } => Self::RefUnsupported { - message: message.clone(), + Self::RefUnsupported { git_ref } => Self::RefUnsupported { + git_ref: git_ref.clone(), }, Self::InvalidCargoToml { path, reason } => Self::InvalidCargoToml { path: path.clone(), diff --git a/installer/src/git.rs b/installer/src/git.rs index 34177b1b..0bb01c3b 100644 --- a/installer/src/git.rs +++ b/installer/src/git.rs @@ -16,6 +16,9 @@ const GIT_TIMEOUT: Duration = Duration::from_secs(300); /// Repository URL for cloning Whitaker. pub const WHITAKER_REPO_URL: &str = "https://github.com/leynos/whitaker"; +/// Private ref used to identify the commit fetched for a requested pin. +const PINNED_REF: &str = "refs/whitaker/pinned-ref"; + /// Clones the Whitaker repository to the specified target directory. /// /// Creates the parent directories if they do not exist. The operation has @@ -78,15 +81,20 @@ pub fn resolve_commit(repo: &Utf8Path, refspec: &str) -> Result { /// Fetches a specific ref (and all tags) from `origin` into the repository. /// /// Used to recover when a pinned ref cannot be resolved from the existing -/// clone. Runs `git fetch origin --tags`, then resolves `FETCH_HEAD` -/// to return the exact commit fetched for the requested ref. +/// clone. The requested ref is force-updated into a private local ref so the +/// returned commit cannot be confused with another entry fetched by `--tags`. /// /// # Errors /// /// Returns `InstallerError::Git` if the fetch fails or times out. pub fn fetch_ref(repo: &Utf8Path, refspec: &str) -> Result { - run_git_checked(&["fetch", "origin", refspec, "--tags"], Some(repo), "fetch")?; - resolve_commit(repo, "FETCH_HEAD") + let pinned_refspec = format!("+{refspec}:{PINNED_REF}"); + run_git_checked( + &["fetch", "origin", &pinned_refspec, "--tags"], + Some(repo), + "fetch", + )?; + resolve_commit(repo, PINNED_REF) } /// Checks out a commit as a detached HEAD. diff --git a/installer/src/git_tests.rs b/installer/src/git_tests.rs index 08d79417..d193697b 100644 --- a/installer/src/git_tests.rs +++ b/installer/src/git_tests.rs @@ -182,10 +182,17 @@ fn fetch_ref_retrieves_a_new_tag(git_fixture: GitFixture) { let source = Utf8PathBuf::try_from(git_fixture._source.path().to_owned()).expect("UTF-8 path"); let third = commit_file(&source, "c.txt", "three", "third"); git(&source, &["tag", "v2"]); + commit_file(&source, "d.txt", "four", "unrelated tag commit"); + git(&source, &["tag", "unrelated"]); // The clone cannot resolve the new tag until it fetches. assert!(resolve_commit(&git_fixture.clone, "v2").is_err()); - fetch_ref(&git_fixture.clone, "v2").expect("fetch new tag"); + let fetched = fetch_ref(&git_fixture.clone, "v2").expect("fetch new tag"); + assert_eq!(fetched, third); + assert_eq!( + resolve_commit(&git_fixture.clone, PINNED_REF).expect("resolve private pinned ref"), + third + ); assert_eq!( resolve_commit(&git_fixture.clone, "v2").expect("resolve v2"), third @@ -213,6 +220,32 @@ fn pin_to_ref_fetches_and_checks_out_a_new_remote_branch(git_fixture: GitFixture ); } +#[rstest] +fn pin_to_ref_prefers_an_updated_remote_branch(git_fixture: GitFixture) { + let source = Utf8PathBuf::try_from(git_fixture._source.path().to_owned()).expect("UTF-8 path"); + let third = commit_file(&source, "c.txt", "three", "third"); + + let pinned_commit = crate::workspace::pin_to_ref(&git_fixture.clone, "main") + .expect("fetch and pin updated main"); + + assert_eq!(pinned_commit, third); + assert_eq!(git(&git_fixture.clone, &["rev-parse", "HEAD"]), third); +} + +#[rstest] +fn pin_to_ref_falls_back_to_a_local_ref_offline(git_fixture: GitFixture) { + git(&git_fixture.clone, &["remote", "remove", "origin"]); + + let pinned_commit = crate::workspace::pin_to_ref(&git_fixture.clone, "v1") + .expect("pin locally available tag while offline"); + + assert_eq!(pinned_commit, git_fixture.first); + assert_eq!( + git(&git_fixture.clone, &["rev-parse", "--abbrev-ref", "HEAD"]), + "HEAD" + ); +} + #[test] fn clone_repository_error_includes_operation() { let target = TempDir::new().expect("clone target temp dir"); diff --git a/installer/src/main.rs b/installer/src/main.rs index 7d551d0d..ad7cf164 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -32,7 +32,7 @@ use whitaker_installer::resolution::{ CrateResolutionOptions, resolve_crates, validate_crate_names, }; use whitaker_installer::toolchain::Toolchain; -use whitaker_installer::workspace::WorkspaceCheckout; +use whitaker_installer::workspace::{WorkspaceAction, WorkspaceCheckout}; use whitaker_installer::wrapper::{generate_wrapper_scripts, path_instructions}; fn main() { @@ -172,9 +172,16 @@ fn run_install(args: &InstallArgs, stderr: &mut dyn Write) -> Result<()> { /// Runs in dry-run mode, showing configuration without side effects. fn run_dry(args: &InstallArgs, dirs: &dyn BaseDirs, stderr: &mut dyn Write) -> Result<()> { - use whitaker_installer::workspace::resolve_workspace_path; + use whitaker_installer::workspace::{ensure_ref_allowed, resolve_workspace_action}; - let workspace_root = resolve_workspace_path(dirs)?; + let action = resolve_workspace_action(dirs, !args.no_update)?; + ensure_ref_allowed(&action, args.git_ref.as_deref())?; + let workspace_root = match action { + WorkspaceAction::UseCurrentDir(dir) + | WorkspaceAction::CloneTo(dir) + | WorkspaceAction::UpdateAt(dir) + | WorkspaceAction::UseExisting(dir) => dir, + }; let requested_crates = resolve_requested_crates(args)?; let toolchain = resolve_toolchain(&workspace_root, args.toolchain.as_deref())?; toolchain.verify_installed()?; @@ -227,9 +234,8 @@ fn ensure_whitaker_workspace( use whitaker_installer::workspace::ensure_workspace; let git_ref = args.git_ref.as_deref(); - workspace_progress::report_workspace_progress(args, dirs, stderr); - let checkout = ensure_workspace(dirs, !args.no_update, git_ref)?; + workspace_progress::report_workspace_progress(args, &checkout, stderr); workspace_progress::report_pinned_checkout(args.quiet, git_ref, &checkout, stderr); Ok(checkout) } diff --git a/installer/src/prebuilt_tests.rs b/installer/src/prebuilt_tests.rs index 6354d59a..519fa338 100644 --- a/installer/src/prebuilt_tests.rs +++ b/installer/src/prebuilt_tests.rs @@ -15,7 +15,7 @@ const TARGET: &str = "x86_64-unknown-linux-gnu"; const TOOLCHAIN: &str = "nightly-2026-05-28"; /// A full 40-hex commit SHA beginning with the test manifest's `abc1234`. -const MATCHING_COMMIT: &str = "abc1234000000000000000000000000000000ab"; +const MATCHING_COMMIT: &str = "abc12340000000000000000000000000000000ab"; /// A full 40-hex commit SHA that does not share the manifest's prefix. const MISMATCHED_COMMIT: &str = "deadbeef00000000000000000000000000000000"; @@ -107,7 +107,7 @@ fn base_config(destination_dir: &Utf8Path) -> PrebuiltConfig<'_> { } } -/// Build a happy-path config plus mocks, overriding only `expected_git_sha`. +/// Construct downloader and extractor mocks for the successful prebuilt path. fn success_mocks() -> (MockArtefactDownloader, MockArtefactExtractor) { let fake_sha = sha256_hex(FAKE_ARCHIVE); let manifest_json = prebuilt_manifest_json(TOOLCHAIN, TARGET, &fake_sha); @@ -203,23 +203,7 @@ fn test_fallback_scenario( fn happy_path_returns_success() { let (_temp, destination_dir) = destination_dir(); let config = base_config(&destination_dir); - let fake_sha = sha256_hex(FAKE_ARCHIVE); - let manifest_json = prebuilt_manifest_json(TOOLCHAIN, TARGET, &fake_sha); - - let mut downloader = MockArtefactDownloader::new(); - downloader - .expect_download_manifest() - .returning(move |_| Ok(manifest_json.clone())); - downloader - .expect_download_archive() - .returning(|_filename, dest| std::fs::write(dest, FAKE_ARCHIVE).map_err(DownloadError::Io)); - - let mut extractor = MockArtefactExtractor::new(); - extractor.expect_extract().returning(|_archive, dest| { - let source_name = "libwhitaker_suite.so".to_owned(); - std::fs::write(dest.join(&source_name), b"fake").expect("write extracted file"); - Ok(vec![source_name]) - }); + let (downloader, extractor) = success_mocks(); let mut stderr = Vec::new(); let result = attempt_prebuilt_with(&config, &downloader, &extractor, &mut stderr); diff --git a/installer/src/workspace.rs b/installer/src/workspace.rs index c7829c7b..76093929 100644 --- a/installer/src/workspace.rs +++ b/installer/src/workspace.rs @@ -76,6 +76,23 @@ pub enum WorkspaceAction { UseExisting(Utf8PathBuf), } +/// Selects the action needed to establish the Whitaker workspace. +/// +/// This is the shared, side-effect-free decision boundary used by real and +/// dry-run installation paths. +/// +/// # Errors +/// +/// Returns an error when the current directory or managed clone directory +/// cannot be determined. +pub fn resolve_workspace_action(dirs: &dyn BaseDirs, update: bool) -> Result { + let cwd = current_dir_utf8()?; + let clone_dir = clone_directory(dirs).ok_or_else(|| InstallerError::WorkspaceNotFound { + reason: "could not determine data directory for cloning".to_owned(), + })?; + Ok(decide_workspace_action(&cwd, &clone_dir, update)) +} + /// Determines what action is needed to establish a Whitaker workspace. /// /// Examines the current directory and clone directory state to decide what @@ -107,6 +124,8 @@ pub struct WorkspaceCheckout { pub root: Utf8PathBuf, /// The full commit SHA a `--ref` pin resolved to, if any. pub pinned_commit: Option, + /// The action selected to prepare this workspace. + pub action: WorkspaceAction, } /// Ensures a Whitaker workspace is available, cloning if necessary. @@ -116,12 +135,12 @@ pub struct WorkspaceCheckout { /// directory. Set `update` to `true` to run `git pull` on existing clones. /// /// When `git_ref` is `Some`, the managed clone is pinned to that commit-ish -/// (SHA, tag, or branch): the ref is resolved locally, fetched once on a -/// resolve-miss, and checked out as a detached HEAD. Pinning is refused when -/// the current directory is itself a Whitaker workspace, since that would -/// mutate the user's own working tree. The update path first reattaches a -/// detached clone to its default branch, so a previous pin never breaks a -/// later un-pinned install. +/// (SHA, tag, or branch): the ref is fetched first and checked out as a +/// detached HEAD, falling back to a locally resolvable ref or SHA if fetching +/// fails. Pinning is refused when the current directory is itself a Whitaker +/// workspace, since that would mutate the user's own working tree. The update +/// path first reattaches a detached clone to its default branch, so a previous +/// pin never breaks a later un-pinned install. /// /// # Errors /// @@ -133,32 +152,28 @@ pub fn ensure_workspace( update: bool, git_ref: Option<&str>, ) -> Result { - let cwd = current_dir_utf8()?; - let clone_dir = clone_directory(dirs).ok_or_else(|| InstallerError::WorkspaceNotFound { - reason: "could not determine data directory for cloning".to_owned(), - })?; - - let action = decide_workspace_action(&cwd, &clone_dir, update); + let action = resolve_workspace_action(dirs, update)?; ensure_ref_allowed(&action, git_ref)?; - match action { + let root = match &action { WorkspaceAction::UseCurrentDir(dir) | WorkspaceAction::UseExisting(dir) => { // UseCurrentDir is guaranteed refless by `ensure_ref_allowed`; // UseExisting pins without pulling, per the `--no-update` contract. - finalize_workspace_checkout(dir, git_ref) + dir.clone() } WorkspaceAction::CloneTo(dir) => { - crate::git::clone_repository(&dir)?; - finalize_workspace_checkout(dir, git_ref) + crate::git::clone_repository(dir)?; + dir.clone() } WorkspaceAction::UpdateAt(dir) => { // Reattach before pulling so a prior detached pin cannot break the // update, even when no new ref is requested. - crate::git::ensure_default_branch(&dir)?; - crate::git::update_repository(&dir)?; - finalize_workspace_checkout(dir, git_ref) + crate::git::ensure_default_branch(dir)?; + crate::git::update_repository(dir)?; + dir.clone() } - } + }; + finalize_workspace_checkout(root, git_ref, action) } /// Applies an optional pin and constructs the resulting workspace checkout. @@ -168,11 +183,13 @@ pub fn ensure_workspace( fn finalize_workspace_checkout( root: Utf8PathBuf, git_ref: Option<&str>, + action: WorkspaceAction, ) -> Result { let pinned_commit = pin_if_requested(&root, git_ref)?; Ok(WorkspaceCheckout { root, pinned_commit, + action, }) } @@ -180,13 +197,15 @@ fn finalize_workspace_checkout( /// /// Pinning checks out a commit; doing so in the user's own working tree could /// destroy uncommitted work, so it is rejected rather than attempted. -fn ensure_ref_allowed(action: &WorkspaceAction, git_ref: Option<&str>) -> Result<()> { +/// +/// # Errors +/// +/// Returns [`InstallerError::RefUnsupported`] when `action` uses the current +/// workspace and `git_ref` requests a pin. +pub fn ensure_ref_allowed(action: &WorkspaceAction, git_ref: Option<&str>) -> Result<()> { if let (WorkspaceAction::UseCurrentDir(_), Some(git_ref)) = (action, git_ref) { return Err(InstallerError::RefUnsupported { - message: format!( - "cannot pin --ref {git_ref}: the current directory is itself a Whitaker \ - workspace; run the installer from outside a checkout to pin the suite" - ), + git_ref: git_ref.to_owned(), }); } Ok(()) @@ -202,15 +221,11 @@ fn pin_if_requested(repo: &Utf8Path, git_ref: Option<&str>) -> Result Result { - let commit = match crate::git::resolve_commit(repo, git_ref) { + let commit = match crate::git::fetch_ref(repo, git_ref) { Ok(commit) => commit, - Err(_) => { - // A fetched branch may exist only as a remote-tracking ref, so use - // the commit Git recorded for the explicit fetch. - crate::git::fetch_ref(repo, git_ref)? - } + Err(fetch_error) => crate::git::resolve_commit(repo, git_ref).map_err(|_| fetch_error)?, }; crate::git::checkout_detached(repo, &commit)?; Ok(commit) @@ -222,14 +237,12 @@ pub(super) fn pin_to_ref(repo: &Utf8Path, git_ref: &str) -> Result { /// returns the platform-specific clone directory (which may not exist yet). /// Useful for dry-run mode to show what would happen without cloning. pub fn resolve_workspace_path(dirs: &dyn BaseDirs) -> Result { - let cwd = current_dir_utf8()?; - - if is_whitaker_workspace(&cwd) { - return Ok(cwd); - } - - clone_directory(dirs).ok_or_else(|| InstallerError::WorkspaceNotFound { - reason: "could not determine data directory for cloning".to_owned(), + let action = resolve_workspace_action(dirs, false)?; + Ok(match action { + WorkspaceAction::UseCurrentDir(dir) + | WorkspaceAction::CloneTo(dir) + | WorkspaceAction::UpdateAt(dir) + | WorkspaceAction::UseExisting(dir) => dir, }) } @@ -388,11 +401,22 @@ mod tests { fn ensure_ref_allowed_refuses_current_dir_workspace() { let action = WorkspaceAction::UseCurrentDir(Utf8PathBuf::from("/some/whitaker")); let err = ensure_ref_allowed(&action, Some("v0.2.5")).expect_err("expected refusal"); - assert!( - matches!(err, InstallerError::RefUnsupported { .. }), - "expected RefUnsupported, got {err:?}" + let InstallerError::RefUnsupported { git_ref } = &err else { + panic!("expected RefUnsupported, got {err:?}"); + }; + assert_eq!(git_ref, "v0.2.5"); + assert_eq!( + err.to_string(), + concat!( + "cannot pin --ref v0.2.5: the current directory is itself a Whitaker ", + "workspace; run the installer from outside a checkout to pin the suite" + ) ); - assert!(err.to_string().contains("v0.2.5")); + + let InstallerError::RefUnsupported { git_ref } = err.clone() else { + panic!("cloned error changed variant"); + }; + assert_eq!(git_ref, "v0.2.5"); } #[test] diff --git a/installer/src/workspace_progress.rs b/installer/src/workspace_progress.rs index 7ef1bee8..d41ea1d2 100644 --- a/installer/src/workspace_progress.rs +++ b/installer/src/workspace_progress.rs @@ -1,37 +1,24 @@ //! Operator-facing progress messages for managed workspace operations. //! -//! This module keeps CLI reporting separate from checkout mutation. It predicts -//! the action before installation and reports the resolved pin afterwards. +//! This module keeps CLI reporting separate from checkout mutation. It reports +//! the action recorded by workspace preparation and the resolved pin. -use camino::Utf8PathBuf; use std::io::Write; use whitaker_installer::cli::InstallArgs; -use whitaker_installer::dirs::BaseDirs; use whitaker_installer::output::write_stderr_line; -use whitaker_installer::workspace::{ - WorkspaceAction, WorkspaceCheckout, clone_directory, decide_workspace_action, -}; +use whitaker_installer::workspace::{WorkspaceAction, WorkspaceCheckout}; -/// Reports the workspace action and requested pin before the operation starts. +/// Reports the workspace action and requested pin selected during preparation. pub(super) fn report_workspace_progress( args: &InstallArgs, - dirs: &dyn BaseDirs, + checkout: &WorkspaceCheckout, stderr: &mut dyn Write, ) { if args.quiet { return; } - let Some(clone_dir) = clone_directory(dirs) else { - return; - }; - let Some(cwd) = std::env::current_dir() - .ok() - .and_then(|path| Utf8PathBuf::try_from(path).ok()) - else { - return; - }; - match decide_workspace_action(&cwd, &clone_dir, !args.no_update) { + match &checkout.action { WorkspaceAction::CloneTo(dir) => { write_stderr_line(stderr, format!("Cloning Whitaker repository to {dir}...")); } @@ -75,3 +62,47 @@ fn short_commit(commit: &str) -> &str { let end = commit.len().min(12); &commit[..end] } + +#[cfg(test)] +mod tests { + use super::*; + use camino::Utf8PathBuf; + use rstest::rstest; + + const COMMIT: &str = "abc1234567890000000000000000000000000000"; + + fn pinned_checkout() -> WorkspaceCheckout { + let root = Utf8PathBuf::from("/managed/whitaker"); + WorkspaceCheckout { + root: root.clone(), + pinned_commit: Some(COMMIT.to_owned()), + action: WorkspaceAction::UseExisting(root), + } + } + + #[rstest] + #[case::requested_ref(Some("v0.2.5"), "Pinned Whitaker suite to v0.2.5 (abc123456789).\n")] + #[case::commit_fallback( + None, + "Pinned Whitaker suite to abc1234567890000000000000000000000000000 (abc123456789).\n" + )] + fn pinned_checkout_reports_exact_message( + #[case] git_ref: Option<&str>, + #[case] expected: &str, + ) { + let mut output = Vec::new(); + + report_pinned_checkout(false, git_ref, &pinned_checkout(), &mut output); + + assert_eq!(String::from_utf8(output).expect("UTF-8 output"), expected); + } + + #[test] + fn pinned_checkout_is_silent_in_quiet_mode() { + let mut output = Vec::new(); + + report_pinned_checkout(true, Some("v0.2.5"), &pinned_checkout(), &mut output); + + assert!(output.is_empty()); + } +} diff --git a/installer/tests/behaviour_cli.rs b/installer/tests/behaviour_cli.rs index 9d4c3e5e..b9ad4d13 100644 --- a/installer/tests/behaviour_cli.rs +++ b/installer/tests/behaviour_cli.rs @@ -19,9 +19,10 @@ use support::{ assert_pinned_ref_output_is_shown, assert_ref_unsupported_message_is_shown, assert_suite_library_is_staged, assert_unknown_lint_message_is_shown, configure_dry_run_experimental_lint, configure_dry_run_experimental_lint_with_opt_in, - configure_dry_run_unknown_lint, configure_dry_run_with_pinned_ref, - configure_dry_run_with_target_dir, configure_ref_in_workspace, configure_suite_install, - is_toolchain_installed, pinned_toolchain_channel, run_installer_cli, workspace_root, + configure_dry_run_ref_in_workspace, configure_dry_run_unknown_lint, + configure_dry_run_with_pinned_ref, configure_dry_run_with_target_dir, + configure_ref_in_workspace, configure_suite_install, is_toolchain_installed, + pinned_toolchain_channel, run_installer_cli, workspace_root, }; #[given("the installer is invoked with dry-run and a target directory")] @@ -61,6 +62,11 @@ fn given_ref_in_workspace(cli_world: &CliWorld) { configure_ref_in_workspace(cli_world); } +#[given("the installer is invoked with dry-run and a ref from a Whitaker workspace")] +fn given_dry_run_ref_in_workspace(cli_world: &CliWorld) { + configure_dry_run_ref_in_workspace(cli_world); +} + #[when("the installer CLI is run")] fn when_installer_cli_run(cli_world: &CliWorld) { run_installer_cli(cli_world); diff --git a/installer/tests/behaviour_cli/scenarios.rs b/installer/tests/behaviour_cli/scenarios.rs index e86e500f..c875b2e9 100644 --- a/installer/tests/behaviour_cli/scenarios.rs +++ b/installer/tests/behaviour_cli/scenarios.rs @@ -39,3 +39,8 @@ fn scenario_pin_the_suite_to_a_ref_in_dry_run(cli_world: CliWorld) { fn scenario_refuse_ref_inside_a_whitaker_workspace(cli_world: CliWorld) { let _ = cli_world; } + +#[scenario(path = "tests/features/installer.feature", index = 25)] +fn scenario_refuse_ref_during_dry_run_inside_a_whitaker_workspace(cli_world: CliWorld) { + let _ = cli_world; +} diff --git a/installer/tests/behaviour_cli/support.rs b/installer/tests/behaviour_cli/support.rs index c6373524..f221e320 100644 --- a/installer/tests/behaviour_cli/support.rs +++ b/installer/tests/behaviour_cli/support.rs @@ -5,7 +5,8 @@ mod pinned_ref; pub(super) use pinned_ref::{ assert_pinned_ref_output_is_shown, assert_ref_unsupported_message_is_shown, - configure_dry_run_with_pinned_ref, configure_ref_in_workspace, + configure_dry_run_ref_in_workspace, configure_dry_run_with_pinned_ref, + configure_ref_in_workspace, }; use super::prebuilt_markers::PREBUILT_INSTALL_MARKER; @@ -29,6 +30,8 @@ pub(super) struct CliWorld { toolchain: RefCell>, // Keep temp_dir alive for the lifetime of the scenario. temp_dir: RefCell>, + // Use an external working directory for scenarios that may pin a ref. + working_dir: RefCell>, } #[fixture] @@ -101,6 +104,12 @@ pub(super) fn setup_temp_dir(cli_world: &CliWorld) -> String { target_dir } +pub(super) fn use_external_working_dir(cli_world: &CliWorld) { + cli_world.working_dir.replace(Some( + TempDir::new().expect("failed to create working directory"), + )); +} + fn detect_host_target() -> Option { let output = Command::new("rustc").args(["-vV"]).output().ok()?; if !output.status.success() { @@ -209,7 +218,12 @@ pub(super) fn run_installer_cli(cli_world: &CliWorld) { let args = cli_world.args.borrow(); let mut command = Command::new(env!("CARGO_BIN_EXE_whitaker-installer")); command.args(args.iter()); - command.current_dir(workspace_root()); + let working_dir = cli_world.working_dir.borrow(); + command.current_dir( + working_dir + .as_ref() + .map_or_else(workspace_root, |dir| dir.path().to_owned()), + ); if cli_world.should_use_test_staged_suite.get() { command.env(TEST_STAGE_SUITE_ENV, "1"); } diff --git a/installer/tests/behaviour_cli/support/pinned_ref.rs b/installer/tests/behaviour_cli/support/pinned_ref.rs index 9cf9a151..95e66085 100644 --- a/installer/tests/behaviour_cli/support/pinned_ref.rs +++ b/installer/tests/behaviour_cli/support/pinned_ref.rs @@ -1,6 +1,9 @@ //! Pinned-ref setup and assertions for installer CLI behaviour scenarios. -use super::{CliWorld, ensure_required_toolchain_available, output_for_assertions, setup_temp_dir}; +use super::{ + CliWorld, ensure_required_toolchain_available, output_for_assertions, setup_temp_dir, + use_external_working_dir, +}; /// The ref used by the pinned-install CLI scenarios. const SCENARIO_REF: &str = "v0.2.5"; @@ -11,6 +14,7 @@ pub(crate) fn configure_dry_run_with_pinned_ref(cli_world: &CliWorld) { }; let target_dir = setup_temp_dir(cli_world); + use_external_working_dir(cli_world); cli_world.args.replace(vec![ "--dry-run".to_owned(), "--toolchain".to_owned(), @@ -22,6 +26,14 @@ pub(crate) fn configure_dry_run_with_pinned_ref(cli_world: &CliWorld) { ]); } +pub(crate) fn configure_dry_run_ref_in_workspace(cli_world: &CliWorld) { + cli_world.args.replace(vec![ + "--dry-run".to_owned(), + "--ref".to_owned(), + SCENARIO_REF.to_owned(), + ]); +} + pub(crate) fn configure_ref_in_workspace(cli_world: &CliWorld) { // The harness runs the installer from the Whitaker workspace root, so a // `--ref` must be refused. `--skip-deps` keeps the refusal ahead of any diff --git a/installer/tests/behaviour_prebuilt.rs b/installer/tests/behaviour_prebuilt.rs index cf2de281..32c5dc9b 100644 --- a/installer/tests/behaviour_prebuilt.rs +++ b/installer/tests/behaviour_prebuilt.rs @@ -212,6 +212,13 @@ fn given_pinned_commit_mismatch(world: &mut PrebuiltWorld) { world.expected_git_sha = Some("deadbeef00000000000000000000000000000000".to_owned()); } +#[given("the pinned commit matches the manifest git SHA")] +fn given_pinned_commit_matches(world: &mut PrebuiltWorld) { + // The shared test manifest records git_sha "abc1234"; this full SHA + // preserves that prefix and may reuse the rolling artefact. + world.expected_git_sha = Some("abc12340000000000000000000000000000000ab".to_owned()); +} + #[when("prebuilt download is attempted")] fn when_prebuilt_attempted(world: &mut PrebuiltWorld) { let toolchain = world @@ -404,3 +411,11 @@ fn scenario_build_only(world: PrebuiltWorld) { fn scenario_pinned_ref_mismatch(world: PrebuiltWorld) { let _ = world; } + +#[scenario( + path = "tests/features/prebuilt_download.feature", + name = "Prebuilt succeeds when the pinned ref matches" +)] +fn scenario_pinned_ref_match(world: PrebuiltWorld) { + let _ = world; +} diff --git a/installer/tests/features/installer.feature b/installer/tests/features/installer.feature index 3f4cb03b..23ea9a85 100644 --- a/installer/tests/features/installer.feature +++ b/installer/tests/features/installer.feature @@ -146,3 +146,9 @@ Feature: Whitaker lint library installer When the installer CLI is run Then the CLI exits with an error And a ref-unsupported message is shown + + Scenario: Refuse --ref during dry-run inside a Whitaker workspace + Given the installer is invoked with dry-run and a ref from a Whitaker workspace + When the installer CLI is run + Then the CLI exits with an error + And a ref-unsupported message is shown diff --git a/installer/tests/features/prebuilt_download.feature b/installer/tests/features/prebuilt_download.feature index 14ebd2b4..e4b5f37f 100644 --- a/installer/tests/features/prebuilt_download.feature +++ b/installer/tests/features/prebuilt_download.feature @@ -58,3 +58,11 @@ Feature: Prebuilt artefact download and verification When prebuilt download is attempted Then the prebuilt result is fallback And the fallback reason mentions "SHA mismatch" + + Scenario: Prebuilt succeeds when the pinned ref matches + Given a valid manifest for target "x86_64-unknown-linux-gnu" + And a matching archive with correct checksum + And the pinned commit matches the manifest git SHA + When prebuilt download is attempted + Then the prebuilt result is success + And the staging path uses toolchain, target, and lib directories From 23ce7654e67e2210e9b1b99ab06144d5b6be014d Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 5 Aug 2026 01:00:13 +0200 Subject: [PATCH 13/26] Preserve pinned validation during offline installs (#271) Reject unsafe ref values and unsupported workspace pins before dependency installation can modify the host. Keep prebuilt commit validation active when `--no-update` reuses a detached checkout from an earlier pinned install, without reporting that inherited state as a newly requested pin. Add regression coverage and split oversized test modules to retain the repository's module-size limit. --- installer/src/cli.rs | 7 +- installer/src/cli_tests.rs | 1 + installer/src/git.rs | 14 + installer/src/git_tests.rs | 35 ++- installer/src/install_flow.rs | 18 ++ installer/src/main.rs | 37 ++- installer/src/tests.rs | 20 ++ installer/src/workspace.rs | 255 ++---------------- installer/src/workspace_progress.rs | 1 + installer/src/workspace_tests.rs | 223 +++++++++++++++ installer/tests/behaviour_prebuilt.rs | 33 +-- .../tests/behaviour_prebuilt/pinned_ref.rs | 30 +++ 12 files changed, 400 insertions(+), 274 deletions(-) create mode 100644 installer/src/workspace_tests.rs create mode 100644 installer/tests/behaviour_prebuilt/pinned_ref.rs diff --git a/installer/src/cli.rs b/installer/src/cli.rs index 9bb92bb1..bbbfbc35 100644 --- a/installer/src/cli.rs +++ b/installer/src/cli.rs @@ -17,8 +17,11 @@ fn parse_git_ref(value: &str) -> Result { if value.starts_with('-') { return Err("ref must not begin with '-'".to_owned()); } - if value.chars().any(char::is_whitespace) { - return Err("ref must not contain whitespace".to_owned()); + if value + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + { + return Err("ref must not contain whitespace or control characters".to_owned()); } Ok(value.to_owned()) } diff --git a/installer/src/cli_tests.rs b/installer/src/cli_tests.rs index 3a31523a..54bb5ead 100644 --- a/installer/src/cli_tests.rs +++ b/installer/src/cli_tests.rs @@ -44,6 +44,7 @@ fn cli_parses_ref_flag_under_install_subcommand() { #[case::leading_hyphen("--ref=-release")] #[case::embedded_space("--ref=release candidate")] #[case::leading_space("--ref= release-candidate")] +#[case::ansi_escape("--ref=release\u{1b}[31m")] fn cli_rejects_invalid_ref_values(#[case] git_ref: &str) { assert!( Cli::try_parse_from(["whitaker-installer", git_ref]).is_err(), diff --git a/installer/src/git.rs b/installer/src/git.rs index 0bb01c3b..25b479ee 100644 --- a/installer/src/git.rs +++ b/installer/src/git.rs @@ -109,6 +109,20 @@ pub fn checkout_detached(repo: &Utf8Path, commit: &str) -> Result<()> { run_git_checked(&["checkout", "--detach", commit], Some(repo), "checkout") } +/// Returns the detached HEAD commit, or `None` when HEAD names a branch. +/// +/// # Errors +/// +/// Returns `InstallerError::Git` if HEAD is detached but cannot be resolved. +pub fn detached_head_commit(repo: &Utf8Path) -> Result> { + let symbolic = + run_git_with_timeout(&["symbolic-ref", "-q", "HEAD"], Some(repo), "symbolic-ref")?; + if symbolic.status.success() { + return Ok(None); + } + resolve_commit(repo, "HEAD").map(Some) +} + /// Reattaches the repository to its default branch when HEAD is detached. /// /// A previous pinned install may leave the platform clone on a detached HEAD, diff --git a/installer/src/git_tests.rs b/installer/src/git_tests.rs index d193697b..19df2727 100644 --- a/installer/src/git_tests.rs +++ b/installer/src/git_tests.rs @@ -2,6 +2,7 @@ use super::*; use camino::Utf8PathBuf; +use cap_std::{ambient_authority, fs_utf8::Dir}; use rstest::{fixture, rstest}; use std::process::Command; use tempfile::TempDir; @@ -23,7 +24,9 @@ fn git(dir: &Utf8Path, args: &[&str]) -> String { /// Commit the given file content in `dir` and return the resulting SHA. fn commit_file(dir: &Utf8Path, name: &str, contents: &str, message: &str) -> String { - std::fs::write(dir.join(name).as_std_path(), contents).expect("write fixture file"); + let directory = + Dir::open_ambient_dir(dir, ambient_authority()).expect("open fixture directory"); + directory.write(name, contents).expect("write fixture file"); git(dir, &["add", "."]); git( dir, @@ -121,6 +124,28 @@ fn checkout_detached_leaves_head_at_commit(git_fixture: GitFixture) { assert!(!symbolic.status.success(), "expected detached HEAD"); } +#[rstest] +fn unpinned_no_update_preserves_detached_commit_gate(git_fixture: GitFixture) { + crate::workspace::pin_to_ref(&git_fixture.clone, "v1").expect("pin initial install"); + + let checkout = crate::workspace::finalize_workspace_checkout( + git_fixture.clone.clone(), + None, + crate::workspace::WorkspaceAction::UseExisting(git_fixture.clone.clone()), + ) + .expect("reuse detached checkout without updating"); + + assert_eq!(checkout.pinned_commit, None); + assert_eq!( + checkout.detached_commit.as_deref(), + Some(git_fixture.first.as_str()) + ); + assert_eq!( + checkout.expected_git_sha(), + Some(git_fixture.first.as_str()) + ); +} + #[rstest] fn pinned_checkout_reattaches_for_unpinned_update(git_fixture: GitFixture) { let pinned_commit = @@ -249,8 +274,12 @@ fn pin_to_ref_falls_back_to_a_local_ref_offline(git_fixture: GitFixture) { #[test] fn clone_repository_error_includes_operation() { let target = TempDir::new().expect("clone target temp dir"); - std::fs::write(target.path().join("occupied"), b"occupied").expect("write target file"); - let target_path = Utf8PathBuf::try_from(target.path().to_owned()).expect("UTF-8 path"); + let target_path = Utf8PathBuf::try_from(target.path().to_owned()).expect("UTF-8 target path"); + let target_dir = Dir::open_ambient_dir(&target_path, ambient_authority()) + .expect("open clone target directory"); + target_dir + .write("occupied", b"occupied") + .expect("write target file"); let err = clone_repository(&target_path).expect_err("clone into non-empty target should fail"); diff --git a/installer/src/install_flow.rs b/installer/src/install_flow.rs index c20fd9e4..df973304 100644 --- a/installer/src/install_flow.rs +++ b/installer/src/install_flow.rs @@ -25,6 +25,24 @@ use whitaker_installer::output::write_stderr_line; use whitaker_installer::prebuilt::{PrebuiltConfig, PrebuiltResult, attempt_prebuilt}; use whitaker_installer::prebuilt_path::prebuilt_library_dir; use whitaker_installer::resolution::{EXPERIMENTAL_LINT_CRATES, LINT_CRATES, SUITE_CRATE}; +use whitaker_installer::workspace::{WorkspaceAction, ensure_ref_allowed}; + +/// Validates a ref before invoking the dependency installer. +pub(crate) fn ensure_dependencies_after_ref_validation( + args: &InstallArgs, + action: &WorkspaceAction, + stderr: &mut dyn Write, + ensure_dependencies: F, +) -> Result<()> +where + F: FnOnce(bool, &mut dyn Write) -> Result<()>, +{ + ensure_ref_allowed(action, args.git_ref.as_deref())?; + if !args.skip_deps { + ensure_dependencies(args.quiet, stderr)?; + } + Ok(()) +} pub(crate) fn ensure_dylint_tools_core( quiet: bool, diff --git a/installer/src/main.rs b/installer/src/main.rs index ad7cf164..ece6c6c2 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -12,7 +12,8 @@ mod workspace_progress; use crate::install_flow::ensure_dylint_tools_with_options; use crate::install_flow::{ MetricsWriteContext, PrebuiltInstallationContext, detect_host_target, - ensure_dylint_tools_with_executor, try_prebuilt_installation, write_install_metrics, + ensure_dependencies_after_ref_validation, ensure_dylint_tools_with_executor, + try_prebuilt_installation, write_install_metrics, }; use camino::{Utf8Path, Utf8PathBuf}; use clap::Parser; @@ -94,9 +95,8 @@ fn try_fast_path_installation( /// Runs the install command to build and stage lint libraries. /// -/// Workflow: (1) check/install Dylint dependencies, (2) locate/clone workspace, -/// (3) resolve crates from CLI flags, (4) build in release mode, (5) stage -/// libraries with toolchain-suffixed names, (6) generate wrapper script. +/// Validates the request, prepares dependencies and a workspace, then installs +/// the selected lint libraries and wrapper scripts. /// /// # Errors /// @@ -109,14 +109,11 @@ fn run_install(args: &InstallArgs, stderr: &mut dyn Write) -> Result<()> { return run_dry(args, &dirs, stderr); } let install_started = Instant::now(); - // Step 1: Check and install Dylint dependencies if needed - if !args.skip_deps { - ensure_dylint_tools(args.quiet, stderr)?; - } - // Step 2: Ensure workspace is available (clone if needed) + // Reject unsafe pin requests before dependency installation can mutate the host. + validate_ref_then_ensure_dependencies(args, &dirs, stderr, ensure_dylint_tools)?; let workspace = ensure_whitaker_workspace(args, &dirs, stderr)?; + let expected_git_sha = workspace.expected_git_sha().map(str::to_owned); let workspace_root = workspace.root; - // Step 3: Resolve crates and toolchain let requested_crates = resolve_requested_crates(args)?; let toolchain = resolve_toolchain(&workspace_root, args.toolchain.as_deref())?; ensure_toolchain_installed( @@ -126,14 +123,13 @@ fn run_install(args: &InstallArgs, stderr: &mut dyn Write) -> Result<()> { stderr, )?; let target_dir = determine_target_dir(args.target_dir.as_deref())?; - // Step 3.5: Attempt prebuilt download or staged-suite fast path. let fast_path_context = FastPathContext { args, dirs: &dirs, requested_crates: &requested_crates, toolchain: &toolchain, target_dir: &target_dir, - expected_git_sha: workspace.pinned_commit.as_deref(), + expected_git_sha: expected_git_sha.as_deref(), }; if let Some((staging_path, install_mode)) = try_fast_path_installation(&fast_path_context, stderr)? @@ -156,7 +152,6 @@ fn run_install(args: &InstallArgs, stderr: &mut dyn Write) -> Result<()> { experimental: args.experimental, quiet: args.quiet, }; - // Step 4: Build and stage let build_results = perform_build(&context, &requested_crates, stderr)?; let staging_path = stage_libraries(&context, &build_results, stderr)?; // Step 5: Generate wrapper scripts if requested @@ -203,6 +198,22 @@ fn run_dry(args: &InstallArgs, dirs: &dyn BaseDirs, stderr: &mut dyn Write) -> R Ok(()) } +/// Validates a pin request before permitting dependency installation. +fn validate_ref_then_ensure_dependencies( + args: &InstallArgs, + dirs: &dyn BaseDirs, + stderr: &mut dyn Write, + ensure_dependencies: F, +) -> Result<()> +where + F: FnOnce(bool, &mut dyn Write) -> Result<()>, +{ + use whitaker_installer::workspace::resolve_workspace_action; + + let action = resolve_workspace_action(dirs, !args.no_update)?; + ensure_dependencies_after_ref_validation(args, &action, stderr, ensure_dependencies) +} + fn determine_dry_run_target_dir( args: &InstallArgs, dirs: &dyn BaseDirs, diff --git a/installer/src/tests.rs b/installer/src/tests.rs index 524be606..6600e863 100644 --- a/installer/src/tests.rs +++ b/installer/src/tests.rs @@ -224,6 +224,26 @@ fn ensure_dylint_tools_propagates_install_failures(test_base_dirs: TestBaseDirs) }); } +#[test] +fn unsupported_ref_is_rejected_before_dependency_install() { + let args = InstallArgs { + git_ref: Some("v0.2.5".to_owned()), + ..InstallArgs::default() + }; + let mut stderr = Vec::new(); + let mut installer_was_called = false; + + let action = WorkspaceAction::UseCurrentDir(Utf8PathBuf::from("/workspace/whitaker")); + let error = ensure_dependencies_after_ref_validation(&args, &action, &mut stderr, |_, _| { + installer_was_called = true; + Ok(()) + }) + .expect_err("current workspace must reject a pinned ref"); + + assert!(matches!(error, InstallerError::RefUnsupported { .. })); + assert!(!installer_was_called); +} + #[derive(Debug, Clone)] struct TestBaseDirs { home_dir: Option, diff --git a/installer/src/workspace.rs b/installer/src/workspace.rs index 76093929..271dc9f3 100644 --- a/installer/src/workspace.rs +++ b/installer/src/workspace.rs @@ -124,6 +124,8 @@ pub struct WorkspaceCheckout { pub root: Utf8PathBuf, /// The full commit SHA a `--ref` pin resolved to, if any. pub pinned_commit: Option, + /// The existing detached HEAD reused by an unpinned `--no-update` install. + pub detached_commit: Option, /// The action selected to prepare this workspace. pub action: WorkspaceAction, } @@ -180,19 +182,43 @@ pub fn ensure_workspace( /// /// This helper only owns the common tail after workspace setup. Cloning, /// updating, and reattachment remain responsibilities of [`ensure_workspace`]. -fn finalize_workspace_checkout( +pub(super) fn finalize_workspace_checkout( root: Utf8PathBuf, git_ref: Option<&str>, action: WorkspaceAction, ) -> Result { let pinned_commit = pin_if_requested(&root, git_ref)?; + let detached_commit = inherited_detached_commit(&root, git_ref, &action)?; Ok(WorkspaceCheckout { root, pinned_commit, + detached_commit, action, }) } +/// Detects a detached commit inherited by an unpinned no-update checkout. +fn inherited_detached_commit( + root: &Utf8Path, + git_ref: Option<&str>, + action: &WorkspaceAction, +) -> Result> { + if git_ref.is_none() && matches!(action, WorkspaceAction::UseExisting(_)) { + return crate::git::detached_head_commit(root); + } + Ok(None) +} + +impl WorkspaceCheckout { + /// Returns the commit that a downloaded prebuilt artefact must match. + #[must_use] + pub fn expected_git_sha(&self) -> Option<&str> { + self.pinned_commit + .as_deref() + .or(self.detached_commit.as_deref()) + } +} + /// Refuses `--ref` when the current directory is itself a Whitaker workspace. /// /// Pinning checks out a commit; doing so in the user's own working tree could @@ -297,228 +323,5 @@ fn is_cargo_workspace_root(cargo_toml: &Utf8Path) -> Result { } #[cfg(test)] -mod tests { - use super::*; - use crate::dirs::{MockBaseDirs, SystemBaseDirs}; - use rstest::{fixture, rstest}; - use std::fs; - use std::path::PathBuf; - use tempfile::TempDir; - - /// A temporary directory converted to a UTF-8 path for workspace tests. - struct TempWorkspace { - _temp: TempDir, - path: Utf8PathBuf, - } - - #[fixture] - fn temp_workspace() -> TempWorkspace { - let temp = TempDir::new().expect("failed to create temp dir"); - let path = Utf8PathBuf::try_from(temp.path().to_owned()).expect("non-UTF8 temp path"); - TempWorkspace { _temp: temp, path } - } - - fn write_cargo_toml(dir: &Utf8Path, package_name: &str) { - let cargo_toml = dir.join("Cargo.toml"); - fs::write( - cargo_toml, - format!("[package]\nname = \"{package_name}\"\nversion = \"0.1.0\"\n"), - ) - .expect("failed to write Cargo.toml"); - } - - #[rstest] - #[case::whitaker_project(Some("whitaker"), true)] - #[case::other_project(Some("other-project"), false)] - #[case::empty_dir(None, false)] - fn is_whitaker_workspace_detection( - temp_workspace: TempWorkspace, - #[case] package_name: Option<&str>, - #[case] expected: bool, - ) { - if let Some(name) = package_name { - write_cargo_toml(&temp_workspace.path, name); - } - assert_eq!(is_whitaker_workspace(&temp_workspace.path), expected); - } - - #[test] - fn clone_directory_returns_some_on_supported_platforms() { - // This test may fail on unsupported platforms, but should pass on - // Linux, macOS, and Windows. - let dirs = SystemBaseDirs::new().expect("failed to create SystemBaseDirs"); - let dir = clone_directory(&dirs); - assert!(dir.is_some(), "expected clone_directory to return Some"); - assert!( - dir.as_ref() - .is_some_and(|p| p.as_str().contains("whitaker")), - "expected path to contain 'whitaker'" - ); - } - - #[rstest] - fn decide_workspace_action_uses_cwd_when_whitaker(temp_workspace: TempWorkspace) { - write_cargo_toml(&temp_workspace.path, "whitaker"); - let clone_dir = Utf8PathBuf::from("/nonexistent/clone/dir"); - - let action = decide_workspace_action(&temp_workspace.path, &clone_dir, true); - - assert_eq!(action, WorkspaceAction::UseCurrentDir(temp_workspace.path)); - } - - #[rstest] - fn decide_workspace_action_clones_when_empty(temp_workspace: TempWorkspace) { - // temp_workspace.path is empty (no Cargo.toml), clone_dir doesn't exist - let clone_dir = temp_workspace.path.join("clone_target"); - - let action = decide_workspace_action(&temp_workspace.path, &clone_dir, true); - - assert_eq!(action, WorkspaceAction::CloneTo(clone_dir)); - } - - #[rstest] - fn decide_workspace_action_updates_when_clone_exists(temp_workspace: TempWorkspace) { - // Create a clone directory (not a whitaker workspace, just exists) - let clone_dir = temp_workspace.path.join("clone_target"); - fs::create_dir(&clone_dir).expect("failed to create clone dir"); - - let action = decide_workspace_action(&temp_workspace.path, &clone_dir, true); - - assert_eq!(action, WorkspaceAction::UpdateAt(clone_dir)); - } - - #[rstest] - fn decide_workspace_action_uses_existing_when_no_update(temp_workspace: TempWorkspace) { - let clone_dir = temp_workspace.path.join("clone_target"); - fs::create_dir(&clone_dir).expect("failed to create clone dir"); - - let action = decide_workspace_action(&temp_workspace.path, &clone_dir, false); - - assert_eq!(action, WorkspaceAction::UseExisting(clone_dir)); - } - - #[test] - fn ensure_ref_allowed_refuses_current_dir_workspace() { - let action = WorkspaceAction::UseCurrentDir(Utf8PathBuf::from("/some/whitaker")); - let err = ensure_ref_allowed(&action, Some("v0.2.5")).expect_err("expected refusal"); - let InstallerError::RefUnsupported { git_ref } = &err else { - panic!("expected RefUnsupported, got {err:?}"); - }; - assert_eq!(git_ref, "v0.2.5"); - assert_eq!( - err.to_string(), - concat!( - "cannot pin --ref v0.2.5: the current directory is itself a Whitaker ", - "workspace; run the installer from outside a checkout to pin the suite" - ) - ); - - let InstallerError::RefUnsupported { git_ref } = err.clone() else { - panic!("cloned error changed variant"); - }; - assert_eq!(git_ref, "v0.2.5"); - } - - #[test] - fn ensure_ref_allowed_permits_current_dir_without_ref() { - let action = WorkspaceAction::UseCurrentDir(Utf8PathBuf::from("/some/whitaker")); - assert!(ensure_ref_allowed(&action, None).is_ok()); - } - - #[rstest] - #[case::clone(WorkspaceAction::CloneTo(Utf8PathBuf::from("/clone")))] - #[case::update(WorkspaceAction::UpdateAt(Utf8PathBuf::from("/clone")))] - #[case::existing(WorkspaceAction::UseExisting(Utf8PathBuf::from("/clone")))] - fn ensure_ref_allowed_permits_ref_for_managed_clones(#[case] action: WorkspaceAction) { - assert!(ensure_ref_allowed(&action, Some("v0.2.5")).is_ok()); - } - - // ------------------------------------------------------------------------- - // Behavioural tests for workspace orchestration with mocked dependencies - // ------------------------------------------------------------------------- - - fn mock_dirs_returning(data_dir: Option) -> MockBaseDirs { - let mut mock = MockBaseDirs::new(); - mock.expect_whitaker_data_dir().return_const(data_dir); - mock - } - - #[rstest] - fn resolve_workspace_path_returns_clone_dir_when_not_in_workspace( - temp_workspace: TempWorkspace, - ) { - // Mock returns a data directory inside temp workspace - let expected_dir = temp_workspace.path.join("data").join("whitaker"); - let mock = mock_dirs_returning(Some(expected_dir.clone().into_std_path_buf())); - - let result = resolve_workspace_path(&mock); - - assert!(result.is_ok()); - assert_eq!(result.unwrap(), expected_dir); - } - - #[rstest] - fn resolve_workspace_path_errors_when_data_dir_unavailable(temp_workspace: TempWorkspace) { - let _ = temp_workspace; // Ensure fixture is used - let mock = mock_dirs_returning(None); - - let result = resolve_workspace_path(&mock); - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - matches!(err, InstallerError::WorkspaceNotFound { .. }), - "expected WorkspaceNotFound error, got: {err:?}" - ); - } - - #[test] - fn clone_directory_returns_none_when_data_dir_unavailable() { - let mock = mock_dirs_returning(None); - assert!(clone_directory(&mock).is_none()); - } - - #[rstest] - fn clone_directory_returns_path_from_mock(temp_workspace: TempWorkspace) { - let expected = temp_workspace.path.join("data").join("whitaker"); - let mock = mock_dirs_returning(Some(expected.clone().into_std_path_buf())); - assert_eq!(clone_directory(&mock), Some(expected)); - } - - // Tests for find_workspace_root - - fn write_workspace_cargo_toml(dir: &Utf8Path) { - fs::write( - dir.join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/*\"]\n", - ) - .expect("failed to write workspace Cargo.toml"); - } - - #[rstest] - fn find_workspace_root_finds_workspace_in_current_dir(temp_workspace: TempWorkspace) { - write_workspace_cargo_toml(&temp_workspace.path); - assert_eq!( - find_workspace_root(&temp_workspace.path).unwrap(), - temp_workspace.path - ); - } - - #[rstest] - fn find_workspace_root_finds_workspace_in_parent_dir(temp_workspace: TempWorkspace) { - write_workspace_cargo_toml(&temp_workspace.path); - let subdir = temp_workspace.path.join("crates").join("my_crate"); - fs::create_dir_all(&subdir).expect("failed to create subdirs"); - assert_eq!(find_workspace_root(&subdir).unwrap(), temp_workspace.path); - } - - #[rstest] - fn find_workspace_root_errors_when_no_workspace_found(temp_workspace: TempWorkspace) { - write_cargo_toml(&temp_workspace.path, "not_a_workspace"); - let result = find_workspace_root(&temp_workspace.path); - assert!(matches!( - result.unwrap_err(), - InstallerError::WorkspaceNotFound { .. } - )); - } -} +#[path = "workspace_tests.rs"] +mod tests; diff --git a/installer/src/workspace_progress.rs b/installer/src/workspace_progress.rs index d41ea1d2..e8ad5795 100644 --- a/installer/src/workspace_progress.rs +++ b/installer/src/workspace_progress.rs @@ -76,6 +76,7 @@ mod tests { WorkspaceCheckout { root: root.clone(), pinned_commit: Some(COMMIT.to_owned()), + detached_commit: None, action: WorkspaceAction::UseExisting(root), } } diff --git a/installer/src/workspace_tests.rs b/installer/src/workspace_tests.rs new file mode 100644 index 00000000..d5f76e32 --- /dev/null +++ b/installer/src/workspace_tests.rs @@ -0,0 +1,223 @@ +//! Tests for workspace detection and checkout orchestration. + +use super::*; +use crate::dirs::{MockBaseDirs, SystemBaseDirs}; +use rstest::{fixture, rstest}; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +/// A temporary directory converted to a UTF-8 path for workspace tests. +struct TempWorkspace { + _temp: TempDir, + path: Utf8PathBuf, +} + +#[fixture] +fn temp_workspace() -> TempWorkspace { + let temp = TempDir::new().expect("failed to create temp dir"); + let path = Utf8PathBuf::try_from(temp.path().to_owned()).expect("non-UTF8 temp path"); + TempWorkspace { _temp: temp, path } +} + +fn write_cargo_toml(dir: &Utf8Path, package_name: &str) { + let cargo_toml = dir.join("Cargo.toml"); + fs::write( + cargo_toml, + format!("[package]\nname = \"{package_name}\"\nversion = \"0.1.0\"\n"), + ) + .expect("failed to write Cargo.toml"); +} + +#[rstest] +#[case::whitaker_project(Some("whitaker"), true)] +#[case::other_project(Some("other-project"), false)] +#[case::empty_dir(None, false)] +fn is_whitaker_workspace_detection( + temp_workspace: TempWorkspace, + #[case] package_name: Option<&str>, + #[case] expected: bool, +) { + if let Some(name) = package_name { + write_cargo_toml(&temp_workspace.path, name); + } + assert_eq!(is_whitaker_workspace(&temp_workspace.path), expected); +} + +#[test] +fn clone_directory_returns_some_on_supported_platforms() { + // This test may fail on unsupported platforms, but should pass on + // Linux, macOS, and Windows. + let dirs = SystemBaseDirs::new().expect("failed to create SystemBaseDirs"); + let dir = clone_directory(&dirs); + assert!(dir.is_some(), "expected clone_directory to return Some"); + assert!( + dir.as_ref() + .is_some_and(|p| p.as_str().contains("whitaker")), + "expected path to contain 'whitaker'" + ); +} + +#[rstest] +fn decide_workspace_action_uses_cwd_when_whitaker(temp_workspace: TempWorkspace) { + write_cargo_toml(&temp_workspace.path, "whitaker"); + let clone_dir = Utf8PathBuf::from("/nonexistent/clone/dir"); + + let action = decide_workspace_action(&temp_workspace.path, &clone_dir, true); + + assert_eq!(action, WorkspaceAction::UseCurrentDir(temp_workspace.path)); +} + +#[rstest] +fn decide_workspace_action_clones_when_empty(temp_workspace: TempWorkspace) { + // temp_workspace.path is empty (no Cargo.toml), clone_dir doesn't exist + let clone_dir = temp_workspace.path.join("clone_target"); + + let action = decide_workspace_action(&temp_workspace.path, &clone_dir, true); + + assert_eq!(action, WorkspaceAction::CloneTo(clone_dir)); +} + +#[rstest] +fn decide_workspace_action_updates_when_clone_exists(temp_workspace: TempWorkspace) { + // Create a clone directory (not a whitaker workspace, just exists) + let clone_dir = temp_workspace.path.join("clone_target"); + fs::create_dir(&clone_dir).expect("failed to create clone dir"); + + let action = decide_workspace_action(&temp_workspace.path, &clone_dir, true); + + assert_eq!(action, WorkspaceAction::UpdateAt(clone_dir)); +} + +#[rstest] +fn decide_workspace_action_uses_existing_when_no_update(temp_workspace: TempWorkspace) { + let clone_dir = temp_workspace.path.join("clone_target"); + fs::create_dir(&clone_dir).expect("failed to create clone dir"); + + let action = decide_workspace_action(&temp_workspace.path, &clone_dir, false); + + assert_eq!(action, WorkspaceAction::UseExisting(clone_dir)); +} + +#[test] +fn ensure_ref_allowed_refuses_current_dir_workspace() { + let action = WorkspaceAction::UseCurrentDir(Utf8PathBuf::from("/some/whitaker")); + let err = ensure_ref_allowed(&action, Some("v0.2.5")).expect_err("expected refusal"); + let InstallerError::RefUnsupported { git_ref } = &err else { + panic!("expected RefUnsupported, got {err:?}"); + }; + assert_eq!(git_ref, "v0.2.5"); + assert_eq!( + err.to_string(), + concat!( + "cannot pin --ref v0.2.5: the current directory is itself a Whitaker ", + "workspace; run the installer from outside a checkout to pin the suite" + ) + ); + + let InstallerError::RefUnsupported { git_ref } = err.clone() else { + panic!("cloned error changed variant"); + }; + assert_eq!(git_ref, "v0.2.5"); +} + +#[test] +fn ensure_ref_allowed_permits_current_dir_without_ref() { + let action = WorkspaceAction::UseCurrentDir(Utf8PathBuf::from("/some/whitaker")); + assert!(ensure_ref_allowed(&action, None).is_ok()); +} + +#[rstest] +#[case::clone(WorkspaceAction::CloneTo(Utf8PathBuf::from("/clone")))] +#[case::update(WorkspaceAction::UpdateAt(Utf8PathBuf::from("/clone")))] +#[case::existing(WorkspaceAction::UseExisting(Utf8PathBuf::from("/clone")))] +fn ensure_ref_allowed_permits_ref_for_managed_clones(#[case] action: WorkspaceAction) { + assert!(ensure_ref_allowed(&action, Some("v0.2.5")).is_ok()); +} + +// ------------------------------------------------------------------------- +// Behavioural tests for workspace orchestration with mocked dependencies +// ------------------------------------------------------------------------- + +fn mock_dirs_returning(data_dir: Option) -> MockBaseDirs { + let mut mock = MockBaseDirs::new(); + mock.expect_whitaker_data_dir().return_const(data_dir); + mock +} + +#[rstest] +fn resolve_workspace_path_returns_clone_dir_when_not_in_workspace(temp_workspace: TempWorkspace) { + // Mock returns a data directory inside temp workspace + let expected_dir = temp_workspace.path.join("data").join("whitaker"); + let mock = mock_dirs_returning(Some(expected_dir.clone().into_std_path_buf())); + + let result = resolve_workspace_path(&mock); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), expected_dir); +} + +#[rstest] +fn resolve_workspace_path_errors_when_data_dir_unavailable(temp_workspace: TempWorkspace) { + let _ = temp_workspace; // Ensure fixture is used + let mock = mock_dirs_returning(None); + + let result = resolve_workspace_path(&mock); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(err, InstallerError::WorkspaceNotFound { .. }), + "expected WorkspaceNotFound error, got: {err:?}" + ); +} + +#[test] +fn clone_directory_returns_none_when_data_dir_unavailable() { + let mock = mock_dirs_returning(None); + assert!(clone_directory(&mock).is_none()); +} + +#[rstest] +fn clone_directory_returns_path_from_mock(temp_workspace: TempWorkspace) { + let expected = temp_workspace.path.join("data").join("whitaker"); + let mock = mock_dirs_returning(Some(expected.clone().into_std_path_buf())); + assert_eq!(clone_directory(&mock), Some(expected)); +} + +// Tests for find_workspace_root + +fn write_workspace_cargo_toml(dir: &Utf8Path) { + fs::write( + dir.join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/*\"]\n", + ) + .expect("failed to write workspace Cargo.toml"); +} + +#[rstest] +fn find_workspace_root_finds_workspace_in_current_dir(temp_workspace: TempWorkspace) { + write_workspace_cargo_toml(&temp_workspace.path); + assert_eq!( + find_workspace_root(&temp_workspace.path).unwrap(), + temp_workspace.path + ); +} + +#[rstest] +fn find_workspace_root_finds_workspace_in_parent_dir(temp_workspace: TempWorkspace) { + write_workspace_cargo_toml(&temp_workspace.path); + let subdir = temp_workspace.path.join("crates").join("my_crate"); + fs::create_dir_all(&subdir).expect("failed to create subdirs"); + assert_eq!(find_workspace_root(&subdir).unwrap(), temp_workspace.path); +} + +#[rstest] +fn find_workspace_root_errors_when_no_workspace_found(temp_workspace: TempWorkspace) { + write_cargo_toml(&temp_workspace.path, "not_a_workspace"); + let result = find_workspace_root(&temp_workspace.path); + assert!(matches!( + result.unwrap_err(), + InstallerError::WorkspaceNotFound { .. } + )); +} diff --git a/installer/tests/behaviour_prebuilt.rs b/installer/tests/behaviour_prebuilt.rs index 32c5dc9b..88bb981e 100644 --- a/installer/tests/behaviour_prebuilt.rs +++ b/installer/tests/behaviour_prebuilt.rs @@ -13,6 +13,9 @@ use whitaker_installer::prebuilt::{PrebuiltConfig, PrebuiltResult, attempt_prebu use whitaker_installer::resolution::{CrateResolutionOptions, resolve_crates}; use whitaker_installer::test_utils::{prebuilt_manifest_json, sha256_hex}; +#[path = "behaviour_prebuilt/pinned_ref.rs"] +mod pinned_ref; + const FAKE_ARCHIVE: &[u8] = b"fake archive content"; const DEFAULT_TARGET: &str = "x86_64-unknown-linux-gnu"; const DEFAULT_TOOLCHAIN: &str = "nightly-2026-05-28"; @@ -205,20 +208,6 @@ fn given_destination_path_conflict(world: &mut PrebuiltWorld) { world.force_destination_conflict = true; } -#[given("the pinned commit does not match the manifest git SHA")] -fn given_pinned_commit_mismatch(world: &mut PrebuiltWorld) { - // The shared test manifest records git_sha "abc1234"; this full SHA does - // not share that prefix, so the pinned install cannot reuse the artefact. - world.expected_git_sha = Some("deadbeef00000000000000000000000000000000".to_owned()); -} - -#[given("the pinned commit matches the manifest git SHA")] -fn given_pinned_commit_matches(world: &mut PrebuiltWorld) { - // The shared test manifest records git_sha "abc1234"; this full SHA - // preserves that prefix and may reuse the rolling artefact. - world.expected_git_sha = Some("abc12340000000000000000000000000000000ab".to_owned()); -} - #[when("prebuilt download is attempted")] fn when_prebuilt_attempted(world: &mut PrebuiltWorld) { let toolchain = world @@ -403,19 +392,3 @@ fn scenario_toolchain_mismatch(world: PrebuiltWorld) { fn scenario_build_only(world: PrebuiltWorld) { let _ = world; } - -#[scenario( - path = "tests/features/prebuilt_download.feature", - name = "Prebuilt is skipped when the pinned ref does not match" -)] -fn scenario_pinned_ref_mismatch(world: PrebuiltWorld) { - let _ = world; -} - -#[scenario( - path = "tests/features/prebuilt_download.feature", - name = "Prebuilt succeeds when the pinned ref matches" -)] -fn scenario_pinned_ref_match(world: PrebuiltWorld) { - let _ = world; -} diff --git a/installer/tests/behaviour_prebuilt/pinned_ref.rs b/installer/tests/behaviour_prebuilt/pinned_ref.rs new file mode 100644 index 00000000..0a009da3 --- /dev/null +++ b/installer/tests/behaviour_prebuilt/pinned_ref.rs @@ -0,0 +1,30 @@ +//! Pinned-ref BDD steps and scenarios for prebuilt installation. + +use super::{PrebuiltWorld, world}; +use rstest_bdd_macros::{given, scenario}; + +#[given("the pinned commit does not match the manifest git SHA")] +fn given_pinned_commit_mismatch(world: &mut PrebuiltWorld) { + world.expected_git_sha = Some("deadbeef00000000000000000000000000000000".to_owned()); +} + +#[given("the pinned commit matches the manifest git SHA")] +fn given_pinned_commit_matches(world: &mut PrebuiltWorld) { + world.expected_git_sha = Some("abc12340000000000000000000000000000000ab".to_owned()); +} + +#[scenario( + path = "tests/features/prebuilt_download.feature", + name = "Prebuilt is skipped when the pinned ref does not match" +)] +fn scenario_pinned_ref_mismatch(world: PrebuiltWorld) { + let _ = world; +} + +#[scenario( + path = "tests/features/prebuilt_download.feature", + name = "Prebuilt succeeds when the pinned ref matches" +)] +fn scenario_pinned_ref_match(world: PrebuiltWorld) { + let _ = world; +} From ed6b4f1a4010474267caf208813dc2ffabdeee16 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 15 Aug 2026 16:54:32 +0200 Subject: [PATCH 14/26] Require exact provenance for pinned prebuilts (#271) Accept a downloaded prebuilt artefact for a pinned installation only when its manifest records the resolved 40-character commit exactly. Keep abbreviated manifest provenance available to rolling installs. Exercise equal, abbreviated, and shared-prefix SHA cases with property tests, and configure the pinned BDD success fixture with full provenance. --- installer/src/prebuilt.rs | 20 +++-- installer/src/prebuilt_tests.rs | 73 +++++++++++++++---- .../tests/behaviour_prebuilt/pinned_ref.rs | 14 +++- 3 files changed, 80 insertions(+), 27 deletions(-) diff --git a/installer/src/prebuilt.rs b/installer/src/prebuilt.rs index fd95c0ce..1c1a3e7c 100644 --- a/installer/src/prebuilt.rs +++ b/installer/src/prebuilt.rs @@ -52,12 +52,11 @@ pub struct PrebuiltConfig<'a> { pub destination_dir: &'a Utf8Path, /// When true, suppress progress output. pub quiet: bool, - /// When set, require the manifest git SHA to prefix this pinned commit. + /// When set, require an exact full-object-ID match in the manifest. /// - /// The rolling manifest records an abbreviated SHA, so a pinned install may - /// only reuse the prebuilt artefact when the resolved full commit SHA - /// begins with the manifest's abbreviated SHA; any mismatch falls back to a - /// source build of the pinned commit. + /// Pinned installs may only reuse a prebuilt artefact whose manifest names + /// the resolved full commit SHA exactly; abbreviated provenance falls back + /// to a source build. Rolling installations leave this unset. pub expected_git_sha: Option<&'a str>, } @@ -208,18 +207,17 @@ fn validate_target(manifest: &Manifest, expected: &str) -> Result<(), PrebuiltEr Ok(()) } -/// Validate that the manifest git SHA prefixes the pinned commit, if pinned. +/// Validate exact full-object-ID provenance for a pinned installation. /// -/// The rolling manifest records an abbreviated SHA, so a pinned install may -/// reuse the prebuilt artefact only when the resolved full commit begins with -/// the manifest's abbreviated SHA. When no commit is pinned this is a no-op, -/// leaving the default rolling behaviour unchanged. +/// An abbreviated manifest SHA is suitable for rolling installations only. +/// Pinned installations require a full object ID equal to the resolved commit. +/// When no commit is pinned this is a no-op, preserving rolling behaviour. fn validate_git_sha(manifest: &Manifest, expected: Option<&str>) -> Result<(), PrebuiltError> { let Some(expected) = expected else { return Ok(()); }; let manifest_sha = manifest.git_sha().as_str(); - if !expected.starts_with(manifest_sha) { + if manifest_sha.len() != 40 || manifest_sha != expected { return Err(PrebuiltError::GitShaMismatch { manifest: manifest_sha.to_owned(), expected: expected.to_owned(), diff --git a/installer/src/prebuilt_tests.rs b/installer/src/prebuilt_tests.rs index 519fa338..5d3b8919 100644 --- a/installer/src/prebuilt_tests.rs +++ b/installer/src/prebuilt_tests.rs @@ -62,24 +62,49 @@ fn write_test_file(path: &Path, contents: &[u8]) -> std::io::Result<()> { proptest! { #[test] - fn git_sha_prefixes_across_supported_lengths_are_accepted( + fn equal_full_git_shas_are_accepted( commit in commit_sha_strategy(), - prefix_len in 7_usize..=40, ) { - let manifest = manifest_with_git_sha(&commit[..prefix_len]) + let manifest = manifest_with_git_sha(&commit) .map_err(|error| TestCaseError::fail(error.to_string()))?; prop_assert!(validate_git_sha(&manifest, Some(&commit)).is_ok()); } #[test] - fn changed_nibble_in_git_sha_prefix_is_rejected( + fn abbreviated_manifest_git_shas_are_rejected_for_pinned_installs( commit in commit_sha_strategy(), - prefix_len in 7_usize..=40, + prefix_len in 7_usize..40, ) { - let mut manifest_sha = commit[..prefix_len].to_owned(); - let replacement = if manifest_sha.starts_with('0') { "1" } else { "0" }; - manifest_sha.replace_range(..1, replacement); + let manifest_sha = &commit[..prefix_len]; + let manifest = manifest_with_git_sha(manifest_sha) + .map_err(|error| TestCaseError::fail(error.to_string()))?; + + match validate_git_sha(&manifest, Some(&commit)) { + Err(PrebuiltError::GitShaMismatch { manifest, expected }) => { + prop_assert_eq!(manifest, manifest_sha); + prop_assert_eq!(expected, commit); + } + other => { + return Err(TestCaseError::fail(format!( + "expected GitShaMismatch, got {other:?}" + ))); + } + } + } + + #[test] + fn distinct_full_git_shas_sharing_a_prefix_are_rejected( + commit in commit_sha_strategy(), + shared_prefix_len in 7_usize..40, + ) { + let mut manifest_sha = commit.clone(); + let replacement = if manifest_sha.as_bytes()[shared_prefix_len] == b'0' { + "1" + } else { + "0" + }; + manifest_sha.replace_range(shared_prefix_len..=shared_prefix_len, replacement); let manifest = manifest_with_git_sha(&manifest_sha) .map_err(|error| TestCaseError::fail(error.to_string()))?; @@ -109,8 +134,14 @@ fn base_config(destination_dir: &Utf8Path) -> PrebuiltConfig<'_> { /// Construct downloader and extractor mocks for the successful prebuilt path. fn success_mocks() -> (MockArtefactDownloader, MockArtefactExtractor) { + success_mocks_with_git_sha("abc1234") +} + +/// Construct successful mocks with the supplied manifest provenance. +fn success_mocks_with_git_sha(git_sha: &str) -> (MockArtefactDownloader, MockArtefactExtractor) { let fake_sha = sha256_hex(FAKE_ARCHIVE); - let manifest_json = prebuilt_manifest_json(TOOLCHAIN, TARGET, &fake_sha); + let manifest_json = + prebuilt_manifest_json(TOOLCHAIN, TARGET, &fake_sha).replacen("abc1234", git_sha, 1); let mut downloader = MockArtefactDownloader::new(); downloader .expect_download_manifest() @@ -147,13 +178,27 @@ fn expected_git_sha_mismatch_returns_fallback() { } } -#[rstest] -#[case::matching_commit(Some(MATCHING_COMMIT))] -#[case::unpinned(None)] -fn matching_or_unpinned_git_sha_returns_success(#[case] expected_git_sha: Option<&str>) { +#[test] +fn matching_full_git_sha_returns_success() { + let (_temp, destination_dir) = destination_dir(); + let config = PrebuiltConfig { + expected_git_sha: Some(MATCHING_COMMIT), + ..base_config(&destination_dir) + }; + let (downloader, extractor) = success_mocks_with_git_sha(MATCHING_COMMIT); + let mut stderr = Vec::new(); + let result = attempt_prebuilt_with(&config, &downloader, &extractor, &mut stderr); + assert!( + matches!(result, PrebuiltResult::Success { .. }), + "expected Success, got {result:?}" + ); +} + +#[test] +fn unpinned_git_sha_returns_success_with_an_abbreviated_manifest_sha() { let (_temp, destination_dir) = destination_dir(); let config = PrebuiltConfig { - expected_git_sha, + expected_git_sha: None, ..base_config(&destination_dir) }; let (downloader, extractor) = success_mocks(); diff --git a/installer/tests/behaviour_prebuilt/pinned_ref.rs b/installer/tests/behaviour_prebuilt/pinned_ref.rs index 0a009da3..ab9c3ea1 100644 --- a/installer/tests/behaviour_prebuilt/pinned_ref.rs +++ b/installer/tests/behaviour_prebuilt/pinned_ref.rs @@ -1,7 +1,8 @@ //! Pinned-ref BDD steps and scenarios for prebuilt installation. -use super::{PrebuiltWorld, world}; +use super::{DEFAULT_TOOLCHAIN, FAKE_ARCHIVE, ManifestBehaviour, PrebuiltWorld, world}; use rstest_bdd_macros::{given, scenario}; +use whitaker_installer::test_utils::{prebuilt_manifest_json, sha256_hex}; #[given("the pinned commit does not match the manifest git SHA")] fn given_pinned_commit_mismatch(world: &mut PrebuiltWorld) { @@ -10,7 +11,16 @@ fn given_pinned_commit_mismatch(world: &mut PrebuiltWorld) { #[given("the pinned commit matches the manifest git SHA")] fn given_pinned_commit_matches(world: &mut PrebuiltWorld) { - world.expected_git_sha = Some("abc12340000000000000000000000000000000ab".to_owned()); + let commit = "abc12340000000000000000000000000000000ab"; + world.expected_git_sha = Some(commit.to_owned()); + world.manifest_behaviour = Some(ManifestBehaviour::Ok( + prebuilt_manifest_json( + DEFAULT_TOOLCHAIN, + super::DEFAULT_TARGET, + sha256_hex(FAKE_ARCHIVE), + ) + .replacen("abc1234", commit, 1), + )); } #[scenario( From 7c2b332011c2a44ac81a5efb99f1db08614eaa55 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 15 Aug 2026 17:01:27 +0200 Subject: [PATCH 15/26] Document 0.3.0 installer migration Document the `--ref` migration, index its guide, and record the installer Git-operation contracts for maintainers. --- docs/contents.md | 5 ++++ docs/developers-guide.md | 15 ++++++++++ docs/migrations/0.3.0.md | 63 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 docs/migrations/0.3.0.md diff --git a/docs/contents.md b/docs/contents.md index 446be541..92838002 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -19,6 +19,11 @@ - [Roadmap](roadmap.md) tracks planned work, implementation phases, and larger changes that have not yet landed. +## Migration guides + +- [Migrating to 0.3.0](migrations/0.3.0.md) summarizes installer pinning, + rolling-default behaviour, and the workspace rules for pinned checkouts. + ## Primary design documents - [Whitaker Dylint suite design](whitaker-dylint-suite-design.md) explains the diff --git a/docs/developers-guide.md b/docs/developers-guide.md index ad8cf93a..565d59d2 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -1899,6 +1899,21 @@ remain in their step functions. Pinned-ref scenario setup and assertions stay in `support/pinned_ref.rs`; support for other behaviour suites should stay local unless multiple suites need exactly the same contract. +#### Public Git operation APIs + +The `whitaker_installer::git` module exposes the following Git operations. They +all accept a UTF-8 repository path and return the installer's `Result` type; +each operation is bounded by the module's five-minute Git timeout. Use these +functions for the managed clone workflow, not to mutate a user's current +Whitaker checkout. + +| API | Purpose | Usage constraints | +| --- | --- | --- | +| `resolve_commit(repo: &Utf8Path, refspec: &str) -> Result` | Resolves a local commit-ish (SHA, tag, or branch) to its full commit SHA and peels annotated tags. | Does not fetch. Call it when the ref is expected to exist locally, or after `fetch_ref` has populated the clone. | +| `fetch_ref(repo: &Utf8Path, refspec: &str) -> Result` | Fetches the requested ref and tags from `origin`, records the result in the private `refs/whitaker/pinned-ref` ref, and returns its full commit SHA. | Use it to refresh a requested pin before checkout. It force-updates only the private pin ref; it does not move the current branch or check out the result. | +| `checkout_detached(repo: &Utf8Path, commit: &str) -> Result<()>` | Checks out exactly `commit` with a detached `HEAD`. | Use only for the installer-managed clone after resolving the requested ref. The workspace layer must reject pinning in the user's current Whitaker workspace first. | +| `ensure_default_branch(repo: &Utf8Path) -> Result<()>` | Reattaches a detached clone to the branch named by `origin/HEAD`; repairs a missing `origin/HEAD` with `git remote set-head origin --auto`. | Call before `update_repository` when a previous pin may have detached the managed clone. It is a no-op when `HEAD` already names a branch and does not pull changes itself. | + #### `resolve_additional_components` ```rust diff --git a/docs/migrations/0.3.0.md b/docs/migrations/0.3.0.md new file mode 100644 index 00000000..8c3f6b4d --- /dev/null +++ b/docs/migrations/0.3.0.md @@ -0,0 +1,63 @@ +# Migrating to Whitaker 0.3.0 + +This guide covers the standalone installer changes introduced for the 0.3.0 +release. The existing Cargo workspace metadata integration continues to work; +the migration matters when the installer is responsible for selecting the +Whitaker suite revision. + +## What changes + +The installer now accepts `--ref ` to select the suite revision that it +builds and stages: + +```sh +whitaker-installer --ref +``` + +`REF` may be a tag, a full or abbreviated commit SHA, or a branch name. Use a +release tag or commit SHA for reproducible installations. A branch name is +resolved at install time and therefore moves when the branch moves. The +installer version and the suite revision are separate: installing +`cargo binstall whitaker-installer@0.3.0` selects the installer version, while +`--ref` selects the Whitaker commit it builds; see the +[Pinning the suite](../users-guide.md#pinning-the-suite) section for the +complete option and prebuilt-artefact reference. + +Without `--ref`, the installer continues to use the moving default branch and +the rolling prebuilt artefacts. This remains the default so existing unpinned +commands keep their rolling-update behaviour. Pin the suite explicitly when a +CI or production workflow needs a stable revision. + +## Workspace and checkout behaviour + +- Do not pass `--ref` while running inside a Whitaker source workspace. The + installer refuses this combination rather than checking out over the current + working tree. Run the installer from the consuming project or another + directory so it can use its managed clone. +- A successful pinned install checks out the managed clone at a detached + commit. This keeps the selected revision exact and allows the installer to + match a prebuilt archive to the resolved commit; a mismatch falls back to a + source build of that commit. +- A later unpinned install reattaches a detached managed clone to the remote + default branch before pulling. Run without `--no-update` when the clone + should return to the rolling default and receive updates. +- `--ref` composes with `--no-update`: the installer first tries to fetch the + requested ref and falls back to a ref or SHA already present in the clone if + the fetch is unavailable. This permits offline pinned installs without + changing the requested revision when it is already local. + +## Migration checklist + +1. Keep existing installer invocations unchanged if rolling suite updates are + intended. +2. For reproducible installs, add `--ref` with a release tag or commit SHA to + the installer command used by CI or release automation. +3. Move any pinned invocation out of the Whitaker source checkout and into the + consuming project or another directory. +4. After a temporary pin, run the installer without `--ref` and without + `--no-update` to reattach and update the managed clone to the rolling + default. + +For user-facing installation details, see the [User's Guide](../users-guide.md). +For the implementation-facing Git operation contracts, see the +[Developer's Guide](../developers-guide.md#public-git-operation-apis). From c3405fac2fa6d8488e20996ee991bec0f7e220a1 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 15 Aug 2026 17:15:55 +0200 Subject: [PATCH 16/26] Add installer diagnostics and output contracts (#271) Record structured diagnostics for Git pinning and detached-checkout recovery without changing the installer's CLI output. Assert complete dry-run and workspace-progress messages to protect their user-visible formatting. --- installer/src/git.rs | 37 +++++++++++++++ installer/src/output.rs | 52 +++++++++++++++++---- installer/src/workspace.rs | 12 ++++- installer/src/workspace_progress.rs | 70 +++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 9 deletions(-) diff --git a/installer/src/git.rs b/installer/src/git.rs index 25b479ee..e10b549d 100644 --- a/installer/src/git.rs +++ b/installer/src/git.rs @@ -8,6 +8,7 @@ use crate::error::{InstallerError, Result}; use camino::Utf8Path; use std::process::{Command, Output, Stdio}; use std::time::Duration; +use tracing::{debug, warn}; use wait_timeout::ChildExt; /// Default timeout for git operations (5 minutes). @@ -63,6 +64,10 @@ pub fn update_repository(repo: &Utf8Path) -> Result<()> { /// Returns `InstallerError::Git` if the ref cannot be resolved or the command /// times out. pub fn resolve_commit(repo: &Utf8Path, refspec: &str) -> Result { + debug!( + operation = "rev-parse", + refspec, "resolving requested Git ref" + ); let peeled = format!("{refspec}^{{commit}}"); let output = run_git_with_timeout(&["rev-parse", "--verify", &peeled], Some(repo), "rev-parse")?; @@ -88,6 +93,12 @@ pub fn resolve_commit(repo: &Utf8Path, refspec: &str) -> Result { /// /// Returns `InstallerError::Git` if the fetch fails or times out. pub fn fetch_ref(repo: &Utf8Path, refspec: &str) -> Result { + debug!( + operation = "fetch", + refspec, + attempt = "remote", + "fetching requested Git ref" + ); let pinned_refspec = format!("+{refspec}:{PINNED_REF}"); run_git_checked( &["fetch", "origin", &pinned_refspec, "--tags"], @@ -106,6 +117,12 @@ pub fn fetch_ref(repo: &Utf8Path, refspec: &str) -> Result { /// /// Returns `InstallerError::Git` if the checkout fails or times out. pub fn checkout_detached(repo: &Utf8Path, commit: &str) -> Result<()> { + debug!( + operation = "checkout", + commit, + attempt = "detached", + "checking out pinned commit" + ); run_git_checked(&["checkout", "--detach", commit], Some(repo), "checkout") } @@ -146,6 +163,11 @@ pub fn ensure_default_branch(repo: &Utf8Path) -> Result<()> { return Ok(()); } + debug!( + operation = "checkout", + attempt = "default_branch_recovery", + "reattaching detached checkout" + ); let branch = default_branch_name(repo)?; run_git_checked(&["checkout", &branch], Some(repo), "checkout") } @@ -157,12 +179,22 @@ fn default_branch_name(repo: &Utf8Path) -> Result { } // An older clone may lack origin/HEAD; ask git to repopulate it, then retry. + debug!( + operation = "remote", + attempt = "repair_origin_head", + "repairing origin default branch reference" + ); let _ = run_git_with_timeout( &["remote", "set-head", "origin", "--auto"], Some(repo), "remote", )?; + debug!( + operation = "rev-parse", + attempt = "retry_origin_head", + "retrying default branch discovery" + ); read_default_branch(repo)?.ok_or_else(|| InstallerError::Git { operation: "rev-parse", message: "could not determine default branch from origin/HEAD".to_owned(), @@ -271,6 +303,11 @@ fn run_git_with_timeout( } None => { // Timeout - kill the process and wait for threads to finish + warn!( + operation, + timeout_seconds = GIT_TIMEOUT.as_secs(), + "git operation timed out" + ); let _ = child.kill(); let _ = child.wait(); let _ = stdout_thread.join(); diff --git a/installer/src/output.rs b/installer/src/output.rs index 9ab8f058..f5317de5 100644 --- a/installer/src/output.rs +++ b/installer/src/output.rs @@ -234,18 +234,54 @@ mod tests { } } - #[rstest] - fn dry_run_display_includes_ref_when_pinned() { + #[test] + fn dry_run_display_matches_default_format() { let crates = vec![CrateName::from("whitaker_suite")]; - let text = dry_run_info(Some("v0.2.5"), &crates).display_text(); - assert!(text.contains("Pinned ref: v0.2.5"), "output: {text}"); + let text = dry_run_info(None, &crates).display_text(); + + assert_eq!( + text, + concat!( + "Dry run - no files will be modified\n\n", + "Workspace root: /home/user/whitaker\n", + "Toolchain: nightly-2025-01-15\n", + "Target directory: /home/user/.local/share/dylint/lib\n", + "Verbosity level: 0\n", + "Quiet: false\n", + "Skip deps: false\n", + "Skip wrapper: false\n", + "No update: false\n\n", + "Crates to build:\n", + " - whitaker_suite" + ) + ); } - #[rstest] - fn dry_run_display_omits_ref_when_unpinned() { + #[test] + fn dry_run_display_matches_pinned_format() { let crates = vec![CrateName::from("whitaker_suite")]; - let text = dry_run_info(None, &crates).display_text(); - assert!(!text.contains("Pinned ref:"), "output: {text}"); + let mut info = dry_run_info(Some("v0.2.5"), &crates); + info.jobs = Some(4); + let text = info.display_text(); + + assert_eq!( + text, + concat!( + "Dry run - no files will be modified\n\n", + "Workspace root: /home/user/whitaker\n", + "Toolchain: nightly-2025-01-15\n", + "Target directory: /home/user/.local/share/dylint/lib\n", + "Verbosity level: 0\n", + "Quiet: false\n", + "Skip deps: false\n", + "Skip wrapper: false\n", + "No update: false\n", + "Pinned ref: v0.2.5\n", + "Parallel jobs: 4\n\n", + "Crates to build:\n", + " - whitaker_suite" + ) + ); } #[rstest] diff --git a/installer/src/workspace.rs b/installer/src/workspace.rs index 271dc9f3..4b057775 100644 --- a/installer/src/workspace.rs +++ b/installer/src/workspace.rs @@ -6,6 +6,7 @@ use crate::dirs::BaseDirs; use crate::error::{InstallerError, Result}; use camino::{Utf8Path, Utf8PathBuf}; +use tracing::debug; /// Repository URL for cloning Whitaker. /// @@ -251,7 +252,16 @@ fn pin_if_requested(repo: &Utf8Path, git_ref: Option<&str>) -> Result Result { let commit = match crate::git::fetch_ref(repo, git_ref) { Ok(commit) => commit, - Err(fetch_error) => crate::git::resolve_commit(repo, git_ref).map_err(|_| fetch_error)?, + Err(fetch_error) => { + debug!( + operation = "resolve", + refspec = git_ref, + attempt = "local_fallback", + error = %fetch_error, + "remote pin fetch failed; resolving locally" + ); + crate::git::resolve_commit(repo, git_ref).map_err(|_| fetch_error)? + } }; crate::git::checkout_detached(repo, &commit)?; Ok(commit) diff --git a/installer/src/workspace_progress.rs b/installer/src/workspace_progress.rs index e8ad5795..ffe3e448 100644 --- a/installer/src/workspace_progress.rs +++ b/installer/src/workspace_progress.rs @@ -81,6 +81,76 @@ mod tests { } } + fn checkout(action: WorkspaceAction) -> WorkspaceCheckout { + WorkspaceCheckout { + root: Utf8PathBuf::from("/managed/whitaker"), + pinned_commit: None, + detached_commit: None, + action, + } + } + + #[rstest] + #[case::clone( + WorkspaceAction::CloneTo(Utf8PathBuf::from("/managed/whitaker")), + "Cloning Whitaker repository to /managed/whitaker...\n" + )] + #[case::update( + WorkspaceAction::UpdateAt(Utf8PathBuf::from("/managed/whitaker")), + "Updating Whitaker repository at /managed/whitaker...\n" + )] + fn workspace_progress_reports_exact_action_message( + #[case] action: WorkspaceAction, + #[case] expected: &str, + ) { + let mut output = Vec::new(); + + report_workspace_progress(&InstallArgs::default(), &checkout(action), &mut output); + + assert_eq!(String::from_utf8(output).expect("UTF-8 output"), expected); + } + + #[test] + fn workspace_progress_reports_requested_pin_message() { + let args = InstallArgs { + git_ref: Some("v0.2.5".to_owned()), + ..InstallArgs::default() + }; + let mut output = Vec::new(); + + report_workspace_progress( + &args, + &checkout(WorkspaceAction::UseExisting(Utf8PathBuf::from( + "/managed/whitaker", + ))), + &mut output, + ); + + assert_eq!( + String::from_utf8(output).expect("UTF-8 output"), + "Pinning Whitaker suite to v0.2.5...\n" + ); + } + + #[test] + fn workspace_progress_is_silent_in_quiet_mode() { + let args = InstallArgs { + quiet: true, + ..InstallArgs::default() + }; + let mut output = Vec::new(); + + report_workspace_progress( + &args, + &checkout(WorkspaceAction::CloneTo(Utf8PathBuf::from( + "/managed/whitaker", + ))), + &mut output, + ); + + assert!(output.is_empty()); + } + #[rstest] #[case::requested_ref(Some("v0.2.5"), "Pinned Whitaker suite to v0.2.5 (abc123456789).\n")] #[case::commit_fallback( From 172136e9a7f5c10eb3b8dae09781ff456d3a222c Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 16 Aug 2026 01:20:34 +0200 Subject: [PATCH 17/26] Parameterize dry-run output tests (#271) Unify default and pinned output cases under one `rstest` matrix while retaining the complete output contracts. --- installer/src/output.rs | 91 ++++++++++++++++++++--------------------- 1 file changed, 45 insertions(+), 46 deletions(-) diff --git a/installer/src/output.rs b/installer/src/output.rs index f5317de5..54fe994a 100644 --- a/installer/src/output.rs +++ b/installer/src/output.rs @@ -234,54 +234,53 @@ mod tests { } } - #[test] - fn dry_run_display_matches_default_format() { - let crates = vec![CrateName::from("whitaker_suite")]; - let text = dry_run_info(None, &crates).display_text(); - - assert_eq!( - text, - concat!( - "Dry run - no files will be modified\n\n", - "Workspace root: /home/user/whitaker\n", - "Toolchain: nightly-2025-01-15\n", - "Target directory: /home/user/.local/share/dylint/lib\n", - "Verbosity level: 0\n", - "Quiet: false\n", - "Skip deps: false\n", - "Skip wrapper: false\n", - "No update: false\n\n", - "Crates to build:\n", - " - whitaker_suite" - ) - ); - } - - #[test] - fn dry_run_display_matches_pinned_format() { + #[rstest] + #[case::default( + None, + None, + concat!( + "Dry run - no files will be modified\n\n", + "Workspace root: /home/user/whitaker\n", + "Toolchain: nightly-2025-01-15\n", + "Target directory: /home/user/.local/share/dylint/lib\n", + "Verbosity level: 0\n", + "Quiet: false\n", + "Skip deps: false\n", + "Skip wrapper: false\n", + "No update: false\n\n", + "Crates to build:\n", + " - whitaker_suite" + ) + )] + #[case::pinned( + Some("v0.2.5"), + Some(4), + concat!( + "Dry run - no files will be modified\n\n", + "Workspace root: /home/user/whitaker\n", + "Toolchain: nightly-2025-01-15\n", + "Target directory: /home/user/.local/share/dylint/lib\n", + "Verbosity level: 0\n", + "Quiet: false\n", + "Skip deps: false\n", + "Skip wrapper: false\n", + "No update: false\n", + "Pinned ref: v0.2.5\n", + "Parallel jobs: 4\n\n", + "Crates to build:\n", + " - whitaker_suite" + ) + )] + fn dry_run_display_matches_expected_format( + #[case] git_ref: Option<&str>, + #[case] jobs: Option, + #[case] expected: &str, + ) { let crates = vec![CrateName::from("whitaker_suite")]; - let mut info = dry_run_info(Some("v0.2.5"), &crates); - info.jobs = Some(4); - let text = info.display_text(); + let mut info = dry_run_info(git_ref, &crates); + info.jobs = jobs; - assert_eq!( - text, - concat!( - "Dry run - no files will be modified\n\n", - "Workspace root: /home/user/whitaker\n", - "Toolchain: nightly-2025-01-15\n", - "Target directory: /home/user/.local/share/dylint/lib\n", - "Verbosity level: 0\n", - "Quiet: false\n", - "Skip deps: false\n", - "Skip wrapper: false\n", - "No update: false\n", - "Pinned ref: v0.2.5\n", - "Parallel jobs: 4\n\n", - "Crates to build:\n", - " - whitaker_suite" - ) - ); + assert_eq!(info.display_text(), expected); } #[rstest] From 5f66124220946fdc9637b1bced520dfc092f527c Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 21 Aug 2026 13:45:01 +0200 Subject: [PATCH 18/26] Snapshot installer progress output (#271) Capture dry-run and workspace-progress output in checked-in Insta snapshots. Fetch requested pins without transferring all remote tags, while retaining the existing fetch-first fallback and dedicated private ref. --- Cargo.lock | 1 + docs/developers-guide.md | 2 +- installer/Cargo.toml | 1 + installer/src/git.rs | 10 ++--- installer/src/git_tests.rs | 4 ++ installer/src/output.rs | 42 ++--------------- ...ry_run_display_matches_default_format.snap | 17 +++++++ ...dry_run_display_matches_pinned_format.snap | 19 ++++++++ ...nned_checkout_is_silent_in_quiet_mode.snap | 5 +++ ...nned_checkout_reports_commit_fallback.snap | 5 +++ ...pinned_checkout_reports_requested_ref.snap | 5 +++ ...pace_progress_is_silent_in_quiet_mode.snap | 5 +++ ...kspace_progress_reports_clone_message.snap | 5 +++ ...rogress_reports_requested_pin_message.snap | 5 +++ ...space_progress_reports_update_message.snap | 5 +++ installer/src/workspace_progress.rs | 45 ++++++++++++------- 16 files changed, 114 insertions(+), 62 deletions(-) create mode 100644 installer/src/snapshots/whitaker_installer__output__tests__dry_run_display_matches_default_format.snap create mode 100644 installer/src/snapshots/whitaker_installer__output__tests__dry_run_display_matches_pinned_format.snap create mode 100644 installer/src/snapshots/whitaker_installer__workspace_progress__tests__pinned_checkout_is_silent_in_quiet_mode.snap create mode 100644 installer/src/snapshots/whitaker_installer__workspace_progress__tests__pinned_checkout_reports_commit_fallback.snap create mode 100644 installer/src/snapshots/whitaker_installer__workspace_progress__tests__pinned_checkout_reports_requested_ref.snap create mode 100644 installer/src/snapshots/whitaker_installer__workspace_progress__tests__workspace_progress_is_silent_in_quiet_mode.snap create mode 100644 installer/src/snapshots/whitaker_installer__workspace_progress__tests__workspace_progress_reports_clone_message.snap create mode 100644 installer/src/snapshots/whitaker_installer__workspace_progress__tests__workspace_progress_reports_requested_pin_message.snap create mode 100644 installer/src/snapshots/whitaker_installer__workspace_progress__tests__workspace_progress_reports_update_message.snap diff --git a/Cargo.lock b/Cargo.lock index 683c9c0f..19c70ef3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3664,6 +3664,7 @@ dependencies = [ "directories-next", "flate2", "fs2", + "insta", "libc", "log", "mockall", diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 565d59d2..cd225ea9 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -1910,7 +1910,7 @@ Whitaker checkout. | API | Purpose | Usage constraints | | --- | --- | --- | | `resolve_commit(repo: &Utf8Path, refspec: &str) -> Result` | Resolves a local commit-ish (SHA, tag, or branch) to its full commit SHA and peels annotated tags. | Does not fetch. Call it when the ref is expected to exist locally, or after `fetch_ref` has populated the clone. | -| `fetch_ref(repo: &Utf8Path, refspec: &str) -> Result` | Fetches the requested ref and tags from `origin`, records the result in the private `refs/whitaker/pinned-ref` ref, and returns its full commit SHA. | Use it to refresh a requested pin before checkout. It force-updates only the private pin ref; it does not move the current branch or check out the result. | +| `fetch_ref(repo: &Utf8Path, refspec: &str) -> Result` | Fetches the requested ref from `origin` into the private `refs/whitaker/pinned-ref` ref and returns its full commit SHA. | Use it to refresh a requested pin before checkout. It force-updates only the private pin ref; it does not move the current branch or check out the result. | | `checkout_detached(repo: &Utf8Path, commit: &str) -> Result<()>` | Checks out exactly `commit` with a detached `HEAD`. | Use only for the installer-managed clone after resolving the requested ref. The workspace layer must reject pinning in the user's current Whitaker workspace first. | | `ensure_default_branch(repo: &Utf8Path) -> Result<()>` | Reattaches a detached clone to the branch named by `origin/HEAD`; repairs a missing `origin/HEAD` with `git remote set-head origin --auto`. | Call before `update_repository` when a previous pin may have detached the managed clone. It is a no-op when `HEAD` already names a branch and does not pull changes itself. | diff --git a/installer/Cargo.toml b/installer/Cargo.toml index 16ca039e..37580ade 100644 --- a/installer/Cargo.toml +++ b/installer/Cargo.toml @@ -78,6 +78,7 @@ zip = { workspace = true } zstd = { workspace = true } [dev-dependencies] +insta = { workspace = true } libc = { workspace = true } mockall = { workspace = true } proptest = { workspace = true } diff --git a/installer/src/git.rs b/installer/src/git.rs index e10b549d..ce070fac 100644 --- a/installer/src/git.rs +++ b/installer/src/git.rs @@ -83,11 +83,11 @@ pub fn resolve_commit(repo: &Utf8Path, refspec: &str) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) } -/// Fetches a specific ref (and all tags) from `origin` into the repository. +/// Fetches a specific ref from `origin` into the private pinned ref. /// /// Used to recover when a pinned ref cannot be resolved from the existing /// clone. The requested ref is force-updated into a private local ref so the -/// returned commit cannot be confused with another entry fetched by `--tags`. +/// returned commit is unambiguous. /// /// # Errors /// @@ -100,11 +100,7 @@ pub fn fetch_ref(repo: &Utf8Path, refspec: &str) -> Result { "fetching requested Git ref" ); let pinned_refspec = format!("+{refspec}:{PINNED_REF}"); - run_git_checked( - &["fetch", "origin", &pinned_refspec, "--tags"], - Some(repo), - "fetch", - )?; + run_git_checked(&["fetch", "origin", &pinned_refspec], Some(repo), "fetch")?; resolve_commit(repo, PINNED_REF) } diff --git a/installer/src/git_tests.rs b/installer/src/git_tests.rs index 19df2727..4f4abecd 100644 --- a/installer/src/git_tests.rs +++ b/installer/src/git_tests.rs @@ -222,6 +222,10 @@ fn fetch_ref_retrieves_a_new_tag(git_fixture: GitFixture) { resolve_commit(&git_fixture.clone, "v2").expect("resolve v2"), third ); + assert!( + resolve_commit(&git_fixture.clone, "unrelated").is_err(), + "fetching v2 must not transfer unrelated tags" + ); } #[rstest] diff --git a/installer/src/output.rs b/installer/src/output.rs index 54fe994a..4ccc9015 100644 --- a/installer/src/output.rs +++ b/installer/src/output.rs @@ -235,52 +235,18 @@ mod tests { } #[rstest] - #[case::default( - None, - None, - concat!( - "Dry run - no files will be modified\n\n", - "Workspace root: /home/user/whitaker\n", - "Toolchain: nightly-2025-01-15\n", - "Target directory: /home/user/.local/share/dylint/lib\n", - "Verbosity level: 0\n", - "Quiet: false\n", - "Skip deps: false\n", - "Skip wrapper: false\n", - "No update: false\n\n", - "Crates to build:\n", - " - whitaker_suite" - ) - )] - #[case::pinned( - Some("v0.2.5"), - Some(4), - concat!( - "Dry run - no files will be modified\n\n", - "Workspace root: /home/user/whitaker\n", - "Toolchain: nightly-2025-01-15\n", - "Target directory: /home/user/.local/share/dylint/lib\n", - "Verbosity level: 0\n", - "Quiet: false\n", - "Skip deps: false\n", - "Skip wrapper: false\n", - "No update: false\n", - "Pinned ref: v0.2.5\n", - "Parallel jobs: 4\n\n", - "Crates to build:\n", - " - whitaker_suite" - ) - )] + #[case::default(None, None, "dry_run_display_matches_default_format")] + #[case::pinned(Some("v0.2.5"), Some(4), "dry_run_display_matches_pinned_format")] fn dry_run_display_matches_expected_format( #[case] git_ref: Option<&str>, #[case] jobs: Option, - #[case] expected: &str, + #[case] snapshot_name: &str, ) { let crates = vec![CrateName::from("whitaker_suite")]; let mut info = dry_run_info(git_ref, &crates); info.jobs = jobs; - assert_eq!(info.display_text(), expected); + insta::assert_snapshot!(snapshot_name, info.display_text()); } #[rstest] diff --git a/installer/src/snapshots/whitaker_installer__output__tests__dry_run_display_matches_default_format.snap b/installer/src/snapshots/whitaker_installer__output__tests__dry_run_display_matches_default_format.snap new file mode 100644 index 00000000..c91711b5 --- /dev/null +++ b/installer/src/snapshots/whitaker_installer__output__tests__dry_run_display_matches_default_format.snap @@ -0,0 +1,17 @@ +--- +source: installer/src/output.rs +expression: info.display_text() +--- +Dry run - no files will be modified + +Workspace root: /home/user/whitaker +Toolchain: nightly-2025-01-15 +Target directory: /home/user/.local/share/dylint/lib +Verbosity level: 0 +Quiet: false +Skip deps: false +Skip wrapper: false +No update: false + +Crates to build: + - whitaker_suite diff --git a/installer/src/snapshots/whitaker_installer__output__tests__dry_run_display_matches_pinned_format.snap b/installer/src/snapshots/whitaker_installer__output__tests__dry_run_display_matches_pinned_format.snap new file mode 100644 index 00000000..4f762e60 --- /dev/null +++ b/installer/src/snapshots/whitaker_installer__output__tests__dry_run_display_matches_pinned_format.snap @@ -0,0 +1,19 @@ +--- +source: installer/src/output.rs +expression: info.display_text() +--- +Dry run - no files will be modified + +Workspace root: /home/user/whitaker +Toolchain: nightly-2025-01-15 +Target directory: /home/user/.local/share/dylint/lib +Verbosity level: 0 +Quiet: false +Skip deps: false +Skip wrapper: false +No update: false +Pinned ref: v0.2.5 +Parallel jobs: 4 + +Crates to build: + - whitaker_suite diff --git a/installer/src/snapshots/whitaker_installer__workspace_progress__tests__pinned_checkout_is_silent_in_quiet_mode.snap b/installer/src/snapshots/whitaker_installer__workspace_progress__tests__pinned_checkout_is_silent_in_quiet_mode.snap new file mode 100644 index 00000000..2d1ab0dd --- /dev/null +++ b/installer/src/snapshots/whitaker_installer__workspace_progress__tests__pinned_checkout_is_silent_in_quiet_mode.snap @@ -0,0 +1,5 @@ +--- +source: installer/src/workspace_progress.rs +expression: "format!(\"{output:?}\")" +--- +"" diff --git a/installer/src/snapshots/whitaker_installer__workspace_progress__tests__pinned_checkout_reports_commit_fallback.snap b/installer/src/snapshots/whitaker_installer__workspace_progress__tests__pinned_checkout_reports_commit_fallback.snap new file mode 100644 index 00000000..44335043 --- /dev/null +++ b/installer/src/snapshots/whitaker_installer__workspace_progress__tests__pinned_checkout_reports_commit_fallback.snap @@ -0,0 +1,5 @@ +--- +source: installer/src/workspace_progress.rs +expression: "String::from_utf8(output).expect(\"UTF-8 output\")" +--- +Pinned Whitaker suite to abc1234567890000000000000000000000000000 (abc123456789). diff --git a/installer/src/snapshots/whitaker_installer__workspace_progress__tests__pinned_checkout_reports_requested_ref.snap b/installer/src/snapshots/whitaker_installer__workspace_progress__tests__pinned_checkout_reports_requested_ref.snap new file mode 100644 index 00000000..2911f238 --- /dev/null +++ b/installer/src/snapshots/whitaker_installer__workspace_progress__tests__pinned_checkout_reports_requested_ref.snap @@ -0,0 +1,5 @@ +--- +source: installer/src/workspace_progress.rs +expression: "String::from_utf8(output).expect(\"UTF-8 output\")" +--- +Pinned Whitaker suite to v0.2.5 (abc123456789). diff --git a/installer/src/snapshots/whitaker_installer__workspace_progress__tests__workspace_progress_is_silent_in_quiet_mode.snap b/installer/src/snapshots/whitaker_installer__workspace_progress__tests__workspace_progress_is_silent_in_quiet_mode.snap new file mode 100644 index 00000000..2d1ab0dd --- /dev/null +++ b/installer/src/snapshots/whitaker_installer__workspace_progress__tests__workspace_progress_is_silent_in_quiet_mode.snap @@ -0,0 +1,5 @@ +--- +source: installer/src/workspace_progress.rs +expression: "format!(\"{output:?}\")" +--- +"" diff --git a/installer/src/snapshots/whitaker_installer__workspace_progress__tests__workspace_progress_reports_clone_message.snap b/installer/src/snapshots/whitaker_installer__workspace_progress__tests__workspace_progress_reports_clone_message.snap new file mode 100644 index 00000000..a9669983 --- /dev/null +++ b/installer/src/snapshots/whitaker_installer__workspace_progress__tests__workspace_progress_reports_clone_message.snap @@ -0,0 +1,5 @@ +--- +source: installer/src/workspace_progress.rs +expression: "String::from_utf8(output).expect(\"UTF-8 output\")" +--- +Cloning Whitaker repository to /managed/whitaker... diff --git a/installer/src/snapshots/whitaker_installer__workspace_progress__tests__workspace_progress_reports_requested_pin_message.snap b/installer/src/snapshots/whitaker_installer__workspace_progress__tests__workspace_progress_reports_requested_pin_message.snap new file mode 100644 index 00000000..b480a2a1 --- /dev/null +++ b/installer/src/snapshots/whitaker_installer__workspace_progress__tests__workspace_progress_reports_requested_pin_message.snap @@ -0,0 +1,5 @@ +--- +source: installer/src/workspace_progress.rs +expression: "String::from_utf8(output).expect(\"UTF-8 output\")" +--- +Pinning Whitaker suite to v0.2.5... diff --git a/installer/src/snapshots/whitaker_installer__workspace_progress__tests__workspace_progress_reports_update_message.snap b/installer/src/snapshots/whitaker_installer__workspace_progress__tests__workspace_progress_reports_update_message.snap new file mode 100644 index 00000000..a24d50af --- /dev/null +++ b/installer/src/snapshots/whitaker_installer__workspace_progress__tests__workspace_progress_reports_update_message.snap @@ -0,0 +1,5 @@ +--- +source: installer/src/workspace_progress.rs +expression: "String::from_utf8(output).expect(\"UTF-8 output\")" +--- +Updating Whitaker repository at /managed/whitaker... diff --git a/installer/src/workspace_progress.rs b/installer/src/workspace_progress.rs index ffe3e448..8b127374 100644 --- a/installer/src/workspace_progress.rs +++ b/installer/src/workspace_progress.rs @@ -93,21 +93,24 @@ mod tests { #[rstest] #[case::clone( WorkspaceAction::CloneTo(Utf8PathBuf::from("/managed/whitaker")), - "Cloning Whitaker repository to /managed/whitaker...\n" + "workspace_progress_reports_clone_message" )] #[case::update( WorkspaceAction::UpdateAt(Utf8PathBuf::from("/managed/whitaker")), - "Updating Whitaker repository at /managed/whitaker...\n" + "workspace_progress_reports_update_message" )] fn workspace_progress_reports_exact_action_message( #[case] action: WorkspaceAction, - #[case] expected: &str, + #[case] snapshot_name: &str, ) { let mut output = Vec::new(); report_workspace_progress(&InstallArgs::default(), &checkout(action), &mut output); - assert_eq!(String::from_utf8(output).expect("UTF-8 output"), expected); + insta::assert_snapshot!( + snapshot_name, + String::from_utf8(output).expect("UTF-8 output") + ); } #[test] @@ -126,9 +129,9 @@ mod tests { &mut output, ); - assert_eq!( - String::from_utf8(output).expect("UTF-8 output"), - "Pinning Whitaker suite to v0.2.5...\n" + insta::assert_snapshot!( + "workspace_progress_reports_requested_pin_message", + String::from_utf8(output).expect("UTF-8 output") ); } @@ -148,24 +151,29 @@ mod tests { &mut output, ); - assert!(output.is_empty()); + let output = String::from_utf8(output).expect("UTF-8 output"); + + insta::assert_snapshot!( + "workspace_progress_is_silent_in_quiet_mode", + format!("{output:?}") + ); } #[rstest] - #[case::requested_ref(Some("v0.2.5"), "Pinned Whitaker suite to v0.2.5 (abc123456789).\n")] - #[case::commit_fallback( - None, - "Pinned Whitaker suite to abc1234567890000000000000000000000000000 (abc123456789).\n" - )] + #[case::requested_ref(Some("v0.2.5"), "pinned_checkout_reports_requested_ref")] + #[case::commit_fallback(None, "pinned_checkout_reports_commit_fallback")] fn pinned_checkout_reports_exact_message( #[case] git_ref: Option<&str>, - #[case] expected: &str, + #[case] snapshot_name: &str, ) { let mut output = Vec::new(); report_pinned_checkout(false, git_ref, &pinned_checkout(), &mut output); - assert_eq!(String::from_utf8(output).expect("UTF-8 output"), expected); + insta::assert_snapshot!( + snapshot_name, + String::from_utf8(output).expect("UTF-8 output") + ); } #[test] @@ -174,6 +182,11 @@ mod tests { report_pinned_checkout(true, Some("v0.2.5"), &pinned_checkout(), &mut output); - assert!(output.is_empty()); + let output = String::from_utf8(output).expect("UTF-8 output"); + + insta::assert_snapshot!( + "pinned_checkout_is_silent_in_quiet_mode", + format!("{output:?}") + ); } } From e8b880a226cc8dc2eaae7a74d9768ea0d30582b8 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 21 Aug 2026 14:01:32 +0200 Subject: [PATCH 19/26] Harden installer provenance test fixtures (#271) Parameterize the prebuilt provenance gate and construct its manifests without positional JSON replacement. Run workspace fixtures through capability-scoped directories and retain failure-specific result context. --- installer/src/prebuilt_tests.rs | 78 +++++++++++++------------------- installer/src/workspace_tests.rs | 68 ++++++++++++++++++---------- 2 files changed, 74 insertions(+), 72 deletions(-) diff --git a/installer/src/prebuilt_tests.rs b/installer/src/prebuilt_tests.rs index 5d3b8919..ea6cd563 100644 --- a/installer/src/prebuilt_tests.rs +++ b/installer/src/prebuilt_tests.rs @@ -30,7 +30,7 @@ fn commit_sha_strategy() -> impl Strategy { }) } -fn manifest_with_git_sha(git_sha: &str) -> serde_json::Result { +fn manifest_with_git_sha(git_sha: &str, sha256: &str) -> serde_json::Result { serde_json::from_value(serde_json::json!({ "git_sha": git_sha, "schema_version": 1, @@ -38,7 +38,7 @@ fn manifest_with_git_sha(git_sha: &str) -> serde_json::Result { "target": TARGET, "generated_at": "2026-02-03T00:00:00Z", "files": ["libwhitaker_suite.so"], - "sha256": "a".repeat(64), + "sha256": sha256, })) } @@ -65,7 +65,7 @@ proptest! { fn equal_full_git_shas_are_accepted( commit in commit_sha_strategy(), ) { - let manifest = manifest_with_git_sha(&commit) + let manifest = manifest_with_git_sha(&commit, &"a".repeat(64)) .map_err(|error| TestCaseError::fail(error.to_string()))?; prop_assert!(validate_git_sha(&manifest, Some(&commit)).is_ok()); @@ -77,7 +77,7 @@ proptest! { prefix_len in 7_usize..40, ) { let manifest_sha = &commit[..prefix_len]; - let manifest = manifest_with_git_sha(manifest_sha) + let manifest = manifest_with_git_sha(manifest_sha, &"a".repeat(64)) .map_err(|error| TestCaseError::fail(error.to_string()))?; match validate_git_sha(&manifest, Some(&commit)) { @@ -105,7 +105,7 @@ proptest! { "0" }; manifest_sha.replace_range(shared_prefix_len..=shared_prefix_len, replacement); - let manifest = manifest_with_git_sha(&manifest_sha) + let manifest = manifest_with_git_sha(&manifest_sha, &"a".repeat(64)) .map_err(|error| TestCaseError::fail(error.to_string()))?; match validate_git_sha(&manifest, Some(&commit)) { @@ -140,8 +140,9 @@ fn success_mocks() -> (MockArtefactDownloader, MockArtefactExtractor) { /// Construct successful mocks with the supplied manifest provenance. fn success_mocks_with_git_sha(git_sha: &str) -> (MockArtefactDownloader, MockArtefactExtractor) { let fake_sha = sha256_hex(FAKE_ARCHIVE); - let manifest_json = - prebuilt_manifest_json(TOOLCHAIN, TARGET, &fake_sha).replacen("abc1234", git_sha, 1); + let manifest = + manifest_with_git_sha(git_sha, &fake_sha).expect("construct manifest with Git SHA"); + let manifest_json = serde_json::to_string(&manifest).expect("serialize manifest with Git SHA"); let mut downloader = MockArtefactDownloader::new(); downloader .expect_download_manifest() @@ -160,56 +161,39 @@ fn success_mocks_with_git_sha(git_sha: &str) -> (MockArtefactDownloader, MockArt (downloader, extractor) } -#[test] -fn expected_git_sha_mismatch_returns_fallback() { +#[rstest] +#[case::mismatch(Some(MISMATCHED_COMMIT), "abc1234", false)] +#[case::matching_full_sha(Some(MATCHING_COMMIT), MATCHING_COMMIT, true)] +#[case::unpinned_abbreviated_sha(None, "abc1234", true)] +fn prebuilt_git_sha_gate( + #[case] expected_git_sha: Option<&str>, + #[case] manifest_git_sha: &str, + #[case] expects_success: bool, +) { let (_temp, destination_dir) = destination_dir(); let config = PrebuiltConfig { - expected_git_sha: Some(MISMATCHED_COMMIT), + expected_git_sha, ..base_config(&destination_dir) }; - let (downloader, extractor) = success_mocks(); + let (downloader, extractor) = success_mocks_with_git_sha(manifest_git_sha); let mut stderr = Vec::new(); let result = attempt_prebuilt_with(&config, &downloader, &extractor, &mut stderr); - match result { - PrebuiltResult::Fallback { reason } => { - assert!(reason.contains("SHA mismatch"), "reason: {reason}"); + + if expects_success { + assert!( + matches!(result, PrebuiltResult::Success { .. }), + "expected Success, got {result:?}" + ); + } else { + match result { + PrebuiltResult::Fallback { reason } => { + assert!(reason.contains("SHA mismatch"), "reason: {reason}"); + } + other => panic!("expected Fallback, got {other:?}"), } - other => panic!("expected Fallback, got {other:?}"), } } -#[test] -fn matching_full_git_sha_returns_success() { - let (_temp, destination_dir) = destination_dir(); - let config = PrebuiltConfig { - expected_git_sha: Some(MATCHING_COMMIT), - ..base_config(&destination_dir) - }; - let (downloader, extractor) = success_mocks_with_git_sha(MATCHING_COMMIT); - let mut stderr = Vec::new(); - let result = attempt_prebuilt_with(&config, &downloader, &extractor, &mut stderr); - assert!( - matches!(result, PrebuiltResult::Success { .. }), - "expected Success, got {result:?}" - ); -} - -#[test] -fn unpinned_git_sha_returns_success_with_an_abbreviated_manifest_sha() { - let (_temp, destination_dir) = destination_dir(); - let config = PrebuiltConfig { - expected_git_sha: None, - ..base_config(&destination_dir) - }; - let (downloader, extractor) = success_mocks(); - let mut stderr = Vec::new(); - let result = attempt_prebuilt_with(&config, &downloader, &extractor, &mut stderr); - assert!( - matches!(result, PrebuiltResult::Success { .. }), - "expected Success, got {result:?}" - ); -} - fn destination_dir() -> (tempfile::TempDir, Utf8PathBuf) { let temp = tempfile::tempdir().expect("temp dir"); let root = Utf8PathBuf::try_from(temp.path().to_path_buf()).expect("UTF-8 path"); diff --git a/installer/src/workspace_tests.rs b/installer/src/workspace_tests.rs index d5f76e32..129f671b 100644 --- a/installer/src/workspace_tests.rs +++ b/installer/src/workspace_tests.rs @@ -2,8 +2,8 @@ use super::*; use crate::dirs::{MockBaseDirs, SystemBaseDirs}; +use cap_std::{ambient_authority, fs_utf8::Dir}; use rstest::{fixture, rstest}; -use std::fs; use std::path::PathBuf; use tempfile::TempDir; @@ -11,19 +11,25 @@ use tempfile::TempDir; struct TempWorkspace { _temp: TempDir, path: Utf8PathBuf, + dir: Dir, } #[fixture] fn temp_workspace() -> TempWorkspace { let temp = TempDir::new().expect("failed to create temp dir"); let path = Utf8PathBuf::try_from(temp.path().to_owned()).expect("non-UTF8 temp path"); - TempWorkspace { _temp: temp, path } + let dir = Dir::open_ambient_dir(&path, ambient_authority()) + .expect("failed to open temporary workspace directory"); + TempWorkspace { + _temp: temp, + path, + dir, + } } -fn write_cargo_toml(dir: &Utf8Path, package_name: &str) { - let cargo_toml = dir.join("Cargo.toml"); - fs::write( - cargo_toml, +fn write_cargo_toml(dir: &Dir, package_name: &str) { + dir.write( + "Cargo.toml", format!("[package]\nname = \"{package_name}\"\nversion = \"0.1.0\"\n"), ) .expect("failed to write Cargo.toml"); @@ -39,7 +45,7 @@ fn is_whitaker_workspace_detection( #[case] expected: bool, ) { if let Some(name) = package_name { - write_cargo_toml(&temp_workspace.path, name); + write_cargo_toml(&temp_workspace.dir, name); } assert_eq!(is_whitaker_workspace(&temp_workspace.path), expected); } @@ -60,7 +66,7 @@ fn clone_directory_returns_some_on_supported_platforms() { #[rstest] fn decide_workspace_action_uses_cwd_when_whitaker(temp_workspace: TempWorkspace) { - write_cargo_toml(&temp_workspace.path, "whitaker"); + write_cargo_toml(&temp_workspace.dir, "whitaker"); let clone_dir = Utf8PathBuf::from("/nonexistent/clone/dir"); let action = decide_workspace_action(&temp_workspace.path, &clone_dir, true); @@ -82,7 +88,10 @@ fn decide_workspace_action_clones_when_empty(temp_workspace: TempWorkspace) { fn decide_workspace_action_updates_when_clone_exists(temp_workspace: TempWorkspace) { // Create a clone directory (not a whitaker workspace, just exists) let clone_dir = temp_workspace.path.join("clone_target"); - fs::create_dir(&clone_dir).expect("failed to create clone dir"); + temp_workspace + .dir + .create_dir("clone_target") + .expect("failed to create clone dir"); let action = decide_workspace_action(&temp_workspace.path, &clone_dir, true); @@ -92,7 +101,10 @@ fn decide_workspace_action_updates_when_clone_exists(temp_workspace: TempWorkspa #[rstest] fn decide_workspace_action_uses_existing_when_no_update(temp_workspace: TempWorkspace) { let clone_dir = temp_workspace.path.join("clone_target"); - fs::create_dir(&clone_dir).expect("failed to create clone dir"); + temp_workspace + .dir + .create_dir("clone_target") + .expect("failed to create clone dir"); let action = decide_workspace_action(&temp_workspace.path, &clone_dir, false); @@ -154,7 +166,10 @@ fn resolve_workspace_path_returns_clone_dir_when_not_in_workspace(temp_workspace let result = resolve_workspace_path(&mock); assert!(result.is_ok()); - assert_eq!(result.unwrap(), expected_dir); + assert_eq!( + result.expect("workspace path should resolve from the mock data directory"), + expected_dir + ); } #[rstest] @@ -165,7 +180,7 @@ fn resolve_workspace_path_errors_when_data_dir_unavailable(temp_workspace: TempW let result = resolve_workspace_path(&mock); assert!(result.is_err()); - let err = result.unwrap_err(); + let err = result.expect_err("workspace path should fail without a data directory"); assert!( matches!(err, InstallerError::WorkspaceNotFound { .. }), "expected WorkspaceNotFound error, got: {err:?}" @@ -187,37 +202,40 @@ fn clone_directory_returns_path_from_mock(temp_workspace: TempWorkspace) { // Tests for find_workspace_root -fn write_workspace_cargo_toml(dir: &Utf8Path) { - fs::write( - dir.join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/*\"]\n", - ) - .expect("failed to write workspace Cargo.toml"); +fn write_workspace_cargo_toml(dir: &Dir) { + dir.write("Cargo.toml", "[workspace]\nmembers = [\"crates/*\"]\n") + .expect("failed to write workspace Cargo.toml"); } #[rstest] fn find_workspace_root_finds_workspace_in_current_dir(temp_workspace: TempWorkspace) { - write_workspace_cargo_toml(&temp_workspace.path); + write_workspace_cargo_toml(&temp_workspace.dir); assert_eq!( - find_workspace_root(&temp_workspace.path).unwrap(), + find_workspace_root(&temp_workspace.path).expect("workspace root should be found"), temp_workspace.path ); } #[rstest] fn find_workspace_root_finds_workspace_in_parent_dir(temp_workspace: TempWorkspace) { - write_workspace_cargo_toml(&temp_workspace.path); + write_workspace_cargo_toml(&temp_workspace.dir); let subdir = temp_workspace.path.join("crates").join("my_crate"); - fs::create_dir_all(&subdir).expect("failed to create subdirs"); - assert_eq!(find_workspace_root(&subdir).unwrap(), temp_workspace.path); + temp_workspace + .dir + .create_dir_all("crates/my_crate") + .expect("failed to create workspace subdirectories"); + assert_eq!( + find_workspace_root(&subdir).expect("workspace root should be found from child crate"), + temp_workspace.path + ); } #[rstest] fn find_workspace_root_errors_when_no_workspace_found(temp_workspace: TempWorkspace) { - write_cargo_toml(&temp_workspace.path, "not_a_workspace"); + write_cargo_toml(&temp_workspace.dir, "not_a_workspace"); let result = find_workspace_root(&temp_workspace.path); assert!(matches!( - result.unwrap_err(), + result.expect_err("non-workspace package should not have a workspace root"), InstallerError::WorkspaceNotFound { .. } )); } From 9d0e3c597b0d5f3163bae31ae33a3bc402881730 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 21 Aug 2026 19:44:57 +0200 Subject: [PATCH 20/26] Caption Git operation API reference Add the concise table caption and `type:docstyle` triage metadata required for the public Git operation API documentation. --- docs/developers-guide.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index cd225ea9..91b7344c 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -1907,6 +1907,10 @@ each operation is bounded by the module's five-minute Git timeout. Use these functions for the managed clone workflow, not to mutate a user's current Whitaker checkout. +Triage: `type:docstyle` + +*Table: Public Git operation APIs.* + | API | Purpose | Usage constraints | | --- | --- | --- | | `resolve_commit(repo: &Utf8Path, refspec: &str) -> Result` | Resolves a local commit-ish (SHA, tag, or branch) to its full commit SHA and peels annotated tags. | Does not fetch. Call it when the ref is expected to exist locally, or after `fetch_ref` has populated the clone. | From 77edfa679eb03dc2fee62e4e4ae8ae7a21330a4d Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 22 Aug 2026 00:10:55 +0200 Subject: [PATCH 21/26] Remove Git API table triage metadata --- docs/developers-guide.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 91b7344c..edad7766 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -1907,8 +1907,6 @@ each operation is bounded by the module's five-minute Git timeout. Use these functions for the managed clone workflow, not to mutate a user's current Whitaker checkout. -Triage: `type:docstyle` - *Table: Public Git operation APIs.* | API | Purpose | Usage constraints | From a370849f6c94fd38804930cfe5170ef34c034bb9 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 22 Aug 2026 00:47:07 +0200 Subject: [PATCH 22/26] Harden pinned checkout provenance and locking (#271) Carry validated full Git object IDs through checkout and prebuilt provenance validation, so raw strings remain at process and manifest edges. Serialize managed-clone preparation and configure the installer diagnostics subscriber to make shared-clone recovery safe and observable. --- Cargo.lock | 73 ++++++ docs/developers-guide.md | 45 +++- .../issue-271-ref-pinned-installation.md | 240 +++++++++++------- installer/Cargo.toml | 1 + installer/src/diagnostics.rs | 15 ++ installer/src/error.rs | 14 + installer/src/git.rs | 36 ++- installer/src/git/commit_sha.rs | 71 ++++++ installer/src/git_tests.rs | 46 ++-- installer/src/install_flow.rs | 3 +- installer/src/lib.rs | 1 + installer/src/main.rs | 9 +- installer/src/prebuilt.rs | 12 +- installer/src/prebuilt_tests.rs | 19 +- installer/src/workspace.rs | 39 ++- installer/src/workspace_lock.rs | 83 ++++++ installer/src/workspace_progress.rs | 6 +- installer/src/workspace_tests.rs | 5 +- installer/tests/behaviour_prebuilt.rs | 9 +- 19 files changed, 573 insertions(+), 154 deletions(-) create mode 100644 installer/src/diagnostics.rs create mode 100644 installer/src/git/commit_sha.rs create mode 100644 installer/src/workspace_lock.rs diff --git a/Cargo.lock b/Cargo.lock index 19c70ef3..130c97d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1731,6 +1731,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "maybe-owned" version = "0.3.4" @@ -1938,6 +1947,15 @@ dependencies = [ "whitaker-common", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2864,6 +2882,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3143,6 +3170,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.53" @@ -3284,6 +3320,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -3505,6 +3571,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "value-bag" version = "1.13.0" @@ -3681,6 +3753,7 @@ dependencies = [ "thiserror 2.0.19", "toml 1.1.3+spec-1.1.0", "tracing", + "tracing-subscriber", "trybuild", "ureq", "wait-timeout", diff --git a/docs/developers-guide.md b/docs/developers-guide.md index edad7766..51d72acd 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -1911,11 +1911,50 @@ Whitaker checkout. | API | Purpose | Usage constraints | | --- | --- | --- | -| `resolve_commit(repo: &Utf8Path, refspec: &str) -> Result` | Resolves a local commit-ish (SHA, tag, or branch) to its full commit SHA and peels annotated tags. | Does not fetch. Call it when the ref is expected to exist locally, or after `fetch_ref` has populated the clone. | -| `fetch_ref(repo: &Utf8Path, refspec: &str) -> Result` | Fetches the requested ref from `origin` into the private `refs/whitaker/pinned-ref` ref and returns its full commit SHA. | Use it to refresh a requested pin before checkout. It force-updates only the private pin ref; it does not move the current branch or check out the result. | -| `checkout_detached(repo: &Utf8Path, commit: &str) -> Result<()>` | Checks out exactly `commit` with a detached `HEAD`. | Use only for the installer-managed clone after resolving the requested ref. The workspace layer must reject pinning in the user's current Whitaker workspace first. | +| `clone_repository(target: &Utf8Path) -> Result<()>` | Clones the public Whitaker repository into `target`. | Creates missing parent directories and uses the five-minute Git timeout. Use only for the installer-managed clone. | +| `update_repository(repo: &Utf8Path) -> Result<()>` | Pulls the latest changes into an existing Whitaker clone. | Call `ensure_default_branch` first when a prior pin may have detached `HEAD`; this function does not reattach a checkout. | +| `resolve_commit(repo: &Utf8Path, refspec: &str) -> Result` | Resolves a local commit-ish (SHA, tag, or branch) to a validated full object ID and peels annotated tags. | Does not fetch. Call it when the ref is expected to exist locally, or after `fetch_ref` has populated the clone. | +| `fetch_ref(repo: &Utf8Path, refspec: &str) -> Result` | Fetches the requested ref from `origin` into the private `refs/whitaker/pinned-ref` ref and returns its validated full object ID. | Use it to refresh a requested pin before checkout. It force-updates only the private pin ref; it does not move the current branch or check out the result. | +| `checkout_detached(repo: &Utf8Path, commit: &CommitSha) -> Result<()>` | Checks out exactly `commit` with a detached `HEAD`. | Use only for the installer-managed clone after resolving the requested ref. The workspace layer must reject pinning in the user's current Whitaker workspace first. | +| `detached_head_commit(repo: &Utf8Path) -> Result>` | Returns the full object ID when `HEAD` is detached, or `None` when `HEAD` names a branch. | Use to preserve provenance for an unpinned `--no-update` install that reuses a detached managed clone. | | `ensure_default_branch(repo: &Utf8Path) -> Result<()>` | Reattaches a detached clone to the branch named by `origin/HEAD`; repairs a missing `origin/HEAD` with `git remote set-head origin --auto`. | Call before `update_repository` when a previous pin may have detached the managed clone. It is a no-op when `HEAD` already names a branch and does not pull changes itself. | +`CommitSha` is the Git adapter's validated full object-ID type. Keep commit +provenance in this type across Git and workspace boundaries; convert to text +only at output or external-format boundaries. + +#### Workspace API and provenance + +`workspace::ensure_workspace(dirs, update, git_ref)` returns a +`WorkspaceCheckout` containing the prepared `root`, the selected +`WorkspaceAction`, and commit provenance. `pinned_commit` is the resolved +`CommitSha` when `git_ref` pins the managed clone. `detached_commit` records the +existing detached `HEAD` reused by an unpinned `--no-update` install. These +fields are mutually exclusive for a single checkout. + +`WorkspaceCheckout::expected_git_sha() -> Option<&CommitSha>` returns the pinned +commit, or the inherited detached commit, as an optional full object ID. Pass +that value to the prebuilt path so a pinned or detached checkout can reuse an +artefact only when its manifest records the same full object ID. A rolling +install with no commit provenance leaves it unset. `ensure_ref_allowed` must +reject a pin when +the selected action is `UseCurrentDir`, because checking out a ref there would +mutate the user's working tree. + +`workspace::ensure_workspace` acquires the internal `ManagedCloneLock` while +preparing the installer-managed clone. The exclusive advisory sidecar is +stored beside the clone as `.lock` and covers action +selection, ref resolution, checkout, and update, so concurrent installers do +not race on shared Git state. Current-directory workspaces do not need this +lock; keep the lock scoped to workspace preparation and surface acquisition +failures as `InstallerError::WorkspaceLock`. + +The installer binary initializes one stderr tracing subscriber at startup. +`RUST_LOG` controls its `EnvFilter`; without it, the default level is `warn`. +Use, for example, `RUST_LOG=whitaker_installer=debug` when diagnosing Git or +workspace preparation. Library code must continue emitting events through +`tracing` without installing a global subscriber. + #### `resolve_additional_components` ```rust diff --git a/docs/execplans/issue-271-ref-pinned-installation.md b/docs/execplans/issue-271-ref-pinned-installation.md index c7eded3c..e3a6e0ee 100644 --- a/docs/execplans/issue-271-ref-pinned-installation.md +++ b/docs/execplans/issue-271-ref-pinned-installation.md @@ -27,7 +27,8 @@ whitaker-installer --ref 1a2b3c4d… # a commit SHA ``` and the installer will build and stage the lint suite from exactly that -commit of `leynos/whitaker`. Running `whitaker-installer` with no `--ref` +commit of `leynos/whitaker`, carrying its resolved full object ID as +provenance. Running `whitaker-installer` with no `--ref` behaves exactly as today (rolling prebuilt artefacts, default-branch source fallback). Rolling remains the intentional default; the maintainer does not want to cut a new installer release for every suite update. This plan @@ -39,7 +40,8 @@ machine stages libraries built from the tagged commit (verifiable because the staged filenames embed the toolchain channel recorded in that commit's `rust-toolchain.toml`), `whitaker-installer --dry-run --ref ` reports the ref, and a subsequent un-pinned `whitaker-installer` run still works (the -clone recovers from the detached checkout). +clone recovers from the detached checkout). A pinned prebuilt is accepted only +when its manifest records the same full object ID as the resolved ref. ## Constraints @@ -88,13 +90,11 @@ clone recovers from the detached checkout). Severity: high. Likelihood: certain without mitigation. Mitigation: the update path must reattach the clone to the default branch before pulling (Stage C step 3). This is a required behaviour, tested. -- Risk (resolved in Stage A): the prebuilt manifest's `git_sha` format could - have made comparison against a resolved ref incorrect. Stage A confirmed - that current rolling producers emit abbreviated SHAs. - Severity: medium. Likelihood: resolved. - Resolution: require the resolved full commit SHA to start with the - manifest's abbreviated SHA. Exact full-SHA equality remains rejected while - the producer contract is abbreviated. +- Risk: a prebuilt manifest could claim a different commit from the resolved + pin. Severity: high. Likelihood: possible when rolling artefacts are reused. + Mitigation: `resolve_commit` and `fetch_ref` return a validated full + `CommitSha`; pinned installs require exact full-object-ID equality before + accepting a prebuilt. Rolling installs leave this expectation unset. - Risk: tags in the whitaker repository may not exist for every released installer version, making `--ref v0.2.5` fail for users. Severity: low (documentation issue, not a code defect). @@ -118,9 +118,10 @@ clone recovers from the detached checkout). detached-HEAD recovery requirement. - [x] (2026-07-08 12:50Z) Drafted this ExecPlan. - [x] (2026-07-08) User approved the plan; proceeding through all stages. -- [x] (2026-07-08) Stage A: confirmed manifest `git_sha` is abbreviated - (`git rev-parse --short HEAD`) and default-branch discovery command - (`git rev-parse --abbrev-ref origin/HEAD`); findings recorded below. +- [x] (2026-07-08, superseded historical record) Stage A confirmed the + manifest `git_sha` was abbreviated (`git rev-parse --short HEAD`) and fixed + the then-current comparison rule; the full-object-ID contract below now + supersedes that rule. - [x] (2026-07-08) Stage B/C slice 1: `--ref` CLI field. Red (E0609 no field `git_ref`) → green (26 cli tests). Commit 6e36c2d. - [x] (2026-07-08) Stage B/C slice 2: git helpers `resolve_commit`, @@ -133,9 +134,9 @@ clone recovers from the detached checkout). update. Red (E0425/E0599) → green. See . -- [x] (2026-07-08) Stage B/C slice 4: prebuilt `expected_git_sha` validation - (prefix-tolerant) threaded through the fast path. Red (mismatch returned - Success) → green (3 unit + 1 BDD). Commit c65281c. +- [x] (2026-07-08, superseded historical record) Stage B/C slice 4 added + prefix-tolerant prebuilt `expected_git_sha` validation. The current + contract requires exact full-object-ID provenance. - [x] (2026-07-08) Stage B/C slice 5: dry-run `git_ref` field, pinning progress messages, 2 CLI BDD scenarios (pinned dry-run, refuse in workspace). Red (missing "Pinned ref:" line) → green. Commit 4caa7d6. @@ -148,14 +149,15 @@ clone recovers from the detached checkout). construction checks with failures driven through `clone_repository` and `update_repository`, and added a real-Git pin, detach, reattach, and update lifecycle test. -- [x] (2026-08-01) Added two proptest properties for accepted 7–40 character - SHA prefixes and rejection after changing a prefix nibble. +- [x] (2026-08-01, superseded historical record) Added two proptest properties + for accepted 7–40 character SHA prefixes and rejection after changing a + prefix nibble. - [x] (2026-08-01) Full review gates passed: formatting, tests, type-checking, linting, Markdown, and Mermaid validation. -- [x] (2026-08-01) Verified the latest review findings. The full-SHA-only - proposal was rejected because rolling producers record abbreviated SHAs; the - fixture, Git recovery and pinning, capability-write, helper-boundary, and - documentation findings remained valid. +- [x] (2026-08-01, superseded historical record) Verified the review findings + and rejected the full-SHA-only proposal because rolling producers recorded + abbreviated SHAs. The current implementation has since adopted exact + full-object-ID validation. - [x] (2026-08-01) Converted `git_fixture` to an injected `rstest` fixture; added real-Git coverage for missing `origin/HEAD` repair and fetched pinning; extracted `finalize_workspace_checkout`; routed prebuilt test writes through @@ -186,17 +188,18 @@ clone recovers from the detached checkout). extra blank line; after the Scribe correction it passed 76 files with 0 errors (`/tmp/issue271-rebase-docfix-markdownlint.log`). Nixie passed 76 files and 15 Mermaid diagrams (`/tmp/issue271-rebase-docfix-nixie.log`). +- [x] (2026-08-22) Current interface and provenance follow-up: Git commit + resolution returns `CommitSha` full object IDs; workspace and prebuilt + interfaces carry and compare that provenance exactly. Current tests cover + clone/update, ref resolution and fetching, detached checkout and recovery, + workspace provenance, and exact prebuilt match/mismatch behaviour. ## Surprises & discoveries -- (2026-07-08, Stage A) The manifest `git_sha` is **abbreviated**, not a full - 40-hex SHA. `.github/workflows/rolling-release.yml` line 142 writes it from - `git rev-parse --short HEAD` into the package tool's `--git-sha`. Git's - `--short` yields the shortest unambiguous prefix (7+ hex). The `GitSha` - newtype in `installer/src/artefact/git_sha.rs` accepts 7–40 hex chars, - consistent with this. **Comparison rule fixed:** a pinned install may use the - rolling prebuilt only when the resolved *full* commit SHA `starts_with` the - manifest's abbreviated `git_sha` (prefix-tolerant), not exact equality. +- (2026-07-08, Stage A, superseded historical record) The manifest `git_sha` + was **abbreviated**, not a full 40-hex SHA, and the then-current comparison + rule was prefix-tolerant. The current pinned-install contract requires a + manifest full object ID to equal the resolved `CommitSha` exactly. - (2026-07-08, Stage A) Default-branch discovery: the platform clone carries `refs/remotes/origin/HEAD` symbolic ref (`git symbolic-ref refs/remotes/origin/HEAD` → `refs/remotes/origin/main`; `git rev-parse @@ -205,10 +208,11 @@ clone recovers from the detached checkout). the branch name, and falls back to `git remote set-head origin --auto` once when the symbolic ref is absent from an older clone. The repository default branch is `main`. -- (2026-07-08, Stage A) The shared test manifest helper - `test_utils::prebuilt_manifest_json` hardcodes `"git_sha":"abc1234"`. The - prebuilt SHA-match tests therefore key off that literal: a full SHA beginning - `abc1234…` matches; any other value is a mismatch. +- (2026-07-08, Stage A, superseded historical record) The shared test manifest + helper `test_utils::prebuilt_manifest_json` hardcodes `"git_sha":"abc1234"`. + The + prebuilt SHA-match tests therefore keyed off that literal and a shared + prefix. Current tests use full-object-ID match and mismatch cases. - (2026-08-04, review pass) Resolving `FETCH_HEAD` after a fetch is ambiguous when Git records more than one fetched object. Fetching the requested refspec into `refs/whitaker/pinned-ref` gives pinning a stable name to resolve @@ -251,11 +255,10 @@ clone recovers from the detached checkout). Rationale: checking out a ref in the user's own working tree could destroy uncommitted work; refusing is the only safe behaviour. Date/Author: 2026-07-08, agent. -- Decision (implementation): the prebuilt SHA match is *prefix-tolerant in one - direction* — the resolved full commit must `starts_with` the manifest's - abbreviated `git_sha` (confirmed abbreviated in Stage A). Exact equality would - never match, since the manifest stores a short SHA and `resolve_commit` - returns the full one. +- Decision (implementation, superseded historical record): the prebuilt SHA + match was *prefix-tolerant in one direction* because the manifest stored an + abbreviated SHA. The current decision requires exact full-object-ID + equality. Date/Author: 2026-07-08, agent. - Decision (process): BDD scenarios were landed with their related implementation slice (prebuilt-mismatch with slice 4; pinned dry-run and the @@ -278,7 +281,8 @@ clone recovers from the detached checkout). orchestration calls git operations, while the git module does not depend on workspace orchestration. Date/Author: 2026-08-01, agent. -- Decision (verification): use proptest for the pure SHA-prefix invariant and +- Decision (verification, superseded historical record): use proptest for the + pure SHA-prefix invariant and real Git repositories for detached-HEAD recovery. Do not add Kani or Verus coverage for Git subprocess state. Rationale: Kani cannot directly model the external Git process and @@ -295,13 +299,18 @@ clone recovers from the detached checkout). Rationale: this removes repeated result construction without hiding the orchestration that distinguishes the workspace setup paths. Date/Author: 2026-08-01, review feedback. -- Decision (review provenance): retain prefix-tolerant pinned-prebuilt - validation and its existing proptest properties; do not require a full - manifest object ID in this review pass. - Rationale: both rolling-release producers record `git rev-parse --short HEAD` - and `GitSha` accepts 7–40 hexadecimal characters. Exact equality would reject - every current abbreviated rolling manifest, disabling valid prebuilt reuse. +- Decision (review provenance, superseded historical record): retain + prefix-tolerant pinned-prebuilt validation and its existing proptest + properties; do not require a full manifest object ID in that review pass. + Rationale: both rolling-release producers recorded abbreviated SHAs. Date/Author: 2026-08-01, agent. +- Decision (current provenance): represent resolved Git commits as `CommitSha` + full object IDs. A pinned or inherited detached checkout may reuse a + prebuilt only when the manifest's full object ID equals the expected value; + rolling installs leave the expected value unset. + Rationale: a short or shared-prefix SHA cannot prove that the artefact was + built from the selected commit. + Date/Author: 2026-08-22, agent. - Decision (review tests): inject the shared real-Git repository through an `rstest` fixture, exercise missing-default-ref recovery and remote-only pinning through production flows, and use a `cap_std::fs::Dir` rooted at each @@ -338,26 +347,29 @@ signature change (`ensure_workspace`). Rolling remains the default; only the explicit-pin part of issue #271 is implemented, and the version-matched default proposed there was deliberately not built (Decision Log). -Lessons: (1) confirming the manifest SHA width in Stage A was load-bearing — the -match is prefix-tolerant, and exact equality would have silently disabled the -prebuilt fast path for every pin. (2) Adding a field to `PrebuiltConfig` and the +Superseded historical lesson: confirming the manifest SHA width in Stage A was +load-bearing — the match was prefix-tolerant, and exact equality would have +silently disabled the prebuilt fast path for every pin. (2) Adding a field to +`PrebuiltConfig` and the context structs surfaced literal constructors in the binary's own test modules (`install_flow/tests.rs`, `tests/fast_path.rs`) that `cargo test --lib` did not compile; an early `cargo check --all-targets` sweep catches these. -The latest review follow-up now contains the minimal valid changes: reusable +Superseded historical review record: the latest review follow-up then +contained the minimal valid changes: reusable `rstest` Git setup, real-Git regressions for default-branch repair and fetched pinning, capability-scoped prebuilt fixture writes, a private checkout finalizer with an explicit orchestration boundary, and terminology corrections. The test-only `cap-std` declaration makes an already-resolved crate available directly to the installer tests; no runtime dependency was -added. The requested full-SHA-only comparison remains deliberately -unimplemented because it conflicts with the rolling manifest format. A +added. The requested full-SHA-only comparison was deliberately unimplemented +at that point because it conflicted with the rolling manifest format. A historical review snapshot reported 1,482/1,482 tests passed with 3 skipped; formatting, type-checking, Cargo documentation, Clippy, Markdownlint, and Nixie were clean. -The 2026-08-01 review follow-up strengthens the real-Git failure and lifecycle +Superseded historical review record (2026-08-01): the follow-up strengthened +the real-Git failure and lifecycle coverage, adds SHA-prefix property tests, and removes the workspace-to-git module cycle. `make check-fmt`, `make typecheck`, and `make lint` passed; lint included rustdoc and Clippy with warnings denied. That historical pre-rebase @@ -365,17 +377,25 @@ included rustdoc and Clippy with warnings denied. That historical pre-rebase `make markdownlint` checked 70 files with 0 errors, and `make nixie` reported all diagrams valid. -The 2026-08-04 review pass makes pinning fetch the requested ref into a +Superseded historical review record (2026-08-04): the review pass made pinning +fetch the requested ref into a dedicated local ref before resolving it, with local resolution as the offline fallback. It also records the selected `WorkspaceAction` in `WorkspaceCheckout`, so progress output describes the operation that -`ensure_workspace` actually selected. The exact-full-SHA proposal remains -rejected because current rolling producers emit abbreviated SHAs. The +`ensure_workspace` actually selected. The exact-full-SHA proposal was then +rejected because current rolling producers emitted abbreviated SHAs. The authoritative final `make test` result is 1,688/1,688 passed, with 5 skipped and 7 slow. Formatting, type-checking, Cargo documentation, Clippy, Markdownlint, and Nixie also passed; the final documentation checks covered 76 files and 15 Mermaid diagrams. +Current contract: `resolve_commit` and `fetch_ref` return `CommitSha`, a +validated full object ID. `WorkspaceCheckout` carries `pinned_commit` or an +inherited `detached_commit`, and `expected_git_sha()` supplies that provenance +to exact prebuilt validation. Current coverage exercises all public Git +operations, detached-checkout recovery, workspace provenance, and exact +prebuilt match and mismatch outcomes. + ## Context and orientation The repository is a Cargo workspace (`Cargo.toml` members: `common`, @@ -402,12 +422,14 @@ relative to the repository root: `decide_workspace_action(cwd, clone_dir, update)`, and `resolve_workspace_action(dirs, update)`. The latter selects from the live environment, while `ensure_workspace(dirs, update, git_ref)` executes that - action and returns it in `WorkspaceCheckout` with the root and optional - pinned commit. -- `installer/src/git.rs` — the owning definition of `WHITAKER_REPO_URL`, - `clone_repository`, `update_repository`, and the private + action and returns it in `WorkspaceCheckout` with the root, selected action, + and optional pinned or inherited detached commit provenance. +- `installer/src/git.rs` — the owning definition of `WHITAKER_REPO_URL`, the + public clone, update, ref-resolution, fetch, detached-checkout, detached + HEAD, and default-branch operations, and the private `run_git_with_timeout(args, working_dir, operation)` helper (5-min timeout, - threaded pipe draining). All new git operations reuse this helper. + threaded pipe draining). All new Git operations reuse this helper. Commit + resolution returns the full-object-ID `CommitSha` type. - `installer/src/prebuilt.rs` — `PrebuiltConfig` (target, toolchain, destination, quiet), `attempt_prebuilt`, and the private `run_pipeline` that downloads the manifest, validates toolchain and target, downloads and @@ -415,8 +437,9 @@ relative to the repository root: `PrebuiltResult::Fallback` — never fatal. - `installer/src/artefact/download.rs` — `ROLLING_TAG = "rolling"` and URL construction; `installer/src/artefact/manifest.rs` and - `artefact/git_sha.rs` — the manifest carries a `git_sha()` accessor whose - exact width Stage A confirms. + `artefact/git_sha.rs` — the manifest carries a `git_sha()` accessor. Rolling + manifests may remain abbreviated; pinned prebuilt reuse requires a full + object ID matching the expected `CommitSha` exactly. - `installer/src/install_flow.rs` — `PrebuiltInstallationContext` and `try_prebuilt_installation`, the seam through which `main.rs` passes data into `prebuilt.rs`. @@ -434,12 +457,14 @@ installer-managed copy of this repository under the user's data directory. ## Plan of work -Stage A — confirm two facts, no code changes. First, read +Stage A (superseded historical plan) — confirm two facts, no code changes. +First, read `installer/src/artefact/git_sha.rs`, `artefact/manifest.rs`, and the release workflow under `.github/workflows/` to establish whether the manifest `git_sha` is a full 40-hex SHA or abbreviated; record the answer in -`Surprises & Discoveries` and fix the comparison rule (exact match for full, -`starts_with` for abbreviated). Second, decide the default-branch discovery +`Surprises & Discoveries` and fix the then-current comparison rule (exact match +for full, `starts_with` for abbreviated). The current rule is exact equality +between full object IDs. Second, decide the default-branch discovery command for detached-HEAD recovery: prefer `git rev-parse --abbrev-ref origin/HEAD` (strips to `origin/main`), falling back to `git remote set-head origin --auto` once if the symbolic ref is @@ -479,11 +504,12 @@ Stage C — implementation, in five small commits: rolling]". Update `Default`, the `after_help` examples, and any literal constructors that fail to compile. 2. Git helpers in `installer/src/git.rs`: `resolve_commit(repo, refspec) -> - Result` (`git rev-parse --verify ^{commit}`), - `fetch_ref(repo, refspec) -> Result` + Result` (`git rev-parse --verify ^{commit}`), + `fetch_ref(repo, refspec) -> Result` (fetch the requested refspec into `refs/whitaker/pinned-ref`, then resolve that dedicated ref), - `checkout_detached(repo, commit)` (`git checkout --detach `), and + `checkout_detached(repo, commit: &CommitSha)` (`git checkout --detach + `), `detached_head_commit(repo) -> Result>`, and `ensure_default_branch(repo)` (no-op when already on a branch; otherwise discover the default branch per Stage A and check it out). All through `run_git_with_timeout`. @@ -496,12 +522,14 @@ Stage C — implementation, in five small commits: `fetch_ref` first; if fetching fails, try `resolve_commit` locally; then `checkout_detached`. Crucially, even an unpinned `UpdateAt` calls `ensure_default_branch` first, fixing the recovery risk. - Return the selected action, workspace path, and resolved commit SHA in - `WorkspaceCheckout` so `main.rs` can hand the SHA to the prebuilt path and - render progress from the action that was actually selected. -4. Prebuilt: add `expected_git_sha: Option<&'a str>` to `PrebuiltConfig`, - validate in `run_pipeline` after the target check using the Stage A - comparison rule, and thread the value from `run_install` through + Return the selected action, workspace path, and resolved full-object-ID + `CommitSha` in `WorkspaceCheckout` so `main.rs` can hand provenance to the + prebuilt path and render progress from the action that was actually + selected. Preserve an inherited detached `CommitSha` for an unpinned + `--no-update` checkout. +4. Prebuilt: add `expected_git_sha: Option<&'a CommitSha>` to `PrebuiltConfig`, + validate in `run_pipeline` after the target check using exact full-object-ID + equality, and thread the value from `run_install` through `PrebuiltInstallationContext` and `try_prebuilt_installation`. Update the fixed-signature `AttemptPrebuiltFn` type alias and hooks in `install_flow.rs` as needed. @@ -580,14 +608,15 @@ Acceptance is behavioural: working tree. 4. After a pinned install, a plain `whitaker-installer` run succeeds (the clone reattaches to the default branch and pulls). -5. With `--ref` set and the rolling manifest's `git_sha` differing from the - resolved commit, the installer prints the fallback message and builds - from source; when they match, the prebuilt path is used. +5. With `--ref` set and the rolling manifest's full object ID differing from + the resolved `CommitSha`, the installer prints the fallback message and + builds from source; when they match exactly, the prebuilt path is used. 6. `make test` passes with the new unit and behaviour tests; each new test demonstrably failed first (Red evidence retained in the tee'd logs under `/tmp/test-whitaker-issue-271.out` and summarized in `Artefacts`). -7. The SHA-prefix proptest properties accept every generated valid 7–40 - character prefix and reject a generated prefix with one changed nibble. +7. Commit-resolution and prebuilt tests preserve full-object-ID provenance: + matching full IDs are accepted and abbreviated or shared-prefix IDs are + rejected for pinned installs. 8. `make check-fmt`, `make lint` (clippy plus the dylint suite), and `make markdownlint` pass. @@ -644,7 +673,7 @@ Red/Green evidence (gate logs under `/tmp/*-whitaker-issue-271*.out`): - Full suite green each slice: the historical initial-implementation `make test` snapshot reported 1,472 tests run, 1,472 passed, and 3 skipped. -Review follow-up test inventory (full gates passed): +Superseded historical review follow-up test inventory (full gates passed): - `installer/src/git_tests.rs` has nine real-Git tests. The lifecycle test pins through `workspace::pin_to_ref`, confirms detached HEAD, advances the remote, @@ -653,8 +682,15 @@ Review follow-up test inventory (full gates passed): - The clone and update error tests invoke `clone_repository` and `update_repository` against failing repositories before inspecting their semantic `InstallerError::Git` fields. -- `installer/src/prebuilt_tests.rs` has two proptest properties covering valid - manifest prefixes and a mismatched prefix across the supported SHA lengths. +- `installer/src/prebuilt_tests.rs` then had two proptest properties covering + valid manifest prefixes and a mismatched prefix across the supported SHA + lengths. + +Current test inventory covers every public Git operation: resolution across +tags, branches, and SHAs; fetching; detached checkout; default-branch repair; +the pin-and-recover lifecycle; and clone/update errors. Workspace tests cover +action selection and ref guarding. Prebuilt tests cover exact full-object-ID +matches, abbreviated IDs, and shared-prefix mismatches. Manual smoke test (Stage D), run against tag `v0.2.4` (commit `8512ee63a212abd175c31501a57e10a713881873`): @@ -697,9 +733,12 @@ In `installer/src/git.rs` (all `pub`, all routed through ```rust pub const WHITAKER_REPO_URL: &str = "https://github.com/leynos/whitaker"; -pub fn resolve_commit(repo: &Utf8Path, refspec: &str) -> Result; -pub fn fetch_ref(repo: &Utf8Path, refspec: &str) -> Result; -pub fn checkout_detached(repo: &Utf8Path, commit: &str) -> Result<()>; +pub fn clone_repository(target: &Utf8Path) -> Result<()>; +pub fn update_repository(repo: &Utf8Path) -> Result<()>; +pub fn resolve_commit(repo: &Utf8Path, refspec: &str) -> Result; +pub fn fetch_ref(repo: &Utf8Path, refspec: &str) -> Result; +pub fn checkout_detached(repo: &Utf8Path, commit: &CommitSha) -> Result<()>; +pub fn detached_head_commit(repo: &Utf8Path) -> Result>; pub fn ensure_default_branch(repo: &Utf8Path) -> Result<()>; ``` @@ -713,7 +752,8 @@ breaking change; all in-repo callers updated in the same commit): ```rust pub struct WorkspaceCheckout { pub root: Utf8PathBuf, - pub pinned_commit: Option, + pub pinned_commit: Option, + pub detached_commit: Option, pub action: WorkspaceAction, } @@ -729,13 +769,17 @@ pub fn ensure_workspace( ) -> Result; ``` +`WorkspaceCheckout::expected_git_sha()` returns the pinned commit, or the +inherited detached commit, for exact prebuilt provenance validation. It is +unset for an ordinary rolling checkout. + In `installer/src/prebuilt.rs`: ```rust pub struct PrebuiltConfig<'a> { // …existing fields… - /// When set, require the manifest git SHA to match this commit. - pub expected_git_sha: Option<&'a str>, + /// When set, require the manifest git SHA to match this full object ID. + pub expected_git_sha: Option<&'a CommitSha>, } ``` @@ -744,7 +788,7 @@ pub struct PrebuiltConfig<'a> { `PrebuiltConfig`. `installer/src/output.rs`'s `DryRunInfo` gains a `git_ref: Option<&'a str>` field rendered in `display_text`. -## Revision note (2026-08-01) +## Revision note (2026-08-01, superseded historical record) Review follow-up clarified that `installer/src/git.rs` owns the repository URL and `installer/src/workspace.rs` keeps only a compatibility re-export. It also @@ -753,17 +797,18 @@ properties, the Kani and Verus scope decision, and `fetch_ref`'s commit-returning signature. Full-gate validation passed with the results recorded in `Outcomes & retrospective`. -## Revision note (2026-08-01, latest review pass) +## Revision note (2026-08-01, latest review pass, superseded historical record) This revision uses “artefacts” consistently and clarifies that ordinary no-ref behaviour remains rolling while allowing recovery from a detached clone left by a prior pin. It records the verified finding dispositions, the implemented -test and helper changes, and the reason full-SHA-only validation was rejected. +test and helper changes, and the reason full-SHA-only validation was rejected +at that time. That historical review validation included 1,482/1,482 tests with 3 skipped, plus formatting, type-checking, Cargo documentation, Clippy, Markdownlint, and Nixie. -## Revision note (2026-08-04, rebase review pass) +## Revision note (2026-08-04, rebase review pass, superseded historical record) This revision marks the abbreviated-SHA risk resolved by Stage A, labels the 1,472, 1,482, and 1,481 test snapshots as historical, and records the @@ -771,6 +816,15 @@ authoritative final result of 1,688/1,688 passed, with 5 skipped and 7 slow. It synchronizes the plan with fetch-first pinning through a dedicated local ref and local fallback on fetch failure, action-carrying `WorkspaceCheckout`, and progress rendered from the selected action. It also records the implemented -two-message pinning output, reaffirms that exact full-SHA validation is +two-message pinning output, reaffirmed that exact full-SHA validation was incompatible with abbreviated rolling producers, and records the clean final formatting, type-checking, lint, Markdownlint, and Nixie results. + +## Revision note (2026-08-22) + +The current provenance contract uses the public `git::CommitSha` type for +full 40-hex object IDs returned by `resolve_commit` and `fetch_ref`. Git's +clone, update, checkout, detached-HEAD, and default-branch APIs are all +documented, and `WorkspaceCheckout` preserves pinned or inherited detached +provenance for exact prebuilt validation. Historical prefix-tolerant +comparison decisions remain above, explicitly labelled as superseded. diff --git a/installer/Cargo.toml b/installer/Cargo.toml index 37580ade..9c3971d0 100644 --- a/installer/Cargo.toml +++ b/installer/Cargo.toml @@ -72,6 +72,7 @@ tempfile = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } tracing = { workspace = true } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } ureq = { workspace = true } wait-timeout = { workspace = true } zip = { workspace = true } diff --git a/installer/src/diagnostics.rs b/installer/src/diagnostics.rs new file mode 100644 index 00000000..fdf3f7f3 --- /dev/null +++ b/installer/src/diagnostics.rs @@ -0,0 +1,15 @@ +//! Runtime diagnostics configuration for the installer binary. +//! +//! The binary installs one stderr tracing subscriber so operational Git and +//! workspace events are available through the standard `RUST_LOG` filter. + +use tracing_subscriber::EnvFilter; + +/// Installs the process-wide installer diagnostics subscriber. +pub(super) fn initialize() { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .try_init(); +} diff --git a/installer/src/error.rs b/installer/src/error.rs index 8a488250..bbded449 100644 --- a/installer/src/error.rs +++ b/installer/src/error.rs @@ -105,6 +105,16 @@ pub enum InstallerError { reason: String, }, + /// Acquiring the managed-clone preparation lock failed. + #[error("failed to lock managed Whitaker workspace {path}: {source}")] + WorkspaceLock { + /// Sidecar lock file path. + path: Utf8PathBuf, + /// Underlying lock or file-system error. + #[source] + source: std::io::Error, + }, + /// Pinning a ref is not supported for the current directory workspace. #[error( "cannot pin --ref {git_ref}: the current directory is itself a Whitaker workspace; run the installer from outside a checkout to pin the suite" @@ -240,6 +250,10 @@ impl Clone for InstallerError { Self::WorkspaceNotFound { reason } => Self::WorkspaceNotFound { reason: reason.clone(), }, + Self::WorkspaceLock { path, source } => Self::WorkspaceLock { + path: path.clone(), + source: clone_io_error(source), + }, Self::RefUnsupported { git_ref } => Self::RefUnsupported { git_ref: git_ref.clone(), }, diff --git a/installer/src/git.rs b/installer/src/git.rs index ce070fac..f3987134 100644 --- a/installer/src/git.rs +++ b/installer/src/git.rs @@ -1,8 +1,14 @@ -//! Git operations for cloning and updating the Whitaker repository. +//! Git operations for managed Whitaker checkouts. //! -//! This module provides functions for managing the local Whitaker clone, -//! including initial cloning and subsequent updates. Operations have a -//! configurable timeout to prevent hangs on network issues. +//! This module clones and updates the managed repository, resolves requested +//! refs, performs detached pinned checkouts, and repairs a detached checkout +//! before an unpinned update. Workspace orchestration selects when to call +//! these helpers; the CLI reports the resulting action and pin progress. +//! Operations have a configurable timeout to prevent hangs on network issues. + +mod commit_sha; + +pub use commit_sha::CommitSha; use crate::error::{InstallerError, Result}; use camino::Utf8Path; @@ -63,7 +69,7 @@ pub fn update_repository(repo: &Utf8Path) -> Result<()> { /// /// Returns `InstallerError::Git` if the ref cannot be resolved or the command /// times out. -pub fn resolve_commit(repo: &Utf8Path, refspec: &str) -> Result { +pub fn resolve_commit(repo: &Utf8Path, refspec: &str) -> Result { debug!( operation = "rev-parse", refspec, "resolving requested Git ref" @@ -80,7 +86,11 @@ pub fn resolve_commit(repo: &Utf8Path, refspec: &str) -> Result { }); } - Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) + let commit = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + CommitSha::try_from(commit.as_str()).map_err(|error| InstallerError::Git { + operation: "rev-parse", + message: format!("resolved ref '{refspec}' returned an invalid commit SHA: {error}"), + }) } /// Fetches a specific ref from `origin` into the private pinned ref. @@ -92,7 +102,7 @@ pub fn resolve_commit(repo: &Utf8Path, refspec: &str) -> Result { /// # Errors /// /// Returns `InstallerError::Git` if the fetch fails or times out. -pub fn fetch_ref(repo: &Utf8Path, refspec: &str) -> Result { +pub fn fetch_ref(repo: &Utf8Path, refspec: &str) -> Result { debug!( operation = "fetch", refspec, @@ -112,14 +122,18 @@ pub fn fetch_ref(repo: &Utf8Path, refspec: &str) -> Result { /// # Errors /// /// Returns `InstallerError::Git` if the checkout fails or times out. -pub fn checkout_detached(repo: &Utf8Path, commit: &str) -> Result<()> { +pub fn checkout_detached(repo: &Utf8Path, commit: &CommitSha) -> Result<()> { debug!( operation = "checkout", - commit, + commit = commit.as_str(), attempt = "detached", "checking out pinned commit" ); - run_git_checked(&["checkout", "--detach", commit], Some(repo), "checkout") + run_git_checked( + &["checkout", "--detach", commit.as_str()], + Some(repo), + "checkout", + ) } /// Returns the detached HEAD commit, or `None` when HEAD names a branch. @@ -127,7 +141,7 @@ pub fn checkout_detached(repo: &Utf8Path, commit: &str) -> Result<()> { /// # Errors /// /// Returns `InstallerError::Git` if HEAD is detached but cannot be resolved. -pub fn detached_head_commit(repo: &Utf8Path) -> Result> { +pub fn detached_head_commit(repo: &Utf8Path) -> Result> { let symbolic = run_git_with_timeout(&["symbolic-ref", "-q", "HEAD"], Some(repo), "symbolic-ref")?; if symbolic.status.success() { diff --git a/installer/src/git/commit_sha.rs b/installer/src/git/commit_sha.rs new file mode 100644 index 00000000..35a566bb --- /dev/null +++ b/installer/src/git/commit_sha.rs @@ -0,0 +1,71 @@ +//! Full Git commit object IDs returned by repository operations. +//! +//! The Git adapter constructs this type only after `git rev-parse` has +//! resolved a commit-ish to a complete object ID. Callers retain the typed +//! value through checkout and prebuilt provenance validation. + +use std::fmt; +use thiserror::Error; + +/// A validated, full lowercase hexadecimal Git commit object ID. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct CommitSha(String); + +/// An error returned when a value is not a full Git commit object ID. +#[derive(Debug, Error)] +#[error("invalid full Git commit SHA: {reason}")] +pub struct CommitShaError { + reason: &'static str, +} + +impl CommitSha { + /// Returns the full commit object ID as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom<&str> for CommitSha { + type Error = CommitShaError; + + fn try_from(value: &str) -> Result { + if value.len() != 40 { + return Err(CommitShaError { + reason: "SHA must contain exactly 40 characters", + }); + } + if !value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Err(CommitShaError { + reason: "SHA must contain only lowercase hexadecimal characters", + }); + } + Ok(Self(value.to_owned())) + } +} + +impl fmt::Display for CommitSha { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use super::CommitSha; + use rstest::rstest; + + #[rstest] + #[case::valid("abc12340000000000000000000000000000000ab", true)] + #[case::too_short("abc1234", false)] + #[case::uppercase("ABC12340000000000000000000000000000000AB", false)] + #[case::non_hex("abc123g0000000000000000000000000000000000", false)] + fn validates_full_lowercase_hex_shas(#[case] value: &str, #[case] expected: bool) { + let result = CommitSha::try_from(value); + + assert_eq!(result.is_ok(), expected); + } +} diff --git a/installer/src/git_tests.rs b/installer/src/git_tests.rs index 4f4abecd..4bf704b8 100644 --- a/installer/src/git_tests.rs +++ b/installer/src/git_tests.rs @@ -88,16 +88,22 @@ fn git_fixture() -> GitFixture { #[rstest] fn resolve_commit_resolves_tag_branch_and_sha(git_fixture: GitFixture) { assert_eq!( - resolve_commit(&git_fixture.clone, "v1").expect("resolve tag"), - git_fixture.first + resolve_commit(&git_fixture.clone, "v1") + .expect("resolve tag") + .as_str(), + git_fixture.first.as_str() ); assert_eq!( - resolve_commit(&git_fixture.clone, "main").expect("resolve branch"), - git_fixture.second + resolve_commit(&git_fixture.clone, "main") + .expect("resolve branch") + .as_str(), + git_fixture.second.as_str() ); assert_eq!( - resolve_commit(&git_fixture.clone, &git_fixture.second).expect("resolve sha"), - git_fixture.second + resolve_commit(&git_fixture.clone, &git_fixture.second) + .expect("resolve sha") + .as_str(), + git_fixture.second.as_str() ); } @@ -110,7 +116,8 @@ fn resolve_commit_errors_on_garbage(git_fixture: GitFixture) { #[rstest] fn checkout_detached_leaves_head_at_commit(git_fixture: GitFixture) { - checkout_detached(&git_fixture.clone, &git_fixture.first).expect("checkout detached"); + let commit = resolve_commit(&git_fixture.clone, "v1").expect("resolve tag"); + checkout_detached(&git_fixture.clone, &commit).expect("checkout detached"); assert_eq!( git(&git_fixture.clone, &["rev-parse", "HEAD"]), git_fixture.first @@ -137,11 +144,11 @@ fn unpinned_no_update_preserves_detached_commit_gate(git_fixture: GitFixture) { assert_eq!(checkout.pinned_commit, None); assert_eq!( - checkout.detached_commit.as_deref(), + checkout.detached_commit.as_ref().map(CommitSha::as_str), Some(git_fixture.first.as_str()) ); assert_eq!( - checkout.expected_git_sha(), + checkout.expected_git_sha().map(CommitSha::as_str), Some(git_fixture.first.as_str()) ); } @@ -150,7 +157,7 @@ fn unpinned_no_update_preserves_detached_commit_gate(git_fixture: GitFixture) { fn pinned_checkout_reattaches_for_unpinned_update(git_fixture: GitFixture) { let pinned_commit = crate::workspace::pin_to_ref(&git_fixture.clone, "v1").expect("pin checkout to v1"); - assert_eq!(pinned_commit, git_fixture.first); + assert_eq!(pinned_commit.as_str(), git_fixture.first); assert_eq!( git(&git_fixture.clone, &["rev-parse", "--abbrev-ref", "HEAD"]), "HEAD" @@ -180,7 +187,8 @@ fn ensure_default_branch_is_noop_on_a_branch(git_fixture: GitFixture) { #[rstest] fn ensure_default_branch_repairs_missing_remote_head(git_fixture: GitFixture) { - checkout_detached(&git_fixture.clone, &git_fixture.first).expect("checkout detached"); + let commit = resolve_commit(&git_fixture.clone, "v1").expect("resolve tag"); + checkout_detached(&git_fixture.clone, &commit).expect("checkout detached"); git( &git_fixture.clone, &["symbolic-ref", "--delete", "refs/remotes/origin/HEAD"], @@ -213,13 +221,17 @@ fn fetch_ref_retrieves_a_new_tag(git_fixture: GitFixture) { // The clone cannot resolve the new tag until it fetches. assert!(resolve_commit(&git_fixture.clone, "v2").is_err()); let fetched = fetch_ref(&git_fixture.clone, "v2").expect("fetch new tag"); - assert_eq!(fetched, third); + assert_eq!(fetched.as_str(), third); assert_eq!( - resolve_commit(&git_fixture.clone, PINNED_REF).expect("resolve private pinned ref"), + resolve_commit(&git_fixture.clone, PINNED_REF) + .expect("resolve private pinned ref") + .as_str(), third ); assert_eq!( - resolve_commit(&git_fixture.clone, "v2").expect("resolve v2"), + resolve_commit(&git_fixture.clone, "v2") + .expect("resolve v2") + .as_str(), third ); assert!( @@ -238,7 +250,7 @@ fn pin_to_ref_fetches_and_checks_out_a_new_remote_branch(git_fixture: GitFixture let pinned_commit = crate::workspace::pin_to_ref(&git_fixture.clone, "release-candidate") .expect("fetch and pin new remote branch"); - assert_eq!(pinned_commit, branch_commit); + assert_eq!(pinned_commit.as_str(), branch_commit); assert_eq!( git(&git_fixture.clone, &["rev-parse", "HEAD"]), branch_commit @@ -257,7 +269,7 @@ fn pin_to_ref_prefers_an_updated_remote_branch(git_fixture: GitFixture) { let pinned_commit = crate::workspace::pin_to_ref(&git_fixture.clone, "main") .expect("fetch and pin updated main"); - assert_eq!(pinned_commit, third); + assert_eq!(pinned_commit.as_str(), third); assert_eq!(git(&git_fixture.clone, &["rev-parse", "HEAD"]), third); } @@ -268,7 +280,7 @@ fn pin_to_ref_falls_back_to_a_local_ref_offline(git_fixture: GitFixture) { let pinned_commit = crate::workspace::pin_to_ref(&git_fixture.clone, "v1") .expect("pin locally available tag while offline"); - assert_eq!(pinned_commit, git_fixture.first); + assert_eq!(pinned_commit.as_str(), git_fixture.first); assert_eq!( git(&git_fixture.clone, &["rev-parse", "--abbrev-ref", "HEAD"]), "HEAD" diff --git a/installer/src/install_flow.rs b/installer/src/install_flow.rs index df973304..381d621b 100644 --- a/installer/src/install_flow.rs +++ b/installer/src/install_flow.rs @@ -20,6 +20,7 @@ use whitaker_installer::deps::{ use whitaker_installer::deps::{DependencyInstallOptions, install_dylint_tools_with_options}; use whitaker_installer::dirs::BaseDirs; use whitaker_installer::error::{InstallerError, Result}; +use whitaker_installer::git::CommitSha; use whitaker_installer::install_metrics::{InstallMode, RecordOutcome, record_install}; use whitaker_installer::output::write_stderr_line; use whitaker_installer::prebuilt::{PrebuiltConfig, PrebuiltResult, attempt_prebuilt}; @@ -102,7 +103,7 @@ pub(crate) struct PrebuiltInstallationContext<'a> { /// Toolchain channel resolved for this install. pub(crate) toolchain_channel: &'a str, /// Resolved pinned commit SHA, when installing at a specific `--ref`. - pub(crate) expected_git_sha: Option<&'a str>, + pub(crate) expected_git_sha: Option<&'a CommitSha>, } /// Context for recording one successful install in aggregate metrics. diff --git a/installer/src/lib.rs b/installer/src/lib.rs index 94e14c53..d0c0d4b3 100644 --- a/installer/src/lib.rs +++ b/installer/src/lib.rs @@ -69,6 +69,7 @@ pub mod test_support; pub mod toolchain; pub mod version; pub mod workspace; +mod workspace_lock; pub mod wrapper; #[cfg(test)] diff --git a/installer/src/main.rs b/installer/src/main.rs index ece6c6c2..e33854b5 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -4,6 +4,7 @@ //! After installation, it prints shell configuration snippets for enabling //! library discovery. +mod diagnostics; mod install_flow; mod staged_suite; mod workspace_progress; @@ -24,6 +25,7 @@ use whitaker_installer::crate_name::CrateName; use whitaker_installer::deps::SystemCommandExecutor; use whitaker_installer::dirs::{BaseDirs, SystemBaseDirs}; use whitaker_installer::error::{InstallerError, Result}; +use whitaker_installer::git::CommitSha; use whitaker_installer::install_metrics::InstallMode; use whitaker_installer::list::{determine_target_dir, run_list}; use whitaker_installer::output::{DryRunInfo, ShellSnippet, write_stderr_line}; @@ -37,6 +39,7 @@ use whitaker_installer::workspace::{WorkspaceAction, WorkspaceCheckout}; use whitaker_installer::wrapper::{generate_wrapper_scripts, path_instructions}; fn main() { + diagnostics::initialize(); let cli = Cli::parse(); let mut stdout = std::io::stdout(); let mut stderr = std::io::stderr(); @@ -112,7 +115,7 @@ fn run_install(args: &InstallArgs, stderr: &mut dyn Write) -> Result<()> { // Reject unsafe pin requests before dependency installation can mutate the host. validate_ref_then_ensure_dependencies(args, &dirs, stderr, ensure_dylint_tools)?; let workspace = ensure_whitaker_workspace(args, &dirs, stderr)?; - let expected_git_sha = workspace.expected_git_sha().map(str::to_owned); + let expected_git_sha = workspace.expected_git_sha().cloned(); let workspace_root = workspace.root; let requested_crates = resolve_requested_crates(args)?; let toolchain = resolve_toolchain(&workspace_root, args.toolchain.as_deref())?; @@ -129,7 +132,7 @@ fn run_install(args: &InstallArgs, stderr: &mut dyn Write) -> Result<()> { requested_crates: &requested_crates, toolchain: &toolchain, target_dir: &target_dir, - expected_git_sha: expected_git_sha.as_deref(), + expected_git_sha: expected_git_sha.as_ref(), }; if let Some((staging_path, install_mode)) = try_fast_path_installation(&fast_path_context, stderr)? @@ -311,7 +314,7 @@ struct FastPathContext<'a> { requested_crates: &'a [CrateName], toolchain: &'a Toolchain, target_dir: &'a Utf8PathBuf, - expected_git_sha: Option<&'a str>, + expected_git_sha: Option<&'a CommitSha>, } /// Finalize installation and record aggregate installer metrics. diff --git a/installer/src/prebuilt.rs b/installer/src/prebuilt.rs index 1c1a3e7c..100dbe2d 100644 --- a/installer/src/prebuilt.rs +++ b/installer/src/prebuilt.rs @@ -20,6 +20,7 @@ use crate::artefact::packaging::compute_sha256; use crate::artefact::packaging_error::PackagingError; use crate::artefact::verification::VerificationPolicy; use crate::builder::{library_extension, library_prefix}; +use crate::git::CommitSha; use crate::output::write_stderr_line; /// The outcome of a prebuilt download attempt. @@ -57,7 +58,7 @@ pub struct PrebuiltConfig<'a> { /// Pinned installs may only reuse a prebuilt artefact whose manifest names /// the resolved full commit SHA exactly; abbreviated provenance falls back /// to a source build. Rolling installations leave this unset. - pub expected_git_sha: Option<&'a str>, + pub expected_git_sha: Option<&'a CommitSha>, } /// Internal error type for the prebuilt pipeline. @@ -212,15 +213,18 @@ fn validate_target(manifest: &Manifest, expected: &str) -> Result<(), PrebuiltEr /// An abbreviated manifest SHA is suitable for rolling installations only. /// Pinned installations require a full object ID equal to the resolved commit. /// When no commit is pinned this is a no-op, preserving rolling behaviour. -fn validate_git_sha(manifest: &Manifest, expected: Option<&str>) -> Result<(), PrebuiltError> { +fn validate_git_sha( + manifest: &Manifest, + expected: Option<&CommitSha>, +) -> Result<(), PrebuiltError> { let Some(expected) = expected else { return Ok(()); }; let manifest_sha = manifest.git_sha().as_str(); - if manifest_sha.len() != 40 || manifest_sha != expected { + if manifest_sha.len() != 40 || manifest_sha != expected.as_str() { return Err(PrebuiltError::GitShaMismatch { manifest: manifest_sha.to_owned(), - expected: expected.to_owned(), + expected: expected.to_string(), }); } Ok(()) diff --git a/installer/src/prebuilt_tests.rs b/installer/src/prebuilt_tests.rs index ea6cd563..3b427076 100644 --- a/installer/src/prebuilt_tests.rs +++ b/installer/src/prebuilt_tests.rs @@ -3,6 +3,7 @@ use super::*; use crate::artefact::download::MockArtefactDownloader; use crate::artefact::extraction::MockArtefactExtractor; +use crate::git::CommitSha; use crate::test_utils::{prebuilt_manifest_json, sha256_hex}; use cap_std::{ambient_authority, fs::Dir}; use proptest::prelude::*; @@ -65,10 +66,12 @@ proptest! { fn equal_full_git_shas_are_accepted( commit in commit_sha_strategy(), ) { + let expected = CommitSha::try_from(commit.as_str()) + .map_err(|error| TestCaseError::fail(error.to_string()))?; let manifest = manifest_with_git_sha(&commit, &"a".repeat(64)) .map_err(|error| TestCaseError::fail(error.to_string()))?; - prop_assert!(validate_git_sha(&manifest, Some(&commit)).is_ok()); + prop_assert!(validate_git_sha(&manifest, Some(&expected)).is_ok()); } #[test] @@ -76,11 +79,13 @@ proptest! { commit in commit_sha_strategy(), prefix_len in 7_usize..40, ) { + let expected = CommitSha::try_from(commit.as_str()) + .map_err(|error| TestCaseError::fail(error.to_string()))?; let manifest_sha = &commit[..prefix_len]; let manifest = manifest_with_git_sha(manifest_sha, &"a".repeat(64)) .map_err(|error| TestCaseError::fail(error.to_string()))?; - match validate_git_sha(&manifest, Some(&commit)) { + match validate_git_sha(&manifest, Some(&expected)) { Err(PrebuiltError::GitShaMismatch { manifest, expected }) => { prop_assert_eq!(manifest, manifest_sha); prop_assert_eq!(expected, commit); @@ -98,6 +103,8 @@ proptest! { commit in commit_sha_strategy(), shared_prefix_len in 7_usize..40, ) { + let expected = CommitSha::try_from(commit.as_str()) + .map_err(|error| TestCaseError::fail(error.to_string()))?; let mut manifest_sha = commit.clone(); let replacement = if manifest_sha.as_bytes()[shared_prefix_len] == b'0' { "1" @@ -108,7 +115,7 @@ proptest! { let manifest = manifest_with_git_sha(&manifest_sha, &"a".repeat(64)) .map_err(|error| TestCaseError::fail(error.to_string()))?; - match validate_git_sha(&manifest, Some(&commit)) { + match validate_git_sha(&manifest, Some(&expected)) { Err(PrebuiltError::GitShaMismatch { manifest, expected }) => { prop_assert_eq!(manifest, manifest_sha); prop_assert_eq!(expected, commit); @@ -171,8 +178,12 @@ fn prebuilt_git_sha_gate( #[case] expects_success: bool, ) { let (_temp, destination_dir) = destination_dir(); + let expected_commit = expected_git_sha + .map(CommitSha::try_from) + .transpose() + .expect("full test commit SHA"); let config = PrebuiltConfig { - expected_git_sha, + expected_git_sha: expected_commit.as_ref(), ..base_config(&destination_dir) }; let (downloader, extractor) = success_mocks_with_git_sha(manifest_git_sha); diff --git a/installer/src/workspace.rs b/installer/src/workspace.rs index 4b057775..943d518f 100644 --- a/installer/src/workspace.rs +++ b/installer/src/workspace.rs @@ -1,10 +1,14 @@ -//! Workspace detection and path resolution. +//! Managed Whitaker workspace selection and preparation. //! -//! This module provides utilities for detecting whether the current directory -//! is a Whitaker workspace and for resolving platform-specific clone locations. +//! This module detects local workspaces, selects the managed-clone action, and +//! orchestrates clone, update, pinning, and detached-checkout recovery through +//! [`crate::git`]. It returns checkout provenance for prebuilt validation while +//! the CLI presentation boundary reports the completed action. use crate::dirs::BaseDirs; use crate::error::{InstallerError, Result}; +use crate::git::CommitSha; +use crate::workspace_lock::ManagedCloneLock; use camino::{Utf8Path, Utf8PathBuf}; use tracing::debug; @@ -124,9 +128,9 @@ pub struct WorkspaceCheckout { /// Path to the workspace root the install should build from. pub root: Utf8PathBuf, /// The full commit SHA a `--ref` pin resolved to, if any. - pub pinned_commit: Option, + pub pinned_commit: Option, /// The existing detached HEAD reused by an unpinned `--no-update` install. - pub detached_commit: Option, + pub detached_commit: Option, /// The action selected to prepare this workspace. pub action: WorkspaceAction, } @@ -155,7 +159,18 @@ pub fn ensure_workspace( update: bool, git_ref: Option<&str>, ) -> Result { - let action = resolve_workspace_action(dirs, update)?; + let cwd = current_dir_utf8()?; + let clone_dir = clone_directory(dirs).ok_or_else(|| InstallerError::WorkspaceNotFound { + reason: "could not determine data directory for cloning".to_owned(), + })?; + if is_whitaker_workspace(&cwd) { + let action = WorkspaceAction::UseCurrentDir(cwd.clone()); + ensure_ref_allowed(&action, git_ref)?; + return finalize_workspace_checkout(cwd, git_ref, action); + } + + let _lock = ManagedCloneLock::acquire(&clone_dir)?; + let action = decide_workspace_action(&cwd, &clone_dir, update); ensure_ref_allowed(&action, git_ref)?; let root = match &action { @@ -203,7 +218,7 @@ fn inherited_detached_commit( root: &Utf8Path, git_ref: Option<&str>, action: &WorkspaceAction, -) -> Result> { +) -> Result> { if git_ref.is_none() && matches!(action, WorkspaceAction::UseExisting(_)) { return crate::git::detached_head_commit(root); } @@ -213,10 +228,10 @@ fn inherited_detached_commit( impl WorkspaceCheckout { /// Returns the commit that a downloaded prebuilt artefact must match. #[must_use] - pub fn expected_git_sha(&self) -> Option<&str> { + pub fn expected_git_sha(&self) -> Option<&CommitSha> { self.pinned_commit - .as_deref() - .or(self.detached_commit.as_deref()) + .as_ref() + .or(self.detached_commit.as_ref()) } } @@ -241,7 +256,7 @@ pub fn ensure_ref_allowed(action: &WorkspaceAction, git_ref: Option<&str>) -> Re /// Pins the managed clone to `git_ref` when one is requested. /// /// Returns the resolved commit SHA, or `None` when no ref was requested. -fn pin_if_requested(repo: &Utf8Path, git_ref: Option<&str>) -> Result> { +fn pin_if_requested(repo: &Utf8Path, git_ref: Option<&str>) -> Result> { match git_ref { Some(git_ref) => Ok(Some(pin_to_ref(repo, git_ref)?)), None => Ok(None), @@ -249,7 +264,7 @@ fn pin_if_requested(repo: &Utf8Path, git_ref: Option<&str>) -> Result Result { +pub(super) fn pin_to_ref(repo: &Utf8Path, git_ref: &str) -> Result { let commit = match crate::git::fetch_ref(repo, git_ref) { Ok(commit) => commit, Err(fetch_error) => { diff --git a/installer/src/workspace_lock.rs b/installer/src/workspace_lock.rs new file mode 100644 index 00000000..08e7bed1 --- /dev/null +++ b/installer/src/workspace_lock.rs @@ -0,0 +1,83 @@ +//! Cross-process serialization for managed Whitaker clone preparation. +//! +//! A sidecar lock prevents concurrent installers from changing the shared +//! managed checkout between action selection, ref resolution, and checkout. + +use crate::error::{InstallerError, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use fs2::FileExt; +use std::fs::{File, OpenOptions}; + +/// An exclusive advisory lock held while preparing the managed clone. +pub(crate) struct ManagedCloneLock { + _file: File, +} + +impl ManagedCloneLock { + /// Acquires the sidecar lock for `clone_dir`, waiting for another installer + /// to finish preparation before re-evaluating the workspace action. + pub(crate) fn acquire(clone_dir: &Utf8Path) -> Result { + let path = lock_path(clone_dir); + let parent = path + .parent() + .ok_or_else(|| InstallerError::WorkspaceNotFound { + reason: format!("could not determine parent directory for workspace lock {path}"), + })?; + std::fs::create_dir_all(parent).map_err(|source| InstallerError::WorkspaceLock { + path: path.clone(), + source, + })?; + let file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&path) + .map_err(|source| InstallerError::WorkspaceLock { + path: path.clone(), + source, + })?; + file.lock_exclusive() + .map_err(|source| InstallerError::WorkspaceLock { path, source })?; + Ok(Self { _file: file }) + } +} + +/// Returns the persistent sidecar lock path for a managed clone directory. +#[must_use] +pub(crate) fn lock_path(clone_dir: &Utf8Path) -> Utf8PathBuf { + clone_dir.with_extension("lock") +} + +#[cfg(test)] +mod tests { + use super::{ManagedCloneLock, lock_path}; + use camino::Utf8PathBuf; + use fs2::FileExt; + use std::fs::OpenOptions; + use tempfile::TempDir; + + #[test] + fn managed_clone_lock_excludes_another_open_file() { + let temp = TempDir::new().expect("create temporary lock directory"); + let clone_dir = Utf8PathBuf::try_from(temp.path().join("whitaker")) + .expect("temporary lock path is UTF-8"); + let first = ManagedCloneLock::acquire(&clone_dir).expect("acquire first lock"); + let path = lock_path(&clone_dir); + let second = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .expect("open second lock handle"); + + let error = second + .try_lock_exclusive() + .expect_err("second handle must contend for the lock"); + assert_eq!(error.kind(), fs2::lock_contended_error().kind()); + + drop(first); + second + .try_lock_exclusive() + .expect("second handle acquires lock after release"); + } +} diff --git a/installer/src/workspace_progress.rs b/installer/src/workspace_progress.rs index 8b127374..070d1547 100644 --- a/installer/src/workspace_progress.rs +++ b/installer/src/workspace_progress.rs @@ -5,6 +5,7 @@ use std::io::Write; use whitaker_installer::cli::InstallArgs; +use whitaker_installer::git::CommitSha; use whitaker_installer::output::write_stderr_line; use whitaker_installer::workspace::{WorkspaceAction, WorkspaceCheckout}; @@ -58,7 +59,8 @@ pub(super) fn report_pinned_checkout( } /// Abbreviates a commit SHA to its leading 12 characters for display. -fn short_commit(commit: &str) -> &str { +fn short_commit(commit: &CommitSha) -> &str { + let commit = commit.as_str(); let end = commit.len().min(12); &commit[..end] } @@ -75,7 +77,7 @@ mod tests { let root = Utf8PathBuf::from("/managed/whitaker"); WorkspaceCheckout { root: root.clone(), - pinned_commit: Some(COMMIT.to_owned()), + pinned_commit: Some(CommitSha::try_from(COMMIT).expect("full test commit SHA")), detached_commit: None, action: WorkspaceAction::UseExisting(root), } diff --git a/installer/src/workspace_tests.rs b/installer/src/workspace_tests.rs index 129f671b..5b799a44 100644 --- a/installer/src/workspace_tests.rs +++ b/installer/src/workspace_tests.rs @@ -172,9 +172,8 @@ fn resolve_workspace_path_returns_clone_dir_when_not_in_workspace(temp_workspace ); } -#[rstest] -fn resolve_workspace_path_errors_when_data_dir_unavailable(temp_workspace: TempWorkspace) { - let _ = temp_workspace; // Ensure fixture is used +#[test] +fn resolve_workspace_path_errors_when_data_dir_unavailable() { let mock = mock_dirs_returning(None); let result = resolve_workspace_path(&mock); diff --git a/installer/tests/behaviour_prebuilt.rs b/installer/tests/behaviour_prebuilt.rs index 88bb981e..0bec3776 100644 --- a/installer/tests/behaviour_prebuilt.rs +++ b/installer/tests/behaviour_prebuilt.rs @@ -9,6 +9,7 @@ use std::sync::Mutex; use whitaker_installer::artefact::download::{ArtefactDownloader, DownloadError}; use whitaker_installer::artefact::extraction::{ArtefactExtractor, ExtractionError}; use whitaker_installer::cli::{Cli, InstallArgs}; +use whitaker_installer::git::CommitSha; use whitaker_installer::prebuilt::{PrebuiltConfig, PrebuiltResult, attempt_prebuilt_with}; use whitaker_installer::resolution::{CrateResolutionOptions, resolve_crates}; use whitaker_installer::test_utils::{prebuilt_manifest_json, sha256_hex}; @@ -228,12 +229,18 @@ fn when_prebuilt_attempted(world: &mut PrebuiltWorld) { .join("lib") }; world.attempted_destination = Some(destination_dir.clone()); + let expected_commit = world + .expected_git_sha + .as_deref() + .map(CommitSha::try_from) + .transpose() + .expect("full expected Git SHA"); let config = PrebuiltConfig { target, toolchain, destination_dir: &destination_dir, quiet: true, - expected_git_sha: world.expected_git_sha.as_deref(), + expected_git_sha: expected_commit.as_ref(), }; let manifest_behaviour = world From 56b5aaeeb77e4ec24d2f4b744397a746973b5521 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 22 Aug 2026 16:32:17 +0200 Subject: [PATCH 23/26] Serialize managed clone preparation (#271) Use capability-scoped sidecar lock access and prove that concurrent preparation re-evaluates state rather than issuing a duplicate clone. Clarify inherited detached-checkout provenance at the prebuilt boundary. --- docs/developers-guide.md | 9 + .../issue-271-ref-pinned-installation.md | 12 +- installer/src/git/commit_sha.rs | 2 + installer/src/install_flow.rs | 3 +- installer/src/workspace.rs | 74 +++++++-- installer/src/workspace_lock.rs | 62 +++++-- installer/src/workspace_tests.rs | 155 ++++++++++++++++++ 7 files changed, 282 insertions(+), 35 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 51d72acd..5712a39d 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -1892,6 +1892,15 @@ update setup. It may combine optional pinning with `WorkspaceCheckout` construction and record the supplied action; it must not select an action or clone, update, or reattach a repository. +`workspace::WorkspaceRepository` is a private seam for workspace orchestration. +The production `GitWorkspaceRepository` implementation delegates clone, update, +and default-branch operations to `crate::git`; `ensure_workspace` must compose +through that implementation, keeping Git process ownership in the workspace +boundary. Tests may inject a repository implementation only through the +private preparation helper to exercise action re-evaluation and lock behaviour +without network or process side effects. The seam must not become a public Git +adapter or be used to bypass the `crate::git` APIs in production. + Behaviour-test support follows the same ownership rule. `behaviour_cli::support::output_for_assertions` combines scenario-skip handling with borrowing the captured process output. Assertion-specific expectations diff --git a/docs/execplans/issue-271-ref-pinned-installation.md b/docs/execplans/issue-271-ref-pinned-installation.md index e3a6e0ee..63d9d1a9 100644 --- a/docs/execplans/issue-271-ref-pinned-installation.md +++ b/docs/execplans/issue-271-ref-pinned-installation.md @@ -193,6 +193,11 @@ when its manifest records the same full object ID as the resolved ref. interfaces carry and compare that provenance exactly. Current tests cover clone/update, ref resolution and fetching, detached checkout and recovery, workspace provenance, and exact prebuilt match/mismatch behaviour. +- [x] (2026-08-22) Managed-clone preparation is serialized by + `ManagedCloneLock` from action selection through checkout. The lock creates + its parent directory and sidecar through `cap_std::fs_utf8::Dir`, converting + the capability-bound file with `into_std()` only immediately before + `fs2::FileExt::lock_exclusive()`; waiters then re-evaluate workspace state. ## Surprises & discoveries @@ -769,9 +774,10 @@ pub fn ensure_workspace( ) -> Result; ``` -`WorkspaceCheckout::expected_git_sha()` returns the pinned commit, or the -inherited detached commit, for exact prebuilt provenance validation. It is -unset for an ordinary rolling checkout. +`WorkspaceCheckout::expected_git_sha()` returns the newly pinned commit, or an +inherited detached commit when no new `--ref` is supplied (including an +unpinned `--no-update` run), so the prebuilt SHA gate remains enabled for that +managed checkout. It is unset only for an ordinary rolling checkout. In `installer/src/prebuilt.rs`: diff --git a/installer/src/git/commit_sha.rs b/installer/src/git/commit_sha.rs index 35a566bb..631bc660 100644 --- a/installer/src/git/commit_sha.rs +++ b/installer/src/git/commit_sha.rs @@ -55,6 +55,8 @@ impl fmt::Display for CommitSha { #[cfg(test)] mod tests { + //! Validates `CommitSha` parsing and rejection behaviour. + use super::CommitSha; use rstest::rstest; diff --git a/installer/src/install_flow.rs b/installer/src/install_flow.rs index 381d621b..998b8f8b 100644 --- a/installer/src/install_flow.rs +++ b/installer/src/install_flow.rs @@ -102,7 +102,8 @@ pub(crate) struct PrebuiltInstallationContext<'a> { pub(crate) requested_crates: &'a [CrateName], /// Toolchain channel resolved for this install. pub(crate) toolchain_channel: &'a str, - /// Resolved pinned commit SHA, when installing at a specific `--ref`. + /// Full commit ID for a requested pin or inherited detached `--no-update` + /// checkout, used to keep the prebuilt SHA gate active in either case. pub(crate) expected_git_sha: Option<&'a CommitSha>, } diff --git a/installer/src/workspace.rs b/installer/src/workspace.rs index 943d518f..a04495ac 100644 --- a/installer/src/workspace.rs +++ b/installer/src/workspace.rs @@ -135,6 +135,39 @@ pub struct WorkspaceCheckout { pub action: WorkspaceAction, } +/// Git operations owned by managed-workspace preparation. +/// +/// This private seam keeps production calls in this module while allowing the +/// lock behaviour to be tested without a network clone. +trait WorkspaceRepository { + fn clone(&self, target: &Utf8Path) -> Result<()>; + fn update(&self, repo: &Utf8Path) -> Result<()>; + fn ensure_default_branch(&self, repo: &Utf8Path) -> Result<()>; +} + +/// Production Git operations for managed-workspace preparation. +struct GitWorkspaceRepository; + +impl WorkspaceRepository for GitWorkspaceRepository { + fn clone(&self, target: &Utf8Path) -> Result<()> { + crate::git::clone_repository(target) + } + + fn update(&self, repo: &Utf8Path) -> Result<()> { + crate::git::update_repository(repo) + } + + fn ensure_default_branch(&self, repo: &Utf8Path) -> Result<()> { + crate::git::ensure_default_branch(repo) + } +} +/// Inputs shared by workspace preparation and its test boundary. +struct WorkspacePreparation<'a> { + dirs: &'a dyn BaseDirs, + update: bool, + git_ref: Option<&'a str>, +} + /// Ensures a Whitaker workspace is available, cloning if necessary. /// /// If the current directory is already a Whitaker workspace, returns its path. @@ -160,18 +193,33 @@ pub fn ensure_workspace( git_ref: Option<&str>, ) -> Result { let cwd = current_dir_utf8()?; - let clone_dir = clone_directory(dirs).ok_or_else(|| InstallerError::WorkspaceNotFound { - reason: "could not determine data directory for cloning".to_owned(), - })?; - if is_whitaker_workspace(&cwd) { - let action = WorkspaceAction::UseCurrentDir(cwd.clone()); - ensure_ref_allowed(&action, git_ref)?; - return finalize_workspace_checkout(cwd, git_ref, action); + let preparation = WorkspacePreparation { + dirs, + update, + git_ref, + }; + ensure_workspace_from(&cwd, &preparation, &GitWorkspaceRepository) +} + +/// Prepares a workspace from an explicit current directory and Git boundary. +fn ensure_workspace_from( + cwd: &Utf8Path, + preparation: &WorkspacePreparation<'_>, + repository: &impl WorkspaceRepository, +) -> Result { + let clone_dir = + clone_directory(preparation.dirs).ok_or_else(|| InstallerError::WorkspaceNotFound { + reason: "could not determine data directory for cloning".to_owned(), + })?; + if is_whitaker_workspace(cwd) { + let action = WorkspaceAction::UseCurrentDir(cwd.to_owned()); + ensure_ref_allowed(&action, preparation.git_ref)?; + return finalize_workspace_checkout(cwd.to_owned(), preparation.git_ref, action); } let _lock = ManagedCloneLock::acquire(&clone_dir)?; - let action = decide_workspace_action(&cwd, &clone_dir, update); - ensure_ref_allowed(&action, git_ref)?; + let action = decide_workspace_action(cwd, &clone_dir, preparation.update); + ensure_ref_allowed(&action, preparation.git_ref)?; let root = match &action { WorkspaceAction::UseCurrentDir(dir) | WorkspaceAction::UseExisting(dir) => { @@ -180,18 +228,18 @@ pub fn ensure_workspace( dir.clone() } WorkspaceAction::CloneTo(dir) => { - crate::git::clone_repository(dir)?; + repository.clone(dir)?; dir.clone() } WorkspaceAction::UpdateAt(dir) => { // Reattach before pulling so a prior detached pin cannot break the // update, even when no new ref is requested. - crate::git::ensure_default_branch(dir)?; - crate::git::update_repository(dir)?; + repository.ensure_default_branch(dir)?; + repository.update(dir)?; dir.clone() } }; - finalize_workspace_checkout(root, git_ref, action) + finalize_workspace_checkout(root, preparation.git_ref, action) } /// Applies an optional pin and constructs the resulting workspace checkout. diff --git a/installer/src/workspace_lock.rs b/installer/src/workspace_lock.rs index 08e7bed1..fb590455 100644 --- a/installer/src/workspace_lock.rs +++ b/installer/src/workspace_lock.rs @@ -5,8 +5,12 @@ use crate::error::{InstallerError, Result}; use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::{ + ambient_authority, + fs_utf8::{Dir, OpenOptions}, +}; use fs2::FileExt; -use std::fs::{File, OpenOptions}; +use std::fs::File; /// An exclusive advisory lock held while preparing the managed clone. pub(crate) struct ManagedCloneLock { @@ -23,16 +27,28 @@ impl ManagedCloneLock { .ok_or_else(|| InstallerError::WorkspaceNotFound { reason: format!("could not determine parent directory for workspace lock {path}"), })?; - std::fs::create_dir_all(parent).map_err(|source| InstallerError::WorkspaceLock { - path: path.clone(), - source, + let file_name = path + .file_name() + .ok_or_else(|| InstallerError::WorkspaceNotFound { + reason: format!("could not determine file name for workspace lock {path}"), + })?; + Dir::create_ambient_dir_all(parent, ambient_authority()).map_err(|source| { + InstallerError::WorkspaceLock { + path: path.clone(), + source, + } })?; - let file = OpenOptions::new() - .create(true) - .truncate(false) - .read(true) - .write(true) - .open(&path) + let directory = Dir::open_ambient_dir(parent, ambient_authority()).map_err(|source| { + InstallerError::WorkspaceLock { + path: path.clone(), + source, + } + })?; + let mut options = OpenOptions::new(); + options.create(true).truncate(false).read(true).write(true); + let file = directory + .open_with(file_name, &options) + .map(|file| file.into_std()) .map_err(|source| InstallerError::WorkspaceLock { path: path.clone(), source, @@ -51,10 +67,15 @@ pub(crate) fn lock_path(clone_dir: &Utf8Path) -> Utf8PathBuf { #[cfg(test)] mod tests { - use super::{ManagedCloneLock, lock_path}; + //! Validates capability-scoped managed-clone lock access and exclusion. + + use super::ManagedCloneLock; use camino::Utf8PathBuf; + use cap_std::{ + ambient_authority, + fs_utf8::{Dir, OpenOptions}, + }; use fs2::FileExt; - use std::fs::OpenOptions; use tempfile::TempDir; #[test] @@ -63,12 +84,17 @@ mod tests { let clone_dir = Utf8PathBuf::try_from(temp.path().join("whitaker")) .expect("temporary lock path is UTF-8"); let first = ManagedCloneLock::acquire(&clone_dir).expect("acquire first lock"); - let path = lock_path(&clone_dir); - let second = OpenOptions::new() - .read(true) - .write(true) - .open(&path) - .expect("open second lock handle"); + let parent = clone_dir + .parent() + .expect("temporary clone directory has a parent"); + let directory = Dir::open_ambient_dir(parent, ambient_authority()) + .expect("open temporary lock directory capability"); + let mut options = OpenOptions::new(); + options.read(true).write(true); + let second = directory + .open_with("whitaker.lock", &options) + .expect("open second lock handle through directory capability") + .into_std(); let error = second .try_lock_exclusive() diff --git a/installer/src/workspace_tests.rs b/installer/src/workspace_tests.rs index 5b799a44..21f05700 100644 --- a/installer/src/workspace_tests.rs +++ b/installer/src/workspace_tests.rs @@ -5,6 +5,13 @@ use crate::dirs::{MockBaseDirs, SystemBaseDirs}; use cap_std::{ambient_authority, fs_utf8::Dir}; use rstest::{fixture, rstest}; use std::path::PathBuf; +use std::sync::{ + Arc, Barrier, Mutex, + atomic::{AtomicUsize, Ordering}, + mpsc, +}; +use std::thread; +use std::time::Duration; use tempfile::TempDir; /// A temporary directory converted to a UTF-8 path for workspace tests. @@ -199,6 +206,154 @@ fn clone_directory_returns_path_from_mock(temp_workspace: TempWorkspace) { assert_eq!(clone_directory(&mock), Some(expected)); } +#[derive(Clone)] +struct FixedBaseDirs { + data_dir: PathBuf, +} + +impl BaseDirs for FixedBaseDirs { + fn home_dir(&self) -> Option { + None + } + + fn bin_dir(&self) -> Option { + None + } + + fn whitaker_data_dir(&self) -> Option { + Some(self.data_dir.clone()) + } +} + +struct ConcurrentWorkspaceRepository { + clone_started: mpsc::Sender<()>, + release_clone: Mutex>, + clone_calls: AtomicUsize, + update_calls: AtomicUsize, +} + +impl WorkspaceRepository for ConcurrentWorkspaceRepository { + fn clone(&self, target: &Utf8Path) -> Result<()> { + self.clone_calls.fetch_add(1, Ordering::SeqCst); + self.clone_started + .send(()) + .expect("report mock clone start"); + self.release_clone + .lock() + .expect("lock mock clone release receiver") + .recv() + .expect("release mock clone"); + let parent = target + .parent() + .ok_or_else(|| InstallerError::WorkspaceNotFound { + reason: format!("mock clone target has no parent: {target}"), + })?; + let name = target + .file_name() + .ok_or_else(|| InstallerError::WorkspaceNotFound { + reason: format!("mock clone target has no file name: {target}"), + })?; + let directory = Dir::open_ambient_dir(parent, ambient_authority())?; + directory.create_dir(name)?; + Ok(()) + } + + fn update(&self, _repo: &Utf8Path) -> Result<()> { + self.update_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + fn ensure_default_branch(&self, _repo: &Utf8Path) -> Result<()> { + Ok(()) + } +} + +#[rstest] +fn concurrent_workspace_preparation_waits_and_rechecks_action(temp_workspace: TempWorkspace) { + temp_workspace + .dir + .create_dir_all("caller/data") + .expect("create caller and data directories"); + let cwd = temp_workspace.path.join("caller"); + let clone_dir = temp_workspace.path.join("caller/data/whitaker"); + let dirs = FixedBaseDirs { + data_dir: clone_dir.clone().into_std_path_buf(), + }; + let (clone_started_sender, clone_started_receiver) = mpsc::channel(); + let (release_clone_sender, release_clone_receiver) = mpsc::channel(); + let repository = Arc::new(ConcurrentWorkspaceRepository { + clone_started: clone_started_sender, + release_clone: Mutex::new(release_clone_receiver), + clone_calls: AtomicUsize::new(0), + update_calls: AtomicUsize::new(0), + }); + + let first = { + let cwd = cwd.clone(); + let dirs = dirs.clone(); + let repository = Arc::clone(&repository); + thread::spawn(move || { + let preparation = WorkspacePreparation { + dirs: &dirs, + update: true, + git_ref: None, + }; + ensure_workspace_from(&cwd, &preparation, &*repository) + }) + }; + clone_started_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("first preparation begins cloning while holding the lock"); + + let start_barrier = Arc::new(Barrier::new(2)); + let (second_ready_sender, second_ready_receiver) = mpsc::channel(); + let second = { + let cwd = cwd.clone(); + let dirs = dirs.clone(); + let repository = Arc::clone(&repository); + let start_barrier = Arc::clone(&start_barrier); + thread::spawn(move || { + second_ready_sender + .send(()) + .expect("report second preparation ready"); + start_barrier.wait(); + let preparation = WorkspacePreparation { + dirs: &dirs, + update: true, + git_ref: None, + }; + ensure_workspace_from(&cwd, &preparation, &*repository) + }) + }; + second_ready_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("second preparation is ready to contend"); + start_barrier.wait(); + assert!( + clone_started_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err(), + "second preparation must wait for the managed-clone lock" + ); + + release_clone_sender + .send(()) + .expect("release first preparation clone"); + let first = first + .join() + .expect("first preparation thread should not panic") + .expect("first preparation should succeed"); + let second = second + .join() + .expect("second preparation thread should not panic") + .expect("second preparation should succeed"); + + assert_eq!(first.action, WorkspaceAction::CloneTo(clone_dir.clone())); + assert_eq!(second.action, WorkspaceAction::UpdateAt(clone_dir)); + assert_eq!(repository.clone_calls.load(Ordering::SeqCst), 1); + assert_eq!(repository.update_calls.load(Ordering::SeqCst), 1); +} + // Tests for find_workspace_root fn write_workspace_cargo_toml(dir: &Dir) { From 7293a27f98673d2961600ac072856bc48dbd4470 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 22 Aug 2026 16:46:50 +0200 Subject: [PATCH 24/26] Test managed-clone lock serialization (#271) Exercise the real Git workspace-preparation path while a competing operation holds the managed-clone sidecar lock. Assert the waiter re-evaluates the shared clone, updates `main`, and reaches the remote commit after lock release. --- .../src/workspace_lock_workflow_tests.rs | 152 ++++++++++++++++++ installer/src/workspace_tests.rs | 3 + 2 files changed, 155 insertions(+) create mode 100644 installer/src/workspace_lock_workflow_tests.rs diff --git a/installer/src/workspace_lock_workflow_tests.rs b/installer/src/workspace_lock_workflow_tests.rs new file mode 100644 index 00000000..a6662333 --- /dev/null +++ b/installer/src/workspace_lock_workflow_tests.rs @@ -0,0 +1,152 @@ +//! Real-Git workflow coverage for managed-clone lock serialization. + +use super::super::{ + GitWorkspaceRepository, ManagedCloneLock, WorkspaceAction, WorkspacePreparation, + ensure_workspace_from, +}; +use crate::dirs::BaseDirs; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::{ambient_authority, fs_utf8::Dir}; +use std::path::PathBuf; +use std::process::Command; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; +use tempfile::TempDir; + +#[derive(Clone)] +struct ManagedCloneDirs { + clone_dir: PathBuf, +} + +impl BaseDirs for ManagedCloneDirs { + fn home_dir(&self) -> Option { + None + } + + fn bin_dir(&self) -> Option { + None + } + + fn whitaker_data_dir(&self) -> Option { + Some(self.clone_dir.clone()) + } +} + +fn git(dir: &Utf8Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(dir.as_std_path()) + .output() + .expect("spawn fixture Git command"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_owned() +} + +fn commit_file(dir: &Utf8Path, contents: &str, message: &str) -> String { + let directory = + Dir::open_ambient_dir(dir, ambient_authority()).expect("open source fixture capability"); + directory + .write("fixture.txt", contents) + .expect("write source fixture file"); + git(dir, &["add", "."]); + git( + dir, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + message, + ], + ); + git(dir, &["rev-parse", "HEAD"]) +} + +fn clone_repository(source: &Utf8Path, target: &Utf8Path) { + let output = Command::new("git") + .args(["clone", source.as_str(), target.as_str()]) + .output() + .expect("spawn fixture clone"); + assert!( + output.status.success(), + "fixture clone failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn managed_clone_waiter_rechecks_state_and_updates_after_lock_release() { + let temp = TempDir::new().expect("create temporary workflow directory"); + let root = Utf8PathBuf::try_from(temp.path().to_owned()).expect("temporary path is UTF-8"); + let directory = Dir::open_ambient_dir(&root, ambient_authority()) + .expect("open temporary workflow capability"); + directory + .create_dir_all("source/caller/data") + .expect("create workflow fixture directories"); + let source = root.join("source"); + let caller = root.join("caller"); + let managed_clone = root.join("caller/data/whitaker"); + git(&source, &["init", "-b", "main"]); + commit_file(&source, "first", "initial commit"); + + let lock = ManagedCloneLock::acquire(&managed_clone).expect("acquire managed-clone lock"); + let dirs = ManagedCloneDirs { + clone_dir: managed_clone.clone().into_std_path_buf(), + }; + let (started_sender, started_receiver) = mpsc::channel(); + let (result_sender, result_receiver) = mpsc::channel(); + let waiter = thread::spawn(move || { + started_sender.send(()).expect("report waiter start"); + let preparation = WorkspacePreparation { + dirs: &dirs, + update: true, + git_ref: None, + }; + result_sender.send(ensure_workspace_from( + &caller, + &preparation, + &GitWorkspaceRepository, + )) + }); + started_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("waiter begins workspace preparation"); + assert!( + result_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err(), + "workspace preparation must wait for the managed-clone lock" + ); + + clone_repository(&source, &managed_clone); + let expected_commit = commit_file(&source, "second", "remote update"); + drop(lock); + + let checkout = result_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("waiter completes after lock release") + .expect("waiter workspace preparation succeeds"); + waiter + .join() + .expect("waiter thread should not panic") + .expect("waiter reports its workspace result"); + + assert_eq!( + checkout.action, + WorkspaceAction::UpdateAt(managed_clone.clone()) + ); + assert_eq!( + git(&managed_clone, &["symbolic-ref", "HEAD"]), + "refs/heads/main" + ); + assert_eq!(git(&managed_clone, &["rev-parse", "HEAD"]), expected_commit); +} diff --git a/installer/src/workspace_tests.rs b/installer/src/workspace_tests.rs index 21f05700..0e56ad6a 100644 --- a/installer/src/workspace_tests.rs +++ b/installer/src/workspace_tests.rs @@ -393,3 +393,6 @@ fn find_workspace_root_errors_when_no_workspace_found(temp_workspace: TempWorksp InstallerError::WorkspaceNotFound { .. } )); } + +#[path = "workspace_lock_workflow_tests.rs"] +mod workspace_lock_workflow_tests; From 71c6b4747573cc7b41ecf9d1ed9b5f30b8edf274 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 22 Aug 2026 17:05:13 +0200 Subject: [PATCH 25/26] Harden default branch recovery (#271) Keep default-branch discovery read-only and repair a missing remote head only from the reattachment workflow. Reject option-like remote branch names before checkout, and align the developer and execution-plan documentation with the shipped interface. --- docs/developers-guide.md | 3 +- .../issue-271-ref-pinned-installation.md | 27 +++++----- installer/src/git.rs | 54 +++++++++++++------ installer/src/git_tests.rs | 39 ++++++++++++++ 4 files changed, 95 insertions(+), 28 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 5712a39d..87f0c62d 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -1837,6 +1837,7 @@ Whitaker data directory keyed by toolchain and target: - `--skip-deps` — Skip `cargo-dylint`/`dylint-link` installation check - `--skip-wrapper` — Skip wrapper script generation - `--no-update` — Don't update existing repository clone +- `--ref REF` — Pin the suite to a commit-ish (SHA, tag, or branch name) ### Using installed lints @@ -1988,7 +1989,7 @@ struct FastPathContext<'a> { } ``` -A parameter-object struct that bundles the five immutable inputs consumed by +A parameter-object struct that bundles the six immutable inputs consumed by `try_fast_path_installation`. This follows the same idiom used elsewhere in the codebase (`FinishInstallContext`, `PrebuiltInstallationContext`, `MetricsWriteContext`) to keep function argument counts within the project diff --git a/docs/execplans/issue-271-ref-pinned-installation.md b/docs/execplans/issue-271-ref-pinned-installation.md index 63d9d1a9..c823c50d 100644 --- a/docs/execplans/issue-271-ref-pinned-installation.md +++ b/docs/execplans/issue-271-ref-pinned-installation.md @@ -56,10 +56,11 @@ when its manifest records the same full object ID as the resolved ref. - The installer must never mutate a user's own working tree: if the current directory is itself a Whitaker workspace, `--ref` must fail with a clear error rather than checking anything out. -- No new runtime external dependencies. Test support may directly declare the - already-resolved `cap-std` crate solely for capability-scoped fixture writes. - Git operations continue to go through `installer/src/git.rs` with the - existing 5-minute timeout discipline. +- Runtime diagnostics use the `tracing-subscriber` dependency with its + `env-filter` feature. Test support may directly declare the already-resolved + `cap-std` crate solely for capability-scoped fixture writes. Git operations + continue to go through `installer/src/git.rs` with the existing 5-minute + timeout discipline. - All work follows the repository gates: `make check-fmt`, `make lint`, `make test`, `make markdownlint` must pass before each commit. - Commit messages follow the file-based workflow (`git commit -F`), no AI @@ -74,7 +75,7 @@ when its manifest records the same full object ID as the resolved ref. - Interface: if pinning turns out to require changing the signature of a public function other than those listed in `Interfaces and dependencies`, stop and escalate. -- Dependencies: if a new crate dependency appears necessary, stop and +- Dependencies: if an additional crate dependency appears necessary, stop and escalate. - Iterations: if a gate still fails after 3 fix attempts on the same failure, stop and escalate. @@ -347,10 +348,11 @@ filename, and the detached-HEAD recovery end-to-end against tag `v0.2.4`. Scope stayed within tolerances: the implementation touched the CLI, git, workspace, prebuilt, install-flow, output, error, and main modules (well under -the 12-file limit) with no new dependencies and only the one sanctioned public -signature change (`ensure_workspace`). Rolling remains the default; only the -explicit-pin part of issue #271 is implemented, and the version-matched default -proposed there was deliberately not built (Decision Log). +the 12-file limit), added the `tracing-subscriber` runtime dependency, and made +only the one sanctioned public signature change (`ensure_workspace`). Rolling +remains the default; only the explicit-pin part of issue #271 is implemented, +and the version-matched default proposed there was deliberately not built +(Decision Log). Superseded historical lesson: confirming the manifest SHA width in Stage A was load-bearing — the match was prefix-tolerant, and exact equality would have @@ -718,9 +720,10 @@ Manual smoke test (Stage D), run against tag `v0.2.4` ## Interfaces and dependencies -No new third-party package is introduced. The installer adds the workspace's -existing `proptest` package as a development dependency. At completion the -following must exist: +The installer adds `tracing-subscriber` with its `env-filter` feature as a +runtime dependency for CLI diagnostics. It also adds the workspace's existing +`proptest` package as a development dependency. At completion the following +must exist: In `installer/src/cli.rs`: diff --git a/installer/src/git.rs b/installer/src/git.rs index f3987134..e12783fd 100644 --- a/installer/src/git.rs +++ b/installer/src/git.rs @@ -178,36 +178,60 @@ pub fn ensure_default_branch(repo: &Utf8Path) -> Result<()> { attempt = "default_branch_recovery", "reattaching detached checkout" ); - let branch = default_branch_name(repo)?; + let branch = match default_branch_name(repo)? { + Some(branch) => branch, + None => { + repair_default_branch_head(repo)?; + default_branch_name(repo)?.ok_or_else(|| InstallerError::Git { + operation: "rev-parse", + message: "could not determine default branch from origin/HEAD".to_owned(), + })? + } + }; run_git_checked(&["checkout", &branch], Some(repo), "checkout") } /// Discovers the remote default branch name (without the `origin/` prefix). -fn default_branch_name(repo: &Utf8Path) -> Result { - if let Some(branch) = read_default_branch(repo)? { - return Ok(branch); - } +fn default_branch_name(repo: &Utf8Path) -> Result> { + let Some(branch) = read_default_branch(repo)? else { + return Ok(None); + }; + validate_default_branch(repo, &branch)?; + Ok(Some(branch)) +} - // An older clone may lack origin/HEAD; ask git to repopulate it, then retry. +/// Restores a missing `origin/HEAD` symbolic reference from the remote. +fn repair_default_branch_head(repo: &Utf8Path) -> Result<()> { + // An older clone may lack origin/HEAD; ask git to repopulate it before retrying. debug!( operation = "remote", attempt = "repair_origin_head", "repairing origin default branch reference" ); - let _ = run_git_with_timeout( + run_git_checked( &["remote", "set-head", "origin", "--auto"], Some(repo), "remote", + ) +} + +/// Rejects a remote-head value that Git would parse as a checkout option. +fn validate_default_branch(repo: &Utf8Path, branch: &str) -> Result<()> { + let output = run_git_with_timeout( + &["check-ref-format", "--branch", branch], + Some(repo), + "check-ref-format", )?; + if output.status.success() { + return Ok(()); + } - debug!( - operation = "rev-parse", - attempt = "retry_origin_head", - "retrying default branch discovery" - ); - read_default_branch(repo)?.ok_or_else(|| InstallerError::Git { - operation: "rev-parse", - message: "could not determine default branch from origin/HEAD".to_owned(), + Err(InstallerError::Git { + operation: "check-ref-format", + message: format!( + "invalid default branch from origin/HEAD: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), }) } diff --git a/installer/src/git_tests.rs b/installer/src/git_tests.rs index 4bf704b8..add023cd 100644 --- a/installer/src/git_tests.rs +++ b/installer/src/git_tests.rs @@ -185,6 +185,28 @@ fn ensure_default_branch_is_noop_on_a_branch(git_fixture: GitFixture) { ); } +#[rstest] +fn default_branch_name_does_not_repair_missing_remote_head(git_fixture: GitFixture) { + git( + &git_fixture.clone, + &["symbolic-ref", "--delete", "refs/remotes/origin/HEAD"], + ); + + assert_eq!( + default_branch_name(&git_fixture.clone).expect("query default branch"), + None + ); + let remote_head = Command::new("git") + .args(["symbolic-ref", "refs/remotes/origin/HEAD"]) + .current_dir(git_fixture.clone.as_std_path()) + .output() + .expect("query missing origin HEAD"); + assert!( + !remote_head.status.success(), + "query must not repair origin/HEAD" + ); +} + #[rstest] fn ensure_default_branch_repairs_missing_remote_head(git_fixture: GitFixture) { let commit = resolve_commit(&git_fixture.clone, "v1").expect("resolve tag"); @@ -209,6 +231,23 @@ fn ensure_default_branch_repairs_missing_remote_head(git_fixture: GitFixture) { ); } +#[rstest] +fn default_branch_validation_rejects_option_like_ref(git_fixture: GitFixture) { + let err = validate_default_branch(&git_fixture.clone, "--orphan=attacker") + .expect_err("option-like default branch must be rejected"); + + assert!( + matches!( + err, + InstallerError::Git { + operation: "check-ref-format", + .. + } + ), + "got {err:?}" + ); +} + #[rstest] fn fetch_ref_retrieves_a_new_tag(git_fixture: GitFixture) { // Add a third commit and tag it in the source, after the clone was made. From dbef18465df5a2552bf2d37fc8469ff251918d9d Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 23 Aug 2026 03:48:56 +0200 Subject: [PATCH 26/26] Strengthen pinned installation verification (#271) Cover the managed-clone lock with a competing pinned clone and unpinned update, proving that the waiter re-evaluates the shared checkout after it acquires the lock. Assert pinned commit provenance reaches prebuilt validation and that a SHA mismatch stops before archive download. Restore the no-default-feature build regression while removing its unused implementation stub, and update the developer and execution-plan records. --- crates/no_std_fs_operations/src/lib.rs | 12 -- .../tests/no_default_features_build.rs | 90 ++++++++++ docs/developers-guide.md | 1 + .../issue-271-ref-pinned-installation.md | 18 +- installer/src/install_flow/tests.rs | 49 ++++++ installer/src/prebuilt_provenance_tests.rs | 36 ++++ installer/src/prebuilt_tests.rs | 5 +- .../src/workspace_lock_workflow_tests.rs | 160 ++++++++++++++---- 8 files changed, 317 insertions(+), 54 deletions(-) create mode 100644 crates/no_std_fs_operations/tests/no_default_features_build.rs create mode 100644 installer/src/prebuilt_provenance_tests.rs diff --git a/crates/no_std_fs_operations/src/lib.rs b/crates/no_std_fs_operations/src/lib.rs index 0be3f30c..7af853cb 100644 --- a/crates/no_std_fs_operations/src/lib.rs +++ b/crates/no_std_fs_operations/src/lib.rs @@ -22,15 +22,3 @@ mod usage; pub use config::NoStdFsConfig; #[cfg(feature = "dylint-driver")] pub use driver::*; - -#[cfg(not(feature = "dylint-driver"))] -mod stub { - //! Placeholder compiled when the `dylint-driver` feature is off, so the - //! crate still builds without the `rustc_private` toolchain internals. - - #[expect( - dead_code, - reason = "Exposed only when built without the `dylint-driver` feature" - )] - pub fn no_std_fs_operations_disabled_stub() {} -} diff --git a/crates/no_std_fs_operations/tests/no_default_features_build.rs b/crates/no_std_fs_operations/tests/no_default_features_build.rs new file mode 100644 index 00000000..181ed293 --- /dev/null +++ b/crates/no_std_fs_operations/tests/no_default_features_build.rs @@ -0,0 +1,90 @@ +//! Compile-time regression guard for the `dylint-driver`-disabled build. +//! +//! The crate is a Dylint lint library: the lint logic lives behind the +//! optional `dylint-driver` feature, while the default feature set is empty so +//! the crate can be built by tools that only need a plain library. Issue #322 +//! removed the private stub that previously existed solely to keep that +//! empty build warning-free. This test re-checks the configuration the stub +//! existed for: `cargo check --no-default-features --lib` must still succeed +//! with warnings denied, now without relying on that stub. +//! +//! The check is deliberately restricted to the library target: the crate's +//! integration test binaries are Dylint harnesses that require `cargo-dylint` +//! and `dylint-link`, which must not be assumed here. The nested `cargo` +//! invocation therefore inherits the outer `RUSTFLAGS` (so `-D warnings` from +//! the Makefile gate applies) but uses an isolated target directory so it never +//! contends with the outer build. + +use std::path::PathBuf; +use std::process::Command; + +use anyhow::Context as _; +use serde_json::Value; +use tempfile::TempDir; + +/// Top-level name of the package under test. +const CRATE: &str = "no_std_fs_operations"; + +/// Runs `cargo check --no-default-features --lib` for this crate and asserts +/// that the build succeeds with warnings denied (via inherited `RUSTFLAGS`). +/// +/// The workspace manifest is located by walking up from this test crate's +/// manifest directory, mirroring `integration_exclusion.rs`, so the nested +/// invocation resolves the same workspace this test builds under. +#[test] +fn crate_builds_without_dylint_driver_feature() -> anyhow::Result<()> { + let workspace_root = workspace_root()?; + let target_dir = TempDir::new().context("failed to create isolated target directory")?; + + let output = Command::new("cargo") + .arg("check") + .arg("--package") + .arg(CRATE) + .arg("--no-default-features") + .arg("--lib") + .arg("--message-format=json") + .current_dir(&workspace_root) + .env("CARGO_TARGET_DIR", target_dir.path()) + .output() + .context("failed to execute nested cargo check")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "`cargo check --no-default-features --lib` for `{CRATE}` failed \ + (it must compile without the `dylint-driver` feature now that the \ + no-driver stub is gone):\n{stderr}" + ); + + let diagnostics = stdout + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|message| message["reason"] == "compiler-message") + .collect::>(); + assert!( + diagnostics.is_empty(), + "`cargo check --no-default-features --lib` for `{CRATE}` emitted \ + compiler diagnostics under `-D warnings`: {diagnostics:#?}" + ); + + Ok(()) +} + +/// Returns the workspace root containing this crate's manifest. +fn workspace_root() -> anyhow::Result { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let mut candidate = manifest_dir.as_path(); + loop { + if candidate.join("Cargo.toml").is_file() { + let workspace = std::fs::read_to_string(candidate.join("Cargo.toml")) + .context("failed to read candidate workspace Cargo.toml")?; + if workspace.contains("[workspace]") { + return Ok(candidate.to_path_buf()); + } + } + candidate = candidate + .parent() + .context("workspace root not found above CARGO_MANIFEST_DIR")?; + } +} diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 87f0c62d..a725df5c 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -1986,6 +1986,7 @@ struct FastPathContext<'a> { requested_crates: &'a [CrateName], toolchain: &'a Toolchain, target_dir: &'a Utf8PathBuf, + expected_git_sha: Option<&'a CommitSha>, } ``` diff --git a/docs/execplans/issue-271-ref-pinned-installation.md b/docs/execplans/issue-271-ref-pinned-installation.md index c823c50d..d2e61628 100644 --- a/docs/execplans/issue-271-ref-pinned-installation.md +++ b/docs/execplans/issue-271-ref-pinned-installation.md @@ -94,7 +94,7 @@ when its manifest records the same full object ID as the resolved ref. - Risk: a prebuilt manifest could claim a different commit from the resolved pin. Severity: high. Likelihood: possible when rolling artefacts are reused. Mitigation: `resolve_commit` and `fetch_ref` return a validated full - `CommitSha`; pinned installs require exact full-object-ID equality before + `CommitSha`; pinned installations require exact full-object-ID equality before accepting a prebuilt. Rolling installs leave this expectation unset. - Risk: tags in the whitaker repository may not exist for every released installer version, making `--ref v0.2.5` fail for users. @@ -242,7 +242,7 @@ when its manifest records the same full object ID as the resolved ref. - Decision: `--ref` composes with `--no-update` rather than conflicting. With both flags, the installer first attempts to fetch the requested ref into its dedicated local ref. If fetching fails, it resolves an already-available - local ref or SHA, preserving offline pinned installs. + local ref or SHA, preserving offline pinned installations. Rationale: "use my existing clone, offline, at this ref" is a coherent and useful request; fetch-first avoids silently pinning a stale local branch, while local fallback preserves the offline case. @@ -623,7 +623,7 @@ Acceptance is behavioural: `/tmp/test-whitaker-issue-271.out` and summarized in `Artefacts`). 7. Commit-resolution and prebuilt tests preserve full-object-ID provenance: matching full IDs are accepted and abbreviated or shared-prefix IDs are - rejected for pinned installs. + rejected for pinned installations. 8. `make check-fmt`, `make lint` (clippy plus the dylint suite), and `make markdownlint` pass. @@ -698,6 +698,11 @@ tags, branches, and SHAs; fetching; detached checkout; default-branch repair; the pin-and-recover lifecycle; and clone/update errors. Workspace tests cover action selection and ref guarding. Prebuilt tests cover exact full-object-ID matches, abbreviated IDs, and shared-prefix mismatches. +The managed-clone workflow test +`pinned_and_unpinned_preparations_serialize_and_recheck_state` holds the lock +during a pinned clone, confirms an unpinned preparation performs no repository +operations while waiting, then verifies it re-evaluates state, reattaches the +default branch, and updates after the lock is released. Manual smoke test (Stage D), run against tag `v0.2.4` (commit `8512ee63a212abd175c31501a57e10a713881873`): @@ -792,9 +797,10 @@ pub struct PrebuiltConfig<'a> { } ``` -`installer/src/install_flow.rs` threads `pinned_commit` from -`WorkspaceCheckout` into `PrebuiltInstallationContext` and on into -`PrebuiltConfig`. `installer/src/output.rs`'s `DryRunInfo` gains a +`installer/src/install_flow.rs` passes +`WorkspaceCheckout::expected_git_sha()`, or equivalent expected commit +provenance, through `PrebuiltInstallationContext` into `PrebuiltConfig`. +`installer/src/output.rs`'s `DryRunInfo` gains a `git_ref: Option<&'a str>` field rendered in `display_text`. ## Revision note (2026-08-01, superseded historical record) diff --git a/installer/src/install_flow/tests.rs b/installer/src/install_flow/tests.rs index 320da44e..983c4f4b 100644 --- a/installer/src/install_flow/tests.rs +++ b/installer/src/install_flow/tests.rs @@ -29,6 +29,7 @@ impl BaseDirs for TestBaseDirs { } static PRUNE_HOOK_CALLED: AtomicBool = AtomicBool::new(false); +const PINNED_COMMIT: &str = "abc12340000000000000000000000000000000ab"; fn stub_detect_host_target() -> Result { Ok("x86_64-unknown-linux-gnu".to_owned()) @@ -48,6 +49,20 @@ fn stub_attempt_prebuilt(_config: &PrebuiltConfig<'_>, _stderr: &mut dyn Write) } } +fn stub_reject_mismatched_pinned_prebuilt( + config: &PrebuiltConfig<'_>, + _stderr: &mut dyn Write, +) -> PrebuiltResult { + assert_eq!( + config.expected_git_sha.map(CommitSha::as_str), + Some(PINNED_COMMIT), + "the resolved pinned commit must reach prebuilt validation" + ); + PrebuiltResult::Fallback { + reason: "git SHA mismatch: manifest has deadbeef, pinned commit is abc1234".to_owned(), + } +} + fn stub_prune_prebuilt_libraries( _staging_path: &Utf8Path, _toolchain_channel: &str, @@ -207,3 +222,37 @@ fn try_prebuilt_installation_prune_error_falls_back_to_local_build() { "fallback message should be emitted, stderr: {stderr}" ); } + +#[test] +fn pinned_commit_provenance_reaches_prebuilt_validation() { + let dirs = TestBaseDirs { + data_dir: Some(PathBuf::from("/tmp/whitaker-test-data")), + }; + let args = InstallArgs::default(); + let requested_crates = vec![CrateName::from(SUITE_CRATE)]; + let expected_git_sha = CommitSha::try_from(PINNED_COMMIT).expect("valid pinned commit"); + let context = PrebuiltInstallationContext { + args: &args, + dirs: &dirs, + requested_crates: &requested_crates, + toolchain_channel: "nightly-2026-05-28", + expected_git_sha: Some(&expected_git_sha), + }; + + let mut stderr = Vec::new(); + let result = try_prebuilt_installation_with( + &context, + &mut stderr, + PrebuiltInstallationHooks { + detect_host_target: stub_detect_host_target, + resolve_destination_dir: stub_resolve_destination_dir, + attempt_prebuilt: stub_reject_mismatched_pinned_prebuilt, + prune_prebuilt_libraries: stub_prune_prebuilt_libraries, + }, + ); + + assert!( + matches!(result, Ok(None)), + "a mismatched pinned manifest must fall back to source compilation" + ); +} diff --git a/installer/src/prebuilt_provenance_tests.rs b/installer/src/prebuilt_provenance_tests.rs new file mode 100644 index 00000000..75a8c47f --- /dev/null +++ b/installer/src/prebuilt_provenance_tests.rs @@ -0,0 +1,36 @@ +//! Regression coverage for pinned prebuilt provenance gating. + +use super::*; +use crate::artefact::download::MockArtefactDownloader; +use crate::artefact::extraction::MockArtefactExtractor; +use crate::git::CommitSha; +use crate::test_utils::sha256_hex; + +#[test] +fn mismatched_pinned_manifest_skips_archive_download() { + let (_temp, destination_dir) = destination_dir(); + let expected = CommitSha::try_from(MATCHING_COMMIT).expect("full test commit SHA"); + let config = PrebuiltConfig { + expected_git_sha: Some(&expected), + ..base_config(&destination_dir) + }; + let fake_sha = sha256_hex(FAKE_ARCHIVE); + let manifest = + manifest_with_git_sha(MISMATCHED_COMMIT, &fake_sha).expect("construct mismatched manifest"); + let manifest_json = serde_json::to_string(&manifest).expect("serialize mismatched manifest"); + let mut downloader = MockArtefactDownloader::new(); + downloader + .expect_download_manifest() + .returning(move |_| Ok(manifest_json.clone())); + downloader.expect_download_archive().times(0); + let mut extractor = MockArtefactExtractor::new(); + extractor.expect_extract().times(0); + + let mut stderr = Vec::new(); + let result = attempt_prebuilt_with(&config, &downloader, &extractor, &mut stderr); + + assert!( + matches!(result, PrebuiltResult::Fallback { ref reason } if reason.contains("SHA mismatch")), + "a mismatched pinned manifest must fail before archive download, got {result:?}" + ); +} diff --git a/installer/src/prebuilt_tests.rs b/installer/src/prebuilt_tests.rs index 3b427076..e9d53f39 100644 --- a/installer/src/prebuilt_tests.rs +++ b/installer/src/prebuilt_tests.rs @@ -162,7 +162,7 @@ fn success_mocks_with_git_sha(git_sha: &str) -> (MockArtefactDownloader, MockArt let mut extractor = MockArtefactExtractor::new(); extractor.expect_extract().returning(|_archive, dest| { let source_name = "libwhitaker_suite.so".to_owned(); - write_test_file(&dest.join(&source_name), b"fake").expect("write extracted file"); + write_test_file(&dest.join(&source_name), b"fake")?; Ok(vec![source_name]) }); (downloader, extractor) @@ -386,3 +386,6 @@ fn destination_creation_failure_returns_fallback() { other => panic!("expected Fallback, got {other:?}"), } } + +#[path = "prebuilt_provenance_tests.rs"] +mod prebuilt_provenance_tests; diff --git a/installer/src/workspace_lock_workflow_tests.rs b/installer/src/workspace_lock_workflow_tests.rs index a6662333..a60361db 100644 --- a/installer/src/workspace_lock_workflow_tests.rs +++ b/installer/src/workspace_lock_workflow_tests.rs @@ -1,15 +1,15 @@ //! Real-Git workflow coverage for managed-clone lock serialization. use super::super::{ - GitWorkspaceRepository, ManagedCloneLock, WorkspaceAction, WorkspacePreparation, - ensure_workspace_from, + WorkspaceAction, WorkspacePreparation, WorkspaceRepository, ensure_workspace_from, }; use crate::dirs::BaseDirs; use camino::{Utf8Path, Utf8PathBuf}; use cap_std::{ambient_authority, fs_utf8::Dir}; use std::path::PathBuf; use std::process::Command; -use std::sync::mpsc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; use std::thread; use std::time::Duration; use tempfile::TempDir; @@ -83,8 +83,43 @@ fn clone_repository(source: &Utf8Path, target: &Utf8Path) { ); } +struct BlockingGitWorkspaceRepository { + source: Utf8PathBuf, + clone_started: mpsc::Sender<()>, + release_clone: Mutex>, + clone_calls: AtomicUsize, + update_calls: AtomicUsize, + branch_repair_calls: AtomicUsize, +} + +impl WorkspaceRepository for BlockingGitWorkspaceRepository { + fn clone(&self, target: &Utf8Path) -> crate::error::Result<()> { + self.clone_calls.fetch_add(1, Ordering::SeqCst); + self.clone_started + .send(()) + .expect("report clone after acquiring managed-clone lock"); + self.release_clone + .lock() + .expect("lock clone-release receiver") + .recv() + .expect("release pinned clone"); + clone_repository(&self.source, target); + Ok(()) + } + + fn update(&self, repo: &Utf8Path) -> crate::error::Result<()> { + self.update_calls.fetch_add(1, Ordering::SeqCst); + crate::git::update_repository(repo) + } + + fn ensure_default_branch(&self, repo: &Utf8Path) -> crate::error::Result<()> { + self.branch_repair_calls.fetch_add(1, Ordering::SeqCst); + crate::git::ensure_default_branch(repo) + } +} + #[test] -fn managed_clone_waiter_rechecks_state_and_updates_after_lock_release() { +fn pinned_and_unpinned_preparations_serialize_and_recheck_state() { let temp = TempDir::new().expect("create temporary workflow directory"); let root = Utf8PathBuf::try_from(temp.path().to_owned()).expect("temporary path is UTF-8"); let directory = Dir::open_ambient_dir(&root, ambient_authority()) @@ -96,57 +131,112 @@ fn managed_clone_waiter_rechecks_state_and_updates_after_lock_release() { let caller = root.join("caller"); let managed_clone = root.join("caller/data/whitaker"); git(&source, &["init", "-b", "main"]); - commit_file(&source, "first", "initial commit"); + let pinned_commit = commit_file(&source, "first", "initial commit"); + git(&source, &["tag", "v1"]); + let updated_commit = commit_file(&source, "second", "remote update"); - let lock = ManagedCloneLock::acquire(&managed_clone).expect("acquire managed-clone lock"); let dirs = ManagedCloneDirs { clone_dir: managed_clone.clone().into_std_path_buf(), }; - let (started_sender, started_receiver) = mpsc::channel(); - let (result_sender, result_receiver) = mpsc::channel(); - let waiter = thread::spawn(move || { - started_sender.send(()).expect("report waiter start"); - let preparation = WorkspacePreparation { - dirs: &dirs, - update: true, - git_ref: None, - }; - result_sender.send(ensure_workspace_from( - &caller, - &preparation, - &GitWorkspaceRepository, - )) + let (clone_started_sender, clone_started_receiver) = mpsc::channel(); + let (release_clone_sender, release_clone_receiver) = mpsc::channel(); + let repository = Arc::new(BlockingGitWorkspaceRepository { + source: source.clone(), + clone_started: clone_started_sender, + release_clone: Mutex::new(release_clone_receiver), + clone_calls: AtomicUsize::new(0), + update_calls: AtomicUsize::new(0), + branch_repair_calls: AtomicUsize::new(0), }); - started_receiver + + let pinned = { + let caller = caller.clone(); + let dirs = dirs.clone(); + let repository = Arc::clone(&repository); + thread::spawn(move || { + let preparation = WorkspacePreparation { + dirs: &dirs, + update: true, + git_ref: Some("v1"), + }; + ensure_workspace_from(&caller, &preparation, &*repository) + }) + }; + clone_started_receiver .recv_timeout(Duration::from_secs(1)) - .expect("waiter begins workspace preparation"); + .expect("pinned preparation enters clone after acquiring the lock"); + + let (unpinned_started_sender, unpinned_started_receiver) = mpsc::channel(); + let (unpinned_result_sender, unpinned_result_receiver) = mpsc::channel(); + let unpinned = { + let caller = caller.clone(); + let dirs = dirs.clone(); + let repository = Arc::clone(&repository); + thread::spawn(move || { + unpinned_started_sender + .send(()) + .expect("report unpinned preparation start"); + let preparation = WorkspacePreparation { + dirs: &dirs, + update: true, + git_ref: None, + }; + unpinned_result_sender.send(ensure_workspace_from(&caller, &preparation, &*repository)) + }) + }; + unpinned_started_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("unpinned preparation begins while the pinned clone holds the lock"); assert!( - result_receiver + unpinned_result_receiver .recv_timeout(Duration::from_millis(100)) .is_err(), - "workspace preparation must wait for the managed-clone lock" + "unpinned preparation must wait for the managed-clone lock" ); + assert_eq!(repository.clone_calls.load(Ordering::SeqCst), 1); + assert_eq!(repository.update_calls.load(Ordering::SeqCst), 0); + assert_eq!(repository.branch_repair_calls.load(Ordering::SeqCst), 0); - clone_repository(&source, &managed_clone); - let expected_commit = commit_file(&source, "second", "remote update"); - drop(lock); + release_clone_sender.send(()).expect("release pinned clone"); - let checkout = result_receiver + let pinned = pinned + .join() + .expect("pinned preparation thread should not panic") + .expect("pinned preparation should succeed"); + let unpinned_checkout = unpinned_result_receiver .recv_timeout(Duration::from_secs(5)) - .expect("waiter completes after lock release") - .expect("waiter workspace preparation succeeds"); - waiter + .expect("unpinned preparation completes after lock release") + .expect("unpinned preparation should succeed"); + unpinned .join() - .expect("waiter thread should not panic") - .expect("waiter reports its workspace result"); + .expect("unpinned preparation thread should not panic") + .expect("unpinned preparation result should be delivered"); assert_eq!( - checkout.action, + pinned.action, + WorkspaceAction::CloneTo(managed_clone.clone()) + ); + assert_eq!( + pinned.pinned_commit.as_ref().map(|commit| commit.as_str()), + Some(pinned_commit.as_str()) + ); + assert_eq!( + unpinned_checkout.action, WorkspaceAction::UpdateAt(managed_clone.clone()) ); + assert_eq!(unpinned_checkout.pinned_commit, None); + assert_eq!( + crate::git::resolve_commit(&managed_clone, "refs/whitaker/pinned-ref") + .expect("fetch stores pinned ref") + .as_str(), + pinned_commit + ); assert_eq!( git(&managed_clone, &["symbolic-ref", "HEAD"]), "refs/heads/main" ); - assert_eq!(git(&managed_clone, &["rev-parse", "HEAD"]), expected_commit); + assert_eq!(git(&managed_clone, &["rev-parse", "HEAD"]), updated_commit); + assert_eq!(repository.clone_calls.load(Ordering::SeqCst), 1); + assert_eq!(repository.update_calls.load(Ordering::SeqCst), 1); + assert_eq!(repository.branch_repair_calls.load(Ordering::SeqCst), 1); }