amont_runtime/hooks/branch_pattern.rs
1//! pre-push-branch-pattern — reject branch names that don't follow the
2//! convention, unless the branch is already on the remote.
3//!
4//! Ported from zsh. The shell version needed `rg` with a `grep` fallback and a
5//! pattern written to satisfy both engines; here the match is a dozen lines of
6//! ASCII checks, so the `rg`/`grep` split and `HOOKS_FORCE_GREP` disappear
7//! entirely — as does the failure mode that motivated them, where a missing
8//! `rg` made `! rg …` true and the hook rejected EVERY branch name.
9
10use crate::check::Outcome;
11use crate::git;
12use crate::pushrefs::PushRef;
13use crate::ui::{error_sign, highlight, valid_sign};
14
15use crate::vocabulary;
16
17/// Both the rule and the message now come from `vocabulary`, so what a user is
18/// told and what is enforced cannot drift — and neither can the branch prefixes
19/// and the commit types, which had diverged to the point that `docs/…` was
20/// unpushable while `docs:` was a valid commit type.
21///
22/// `[[:alnum:]]` is ASCII in both rg and `grep -E` under the C locale, which is
23/// what the shell version enforced; `is_ascii_alphanumeric` keeps that. A
24/// Unicode-aware check would silently LOOSEN the rule.
25pub fn conforms(branch: &str) -> bool {
26 let Some((prefix, rest)) = branch.split_once('/') else {
27 return false;
28 };
29 if rest.is_empty() {
30 return false;
31 }
32 let Some(p) = vocabulary::branch_prefix(prefix) else {
33 return false;
34 };
35 rest.chars()
36 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || (p.dots && c == '.'))
37}
38
39/// A delete pushes no name to validate. Same rule as `branch_protect::is_delete`
40/// — the all-zero local oid is how git spells it.
41fn is_delete(r: &PushRef) -> bool {
42 r.local_oid.chars().all(|c| c == '0')
43}
44
45/// The branch name this ref would CREATE on the server, if it creates one.
46///
47/// `None` for a delete, for anything that is not `refs/heads/` (tags, notes),
48/// and for a ref whose remote oid is non-zero — that last one means the branch
49/// already exists on the server, so its name was authorised the day it was
50/// created.
51fn name_to_validate<'a>(r: &'a PushRef, zero: &str) -> Option<&'a str> {
52 if is_delete(r) {
53 return None;
54 }
55 if r.remote_oid != zero {
56 return None;
57 }
58 r.remote_ref.strip_prefix("refs/heads/")
59}
60
61/// Judged on the REFS BEING PUSHED, not on the branch that happens to be
62/// checked out.
63///
64/// It used to ask `rev-parse --abbrev-ref HEAD`, exactly the mistake
65/// `branch_protect` documents avoiding, and it cost two things:
66///
67/// - `git push origin local:refs/heads/other` validated the wrong name
68/// entirely — the one you are standing on rather than the one being
69/// created;
70/// - on a DETACHED HEAD, `--abbrev-ref HEAD` returns the literal string
71/// `"HEAD"`, which `conforms` rejects. The `show-branch
72/// remotes/origin/HEAD` short-circuit hid that in a normal clone, but in a
73/// repository with no `refs/remotes/origin/HEAD` — a bare `git init` plus
74/// `git remote add`, or after `git remote set-head --delete` — a perfectly
75/// ordinary `git push origin HEAD:refs/heads/feat/x` was BLOCKED.
76///
77/// The `show-branch` probe is gone, replaced by the non-zero remote oid: git
78/// has already told us whether the branch exists on the server, and the probe
79/// depended on a remote-tracking ref that a fresh clone may not have.
80pub fn run(refs: &[PushRef], args: &[std::ffi::OsString]) -> Outcome {
81 // Matches `branch_protect::no_refs_is_a_pass`: nothing pushed, nothing to
82 // judge.
83 if refs.is_empty() {
84 return Outcome::Passed;
85 }
86 let zero = git::stdout(&["hash-object", "--stdin"])
87 .map(|h| "0".repeat(h.len()))
88 .unwrap_or_else(|| "0".repeat(40));
89
90 let candidates: Vec<&str> = refs
91 .iter()
92 .filter_map(|r| name_to_validate(r, &zero))
93 .collect();
94 if candidates.is_empty() {
95 println!(
96 "{} No new branch name to validate. Push is authorized.",
97 valid_sign()
98 );
99 return Outcome::Passed;
100 }
101
102 // Initial push to a brand-new empty remote: there's no feature-branch
103 // convention to enforce while initializing a repo, and the default branch
104 // (main/master) doesn't match the pattern anyway. Still needed alongside
105 // the remote-oid rule above, because on a brand-new remote EVERY remote
106 // oid is zero. git passes the remote name as the first argument.
107 let remote = args
108 .first()
109 .and_then(|a| a.to_str())
110 .filter(|s| !s.is_empty())
111 .unwrap_or("origin");
112 // `None` here means git failed, which is NOT the same as "no branches" —
113 // treat only a successful, empty listing as the initial-push case.
114 if git::stdout(&["ls-remote", "--heads", remote]).is_some_and(|s| s.is_empty()) {
115 println!(
116 "{} Remote has no branches yet (initial push). Name is authorized.",
117 valid_sign()
118 );
119 return Outcome::Passed;
120 }
121
122 // Per offending ref, so a multi-ref push names them all rather than
123 // stopping at the first.
124 let offenders: Vec<&str> = candidates
125 .iter()
126 .copied()
127 .filter(|name| !conforms(name))
128 .collect();
129 if !offenders.is_empty() {
130 for name in &offenders {
131 println!(
132 "{} Branch name {} does not adhere to this project's contract:
133 {}.
134 Rename your branch with: {} <branch name>
135 Or bypass this check with git -c hook.skip=branch-pattern push",
136 error_sign(),
137 highlight(name),
138 highlight(&vocabulary::branch_contract()),
139 highlight("git branch -m")
140 );
141 }
142 return Outcome::Failed;
143 }
144
145 println!(
146 "{} Branch name conforms with authorized pattern",
147 valid_sign()
148 );
149 Outcome::Passed
150}
151
152#[cfg(test)]
153mod tests {
154 use super::{conforms, name_to_validate, run, Outcome, PushRef};
155
156 const ZERO: &str = "0000000000000000000000000000000000000000";
157
158 fn r(local_oid: &str, remote_ref: &str, remote_oid: &str) -> PushRef {
159 PushRef {
160 local_ref: "refs/heads/whatever-is-checked-out".into(),
161 local_oid: local_oid.into(),
162 remote_ref: remote_ref.into(),
163 remote_oid: remote_oid.into(),
164 }
165 }
166
167 /// The table `branch_protect`'s tests are built from, applied to the
168 /// question this check actually has to answer.
169 ///
170 /// `name_to_validate` rather than `run`, because `run`'s remaining branches
171 /// consult `ls-remote` and a unit test must not reach a network.
172 #[test]
173 fn only_a_new_branch_ref_carries_a_name_to_validate() {
174 // Judged on the REMOTE ref, not on whatever is checked out.
175 // `git push origin local:refs/heads/other` creates `other`.
176 assert_eq!(
177 name_to_validate(&r("a", "refs/heads/feat/x", ZERO), ZERO),
178 Some("feat/x")
179 );
180 // Already on the server: git has told us so with a non-zero remote
181 // oid, and the name was authorised the day it was created.
182 assert_eq!(
183 name_to_validate(&r("a", "refs/heads/off-pattern", "b"), ZERO),
184 None
185 );
186 // A delete pushes no name.
187 assert_eq!(
188 name_to_validate(&r(ZERO, "refs/heads/off", ZERO), ZERO),
189 None
190 );
191 // A tag is not a branch, and neither is anything else outside
192 // `refs/heads/`.
193 assert_eq!(
194 name_to_validate(&r("a", "refs/tags/v1.0", ZERO), ZERO),
195 None
196 );
197 assert_eq!(
198 name_to_validate(&r("a", "refs/notes/commits", ZERO), ZERO),
199 None
200 );
201 }
202
203 /// Matches `branch_protect::no_refs_is_a_pass`, and reaches no git at all.
204 #[test]
205 fn no_refs_is_a_pass() {
206 assert_eq!(run(&[], &[]), Outcome::Passed);
207 }
208
209 #[test]
210 fn accepts_every_declared_prefix() {
211 for p in crate::vocabulary::BRANCH_PREFIXES {
212 assert!(conforms(&format!("{}/some-work", p.name)), "{}", p.name);
213 }
214 }
215
216 /// The divergence this module exists to end: these were all REJECTED as
217 /// branch names while being perfectly valid commit types.
218 #[test]
219 fn accepts_the_prefixes_that_used_to_be_rejected() {
220 for b in [
221 "docs/rust-migration",
222 "refactor/hook-registry",
223 "perf/faster-startup",
224 "build/bump-toolchain",
225 "style/reformat",
226 "revert/bad-change",
227 "add/new-thing",
228 "remove/dead-code",
229 ] {
230 assert!(conforms(b), "{b} should be allowed now");
231 }
232 }
233
234 #[test]
235 fn rejects_everything_else() {
236 assert!(!conforms("off-pattern"));
237 assert!(!conforms("duro-1.50.50"));
238 assert!(!conforms("feat/"));
239 assert!(!conforms("/x"));
240 assert!(!conforms("feat/a/b"));
241 assert!(!conforms("main"));
242 assert!(!conforms("release/1")); // not a declared prefix
243 }
244
245 /// Dots stay a chore-only affordance for version bumps.
246 #[test]
247 fn dots_are_chore_only() {
248 assert!(conforms("chore/duro-1.50.50"));
249 assert!(!conforms("feat/duro-1.50.50"));
250 assert!(!conforms("docs/1.2.3"));
251 }
252
253 /// `[[:alnum:]]` is ASCII under the C locale in both engines the shell
254 /// version used; a Unicode-aware check would LOOSEN the rule.
255 #[test]
256 fn alnum_stays_ascii() {
257 assert!(!conforms("feat/café"));
258 assert!(!conforms("chore/日本語"));
259 }
260}