A file-based task manager
0
fork

Configure Feed

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

Scope assign / accept / reject / inbox auto-sync to relevant refs

Auto-push and auto-pull paths around assign/accept/reject/inbox no
longer ship the entire refs/tsk/* namespace. Each operation declares
the minimal set of refs it actually touches:

- assign-out: target queue + task ref + property indices that already
reference the task
- accept-inbox: active queue + active namespace
- reject-inbox: active queue + source queue (the bounce target)
- inbox auto-pull: just the active queue

Full `tsk git-push` / `tsk git-pull` continue to sync everything.

Also adds a -R remote flag to `tsk accept` for symmetry with assign /
reject (default "origin", empty string skips).

Integration test inspects the bare origin's refs after an assign and
asserts the active queue and namespace were *not* pushed.

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

+151 -11
+22 -7
src/lib.rs
··· 181 181 remote: Option<String>, 182 182 }, 183 183 /// Accept an inbox item by key (no key = first item). 184 - Accept { key: Option<String> }, 184 + Accept { 185 + key: Option<String>, 186 + /// Auto-push refs to this remote after accepting. Empty string skips. Default: origin. 187 + #[arg(short = 'R')] 188 + remote: Option<String>, 189 + }, 185 190 /// Reject an inbox item by key (no key = first item). 186 191 Reject { 187 192 key: Option<String>, ··· 401 406 } => command_assign(dir, target, task_id, remote), 402 407 Commands::Pull { source, task_id } => command_pull(dir, source, task_id), 403 408 Commands::Inbox { remote } => command_inbox(dir, remote), 404 - Commands::Accept { key } => command_accept(dir, key), 409 + Commands::Accept { key, remote } => command_accept(dir, key, remote), 405 410 Commands::Reject { key, remote } => command_reject(dir, key, remote), 406 411 Commands::Prop { action } => command_prop(dir, action), 407 412 Commands::Namespace { action } => command_namespace(dir, action), ··· 563 568 remote: Option<String>, 564 569 ) -> Result<()> { 565 570 let ws = Workspace::from_path(dir)?; 566 - let key = ws.assign_to_queue(task_id.into(), &target)?; 571 + let (key, stable) = ws.assign_to_queue(task_id.into(), &target)?; 567 572 println!("Assigned to {target} as {key}"); 568 573 if let Some(r) = effective_remote(remote) { 569 - let _ = ws.git_push(&r); 574 + let refs = ws.refs_for_assign_out(&target, &stable)?; 575 + let _ = ws.git_push_refs(&r, &refs); 570 576 } 571 577 Ok(()) 572 578 } ··· 585 591 fn command_inbox(dir: PathBuf, remote: Option<String>) -> Result<()> { 586 592 let ws = Workspace::from_path(dir)?; 587 593 if let Some(r) = effective_remote(remote) { 588 - let _ = ws.git_pull(&r); 594 + let refs = ws.refs_for_inbox_pull(); 595 + let _ = ws.git_fetch_refs(&r, &refs); 589 596 } 590 597 let inbox = ws.list_inbox()?; 591 598 if inbox.is_empty() { ··· 598 605 Ok(()) 599 606 } 600 607 601 - fn command_accept(dir: PathBuf, key: Option<String>) -> Result<()> { 608 + fn command_accept(dir: PathBuf, key: Option<String>, remote: Option<String>) -> Result<()> { 602 609 let ws = Workspace::from_path(dir)?; 603 610 let key = match key { 604 611 Some(k) => k, ··· 612 619 }; 613 620 let id = ws.accept_inbox(&key)?; 614 621 println!("Accepted as {id}"); 622 + if let Some(r) = effective_remote(remote) { 623 + let refs = ws.refs_for_accept_inbox(); 624 + let _ = ws.git_push_refs(&r, &refs); 625 + } 615 626 Ok(()) 616 627 } 617 628 ··· 634 645 println!("Rejected {key}"); 635 646 } 636 647 if let Some(r) = effective_remote(remote) { 637 - let _ = ws.git_push(&r); 648 + let source = key.rsplit_once('-').map(|(s, _)| s.to_string()); 649 + if let Some(src) = source { 650 + let refs = ws.refs_for_reject_inbox(&src); 651 + let _ = ws.git_push_refs(&r, &refs); 652 + } 638 653 } 639 654 Ok(()) 640 655 }
+90 -4
src/workspace.rs
··· 643 643 &self, 644 644 identifier: TaskIdentifier, 645 645 target_queue: &str, 646 - ) -> Result<String> { 646 + ) -> Result<(String, StableId)> { 647 647 let cur = self.queue(); 648 648 if target_queue == cur { 649 649 return Err(Error::Parse( ··· 656 656 let key = queue::inbox_key(&cur, id.0); 657 657 queue::add_to_inbox(&repo, target_queue, key.clone(), stable.clone(), "assign")?; 658 658 queue::remove(&repo, &cur, &stable, "assigned-out")?; 659 - Ok(key) 659 + Ok((key, stable)) 660 660 } 661 661 662 662 pub fn list_inbox(&self) -> Result<Vec<InboxItem>> { ··· 784 784 Ok(()) 785 785 } 786 786 787 + #[allow(dead_code)] // CLI calls git_pull_with_strategy directly; kept for API symmetry. 787 788 pub fn git_pull(&self, remote: &str) -> Result<()> { 788 789 self.git_pull_with_strategy(remote, merge::Strategy::default())?; 789 790 Ok(()) 790 791 } 791 792 793 + /// Push only the named refs to `remote`. Each ref is sent as its own 794 + /// `<ref>:<ref>` refspec (no force) so the operation refuses 795 + /// non-fast-forward updates the same way `git_push` does. 796 + pub fn git_push_refs(&self, remote: &str, refs: &[String]) -> Result<()> { 797 + if refs.is_empty() { 798 + return Ok(()); 799 + } 800 + let mut cmd = std::process::Command::new("git"); 801 + cmd.arg("--git-dir").arg(&self.git_dir).args(["push", remote]); 802 + for r in refs { 803 + cmd.arg(format!("{r}:{r}")); 804 + } 805 + let s = cmd.status()?; 806 + if !s.success() { 807 + return Err(Error::Parse("git push failed".into())); 808 + } 809 + Ok(()) 810 + } 811 + 812 + /// Fetch only the named refs from `remote`, force-updating each. Used 813 + /// by paths (e.g. `tsk inbox`) that need a single ref refreshed without 814 + /// the wire cost of a full `git_pull`. 815 + pub fn git_fetch_refs(&self, remote: &str, refs: &[String]) -> Result<()> { 816 + if refs.is_empty() { 817 + return Ok(()); 818 + } 819 + let mut cmd = std::process::Command::new("git"); 820 + cmd.arg("--git-dir") 821 + .arg(&self.git_dir) 822 + .args(["fetch", "--refmap=", remote]); 823 + for r in refs { 824 + cmd.arg(format!("+{r}:{r}")); 825 + } 826 + let s = cmd.status()?; 827 + if !s.success() { 828 + return Err(Error::Parse("git fetch failed".into())); 829 + } 830 + Ok(()) 831 + } 832 + 833 + /// Refs to push after `assign_to_queue`: the target queue (gained an 834 + /// inbox entry), the task ref itself (so the receiver can read the 835 + /// body), and every property index that already references this task. 836 + pub fn refs_for_assign_out( 837 + &self, 838 + target_queue: &str, 839 + stable: &StableId, 840 + ) -> Result<Vec<String>> { 841 + let repo = self.repo()?; 842 + let mut refs = vec![queue::refname(target_queue), stable.refname()]; 843 + for key in properties::list_keys(&repo)? { 844 + let entries = properties::read(&repo, &key)?; 845 + if entries.contains_key(stable) { 846 + refs.push(properties::refname(&key)); 847 + } 848 + } 849 + Ok(refs) 850 + } 851 + 852 + /// Refs to push after `accept_inbox`: the active queue (entry moved 853 + /// from inbox to index) and the active namespace (the receiver may have 854 + /// allocated a new human id binding the accepted task). 855 + pub fn refs_for_accept_inbox(&self) -> Vec<String> { 856 + vec![ 857 + queue::refname(&self.queue()), 858 + namespace::refname(&self.namespace()), 859 + ] 860 + } 861 + 862 + /// Refs to push after `reject_inbox`: the active queue (entry left the 863 + /// inbox) and the source queue (entry was bounced back into its inbox). 864 + pub fn refs_for_reject_inbox(&self, source_queue: &str) -> Vec<String> { 865 + vec![ 866 + queue::refname(&self.queue()), 867 + queue::refname(source_queue), 868 + ] 869 + } 870 + 871 + /// Refs to fetch before listing the inbox: just the active queue. 872 + pub fn refs_for_inbox_pull(&self) -> Vec<String> { 873 + vec![queue::refname(&self.queue())] 874 + } 875 + 792 876 /// Fetch into a non-clobbering shadow namespace, then reconcile each 793 877 /// task ref under the chosen strategy (default `merge`). Non-task refs 794 878 /// (namespaces/queues/property indices) still force-update from the ··· 940 1024 ws.push_task(t).unwrap(); 941 1025 let key = ws 942 1026 .assign_to_queue(TaskIdentifier::Id(id), "review") 943 - .unwrap(); 1027 + .unwrap() 1028 + .0; 944 1029 let stack = ws.read_stack().unwrap(); 945 1030 assert!(stack.is_empty()); 946 1031 ws.switch_queue("review").unwrap(); ··· 963 1048 ws.push_task(t).unwrap(); 964 1049 let assign_key = ws 965 1050 .assign_to_queue(TaskIdentifier::Id(id), "review") 966 - .unwrap(); 1051 + .unwrap() 1052 + .0; 967 1053 ws.switch_queue("review").unwrap(); 968 1054 ws.reject_inbox(&assign_key).unwrap(); 969 1055 let inbox_here = ws.list_inbox().unwrap();
+39
tests/multi_user.rs
··· 309 309 assert!(listing.contains("priority\thigh"), "alice's edit lost: {listing}"); 310 310 assert!(listing.contains("owner\tbob"), "bob's edit lost: {listing}"); 311 311 } 312 + 313 + #[test] 314 + fn assign_auto_push_only_targets_relevant_refs() { 315 + let (dir, alice, _bob) = setup_two_clones(); 316 + let origin = dir.path().join("origin.git"); 317 + 318 + // Alice creates a review queue and a task, neither pushed yet. 319 + tsk_ok(&alice, &["queue", "create", "review"]); 320 + tsk_ok(&alice, &["push", "task-to-assign"]); 321 + 322 + // Capture origin's refs before the auto-push. 323 + let before = git(&origin, &["for-each-ref", "refs/tsk/"]); 324 + assert!( 325 + !before.contains("refs/tsk/"), 326 + "origin should be empty: {before}" 327 + ); 328 + 329 + // Assign auto-pushes to origin (the default). 330 + tsk_ok(&alice, &["assign", "review", "-r", "0"]); 331 + 332 + // Origin should have *only* the target queue, the task ref, and any 333 + // property indices referencing that task. The active queue (tsk) and 334 + // namespace must NOT have been pushed. 335 + let after = git(&origin, &["for-each-ref", "refs/tsk/"]); 336 + assert!( 337 + after.contains("refs/tsk/queues/review"), 338 + "review queue must be pushed: {after}" 339 + ); 340 + let task_ref_present = after.lines().any(|l| l.contains("refs/tsk/tasks/")); 341 + assert!(task_ref_present, "task ref must be pushed: {after}"); 342 + assert!( 343 + !after.contains("refs/tsk/queues/tsk"), 344 + "active queue must NOT be pushed: {after}" 345 + ); 346 + assert!( 347 + !after.contains("refs/tsk/namespaces/"), 348 + "namespace must NOT be pushed: {after}" 349 + ); 350 + }