Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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}");
            }
        }
    }
}