A file-based task manager
0
fork

Configure Feed

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

Further dedup: title_for, ns_reverse, ensure_bound, pop_line, auto_push_refs, print_lines

Five more shared helpers that pull repeated boilerplate out of the
big call sites:

- workspace::title_for(repo, stable): the
`object::read(repo, &stable)?.map(|t| t.title()...).unwrap_or_default()`
trio appeared 3× in find_by_property / read_stack / list_namespace_tasks
/ list_inbox.
- workspace::ns_reverse(ns): the `BTreeMap<&StableId, u32>` reverse map
hand-built in two functions; one helper, one collector.
- namespace::ensure_bound(repo, name, stable, msg): the
`match human_for { Some(h) => h, None => assign_id(...) }` shape that
lived in import / accept / pull paths.
- patch::pop_line(rest, msg): the
`position(\n) + from_utf8 + trim('\r') + advance` ritual repeated
twice per file-block in patch parsing.
- lib::auto_push_refs(ws, remote, refs): the
`if let Some(r) = effective_remote(remote) { let _ = git_push_refs(...) }`
block at the tail of assign / accept / reject.
- lib::print_lines(items): collapses several `for x in ws.foo()? { println!("{x}") }`
loops in command_namespace / command_queue / prop keys / prop values.

Also folds command_reject's two passes through `key.rsplit_once('-')`
into one, and uses `TaskId::default()` for the empty-flags picker
construction in command_export (one new derive on TaskId).

Net −21 src lines on top of the previous −54 (cumulative −75 since
the bloat-reduction task started). 98 tests still green; no test
churn.

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

