Skip to main content

thoughts_tool/git/
ref_key.rs

1use anyhow::Result;
2use anyhow::bail;
3use sha2::Digest;
4use sha2::Sha256;
5
6const MAX_REF_KEY_LEN: usize = 120;
7const HASH_HEX_LEN: usize = 16;
8const PREFIX: &str = "r-";
9
10pub fn encode_ref_key(ref_name: &str) -> Result<String> {
11    let ref_name = ref_name.trim();
12    if ref_name.is_empty() {
13        bail!("Reference name cannot be empty");
14    }
15    if ref_name.contains('\0') {
16        bail!("Reference name cannot contain NUL bytes");
17    }
18    if looks_like_raw_git_oid(ref_name) {
19        bail!("Raw commit SHAs are not supported; provide a named ref instead");
20    }
21
22    let mut encoded = String::with_capacity(ref_name.len() + PREFIX.len());
23    encoded.push_str(PREFIX);
24    for byte in ref_name.bytes() {
25        match byte {
26            b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => encoded.push(byte as char),
27            _ => push_escape(&mut encoded, byte),
28        }
29    }
30
31    if encoded.len() <= MAX_REF_KEY_LEN {
32        return Ok(encoded);
33    }
34
35    let hash = short_hash_hex(ref_name.as_bytes());
36    let suffix = format!("--{hash}");
37    let keep = MAX_REF_KEY_LEN
38        .checked_sub(PREFIX.len() + suffix.len())
39        .ok_or_else(|| anyhow::anyhow!("Invalid ref key length configuration"))?;
40
41    let mut truncated = String::with_capacity(MAX_REF_KEY_LEN);
42    truncated.push_str(PREFIX);
43    truncated.push_str(&truncate_on_char_boundary(&encoded[PREFIX.len()..], keep));
44    truncated.push_str(&suffix);
45    Ok(truncated)
46}
47
48fn looks_like_raw_git_oid(value: &str) -> bool {
49    matches!(value.len(), 40 | 64) && value.bytes().all(|b| b.is_ascii_hexdigit())
50}
51
52fn push_escape(out: &mut String, byte: u8) {
53    const HEX: &[u8; 16] = b"0123456789abcdef";
54    out.push('~');
55    out.push(HEX[(byte >> 4) as usize] as char);
56    out.push(HEX[(byte & 0x0f) as usize] as char);
57}
58
59fn short_hash_hex(bytes: &[u8]) -> String {
60    let digest = Sha256::digest(bytes);
61    digest[..(HASH_HEX_LEN / 2)]
62        .iter()
63        .map(|byte| format!("{byte:02x}"))
64        .collect()
65}
66
67fn truncate_on_char_boundary(value: &str, max_len: usize) -> String {
68    if value.len() <= max_len {
69        return value.to_string();
70    }
71
72    let mut end = max_len;
73    while !value.is_char_boundary(end) {
74        end -= 1;
75    }
76    value[..end].to_string()
77}
78
79#[cfg(test)]
80mod tests {
81    use super::MAX_REF_KEY_LEN;
82    use super::encode_ref_key;
83
84    #[test]
85    fn encodes_safe_ascii_directly() {
86        assert_eq!(encode_ref_key("main").unwrap(), "r-main");
87        assert_eq!(encode_ref_key("release-1.2_3").unwrap(), "r-release-1.2_3");
88    }
89
90    #[test]
91    fn escapes_slashes_and_uppercase_for_case_safe_segments() {
92        assert_eq!(encode_ref_key("feature/foo").unwrap(), "r-feature~2ffoo");
93        assert_eq!(encode_ref_key("Main").unwrap(), "r-~4dain");
94    }
95
96    #[test]
97    fn rejects_empty_nul_and_raw_sha_values() {
98        assert!(encode_ref_key("   ").is_err());
99        assert!(encode_ref_key("abc\0def").is_err());
100        assert!(encode_ref_key("0123456789abcdef0123456789abcdef01234567").is_err());
101    }
102
103    #[test]
104    fn truncates_long_values_with_stable_structure() {
105        let input = "refs/heads/".to_string() + &"very-long-".repeat(40);
106        let out = encode_ref_key(&input).unwrap();
107
108        assert!(out.starts_with("r-"), "must retain r- prefix");
109        assert!(
110            out.len() <= MAX_REF_KEY_LEN,
111            "must be bounded by MAX_REF_KEY_LEN"
112        );
113
114        let (_prefix_part, hash) = out.rsplit_once("--").expect("expected --{hash} suffix");
115        assert_eq!(hash.len(), 16, "expected 16 hex chars");
116        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
117    }
118
119    #[test]
120    fn truncation_hash_makes_long_keys_unique() {
121        let base = "refs/heads/".to_string() + &"a".repeat(300);
122        let a = format!("{}-x", base);
123        let b = format!("{}-y", base);
124
125        let ka = encode_ref_key(&a).unwrap();
126        let kb = encode_ref_key(&b).unwrap();
127        assert_ne!(ka, kb, "distinct long refs must not collide");
128    }
129}