A file-based task manager
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at noah/git-backend-2 271 lines 8.7 kB view raw
1//! A namespace: a tree mapping human-readable ids → task stable ids. 2//! 3//! Stored as a commit chain at `refs/tsk/namespaces/<name>`. Tree layout: 4//! next → blob: next human id to allocate, e.g. "5\n" 5//! ids/<human-id> → blob: 40-hex stable id 6//! 7//! The default namespace is `tsk`. Namespaces are self-contained (no 8//! cross-namespace references at this layer). 9 10use crate::errors::{Error, Result}; 11use crate::object::{self, StableId}; 12use git2::{Oid, Repository}; 13use std::collections::BTreeMap; 14 15pub const NS_REF_PREFIX: &str = "refs/tsk/namespaces/"; 16pub const DEFAULT_NS: &str = "tsk"; 17const NEXT_FILE: &str = "next"; 18const IDS_DIR: &str = "ids"; 19 20pub fn refname(name: &str) -> String { 21 format!("{NS_REF_PREFIX}{name}") 22} 23 24pub fn validate_name(name: &str) -> Result<()> { 25 if name.is_empty() { 26 return Err(Error::Parse("Namespace name cannot be empty".into())); 27 } 28 if !name 29 .chars() 30 .all(|c| c.is_alphanumeric() || c == '_' || c == '-') 31 { 32 return Err(Error::Parse(format!( 33 "Namespace '{name}' must contain only alphanumerics, '-', or '_'" 34 ))); 35 } 36 Ok(()) 37} 38 39#[derive(Clone, Debug, Default, Eq, PartialEq)] 40pub struct Namespace { 41 pub next: u32, 42 /// human id → stable id 43 pub mapping: BTreeMap<u32, StableId>, 44} 45 46pub fn read(repo: &Repository, name: &str) -> Result<Namespace> { 47 let Ok(r) = repo.find_reference(&refname(name)) else { 48 return Ok(Namespace { 49 next: 1, 50 mapping: BTreeMap::new(), 51 }); 52 }; 53 let Some(target) = r.target() else { 54 return Ok(Namespace { 55 next: 1, 56 mapping: BTreeMap::new(), 57 }); 58 }; 59 read_at_commit(repo, target) 60} 61 62/// Read a namespace from the tree of a specific commit (rather than from 63/// the active ref). Used by the namespace merge driver to compare local 64/// and fetched-remote tips. 65pub fn read_at_commit(repo: &Repository, commit_oid: Oid) -> Result<Namespace> { 66 let tree = repo.find_commit(commit_oid)?.tree()?; 67 let mut ns = Namespace { 68 next: 1, 69 mapping: BTreeMap::new(), 70 }; 71 if let Some(entry) = tree.get_name(NEXT_FILE) { 72 let blob = entry.to_object(repo)?.peel_to_blob()?; 73 ns.next = String::from_utf8_lossy(blob.content()) 74 .trim() 75 .parse() 76 .unwrap_or(1); 77 } 78 if let Some(ids_entry) = tree.get_name(IDS_DIR) { 79 let ids_tree = ids_entry.to_object(repo)?.peel_to_tree()?; 80 for e in ids_tree.iter() { 81 let Some(name) = e.name() else { continue }; 82 let Ok(human) = name.parse::<u32>() else { 83 continue; 84 }; 85 let blob = e.to_object(repo)?.peel_to_blob()?; 86 let stable = String::from_utf8_lossy(blob.content()).trim().to_string(); 87 if !stable.is_empty() { 88 ns.mapping.insert(human, StableId(stable)); 89 } 90 } 91 } 92 Ok(ns) 93} 94 95pub fn build_tree(repo: &Repository, ns: &Namespace) -> Result<Oid> { 96 let mut ids_tb = repo.treebuilder(None)?; 97 for (human, stable) in &ns.mapping { 98 let oid = repo.blob(stable.0.as_bytes())?; 99 ids_tb.insert(human.to_string().as_str(), oid, 0o100644)?; 100 } 101 let ids_oid = ids_tb.write()?; 102 103 let mut tb = repo.treebuilder(None)?; 104 let next_oid = repo.blob(format!("{}\n", ns.next).as_bytes())?; 105 tb.insert(NEXT_FILE, next_oid, 0o100644)?; 106 tb.insert(IDS_DIR, ids_oid, 0o040000)?; 107 Ok(tb.write()?) 108} 109 110pub fn write(repo: &Repository, name: &str, ns: &Namespace, message: &str) -> Result<()> { 111 validate_name(name)?; 112 let tree_oid = build_tree(repo, ns)?; 113 let parent = repo 114 .find_reference(&refname(name)) 115 .ok() 116 .and_then(|r| r.target()) 117 .and_then(|o| repo.find_commit(o).ok()); 118 if let Some(p) = &parent 119 && p.tree_id() == tree_oid 120 { 121 return Ok(()); 122 } 123 let sig = object::signature(repo); 124 let parents: Vec<&git2::Commit> = parent.iter().collect(); 125 let commit = repo.commit( 126 None, 127 &sig, 128 &sig, 129 message, 130 &repo.find_tree(tree_oid)?, 131 &parents, 132 )?; 133 repo.reference(&refname(name), commit, true, message)?; 134 Ok(()) 135} 136 137/// Allocate the next human id, insert the binding, and persist. Returns the 138/// human id assigned. 139pub fn assign_id( 140 repo: &Repository, 141 name: &str, 142 stable: StableId, 143 message: &str, 144) -> Result<u32> { 145 let mut ns = read(repo, name)?; 146 let human = ns.next; 147 ns.next += 1; 148 ns.mapping.insert(human, stable); 149 write(repo, name, &ns, &format!("{message} {name}-{human}"))?; 150 Ok(human) 151} 152 153#[allow(dead_code)] // kept for future "tsk forget" / hard-delete command 154pub fn unassign_id(repo: &Repository, name: &str, human: u32, message: &str) -> Result<()> { 155 let mut ns = read(repo, name)?; 156 if ns.mapping.remove(&human).is_some() { 157 write(repo, name, &ns, &format!("{message} {name}-{human}"))?; 158 } 159 Ok(()) 160} 161 162pub fn list_names(repo: &Repository) -> Result<Vec<String>> { 163 let mut out = Vec::new(); 164 for r in repo.references_glob(&format!("{NS_REF_PREFIX}*"))? { 165 let r = r?; 166 if let Some(name) = r.name() 167 && let Some(rest) = name.strip_prefix(NS_REF_PREFIX) 168 { 169 out.push(rest.to_string()); 170 } 171 } 172 out.sort(); 173 Ok(out) 174} 175 176pub fn lookup(repo: &Repository, name: &str, human: u32) -> Result<Option<StableId>> { 177 Ok(read(repo, name)?.mapping.get(&human).cloned()) 178} 179 180/// Existing human id for `stable` in `name`, or a freshly-assigned one. 181pub fn ensure_bound( 182 repo: &Repository, 183 name: &str, 184 stable: StableId, 185 message: &str, 186) -> Result<u32> { 187 match human_for(repo, name, &stable)? { 188 Some(h) => Ok(h), 189 None => assign_id(repo, name, stable, message), 190 } 191} 192 193/// Reverse lookup: stable → human in the given namespace, if present. 194pub fn human_for(repo: &Repository, name: &str, stable: &StableId) -> Result<Option<u32>> { 195 Ok(read(repo, name)? 196 .mapping 197 .iter() 198 .find(|(_, s)| *s == stable) 199 .map(|(h, _)| *h)) 200} 201 202#[cfg(test)] 203mod test { 204 use super::*; 205 use crate::object; 206 207 fn init_repo(p: &std::path::Path) -> Repository { 208 let r = Repository::init(p).unwrap(); 209 let mut cfg = r.config().unwrap(); 210 cfg.set_str("user.name", "T").unwrap(); 211 cfg.set_str("user.email", "t@e").unwrap(); 212 r 213 } 214 215 #[test] 216 fn empty_namespace_reads_as_default() { 217 let dir = tempfile::tempdir().unwrap(); 218 let repo = init_repo(dir.path()); 219 let ns = read(&repo, "tsk").unwrap(); 220 assert_eq!(ns.next, 1); 221 assert!(ns.mapping.is_empty()); 222 } 223 224 #[test] 225 fn assign_then_round_trip() { 226 let dir = tempfile::tempdir().unwrap(); 227 let repo = init_repo(dir.path()); 228 let s1 = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 229 let s2 = object::create(&repo, &object::Task::new("b"), "c").unwrap(); 230 let h1 = assign_id(&repo, "tsk", s1.clone(), "assign").unwrap(); 231 let h2 = assign_id(&repo, "tsk", s2.clone(), "assign").unwrap(); 232 assert_eq!(h1, 1); 233 assert_eq!(h2, 2); 234 let ns = read(&repo, "tsk").unwrap(); 235 assert_eq!(ns.next, 3); 236 assert_eq!(ns.mapping.get(&1), Some(&s1)); 237 assert_eq!(ns.mapping.get(&2), Some(&s2)); 238 assert_eq!(human_for(&repo, "tsk", &s1).unwrap(), Some(1)); 239 } 240 241 #[test] 242 fn unassign_removes_only_mapping_keeps_next() { 243 let dir = tempfile::tempdir().unwrap(); 244 let repo = init_repo(dir.path()); 245 let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 246 let _ = assign_id(&repo, "tsk", s, "assign").unwrap(); 247 unassign_id(&repo, "tsk", 1, "drop").unwrap(); 248 let ns = read(&repo, "tsk").unwrap(); 249 assert!(ns.mapping.is_empty()); 250 assert_eq!(ns.next, 2, "next must monotonically grow"); 251 } 252 253 #[test] 254 fn list_names_returns_known_namespaces() { 255 let dir = tempfile::tempdir().unwrap(); 256 let repo = init_repo(dir.path()); 257 let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 258 let _ = assign_id(&repo, "tsk", s.clone(), "assign").unwrap(); 259 let _ = assign_id(&repo, "alpha", s, "assign").unwrap(); 260 let mut names = list_names(&repo).unwrap(); 261 names.sort(); 262 assert_eq!(names, vec!["alpha".to_string(), "tsk".to_string()]); 263 } 264 265 #[test] 266 fn validate_name_rejects_bad_input() { 267 assert!(validate_name("").is_err()); 268 assert!(validate_name("a/b").is_err()); 269 assert!(validate_name("ok-name_1").is_ok()); 270 } 271}