Something went wrong. Try again.
A git-based task manager
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275//! Per-property indices: `refs/tsk/properties/<key>` → commit chain whose//! tree contains one blob per task that has the key set. Each blob's lines//! are that task's values for the key.//!//! Each key has its own commit history so concurrent edits to *different*//! keys can never conflict. Concurrent edits to the same key on different//! tasks land in different tree entries and can be merged by git's default//! tree merge; only same-key/same-task races require manual resolution.
use crate::errors::Result;use crate::object::{self, StableId};use crate::propvalue;use git2::{Oid, Repository};use std::collections::BTreeMap;
pub const PROP_REF_PREFIX: &str = "refs/tsk/properties/";pub const REFERENCES_KEY: &str = "references";pub const REFERENCED_BY_KEY: &str = "referenced-by";pub const DEPENDS_ON_KEY: &str = "depends-on";pub const BLOCKS_KEY: &str = "blocks";
/// Properties managed by tsk itself. User-facing property mutation commands/// reject these keys; internal workspace operations may still update them.pub const PROTECTED_KEYS: &[&str] = &[ "status", "closed-on", REFERENCES_KEY, REFERENCED_BY_KEY, DEPENDS_ON_KEY, BLOCKS_KEY,];
pub fn is_protected(key: &str) -> bool { PROTECTED_KEYS.contains(&key)}
pub fn refname(key: &str) -> String { format!("{PROP_REF_PREFIX}{key}")}
/// Read every (stable_id, values) entry currently indexed under `key`.pub fn read(repo: &Repository, key: &str) -> Result<BTreeMap<StableId, Vec<String>>> { let mut out = BTreeMap::new(); let Ok(r) = repo.find_reference(&refname(key)) else { return Ok(out); }; let Some(target) = r.target() else { return Ok(out); }; let tree = repo.find_commit(target)?.tree()?; for entry in tree.iter() { let Ok(name) = entry.name() else { continue }; let blob = entry.to_object(repo)?.peel_to_blob()?; let values = propvalue::decode(blob.content()); out.insert(StableId(name.to_string()), values); } Ok(out)}
fn write_index( repo: &Repository, key: &str, entries: &BTreeMap<StableId, Vec<String>>, message: &str,) -> Result<()> { if entries.is_empty() { // Drop the ref entirely — empty indexes are noise. if let Ok(mut r) = repo.find_reference(&refname(key)) { r.delete()?; } return Ok(()); } let mut tb = repo.treebuilder(None)?; for (stable, values) in entries { let body = propvalue::encode(values); let oid = repo.blob(&body)?; tb.insert(stable.0.as_str(), oid, 0o100644)?; } let tree_oid: Oid = tb.write()?; let parent = repo .find_reference(&refname(key)) .ok() .and_then(|r| r.target()) .and_then(|o| repo.find_commit(o).ok()); if let Some(p) = &parent && p.tree_id() == tree_oid { return Ok(()); } let sig = object::signature(repo); let parents: Vec<&git2::Commit> = parent.iter().collect(); let commit = repo.commit( None, &sig, &sig, message, &repo.find_tree(tree_oid)?, &parents, )?; repo.reference(&refname(key), commit, true, message)?; Ok(())}
/// Set values for `(key, stable)` in the index; empty `values` removes the/// task from this key's index.pub fn set( repo: &Repository, key: &str, stable: &StableId, values: &[String], message: &str,) -> Result<()> { let mut entries = read(repo, key)?; if values.is_empty() { entries.remove(stable); } else { entries.insert(stable.clone(), values.to_vec()); } write_index(repo, key, &entries, message)}
/// Every property key currently indexed in this repo.pub fn list_keys(repo: &Repository) -> Result<Vec<String>> { let mut out = Vec::new(); for r in repo.references_glob(&format!("{PROP_REF_PREFIX}*"))? { let r = r?; if let Ok(name) = r.name() && let Some(rest) = name.strip_prefix(PROP_REF_PREFIX) { out.push(rest.to_string()); } } out.sort(); Ok(out)}
/// Stable ids of every task that has `key` set; if `value` is supplied,/// restricts to tasks where `key` contains that value.pub fn find(repo: &Repository, key: &str, value: Option<&str>) -> Result<Vec<StableId>> { let entries = read(repo, key)?; Ok(entries .into_iter() .filter(|(_, vs)| value.map_or(true, |target| vs.iter().any(|v| v == target))) .map(|(s, _)| s) .collect())}
/// Distinct values seen for `key`, sorted alphabetically.pub fn values_for(repo: &Repository, key: &str) -> Result<Vec<String>> { let entries = read(repo, key)?; let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); for vs in entries.values() { for v in vs { set.insert(v.clone()); } } Ok(set.into_iter().collect())}
/// Re-index a task across all currently-indexed keys + its own properties./// Removes the task from indices it no longer belongs to. Call this after/// any task tree write that may have added/removed properties.pub fn reindex_task( repo: &Repository, stable: &StableId, properties: &BTreeMap<String, Vec<String>>,) -> Result<()> { use std::collections::BTreeSet; let known: BTreeSet<String> = list_keys(repo)?.into_iter().collect(); let current: BTreeSet<String> = properties.keys().cloned().collect(); // Update keys the task currently has. for key in ¤t { let values = properties.get(key).cloned().unwrap_or_default(); set(repo, key, stable, &values, "reindex")?; } // Drop the task from indices it no longer participates in. for key in known.difference(¤t) { set(repo, key, stable, &[], "reindex-remove")?; } Ok(())}
/// Update a task object, then refresh this task's entries across all property/// indices. Returns whether the task object write created a new commit.pub fn update_task( repo: &Repository, stable: &StableId, task: &object::Task, message: &str,) -> Result<bool> { let wrote = object::update(repo, stable, task, message)?; reindex_task(repo, stable, &task.properties)?; Ok(wrote)}
#[cfg(test)]mod test { use super::*; use crate::object;
fn init_repo(p: &std::path::Path) -> Repository { let r = Repository::init(p).unwrap(); let mut cfg = r.config().unwrap(); cfg.set_str("user.name", "T").unwrap(); cfg.set_str("user.email", "t@e").unwrap(); r }
#[test] fn set_find_round_trip() { let dir = tempfile::tempdir().unwrap(); let repo = init_repo(dir.path()); let s1 = object::create(&repo, &object::Task::new("a"), "c").unwrap(); let s2 = object::create(&repo, &object::Task::new("b"), "c").unwrap(); set(&repo, "priority", &s1, &["high".into()], "x").unwrap(); set( &repo, "priority", &s2, &["low".into(), "medium".into()], "x", ) .unwrap(); let high = find(&repo, "priority", Some("high")).unwrap(); assert_eq!(high, vec![s1.clone()]); let any_priority = find(&repo, "priority", None).unwrap(); assert_eq!(any_priority.len(), 2); assert_eq!( values_for(&repo, "priority").unwrap(), vec!["high".to_string(), "low".into(), "medium".into()] ); }
#[test] fn empty_values_removes_from_index() { let dir = tempfile::tempdir().unwrap(); let repo = init_repo(dir.path()); let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); set(&repo, "tag", &s, &["x".into()], "x").unwrap(); assert_eq!(find(&repo, "tag", None).unwrap(), vec![s.clone()]); set(&repo, "tag", &s, &[], "rm").unwrap(); assert_eq!(find(&repo, "tag", None).unwrap(), Vec::<StableId>::new()); // Index ref is dropped when the last entry is removed. assert!(repo.find_reference(&refname("tag")).is_err()); }
#[test] fn list_keys_reports_indexed() { let dir = tempfile::tempdir().unwrap(); let repo = init_repo(dir.path()); let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); set(&repo, "k1", &s, &["v".into()], "x").unwrap(); set(&repo, "k2", &s, &["v".into()], "x").unwrap(); let mut keys = list_keys(&repo).unwrap(); keys.sort(); assert_eq!(keys, vec!["k1".to_string(), "k2".into()]); }
#[test] fn reindex_drops_removed_keys() { let dir = tempfile::tempdir().unwrap(); let repo = init_repo(dir.path()); let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); set(&repo, "kept", &s, &["v".into()], "x").unwrap(); set(&repo, "gone", &s, &["v".into()], "x").unwrap();
let mut props: BTreeMap<String, Vec<String>> = BTreeMap::new(); props.insert("kept".into(), vec!["v".into()]); // "gone" is not in props anymore; reindex should remove the task from it. reindex_task(&repo, &s, &props).unwrap(); assert!(find(&repo, "gone", None).unwrap().is_empty()); assert_eq!(find(&repo, "kept", None).unwrap(), vec![s]); }}