+102 -156
+28 -49
src/lib.rs
··· 316 316 title_simple: Option<Vec<String>>, 317 317 } 318 318 319 - #[derive(Args)] 319 + #[derive(Args, Default)] 320 320 #[group(required = false, multiple = false)] 321 321 struct TaskId { 322 322 #[arg(short = 't', value_name = "ID")] ··· 375 375 supplied 376 376 .map(|s| if s.is_empty() { None } else { Some(s) }) 377 377 .unwrap_or_else(|| Some("origin".to_string())) 378 + } 379 + 380 + /// Scoped push (best-effort, silent on `-R ""`). 381 + fn auto_push_refs(ws: &Workspace, remote: Option<String>, refs: Vec<String>) { 382 + if let Some(r) = effective_remote(remote) { 383 + let _ = ws.git_push_refs(&r, &refs); 384 + } 378 385 } 379 386 380 387 fn dispatch(cli: Cli) -> Result<()> { ··· 630 637 let ws = Workspace::from_path(dir)?; 631 638 let (key, stable) = ws.assign_to_queue(task_id.into(), &target)?; 632 639 println!("Assigned to {target} as {key}"); 633 - if let Some(r) = effective_remote(remote) { 634 - let refs = ws.refs_for_assign_out(&target, &stable)?; 635 - let _ = ws.git_push_refs(&r, &refs); 636 - } 640 + auto_push_refs(&ws, remote, ws.refs_for_assign_out(&target, &stable)?); 637 641 Ok(()) 638 642 } 639 643 ··· 682 686 let key = pick_inbox_key(&ws, key)?; 683 687 let id = ws.accept_inbox(&key)?; 684 688 println!("Accepted as {id}"); 685 - if let Some(r) = effective_remote(remote) { 686 - let refs = ws.refs_for_accept_inbox(); 687 - let _ = ws.git_push_refs(&r, &refs); 688 - } 689 + auto_push_refs(&ws, remote, ws.refs_for_accept_inbox()); 689 690 Ok(()) 690 691 } 691 692 ··· 693 694 let ws = Workspace::from_path(dir)?; 694 695 let key = pick_inbox_key(&ws, key)?; 695 696 ws.reject_inbox(&key)?; 696 - if let Some((src, _)) = key.rsplit_once('-') { 697 - println!("Rejected {key} (returned to '{src}' inbox)"); 698 - } else { 699 - println!("Rejected {key}"); 697 + let source = key.rsplit_once('-').map(|(s, _)| s.to_string()); 698 + match &source { 699 + Some(src) => println!("Rejected {key} (returned to '{src}' inbox)"), 700 + None => println!("Rejected {key}"), 700 701 } 701 - if let Some(r) = effective_remote(remote) { 702 - let source = key.rsplit_once('-').map(|(s, _)| s.to_string()); 703 - if let Some(src) = source { 704 - let refs = ws.refs_for_reject_inbox(&src); 705 - let _ = ws.git_push_refs(&r, &refs); 706 - } 702 + if let Some(src) = source { 703 + auto_push_refs(&ws, remote, ws.refs_for_reject_inbox(&src)); 707 704 } 708 705 Ok(()) 709 706 } ··· 732 729 } 733 730 if identifiers.is_empty() { 734 731 // Interactive fallback: fzf single-pick. 735 - let picker = TaskId { 736 - id: None, 737 - tsk_id: None, 738 - relative_id: None, 739 - }; 740 - identifiers.push(picker.resolve_or_pick(&ws)?); 732 + identifiers.push(TaskId::default().resolve_or_pick(&ws)?); 741 733 } 742 734 // Dedupe while preserving order. 743 735 let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new(); 744 - identifiers.retain(|i| match i { 745 - TaskIdentifier::Id(id) => seen.insert(id.0), 746 - _ => true, 747 - }); 736 + identifiers.retain(|i| !matches!(i, TaskIdentifier::Id(id) if !seen.insert(id.0))); 748 737 let mbox = ws.export_tasks(&identifiers, bind)?; 749 738 print!("{mbox}"); 750 739 Ok(()) ··· 843 832 key, 844 833 value, 845 834 } => ws.unset_property(task_id.into(), &key, value.as_deref())?, 846 - PropAction::Keys => { 847 - for k in ws.property_keys()? { 848 - println!("{k}"); 849 - } 850 - } 851 - PropAction::Values { key } => { 852 - for v in ws.property_values(&key)? { 853 - println!("{v}"); 854 - } 855 - } 835 + PropAction::Keys => print_lines(ws.property_keys()?), 836 + PropAction::Values { key } => print_lines(ws.property_values(&key)?), 856 837 PropAction::Find { key, value } => { 857 838 let key = match key { 858 839 Some(k) => k, ··· 888 869 Ok(()) 889 870 } 890 871 872 + fn print_lines<I: std::fmt::Display>(items: impl IntoIterator<Item = I>) { 873 + for i in items { 874 + println!("{i}"); 875 + } 876 + } 877 + 891 878 fn command_namespace(dir: PathBuf, action: NamespaceAction) -> Result<()> { 892 879 let ws = Workspace::from_path(dir)?; 893 880 match action { 894 - NamespaceAction::List => { 895 - for n in ws.list_namespaces()? { 896 - println!("{n}"); 897 - } 898 - } 881 + NamespaceAction::List => print_lines(ws.list_namespaces()?), 899 882 NamespaceAction::Current => println!("{}", ws.namespace()), 900 883 NamespaceAction::Switch { name } => return resolve_and_switch_namespace(&ws, name), 901 884 NamespaceAction::Tasks { name } => { ··· 911 894 fn command_queue(dir: PathBuf, action: QueueAction) -> Result<()> { 912 895 let ws = Workspace::from_path(dir)?; 913 896 match action { 914 - QueueAction::List => { 915 - for n in ws.list_queues()? { 916 - println!("{n}"); 917 - } 918 - } 897 + QueueAction::List => print_lines(ws.list_queues()?), 919 898 QueueAction::Current => println!("{}", ws.queue()), 920 899 QueueAction::Create { name, can_pull } => { 921 900 ws.create_queue(&name, Some(can_pull))?;
+13
src/namespace.rs
··· 177 177 Ok(read(repo, name)?.mapping.get(&human).cloned()) 178 178 } 179 179 180 + /// Existing human id for `stable` in `name`, or a freshly-assigned one. 181 + pub fn ensure_bound( 182 + repo: &Repository, 183 + name: &str, 184 + stable: StableId, 185 + message: &str, 186 + ) -> Result<u32> { 187 + match human_for(repo, name, &stable)? { 188 + Some(h) => Ok(h), 189 + None => assign_id(repo, name, stable, message), 190 + } 191 + } 192 + 180 193 /// Reverse lookup: stable → human in the given namespace, if present. 181 194 pub fn human_for(repo: &Repository, name: &str, stable: &StableId) -> Result<Option<u32>> { 182 195 Ok(read(repo, name)?
+2 -4
src/object.rs
··· 65 65 } 66 66 } 67 67 68 - /// Local user's git signature, with a `tsk@local` fallback when the 69 - /// surrounding repo has no `user.name`/`user.email` configured. Shared 70 - /// across the namespace / queue / properties / merge writers so they all 71 - /// stamp commits the same way. 68 + /// Local user's git signature, with a `tsk@local` fallback. Shared by 69 + /// every writer so commits all carry the same author/committer. 72 70 pub(crate) fn signature(repo: &Repository) -> Signature<'static> { 73 71 repo.signature() 74 72 .map(|s| s.to_owned())
+21 -24
src/patch.rs
··· 334 334 Ok(entries) 335 335 } 336 336 337 + /// Consume one `\n`-terminated line; trailing `\r` is stripped. 338 + fn pop_line<'a>(rest: &mut &'a [u8], eof_msg: &str) -> Result<&'a str> { 339 + let nl = rest 340 + .iter() 341 + .position(|b| *b == b'\n') 342 + .ok_or_else(|| Error::Parse(eof_msg.into()))?; 343 + let line = std::str::from_utf8(&rest[..nl]) 344 + .map_err(|e| Error::Parse(e.to_string()))? 345 + .trim_end_matches('\r'); 346 + *rest = &rest[nl + 1..]; 347 + Ok(line) 348 + } 349 + 337 350 fn parse_entry(chunk: &str) -> Result<Entry> { 338 351 let mut lines = chunk.split_inclusive('\n'); 339 352 // First line: "From <oid> Mon Sep 17 ..." ··· 402 415 } else { 403 416 unmangle_from(message.trim_end_matches('\n')) 404 417 }; 405 - // Parse file blocks until END_DELIM. 418 + // Parse file blocks until END_DELIM. We need byte-level reads for the 419 + // size-prefixed bodies, so switch from `lines` to slice indexing. 406 420 let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new(); 407 - // We need byte-level reads for sizes, so switch back to slice indexing. 408 - // Compute remaining input from `lines`. 409 421 let remaining = lines.collect::<String>(); 410 422 let mut rest = remaining.as_bytes(); 411 423 loop { 412 - // Read line. 413 - let nl = rest 414 - .iter() 415 - .position(|b| *b == b'\n') 416 - .ok_or_else(|| Error::Parse("unexpected eof in tree".into()))?; 417 - let line = std::str::from_utf8(&rest[..nl]).map_err(|e| Error::Parse(e.to_string()))?; 418 - let line_trim = line.trim_end_matches('\r'); 419 - rest = &rest[nl + 1..]; 420 - if line_trim == END_DELIM { 424 + let line = pop_line(&mut rest, "unexpected eof in tree")?; 425 + if line == END_DELIM { 421 426 break; 422 427 } 423 - let name = line_trim 428 + let name = line 424 429 .strip_prefix("file: ") 425 - .ok_or_else(|| Error::Parse(format!("expected 'file:' got: {line_trim:?}")))? 430 + .ok_or_else(|| Error::Parse(format!("expected 'file:' got: {line:?}")))? 426 431 .to_string(); 427 - let nl = rest 428 - .iter() 429 - .position(|b| *b == b'\n') 430 - .ok_or_else(|| Error::Parse("unexpected eof reading size".into()))?; 431 - let size_line = std::str::from_utf8(&rest[..nl]).map_err(|e| Error::Parse(e.to_string()))?; 432 - let size_line = size_line.trim_end_matches('\r'); 433 - rest = &rest[nl + 1..]; 432 + let size_line = pop_line(&mut rest, "unexpected eof reading size")?; 434 433 let size: usize = size_line 435 434 .strip_prefix("size: ") 436 435 .ok_or_else(|| Error::Parse(format!("expected 'size:' got: {size_line:?}")))? ··· 441 440 } 442 441 let mangled = std::str::from_utf8(&rest[..size]) 443 442 .map_err(|e| Error::Parse(e.to_string()))?; 444 - let bytes = unmangle_from(mangled).into_bytes(); 445 - // Trailing \n separator (not part of size). 446 443 if rest[size] != b'\n' { 447 444 return Err(Error::Parse("missing newline after file body".into())); 448 445 } 449 446 rest = &rest[size + 1..]; 450 - files.insert(name, bytes); 447 + files.insert(name, unmangle_from(mangled).into_bytes()); 451 448 } 452 449 Ok(Entry { 453 450 author_name,
+38 -79
src/workspace.rs
··· 241 241 .ok_or_else(|| Error::Parse(format!("task {stable} content missing"))) 242 242 } 243 243 244 + fn title_for(repo: &Repository, stable: &StableId) -> Result<String> { 245 + Ok(object::read(repo, stable)? 246 + .map(|t| t.title().to_string()) 247 + .unwrap_or_default()) 248 + } 249 + 244 250 /// Create a task — or, when the content matches an existing task, 245 251 /// reopen / re-bind it instead of clobbering. 246 252 /// ··· 412 418 value: Option<&str>, 413 419 ) -> Result<Vec<(Id, StableId, String)>> { 414 420 let repo = self.repo()?; 415 - let stables = properties::find(&repo, key, value)?; 416 - let ns = namespace::read(&repo, &self.namespace())?; 417 - let mut by_stable: BTreeMap<&StableId, u32> = BTreeMap::new(); 418 - for (h, s) in &ns.mapping { 419 - by_stable.insert(s, *h); 420 - } 421 + let by_stable = ns_reverse(&namespace::read(&repo, &self.namespace())?); 421 422 let mut out = Vec::new(); 422 - for stable in stables { 423 - // Only return tasks visible in the active namespace. 424 - let Some(&human) = by_stable.get(&stable) else { 425 - continue; 426 - }; 427 - let title = object::read(&repo, &stable)? 428 - .map(|t| t.title().to_string()) 429 - .unwrap_or_default(); 430 - out.push((Id(human), stable, title)); 423 + for stable in properties::find(&repo, key, value)? { 424 + let Some(&human) = by_stable.get(&stable) else { continue }; 425 + out.push((Id(human), stable.clone(), Self::title_for(&repo, &stable)?)); 431 426 } 432 427 Ok(out) 433 428 } ··· 442 437 443 438 pub fn read_stack(&self) -> Result<Vec<StackEntry>> { 444 439 let repo = self.repo()?; 445 - let q = queue::read(&repo, &self.queue())?; 446 - let ns_name = self.namespace(); 447 - let ns = namespace::read(&repo, &ns_name)?; 448 - let mut by_stable: BTreeMap<&StableId, u32> = BTreeMap::new(); 449 - for (h, s) in &ns.mapping { 450 - by_stable.insert(s, *h); 451 - } 452 - let mut out = Vec::with_capacity(q.index.len()); 453 - for stable in q.index { 440 + let by_stable = ns_reverse(&namespace::read(&repo, &self.namespace())?); 441 + let mut out = Vec::new(); 442 + for stable in queue::read(&repo, &self.queue())?.index { 454 443 // Skip tasks not visible in the active namespace (different ns owns them). 455 - let Some(&human) = by_stable.get(&stable) else { 456 - continue; 457 - }; 458 - let title = object::read(&repo, &stable)? 459 - .map(|t| t.title().to_string()) 460 - .unwrap_or_default(); 461 - out.push(StackEntry { 462 - id: Id(human), 463 - stable, 464 - title, 465 - }); 444 + let Some(&human) = by_stable.get(&stable) else { continue }; 445 + let title = Self::title_for(&repo, &stable)?; 446 + out.push(StackEntry { id: Id(human), stable, title }); 466 447 } 467 448 Ok(out) 468 449 } ··· 471 452 /// sorted by human id ascending. Independent of any queue. 472 453 pub fn list_namespace_tasks(&self, name: &str) -> Result<Vec<StackEntry>> { 473 454 let repo = self.repo()?; 474 - let ns = namespace::read(&repo, name)?; 475 - let mut out = Vec::with_capacity(ns.mapping.len()); 476 - for (human, stable) in ns.mapping { 477 - let title = object::read(&repo, &stable)? 478 - .map(|t| t.title().to_string()) 479 - .unwrap_or_default(); 480 - out.push(StackEntry { 481 - id: Id(human), 482 - stable, 483 - title, 484 - }); 455 + let mut out = Vec::new(); 456 + for (human, stable) in namespace::read(&repo, name)?.mapping { 457 + let title = Self::title_for(&repo, &stable)?; 458 + out.push(StackEntry { id: Id(human), stable, title }); 485 459 } 486 460 Ok(out) 487 461 } ··· 554 528 let mut out = Vec::with_capacity(results.len()); 555 529 for res in results { 556 530 let bound_human = if bind { 557 - let ns = self.namespace(); 558 - let human = match namespace::human_for(&repo, &ns, &res.stable)? { 559 - Some(h) => h, 560 - None => namespace::assign_id( 561 - &repo, 562 - &ns, 563 - res.stable.clone(), 564 - "import-bind", 565 - )?, 566 - }; 567 - Some(human) 531 + Some(namespace::ensure_bound( 532 + &repo, 533 + &self.namespace(), 534 + res.stable.clone(), 535 + "import-bind", 536 + )?) 568 537 } else { 569 538 None 570 539 }; ··· 774 743 775 744 pub fn list_inbox(&self) -> Result<Vec<InboxItem>> { 776 745 let repo = self.repo()?; 777 - let q = queue::read(&repo, &self.queue())?; 778 - let mut out = Vec::with_capacity(q.inbox.len()); 779 - for (key, stable) in q.inbox { 746 + let mut out = Vec::new(); 747 + for (key, stable) in queue::read(&repo, &self.queue())?.inbox { 780 748 let source_queue = key 781 749 .rsplit_once('-') 782 750 .map(|(s, _)| s.to_string()) 783 751 .unwrap_or_else(|| key.clone()); 784 - let title = object::read(&repo, &stable)? 785 - .map(|t| t.title().to_string()) 786 - .unwrap_or_default(); 787 - out.push(InboxItem { 788 - key, 789 - source_queue, 790 - stable, 791 - title, 792 - }); 752 + let title = Self::title_for(&repo, &stable)?; 753 + out.push(InboxItem { key, source_queue, stable, title }); 793 754 } 794 755 Ok(out) 795 756 } ··· 800 761 let repo = self.repo()?; 801 762 let stable = queue::take_from_inbox(&repo, &self.queue(), key, "accept")? 802 763 .ok_or_else(|| Error::Parse(format!("Inbox item '{key}' not found")))?; 803 - let ns_name = self.namespace(); 804 - let human = match namespace::human_for(&repo, &ns_name, &stable)? { 805 - Some(h) => h, 806 - None => namespace::assign_id(&repo, &ns_name, stable.clone(), "accept-bind")?, 807 - }; 764 + let human = 765 + namespace::ensure_bound(&repo, &self.namespace(), stable.clone(), "accept-bind")?; 808 766 queue::push_top(&repo, &self.queue(), stable, "accept-push")?; 809 767 Ok(Id(human)) 810 768 } ··· 849 807 } 850 808 queue::remove(&repo, source_queue, &stable, "pulled-out")?; 851 809 queue::push_top(&repo, &cur, stable.clone(), "pull")?; 852 - let ns_name = self.namespace(); 853 - let human = match namespace::human_for(&repo, &ns_name, &stable)? { 854 - Some(h) => h, 855 - None => namespace::assign_id(&repo, &ns_name, stable, "pull-bind")?, 856 - }; 810 + let human = namespace::ensure_bound(&repo, &self.namespace(), stable, "pull-bind")?; 857 811 Ok(Id(human)) 858 812 } 859 813 ··· 1013 967 merge::fast_forward_non_task_refs(&repo, remote)?; 1014 968 Ok(merge::PullOutcome { tasks, namespaces }) 1015 969 } 970 + } 971 + 972 + /// `stable → human` reverse of a namespace mapping for O(log n) visibility checks. 973 + fn ns_reverse(ns: &namespace::Namespace) -> BTreeMap<StableId, u32> { 974 + ns.mapping.iter().map(|(h, s)| (s.clone(), *h)).collect() 1016 975 } 1017 976 1018 977 pub fn find_git_dir(start: &std::path::Path) -> Option<PathBuf> {