Skip to content

feat!: command-flow overhaul, persistent default, non-interactive installs + de-bloat - #40

Merged
Fahl-Design merged 37 commits into
mainfrom
feat/ponytail-audit-cuts
Jul 24, 2026
Merged

feat!: command-flow overhaul, persistent default, non-interactive installs + de-bloat#40
Fahl-Design merged 37 commits into
mainfrom
feat/ponytail-audit-cuts

Conversation

@Fahl-Design

Copy link
Copy Markdown
Member

What

36 commits in three blocks: a repo-wide de-bloat pass, a command-flow overhaul with six new commands, and review-driven fixes.

1. Ponytail cuts (refactor/chore)

  • Dropped 4 dependencies (futures-util, log, env_logger, chrono build-dep) and trimmed tokio from full to macros + rt-multi-thread.
  • Replaced hand-rolled code with stdlib/native equivalents: semver sort instead of a 54-line comparator, reqwest::Response::chunk() instead of StreamExt, date -u instead of chrono.
  • Deleted dead flexibility: PVM_SHELL_PID branch, always-None parameter, .exe checks, unused bindings, duplicated Bash/Zsh shell emission, five copies of minor-version extraction.

2. Command flows (feat)

  • pvm default <version> — persistent default; pvm env activates it in every new shell. pvm default system clears it.
  • pvm use system — switches the shell back to the system PHP (new Shell::deactivate strips pvm entries from PATH; bash/zsh/fish).
  • pvm ls interactive — Enter switches to the selected version and offers to save it to .php-version; Esc just exits. Piped output stays plain.
  • pvm install --packages cli,fpm -y — fully non-interactive installs; without a terminal the package selection defaults to cli. Already-installed versions short-circuit (idempotent for scripts) and the MultiSelect preselects missing packages.
  • pvm which [version], pvm exec <version> <cmd...>, pvm prune [-y], pvm cache clear — path inspection, one-off runs under a version, superseded-patch cleanup (re-points the default), cache invalidation.
  • pvm init — confirms before overwriting .php-version, offers installed versions first, degrades gracefully offline.
  • BREAKING: pvm ls-remote is now a plain listing (script-friendly); the interactive install picker lives in pvm install without arguments.

3. Review fixes (fix)

  • No surprise network installs: non-TTY pvm use <missing> without --yes fails with a hint instead of auto-installing.
  • Uninstalling the default version clears the stale default (prune already re-points); pvm env warns on stderr when the default is missing.
  • -y now covers the package MultiSelect; no false "Switched to PHP" without a terminal; exec propagates signal deaths as 128+n; .php-version containing system deactivates instead of prompting to "install PHP system".

Tests

  • Integration suite grew from 13 to 42 tests, all offline via new cache/version seeding helpers.
  • New e2e cases 15-18 (default, use system, non-interactive install, prune); uninstall moved to case 19.
  • Gates green throughout: cargo clippy -- -D warnings, cargo fmt --check, cargo test (24 unit + 42 integration).

Notes for review

  • Every logical change is its own conventional commit; the branch is meant to be rebase-merged, not squashed (the feat! commit carries the BREAKING CHANGE footer for semantic-release).
  • e2e suite not run locally (needs the Docker sandbox); relies on the e2e tests - linux job in CI.

Bash and Zsh emitted byte-identical path/set_env_var/wrapper_fn
strings from two copies. Extract posix_path, posix_set_env_var and
posix_wrapper_fn free functions; both impls now delegate. Only the
use_on_cd hooks differ per shell.
utils::sort_versions carried a 3-way semver/non-semver fallback
comparator, but its only caller sorts installed-version directory
names, which are always full semver. One sort_by_cached_key on
semver::Version::parse covers it; delete src/utils.rs.
list_installed_versions already returns the list semver-sorted;
the inline parts-based re-sort was a third comparator doing the
same work again.
futures-util existed only for StreamExt on bytes_stream().
reqwest::Response::chunk() does the same natively. One dependency
fewer.
Two dependencies served a single log::debug! call on a path where
the error is intentionally swallowed (best-effort update check).
Delete the call and both crates.
Nothing ever sets PVM_SHELL_PID: the shell wrappers pass per-session
uniqueness via PVM_ENV_UPDATE_PATH. The suffix branch could never
execute.
Every caller passed None; the PVM_ENV_UPDATE_PATH env var is the
real override mechanism.
Only tokio::main is used directly; reqwest enables the tokio
features it needs itself. 'full' pulled signal, net, io and
process drivers into a size-optimized binary for nothing.
It only forwarded to execute_install_with(v, true); callers now
call that directly.
get_versions_dir derives from the same root and create_dir_all
creates the full path.
…e -u

