A file-based task manager
0
fork

Configure Feed

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

Implement multi-value task properties with per-key indices

Properties are now stored as plain blobs in the task tree (one file per
key, lines are values), so reads/writes go through the same per-task
commit history as content. A new properties module maintains a per-key
index ref at refs/tsk/properties/<key> with a tree of <stable-id> blobs;
each key gets its own commit history so concurrent edits to different
keys cannot conflict.

CLI: tsk prop {list,add,set,unset,keys,values,find}. find accepts an
optional key/value and falls through to fzf for whichever is missing,
with a <any> sentinel to skip value-narrowing.

Validation: 4 new unit tests in properties.rs, 2 new integration tests
in multi_user.rs covering the binary CLI plus push/pull visibility of
the index across clones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+543 -11
+123 -2
src/main.rs
··· 2 2 mod fzf; 3 3 mod namespace; 4 4 mod object; 5 + mod properties; 5 6 mod queue; 6 7 mod task; 7 8 mod util; ··· 152 153 #[arg(short = 'R')] 153 154 remote: Option<String>, 154 155 }, 156 + /// Get/set/find tasks by property. Properties are zero-or-more text values 157 + /// stored as files in the task's tree object; each value is one line. 158 + Prop { 159 + #[command(subcommand)] 160 + action: PropAction, 161 + }, 155 162 /// Manage namespaces. 156 163 Namespace { 157 164 #[command(subcommand)] ··· 174 181 } 175 182 176 183 #[derive(Subcommand)] 184 + enum PropAction { 185 + /// List all values for every property on a task. 186 + List { 187 + #[command(flatten)] 188 + task_id: TaskId, 189 + }, 190 + /// Append a value to a property on a task. Creates the property if absent. 191 + Add { 192 + #[command(flatten)] 193 + task_id: TaskId, 194 + key: String, 195 + value: String, 196 + }, 197 + /// Replace the entire value list for a property. With no values, removes the property. 198 + Set { 199 + #[command(flatten)] 200 + task_id: TaskId, 201 + key: String, 202 + values: Vec<String>, 203 + }, 204 + /// Remove a single value (or, with no value, the entire property). 205 + Unset { 206 + #[command(flatten)] 207 + task_id: TaskId, 208 + key: String, 209 + value: Option<String>, 210 + }, 211 + /// List every property key currently in use across the workspace. 212 + Keys, 213 + /// List distinct values seen for a property key. 214 + Values { key: String }, 215 + /// Find every task in the active namespace whose `key` is set (and equals 216 + /// `value`, if supplied). With both omitted, fzf-picks the key, then value. 217 + Find { 218 + key: Option<String>, 219 + value: Option<String>, 220 + }, 221 + } 222 + 223 + #[derive(Subcommand)] 177 224 enum NamespaceAction { 178 225 List, 179 226 Current, ··· 283 330 Commands::Inbox { remote } => command_inbox(dir, remote), 284 331 Commands::Accept { key } => command_accept(dir, key), 285 332 Commands::Reject { key, remote } => command_reject(dir, key, remote), 333 + Commands::Prop { action } => command_prop(dir, action), 286 334 Commands::Namespace { action } => command_namespace(dir, action), 287 335 Commands::Queue { action } => command_queue(dir, action), 288 336 Commands::Switch { name } => command_namespace_switch(dir, name), ··· 383 431 let task = Workspace::from_path(dir)?.task(task_id.into())?; 384 432 if show_attrs && !task.attributes.is_empty() { 385 433 println!("---"); 386 - for (k, v) in &task.attributes { 387 - println!("{k}: \"{v}\""); 434 + for (k, vs) in &task.attributes { 435 + for v in vs { 436 + println!("{k}: \"{v}\""); 437 + } 388 438 } 389 439 println!("---"); 390 440 } ··· 496 546 println!("Rejected {key}"); 497 547 if let Some(r) = effective_remote(remote) { 498 548 let _ = ws.git_push(&r); 549 + } 550 + Ok(()) 551 + } 552 + 553 + fn command_prop(dir: PathBuf, action: PropAction) -> Result<()> { 554 + let ws = Workspace::from_path(dir)?; 555 + match action { 556 + PropAction::List { task_id } => { 557 + let task = ws.task(task_id.into())?; 558 + for (k, vs) in &task.attributes { 559 + for v in vs { 560 + println!("{k}\t{v}"); 561 + } 562 + } 563 + } 564 + PropAction::Add { 565 + task_id, 566 + key, 567 + value, 568 + } => ws.add_property_value(task_id.into(), &key, &value)?, 569 + PropAction::Set { 570 + task_id, 571 + key, 572 + values, 573 + } => ws.set_property(task_id.into(), &key, values)?, 574 + PropAction::Unset { 575 + task_id, 576 + key, 577 + value, 578 + } => ws.unset_property(task_id.into(), &key, value.as_deref())?, 579 + PropAction::Keys => { 580 + for k in ws.property_keys()? { 581 + println!("{k}"); 582 + } 583 + } 584 + PropAction::Values { key } => { 585 + for v in ws.property_values(&key)? { 586 + println!("{v}"); 587 + } 588 + } 589 + PropAction::Find { key, value } => { 590 + let key = match key { 591 + Some(k) => k, 592 + None => fzf::select::<_, String, _>( 593 + ws.property_keys()?, 594 + ["--prompt=key> "], 595 + )? 596 + .ok_or_else(|| errors::Error::Parse("No key selected".into()))?, 597 + }; 598 + let value = match value { 599 + Some(v) if v == "<any>" => None, 600 + Some(v) => Some(v), 601 + None => { 602 + let mut choices = ws.property_values(&key)?; 603 + choices.insert(0, "<any>".to_string()); 604 + let picked = fzf::select::<_, String, _>( 605 + choices, 606 + ["--prompt=value> "], 607 + )? 608 + .ok_or_else(|| errors::Error::Parse("No value selected".into()))?; 609 + if picked == "<any>" { 610 + None 611 + } else { 612 + Some(picked) 613 + } 614 + } 615 + }; 616 + for (id, _stable, title) in ws.find_by_property(&key, value.as_deref())? { 617 + println!("{id}\t{title}"); 618 + } 619 + } 499 620 } 500 621 Ok(()) 501 622 }
+13 -6
src/object.rs
··· 40 40 #[derive(Clone, Debug, Default, Eq, PartialEq)] 41 41 pub struct Task { 42 42 pub content: String, 43 - pub properties: BTreeMap<String, String>, 43 + /// Each property is zero or more text values (one per line in storage). 44 + pub properties: BTreeMap<String, Vec<String>>, 44 45 } 45 46 46 47 impl Task { ··· 73 74 repo: &Repository, 74 75 content_oid: Oid, 75 76 title: &str, 76 - properties: &BTreeMap<String, String>, 77 + properties: &BTreeMap<String, Vec<String>>, 77 78 ) -> Result<Oid> { 78 79 let mut tb = repo.treebuilder(None)?; 79 80 tb.insert(CONTENT_FILE, content_oid, 0o100644)?; 80 81 let title_oid = repo.blob(title.as_bytes())?; 81 82 tb.insert(TITLE_FILE, title_oid, 0o100644)?; 82 - for (k, v) in properties { 83 + for (k, values) in properties { 83 84 if k == CONTENT_FILE || k == TITLE_FILE { 84 85 continue; 85 86 } 86 - let oid = repo.blob(v.as_bytes())?; 87 + let body: String = values.iter().map(|v| format!("{v}\n")).collect(); 88 + let oid = repo.blob(body.as_bytes())?; 87 89 tb.insert(k.as_str(), oid, 0o100644)?; 88 90 } 89 91 Ok(tb.write()?) ··· 154 156 CONTENT_FILE => task.content = val, 155 157 TITLE_FILE => {} // cache only; canonical title is content's first line 156 158 _ => { 157 - task.properties.insert(name, val); 159 + let values: Vec<String> = 160 + val.lines().map(str::to_string).collect(); 161 + task.properties.insert(name, values); 158 162 } 159 163 } 160 164 } ··· 201 205 let dir = tempfile::tempdir().unwrap(); 202 206 let repo = init_repo(dir.path()); 203 207 let mut t = Task::new("Hello\n\nbody text"); 204 - t.properties.insert("priority".into(), "high".into()); 208 + t.properties 209 + .insert("priority".into(), vec!["high".into()]); 210 + t.properties 211 + .insert("tag".into(), vec!["alpha".into(), "beta".into()]); 205 212 let id = create(&repo, &t, "create").unwrap(); 206 213 let read_back = read(&repo, &id).unwrap().unwrap(); 207 214 assert_eq!(read_back.content, t.content);
+254
src/properties.rs
··· 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 + 10 + use crate::errors::Result; 11 + use crate::object::StableId; 12 + use git2::{Oid, Repository, Signature}; 13 + use std::collections::BTreeMap; 14 + 15 + pub const PROP_REF_PREFIX: &str = "refs/tsk/properties/"; 16 + 17 + pub fn refname(key: &str) -> String { 18 + format!("{PROP_REF_PREFIX}{key}") 19 + } 20 + 21 + fn signature(repo: &Repository) -> Signature<'static> { 22 + repo.signature() 23 + .map(|s| s.to_owned()) 24 + .unwrap_or_else(|_| Signature::now("tsk", "tsk@local").unwrap()) 25 + } 26 + 27 + /// Read every (stable_id, values) entry currently indexed under `key`. 28 + pub fn read(repo: &Repository, key: &str) -> Result<BTreeMap<StableId, Vec<String>>> { 29 + let mut out = BTreeMap::new(); 30 + let Ok(r) = repo.find_reference(&refname(key)) else { 31 + return Ok(out); 32 + }; 33 + let Some(target) = r.target() else { 34 + return Ok(out); 35 + }; 36 + let tree = repo.find_commit(target)?.tree()?; 37 + for entry in tree.iter() { 38 + let Some(name) = entry.name() else { continue }; 39 + let blob = entry.to_object(repo)?.peel_to_blob()?; 40 + let values: Vec<String> = String::from_utf8_lossy(blob.content()) 41 + .lines() 42 + .map(str::to_string) 43 + .collect(); 44 + out.insert(StableId(name.to_string()), values); 45 + } 46 + Ok(out) 47 + } 48 + 49 + fn write_index( 50 + repo: &Repository, 51 + key: &str, 52 + entries: &BTreeMap<StableId, Vec<String>>, 53 + message: &str, 54 + ) -> Result<()> { 55 + if entries.is_empty() { 56 + // Drop the ref entirely — empty indexes are noise. 57 + if let Ok(mut r) = repo.find_reference(&refname(key)) { 58 + r.delete()?; 59 + } 60 + return Ok(()); 61 + } 62 + let mut tb = repo.treebuilder(None)?; 63 + for (stable, values) in entries { 64 + let body: String = values.iter().map(|v| format!("{v}\n")).collect(); 65 + let oid = repo.blob(body.as_bytes())?; 66 + tb.insert(stable.0.as_str(), oid, 0o100644)?; 67 + } 68 + let tree_oid: Oid = tb.write()?; 69 + let parent = repo 70 + .find_reference(&refname(key)) 71 + .ok() 72 + .and_then(|r| r.target()) 73 + .and_then(|o| repo.find_commit(o).ok()); 74 + if let Some(p) = &parent 75 + && p.tree_id() == tree_oid 76 + { 77 + return Ok(()); 78 + } 79 + let sig = signature(repo); 80 + let parents: Vec<&git2::Commit> = parent.iter().collect(); 81 + let commit = repo.commit( 82 + None, 83 + &sig, 84 + &sig, 85 + message, 86 + &repo.find_tree(tree_oid)?, 87 + &parents, 88 + )?; 89 + repo.reference(&refname(key), commit, true, message)?; 90 + Ok(()) 91 + } 92 + 93 + /// Set values for `(key, stable)` in the index; empty `values` removes the 94 + /// task from this key's index. 95 + pub fn set( 96 + repo: &Repository, 97 + key: &str, 98 + stable: &StableId, 99 + values: &[String], 100 + message: &str, 101 + ) -> Result<()> { 102 + let mut entries = read(repo, key)?; 103 + if values.is_empty() { 104 + entries.remove(stable); 105 + } else { 106 + entries.insert(stable.clone(), values.to_vec()); 107 + } 108 + write_index(repo, key, &entries, message) 109 + } 110 + 111 + /// Replace the entire index for `key` with the given map. Useful when a 112 + /// task is fully rewritten and we sync many keys at once. 113 + pub fn replace_all( 114 + repo: &Repository, 115 + key: &str, 116 + entries: &BTreeMap<StableId, Vec<String>>, 117 + message: &str, 118 + ) -> Result<()> { 119 + write_index(repo, key, entries, message) 120 + } 121 + 122 + /// Every property key currently indexed in this repo. 123 + pub fn list_keys(repo: &Repository) -> Result<Vec<String>> { 124 + let mut out = Vec::new(); 125 + for r in repo.references_glob(&format!("{PROP_REF_PREFIX}*"))? { 126 + let r = r?; 127 + if let Some(name) = r.name() 128 + && let Some(rest) = name.strip_prefix(PROP_REF_PREFIX) 129 + { 130 + out.push(rest.to_string()); 131 + } 132 + } 133 + out.sort(); 134 + Ok(out) 135 + } 136 + 137 + /// Stable ids of every task that has `key` set; if `value` is supplied, 138 + /// restricts to tasks where `key` contains that value. 139 + pub fn find(repo: &Repository, key: &str, value: Option<&str>) -> Result<Vec<StableId>> { 140 + let entries = read(repo, key)?; 141 + Ok(entries 142 + .into_iter() 143 + .filter(|(_, vs)| value.map_or(true, |target| vs.iter().any(|v| v == target))) 144 + .map(|(s, _)| s) 145 + .collect()) 146 + } 147 + 148 + /// Distinct values seen for `key`, sorted alphabetically. 149 + pub fn values_for(repo: &Repository, key: &str) -> Result<Vec<String>> { 150 + let entries = read(repo, key)?; 151 + let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); 152 + for vs in entries.values() { 153 + for v in vs { 154 + set.insert(v.clone()); 155 + } 156 + } 157 + Ok(set.into_iter().collect()) 158 + } 159 + 160 + /// Re-index a task across all currently-indexed keys + its own properties. 161 + /// Removes the task from indices it no longer belongs to. Call this after 162 + /// any task tree write that may have added/removed properties. 163 + pub fn reindex_task( 164 + repo: &Repository, 165 + stable: &StableId, 166 + properties: &BTreeMap<String, Vec<String>>, 167 + ) -> Result<()> { 168 + use std::collections::BTreeSet; 169 + let known: BTreeSet<String> = list_keys(repo)?.into_iter().collect(); 170 + let current: BTreeSet<String> = properties.keys().cloned().collect(); 171 + // Update keys the task currently has. 172 + for key in &current { 173 + let values = properties.get(key).cloned().unwrap_or_default(); 174 + set(repo, key, stable, &values, "reindex")?; 175 + } 176 + // Drop the task from indices it no longer participates in. 177 + for key in known.difference(&current) { 178 + set(repo, key, stable, &[], "reindex-remove")?; 179 + } 180 + Ok(()) 181 + } 182 + 183 + #[cfg(test)] 184 + mod test { 185 + use super::*; 186 + use crate::object; 187 + 188 + fn init_repo(p: &std::path::Path) -> Repository { 189 + let r = Repository::init(p).unwrap(); 190 + let mut cfg = r.config().unwrap(); 191 + cfg.set_str("user.name", "T").unwrap(); 192 + cfg.set_str("user.email", "t@e").unwrap(); 193 + r 194 + } 195 + 196 + #[test] 197 + fn set_find_round_trip() { 198 + let dir = tempfile::tempdir().unwrap(); 199 + let repo = init_repo(dir.path()); 200 + let s1 = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 201 + let s2 = object::create(&repo, &object::Task::new("b"), "c").unwrap(); 202 + set(&repo, "priority", &s1, &["high".into()], "x").unwrap(); 203 + set(&repo, "priority", &s2, &["low".into(), "medium".into()], "x").unwrap(); 204 + let high = find(&repo, "priority", Some("high")).unwrap(); 205 + assert_eq!(high, vec![s1.clone()]); 206 + let any_priority = find(&repo, "priority", None).unwrap(); 207 + assert_eq!(any_priority.len(), 2); 208 + assert_eq!( 209 + values_for(&repo, "priority").unwrap(), 210 + vec!["high".to_string(), "low".into(), "medium".into()] 211 + ); 212 + } 213 + 214 + #[test] 215 + fn empty_values_removes_from_index() { 216 + let dir = tempfile::tempdir().unwrap(); 217 + let repo = init_repo(dir.path()); 218 + let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 219 + set(&repo, "tag", &s, &["x".into()], "x").unwrap(); 220 + assert_eq!(find(&repo, "tag", None).unwrap(), vec![s.clone()]); 221 + set(&repo, "tag", &s, &[], "rm").unwrap(); 222 + assert_eq!(find(&repo, "tag", None).unwrap(), Vec::<StableId>::new()); 223 + // Index ref is dropped when the last entry is removed. 224 + assert!(repo.find_reference(&refname("tag")).is_err()); 225 + } 226 + 227 + #[test] 228 + fn list_keys_reports_indexed() { 229 + let dir = tempfile::tempdir().unwrap(); 230 + let repo = init_repo(dir.path()); 231 + let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 232 + set(&repo, "k1", &s, &["v".into()], "x").unwrap(); 233 + set(&repo, "k2", &s, &["v".into()], "x").unwrap(); 234 + let mut keys = list_keys(&repo).unwrap(); 235 + keys.sort(); 236 + assert_eq!(keys, vec!["k1".to_string(), "k2".into()]); 237 + } 238 + 239 + #[test] 240 + fn reindex_drops_removed_keys() { 241 + let dir = tempfile::tempdir().unwrap(); 242 + let repo = init_repo(dir.path()); 243 + let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 244 + set(&repo, "kept", &s, &["v".into()], "x").unwrap(); 245 + set(&repo, "gone", &s, &["v".into()], "x").unwrap(); 246 + 247 + let mut props: BTreeMap<String, Vec<String>> = BTreeMap::new(); 248 + props.insert("kept".into(), vec!["v".into()]); 249 + // "gone" is not in props anymore; reindex should remove the task from it. 250 + reindex_task(&repo, &s, &props).unwrap(); 251 + assert!(find(&repo, "gone", None).unwrap().is_empty()); 252 + assert_eq!(find(&repo, "kept", None).unwrap(), vec![s]); 253 + } 254 + }
+100 -3
src/workspace.rs
··· 9 9 10 10 use crate::errors::{Error, Result}; 11 11 use crate::object::{self, StableId, Task as TaskObj}; 12 - use crate::{namespace, queue, util}; 12 + use crate::{namespace, properties, queue, util}; 13 13 use git2::Repository; 14 14 use std::collections::BTreeMap; 15 15 use std::fmt::Display; ··· 69 69 } 70 70 71 71 /// User-facing task: human id (in active namespace) + content + properties. 72 + /// Each property holds zero or more text values. 72 73 pub struct Task { 73 74 pub id: Id, 74 75 pub stable: StableId, 75 76 pub title: String, 76 77 pub body: String, 77 - pub attributes: BTreeMap<String, String>, 78 + pub attributes: BTreeMap<String, Vec<String>>, 78 79 } 79 80 80 81 impl Display for Task { ··· 247 248 content, 248 249 properties: task.attributes.clone(), 249 250 }; 250 - object::update(&repo, &task.stable, &task_obj, "edit") 251 + object::update(&repo, &task.stable, &task_obj, "edit")?; 252 + properties::reindex_task(&repo, &task.stable, &task.attributes)?; 253 + Ok(()) 254 + } 255 + 256 + /// Append a value to a property on a task. If the value is already 257 + /// present, this is a no-op. Persists both the task tree and the index. 258 + pub fn add_property_value( 259 + &self, 260 + identifier: TaskIdentifier, 261 + key: &str, 262 + value: &str, 263 + ) -> Result<()> { 264 + let mut task = self.task(identifier)?; 265 + let entry = task.attributes.entry(key.to_string()).or_default(); 266 + if !entry.iter().any(|v| v == value) { 267 + entry.push(value.to_string()); 268 + } 269 + self.save_task(&task) 270 + } 271 + 272 + /// Replace the entire value list for a property. 273 + pub fn set_property( 274 + &self, 275 + identifier: TaskIdentifier, 276 + key: &str, 277 + values: Vec<String>, 278 + ) -> Result<()> { 279 + let mut task = self.task(identifier)?; 280 + if values.is_empty() { 281 + task.attributes.remove(key); 282 + } else { 283 + task.attributes.insert(key.to_string(), values); 284 + } 285 + self.save_task(&task) 286 + } 287 + 288 + /// Remove a single value from a property, or the whole property if 289 + /// `value` is None. 290 + pub fn unset_property( 291 + &self, 292 + identifier: TaskIdentifier, 293 + key: &str, 294 + value: Option<&str>, 295 + ) -> Result<()> { 296 + let mut task = self.task(identifier)?; 297 + match value { 298 + None => { 299 + task.attributes.remove(key); 300 + } 301 + Some(v) => { 302 + if let Some(entry) = task.attributes.get_mut(key) { 303 + entry.retain(|x| x != v); 304 + if entry.is_empty() { 305 + task.attributes.remove(key); 306 + } 307 + } 308 + } 309 + } 310 + self.save_task(&task) 311 + } 312 + 313 + pub fn property_keys(&self) -> Result<Vec<String>> { 314 + properties::list_keys(&self.repo()?) 315 + } 316 + 317 + pub fn property_values(&self, key: &str) -> Result<Vec<String>> { 318 + properties::values_for(&self.repo()?, key) 319 + } 320 + 321 + /// Find tasks (by human id, scoped to active namespace) that have 322 + /// `key` set; if `value` is supplied, restricts to entries containing 323 + /// that value. 324 + pub fn find_by_property( 325 + &self, 326 + key: &str, 327 + value: Option<&str>, 328 + ) -> Result<Vec<(Id, StableId, String)>> { 329 + let repo = self.repo()?; 330 + let stables = properties::find(&repo, key, value)?; 331 + let ns = namespace::read(&repo, &self.namespace())?; 332 + let mut by_stable: BTreeMap<&StableId, u32> = BTreeMap::new(); 333 + for (h, s) in &ns.mapping { 334 + by_stable.insert(s, *h); 335 + } 336 + let mut out = Vec::new(); 337 + for stable in stables { 338 + // Only return tasks visible in the active namespace. 339 + let Some(&human) = by_stable.get(&stable) else { 340 + continue; 341 + }; 342 + let title = object::read(&repo, &stable)? 343 + .map(|t| t.title().to_string()) 344 + .unwrap_or_default(); 345 + out.push((Id(human), stable, title)); 346 + } 347 + Ok(out) 251 348 } 252 349 253 350 pub fn push_task(&self, task: Task) -> Result<()> {
+53
tests/multi_user.rs
··· 156 156 } 157 157 158 158 #[test] 159 + fn property_set_find_round_trip_via_binary() { 160 + let (_dir, alice, _bob) = setup_two_clones(); 161 + 162 + tsk_ok(&alice, &["push", "first"]); 163 + tsk_ok(&alice, &["push", "second"]); 164 + // Set priority on tsk-1 (the bottom of stack — first pushed). 165 + tsk_ok(&alice, &["prop", "add", "-T", "tsk-1", "priority", "high"]); 166 + tsk_ok(&alice, &["prop", "add", "-T", "tsk-1", "tag", "alpha"]); 167 + tsk_ok(&alice, &["prop", "add", "-T", "tsk-1", "tag", "beta"]); 168 + tsk_ok(&alice, &["prop", "add", "-T", "tsk-2", "priority", "low"]); 169 + 170 + // List on tsk-1: priority=high, tag=alpha, tag=beta. 171 + let list = tsk_ok(&alice, &["prop", "list", "-T", "tsk-1"]); 172 + assert!(list.contains("priority\thigh"), "got {list}"); 173 + assert!(list.contains("tag\talpha"), "got {list}"); 174 + assert!(list.contains("tag\tbeta"), "got {list}"); 175 + 176 + // Keys index has both `priority` and `tag`. 177 + let keys = tsk_ok(&alice, &["prop", "keys"]); 178 + assert!(keys.contains("priority"), "got {keys}"); 179 + assert!(keys.contains("tag"), "got {keys}"); 180 + 181 + // Find tasks with priority=high. 182 + let found = tsk_ok(&alice, &["prop", "find", "priority", "high"]); 183 + assert!(found.contains("tsk-1"), "got {found}"); 184 + assert!(!found.contains("tsk-2"), "got {found}"); 185 + 186 + // Unsetting one value on multi-value property. 187 + tsk_ok(&alice, &["prop", "unset", "-T", "tsk-1", "tag", "alpha"]); 188 + let list = tsk_ok(&alice, &["prop", "list", "-T", "tsk-1"]); 189 + assert!(!list.contains("tag\talpha"), "alpha should be gone: {list}"); 190 + assert!(list.contains("tag\tbeta"), "beta survives: {list}"); 191 + 192 + // Replace whole property. 193 + tsk_ok(&alice, &["prop", "set", "-T", "tsk-1", "priority", "medium"]); 194 + let list = tsk_ok(&alice, &["prop", "list", "-T", "tsk-1"]); 195 + assert!(list.contains("priority\tmedium"), "got {list}"); 196 + assert!(!list.contains("priority\thigh"), "got {list}"); 197 + } 198 + 199 + #[test] 200 + fn property_index_pushed_and_visible_to_other_clone() { 201 + let (_dir, alice, bob) = setup_two_clones(); 202 + tsk_ok(&alice, &["push", "shared task"]); 203 + tsk_ok(&alice, &["prop", "add", "-T", "tsk-1", "owner", "alice"]); 204 + tsk_ok(&alice, &["git-push"]); 205 + 206 + tsk_ok(&bob, &["git-pull"]); 207 + let found = tsk_ok(&bob, &["prop", "find", "owner", "alice"]); 208 + assert!(found.contains("tsk-1"), "bob sees alice's index: {found}"); 209 + } 210 + 211 + #[test] 159 212 fn share_into_namespace_round_trip() { 160 213 let (_dir, alice, _bob) = setup_two_clones(); 161 214