crates/gunnar-ui-web/src/default_branch.rs
//! **What the default-branch control decides** (owner, 2026-10-14: Settings
//! showed "Default branch ny_dok" as text with no way to change it), on the
//! host side of the wasm boundary. Same argument as [`crate::room`].
//!
//! The choices are the repository's own branches, read from the references
//! the page already has; the PATCH sends the full ref
//! (`{"default_branch":"refs/heads/main"}`), because gunnar refuses a short
//! name rather than guess one. Who may change it is gunnar's answer
//! (`Repos.SetDefaultBranch` needs Admin on the repository, asked as the
//! person): nothing here decides that, it only says the answer in plain words.
/// The branches the control offers: the short names of `refs/heads/*`, sorted,
/// each once. Tags and everything else are not branches.
pub fn choices<'a>(refs: impl IntoIterator<Item = &'a str>) -> Vec<String> {
let mut names: Vec<String> = refs
.into_iter()
.filter_map(|r| r.strip_prefix("refs/heads/"))
.filter(|b| !b.is_empty())
.map(str::to_owned)
.collect();
names.sort();
names.dedup();
names
}
/// What the PATCH sends for the branch `short` (`main` → `refs/heads/main`).
pub fn full_ref(short: &str) -> String {
if short.starts_with("refs/") {
short.to_owned()
} else {
format!("refs/heads/{short}")
}
}
/// Whether Save has anything to send: a branch is chosen and it is not the
/// one already set.
pub fn changes(current_full: &str, chosen_short: &str) -> bool {
!chosen_short.is_empty() && full_ref(chosen_short) != current_full
}
/// The line after a successful change.
pub fn done(branch_full: &str) -> String {
format!("The default branch is now {}. A new clone checks it out.", crate::fmt::short_ref(branch_full))
}
/// The line after a refusal, in plain words; gunnar's own sentence is shown
/// under it unchanged.
pub fn refused(status: u16, code: &str, path: &str) -> String {
match (status, code) {
(403, _) => format!("Not changed: only an admin of {path} may change its default branch."),
(401, _) => "Not changed: your session has ended. Sign in again and retry.".to_owned(),
(404, _) => format!("Not changed: Gunnar does not know {path}."),
(400, _) | (_, "invalidargument") => "Not changed: Gunnar does not accept that branch name.".to_owned(),
(0, _) => "Not changed: the console could not reach Gunnar. Try again.".to_owned(),
_ => "Not changed: Gunnar could not make the change just now. Try again in a while.".to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_choices_are_the_branches_by_short_name_and_nothing_else() {
let refs = ["refs/heads/ny_dok", "refs/tags/v1", "refs/heads/main", "HEAD", "refs/heads/feat/x", "refs/heads/main"];
assert_eq!(choices(refs), vec!["feat/x".to_owned(), "main".to_owned(), "ny_dok".to_owned()]);
assert!(choices(["refs/tags/v1"]).is_empty());
}
#[test]
fn the_patch_sends_the_full_ref() {
assert_eq!(full_ref("main"), "refs/heads/main");
assert_eq!(full_ref("feat/x"), "refs/heads/feat/x");
assert_eq!(full_ref("refs/heads/main"), "refs/heads/main");
}
#[test]
fn save_has_something_to_send_only_for_another_branch() {
assert!(!changes("refs/heads/ny_dok", "ny_dok"), "the branch already set is no change");
assert!(!changes("refs/heads/ny_dok", ""), "nothing chosen is no change");
assert!(changes("refs/heads/ny_dok", "main"));
}
#[test]
fn the_answers_are_plain_words() {
assert_eq!(done("refs/heads/main"), "The default branch is now main. A new clone checks it out.");
let no = refused(403, "permissiondenied", "acme/app");
assert!(no.contains("only an admin of acme/app"), "{no}");
for (status, code) in [(401, ""), (404, "notfound"), (400, "invalidargument"), (0, ""), (502, "internal")] {
let line = refused(status, code, "acme/app");
assert!(line.starts_with("Not changed: "), "{status}: {line}");
for jargon in ["HTTP", "grpc", "Status", "PermissionDenied"] {
assert!(!line.contains(jargon), "{status}: {line}");
}
}
}
}