Skip to content

Commit e1c0f2a

Browse files
committed
feat(ux): detect install source for actionable upgrade hints
Detects how prek was installed (Homebrew or Cargo) by inspecting the executable path, then provides actionable upgrade hints when: - `prek self update` fails (not installed via standalone scripts) - `minimum_prek_version` isn't satisfied Detection heuristics: - Homebrew: path contains `/Cellar/prek/` - Cargo: path contains `/.cargo/bin/` Based on astral-sh/uv#16838.
1 parent f8df4e7 commit e1c0f2a

5 files changed

Lines changed: 192 additions & 35 deletions

File tree

crates/prek/src/cli/self_update.rs

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,26 @@ use owo_colors::OwoColorize;
2929
use tracing::{debug, enabled};
3030

3131
use crate::cli::ExitStatus;
32+
use crate::install_source::InstallSource;
3233
use crate::printer::Printer;
3334

35+
fn format_install_hint() -> String {
36+
match InstallSource::detect() {
37+
Some(s) => format!(
38+
"{}{} You installed prek via {}. To update, run `{}`",
39+
"hint".cyan().bold(),
40+
":".bold(),
41+
s.description(),
42+
s.update_instructions()
43+
),
44+
None => format!(
45+
"{}{} Please use your package manager to update prek.",
46+
"hint".cyan().bold(),
47+
":".bold()
48+
),
49+
}
50+
}
51+
3452
/// Attempt to update the prek binary.
3553
pub(crate) async fn self_update(
3654
version: Option<String>,
@@ -55,18 +73,11 @@ pub(crate) async fn self_update(
5573
debug!("no receipt found; assuming prek was installed via a package manager");
5674
writeln!(
5775
printer.stderr(),
58-
"{}",
59-
format_args!(
60-
concat!(
61-
"{}{} Self-update is only available for prek binaries installed via the standalone installation scripts.",
62-
"\n",
63-
"\n",
64-
"If you installed prek with pip, brew, or another package manager, update prek with `pip install --upgrade`, `brew upgrade`, or similar."
65-
),
66-
"warning".yellow().bold(),
67-
":".bold()
68-
)
76+
"{}{} prek was installed via an external package manager and cannot self-update.",
77+
"error".red().bold(),
78+
":".bold(),
6979
)?;
80+
writeln!(printer.stderr(), "{}", format_install_hint())?;
7081
return Ok(ExitStatus::Error);
7182
};
7283

@@ -79,18 +90,11 @@ pub(crate) async fn self_update(
7990
);
8091
writeln!(
8192
printer.stderr(),
82-
"{}",
83-
format_args!(
84-
concat!(
85-
"{}{} Self-update is only available for prek binaries installed via the standalone installation scripts.",
86-
"\n",
87-
"\n",
88-
"If you installed prek with pip, brew, or another package manager, update prek with `pip install --upgrade`, `brew upgrade`, or similar."
89-
),
90-
"warning".yellow().bold(),
91-
":".bold()
92-
)
93+
"{}{} prek was installed via an external package manager and cannot self-update.",
94+
"error".red().bold(),
95+
":".bold(),
9396
)?;
97+
writeln!(printer.stderr(), "{}", format_install_hint())?;
9498
return Ok(ExitStatus::Error);
9599
}
96100

crates/prek/src/config.rs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,8 +1087,14 @@ where
10871087
.parse::<semver::Version>()
10881088
.expect("Invalid prek version");
10891089
if version > cur_version {
1090+
use crate::install_source::InstallSource;
1091+
1092+
let hint = InstallSource::detect()
1093+
.map(|s| format!(" To update, run `{}`.", s.update_instructions()))
1094+
.unwrap_or_default();
1095+
10901096
return Err(serde::de::Error::custom(format!(
1091-
"Required minimum prek version `{version}` is greater than current version `{cur_version}`. Please consider updating prek.",
1097+
"Required minimum prek version `{version}` is greater than current version `{cur_version}`.{hint}",
10921098
)));
10931099
}
10941100

