use crate::check::Outcome;
use crate::git;
use crate::pushrefs::PushRef;
use crate::ui::{error_sign, highlight, warning_sign};
const PROTECTED: [&str; 2] = ["main", "master"];
fn protected_name(remote_ref: &str) -> Option<&'static str> {
let name = remote_ref.strip_prefix("refs/heads/")?;
PROTECTED.iter().copied().find(|p| *p == name)
}
fn is_delete(r: &PushRef) -> bool {
all_zero(&r.local_oid)
}
fn is_creation(r: &PushRef) -> bool {
all_zero(&r.remote_oid)
}
fn all_zero(oid: &str) -> bool {
!oid.is_empty() && oid.chars().all(|c| c == '0')
}
fn on_a_remote(branch: &str) -> bool {
let pattern = format!("refs/remotes/*/{branch}");
git::stdout(&["for-each-ref", "--format=%(refname)", &pattern])
.is_some_and(|out| !out.trim().is_empty())
}
pub fn early(settings: &crate::config::Settings) -> Outcome {
let Some(branch) = git::current_branch() else {
return Outcome::Passed;
};
if !PROTECTED.contains(&branch) {
crate::hooks::common::ok(settings, "Not committing on a protected branch");
return Outcome::Passed;
}
if !git::has_remote() || !on_a_remote(branch) {
return Outcome::Passed;
}
crate::say!(
"{} Committing on {} — pushing it will be refused by {}.
Move the work to a branch now, while it is one command: {} <prefix>/…
(the commit comes along; {} stays where it was)",
warning_sign(),
highlight(branch),
highlight("pre-push-branch-protect"),
highlight("git switch -c"),
highlight(branch)
);
Outcome::Warned
}
pub fn run(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
let mut blocked = Vec::new();
for r in refs {
if is_creation(r) {
continue;
}
if let Some(name) = protected_name(&r.remote_ref) {
blocked.push((name, is_delete(r)));
}
}
if blocked.is_empty() {
crate::hooks::common::ok(settings, "No push to a protected branch");
return Outcome::Passed;
}
for (name, deleting) in &blocked {
let what = if *deleting { "Deleting" } else { "Pushing to" };
crate::say!(
"{} {what} branch {} is forbidden. Open a Pull Request.",
error_sign(),
highlight(name)
);
}
crate::say!(
" (if you really mean it: {})",
highlight("git push --no-verify")
);
Outcome::Failed
}
#[cfg(test)]
mod tests {
use super::*;
fn test_settings() -> crate::config::Settings {
crate::config::Settings::default()
}
const ZEROS: &str = "0000000000000000000000000000000000000000";
fn r(local_oid: &str, remote_ref: &str) -> PushRef {
PushRef {
local_ref: "refs/heads/whatever".into(),
local_oid: local_oid.into(),
remote_ref: remote_ref.into(),
remote_oid: "b".into(),
}
}
fn creating(remote_ref: &str) -> PushRef {
PushRef {
local_ref: "refs/heads/main".into(),
local_oid: "a".into(),
remote_ref: remote_ref.into(),
remote_oid: ZEROS.into(),
}
}
#[test]
fn blocks_main_and_master() {
assert_eq!(
run(&test_settings(), &[r("a", "refs/heads/main")]),
Outcome::Failed
);
assert_eq!(
run(&test_settings(), &[r("a", "refs/heads/master")]),
Outcome::Failed
);
}
#[test]
fn allows_any_other_branch() {
assert_eq!(
run(&test_settings(), &[r("a", "refs/heads/feat/x")]),
Outcome::Passed
);
assert_eq!(
run(&test_settings(), &[r("a", "refs/heads/maintenance")]),
Outcome::Passed
);
assert_eq!(
run(&test_settings(), &[r("a", "refs/heads/mainline")]),
Outcome::Passed
);
}
#[test]
fn allows_tags_even_named_main() {
assert_eq!(
run(&test_settings(), &[r("a", "refs/tags/main")]),
Outcome::Passed
);
}
#[test]
fn a_renamed_push_to_main_is_still_blocked() {
let mut p = r("a", "refs/heads/main");
p.local_ref = "refs/heads/my-feature".into();
assert_eq!(run(&test_settings(), &[p]), Outcome::Failed);
}
#[test]
fn a_branch_delete_is_blocked_too() {
assert_eq!(
run(
&test_settings(),
&[r(
"0000000000000000000000000000000000000000",
"refs/heads/main"
)]
),
Outcome::Failed
);
}
#[test]
fn no_refs_is_a_pass() {
assert_eq!(run(&test_settings(), &[]), Outcome::Passed);
}
#[test]
fn a_mixed_push_is_blocked() {
assert_eq!(
run(
&test_settings(),
&[r("a", "refs/heads/feat/x"), r("a", "refs/heads/main")]
),
Outcome::Failed
);
}
#[test]
fn creating_main_on_the_remote_is_allowed() {
assert_eq!(
run(&test_settings(), &[creating("refs/heads/main")]),
Outcome::Passed
);
assert_eq!(
run(&test_settings(), &[creating("refs/heads/master")]),
Outcome::Passed
);
}
#[test]
fn the_next_push_to_that_same_branch_is_refused() {
assert_eq!(
run(&test_settings(), &[r("a", "refs/heads/main")]),
Outcome::Failed
);
}
#[test]
fn a_creation_beside_a_real_update_still_fails() {
assert_eq!(
run(
&test_settings(),
&[creating("refs/heads/feat/x"), r("a", "refs/heads/main")]
),
Outcome::Failed
);
}
#[test]
fn a_delete_of_a_nonexistent_remote_branch_is_not_blocked() {
let mut p = creating("refs/heads/main");
p.local_oid = ZEROS.into();
assert_eq!(run(&test_settings(), &[p]), Outcome::Passed);
}
#[test]
fn an_empty_remote_oid_is_not_a_creation() {
let mut p = r("a", "refs/heads/main");
p.remote_oid = String::new();
assert!(!is_creation(&p));
assert_eq!(run(&test_settings(), &[p]), Outcome::Failed);
}
}