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 299 lines 9.7 kB view raw
1//! A queue: an ordered list of tasks plus an inbox of pending assignments. 2//! 3//! Stored as a commit chain at `refs/tsk/queues/<name>`. Tree layout: 4//! index → blob: ordered stable ids (one per line, top-of-stack first) 5//! can-pull → blob: "true" or "false" (defaults: true for `tsk`, false otherwise) 6//! inbox/<src>-<n> → blob: stable id of a task assigned by queue <src> 7//! 8//! Queues are pushed/shared (refspec `refs/tsk/*`). The active queue per-user 9//! is selected by `<git-dir>/tsk/queue`. 10 11use crate::errors::{Error, Result}; 12use crate::object::{self, StableId}; 13use git2::{Oid, Repository}; 14use std::collections::BTreeMap; 15 16pub const QUEUE_REF_PREFIX: &str = "refs/tsk/queues/"; 17pub const DEFAULT_QUEUE: &str = "tsk"; 18const INDEX_FILE: &str = "index"; 19const CAN_PULL_FILE: &str = "can-pull"; 20const INBOX_DIR: &str = "inbox"; 21 22pub fn refname(name: &str) -> String { 23 format!("{QUEUE_REF_PREFIX}{name}") 24} 25 26pub fn validate_name(name: &str) -> Result<()> { 27 if name.is_empty() { 28 return Err(Error::Parse("Queue name cannot be empty".into())); 29 } 30 if !name 31 .chars() 32 .all(|c| c.is_alphanumeric() || c == '_' || c == '-') 33 { 34 return Err(Error::Parse(format!( 35 "Queue '{name}' must contain only alphanumerics, '-', or '_'" 36 ))); 37 } 38 Ok(()) 39} 40 41#[derive(Clone, Debug, Eq, PartialEq)] 42pub struct Queue { 43 /// top-of-stack first 44 pub index: Vec<StableId>, 45 pub can_pull: bool, 46 /// inbox key → stable id of the task being offered 47 pub inbox: BTreeMap<String, StableId>, 48} 49 50impl Queue { 51 pub fn new(name: &str) -> Self { 52 Self { 53 index: Vec::new(), 54 can_pull: name == DEFAULT_QUEUE, 55 inbox: BTreeMap::new(), 56 } 57 } 58} 59 60pub fn read(repo: &Repository, name: &str) -> Result<Queue> { 61 let Some(target) = repo.find_reference(&refname(name)).ok().and_then(|r| r.target()) else { 62 return Ok(Queue::new(name)); 63 }; 64 read_at_commit(repo, name, target) 65} 66 67/// Read a queue from the tree of a specific commit (used by the queue 68/// merge driver to compare local and fetched-remote tips). 69pub fn read_at_commit(repo: &Repository, name: &str, commit_oid: Oid) -> Result<Queue> { 70 let tree = repo.find_commit(commit_oid)?.tree()?; 71 let mut q = Queue::new(name); 72 if let Some(e) = tree.get_name(INDEX_FILE) { 73 let blob = e.to_object(repo)?.peel_to_blob()?; 74 q.index = String::from_utf8_lossy(blob.content()) 75 .lines() 76 .filter(|l| !l.trim().is_empty()) 77 .map(|l| StableId(l.trim().to_string())) 78 .collect(); 79 } 80 if let Some(e) = tree.get_name(CAN_PULL_FILE) { 81 let blob = e.to_object(repo)?.peel_to_blob()?; 82 q.can_pull = String::from_utf8_lossy(blob.content()).trim() == "true"; 83 } 84 if let Some(e) = tree.get_name(INBOX_DIR) { 85 let inbox_tree = e.to_object(repo)?.peel_to_tree()?; 86 for ie in inbox_tree.iter() { 87 let Some(name) = ie.name() else { continue }; 88 let blob = ie.to_object(repo)?.peel_to_blob()?; 89 let stable = String::from_utf8_lossy(blob.content()).trim().to_string(); 90 if !stable.is_empty() { 91 q.inbox.insert(name.to_string(), StableId(stable)); 92 } 93 } 94 } 95 Ok(q) 96} 97 98pub fn build_tree(repo: &Repository, q: &Queue) -> Result<Oid> { 99 let mut tb = repo.treebuilder(None)?; 100 let index_text: String = q 101 .index 102 .iter() 103 .map(|s| format!("{}\n", s.0)) 104 .collect(); 105 let index_oid = repo.blob(index_text.as_bytes())?; 106 tb.insert(INDEX_FILE, index_oid, 0o100644)?; 107 let cp = if q.can_pull { "true\n" } else { "false\n" }; 108 let cp_oid = repo.blob(cp.as_bytes())?; 109 tb.insert(CAN_PULL_FILE, cp_oid, 0o100644)?; 110 if !q.inbox.is_empty() { 111 let mut ib = repo.treebuilder(None)?; 112 for (k, v) in &q.inbox { 113 let oid = repo.blob(v.0.as_bytes())?; 114 ib.insert(k.as_str(), oid, 0o100644)?; 115 } 116 let ib_oid = ib.write()?; 117 tb.insert(INBOX_DIR, ib_oid, 0o040000)?; 118 } 119 Ok(tb.write()?) 120} 121 122pub fn write(repo: &Repository, name: &str, q: &Queue, message: &str) -> Result<()> { 123 validate_name(name)?; 124 let tree_oid = build_tree(repo, q)?; 125 let parent = repo 126 .find_reference(&refname(name)) 127 .ok() 128 .and_then(|r| r.target()) 129 .and_then(|o| repo.find_commit(o).ok()); 130 if let Some(p) = &parent 131 && p.tree_id() == tree_oid 132 { 133 return Ok(()); 134 } 135 let sig = object::signature(repo); 136 let parents: Vec<&git2::Commit> = parent.iter().collect(); 137 let commit = repo.commit( 138 None, 139 &sig, 140 &sig, 141 message, 142 &repo.find_tree(tree_oid)?, 143 &parents, 144 )?; 145 repo.reference(&refname(name), commit, true, message)?; 146 Ok(()) 147} 148 149pub fn list_names(repo: &Repository) -> Result<Vec<String>> { 150 let mut out = Vec::new(); 151 for r in repo.references_glob(&format!("{QUEUE_REF_PREFIX}*"))? { 152 let r = r?; 153 if let Some(name) = r.name() 154 && let Some(rest) = name.strip_prefix(QUEUE_REF_PREFIX) 155 { 156 out.push(rest.to_string()); 157 } 158 } 159 out.sort(); 160 Ok(out) 161} 162 163/// Push `stable` onto the top of `name`'s index. Idempotent: if already 164/// present, moves it to the top. 165pub fn push_top(repo: &Repository, name: &str, stable: StableId, message: &str) -> Result<()> { 166 let mut q = read(repo, name)?; 167 q.index.retain(|s| s != &stable); 168 q.index.insert(0, stable); 169 write(repo, name, &q, message) 170} 171 172pub fn push_bottom( 173 repo: &Repository, 174 name: &str, 175 stable: StableId, 176 message: &str, 177) -> Result<()> { 178 let mut q = read(repo, name)?; 179 q.index.retain(|s| s != &stable); 180 q.index.push(stable); 181 write(repo, name, &q, message) 182} 183 184pub fn remove(repo: &Repository, name: &str, stable: &StableId, message: &str) -> Result<bool> { 185 let mut q = read(repo, name)?; 186 let len = q.index.len(); 187 q.index.retain(|s| s != stable); 188 if q.index.len() == len { 189 return Ok(false); 190 } 191 write(repo, name, &q, message)?; 192 Ok(true) 193} 194 195/// Stable inbox key for a `(source-queue, sequence)` pair so re-assigns 196/// overwrite the same slot rather than piling up. Sequence is the per-source 197/// counter the caller maintains; we don't try to compute it here. 198pub fn inbox_key(src_queue: &str, src_seq: u32) -> String { 199 format!("{src_queue}-{src_seq}") 200} 201 202pub fn add_to_inbox( 203 repo: &Repository, 204 name: &str, 205 key: String, 206 stable: StableId, 207 message: &str, 208) -> Result<()> { 209 let mut q = read(repo, name)?; 210 q.inbox.insert(key, stable); 211 write(repo, name, &q, message) 212} 213 214pub fn take_from_inbox( 215 repo: &Repository, 216 name: &str, 217 key: &str, 218 message: &str, 219) -> Result<Option<StableId>> { 220 let mut q = read(repo, name)?; 221 let Some(stable) = q.inbox.remove(key) else { 222 return Ok(None); 223 }; 224 write(repo, name, &q, message)?; 225 Ok(Some(stable)) 226} 227 228#[cfg(test)] 229mod test { 230 use super::*; 231 use crate::object; 232 233 fn init_repo(p: &std::path::Path) -> Repository { 234 let r = Repository::init(p).unwrap(); 235 let mut cfg = r.config().unwrap(); 236 cfg.set_str("user.name", "T").unwrap(); 237 cfg.set_str("user.email", "t@e").unwrap(); 238 r 239 } 240 241 #[test] 242 fn empty_default_queue_is_can_pull() { 243 let dir = tempfile::tempdir().unwrap(); 244 let repo = init_repo(dir.path()); 245 let q = read(&repo, "tsk").unwrap(); 246 assert!(q.can_pull); 247 assert!(q.index.is_empty()); 248 } 249 250 #[test] 251 fn empty_named_queue_defaults_no_pull() { 252 let dir = tempfile::tempdir().unwrap(); 253 let repo = init_repo(dir.path()); 254 let q = read(&repo, "private").unwrap(); 255 assert!(!q.can_pull); 256 } 257 258 #[test] 259 fn push_pop_round_trip() { 260 let dir = tempfile::tempdir().unwrap(); 261 let repo = init_repo(dir.path()); 262 let s1 = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 263 let s2 = object::create(&repo, &object::Task::new("b"), "c").unwrap(); 264 push_top(&repo, "tsk", s1.clone(), "push").unwrap(); 265 push_top(&repo, "tsk", s2.clone(), "push").unwrap(); 266 let q = read(&repo, "tsk").unwrap(); 267 assert_eq!(q.index, vec![s2.clone(), s1.clone()]); 268 assert!(remove(&repo, "tsk", &s2, "drop").unwrap()); 269 let q = read(&repo, "tsk").unwrap(); 270 assert_eq!(q.index, vec![s1]); 271 } 272 273 #[test] 274 fn push_top_dedupes_existing() { 275 let dir = tempfile::tempdir().unwrap(); 276 let repo = init_repo(dir.path()); 277 let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 278 push_top(&repo, "tsk", s.clone(), "push").unwrap(); 279 push_bottom(&repo, "tsk", s.clone(), "push").unwrap(); // same id, moved to bottom 280 let q = read(&repo, "tsk").unwrap(); 281 assert_eq!(q.index.len(), 1); 282 assert_eq!(q.index[0], s); 283 } 284 285 #[test] 286 fn inbox_round_trip() { 287 let dir = tempfile::tempdir().unwrap(); 288 let repo = init_repo(dir.path()); 289 let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 290 let key = inbox_key("alice", 5); 291 add_to_inbox(&repo, "bob", key.clone(), s.clone(), "assign").unwrap(); 292 let q = read(&repo, "bob").unwrap(); 293 assert_eq!(q.inbox.get(&key), Some(&s)); 294 let taken = take_from_inbox(&repo, "bob", &key, "accept").unwrap(); 295 assert_eq!(taken, Some(s)); 296 let q = read(&repo, "bob").unwrap(); 297 assert!(q.inbox.is_empty()); 298 } 299}