Skip to main content

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}