@@ -1613,14 +1619,14 @@ mod tests {
16131619
"};
16141620
let err = serde_saphyr::from_str::<Config>(yaml).unwrap_err();
16151621
insta::with_settings!({ filters => vec![VERSION_FILTER] }, {
1616-
insta::assert_snapshot!(err, @"
1617-
error: line 8 column 23: Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`. Please consider updating prek. at line 8, column 23
1622+
insta::assert_snapshot!(err, @r"
1623+
error: line 8 column 23: Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`. at line 8, column 23
16181624
--> <input>:8:23
16191625
|
16201626
6 | entry: echo test
16211627
7 | language: system
16221628
8 | minimum_prek_version: '10.0.0'
1623-
| ^ Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`. Please consider updating prek. at line 8, column 23
1629+
| ^ Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`. at line 8, column 23
16241630
");
16251631
});
16261632

@@ -1634,12 +1640,12 @@ mod tests {
16341640
"};
16351641
let err = serde_saphyr::from_str::<Manifest>(yaml).unwrap_err();
16361642
insta::with_settings!({ filters => vec![VERSION_FILTER] }, {
1637-
insta::assert_snapshot!(err, @"
1638-
error: line 1 column 3: Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`. Please consider updating prek. at line 1, column 3
1643+
insta::assert_snapshot!(err, @r"
1644+
error: line 1 column 3: Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`. at line 1, column 3
16391645
--> <input>:1:3
16401646
|
16411647
1 | - id: test-hook
1642-
| ^ Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`. Please consider updating prek. at line 1, column 3
1648+
| ^ Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`. at line 1, column 3
16431649
2 | name: Test Hook
16441650
3 | entry: echo test
16451651
|

crates/prek/src/install_source.rs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
use std::{
2+
ffi::OsStr,
3+
path::{Path, PathBuf},
4+
};
5+
6+
/// Represents how prek was installed on the system.
7+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8+
pub(crate) enum InstallSource {
9+
Homebrew,
10+
Cargo,
11+
}
12+
13+
impl InstallSource {
14+
/// Detect the install source from a given path.
15+
fn from_path(path: &Path) -> Option<Self> {
16+
let canonical = path.canonicalize().unwrap_or_else(|_| PathBuf::from(path));
17+
let components: Vec<_> = canonical
18+
.components()
19+
.map(|c| c.as_os_str().to_owned())
20+
.collect();
21+
22+
// Check for Homebrew Cellar installation: .../Cellar/prek/...
23+
let cellar = OsStr::new("Cellar");
24+
let prek = OsStr::new("prek");
25+
if components
26+
.windows(2)
27+
.any(|w| w[0] == cellar && w[1] == prek)
28+
{
29+
return Some(Self::Homebrew);
30+
}
31+
32+
// Check for cargo bin installation: .../.cargo/bin/...
33+
let cargo = OsStr::new(".cargo");
34+
let bin = OsStr::new("bin");
35+
if components.windows(2).any(|w| w[0] == cargo && w[1] == bin) {
36+
return Some(Self::Cargo);
37+
}
38+
39+
None
40+
}
41+
42+
/// Detect the install source from the current executable path.
43+
pub(crate) fn detect() -> Option<Self> {
44+
Self::from_path(&std::env::current_exe().ok()?)
45+
}
46+
47+
/// Returns a human-readable description of the install source.
48+
pub(crate) fn description(self) -> &'static str {
49+
match self {
50+
Self::Homebrew => "Homebrew",
51+
Self::Cargo => "cargo",
52+
}
53+
}
54+
55+
/// Returns the command to update prek for this install source.
56+
pub(crate) fn update_instructions(self) -> &'static str {
57+
match self {
58+
Self::Homebrew => "brew update && brew upgrade prek",
59+
Self::Cargo => "cargo install prek",
60+
}
61+
}
62+
}
63+
64+
#[cfg(test)]
65+
mod tests {
66+
use super::*;
67+
68+
#[test]
69+
fn detects_homebrew_cellar_arm() {
70+
assert_eq!(
71+
InstallSource::from_path(Path::new("/opt/homebrew/Cellar/prek/0.3.1/bin/prek")),
72+
Some(InstallSource::Homebrew)
73+
);
74+
}
75+
76+
#[test]
77+
fn detects_homebrew_cellar_intel() {
78+
assert_eq!(
79+
InstallSource::from_path(Path::new("/usr/local/Cellar/prek/0.3.1/bin/prek")),
80+
Some(InstallSource::Homebrew)
81+
);
82+
}
83+
84+
#[test]
85+
fn detects_cargo_bin_macos() {
86+
assert_eq!(
87+
InstallSource::from_path(Path::new("/Users/user/.cargo/bin/prek")),
88+
Some(InstallSource::Cargo)
89+
);
90+
}
91+
92+
#[test]
93+
fn detects_cargo_bin_linux() {
94+
assert_eq!(
95+
InstallSource::from_path(Path::new("/home/user/.cargo/bin/prek")),
96+
Some(InstallSource::Cargo)
97+
);
98+
}
99+
100+
#[test]
101+
fn returns_none_for_unknown_unix_path() {
102+
assert_eq!(
103+
InstallSource::from_path(Path::new("/usr/local/bin/prek")),
104+
None
105+
);
106+
}
107+
108+
#[test]
109+
fn does_not_match_other_cellar_formula() {
110+
assert_eq!(
111+
InstallSource::from_path(Path::new("/opt/homebrew/Cellar/other/0.1.0/bin/prek")),
112+
None
113+
);
114+
}
115+
116+
#[test]
117+
#[cfg(windows)]
118+
fn detects_cargo_bin_windows() {
119+
assert_eq!(
120+
InstallSource::from_path(Path::new(r"C:\Users\user\.cargo\bin\prek.exe")),
121+
Some(InstallSource::Cargo)
122+
);
123+
}
124+
125+
#[test]
126+
#[cfg(windows)]
127+
fn returns_none_for_unknown_windows_path() {
128+
assert_eq!(
129+
InstallSource::from_path(Path::new(r"C:\Program Files\prek\prek.exe")),
130+
None
131+
);
132+
}
133+
}

crates/prek/src/main.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ mod git;
3535
mod hook;
3636
mod hooks;
3737
mod identify;
38+
mod install_source;
3839
mod languages;
3940
mod printer;
4041
mod process;
@@ -381,10 +382,23 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
381382
}) => cli::self_update(target_version, token, printer).await,
382383
#[cfg(not(feature = "self-update"))]
383384
Command::Self_(_) => {
384-
anyhow::bail!(
385-
"prek was installed through an external package manager, and self-update \
386-
is not available. Please use your package manager to update prek."
387-
);
385+
use crate::install_source::InstallSource;
386+
387+
let msg = InstallSource::detect()
388+
.map(|s| {
389+
format!(
390+
"prek was installed via {} and cannot self-update. To update, run `{}`",
391+
s.description(),
392+
s.update_instructions()
393+
)
394+
})
395+
.unwrap_or_else(|| {
396+
"prek was installed via an external package manager and cannot self-update. \
397+
Please use your package manager to update prek."
398+
.into()
399+
});
400+
401+
anyhow::bail!("{msg}");
388402
}
389403

390404
Command::GenerateShellCompletion(args) => {

crates/prek/tests/run.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1931,11 +1931,11 @@ fn minimum_prek_version() {
19311931
19321932
----- stderr -----
19331933
error: Failed to parse `.pre-commit-config.yaml`
1934-
caused by: error: line 1 column 23: Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`. Please consider updating prek. at line 1, column 23
1934+
caused by: error: line 1 column 23: Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`. at line 1, column 23
19351935
--> <input>:1:23
19361936
|
19371937
1 | minimum_prek_version: 10.0.0
1938-
| ^ Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`. Please consider updating prek. at line 1, column 23
1938+
| ^ Required minimum prek version `10.0.0` is greater than current version `[CURRENT_VERSION]`. at line 1, column 23
19391939
2 | repos:
19401940
3 | - repo: local
19411941
|

0 commit comments

Comments
 (0)