Only supported build hosts are Linux/macOS where date(1) exists;
build.rs already shells out to git. Extract cmd_stdout helper for
the three command-to-string call sites.
…sions

Integration tests can now exercise version resolution and install
logic without network access by pre-filling remote_cache-<target>.json
and fake versions/<v>/bin layouts.
pvm ls-remote now prints the remote versions (with installed markers
and package tags) instead of opening an install picker, so scripts
and non-TTY callers can consume it. The interactive picker moved to
'pvm install' without arguments, which already delegated here; it now
bails with a usage hint when stdin is not a terminal.

BREAKING CHANGE: 'pvm ls-remote' no longer prompts to install a
selected version. Use 'pvm install' without arguments for the
interactive picker.
…Y defaults

- pvm install gains --packages cli,fpm,micro (validated against the
  remote index) and -y/--yes; without a terminal the package selection
  defaults to cli instead of failing in dialoguer.
- pvm use gains -y/--yes for the install-missing and patch-update
  prompts; patch-update offers are now skipped entirely without a
  terminal so scripts never trigger surprise downloads.
- Patch updates reuse the package set of the version being replaced
  instead of re-prompting.
- New prompt::confirm helper centralizes Confirm handling (assume-yes,
  non-TTY returns the default); uninstall now uses it too.
Installing an already-present version now prints what is installed,
preselects only the missing packages in the interactive selection,
and becomes an idempotent no-op for non-interactive callers when cli
is already there. Explicit --packages still reinstalls (repair).
pvm default <version> stores the version in PVM_DIR/default; pvm env
activates it on shell startup, so a chosen version finally survives
opening a new terminal. pvm default without argument shows an
interactive picker (or prints the current default when non-TTY), and
pvm default system clears it. Also added to the interactive menu.
pvm use system writes a deactivation snippet to the env-update file:
it clears PVM_MULTISHELL_PATH and filters every PVM_DIR/versions
entry out of PATH (tr/grep/paste for bash and zsh, string match for
fish). Until now there was no way back to the system PHP without
opening a new shell.
… offer

pvm ls on a terminal now shows the installed versions as a picker:
Enter switches the shell to the selection, Esc just exits. After a
picker-driven switch (ls or pvm use without arguments) pvm offers to
save the choice to .php-version. Scripts and pipes still get the
plain list. The activation tail of pvm use moved into a shared
activate() used by both flows, and a missing version without a
terminal now fails with a usage hint instead of a dialoguer error.
pvm init no longer overwrites an existing .php-version silently: it
shows the current content and asks first. The selection list now
offers locally installed versions (marked) before the remote
major.minor lines, and falls back to installed-only with a warning
when the remote index is unreachable. Without a terminal init fails
with a hint instead of a dialoguer error.
pvm cache clear deletes the cached remote_cache-<target>.json files
so a freshly published upstream patch becomes visible before the 24h
cache expiry, without hand-deleting files in PVM_DIR.
pvm which resolves the active version (or an explicit argument like
8.3) and prints the full path of its php binary - a debugging aid for
"which php am I actually running".
pvm exec <version> <cmd...> prepends the version bin directory to
PATH for a single child process and propagates its exit code -
testing across versions without switching the shell.
pvm prune [-y] deletes every installed patch that is no longer the
newest of its minor line, keeping the currently active version. If
the persisted default version gets pruned, it is re-pointed to the
kept patch of the same minor.
New cases 15-18 exercise pvm default persistence + env activation,
pvm use system PATH stripping, install --packages/-y idempotency and
prune with default re-pointing. The uninstall case moves to slot 19
and removes LATEST, since prune already dropped PREVIOUS. Cases guard
the single-upstream-patch situation where LATEST == PREVIOUS.
Covers default/use system, which/exec/prune/cache clear, the
non-interactive install flags, the interactive ls picker and plain
output when piped, and replaces the stale fs4 reference with OS file
locks.
Previously untracked. Documents command-dispatch conventions incl.
the non-interactive requirements (prompt::confirm, IsTerminal
guards), the default-version and cache files in PVM_DIR, the shared
activate() tail, Shell::deactivate, and the offline test helpers.
Uninstalling the persisted default version now clears the default
file with a hint (prune already re-points it); pvm env warns on
stderr when the stored default is not installed instead of silently
falling back to system PHP. Tests cover both paths plus the
non-TTY prune default.
Non-TTY 'pvm use <missing>' without --yes now fails with a hint
instead of auto-confirming a network install (both the argument and
.php-version paths), matching the existing patch-update gate. A
.php-version containing 'system' deactivates instead of offering to
install 'PHP system', --silent suppresses the deactivation message
for cd-hooks, and Esc in the version picker exits quietly as the ls
prompt promises.
-y now also skips the package MultiSelect (defaults to cli, like a
missing terminal); without a terminal and without -y the trailing
'use now?' question no longer auto-answers yes, which printed a
'Switched to PHP' message although no shell evaluates the env file.
Signal-killed children previously collapsed to exit code 1; now they
follow the shell convention so callers can distinguish SIGTERM from
a plain failure.
Both the picker and explicit-argument flows carried the same
confirm-write-report block; save_question() now picks the wording
and one shared block does the writing.
init.rs and prune.rs carried identical private helpers; fs.rs,
update.rs and the install picker had three more inline split
variants of the same logic.
Windows is an unsupported target (get_target_triple bails before any
install), so the .exe fallbacks could never match.
Every arm bound the command to a temporary before calling it; call
directly on the struct literal instead.
Claude Code drops runtime files (locks, worktrees) there; they are
machine-local and must not land in commits.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 228c9e3b-53bb-4ebe-947b-240d3dcca1fd

