A file-based task manager
0
fork

Configure Feed

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

Multi-task export: -T (repeatable), --where, --all

`tsk export` now accepts multiple selectors and concatenates the
resulting mboxes:

- `-T tsk-N` is repeatable (clap Vec<Id>)
- `--where KEY=VALUE` adds every task with that property
- `--all` adds every binding in the active namespace
- no selector still drops into the fzf single-pick

Selectors are unioned; duplicates are removed before emission.

`tsk import` parses the concatenated mboxes via patch::import_mbox,
which groups entries by their X-Tsk-Stable-Id and rebuilds each
chain in order. The single-task `import_task` is now a thin wrapper
that errors if more than one chain shows up.

Smoke test: `--all` and `--where status=open` against the live repo
both produced the expected entry counts.

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

+183 -45
+68 -19
src/lib.rs
··· 113 113 /// Currently: backfill `status=open` on tasks without a status property. 114 114 /// New migrations land here as they're added. 115 115 FixUp, 116 - /// Export a task as an mbox-format patch series (one entry per commit). 116 + /// Export one or more tasks as a concatenated mbox-format patch series. 117 + /// With no -T / --where / --all, drops into fzf for a single-task pick. 117 118 /// Pipe to a file for offline transfer; recipient runs `tsk import`. 118 119 Export { 119 - #[command(flatten)] 120 - task_id: TaskId, 121 - /// Embed the task's namespace+human-id so the recipient can opt in 122 - /// to binding it on import. 120 + /// Specific task by tsk-id. Repeatable for multi-task export. 121 + #[arg(short = 'T', value_parser = parse_id)] 122 + ids: Vec<Id>, 123 + /// Property filter: `--where status=open`. Combines with -T flags. 124 + #[arg(long, value_name = "KEY=VALUE")] 125 + r#where: Option<String>, 126 + /// Export every task bound in the active namespace. 127 + #[arg(long)] 128 + all: bool, 129 + /// Embed each task's namespace+human-id in its root entry so the 130 + /// recipient can opt in to mirroring the bindings on import. 123 131 #[arg(long)] 124 132 bind: bool, 125 133 }, ··· 400 408 Workspace::from_path(dir)?.deprioritize(task_id.into()) 401 409 } 402 410 Commands::Clean => Workspace::from_path(dir)?.clean(), 403 - Commands::Export { task_id, bind } => command_export(dir, task_id, bind), 411 + Commands::Export { 412 + ids, 413 + r#where, 414 + all, 415 + bind, 416 + } => command_export(dir, ids, r#where, all, bind), 404 417 Commands::Import { bind } => command_import(dir, bind), 405 418 Commands::Log { target } => command_log(dir, target), 406 419 Commands::FixUp => { ··· 699 712 Ok(()) 700 713 } 701 714 702 - fn command_export(dir: PathBuf, task_id: TaskId, bind: bool) -> Result<()> { 715 + fn command_export( 716 + dir: PathBuf, 717 + ids: Vec<Id>, 718 + where_: Option<String>, 719 + all: bool, 720 + bind: bool, 721 + ) -> Result<()> { 703 722 let ws = Workspace::from_path(dir)?; 704 - let identifier = task_id.resolve_or_pick(&ws)?; 705 - let mbox = ws.export_task(identifier, bind)?; 723 + let mut identifiers: Vec<TaskIdentifier> = ids.into_iter().map(Into::into).collect(); 724 + if all { 725 + for entry in ws.list_namespace_tasks(&ws.namespace())? { 726 + identifiers.push(TaskIdentifier::Id(entry.id)); 727 + } 728 + } 729 + if let Some(spec) = where_ { 730 + let (key, value) = spec 731 + .split_once('=') 732 + .ok_or_else(|| errors::Error::Parse("expected --where KEY=VALUE".into()))?; 733 + for (id, _stable, _title) in ws.find_by_property(key, Some(value))? { 734 + identifiers.push(TaskIdentifier::Id(id)); 735 + } 736 + } 737 + if identifiers.is_empty() { 738 + // Interactive fallback: fzf single-pick. 739 + let picker = TaskId { 740 + id: None, 741 + tsk_id: None, 742 + relative_id: None, 743 + }; 744 + identifiers.push(picker.resolve_or_pick(&ws)?); 745 + } 746 + // Dedupe while preserving order. 747 + let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new(); 748 + identifiers.retain(|i| match i { 749 + TaskIdentifier::Id(id) => seen.insert(id.0), 750 + _ => true, 751 + }); 752 + let mbox = ws.export_tasks(&identifiers, bind)?; 706 753 print!("{mbox}"); 707 754 Ok(()) 708 755 } ··· 711 758 let ws = Workspace::from_path(dir)?; 712 759 let mut buf = String::new(); 713 760 std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?; 714 - let res = ws.import_task(&buf, bind)?; 715 - let bound = if let Some(id) = res.bound_human { 716 - format!(" bound as {}-{}", ws.namespace(), id) 717 - } else { 718 - String::new() 719 - }; 720 - println!( 721 - "Imported {} commit(s) for task {}{bound}", 722 - res.commits_imported, res.stable 723 - ); 761 + let outcomes = ws.import_task(&buf, bind)?; 762 + for res in &outcomes { 763 + let bound = if let Some(id) = res.bound_human { 764 + format!(" bound as {}-{}", ws.namespace(), id) 765 + } else { 766 + String::new() 767 + }; 768 + println!( 769 + "Imported {} commit(s) for task {}{bound}", 770 + res.commits_imported, res.stable 771 + ); 772 + } 724 773 Ok(()) 725 774 } 726 775
+64 -1
src/patch.rs
··· 200 200 pub ns_bind: Option<(String, u32)>, 201 201 } 202 202 203 + /// Convenience wrapper for the single-task path: parse the mbox, expect 204 + /// exactly one task's chain, import it. 205 + #[allow(dead_code)] // kept for tests and external callers that want strict single-task semantics 203 206 pub fn import_task(repo: &Repository, mbox: &str) -> Result<ImportResult> { 207 + let mut all = import_mbox(repo, mbox)?; 208 + if all.len() > 1 { 209 + return Err(Error::Parse(format!( 210 + "expected one task; mbox contained {}", 211 + all.len() 212 + ))); 213 + } 214 + all.pop() 215 + .ok_or_else(|| Error::Parse("no patch entries found".into())) 216 + } 217 + 218 + /// Import every task in an mbox stream. Entries are grouped by their 219 + /// `X-Tsk-Stable-Id` header (consecutive entries with the same stable id 220 + /// belong to the same task's chain) and each group is imported in order. 221 + pub fn import_mbox(repo: &Repository, mbox: &str) -> Result<Vec<ImportResult>> { 204 222 let entries = parse_mbox(mbox)?; 205 223 if entries.is_empty() { 206 224 return Err(Error::Parse("no patch entries found".into())); 207 225 } 226 + // Group consecutive entries by stable id. 227 + let mut groups: Vec<Vec<Entry>> = Vec::new(); 228 + for e in entries { 229 + match groups.last_mut() { 230 + Some(g) if g[0].stable == e.stable => g.push(e), 231 + _ => groups.push(vec![e]), 232 + } 233 + } 234 + let mut out = Vec::with_capacity(groups.len()); 235 + for group in groups { 236 + out.push(import_one_chain(repo, &group)?); 237 + } 238 + Ok(out) 239 + } 240 + 241 + fn import_one_chain(repo: &Repository, entries: &[Entry]) -> Result<ImportResult> { 208 242 let stable_hex = entries[0].stable.clone(); 209 243 let ns_bind = entries[0].ns_bind.clone(); 210 244 let mut prev: Option<Oid> = None; 211 245 for (idx, e) in entries.iter().enumerate() { 212 246 if e.stable != stable_hex { 213 247 return Err(Error::Parse(format!( 214 - "stable id mismatch across entries: {} vs {}", 248 + "stable id mismatch within chain: {} vs {}", 215 249 stable_hex, e.stable 216 250 ))); 217 251 } ··· 647 681 "Alice", 648 682 "the root commit's author must still be Alice across two hops" 649 683 ); 684 + } 685 + 686 + #[test] 687 + fn multi_task_mbox_imports_all_chains() { 688 + let dir = tempfile::tempdir().unwrap(); 689 + let src = init_repo(dir.path()); 690 + let s1 = object::create(&src, &Task::new("first task"), "create").unwrap(); 691 + let s2 = object::create(&src, &Task::new("second task"), "create").unwrap(); 692 + // Add an edit to s2 so it has a multi-commit chain — the grouping 693 + // logic must keep both of s2's entries together. 694 + let mut t2 = object::read(&src, &s2).unwrap().unwrap(); 695 + t2.properties.insert("priority".into(), vec!["low".into()]); 696 + object::update(&src, &s2, &t2, "edit-second").unwrap(); 697 + 698 + let mbox1 = export_task(&src, &s1, &ExportOpts { bind: None }).unwrap(); 699 + let mbox2 = export_task(&src, &s2, &ExportOpts { bind: None }).unwrap(); 700 + let combined = format!("{mbox1}{mbox2}"); 701 + 702 + let dst_dir = tempfile::tempdir().unwrap(); 703 + let dst = init_repo(dst_dir.path()); 704 + let outcomes = import_mbox(&dst, &combined).unwrap(); 705 + assert_eq!(outcomes.len(), 2, "two chains must yield two outcomes"); 706 + assert_eq!(outcomes[0].stable, s1); 707 + assert_eq!(outcomes[0].commits_imported, 1); 708 + assert_eq!(outcomes[1].stable, s2); 709 + assert_eq!(outcomes[1].commits_imported, 2); 710 + // Both task refs landed in the destination repo. 711 + assert!(dst.find_reference(&s1.refname()).is_ok()); 712 + assert!(dst.find_reference(&s2.refname()).is_ok()); 650 713 } 651 714 652 715 #[test]
+51 -25
src/workspace.rs
··· 63 63 } 64 64 } 65 65 66 + #[derive(Clone)] 66 67 pub enum TaskIdentifier { 67 68 Id(Id), 68 69 /// Index into the active queue's stack (0 = top). ··· 455 456 /// Export a task as an mbox-format patch series. With `bind=true`, the 456 457 /// root entry carries the active namespace's human id so the recipient 457 458 /// can opt in to mirroring the binding on import. 459 + #[allow(dead_code)] // single-task wrapper, kept for callers that don't care about batch 458 460 pub fn export_task(&self, identifier: TaskIdentifier, bind: bool) -> Result<String> { 459 - let (id, stable) = self.resolve(identifier)?; 460 - let opts = patch::ExportOpts { 461 - bind: if bind { 462 - Some((self.namespace(), id.0)) 463 - } else { 464 - None 465 - }, 466 - }; 461 + self.export_tasks(&[identifier], bind) 462 + } 463 + 464 + /// Export multiple tasks as a single concatenated mbox stream. 465 + /// Each task's full commit chain is emitted in order; the importer 466 + /// groups them back by stable id. 467 + pub fn export_tasks( 468 + &self, 469 + identifiers: &[TaskIdentifier], 470 + bind: bool, 471 + ) -> Result<String> { 467 472 let repo = self.repo()?; 468 - patch::export_task(&repo, &stable, &opts) 473 + let mut out = String::new(); 474 + for ident in identifiers { 475 + let (id, stable) = self.resolve(ident.clone())?; 476 + let opts = patch::ExportOpts { 477 + bind: if bind { 478 + Some((self.namespace(), id.0)) 479 + } else { 480 + None 481 + }, 482 + }; 483 + out.push_str(&patch::export_task(&repo, &stable, &opts)?); 484 + } 485 + Ok(out) 469 486 } 470 487 471 488 /// Import a task from an mbox patch series produced by `export_task`. 472 489 /// On `bind=true`, also bind the imported stable id into the active 473 490 /// namespace (reusing the existing human id if already bound). 474 - pub fn import_task(&self, mbox: &str, bind: bool) -> Result<ImportOutcome> { 491 + pub fn import_task(&self, mbox: &str, bind: bool) -> Result<Vec<ImportOutcome>> { 475 492 let repo = self.repo()?; 476 - let res = patch::import_task(&repo, mbox)?; 477 - let bound_human = if bind { 478 - let ns = self.namespace(); 479 - let human = match namespace::human_for(&repo, &ns, &res.stable)? { 480 - Some(h) => h, 481 - None => namespace::assign_id(&repo, &ns, res.stable.clone(), "import-bind")?, 493 + let results = patch::import_mbox(&repo, mbox)?; 494 + let mut out = Vec::with_capacity(results.len()); 495 + for res in results { 496 + let bound_human = if bind { 497 + let ns = self.namespace(); 498 + let human = match namespace::human_for(&repo, &ns, &res.stable)? { 499 + Some(h) => h, 500 + None => namespace::assign_id( 501 + &repo, 502 + &ns, 503 + res.stable.clone(), 504 + "import-bind", 505 + )?, 506 + }; 507 + Some(human) 508 + } else { 509 + None 482 510 }; 483 - Some(human) 484 - } else { 485 - None 486 - }; 487 - Ok(ImportOutcome { 488 - stable: res.stable, 489 - commits_imported: res.commits_imported, 490 - bound_human, 491 - }) 511 + out.push(ImportOutcome { 512 + stable: res.stable, 513 + commits_imported: res.commits_imported, 514 + bound_human, 515 + }); 516 + } 517 + Ok(out) 492 518 } 493 519 494 520 /// History of edits to a single task.