amont_runtime/hooks/
package_lock.rs1use super::common::{fail, hl, ok, repo_root, staged_files, warn};
27use crate::check::Outcome;
28use std::path::Path;
29
30fn dir_of(path: &str) -> &str {
31 match path.rfind('/') {
32 Some(i) => &path[..i],
33 None => ".",
34 }
35}
36
37fn sibling(dir: &str, name: &str) -> String {
38 if dir == "." {
39 name.to_string()
40 } else {
41 format!("{dir}/{name}")
42 }
43}
44
45pub struct Verdict {
46 pub forgot_lock: Vec<String>,
48 pub orphan_lock: Vec<String>,
50}
51
52pub fn classify(staged: &[String], lock_exists: impl Fn(&str) -> bool) -> Verdict {
53 let is_staged = |p: &str| staged.iter().any(|s| s == p);
54 let mut v = Verdict {
55 forgot_lock: Vec::new(),
56 orphan_lock: Vec::new(),
57 };
58 for f in staged {
59 let dir = dir_of(f);
60 let base = f.rsplit('/').next().unwrap_or(f);
61 match base {
62 "package.json" => {
63 let lock = sibling(dir, "package-lock.json");
64 if is_staged(&lock) {
65 continue; }
67 if lock_exists(&lock) {
68 v.forgot_lock.push(f.clone());
69 }
70 }
71 "package-lock.json" => {
72 let pkg = sibling(dir, "package.json");
73 if !is_staged(&pkg) {
74 v.orphan_lock.push(f.clone());
75 }
76 }
77 _ => {}
78 }
79 }
80 v
81}
82
83pub fn run(_args: &[std::ffi::OsString]) -> Outcome {
85 let staged = staged_files(&[]);
86 let root = repo_root();
87 let v = classify(&staged, |lock| Path::new(&root).join(lock).is_file());
88
89 if v.forgot_lock.is_empty() && v.orphan_lock.is_empty() {
90 ok("package.json & package-lock.json look in sync");
91 return Outcome::Passed;
92 }
93 for f in &v.orphan_lock {
94 fail(&format!("{} staged without its package.json", hl(f)));
95 }
96 for f in &v.forgot_lock {
97 warn(&format!(
98 "{} changed but its package-lock.json is not staged",
99 hl(f)
100 ));
101 }
102
103 fail(&format!(
107 "Run {} and stage the lockfile. To stop this blocking, {}; \
108 to bypass once, {}",
109 hl("npm install"),
110 hl("git config amont.severity.package-lock warn"),
111 hl("git -c hook.skip=package-lock commit")
112 ));
113 Outcome::Failed
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 fn v(s: &[&str], locks: &[&str]) -> Verdict {
121 let staged: Vec<String> = s.iter().map(|x| x.to_string()).collect();
122 let locks: Vec<String> = locks.iter().map(|x| x.to_string()).collect();
123 classify(&staged, move |l| locks.iter().any(|x| x == l))
124 }
125
126 #[test]
127 fn both_staged_is_in_sync() {
128 let r = v(
129 &["package.json", "package-lock.json"],
130 &["package-lock.json"],
131 );
132 assert!(r.forgot_lock.is_empty() && r.orphan_lock.is_empty());
133 }
134
135 #[test]
136 fn package_json_alone_with_a_real_lock_on_disk_is_a_forgotten_lock() {
137 let r = v(&["package.json"], &["package-lock.json"]);
138 assert_eq!(r.forgot_lock, vec!["package.json".to_string()]);
139 }
140
141 #[test]
144 fn package_json_without_a_lock_on_disk_demands_nothing() {
145 let r = v(&["package.json"], &[]);
146 assert!(r.forgot_lock.is_empty() && r.orphan_lock.is_empty());
147 }
148
149 #[test]
150 fn a_lock_without_its_package_json_is_an_orphan() {
151 let r = v(&["package-lock.json"], &["package-lock.json"]);
152 assert_eq!(r.orphan_lock, vec!["package-lock.json".to_string()]);
153 }
154
155 #[test]
157 fn scoping_is_per_directory() {
158 let r = v(
159 &["apps/a/package.json", "apps/b/package-lock.json"],
160 &["apps/a/package-lock.json", "apps/b/package-lock.json"],
161 );
162 assert_eq!(r.forgot_lock, vec!["apps/a/package.json".to_string()]);
163 assert_eq!(r.orphan_lock, vec!["apps/b/package-lock.json".to_string()]);
164 }
165}