use crate::check::Outcome;
use crate::git;
use crate::pushrefs::PushRef;
use crate::ui::{error_sign, highlight, valid_sign, warning_sign};
use crate::vocabulary;
pub fn early() -> Outcome {
let Some(branch) = git::stdout(&["symbolic-ref", "--quiet", "--short", "HEAD"]) else {
return Outcome::Passed;
};
if conforms(&branch) {
println!(
"{} Branch name conforms with authorized pattern",
valid_sign()
);
return Outcome::Passed;
}
if git::stdout(&["remote"])
.map(|remotes| remotes.is_empty())
.unwrap_or(true)
{
return Outcome::Passed;
}
let tracking = format!("refs/remotes/*/{branch}");
if git::stdout(&["for-each-ref", "--format=%(refname)", &tracking])
.is_some_and(|refs| !refs.is_empty())
{
return Outcome::Passed;
}
let prefixes = vocabulary::BRANCH_PREFIXES
.iter()
.map(|p| p.name)
.collect::<Vec<_>>()
.join(", ");
println!(
"{} Branch {} will be refused at push time — it does not match
{}.
Rename it now, while nothing is stacked on the name: {} <prefix>/…
Prefixes: {prefixes}",
warning_sign(),
highlight(&branch),
highlight(&vocabulary::branch_contract()),
highlight("git branch -m")
);
Outcome::Warned
}
pub fn conforms(branch: &str) -> bool {
let Some((prefix, rest)) = branch.split_once('/') else {
return false;
};
if rest.is_empty() {
return false;
}
let Some(p) = vocabulary::branch_prefix(prefix) else {
return false;
};
rest.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || (p.dots && c == '.'))
}
fn is_delete(r: &PushRef) -> bool {
r.local_oid.chars().all(|c| c == '0')
}
fn name_to_validate<'a>(r: &'a PushRef, zero: &str) -> Option<&'a str> {
if is_delete(r) {
return None;
}
if r.remote_oid != zero {
return None;
}
r.remote_ref.strip_prefix("refs/heads/")
}
pub fn run(refs: &[PushRef], args: &[std::ffi::OsString]) -> Outcome {
if refs.is_empty() {
return Outcome::Passed;
}
let zero = git::stdout(&["hash-object", "--stdin"])
.map(|h| "0".repeat(h.len()))
.unwrap_or_else(|| "0".repeat(40));
let candidates: Vec<&str> = refs
.iter()
.filter_map(|r| name_to_validate(r, &zero))
.collect();
if candidates.is_empty() {
println!(
"{} No new branch name to validate. Push is authorized.",
valid_sign()
);
return Outcome::Passed;
}
let remote = args
.first()
.and_then(|a| a.to_str())
.filter(|s| !s.is_empty())
.unwrap_or("origin");
if git::stdout(&["ls-remote", "--heads", remote]).is_some_and(|s| s.is_empty()) {
println!(
"{} Remote has no branches yet (initial push). Name is authorized.",
valid_sign()
);
return Outcome::Passed;
}
let offenders: Vec<&str> = candidates
.iter()
.copied()
.filter(|name| !conforms(name))
.collect();
if !offenders.is_empty() {
for name in &offenders {
println!(
"{} Branch name {} does not adhere to this project's contract:
{}.
Rename your branch with: {} <branch name>
Or bypass this check with git -c hook.skip=branch-pattern push",
error_sign(),
highlight(name),
highlight(&vocabulary::branch_contract()),
highlight("git branch -m")
);
}
return Outcome::Failed;
}
println!(
"{} Branch name conforms with authorized pattern",
valid_sign()
);
Outcome::Passed
}
#[cfg(test)]
mod tests {
use super::{conforms, name_to_validate, run, Outcome, PushRef};
const ZERO: &str = "0000000000000000000000000000000000000000";
fn r(local_oid: &str, remote_ref: &str, remote_oid: &str) -> PushRef {
PushRef {
local_ref: "refs/heads/whatever-is-checked-out".into(),
local_oid: local_oid.into(),
remote_ref: remote_ref.into(),
remote_oid: remote_oid.into(),
}
}
#[test]
fn only_a_new_branch_ref_carries_a_name_to_validate() {
assert_eq!(
name_to_validate(&r("a", "refs/heads/feat/x", ZERO), ZERO),
Some("feat/x")
);
assert_eq!(
name_to_validate(&r("a", "refs/heads/off-pattern", "b"), ZERO),
None
);
assert_eq!(
name_to_validate(&r(ZERO, "refs/heads/off", ZERO), ZERO),
None
);
assert_eq!(
name_to_validate(&r("a", "refs/tags/v1.0", ZERO), ZERO),
None
);
assert_eq!(
name_to_validate(&r("a", "refs/notes/commits", ZERO), ZERO),
None
);
}
#[test]
fn no_refs_is_a_pass() {
assert_eq!(run(&[], &[]), Outcome::Passed);
}
#[test]
fn accepts_every_declared_prefix() {
for p in crate::vocabulary::BRANCH_PREFIXES {
assert!(conforms(&format!("{}/some-work", p.name)), "{}", p.name);
}
}
#[test]
fn accepts_the_prefixes_that_used_to_be_rejected() {
for b in [
"docs/rust-migration",
"refactor/hook-registry",
"perf/faster-startup",
"build/bump-toolchain",
"style/reformat",
"revert/bad-change",
"add/new-thing",
"remove/dead-code",
] {
assert!(conforms(b), "{b} should be allowed now");
}
}
#[test]
fn rejects_everything_else() {
assert!(!conforms("off-pattern"));
assert!(!conforms("duro-1.50.50"));
assert!(!conforms("feat/"));
assert!(!conforms("/x"));
assert!(!conforms("feat/a/b"));
assert!(!conforms("main"));
assert!(!conforms("release/1")); }
#[test]
fn dots_are_chore_only() {
assert!(conforms("chore/duro-1.50.50"));
assert!(!conforms("feat/duro-1.50.50"));
assert!(!conforms("docs/1.2.3"));
}
#[test]
fn alnum_stays_ascii() {
assert!(!conforms("feat/café"));
assert!(!conforms("chore/日本語"));
}
}