1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
//! pre-commit-prettier — format check over staged files, scoped to repos that
//! actually opt into prettier.
use super::common::{
fail, first_existing, fixing_enabled, hl, ok, repo_root, resolve_tool, restage,
run as run_tool, run_quiet, staged_files, warn, Restaged,
};
use crate::check::Outcome;
use std::path::Path;
/// Everything prettier is asked to look at.
///
/// Exported, even though `registry.rs` deliberately declares
/// `Scope::files(&[])` for this check (it is opt-in by CONFIG, not by file
/// type): the drift guard needs to know what the check actually consumes, and
/// an empty declared set is a subset of this one.
pub const EXTS: &[&str] = &[
".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".json", ".jsonc", ".css", ".scss", ".less",
".html", ".vue", ".md", ".mdx", ".yaml", ".yml",
];
/// A repo opts in via a prettier config file or a `"prettier"` key in
/// package.json. Without that signal the hook does nothing — in a repo that
/// does not use prettier, `npx prettier` would download it and flag every file
/// against defaults.
fn has_config(root: &str) -> bool {
if first_existing(
root,
&[
".prettierrc",
"prettier.config.js",
"prettier.config.mjs",
"prettier.config.cjs",
".prettierrc.json",
".prettierrc.yml",
".prettierrc.yaml",
".prettierrc.js",
".prettierrc.cjs",
".prettierrc.mjs",
".prettierrc.toml",
],
)
.is_some()
{
return true;
}
std::fs::read_to_string(Path::new(root).join("package.json"))
.map(|p| p.contains("\"prettier\""))
.unwrap_or(false)
}
pub fn run(_args: &[std::ffi::OsString]) -> Outcome {
let files = staged_files(EXTS);
if files.is_empty() {
return Outcome::Passed;
}
let root = repo_root();
let tool = resolve_tool(&root, "prettier");
// A pinned local prettier is itself an opt-in signal; without config AND
// without a local binary the repo simply does not use prettier.
let local_pinned = tool
.as_ref()
.map(|t| t[0].contains("node_modules/.bin"))
.unwrap_or(false);
if !has_config(&root) && !local_pinned {
return Outcome::Passed;
}
let Some(argv) = tool else {
warn(&format!(
"prettier config found but no prettier binary. Run {}",
hl("npm install")
));
return Outcome::Unavailable;
};
// Ignore a GLOBAL ~/.editorconfig when this repo defines none of its own:
// prettier otherwise walks up past the repo and enforces machine-wide
// defaults (4-space, say) on a repo formatted differently — a false
// positive that also disagrees with CI, which has no ~/.editorconfig.
// A repo-level .editorconfig is still honoured, matching CI.
let mut flags: Vec<String> = Vec::new();
if !Path::new(&root).join(".editorconfig").is_file() {
flags.push("--no-editorconfig".into());
}
// `--` before the file list at each of these: a staged file named e.g.
// `-x.js` would otherwise be read as a flag by prettier's own parser —
// and prettier does not even error on that, it exits 0 having checked
// nothing, so the file would silently never be linted at all.
// ONE `--check` pass. It used to run twice on the clean path — once to
// decide whether to repair, once to decide the verdict — which is a whole
// extra prettier startup on every commit that had nothing wrong with it.
let mut check = flags.clone();
check.push("--check".into());
check.push("--".into());
check.extend(files.iter().cloned());
if run_quiet(&root, &argv, &check) {
ok("Prettier passed");
return Outcome::Passed;
}
if fixing_enabled() {
// Asked to repair, so repair rather than reporting an instruction the
// author would carry out identically by hand.
let mut write = flags.clone();
write.push("--write".into());
write.push("--".into());
write.extend(files.iter().cloned());
if run_quiet(&root, &argv, &write) {
// Dropping the confirming re-`--check` after the write is safe:
// `prettier --write` exits NON-ZERO on a parse error, so a
// successful write means the files are formatted.
match restage(&files) {
Restaged::Staged => {
ok("Prettier reformatted and re-staged");
return Outcome::Fixed;
}
// The index still holds the content prettier has just replaced
// on disk. Reporting anything but a failure here is how
// unformatted code reached a commit the hook called clean.
Restaged::Failed(stuck) => {
fail(&format!(
"Prettier reformatted these files but {} failed — the index still \
holds the UNFORMATTED content: {}",
hl("git add"),
stuck.join(", ")
));
return Outcome::Failed;
}
// Nothing differed from the index, so the write changed
// nothing that was staged. Fall through and report.
Restaged::Nothing => {}
}
}
}
fail(&format!(
"Prettier found unformatted files. Run {} on:",
hl("prettier --write")
));
let mut list = flags;
list.push("--list-different".into());
list.push("--".into());
list.extend(files);
let _ = run_tool(&root, &argv, &list);
Outcome::Failed
}