A file-based task manager
0
fork

Configure Feed

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

Auto-upgrade legacy git ref keys on workspace open

Earlier iterations of the git backend named per-task refs after the file
backend's filenames (refs/tsk/tasks/tsk-N.tsk). The current scheme uses just
the integer id (refs/tsk/tasks/N). Workspaces created or migrated under the
old scheme were unreadable: tsk list worked (the index ref kept the same
key) but tsk show/edit/drop failed because read_task looked under the new
key and saw nothing.

Add a one-shot rename pass in store_for that runs every time a git-backed
workspace is opened. Legacy keys (tsk-N.tsk) are renamed to the new scheme
(N); if both already exist, the legacy one is dropped.

Also adds a comprehensive command-flow test suite (run_every_command) that
mirrors each command_*'s workspace logic and runs against both backends.
This would have caught the migration bug had a legacy ref slipped into a
test fixture.

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

+231 -169
-2
.tsk/archive/tsk-1.tsk
··· 1 - implement searching task bodies 2 -
-2
.tsk/archive/tsk-10.tsk
··· 1 - foreign workspaces 2 -
-2
.tsk/archive/tsk-11.tsk
··· 1 - Use symlinks for tasks/ 2 -
-2
.tsk/archive/tsk-12.tsk
··· 1 - Update readme with changes to linking 2 -
-2
.tsk/archive/tsk-13.tsk
··· 1 - user-defined labels 2 -
-14
.tsk/archive/tsk-14.tsk
··· 1 - parse internal links from body 2 - 3 - This is some !test bold text!. 4 - here's some =highlighted text= 5 - 6 - and finally some *italics!* 7 - 8 - here's [a link](https://ngp.computer). 9 - 10 - and an internal link: [[tsk-11]]. This should add a backlink 11 - 12 - and some _underlined text_ 13 - 14 - some ~strikethrough~.
-11
.tsk/archive/tsk-15.tsk
··· 1 - Add link identification to tasks 2 - 3 - [This crate](https://docs.rs/linkify/latest/linkify/) should be helpful for 4 - that, though I've only done a cursory search. 5 - 6 - The intent here is to provide a command that allows you to list and open a link 7 - from a provided task and optionall use a system-handler to open the link. 8 - 9 - Something along the lines of `tsk hyperlinks -t 12 -s`, which will scan TSK-12 for 10 - hyperlinks (or email addresses?) and pipe them to `fzf` for selection and 11 - opening (-s flag) or simply print the link if no option is specified.
-3
.tsk/archive/tsk-16.tsk
··· 1 - Add ability to search archived tasks with find command 2 - 3 - Probably want to use `-a` flag or something
-5
.tsk/archive/tsk-17.tsk
··· 1 - Add reopen command 2 - 3 - Reopen will allow selecting an *archived* task (note: this needs to be 4 - restricted to archived tasks) and recreating the symlink in tasks/ to mark it as 5 - open.
-2
.tsk/archive/tsk-18.tsk
··· 1 - Add reindex command 2 -
-7
.tsk/archive/tsk-19.tsk
··· 1 - add "raw" output option for show 2 - 3 - Should probably be some variant of `tsk show -x` or something to skip the parsing step, 4 - just display the body of the text directly. 5 - 6 - This does suggest I should add a `format` subcommand that takes in a body and outputs 7 - the parsed + styled form, could be useful for editor plugins
-2
.tsk/archive/tsk-2.tsk
··· 1 - fix -C 2 -
-5
.tsk/archive/tsk-20.tsk
··· 1 - fix issue where links use absolute paths 2 - 3 - MacOS 4 - 5 -
-7
.tsk/archive/tsk-21.tsk
··· 1 - Add command to setup git stuff 2 - 3 - Will want to prompt to add `.tsk` to the `.git/info/exclude` file (or 4 - .gitignore/globally) and *probably* set up 5 - [metastore](https://github.com/przemoc/metastore) 6 - 7 - What else should we do?
-3
.tsk/archive/tsk-22.tsk
··· 1 - Figure out why link parsing isn't working in tsk-21 2 - 3 - Actually, it appears *all* styling is broken somehow
-2
.tsk/archive/tsk-23.tsk
··· 1 - Allow selecting which task to follow links from 2 -
-2
.tsk/archive/tsk-24.tsk
··· 1 - properly handle removing links from task 2 -
-2
.tsk/archive/tsk-25.tsk
··· 1 - fix duplicate backrefs on edit 2 -
-2
.tsk/archive/tsk-26.tsk
··· 1 - fix -T tsk-id parsing 2 -
-2
.tsk/archive/tsk-27.tsk
··· 1 - Fix -F doc on find 2 -
-17
.tsk/archive/tsk-28.tsk
··· 1 - Add tool to clean up old tasks not in index 2 - 3 - Previously the `drop` command did not remove the symlink in .tsk/tasks when a 4 - task was dropped, only removing it from the index. I don't recall if this was 5 - because my original design expected the index to be the source of truth on 6 - whether a task was prioritized or not or if I simply forgot to add it. Either 7 - way, drop now properly removes the symlink but basically every existing 8 - workspace has a bunch of junk in it. I can write a simple script that deletes 9 - all symlinks that aren't present in the index. 10 - 11 - This does suggest that I should add a reindex command to add tasks that are not 12 - present in the index but are in the tasks folder to the index at the *bottom*, 13 - preserving the existing order of the index. 14 - 15 - 16 - How about just adding a `fixup` command that does this and reindex as laid out in 17 - [[tsk-18]].
-2
.tsk/archive/tsk-29.tsk
··· 1 - style titles in list command output 2 -
-4
.tsk/archive/tsk-3.tsk
··· 1 - An example task with a body 2 - 3 - This is an example body! 4 - It has multiple lines!
-2
.tsk/archive/tsk-30.tsk
··· 1 - Add flag to only print IDs in list command 2 -
-8
.tsk/archive/tsk-31.tsk
··· 1 - DO THE THING 2 - 3 - 4 - remember to do part1 5 - 6 - and part2 7 - 8 - and part3
-5
.tsk/archive/tsk-32.tsk
··· 1 - Use git refs to store tasks 2 - 3 - When we detect we're in a git repo, store tasks in a special ref namespace called tsk 4 - instead of plain files (though if it's easiest, cache files on disk in .tsk/ as is done 5 - now.)
-4
.tsk/archive/tsk-4.tsk
··· 1 - Add basic metadata 2 - 3 - Currently have basic metadata reading done. There's nothing *writing* metadata, but 4 - we'll get there next.
-2
.tsk/archive/tsk-5.tsk
··· 1 - Add links 2 -
-6
.tsk/archive/tsk-6.tsk
··· 1 - automatically add backlinks 2 - 3 - I need to parse on save/edit/create for outgoing internal links. If any exist and their 4 - corresponding task exists, update the targetted task with a backlink reference 5 - 6 - Using [[tsk-11]] as my test.
-2
.tsk/archive/tsk-7.tsk
··· 1 - allow for creating tasks that don't go to top of stack 2 -
-2
.tsk/archive/tsk-8.tsk
··· 1 - IMAP4-based sync 2 -
-5
.tsk/archive/tsk-9.tsk
··· 1 - fix timestamp storage and parsing 2 - 3 - It looks like timestamps aren't being stored or parsed from the index anymore. 4 - I'm not quite sure how this broke, but it's like an issue in `StackItem`'s 5 - FromStr and Display implementations.
+1
.tsk/git-backed
··· 1 + /home/noah/repos/tsk/.git
-6
.tsk/index
··· 1 - tsk-15 Add link identification to tasks 1732594198 2 - tsk-9 fix timestamp storage and parsing 1732594198 3 - tsk-7 allow for creating tasks that don't go to top of stack 1732594198 4 - tsk-13 user-defined labels 1732594198 5 - tsk-18 Add reindex command 1735006716 6 - tsk-8 IMAP4-based sync 1767469318
-1
.tsk/next
··· 1 - 33
-1
.tsk/remotes
··· 1 - example /tmp/example-remote-tsk
-1
.tsk/tasks/tsk-11.tsk
··· 1 - ../archive/tsk-11.tsk
-1
.tsk/tasks/tsk-12.tsk
··· 1 - ../archive/tsk-12.tsk
-1
.tsk/tasks/tsk-13.tsk
··· 1 - ../archive/tsk-13.tsk
-1
.tsk/tasks/tsk-14.tsk
··· 1 - ../archive/tsk-14.tsk
-1
.tsk/tasks/tsk-15.tsk
··· 1 - ../archive/tsk-15.tsk
-1
.tsk/tasks/tsk-18.tsk
··· 1 - ../archive/tsk-18.tsk
-1
.tsk/tasks/tsk-19.tsk
··· 1 - ../archive/tsk-19.tsk
-1
.tsk/tasks/tsk-20.tsk
··· 1 - ../archive/tsk-20.tsk
-1
.tsk/tasks/tsk-22.tsk
··· 1 - ../archive/tsk-22.tsk
-1
.tsk/tasks/tsk-23.tsk
··· 1 - ../archive/tsk-23.tsk
-1
.tsk/tasks/tsk-24.tsk
··· 1 - ../archive/tsk-24.tsk
-1
.tsk/tasks/tsk-25.tsk
··· 1 - ../archive/tsk-25.tsk
-1
.tsk/tasks/tsk-26.tsk
··· 1 - ../archive/tsk-26.tsk
-1
.tsk/tasks/tsk-27.tsk
··· 1 - ../archive/tsk-27.tsk
-1
.tsk/tasks/tsk-4.tsk
··· 1 - ../archive/tsk-4.tsk
-1
.tsk/tasks/tsk-5.tsk
··· 1 - ../archive/tsk-5.tsk
-1
.tsk/tasks/tsk-6.tsk
··· 1 - ../archive/tsk-6.tsk
-1
.tsk/tasks/tsk-7.tsk
··· 1 - ../archive/tsk-7.tsk
-1
.tsk/tasks/tsk-8.tsk
··· 1 - ../archive/tsk-8.tsk
-1
.tsk/tasks/tsk-9.tsk
··· 1 - ../archive/tsk-9.tsk
+74 -1
src/backend.rs
··· 378 378 let marker = tsk_dir.join(GIT_BACKED_MARKER); 379 379 if marker.exists() { 380 380 let git_dir = fs::read_to_string(&marker)?.trim().to_string(); 381 - Ok(Box::new(GitStore::open(PathBuf::from(git_dir))?)) 381 + let store = GitStore::open(PathBuf::from(git_dir))?; 382 + upgrade_legacy_keys(&store)?; 383 + Ok(Box::new(store)) 382 384 } else { 383 385 Ok(Box::new(FileStore::new(tsk_dir.to_path_buf()))) 384 386 } 387 + } 388 + 389 + /// Rename legacy-scheme refs (`tasks/tsk-N.tsk`) to the current scheme 390 + /// (`tasks/N`). Older versions of the git backend named blobs after the file 391 + /// path used by the file backend; the current scheme uses just the integer id. 392 + /// Runs on every open so stale workspaces self-heal on first use. 393 + fn upgrade_legacy_keys(store: &dyn Store) -> Result<()> { 394 + for bucket in ["tasks", "archive"] { 395 + for key in store.list(bucket)? { 396 + // key looks like "tasks/<name>" — strip prefix to get the leaf. 397 + let leaf = key.split('/').next_back().unwrap_or(""); 398 + // Legacy names look like "tsk-N.tsk". New names are just "N". 399 + if let Some(num) = leaf 400 + .strip_prefix("tsk-") 401 + .and_then(|s| s.strip_suffix(".tsk")) 402 + && num.parse::<u32>().is_ok() 403 + { 404 + let new_key = format!("{bucket}/{num}"); 405 + if store.exists(&new_key)? { 406 + // New-scheme blob already present; just drop the legacy one. 407 + store.delete(&key)?; 408 + continue; 409 + } 410 + if let Some(data) = store.read(&key)? { 411 + store.write(&new_key, &data)?; 412 + store.delete(&key)?; 413 + } 414 + } 415 + } 416 + } 417 + Ok(()) 385 418 } 386 419 387 420 #[cfg(test)] ··· 502 535 write_remotes(s, &[]).unwrap(); 503 536 assert!(read_remotes(s).unwrap().is_empty()); 504 537 } 538 + } 539 + 540 + #[test] 541 + fn test_upgrade_legacy_keys_renames_old_scheme() { 542 + let dir = tempfile::tempdir().unwrap(); 543 + let root = dir.path().join("repo"); 544 + fs::create_dir_all(&root).unwrap(); 545 + run_git_init(&root); 546 + let store = GitStore::open(root.join(".git")).unwrap(); 547 + 548 + // Seed legacy-scheme refs (what older versions of tsk wrote). 549 + store.write("tasks/tsk-1.tsk", b"old\n\nbody").unwrap(); 550 + store.write("archive/tsk-2.tsk", b"old2\n\nbody2").unwrap(); 551 + // And one already-correct new-scheme ref alongside. 552 + store.write("tasks/3", b"new\n\nbody3").unwrap(); 553 + 554 + upgrade_legacy_keys(&store).unwrap(); 555 + 556 + // Legacy keys should be gone, new-scheme keys present. 557 + assert!(!store.exists("tasks/tsk-1.tsk").unwrap()); 558 + assert!(!store.exists("archive/tsk-2.tsk").unwrap()); 559 + assert_eq!(store.read("tasks/1").unwrap().as_deref(), Some(&b"old\n\nbody"[..])); 560 + assert_eq!(store.read("archive/2").unwrap().as_deref(), Some(&b"old2\n\nbody2"[..])); 561 + assert_eq!(store.read("tasks/3").unwrap().as_deref(), Some(&b"new\n\nbody3"[..])); 562 + } 563 + 564 + #[test] 565 + fn test_upgrade_legacy_keys_keeps_new_when_both_present() { 566 + let dir = tempfile::tempdir().unwrap(); 567 + let root = dir.path().join("repo"); 568 + fs::create_dir_all(&root).unwrap(); 569 + run_git_init(&root); 570 + let store = GitStore::open(root.join(".git")).unwrap(); 571 + 572 + store.write("tasks/tsk-1.tsk", b"legacy").unwrap(); 573 + store.write("tasks/1", b"current").unwrap(); 574 + upgrade_legacy_keys(&store).unwrap(); 575 + 576 + assert!(!store.exists("tasks/tsk-1.tsk").unwrap()); 577 + assert_eq!(store.read("tasks/1").unwrap().as_deref(), Some(&b"current"[..])); 505 578 } 506 579 507 580 #[test]
+156 -2
src/workspace.rs
··· 840 840 841 841 let f = std::fs::File::open(&out).unwrap(); 842 842 let mut zip = zip::ZipArchive::new(f).unwrap(); 843 - let names: std::collections::HashSet<String> = 844 - (0..zip.len()).map(|i| zip.by_index(i).unwrap().name().to_string()).collect(); 843 + let names: std::collections::HashSet<String> = (0..zip.len()) 844 + .map(|i| zip.by_index(i).unwrap().name().to_string()) 845 + .collect(); 845 846 assert!(names.contains(&format!("tasks/{}", id.0))); 846 847 assert!(names.contains("index")); 847 848 assert!(names.contains("next")); ··· 924 925 925 926 // Migrating an already-git-backed workspace fails. 926 927 assert!(ws2.migrate_to_git().is_err()); 928 + } 929 + 930 + /// Runs through every command's workspace-level logic against `ws`. Mirrors 931 + /// what main.rs's `command_*` functions do (sans interactive bits like fzf 932 + /// and $EDITOR). 933 + fn run_every_command(ws: &Workspace) { 934 + // command_push (twice): create_task → handle_metadata → push_task 935 + let t1 = ws.new_task("first".into(), "body1".into()).unwrap(); 936 + let id1 = t1.id; 937 + ws.handle_metadata(&t1, None).unwrap(); 938 + ws.push_task(t1).unwrap(); 939 + 940 + let t2 = ws.new_task("second".into(), "body2".into()).unwrap(); 941 + let id2 = t2.id; 942 + ws.handle_metadata(&t2, None).unwrap(); 943 + ws.push_task(t2).unwrap(); 944 + 945 + // command_append: append_task at the bottom 946 + let t3 = ws.new_task("third".into(), "".into()).unwrap(); 947 + let id3 = t3.id; 948 + ws.append_task(t3).unwrap(); 949 + 950 + // command_list: stack reads in expected order 951 + let stack = ws.read_stack().unwrap(); 952 + let order: Vec<_> = stack.iter().map(|i| i.id).collect(); 953 + assert_eq!(order, vec![id2, id1, id3], "{order:?}"); 954 + 955 + // command_show: read by id 956 + let shown = ws.task(TaskIdentifier::Id(id1)).unwrap(); 957 + assert_eq!(shown.title, "first"); 958 + assert_eq!(shown.body, "body1"); 959 + 960 + // command_show: read by relative position 961 + let top = ws.task(TaskIdentifier::Relative(0)).unwrap(); 962 + assert_eq!(top.id, id2); 963 + 964 + // command_edit: this is the regression suspected by the user. Mirror the 965 + // exact code path command_edit uses, sans open_editor. 966 + { 967 + let mut task = ws.task(TaskIdentifier::Id(id1)).unwrap(); 968 + let pre_links = parse_task(&task.to_string()).map(|pt| pt.intenal_links()); 969 + let new_content = format!("edited title [[{id3}]]\n\nedited body"); 970 + let (title, body) = new_content.split_once('\n').unwrap(); 971 + task.title = title.replace(['\n', '\r'], " "); 972 + task.body = body.to_string(); 973 + ws.handle_metadata(&task, pre_links).unwrap(); 974 + ws.save_task(&task).unwrap(); 975 + 976 + let reread = ws.task(TaskIdentifier::Id(id1)).unwrap(); 977 + assert!(reread.title.starts_with("edited title"), "{}", reread.title); 978 + assert_eq!(reread.body.trim(), "edited body"); 979 + // Stack title refreshed. 980 + let s = ws.read_stack().unwrap(); 981 + let item = s.iter().find(|i| i.id == id1).unwrap(); 982 + assert!(item.title.starts_with("edited title")); 983 + // Backlink from id1 → id3 should now exist. 984 + let bl3 = backend::read_backlinks(ws.store(), id3).unwrap(); 985 + assert!(bl3.contains(&id1), "edit should add backlinks: {bl3:?}"); 986 + } 987 + 988 + // Editing an archived task should leave it archived, not resurrect it. 989 + ws.drop(TaskIdentifier::Id(id3)).unwrap(); 990 + assert_eq!(backend::task_location(ws.store(), id3).unwrap(), Some(Loc::Archived)); 991 + { 992 + let mut task = ws.task(TaskIdentifier::Id(id3)).unwrap(); 993 + task.body = "edited while archived".into(); 994 + ws.save_task(&task).unwrap(); 995 + assert_eq!( 996 + backend::task_location(ws.store(), id3).unwrap(), 997 + Some(Loc::Archived), 998 + "save_task must preserve archive location" 999 + ); 1000 + let reread = ws.task(TaskIdentifier::Id(id3)).unwrap(); 1001 + assert_eq!(reread.body, "edited while archived"); 1002 + } 1003 + // Reopen so subsequent stack ops have it back. 1004 + ws.reopen(TaskIdentifier::Id(id3)).unwrap(); 1005 + 1006 + // command_swap 1007 + let before: Vec<_> = ws.read_stack().unwrap().iter().map(|i| i.id).collect(); 1008 + ws.swap_top().unwrap(); 1009 + let after: Vec<_> = ws.read_stack().unwrap().iter().map(|i| i.id).collect(); 1010 + assert_eq!(after[0], before[1]); 1011 + assert_eq!(after[1], before[0]); 1012 + ws.swap_top().unwrap(); 1013 + 1014 + // command_rot / command_tor are inverses 1015 + let before: Vec<_> = ws.read_stack().unwrap().iter().map(|i| i.id).collect(); 1016 + ws.rot().unwrap(); 1017 + ws.tor().unwrap(); 1018 + let after: Vec<_> = ws.read_stack().unwrap().iter().map(|i| i.id).collect(); 1019 + assert_eq!(before, after); 1020 + 1021 + // command_prioritize 1022 + ws.prioritize(TaskIdentifier::Id(id1)).unwrap(); 1023 + assert_eq!(ws.read_stack().unwrap().iter().next().unwrap().id, id1); 1024 + 1025 + // command_deprioritize 1026 + ws.deprioritize(TaskIdentifier::Id(id1)).unwrap(); 1027 + let s = ws.read_stack().unwrap(); 1028 + assert_eq!(s.iter().last().unwrap().id, id1); 1029 + 1030 + // command_drop 1031 + ws.drop(TaskIdentifier::Id(id1)).unwrap(); 1032 + assert!(!ws.read_stack().unwrap().iter().any(|i| i.id == id1)); 1033 + assert_eq!(backend::task_location(ws.store(), id1).unwrap(), Some(Loc::Archived)); 1034 + 1035 + // command_reopen 1036 + ws.reopen(TaskIdentifier::Id(id1)).unwrap(); 1037 + assert!(ws.read_stack().unwrap().iter().any(|i| i.id == id1)); 1038 + assert_eq!(backend::task_location(ws.store(), id1).unwrap(), Some(Loc::Active)); 1039 + 1040 + // command_clean: orphan a task in active that isn't on the stack 1041 + backend::write_task(ws.store(), Id(99_999), "orphan", "", Loc::Active).unwrap(); 1042 + assert!(backend::list_active(ws.store()).unwrap().contains(&Id(99_999))); 1043 + ws.clean().unwrap(); 1044 + assert!(!backend::list_active(ws.store()).unwrap().contains(&Id(99_999))); 1045 + assert!(backend::list_archive(ws.store()).unwrap().contains(&Id(99_999))); 1046 + 1047 + // command_remote (List/Add/Remove) 1048 + assert!(ws.read_remotes().unwrap().is_empty()); 1049 + ws.add_remote("up", "/tmp/p").unwrap(); 1050 + assert_eq!(ws.read_remotes().unwrap().len(), 1); 1051 + assert!(ws.add_remote("up", "/tmp/q").is_err()); // duplicate 1052 + assert!(ws.remove_remote("nope").is_err()); // nonexistent 1053 + ws.remove_remote("up").unwrap(); 1054 + assert!(ws.read_remotes().unwrap().is_empty()); 1055 + 1056 + // command_export: writes a zip with all blobs 1057 + let dest = ws.path.join("exp.zip"); 1058 + ws.export_zip(&dest).unwrap(); 1059 + assert!(dest.exists()); 1060 + let f = std::fs::File::open(&dest).unwrap(); 1061 + let zip = zip::ZipArchive::new(f).unwrap(); 1062 + assert!(zip.len() >= 2, "export contains at least index + tasks"); 1063 + std::fs::remove_file(&dest).unwrap(); 1064 + } 1065 + 1066 + #[test] 1067 + fn test_every_command_file_backend() { 1068 + let dir = tempfile::tempdir().unwrap(); 1069 + Workspace::init(dir.path().to_path_buf()).unwrap(); 1070 + let ws = Workspace::from_path(dir.path().to_path_buf()).unwrap(); 1071 + run_every_command(&ws); 1072 + } 1073 + 1074 + #[test] 1075 + fn test_every_command_git_backend() { 1076 + let dir = tempfile::tempdir().unwrap(); 1077 + run_git_init(dir.path()); 1078 + Workspace::init(dir.path().to_path_buf()).unwrap(); 1079 + let ws = Workspace::from_path(dir.path().to_path_buf()).unwrap(); 1080 + run_every_command(&ws); 927 1081 } 928 1082 929 1083 #[test]