use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ref {
pub branch: Option<String>,
pub commit: Option<String>,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum RefParseError {
#[error("--ref value is empty or whitespace-only")]
Empty,
#[error("--ref value contains more than one `@` separator")]
MultipleAt,
#[error("--ref value has empty branch component before `@`")]
EmptyBranch,
#[error("--ref value has empty commit component after `@`")]
EmptyCommit,
#[error("--ref commit `{0}` must be 7..40 hex chars (0-9 a-f A-F)")]
InvalidCommit(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefAction {
Add,
AddSibling,
SilentReject,
WarnReject,
WarnAdd,
}
impl RefAction {
pub fn is_add(self) -> bool {
matches!(self, Self::Add | Self::AddSibling | Self::WarnAdd)
}
pub fn is_reject(self) -> bool {
matches!(self, Self::SilentReject | Self::WarnReject)
}
pub fn warns(self) -> bool {
matches!(self, Self::WarnAdd | Self::WarnReject)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct AddContext {
pub url_tracked: bool,
pub dup_hit: bool,
}
pub const DEFAULT_BRANCH: &str = "main";
pub const SHA_PREFIX_MIN: usize = 7;
pub const SHA_PREFIX_MAX: usize = 40;
pub fn parse_ref(token: &str) -> Result<Ref, RefParseError> {
let trimmed = token.trim();
if trimmed.is_empty() {
return Err(RefParseError::Empty);
}
let parts: Vec<&str> = trimmed.split('@').collect();
match parts.as_slice() {
[bare] => {
if is_hex_sha(bare) {
Ok(Ref { branch: None, commit: Some((*bare).to_string()) })
} else {
Ok(Ref { branch: Some((*bare).to_string()), commit: None })
}
}
[branch, commit] => {
if branch.is_empty() {
return Err(RefParseError::EmptyBranch);
}
if commit.is_empty() {
return Err(RefParseError::EmptyCommit);
}
if !is_hex_sha(commit) {
return Err(RefParseError::InvalidCommit((*commit).to_string()));
}
Ok(Ref { branch: Some((*branch).to_string()), commit: Some((*commit).to_string()) })
}
_ => Err(RefParseError::MultipleAt),
}
}
fn is_hex_sha(s: &str) -> bool {
let len = s.len();
if !(SHA_PREFIX_MIN..=SHA_PREFIX_MAX).contains(&len) {
return false;
}
s.chars().all(|c| c.is_ascii_hexdigit())
}
fn encode_branch(s: &str) -> String {
s.chars().map(|c| if c == '/' { '_' } else { c }).collect()
}
pub fn encode_refdir(r: &Ref) -> String {
encode_refdir_with_prefix(r, SHA_PREFIX_MIN)
}
pub fn encode_refdir_with_prefix(r: &Ref, prefix_len: usize) -> String {
let prefix_len = prefix_len.clamp(SHA_PREFIX_MIN, SHA_PREFIX_MAX);
let branch = match &r.branch {
Some(b) => encode_branch(b),
None => DEFAULT_BRANCH.to_string(),
};
match &r.commit {
None => branch,
Some(sha) => {
let take = prefix_len.min(sha.len());
let short: String = sha.chars().take(take).collect();
format!("{branch}@{short}")
}
}
}
pub fn resolve_unique_refdir(r: &Ref, existing: &HashSet<String>) -> String {
let Some(sha) = r.commit.as_ref() else {
return encode_refdir(r);
};
let max_len = sha.len().min(SHA_PREFIX_MAX);
let mut len = SHA_PREFIX_MIN.min(max_len);
if len < SHA_PREFIX_MIN {
len = max_len;
}
loop {
let candidate = encode_refdir_with_prefix(r, len);
if !existing.contains(&candidate) {
return candidate;
}
if len >= max_len {
return candidate;
}
len += 1;
}
}
pub fn classify_ref_input(r: &Ref, ctx: &AddContext) -> RefAction {
let b = r.branch.is_some();
let c = r.commit.is_some();
let u = ctx.url_tracked;
let dup = ctx.dup_hit;
match (b, c, u) {
(false, false, false) => RefAction::Add,
(false, false, true) => RefAction::SilentReject,
(true, false, false) => RefAction::Add,
(true, false, true) => {
if dup {
RefAction::SilentReject
} else {
RefAction::AddSibling
}
}
(false, true, false) => RefAction::Add,
(false, true, true) => {
if dup {
RefAction::WarnReject
} else {
RefAction::WarnAdd
}
}
(true, true, false) => RefAction::Add,
(true, true, true) => {
if dup {
RefAction::WarnReject
} else {
RefAction::WarnAdd
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_bare_branch() {
let r = parse_ref("main").unwrap();
assert_eq!(r, Ref { branch: Some("main".into()), commit: None });
}
#[test]
fn parse_bare_branch_with_slash() {
let r = parse_ref("feature/foo").unwrap();
assert_eq!(r, Ref { branch: Some("feature/foo".into()), commit: None });
}
#[test]
fn parse_bare_short_sha() {
let r = parse_ref("a3f9c1d").unwrap();
assert_eq!(r, Ref { branch: None, commit: Some("a3f9c1d".into()) });
}
#[test]
fn parse_bare_full_sha() {
let sha = "a3f9c1d2b8e7f6a5c4d3b2a1908f7e6d5c4b3a29";
let r = parse_ref(sha).unwrap();
assert_eq!(r, Ref { branch: None, commit: Some(sha.to_string()) });
}
#[test]
fn parse_branch_at_commit() {
let r = parse_ref("main@a3f9c1d").unwrap();
assert_eq!(r, Ref { branch: Some("main".into()), commit: Some("a3f9c1d".into()) });
}
#[test]
fn parse_empty_rejected() {
assert_eq!(parse_ref(""), Err(RefParseError::Empty));
assert_eq!(parse_ref(" "), Err(RefParseError::Empty));
}
#[test]
fn parse_double_at_rejected() {
assert_eq!(parse_ref("a@b@c"), Err(RefParseError::MultipleAt));
}
#[test]
fn parse_empty_branch_rejected() {
assert_eq!(parse_ref("@a3f9c1d"), Err(RefParseError::EmptyBranch));
}
#[test]
fn parse_empty_commit_rejected() {
assert_eq!(parse_ref("main@"), Err(RefParseError::EmptyCommit));
}
#[test]
fn parse_invalid_commit_rejected() {
assert!(matches!(parse_ref("main@notahex"), Err(RefParseError::InvalidCommit(_))));
assert!(matches!(parse_ref("main@abc"), Err(RefParseError::InvalidCommit(_))));
}
#[test]
fn parse_short_six_char_branch_not_misread_as_sha() {
let r = parse_ref("abcdef").unwrap();
assert_eq!(r, Ref { branch: Some("abcdef".into()), commit: None });
}
#[test]
fn encode_bare_branch() {
let r = Ref { branch: Some("main".into()), commit: None };
assert_eq!(encode_refdir(&r), "main");
}
#[test]
fn encode_branch_with_slash_replaced() {
let r = Ref { branch: Some("feature/foo".into()), commit: None };
assert_eq!(encode_refdir(&r), "feature_foo");
}
#[test]
fn encode_no_branch_no_commit_defaults_to_main() {
let r = Ref { branch: None, commit: None };
assert_eq!(encode_refdir(&r), "main");
}
#[test]
fn encode_bare_commit_uses_main_at_short() {
let r = Ref { branch: None, commit: Some("a3f9c1d2b8e7".into()) };
assert_eq!(encode_refdir(&r), "main@a3f9c1d");
}
#[test]
fn encode_branch_at_commit_short_seven() {
let r = Ref { branch: Some("main".into()), commit: Some("a3f9c1d2b8e7".into()) };
assert_eq!(encode_refdir(&r), "main@a3f9c1d");
}
#[test]
fn encode_with_extended_prefix() {
let r = Ref { branch: Some("main".into()), commit: Some("a3f9c1d2b8e7".into()) };
assert_eq!(encode_refdir_with_prefix(&r, 8), "main@a3f9c1d2");
assert_eq!(encode_refdir_with_prefix(&r, 12), "main@a3f9c1d2b8e7");
}
#[test]
fn encode_prefix_clamps_to_min_max() {
let r = Ref { branch: Some("main".into()), commit: Some("a".repeat(40)) };
assert_eq!(encode_refdir_with_prefix(&r, 3).len(), "main@".len() + 7);
assert_eq!(encode_refdir_with_prefix(&r, 100).len(), "main@".len() + 40);
}
#[test]
fn resolve_no_collision_returns_seven_char_prefix() {
let r = Ref { branch: Some("main".into()), commit: Some("a3f9c1d2b8e7".into()) };
let existing: HashSet<String> = HashSet::new();
assert_eq!(resolve_unique_refdir(&r, &existing), "main@a3f9c1d");
}
#[test]
fn resolve_collision_extends_one_char() {
let r = Ref { branch: Some("main".into()), commit: Some("a3f9c1d2b8e7".into()) };
let mut existing = HashSet::new();
existing.insert("main@a3f9c1d".to_string());
assert_eq!(resolve_unique_refdir(&r, &existing), "main@a3f9c1d2");
}
#[test]
fn resolve_collision_extends_multiple_chars() {
let r = Ref { branch: Some("main".into()), commit: Some("a3f9c1d2b8e7".into()) };
let mut existing = HashSet::new();
existing.insert("main@a3f9c1d".to_string());
existing.insert("main@a3f9c1d2".to_string());
existing.insert("main@a3f9c1d2b".to_string());
assert_eq!(resolve_unique_refdir(&r, &existing), "main@a3f9c1d2b8");
}
#[test]
fn resolve_bare_branch_no_extension() {
let r = Ref { branch: Some("main".into()), commit: None };
let mut existing = HashSet::new();
existing.insert("main".to_string());
assert_eq!(resolve_unique_refdir(&r, &existing), "main");
}
#[test]
fn resolve_full_sha_collision_returns_full() {
let r = Ref { branch: Some("main".into()), commit: Some("a3f9c1d2b8e7".into()) };
let mut existing = HashSet::new();
for n in SHA_PREFIX_MIN..=12 {
existing.insert(encode_refdir_with_prefix(&r, n));
}
assert_eq!(resolve_unique_refdir(&r, &existing), "main@a3f9c1d2b8e7");
}
fn br(name: &str) -> Ref {
Ref { branch: Some(name.into()), commit: None }
}
fn co(sha: &str) -> Ref {
Ref { branch: None, commit: Some(sha.into()) }
}
fn br_co(name: &str, sha: &str) -> Ref {
Ref { branch: Some(name.into()), commit: Some(sha.into()) }
}
#[test]
fn fa_cell_1_b0_c0_u0_add() {
let r = Ref { branch: None, commit: None };
let ctx = AddContext { url_tracked: false, dup_hit: false };
assert_eq!(classify_ref_input(&r, &ctx), RefAction::Add);
}
#[test]
fn fa_cell_2_b0_c0_u1_silent_reject() {
let r = Ref { branch: None, commit: None };
let ctx = AddContext { url_tracked: true, dup_hit: false };
assert_eq!(classify_ref_input(&r, &ctx), RefAction::SilentReject);
}
#[test]
fn fa_cell_3_b1_c0_u0_add() {
let r = br("feature/foo");
let ctx = AddContext { url_tracked: false, dup_hit: false };
assert_eq!(classify_ref_input(&r, &ctx), RefAction::Add);
}
#[test]
fn fa_cell_4_b1_c0_u1_dup_silent_reject() {
let r = br("main");
let ctx = AddContext { url_tracked: true, dup_hit: true };
assert_eq!(classify_ref_input(&r, &ctx), RefAction::SilentReject);
}
#[test]
fn fa_cell_4_b1_c0_u1_no_dup_add_sibling() {
let r = br("develop");
let ctx = AddContext { url_tracked: true, dup_hit: false };
assert_eq!(classify_ref_input(&r, &ctx), RefAction::AddSibling);
}
#[test]
fn fa_cell_5_b0_c1_u0_add() {
let r = co("a3f9c1d");
let ctx = AddContext { url_tracked: false, dup_hit: false };
assert_eq!(classify_ref_input(&r, &ctx), RefAction::Add);
}
#[test]
fn fa_cell_6_b0_c1_u1_dup_warn_reject() {
let r = co("a3f9c1d");
let ctx = AddContext { url_tracked: true, dup_hit: true };
assert_eq!(classify_ref_input(&r, &ctx), RefAction::WarnReject);
}
#[test]
fn fa_cell_6_b0_c1_u1_no_dup_warn_add() {
let r = co("a3f9c1d");
let ctx = AddContext { url_tracked: true, dup_hit: false };
assert_eq!(classify_ref_input(&r, &ctx), RefAction::WarnAdd);
}
#[test]
fn fa_cell_7_b1_c1_u0_add() {
let r = br_co("main", "a3f9c1d");
let ctx = AddContext { url_tracked: false, dup_hit: false };
assert_eq!(classify_ref_input(&r, &ctx), RefAction::Add);
}
#[test]
fn fa_cell_8_b1_c1_u1_dup_warn_reject() {
let r = br_co("main", "a3f9c1d");
let ctx = AddContext { url_tracked: true, dup_hit: true };
assert_eq!(classify_ref_input(&r, &ctx), RefAction::WarnReject);
}
#[test]
fn fa_cell_8_b1_c1_u1_no_dup_warn_add() {
let r = br_co("main", "a3f9c1d");
let ctx = AddContext { url_tracked: true, dup_hit: false };
assert_eq!(classify_ref_input(&r, &ctx), RefAction::WarnAdd);
}
#[test]
fn ref_action_predicates_partition() {
for a in [
RefAction::Add,
RefAction::AddSibling,
RefAction::WarnAdd,
RefAction::SilentReject,
RefAction::WarnReject,
] {
assert_ne!(a.is_add(), a.is_reject(), "action {a:?}");
}
assert!(RefAction::WarnAdd.warns());
assert!(RefAction::WarnReject.warns());
assert!(!RefAction::Add.warns());
assert!(!RefAction::AddSibling.warns());
assert!(!RefAction::SilentReject.warns());
}
#[test]
fn dup_safe_idempotent_on_identical_re_add() {
let cases: [(Ref, AddContext); 4] = [
(Ref { branch: None, commit: None }, AddContext { url_tracked: true, dup_hit: false }), (br("main"), AddContext { url_tracked: true, dup_hit: true }), (co("a3f9c1d"), AddContext { url_tracked: true, dup_hit: true }), (br_co("main", "a3f9c1d"), AddContext { url_tracked: true, dup_hit: true }), ];
for (r, ctx) in &cases {
let action = classify_ref_input(r, ctx);
assert!(action.is_reject(), "expected reject for {r:?} {ctx:?}, got {action:?}");
}
}
}