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, warning_sign};
14
15use crate::vocabulary;
16
17/// The same contract, said at the FIRST COMMIT — `pre-commit-branch-pattern`.
18///
19/// pre-push is the enforcement point, and it is also the worst possible
20/// moment to learn the rule: the work is done and stacked on a name that now
21/// has to change, usually by an agent or a person who never saw the contract
22/// before creating the branch. This is `usual-name`'s argument applied to
23/// branch names — say it at the first commit, when the fix is one
24/// `git branch -m` and nothing is built on top.
25///
26/// A warning, never a block, and quiet in every state where the push check
27/// would not judge this branch:
28///
29/// - a DETACHED head (rebase, cherry-pick, bisect) names no branch;
30/// - a repository with no remote has nothing this contract gates;
31/// - a branch with a remote-tracking ref under any remote already exists
32/// on a server. pre-push authorises an existing branch by its non-zero
33/// remote oid; the remote-tracking ref is the local mirror of that same
34/// fact, and costs no network to consult.
35pub fn early() -> Outcome {
36 let Some(branch) = git::current_branch() else {
37 return Outcome::Passed;
38 };
39 if conforms(branch) {
40 crate::say!(
41 "{} Branch name conforms with authorized pattern",
42 valid_sign()
43 );
44 return Outcome::Passed;
45 }
46 if !git::has_remote() {
47 return Outcome::Passed;
48 }
49 // `*` in a for-each-ref pattern does not cross `/`, so this matches the
50 // branch under any single remote name and nothing else.
51 let tracking = format!("refs/remotes/*/{branch}");
52 if git::stdout(&["for-each-ref", "--format=%(refname)", &tracking])
53 .is_some_and(|refs| !refs.is_empty())
54 {
55 return Outcome::Passed;
56 }
57 let prefixes = vocabulary::BRANCH_PREFIXES
58 .iter()
59 .map(|p| p.name)
60 .collect::<Vec<_>>()
61 .join(", ");
62 crate::say!(
63 "{} Branch {} will be refused at push time — it does not match
64 {}.
65 Rename it now, while nothing is stacked on the name: {} <prefix>/…
66 Prefixes: {prefixes}",
67 warning_sign(),
68 highlight(branch),
69 highlight(&vocabulary::branch_contract()),
70 highlight("git branch -m")
71 );
72 Outcome::Warned
73}
74
75/// Both the rule and the message now come from `vocabulary`, so what a user is
76/// told and what is enforced cannot drift — and neither can the branch prefixes
77/// and the commit types, which had diverged to the point that `docs/…` was
78/// unpushable while `docs:` was a valid commit type.
79///
80/// `[[:alnum:]]` is ASCII in both rg and `grep -E` under the C locale, which is
81/// what the shell version enforced; `is_ascii_alphanumeric` keeps that. A
82/// Unicode-aware check would silently LOOSEN the rule.
83pub fn conforms(branch: &str) -> bool {
84 let Some((prefix, rest)) = branch.split_once('/') else {
85 return false;
86 };
87 if rest.is_empty() {
88 return false;
89 }
90 let Some(p) = vocabulary::branch_prefix(prefix) else {
91 return false;
92 };
93 rest.chars()
94 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || (p.dots && c == '.'))
95}
96
97/// A delete pushes no name to validate. Same rule as `branch_protect::is_delete`
98/// — the all-zero local oid is how git spells it.
99fn is_delete(r: &PushRef) -> bool {
100 r.local_oid.chars().all(|c| c == '0')
101}
102
103/// The branch name this ref would CREATE on the server, if it creates one.
104///
105/// `None` for a delete, for anything that is not `refs/heads/` (tags, notes),
106/// and for a ref whose remote oid is non-zero — that last one means the branch
107/// already exists on the server, so its name was authorised the day it was
108/// created.
109fn name_to_validate<'a>(r: &'a PushRef, zero: &str) -> Option<&'a str> {
110 if is_delete(r) {
111 return None;
112 }
113 if r.remote_oid != zero {
114 return None;
115 }
116 r.remote_ref.strip_prefix("refs/heads/")
117}
118
119/// Judged on the REFS BEING PUSHED, not on the branch that happens to be
120/// checked out.
121///
122/// It used to ask `rev-parse --abbrev-ref HEAD`, exactly the mistake
123/// `branch_protect` documents avoiding, and it cost two things:
124///
125/// - `git push origin local:refs/heads/other` validated the wrong name
126/// entirely — the one you are standing on rather than the one being
127/// created;
128/// - on a DETACHED HEAD, `--abbrev-ref HEAD` returns the literal string
129/// `"HEAD"`, which `conforms` rejects. The `show-branch
130/// remotes/origin/HEAD` short-circuit hid that in a normal clone, but in a
131/// repository with no `refs/remotes/origin/HEAD` — a bare `git init` plus
132/// `git remote add`, or after `git remote set-head --delete` — a perfectly
133/// ordinary `git push origin HEAD:refs/heads/feat/x` was BLOCKED.
134///
135/// The `show-branch` probe is gone, replaced by the non-zero remote oid: git
136/// has already told us whether the branch exists on the server, and the probe
137/// depended on a remote-tracking ref that a fresh clone may not have.
138pub fn run(refs: &[PushRef], args: &[std::ffi::OsString]) -> Outcome {
139 // Matches `branch_protect::no_refs_is_a_pass`: nothing pushed, nothing to
140 // judge.
141 if refs.is_empty() {
142 return Outcome::Passed;
143 }
144 let zero = git::stdout(&["hash-object", "--stdin"])
145 .map(|h| "0".repeat(h.len()))
146 .unwrap_or_else(|| "0".repeat(40));
147
148 let candidates: Vec<&str> = refs
149 .iter()
150 .filter_map(|r| name_to_validate(r, &zero))
151 .collect();
152 if candidates.is_empty() {
153 crate::say!(
154 "{} No new branch name to validate. Push is authorized.",
155 valid_sign()
156 );
157 return Outcome::Passed;
158 }
159
160 // Initial push to a brand-new empty remote: there's no feature-branch
161 // convention to enforce while initializing a repo, and the default branch
162 // (main/master) doesn't match the pattern anyway. Still needed alongside
163 // the remote-oid rule above, because on a brand-new remote EVERY remote
164 // oid is zero. git passes the remote name as the first argument.
165 let remote = args
166 .first()
167 .and_then(|a| a.to_str())
168 .filter(|s| !s.is_empty())
169 .unwrap_or("origin");
170 // `--exit-code` turns the answer into exit codes — 2 for "connected,
171 // zero refs", 0 for "has branches" — so nothing here reads (or waits
172 // on) the listing itself. Bounded: this is a network round-trip asked
173 // before a purely local check, and offline used to hang it. Anything
174 // other than a clean "zero refs" answer falls through to the pattern
175 // check — git failing is NOT the same as "no branches".
176 match git::probe(
177 &["ls-remote", "--exit-code", "--heads", remote],
178 super::common::network_probe_budget(),
179 ) {
180 git::Probe::Exit(2) => {
181 crate::say!(
182 "{} Remote has no branches yet (initial push). Name is authorized.",
183 valid_sign()
184 );
185 return Outcome::Passed;
186 }
187 git::Probe::TimedOut(secs) => {
188 crate::say!(
189 "{} {remote} did not answer within {secs}s — checking the name anyway.",
190 warning_sign()
191 );
192 }
193 _ => {}
194 }
195
196 // Per offending ref, so a multi-ref push names them all rather than
197 // stopping at the first.
198 let offenders: Vec<&str> = candidates
199 .iter()
200 .copied()
201 .filter(|name| !conforms(name))
202 .collect();
203 if !offenders.is_empty() {
204 for name in &offenders {
205 crate::say!(
206 "{} Branch name {} does not adhere to this project's contract:
207 {}.
208 Rename your branch with: {} <branch name>
209 Or bypass this check with git -c hook.skip=branch-pattern push",
210 error_sign(),
211 highlight(name),
212 highlight(&vocabulary::branch_contract()),
213 highlight("git branch -m")
214 );
215 }
216 return Outcome::Failed;
217 }
218
219 crate::say!(
220 "{} Branch name conforms with authorized pattern",
221 valid_sign()
222 );
223 Outcome::Passed
224}
225
226#[cfg(test)]
227mod tests {
228 use super::{conforms, name_to_validate, run, Outcome, PushRef};
229
230 const ZERO: &str = "0000000000000000000000000000000000000000";
231
232 fn r(local_oid: &str, remote_ref: &str, remote_oid: &str) -> PushRef {
233 PushRef {
234 local_ref: "refs/heads/whatever-is-checked-out".into(),
235 local_oid: local_oid.into(),
236 remote_ref: remote_ref.into(),
237 remote_oid: remote_oid.into(),
238 }
239 }
240
241 /// The table `branch_protect`'s tests are built from, applied to the
242 /// question this check actually has to answer.
243 ///
244 /// `name_to_validate` rather than `run`, because `run`'s remaining branches
245 /// consult `ls-remote` and a unit test must not reach a network.
246 #[test]
247 fn only_a_new_branch_ref_carries_a_name_to_validate() {
248 // Judged on the REMOTE ref, not on whatever is checked out.
249 // `git push origin local:refs/heads/other` creates `other`.
250 assert_eq!(
251 name_to_validate(&r("a", "refs/heads/feat/x", ZERO), ZERO),
252 Some("feat/x")
253 );
254 // Already on the server: git has told us so with a non-zero remote
255 // oid, and the name was authorised the day it was created.
256 assert_eq!(
257 name_to_validate(&r("a", "refs/heads/off-pattern", "b"), ZERO),
258 None
259 );
260 // A delete pushes no name.
261 assert_eq!(
262 name_to_validate(&r(ZERO, "refs/heads/off", ZERO), ZERO),
263 None
264 );
265 // A tag is not a branch, and neither is anything else outside
266 // `refs/heads/`.
267 assert_eq!(
268 name_to_validate(&r("a", "refs/tags/v1.0", ZERO), ZERO),
269 None
270 );
271 assert_eq!(
272 name_to_validate(&r("a", "refs/notes/commits", ZERO), ZERO),
273 None
274 );
275 }
276
277 /// Matches `branch_protect::no_refs_is_a_pass`, and reaches no git at all.
278 #[test]
279 fn no_refs_is_a_pass() {
280 assert_eq!(run(&[], &[]), Outcome::Passed);
281 }
282
283 #[test]
284 fn accepts_every_declared_prefix() {
285 for p in crate::vocabulary::BRANCH_PREFIXES {
286 assert!(conforms(&format!("{}/some-work", p.name)), "{}", p.name);
287 }
288 }
289
290 /// The divergence this module exists to end: these were all REJECTED as
291 /// branch names while being perfectly valid commit types.
292 #[test]
293 fn accepts_the_prefixes_that_used_to_be_rejected() {
294 for b in [
295 "docs/rust-migration",
296 "refactor/hook-registry",
297 "perf/faster-startup",
298 "build/bump-toolchain",
299 "style/reformat",
300 "revert/bad-change",
301 "add/new-thing",
302 "remove/dead-code",
303 ] {
304 assert!(conforms(b), "{b} should be allowed now");
305 }
306 }
307
308 #[test]
309 fn rejects_everything_else() {
310 assert!(!conforms("off-pattern"));
311 assert!(!conforms("duro-1.50.50"));
312 assert!(!conforms("feat/"));
313 assert!(!conforms("/x"));
314 assert!(!conforms("feat/a/b"));
315 assert!(!conforms("main"));
316 assert!(!conforms("release/1")); // not a declared prefix
317 }
318
319 /// Dots stay a chore-only affordance for version bumps.
320 #[test]
321 fn dots_are_chore_only() {
322 assert!(conforms("chore/duro-1.50.50"));
323 assert!(!conforms("feat/duro-1.50.50"));
324 assert!(!conforms("docs/1.2.3"));
325 }
326
327 /// `[[:alnum:]]` is ASCII under the C locale in both engines the shell
328 /// version used; a Unicode-aware check would LOOSEN the rule.
329 #[test]
330 fn alnum_stays_ascii() {
331 assert!(!conforms("feat/café"));
332 assert!(!conforms("chore/日本語"));
333 }
334}