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