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 235 lines 8.3 kB view raw
1//! Per-property indices: `refs/tsk/properties/<key>` → commit chain whose 2//! tree contains one blob per task that has the key set. Each blob's lines 3//! are that task's values for the key. 4//! 5//! Each key has its own commit history so concurrent edits to *different* 6//! keys can never conflict. Concurrent edits to the same key on different 7//! tasks land in different tree entries and can be merged by git's default 8//! tree merge; only same-key/same-task races require manual resolution. 9 10use crate::errors::Result; 11use crate::object::{self, StableId}; 12use crate::propvalue; 13use git2::{Oid, Repository}; 14use std::collections::BTreeMap; 15 16pub const PROP_REF_PREFIX: &str = "refs/tsk/properties/"; 17 18pub fn refname(key: &str) -> String { 19 format!("{PROP_REF_PREFIX}{key}") 20} 21 22/// Read every (stable_id, values) entry currently indexed under `key`. 23pub fn read(repo: &Repository, key: &str) -> Result<BTreeMap<StableId, Vec<String>>> { 24 let mut out = BTreeMap::new(); 25 let Ok(r) = repo.find_reference(&refname(key)) else { 26 return Ok(out); 27 }; 28 let Some(target) = r.target() else { 29 return Ok(out); 30 }; 31 let tree = repo.find_commit(target)?.tree()?; 32 for entry in tree.iter() { 33 let Some(name) = entry.name() else { continue }; 34 let blob = entry.to_object(repo)?.peel_to_blob()?; 35 let values = propvalue::decode(blob.content()); 36 out.insert(StableId(name.to_string()), values); 37 } 38 Ok(out) 39} 40 41fn write_index( 42 repo: &Repository, 43 key: &str, 44 entries: &BTreeMap<StableId, Vec<String>>, 45 message: &str, 46) -> Result<()> { 47 if entries.is_empty() { 48 // Drop the ref entirely — empty indexes are noise. 49 if let Ok(mut r) = repo.find_reference(&refname(key)) { 50 r.delete()?; 51 } 52 return Ok(()); 53 } 54 let mut tb = repo.treebuilder(None)?; 55 for (stable, values) in entries { 56 let body = propvalue::encode(values); 57 let oid = repo.blob(&body)?; 58 tb.insert(stable.0.as_str(), oid, 0o100644)?; 59 } 60 let tree_oid: Oid = tb.write()?; 61 let parent = repo 62 .find_reference(&refname(key)) 63 .ok() 64 .and_then(|r| r.target()) 65 .and_then(|o| repo.find_commit(o).ok()); 66 if let Some(p) = &parent 67 && p.tree_id() == tree_oid 68 { 69 return Ok(()); 70 } 71 let sig = object::signature(repo); 72 let parents: Vec<&git2::Commit> = parent.iter().collect(); 73 let commit = repo.commit( 74 None, 75 &sig, 76 &sig, 77 message, 78 &repo.find_tree(tree_oid)?, 79 &parents, 80 )?; 81 repo.reference(&refname(key), commit, true, message)?; 82 Ok(()) 83} 84 85/// Set values for `(key, stable)` in the index; empty `values` removes the 86/// task from this key's index. 87pub fn set( 88 repo: &Repository, 89 key: &str, 90 stable: &StableId, 91 values: &[String], 92 message: &str, 93) -> Result<()> { 94 let mut entries = read(repo, key)?; 95 if values.is_empty() { 96 entries.remove(stable); 97 } else { 98 entries.insert(stable.clone(), values.to_vec()); 99 } 100 write_index(repo, key, &entries, message) 101} 102 103/// Every property key currently indexed in this repo. 104pub fn list_keys(repo: &Repository) -> Result<Vec<String>> { 105 let mut out = Vec::new(); 106 for r in repo.references_glob(&format!("{PROP_REF_PREFIX}*"))? { 107 let r = r?; 108 if let Some(name) = r.name() 109 && let Some(rest) = name.strip_prefix(PROP_REF_PREFIX) 110 { 111 out.push(rest.to_string()); 112 } 113 } 114 out.sort(); 115 Ok(out) 116} 117 118/// Stable ids of every task that has `key` set; if `value` is supplied, 119/// restricts to tasks where `key` contains that value. 120pub fn find(repo: &Repository, key: &str, value: Option<&str>) -> Result<Vec<StableId>> { 121 let entries = read(repo, key)?; 122 Ok(entries 123 .into_iter() 124 .filter(|(_, vs)| value.map_or(true, |target| vs.iter().any(|v| v == target))) 125 .map(|(s, _)| s) 126 .collect()) 127} 128 129/// Distinct values seen for `key`, sorted alphabetically. 130pub fn values_for(repo: &Repository, key: &str) -> Result<Vec<String>> { 131 let entries = read(repo, key)?; 132 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); 133 for vs in entries.values() { 134 for v in vs { 135 set.insert(v.clone()); 136 } 137 } 138 Ok(set.into_iter().collect()) 139} 140 141/// Re-index a task across all currently-indexed keys + its own properties. 142/// Removes the task from indices it no longer belongs to. Call this after 143/// any task tree write that may have added/removed properties. 144pub fn reindex_task( 145 repo: &Repository, 146 stable: &StableId, 147 properties: &BTreeMap<String, Vec<String>>, 148) -> Result<()> { 149 use std::collections::BTreeSet; 150 let known: BTreeSet<String> = list_keys(repo)?.into_iter().collect(); 151 let current: BTreeSet<String> = properties.keys().cloned().collect(); 152 // Update keys the task currently has. 153 for key in &current { 154 let values = properties.get(key).cloned().unwrap_or_default(); 155 set(repo, key, stable, &values, "reindex")?; 156 } 157 // Drop the task from indices it no longer participates in. 158 for key in known.difference(&current) { 159 set(repo, key, stable, &[], "reindex-remove")?; 160 } 161 Ok(()) 162} 163 164#[cfg(test)] 165mod test { 166 use super::*; 167 use crate::object; 168 169 fn init_repo(p: &std::path::Path) -> Repository { 170 let r = Repository::init(p).unwrap(); 171 let mut cfg = r.config().unwrap(); 172 cfg.set_str("user.name", "T").unwrap(); 173 cfg.set_str("user.email", "t@e").unwrap(); 174 r 175 } 176 177 #[test] 178 fn set_find_round_trip() { 179 let dir = tempfile::tempdir().unwrap(); 180 let repo = init_repo(dir.path()); 181 let s1 = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 182 let s2 = object::create(&repo, &object::Task::new("b"), "c").unwrap(); 183 set(&repo, "priority", &s1, &["high".into()], "x").unwrap(); 184 set(&repo, "priority", &s2, &["low".into(), "medium".into()], "x").unwrap(); 185 let high = find(&repo, "priority", Some("high")).unwrap(); 186 assert_eq!(high, vec![s1.clone()]); 187 let any_priority = find(&repo, "priority", None).unwrap(); 188 assert_eq!(any_priority.len(), 2); 189 assert_eq!( 190 values_for(&repo, "priority").unwrap(), 191 vec!["high".to_string(), "low".into(), "medium".into()] 192 ); 193 } 194 195 #[test] 196 fn empty_values_removes_from_index() { 197 let dir = tempfile::tempdir().unwrap(); 198 let repo = init_repo(dir.path()); 199 let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 200 set(&repo, "tag", &s, &["x".into()], "x").unwrap(); 201 assert_eq!(find(&repo, "tag", None).unwrap(), vec![s.clone()]); 202 set(&repo, "tag", &s, &[], "rm").unwrap(); 203 assert_eq!(find(&repo, "tag", None).unwrap(), Vec::<StableId>::new()); 204 // Index ref is dropped when the last entry is removed. 205 assert!(repo.find_reference(&refname("tag")).is_err()); 206 } 207 208 #[test] 209 fn list_keys_reports_indexed() { 210 let dir = tempfile::tempdir().unwrap(); 211 let repo = init_repo(dir.path()); 212 let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 213 set(&repo, "k1", &s, &["v".into()], "x").unwrap(); 214 set(&repo, "k2", &s, &["v".into()], "x").unwrap(); 215 let mut keys = list_keys(&repo).unwrap(); 216 keys.sort(); 217 assert_eq!(keys, vec!["k1".to_string(), "k2".into()]); 218 } 219 220 #[test] 221 fn reindex_drops_removed_keys() { 222 let dir = tempfile::tempdir().unwrap(); 223 let repo = init_repo(dir.path()); 224 let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 225 set(&repo, "kept", &s, &["v".into()], "x").unwrap(); 226 set(&repo, "gone", &s, &["v".into()], "x").unwrap(); 227 228 let mut props: BTreeMap<String, Vec<String>> = BTreeMap::new(); 229 props.insert("kept".into(), vec!["v".into()]); 230 // "gone" is not in props anymore; reindex should remove the task from it. 231 reindex_task(&repo, &s, &props).unwrap(); 232 assert!(find(&repo, "gone", None).unwrap().is_empty()); 233 assert_eq!(find(&repo, "kept", None).unwrap(), vec![s]); 234 } 235}