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 272 lines 9.0 kB view raw
1//! A task: a tree of `{content, <prop>...}` blobs with its own commit history. 2//! 3//! Stable id = SHA-1 hex of the initial `content` blob. Refs: 4//! `refs/tsk/tasks/<stable-id>` → latest commit on that task. 5//! 6//! Tree layout for a task at any commit: 7//! content → blob: full task body, first line is the title 8//! <prop-key> → blob: property value (one file per property) 9//! 10//! Older trees may also contain a `title` blob (a cache of content's first 11//! line). It's ignored on read and silently dropped on the next write. 12 13use crate::errors::Result; 14use crate::propvalue; 15use git2::{Oid, Repository, Signature}; 16use std::collections::BTreeMap; 17use std::fmt::Display; 18 19pub const TASK_REF_PREFIX: &str = "refs/tsk/tasks/"; 20pub const CONTENT_FILE: &str = "content"; 21pub const TITLE_FILE: &str = "title"; 22 23/// Stable identifier for a task: hex SHA-1 of its initial content blob. 24#[derive(Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd)] 25pub struct StableId(pub String); 26 27impl StableId { 28 pub fn refname(&self) -> String { 29 format!("{TASK_REF_PREFIX}{}", self.0) 30 } 31 #[allow(dead_code)] // used by upcoming display layer 32 pub fn short(&self) -> &str { 33 &self.0[..12.min(self.0.len())] 34 } 35} 36 37impl Display for StableId { 38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 39 f.write_str(&self.0) 40 } 41} 42 43#[derive(Clone, Debug, Default, Eq, PartialEq)] 44pub struct Task { 45 pub content: String, 46 /// Each property is zero or more text values (one per line in storage). 47 pub properties: BTreeMap<String, Vec<String>>, 48} 49 50impl Task { 51 pub fn new(content: impl Into<String>) -> Self { 52 Self { 53 content: content.into(), 54 properties: BTreeMap::new(), 55 } 56 } 57 58 pub fn title(&self) -> &str { 59 self.content.lines().next().unwrap_or("") 60 } 61 62 pub fn body(&self) -> &str { 63 match self.content.split_once('\n') { 64 Some((_, rest)) => rest.trim_start_matches('\n'), 65 None => "", 66 } 67 } 68} 69 70/// Local user's git signature, with a `tsk@local` fallback. Shared by 71/// every writer so commits all carry the same author/committer. 72pub(crate) fn signature(repo: &Repository) -> Signature<'static> { 73 repo.signature() 74 .map(|s| s.to_owned()) 75 .unwrap_or_else(|_| Signature::now("tsk", "tsk@local").unwrap()) 76} 77 78fn build_tree( 79 repo: &Repository, 80 content_oid: Oid, 81 properties: &BTreeMap<String, Vec<String>>, 82) -> Result<Oid> { 83 let mut tb = repo.treebuilder(None)?; 84 tb.insert(CONTENT_FILE, content_oid, 0o100644)?; 85 for (k, values) in properties { 86 if k == CONTENT_FILE || k == TITLE_FILE { 87 continue; 88 } 89 let body = propvalue::encode(values); 90 let oid = repo.blob(&body)?; 91 tb.insert(k.as_str(), oid, 0o100644)?; 92 } 93 Ok(tb.write()?) 94} 95 96/// Create a brand-new task. Returns its freshly-minted stable id. 97pub fn create(repo: &Repository, task: &Task, message: &str) -> Result<StableId> { 98 let content_oid = repo.blob(task.content.as_bytes())?; 99 let stable = StableId(content_oid.to_string()); 100 let tree_oid = build_tree(repo, content_oid, &task.properties)?; 101 let sig = signature(repo); 102 let commit = repo.commit( 103 None, 104 &sig, 105 &sig, 106 message, 107 &repo.find_tree(tree_oid)?, 108 &[], 109 )?; 110 repo.reference(&stable.refname(), commit, true, message)?; 111 Ok(stable) 112} 113 114/// Append a new commit to a task's history. Returns `true` if a commit 115/// was actually written; `false` when the resulting tree matches the 116/// parent's (idempotent no-op). 117pub fn update(repo: &Repository, id: &StableId, task: &Task, message: &str) -> Result<bool> { 118 let content_oid = repo.blob(task.content.as_bytes())?; 119 let tree_oid = build_tree(repo, content_oid, &task.properties)?; 120 let parent = repo 121 .find_reference(&id.refname()) 122 .ok() 123 .and_then(|r| r.target()) 124 .and_then(|o| repo.find_commit(o).ok()); 125 if let Some(p) = &parent 126 && p.tree_id() == tree_oid 127 { 128 return Ok(false); 129 } 130 let sig = signature(repo); 131 let parents: Vec<&git2::Commit> = parent.iter().collect(); 132 let commit = repo.commit( 133 None, 134 &sig, 135 &sig, 136 message, 137 &repo.find_tree(tree_oid)?, 138 &parents, 139 )?; 140 repo.reference(&id.refname(), commit, true, message)?; 141 Ok(true) 142} 143 144pub fn read(repo: &Repository, id: &StableId) -> Result<Option<Task>> { 145 let Ok(r) = repo.find_reference(&id.refname()) else { 146 return Ok(None); 147 }; 148 let Some(target) = r.target() else { 149 return Ok(None); 150 }; 151 let commit = repo.find_commit(target)?; 152 let tree = commit.tree()?; 153 let mut task = Task::default(); 154 for entry in tree.iter() { 155 let name = entry.name().unwrap_or("").to_string(); 156 let blob = entry.to_object(repo)?.peel_to_blob()?; 157 let val = String::from_utf8_lossy(blob.content()).into_owned(); 158 match name.as_str() { 159 CONTENT_FILE => task.content = val, 160 TITLE_FILE => {} // cache only; canonical title is content's first line 161 _ => { 162 let values = propvalue::decode(blob.content()); 163 task.properties.insert(name, values); 164 } 165 } 166 } 167 Ok(Some(task)) 168} 169 170#[allow(dead_code)] // exposed for cleanup tooling / future commands 171pub fn delete(repo: &Repository, id: &StableId) -> Result<()> { 172 if let Ok(mut r) = repo.find_reference(&id.refname()) { 173 r.delete()?; 174 } 175 Ok(()) 176} 177 178#[allow(dead_code)] // exposed for cleanup tooling / future commands 179pub fn list_all(repo: &Repository) -> Result<Vec<StableId>> { 180 let mut out = Vec::new(); 181 for r in repo.references_glob(&format!("{TASK_REF_PREFIX}*"))? { 182 let r = r?; 183 if let Some(name) = r.name() 184 && let Some(rest) = name.strip_prefix(TASK_REF_PREFIX) 185 { 186 out.push(StableId(rest.to_string())); 187 } 188 } 189 Ok(out) 190} 191 192#[cfg(test)] 193mod test { 194 use super::*; 195 use std::path::Path; 196 197 fn init_repo(p: &Path) -> Repository { 198 let r = Repository::init(p).unwrap(); 199 let mut cfg = r.config().unwrap(); 200 cfg.set_str("user.name", "Test").unwrap(); 201 cfg.set_str("user.email", "t@e").unwrap(); 202 r 203 } 204 205 #[test] 206 fn create_read_round_trip() { 207 let dir = tempfile::tempdir().unwrap(); 208 let repo = init_repo(dir.path()); 209 let mut t = Task::new("Hello\n\nbody text"); 210 t.properties 211 .insert("priority".into(), vec!["high".into()]); 212 t.properties 213 .insert("tag".into(), vec!["alpha".into(), "beta".into()]); 214 let id = create(&repo, &t, "create").unwrap(); 215 let read_back = read(&repo, &id).unwrap().unwrap(); 216 assert_eq!(read_back.content, t.content); 217 assert_eq!(read_back.properties, t.properties); 218 assert_eq!(read_back.title(), "Hello"); 219 assert_eq!(read_back.body(), "body text"); 220 } 221 222 #[test] 223 fn update_appends_commit() { 224 let dir = tempfile::tempdir().unwrap(); 225 let repo = init_repo(dir.path()); 226 let t = Task::new("v1"); 227 let id = create(&repo, &t, "create").unwrap(); 228 let mut t2 = t.clone(); 229 t2.content = "v2".into(); 230 update(&repo, &id, &t2, "edit").unwrap(); 231 // Two commits in the chain. 232 let head = repo.find_reference(&id.refname()).unwrap().target().unwrap(); 233 let head_commit = repo.find_commit(head).unwrap(); 234 assert_eq!(head_commit.parent_count(), 1); 235 let read_back = read(&repo, &id).unwrap().unwrap(); 236 assert_eq!(read_back.content, "v2"); 237 } 238 239 #[test] 240 fn update_idempotent_when_tree_unchanged() { 241 let dir = tempfile::tempdir().unwrap(); 242 let repo = init_repo(dir.path()); 243 let t = Task::new("same"); 244 let id = create(&repo, &t, "create").unwrap(); 245 let head1 = repo.find_reference(&id.refname()).unwrap().target().unwrap(); 246 update(&repo, &id, &t, "noop").unwrap(); 247 let head2 = repo.find_reference(&id.refname()).unwrap().target().unwrap(); 248 assert_eq!(head1, head2); 249 } 250 251 #[test] 252 fn list_all_sees_every_task() { 253 let dir = tempfile::tempdir().unwrap(); 254 let repo = init_repo(dir.path()); 255 let a = create(&repo, &Task::new("a"), "c").unwrap(); 256 let b = create(&repo, &Task::new("b"), "c").unwrap(); 257 let mut got = list_all(&repo).unwrap(); 258 got.sort(); 259 let mut want = vec![a, b]; 260 want.sort(); 261 assert_eq!(got, want); 262 } 263 264 #[test] 265 fn stable_id_equals_blob_oid() { 266 let dir = tempfile::tempdir().unwrap(); 267 let repo = init_repo(dir.path()); 268 let id = create(&repo, &Task::new("xyz"), "c").unwrap(); 269 let direct = repo.blob(b"xyz").unwrap(); 270 assert_eq!(id.0, direct.to_string()); 271 } 272}