📥 Commits

Reviewing files that changed from the base of the PR and between 43e0d47 and 17994e2.

📒 Files selected for processing (4)
  • src/commands/install.rs
  • src/commands/prune.rs
  • src/commands/use_cmd.rs
  • tests/cli.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/commands/prune.rs
  • src/commands/install.rs
  • src/commands/use_cmd.rs
  • tests/cli.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added exec, which, default, prune, and cache clear commands.
    • Added persistent PHP defaults, system switching, version-specific command execution, and binary path lookup.
    • Improved non-interactive installs with package selection and automatic confirmation options.
    • Added interactive version switching and safer .php-version setup.
  • Documentation
    • Expanded README command examples and feature descriptions.
  • Bug Fixes
    • Improved shell environment cleanup, installation idempotency, cache handling, and default-version maintenance.
  • Tests
    • Added comprehensive CLI and end-to-end coverage for the new workflows.

Walkthrough

This PR expands pvm with persistent default-version management, command execution under specific PHP versions, cache clearing, automated patch pruning, improved non-interactive installation and activation workflows, interactive shell-based version selection, unified shell deactivation generation, and comprehensive test coverage.

Changes

CLI and shell workflows

Layer / File(s) Summary
Build configuration and CLI dispatch
Cargo.toml, src/main.rs, src/cli.rs, src/commands/mod.rs
Removes logging dependencies, registers new commands (exec, default, prune, cache, which), and updates module declarations.
Confirmation, version persistence, and FS helpers
src/prompt.rs, src/constants.rs, src/fs.rs
Adds centralized confirmation prompt; persists default PHP version to ${PVM_DIR}/default file; introduces minor_of() version parser and updates installed-version sorting to use semver comparisons.
Default version command and env activation
src/commands/default_cmd.rs, src/commands/env.rs
New pvm default command reads/sets/clears persisted version; pvm env activates the default version for new shells via PATH prepending.
Execution, cache, and binary resolution
src/commands/exec_cmd.rs, src/commands/cache.rs, src/commands/which_cmd.rs
New exec runs commands under a specific PHP version; cache clear removes stale remote index files; which resolves PHP binary paths.
Patch pruning and default repointing
src/commands/prune.rs
New pvm prune removes superseded patches while keeping the latest per major.minor, updates defaults when pruned versions are removed, and requires --yes or interactive confirmation in non-TTY contexts.
Install with packages and non-interactive flows
src/commands/install.rs
Adds --packages and -y/--yes flags; centralizes package selection; performs idempotent checks; introduces select_packages and pick_and_install helpers; gates activation based on cli package selection and terminal/--yes state.
Use command with shared activation
src/commands/use_cmd.rs
Refactors into shared helpers (activate, pick_installed_version, save_question, switch_to_system, ActivateOpts); adds "system" routing; enforces non-interactive safeguards when missing versions require install; reads and auto-switches via .php-version file.
Init and uninstall flows
src/commands/init.rs, src/commands/uninstall.rs
Init adds terminal check, confirm-before-overwrite, combined installed/remote lists, and uses shared prompt::confirm; uninstall detects and clears stale defaults.
Interactive version selection and ls updates
src/commands/ls.rs, src/commands/ls_remote.rs
Ls adds TTY check and interactive version picker; ls_remote simplifies to plain output with installed markers instead of interactive selection.
Shell deactivation and environment generation
src/shell.rs
Trait deactivate now accepts versions_dir; introduces shared POSIX helpers for PATH/env generation; Bash, Zsh, and Fish delegate to shared helpers for version-dir-specific PATH filtering; adds unit tests.
Root menu expansion
src/interactive.rs
Adds Prune and Default menu options; updates command dispatch and field construction.
Network streaming, build metadata, and version updates
src/network.rs, build.rs, src/update.rs
Removes futures_util; uses reqwest chunk-based iteration; replaces chrono::Utc::now() with date -u command; refactors version parsing to use minor_of helper.
Documentation and repository guidance
CLAUDE.md, README.md, .gitignore
Adds repo-specific conventions, architecture overview, testing practices, and expanded user command examples; ignores .claude/ directory.
CLI integration and E2E test suites
tests/cli.rs, tests/e2e/cases/*
Expands CLI tests with offline cache seeding, fake installations, non-TTY failure paths, and default/exec/cache/which/prune coverage; adds E2E tests for default persistence, system switching, packages, pruning, and uninstall.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Poem

I hop through defaults with nary a care,
Pruning old patches from everywhere.
exec runs commands so swift and precise,
Cache cleared, shells unified—oh so nice!
Tests stamp their paws: each E2E true.
New features arranged—all shiny and new! 🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change set: command-flow overhaul, persistent defaults, non-interactive installs, and cleanup.
Description check ✅ Passed The description matches the changes in the diff, covering new commands, flow changes, dependency cleanup, and tests.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ponytail-audit-cuts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Fahl-Design Fahl-Design self-assigned this Jul 24, 2026
@Fahl-Design Fahl-Design added the enhancement New feature or request label Jul 24, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (7)
src/commands/default_cmd.rs (1)

42-58: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Pre-select the current default in the interactive picker.

current_default is computed at line 42 and used to label the matching entry (default), but .default(0) at line 56 always highlights the first item instead of the current default's index. Minor UX inconsistency for an otherwise well-guarded interactive flow.

💡 Proposed fix
                 let current_default = fs::get_default_version()?.unwrap_or_default();
+                let default_idx = items
+                    .iter()
+                    .position(|i| i.version == current_default)
+                    .unwrap_or(0);
                 let displays: Vec<String> = items
                     .iter()
                     .map(|i| {
                         if i.version == current_default {
                             format!("{} {}", i.display, "(default)".cyan())
                         } else {
                             i.display.clone()
                         }
                     })
                     .collect();

                 let selection = Select::with_theme(&ColorfulTheme::default())
                     .with_prompt("Select the default PHP version for new shells")
-                    .default(0)
+                    .default(default_idx)
                     .items(&displays)
                     .interact_opt()?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/default_cmd.rs` around lines 42 - 58, Update the interactive
picker around current_default and Select::with_theme so the default selection
index is the position of the item whose version matches current_default, rather
than always 0. Preserve the existing "(default)" display labeling and use the
computed matching index when configuring .default(...).
src/commands/install.rs (2)

63-71: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Idempotent short-circuit ignores --yes, so pvm install <v> -y on a TTY re-downloads an already-installed cli.

select_packages treats assume_yes exactly like a missing terminal (line 202), but the early return here only covers the non-TTY case. Gating both on the same condition keeps -y idempotent.

♻️ Proposed change
         // Idempotent no-op for scripts: nothing was explicitly requested and
         // the default cli package is already present.
         if packages.is_empty()
-            && !std::io::stdin().is_terminal()
+            && (assume_yes || !std::io::stdin().is_terminal())
             && already_installed.iter().any(|p| p == "cli")
         {
             return Ok(Some(resolved_version));
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/install.rs` around lines 63 - 71, Update the early-return
condition in the install selection flow around select_packages to treat
assume_yes the same as a non-terminal stdin. Preserve the existing empty-package
and already-installed cli checks, so `pvm install <v> -y` returns the resolved
version without re-downloading.

234-238: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use interact_opt() for the package picker.

dialoguer 0.12 supports MultiSelect::interact_opt(), and returning None is already handled as an operation cancellation; using interact() prevents Esc from producing that same quiet cancel path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/install.rs` around lines 234 - 238, Update the package picker’s
MultiSelect interaction to call interact_opt() instead of interact(), preserving
the existing selections.is_empty() cancellation handling and allowing Esc to
return None quietly.
src/commands/use_cmd.rs (1)

75-117: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated "missing version → gate → confirm → install" flow; consider extracting a helper.

Lines 34-65 and this block differ only in the message wording and the source of the version string. A small async fn install_missing(version: &str, source: Option<&str>, silent: bool, yes: bool) -> Result<Option<String>> would remove the duplication and flatten the 6-level nesting here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/use_cmd.rs` around lines 75 - 117, Extract the duplicated
missing-version handling into a small async helper, such as install_missing,
covering the silent/noninteractive gate, confirmation prompt, installation,
cancellation, and optional source-specific messaging. Replace the nested flow in
the current command handler and the corresponding flow around the earlier
missing-version block with this helper, preserving their differing version
sources and message wording while continuing to set resolved_version from a
successful installation.
Cargo.toml (1)

28-28: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Align the tokio features with the repository guideline.

The crate only needs ["macros", "rt-multi-thread"] for #[tokio::main], so dropping full does not break compilation. Since the guideline still requires features = ["full"], update this one dependency line to that convention unless the project intentionally diverges.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Cargo.toml` at line 28, Update the tokio dependency declaration in Cargo.toml
to use features = ["full"], replacing the current ["macros", "rt-multi-thread"]
feature list and preserving the existing version.

Source: Coding guidelines

src/fs.rs (1)

98-107: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Distinguish "not set" from real I/O errors in get_default_version.

Err(_) => Ok(None) treats every read failure (permission denied, disk error, etc.) the same as "no default configured." Every caller (env.rs, uninstall.rs, prune.rs, default_cmd.rs) will silently behave as if no default exists, masking real problems. cache.rs's clear() in this same PR already distinguishes ErrorKind::NotFound from other I/O errors — worth applying the same pattern here for consistency.

♻️ Proposed fix
 pub fn get_default_version() -> Result<Option<String>> {
     let path = get_pvm_dir()?.join(crate::constants::DEFAULT_VERSION_FILE);
     match std::fs::read_to_string(path) {
         Ok(content) => {
             let trimmed = content.trim().to_string();
             Ok((!trimmed.is_empty()).then_some(trimmed))
         }
-        Err(_) => Ok(None),
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
+        Err(e) => Err(e).context("Failed to read default version file"),
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/fs.rs` around lines 98 - 107, Update get_default_version to return
Ok(None) only when reading DEFAULT_VERSION_FILE fails with ErrorKind::NotFound;
propagate all other I/O errors through the function’s existing Result error
path, matching the error distinction used by cache.rs::clear().
src/commands/cache.rs (1)

27-65: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Cache-clear deletion bypasses the remote-cache locking protocol.

network.rs's cache read/write paths use lock_shared/lock around the cache file, but clear() unlinks matching files without acquiring any lock first. A concurrent writer holding file.lock() on the cache file could have its write silently lost once the file is unlinked mid-operation. As per coding guidelines, "Use file locking (std::fs::File::lock / lock_shared / unlock) when writing to env update files or the remote cache; follow the fs::write_env_file_locked pattern."

♻️ Proposed fix
-                if name.starts_with("remote_cache")
-                    && name.ends_with(".json")
-                    && entry.file_type().map(|ft| ft.is_file()).unwrap_or(false)
-                {
-                    std::fs::remove_file(entry.path())
-                        .with_context(|| format!("Failed to remove cache file '{}'", name))?;
-                    removed += 1;
-                }
+                if name.starts_with("remote_cache")
+                    && name.ends_with(".json")
+                    && entry.file_type().map(|ft| ft.is_file()).unwrap_or(false)
+                {
+                    if let Ok(file) = std::fs::OpenOptions::new().write(true).open(entry.path()) {
+                        let _ = file.lock();
+                        std::fs::remove_file(entry.path())
+                            .with_context(|| format!("Failed to remove cache file '{}'", name))?;
+                        let _ = file.unlock();
+                    }
+                    removed += 1;
+                }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/cache.rs` around lines 27 - 65, Update clear() to acquire the
same exclusive file lock used by the remote-cache write path before removing
each matching cache file. Open the cache file, call the established lock API,
remove it only after the lock is held, and unlock/close it appropriately,
preserving the existing filtering, error context, and removal count behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/commands/install.rs`:
- Around line 334-342: Update the install flow around the installed check and
execute_install_with so an explicitly supplied packages selection is still
applied when the chosen version is already installed. Do not short-circuit to
the “already installed” message when packages need installation; invoke
execute_install_with with the selected version and existing package/assume-yes
arguments, while preserving the message for installed versions with no requested
package work.

In `@src/commands/prune.rs`:
- Around line 50-54: Update the confirmation flow in the prune command around
prompt::confirm so non-interactive stdin cannot proceed with the default
affirmative response. Require self.yes to be explicitly set before allowing
removal when stdin is not a terminal, while preserving the existing interactive
confirmation behavior and cancellation path.

In `@src/commands/use_cmd.rs`:
- Around line 273-290: Update the save prompt condition in the `use` command
flow around `save_question` so it is not evaluated when `opts.quiet` is true.
Preserve the existing prompt and `.php-version` write behavior for non-quiet
invocations, while ensuring silent shell-hook activation never prompts.

---

Nitpick comments:
In `@Cargo.toml`:
- Line 28: Update the tokio dependency declaration in Cargo.toml to use features
= ["full"], replacing the current ["macros", "rt-multi-thread"] feature list and
preserving the existing version.

In `@src/commands/cache.rs`:
- Around line 27-65: Update clear() to acquire the same exclusive file lock used
by the remote-cache write path before removing each matching cache file. Open
the cache file, call the established lock API, remove it only after the lock is
held, and unlock/close it appropriately, preserving the existing filtering,
error context, and removal count behavior.

In `@src/commands/default_cmd.rs`:
- Around line 42-58: Update the interactive picker around current_default and
Select::with_theme so the default selection index is the position of the item
whose version matches current_default, rather than always 0. Preserve the
existing "(default)" display labeling and use the computed matching index when
configuring .default(...).

In `@src/commands/install.rs`:
- Around line 63-71: Update the early-return condition in the install selection
flow around select_packages to treat assume_yes the same as a non-terminal
stdin. Preserve the existing empty-package and already-installed cli checks, so
`pvm install <v> -y` returns the resolved version without re-downloading.
- Around line 234-238: Update the package picker’s MultiSelect interaction to
call interact_opt() instead of interact(), preserving the existing
selections.is_empty() cancellation handling and allowing Esc to return None
quietly.

In `@src/commands/use_cmd.rs`:
- Around line 75-117: Extract the duplicated missing-version handling into a
small async helper, such as install_missing, covering the silent/noninteractive
gate, confirmation prompt, installation, cancellation, and optional
source-specific messaging. Replace the nested flow in the current command
handler and the corresponding flow around the earlier missing-version block with
this helper, preserving their differing version sources and message wording
while continuing to set resolved_version from a successful installation.

In `@src/fs.rs`:
- Around line 98-107: Update get_default_version to return Ok(None) only when
reading DEFAULT_VERSION_FILE fails with ErrorKind::NotFound; propagate all other
I/O errors through the function’s existing Result error path, matching the error
distinction used by cache.rs::clear().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d14a4876-e28a-40d4-995a-fc0b068b23d8

📥 Commits

Reviewing files that changed from the base of the PR and between d00fbfe and 43e0d47.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (35)
  • .gitignore
  • CLAUDE.md
  • Cargo.toml
  • README.md
  • build.rs
  • src/cli.rs
  • src/commands/cache.rs
  • src/commands/default_cmd.rs
  • src/commands/env.rs
  • src/commands/exec_cmd.rs
  • src/commands/init.rs
  • src/commands/install.rs
  • src/commands/ls.rs
  • src/commands/ls_remote.rs
  • src/commands/mod.rs
  • src/commands/prune.rs
  • src/commands/uninstall.rs
  • src/commands/use_cmd.rs
  • src/commands/which_cmd.rs
  • src/constants.rs
  • src/fs.rs
  • src/interactive.rs
  • src/main.rs
  • src/network.rs
  • src/prompt.rs
  • src/shell.rs
  • src/update.rs
  • src/utils.rs
  • tests/cli.rs
  • tests/e2e/cases/15_default.sh
  • tests/e2e/cases/15_uninstall.sh
  • tests/e2e/cases/16_use_system.sh
  • tests/e2e/cases/17_install_packages.sh
  • tests/e2e/cases/18_prune.sh
  • tests/e2e/cases/19_uninstall.sh
💤 Files with no reviewable changes (2)
  • src/utils.rs
  • tests/e2e/cases/15_uninstall.sh

Comment thread src/commands/install.rs Outdated
Comment thread src/commands/prune.rs
Comment thread src/commands/use_cmd.rs
… cd-hook

- pvm prune without a terminal now requires --yes before mass
  deletion, matching the non-TTY guards on use and install.
- Explicit --packages survives the already-installed short-circuit in
  the install picker, so packages can be added to an existing version.
- The silent cd-hook never evaluates the .php-version save prompt; a
  file holding a partial version like 8.3 would otherwise prompt on
  every cd.
@Fahl-Design

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Fahl-Design
Fahl-Design merged commit 46ca57d into main Jul 24, 2026
5 checks passed
@Fahl-Design
Fahl-Design deleted the feat/ponytail-audit-cuts branch July 24, 2026 20:36
webproject-bot Bot pushed a commit that referenced this pull request Jul 24, 2026
## [2.0.0](v1.3.2...v2.0.0) (2026-07-24)

### ⚠ BREAKING CHANGES

* 'pvm ls-remote' no longer prompts to install a
selected version. Use 'pvm install' without arguments for the
interactive picker.

* feat(install): non-interactive installs via --packages, -y and non-TTY defaults

- pvm install gains --packages cli,fpm,micro (validated against the
  remote index) and -y/--yes; without a terminal the package selection
  defaults to cli instead of failing in dialoguer.
- pvm use gains -y/--yes for the install-missing and patch-update
  prompts; patch-update offers are now skipped entirely without a
  terminal so scripts never trigger surprise downloads.
- Patch updates reuse the package set of the version being replaced
  instead of re-prompting.
- New prompt::confirm helper centralizes Confirm handling (assume-yes,
  non-TTY returns the default); uninstall now uses it too.

* feat(install): detect installed packages and preselect missing ones

Installing an already-present version now prints what is installed,
preselects only the missing packages in the interactive selection,
and becomes an idempotent no-op for non-interactive callers when cli
is already there. Explicit --packages still reinstalls (repair).

* feat(default): persistent default version for new shells

pvm default <version> stores the version in PVM_DIR/default; pvm env
activates it on shell startup, so a chosen version finally survives
opening a new terminal. pvm default without argument shows an
interactive picker (or prints the current default when non-TTY), and
pvm default system clears it. Also added to the interactive menu.

* feat(use): support switching back to system PHP

pvm use system writes a deactivation snippet to the env-update file:
it clears PVM_MULTISHELL_PATH and filters every PVM_DIR/versions
entry out of PATH (tr/grep/paste for bash and zsh, string match for
fish). Until now there was no way back to the system PHP without
opening a new shell.

* feat(ls): interactive version switch from list with .php-version save offer

pvm ls on a terminal now shows the installed versions as a picker:
Enter switches the shell to the selection, Esc just exits. After a
picker-driven switch (ls or pvm use without arguments) pvm offers to
save the choice to .php-version. Scripts and pipes still get the
plain list. The activation tail of pvm use moved into a shared
activate() used by both flows, and a missing version without a
terminal now fails with a usage hint instead of a dialoguer error.

* feat(init): confirm .php-version overwrite and prefer installed versions

pvm init no longer overwrites an existing .php-version silently: it
shows the current content and asks first. The selection list now
offers locally installed versions (marked) before the remote
major.minor lines, and falls back to installed-only with a warning
when the remote index is unreachable. Without a terminal init fails
with a hint instead of a dialoguer error.

* feat(cache): add cache clear command for the remote version index

pvm cache clear deletes the cached remote_cache-<target>.json files
so a freshly published upstream patch becomes visible before the 24h
cache expiry, without hand-deleting files in PVM_DIR.

* feat(which): print the path of the active or given PHP binary

pvm which resolves the active version (or an explicit argument like
8.3) and prints the full path of its php binary - a debugging aid for
"which php am I actually running".

* feat(exec): run a command under a specific PHP version

pvm exec <version> <cmd...> prepends the version bin directory to
PATH for a single child process and propagates its exit code -
testing across versions without switching the shell.

* feat(prune): remove superseded patch versions

pvm prune [-y] deletes every installed patch that is no longer the
newest of its minor line, keeping the currently active version. If
the persisted default version gets pruned, it is re-pointed to the
kept patch of the same minor.

* test(e2e): cover default, use system, non-interactive install and prune

New cases 15-18 exercise pvm default persistence + env activation,
pvm use system PATH stripping, install --packages/-y idempotency and
prune with default re-pointing. The uninstall case moves to slot 19
and removes LATEST, since prune already dropped PREVIOUS. Cases guard
the single-upstream-patch situation where LATEST == PREVIOUS.

* docs(readme): document new commands and flows

Covers default/use system, which/exec/prune/cache clear, the
non-interactive install flags, the interactive ls picker and plain
output when piped, and replaces the stale fs4 reference with OS file
locks.

* docs(claude): track CLAUDE.md with current architecture

Previously untracked. Documents command-dispatch conventions incl.
the non-interactive requirements (prompt::confirm, IsTerminal
guards), the default-version and cache files in PVM_DIR, the shared
activate() tail, Shell::deactivate, and the offline test helpers.

* fix(default): keep default lifecycle consistent on uninstall

Uninstalling the persisted default version now clears the default
file with a hint (prune already re-points it); pvm env warns on
stderr when the stored default is not installed instead of silently
falling back to system PHP. Tests cover both paths plus the
non-TTY prune default.

* fix(use): no surprise installs without a terminal

Non-TTY 'pvm use <missing>' without --yes now fails with a hint
instead of auto-confirming a network install (both the argument and
.php-version paths), matching the existing patch-update gate. A
.php-version containing 'system' deactivates instead of offering to
install 'PHP system', --silent suppresses the deactivation message
for cd-hooks, and Esc in the version picker exits quietly as the ls
prompt promises.

* fix(install): make -y fully non-interactive and honest without a TTY

-y now also skips the package MultiSelect (defaults to cli, like a
missing terminal); without a terminal and without -y the trailing
'use now?' question no longer auto-answers yes, which printed a
'Switched to PHP' message although no shell evaluates the env file.

* fix(exec): propagate signal terminations as 128 plus signal number

Signal-killed children previously collapsed to exit code 1; now they
follow the shell convention so callers can distinguish SIGTERM from
a plain failure.

* refactor(use): deduplicate the .php-version save blocks in activate

Both the picker and explicit-argument flows carried the same
confirm-write-report block; save_question() now picks the wording
and one shared block does the writing.

* refactor(fs): consolidate minor-version extraction into fs::minor_of

init.rs and prune.rs carried identical private helpers; fs.rs,
update.rs and the install picker had three more inline split
variants of the same logic.

* refactor(fs): drop dead .exe checks in get_installed_packages

Windows is an unsupported target (get_target_triple bails before any
install), so the .exe fallbacks could never match.

* refactor(interactive): construct and call commands inline in menu arms

Every arm bound the command to a temporary before calling it; call
directly on the struct literal instead.

* chore(gitignore): ignore local .claude directory

Claude Code drops runtime files (locks, worktrees) there; they are
machine-local and must not land in commits.

* fix(review): address CodeRabbit findings on prune, install picker and cd-hook

- pvm prune without a terminal now requires --yes before mass
  deletion, matching the non-TTY guards on use and install.
- Explicit --packages survives the already-installed short-circuit in
  the install picker, so packages can be added to an existing version.
- The silent cd-hook never evaluates the .php-version save prompt; a
  file holding a partial version like 8.3 would otherwise prompt on
  every cd.

### Features

* command-flow overhaul, persistent default, non-interactive installs + de-bloat ([#40](#40)) ([46ca57d](46ca57d))
@webproject-bot

Copy link
Copy Markdown

🎉 This PR is included in version 2.0.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Labels

enhancement New feature or request released

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant