A file-based task manager
0
fork

Configure Feed

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

Add migrate command for file-to-git workspace conversion

Run tsk migrate when an existing file-backed workspace is now inside a git
repository. All blobs (tasks, archive, attrs, backlinks, index, next,
remotes) are copied into refs/tsk/* and the on-disk task data under .tsk/
is removed, leaving only the git-backed marker. The command refuses to run
if the workspace is already git-backed or if no enclosing git repo exists.

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

+210 -23
+14 -6
src/backend.rs
··· 105 105 } 106 106 Ok(out) 107 107 } 108 - 109 108 } 110 109 111 110 // ─── GitStore ─────────────────────────────────────────────────────────────── ··· 139 138 let obj = r 140 139 .peel(ObjectType::Blob) 141 140 .map_err(|e| Error::Parse(format!("peel: {e}")))?; 142 - let blob = obj.as_blob().ok_or_else(|| Error::Parse("not a blob".into()))?; 141 + let blob = obj 142 + .as_blob() 143 + .ok_or_else(|| Error::Parse("not a blob".into()))?; 143 144 Ok(Some((obj.id(), blob.content().to_vec()))) 144 145 } 145 146 ··· 199 200 } 200 201 Ok(out) 201 202 } 202 - 203 203 } 204 204 205 205 // ─── High-level operations over any Store ─────────────────────────────────── ··· 261 261 if from_key == to_key { 262 262 return Ok(()); 263 263 } 264 - let data = store.read(&from_key)?.ok_or_else(|| Error::Parse(format!("task {id} not present at {from_key}")))?; 264 + let data = store 265 + .read(&from_key)? 266 + .ok_or_else(|| Error::Parse(format!("task {id} not present at {from_key}")))?; 265 267 store.write(&to_key, &data)?; 266 268 store.delete(&from_key)?; 267 269 Ok(()) ··· 486 488 for s in [file.as_ref(), git.as_ref()] { 487 489 assert!(read_remotes(s).unwrap().is_empty()); 488 490 let remotes = vec![ 489 - Remote { prefix: "a".into(), path: PathBuf::from("/x") }, 490 - Remote { prefix: "b".into(), path: PathBuf::from("/y") }, 491 + Remote { 492 + prefix: "a".into(), 493 + path: PathBuf::from("/x"), 494 + }, 495 + Remote { 496 + prefix: "b".into(), 497 + path: PathBuf::from("/y"), 498 + }, 491 499 ]; 492 500 write_remotes(s, &remotes).unwrap(); 493 501 assert_eq!(read_remotes(s).unwrap(), remotes);
+13
src/main.rs
··· 192 192 gitignore: bool, 193 193 }, 194 194 195 + /// Migrate a file-backed workspace to a git-backed one. The directory must 196 + /// now be inside a git repository (run `git init` first if needed). All 197 + /// task data is copied into refs/tsk/* and the on-disk files are removed. 198 + Migrate, 199 + 195 200 /// Reopens an archived task, recreating the symlink and adding it back to the stack. 196 201 Reopen { 197 202 #[command(flatten)] ··· 323 328 Commands::Clean => command_clean(dir), 324 329 Commands::Remote { action } => command_remote(dir, action), 325 330 Commands::GitSetup { gitignore } => command_git_setup(dir, gitignore), 331 + Commands::Migrate => command_migrate(dir), 326 332 Commands::Reopen { task_id } => command_reopen(dir, task_id), 327 333 }; 328 334 let _ = sync_dir; ··· 625 631 .open(&ignore_file)?; 626 632 writeln!(file, ".tsk/")?; 627 633 eprintln!("Added .tsk/ to {label}."); 634 + Ok(()) 635 + } 636 + 637 + fn command_migrate(dir: PathBuf) -> Result<()> { 638 + let workspace = Workspace::from_path(dir)?; 639 + let git_dir = workspace.migrate_to_git()?; 640 + eprintln!("Migrated workspace to git refs (git dir: {})", git_dir.display()); 628 641 Ok(()) 629 642 } 630 643
+5 -1
src/stack.rs
··· 62 62 let modify_time = UNIX_EPOCH 63 63 .checked_add(Duration::from_secs(index_epoch)) 64 64 .unwrap_or(UNIX_EPOCH); 65 - Ok(Self { id, title, modify_time }) 65 + Ok(Self { 66 + id, 67 + title, 68 + modify_time, 69 + }) 66 70 } 67 71 } 68 72
+178 -16
src/workspace.rs
··· 105 105 pub fn from_path(path: PathBuf) -> Result<Self> { 106 106 let tsk_dir = util::find_parent_with_dir(path, ".tsk")?.ok_or(Error::Uninitialized)?; 107 107 let store = backend::store_for(&tsk_dir)?; 108 - Ok(Self { path: tsk_dir, store }) 108 + Ok(Self { 109 + path: tsk_dir, 110 + store, 111 + }) 109 112 } 110 113 111 114 pub fn store(&self) -> &dyn Store { ··· 124 127 let stack_item = stack.get(r as usize).ok_or(Error::NoTasks)?; 125 128 Ok(stack_item.id) 126 129 } 127 - TaskIdentifier::Find { exclude_body, archived } => self 130 + TaskIdentifier::Find { 131 + exclude_body, 132 + archived, 133 + } => self 128 134 .search(None, !exclude_body, archived)? 129 135 .ok_or(Error::NotSelected), 130 136 } ··· 137 143 pub fn new_task(&self, title: String, body: String) -> Result<Task> { 138 144 let id = self.next_id()?; 139 145 backend::write_task(self.store(), id, &title, &body, Loc::Active)?; 140 - Ok(Task { id, title, body, attributes: Default::default() }) 146 + Ok(Task { 147 + id, 148 + title, 149 + body, 150 + attributes: Default::default(), 151 + }) 141 152 } 142 153 143 154 pub fn task(&self, identifier: TaskIdentifier) -> Result<Task> { ··· 319 330 ], 320 331 )?) 321 332 } else { 322 - Ok(fzf::select::<_, Id, _>(all_tasks, ["--delimiter=\t", "--accept-nth=1"])?) 333 + Ok(fzf::select::<_, Id, _>( 334 + all_tasks, 335 + ["--delimiter=\t", "--accept-nth=1"], 336 + )?) 323 337 } 324 338 } else if search_body { 325 - let loader = LazyTaskLoader { items: stack.into_iter(), workspace: self }; 339 + let loader = LazyTaskLoader { 340 + items: stack.into_iter(), 341 + workspace: self, 342 + }; 326 343 Ok(fzf::select::<_, Id, _>( 327 344 loader, 328 345 [ ··· 337 354 ], 338 355 )?) 339 356 } else { 340 - Ok(fzf::select::<_, Id, _>(stack, ["--delimiter=\t", "--accept-nth=1"])?) 357 + Ok(fzf::select::<_, Id, _>( 358 + stack, 359 + ["--delimiter=\t", "--accept-nth=1"], 360 + )?) 341 361 } 342 362 } 343 363 ··· 414 434 Ok(Some(task)) 415 435 } 416 436 437 + /// Migrate a file-backed workspace to a git-backed one. Returns Err if the 438 + /// workspace is already git-backed or if no enclosing git repo is found. 439 + /// All blobs are copied into refs/tsk/* and the on-disk task data is then 440 + /// removed, leaving only the `.tsk/git-backed` marker. 441 + pub fn migrate_to_git(&self) -> Result<PathBuf> { 442 + if self.is_git_backed() { 443 + return Err(Error::Parse("Workspace is already git-backed".into())); 444 + } 445 + let git_dir = backend::detect_git_dir(&self.path) 446 + .ok_or_else(|| Error::Parse("No enclosing git repository found".into()))?; 447 + let dest = backend::GitStore::open(git_dir.clone())?; 448 + // Copy every logical blob across. 449 + let prefixes = ["tasks", "archive", "attrs", "backlinks"]; 450 + for prefix in prefixes { 451 + for key in self.store().list(prefix)? { 452 + if let Some(data) = self.store().read(&key)? { 453 + dest.write(&key, &data)?; 454 + } 455 + } 456 + } 457 + for top in ["index", "next", "remotes"] { 458 + if let Some(data) = self.store().read(top)? { 459 + dest.write(top, &data)?; 460 + } 461 + } 462 + // Drop on-disk file backend state: everything under .tsk/ except the 463 + // marker we're about to write. 464 + for entry in std::fs::read_dir(&self.path)? { 465 + let entry = entry?; 466 + let p = entry.path(); 467 + if p.is_dir() { 468 + std::fs::remove_dir_all(&p)?; 469 + } else { 470 + std::fs::remove_file(&p)?; 471 + } 472 + } 473 + std::fs::write( 474 + self.path.join(backend::GIT_BACKED_MARKER), 475 + git_dir.to_string_lossy().as_bytes(), 476 + )?; 477 + Ok(git_dir) 478 + } 479 + 417 480 pub fn reopen(&self, identifier: TaskIdentifier) -> Result<Id> { 418 481 let id = self.resolve(identifier)?; 419 482 match backend::task_location(self.store(), id)? { ··· 450 513 451 514 impl Task { 452 515 fn bare(self) -> SearchTask { 453 - SearchTask { id: self.id, title: self.title, body: self.body } 516 + SearchTask { 517 + id: self.id, 518 + title: self.title, 519 + body: self.body, 520 + } 454 521 } 455 522 } 456 523 ··· 509 576 Workspace::init(git_root.clone()).unwrap(); 510 577 let f = Workspace::from_path(file_root).unwrap(); 511 578 let g = Workspace::from_path(git_root).unwrap(); 512 - assert!(!f.is_git_backed(), "file workspace should not be git-backed"); 579 + assert!( 580 + !f.is_git_backed(), 581 + "file workspace should not be git-backed" 582 + ); 513 583 assert!(g.is_git_backed(), "git workspace should be git-backed"); 514 584 (dir, f, g) 515 585 } 516 586 517 587 fn run_full_lifecycle(ws: &Workspace) { 518 588 // Push two tasks, drop one, verify state. 519 - let t1 = ws.new_task("First".to_string(), "body one".to_string()).unwrap(); 589 + let t1 = ws 590 + .new_task("First".to_string(), "body one".to_string()) 591 + .unwrap(); 520 592 let id1 = t1.id; 521 593 ws.push_task(t1).unwrap(); 522 - let t2 = ws.new_task("Second".to_string(), "body two".to_string()).unwrap(); 594 + let t2 = ws 595 + .new_task("Second".to_string(), "body two".to_string()) 596 + .unwrap(); 523 597 let id2 = t2.id; 524 598 ws.push_task(t2).unwrap(); 525 599 ··· 555 629 assert_eq!(read.title, "First (edited)"); 556 630 let stack = ws.read_stack().unwrap(); 557 631 let item = stack.iter().find(|i| i.id == id1).unwrap(); 558 - assert_eq!(item.title, "First (edited)", "stack title should refresh on save"); 632 + assert_eq!( 633 + item.title, "First (edited)", 634 + "stack title should refresh on save" 635 + ); 559 636 560 637 // Remotes. 561 638 ws.add_remote("up", "/path").unwrap(); ··· 648 725 let id = t.id; 649 726 ws.push_task(t).unwrap(); 650 727 ws.drop(TaskIdentifier::Id(id)).unwrap(); 651 - assert_eq!(backend::task_location(ws.store(), id).unwrap(), Some(Loc::Archived)); 728 + assert_eq!( 729 + backend::task_location(ws.store(), id).unwrap(), 730 + Some(Loc::Archived) 731 + ); 652 732 } 653 733 } 654 734 ··· 672 752 ws.tor().unwrap(); 673 753 let s = ws.read_stack().unwrap(); 674 754 let order: Vec<_> = s.iter().map(|i| i.id).collect(); 675 - assert_eq!(order, vec![ids[2], ids[1], ids[0]], "rot then tor is identity"); 755 + assert_eq!( 756 + order, 757 + vec![ids[2], ids[1], ids[0]], 758 + "rot then tor is identity" 759 + ); 676 760 } 677 761 } 678 762 679 763 #[test] 680 764 fn test_remote_display() { 681 - let r = Remote { prefix: "jira".into(), path: PathBuf::from("/p") }; 765 + let r = Remote { 766 + prefix: "jira".into(), 767 + path: PathBuf::from("/p"), 768 + }; 682 769 assert_eq!(r.to_string(), "jira\t/p"); 683 770 } 684 771 685 772 #[test] 686 773 fn test_bare_task_display() { 687 - let t = SearchTask { id: Id(1), title: "x".into(), body: "y".into() }; 774 + let t = SearchTask { 775 + id: Id(1), 776 + title: "x".into(), 777 + body: "y".into(), 778 + }; 688 779 assert_eq!(t.to_string(), "tsk-1\tx\n\ny"); 689 780 } 690 781 691 782 #[test] 692 783 fn test_task_display() { 693 - let t = Task { id: Id(1), title: "x".into(), body: "y".into(), attributes: Default::default() }; 784 + let t = Task { 785 + id: Id(1), 786 + title: "x".into(), 787 + body: "y".into(), 788 + attributes: Default::default(), 789 + }; 694 790 assert_eq!(t.to_string(), "x\n\ny"); 791 + } 792 + 793 + #[test] 794 + fn test_migrate_file_to_git() { 795 + let dir = tempfile::tempdir().unwrap(); 796 + let root = dir.path().to_path_buf(); 797 + // Init as file-backed (no git yet). 798 + Workspace::init(root.clone()).unwrap(); 799 + let ws = Workspace::from_path(root.clone()).unwrap(); 800 + assert!(!ws.is_git_backed()); 801 + 802 + // Populate some state. 803 + let t1 = ws.new_task("Active".into(), "body1".into()).unwrap(); 804 + let id1 = t1.id; 805 + ws.push_task(t1).unwrap(); 806 + let t2 = ws.new_task("Will archive".into(), "body2".into()).unwrap(); 807 + let id2 = t2.id; 808 + ws.push_task(t2).unwrap(); 809 + ws.drop(TaskIdentifier::Id(id2)).unwrap(); 810 + ws.add_remote("up", "/path").unwrap(); 811 + let mut t = ws.task(TaskIdentifier::Id(id1)).unwrap(); 812 + t.attributes.insert("k".into(), "v".into()); 813 + ws.save_task(&t).unwrap(); 814 + ws.handle_metadata( 815 + &Task { 816 + id: id1, 817 + title: "x".into(), 818 + body: format!("see [[{id2}]]"), 819 + attributes: Default::default(), 820 + }, 821 + None, 822 + ) 823 + .unwrap(); 824 + 825 + // Migration before git init must fail. 826 + assert!(ws.migrate_to_git().is_err()); 827 + 828 + // Now turn the directory into a git repo and migrate. 829 + run_git_init(&root); 830 + ws.migrate_to_git().unwrap(); 831 + 832 + // Re-open the workspace (picks up the new marker → GitStore). 833 + let ws2 = Workspace::from_path(root.clone()).unwrap(); 834 + assert!(ws2.is_git_backed()); 835 + 836 + // All on-disk task data should be gone except the marker. 837 + let entries: Vec<_> = std::fs::read_dir(ws2.path.clone()) 838 + .unwrap() 839 + .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().to_string())) 840 + .collect(); 841 + assert_eq!(entries, vec!["git-backed".to_string()]); 842 + 843 + // State preserved. 844 + let stack = ws2.read_stack().unwrap(); 845 + let ids: Vec<_> = stack.iter().map(|i| i.id).collect(); 846 + assert_eq!(ids, vec![id1]); 847 + let read = ws2.task(TaskIdentifier::Id(id1)).unwrap(); 848 + assert_eq!(read.title, "Active"); 849 + assert_eq!(read.attributes.get("k"), Some(&"v".to_string())); 850 + assert_eq!(backend::task_location(ws2.store(), id2).unwrap(), Some(Loc::Archived)); 851 + let bl = backend::read_backlinks(ws2.store(), id2).unwrap(); 852 + assert!(bl.contains(&id1)); 853 + assert_eq!(ws2.read_remotes().unwrap().len(), 1); 854 + 855 + // Migrating an already-git-backed workspace fails. 856 + assert!(ws2.migrate_to_git().is_err()); 695 857 } 696 858 697 859 #[test]