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