A file-based task manager
0
fork

Configure Feed

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

Add git-push, git-pull, and refspec setup for tsk refs

Custom refs under refs/tsk/* aren't included in git push/fetch by default.
Add three integration points so they can be synced across clones:

- tsk git-push <remote>: shells out to git push <remote> refs/tsk/*:refs/tsk/*
- tsk git-pull <remote>: git fetch <remote> +refs/tsk/*:refs/tsk/*
- tsk git-setup -r <remote>: appends push/fetch refspec config so plain
git push <remote> / git fetch <remote> include refs/tsk/* going forward.
Idempotent: running twice does not duplicate the entries.

Test: a real bare git remote round-trip — push from one workspace, pull
into a fresh workspace, confirm task content + stack survive the trip and
configure_git_remote_refspecs is idempotent. Errors are surfaced when any
of the three are invoked on a file-backed workspace.

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

+241 -12
+16 -4
src/backend.rs
··· 556 556 // Legacy keys should be gone, new-scheme keys present. 557 557 assert!(!store.exists("tasks/tsk-1.tsk").unwrap()); 558 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"[..])); 559 + assert_eq!( 560 + store.read("tasks/1").unwrap().as_deref(), 561 + Some(&b"old\n\nbody"[..]) 562 + ); 563 + assert_eq!( 564 + store.read("archive/2").unwrap().as_deref(), 565 + Some(&b"old2\n\nbody2"[..]) 566 + ); 567 + assert_eq!( 568 + store.read("tasks/3").unwrap().as_deref(), 569 + Some(&b"new\n\nbody3"[..]) 570 + ); 562 571 } 563 572 564 573 #[test] ··· 574 583 upgrade_legacy_keys(&store).unwrap(); 575 584 576 585 assert!(!store.exists("tasks/tsk-1.tsk").unwrap()); 577 - assert_eq!(store.read("tasks/1").unwrap().as_deref(), Some(&b"current"[..])); 586 + assert_eq!( 587 + store.read("tasks/1").unwrap().as_deref(), 588 + Some(&b"current"[..]) 589 + ); 578 590 } 579 591 580 592 #[test]
+34 -2
src/main.rs
··· 190 190 /// Use .gitignore instead of .git/info/exclude. 191 191 #[arg(short = 'g', default_value_t = false)] 192 192 gitignore: bool, 193 + /// Also configure push/fetch refspecs on the named remote so refs/tsk/* 194 + /// is included in `git push <remote>` and `git fetch <remote>`. 195 + #[arg(short = 'r')] 196 + remote: Option<String>, 197 + }, 198 + 199 + /// Push refs/tsk/* to a git remote so other clones can pull task state. 200 + GitPush { 201 + /// Remote name (e.g. origin). 202 + remote: String, 203 + }, 204 + 205 + /// Fetch refs/tsk/* from a git remote, overwriting local task state. 206 + GitPull { 207 + /// Remote name (e.g. origin). 208 + remote: String, 193 209 }, 194 210 195 211 /// Export the entire workspace (tasks, archive, attrs, backlinks, index, ··· 336 352 Commands::Deprioritize { task_id } => command_deprioritize(dir, task_id), 337 353 Commands::Clean => command_clean(dir), 338 354 Commands::Remote { action } => command_remote(dir, action), 339 - Commands::GitSetup { gitignore } => command_git_setup(dir, gitignore), 355 + Commands::GitSetup { gitignore, remote } => command_git_setup(dir, gitignore, remote), 356 + Commands::GitPush { remote } => command_git_push(dir, remote), 357 + Commands::GitPull { remote } => command_git_pull(dir, remote), 340 358 Commands::Export { output } => command_export(dir, output), 341 359 Commands::Migrate => command_migrate(dir), 342 360 Commands::Reopen { task_id } => command_reopen(dir, task_id), ··· 612 630 Ok(()) 613 631 } 614 632 615 - fn command_git_setup(dir: PathBuf, use_gitignore: bool) -> Result<()> { 633 + fn command_git_push(dir: PathBuf, remote: String) -> Result<()> { 634 + let workspace = Workspace::from_path(dir)?; 635 + workspace.git_push_refs(&remote) 636 + } 637 + 638 + fn command_git_pull(dir: PathBuf, remote: String) -> Result<()> { 639 + let workspace = Workspace::from_path(dir)?; 640 + workspace.git_pull_refs(&remote) 641 + } 642 + 643 + fn command_git_setup(dir: PathBuf, use_gitignore: bool, remote: Option<String>) -> Result<()> { 616 644 let workspace = Workspace::from_path(dir)?; 617 645 let git_dir = workspace.path.join(".git"); 618 646 if !git_dir.exists() { ··· 641 669 .open(&ignore_file)?; 642 670 writeln!(file, ".tsk/")?; 643 671 eprintln!("Added .tsk/ to {label}."); 672 + if let Some(remote) = remote { 673 + workspace.configure_git_remote_refspecs(&remote)?; 674 + eprintln!("Configured push/fetch refspecs on remote '{remote}' for refs/tsk/*"); 675 + } 644 676 Ok(()) 645 677 } 646 678
+191 -6
src/workspace.rs
··· 434 434 Ok(Some(task)) 435 435 } 436 436 437 + fn require_git_dir(&self) -> Result<PathBuf> { 438 + if !self.is_git_backed() { 439 + return Err(Error::Parse("Workspace is not git-backed".into())); 440 + } 441 + let marker = std::fs::read_to_string(self.path.join(backend::GIT_BACKED_MARKER))?; 442 + Ok(PathBuf::from(marker.trim())) 443 + } 444 + 445 + fn run_git(&self, args: &[&str]) -> Result<()> { 446 + let git_dir = self.require_git_dir()?; 447 + let status = std::process::Command::new("git") 448 + .arg("--git-dir") 449 + .arg(&git_dir) 450 + .args(args) 451 + .status()?; 452 + if !status.success() { 453 + return Err(Error::Parse(format!("git {args:?} exited with {status}"))); 454 + } 455 + Ok(()) 456 + } 457 + 458 + /// Push every refs/tsk/* ref to the given remote. 459 + pub fn git_push_refs(&self, remote: &str) -> Result<()> { 460 + self.run_git(&["push", remote, "refs/tsk/*:refs/tsk/*"]) 461 + } 462 + 463 + /// Fetch every refs/tsk/* ref from the given remote, overwriting locally. 464 + pub fn git_pull_refs(&self, remote: &str) -> Result<()> { 465 + self.run_git(&["fetch", remote, "+refs/tsk/*:refs/tsk/*"]) 466 + } 467 + 468 + /// Configure git so future `git push <remote>` / `git fetch <remote>` 469 + /// include the tsk ref namespace. Idempotent. 470 + pub fn configure_git_remote_refspecs(&self, remote: &str) -> Result<()> { 471 + let git_dir = self.require_git_dir()?; 472 + for (key, value) in [ 473 + (format!("remote.{remote}.push"), "refs/tsk/*:refs/tsk/*"), 474 + (format!("remote.{remote}.fetch"), "+refs/tsk/*:refs/tsk/*"), 475 + ] { 476 + // Read existing values; skip if our refspec is already present. 477 + let existing = std::process::Command::new("git") 478 + .arg("--git-dir") 479 + .arg(&git_dir) 480 + .args(["config", "--get-all", &key]) 481 + .output()?; 482 + let existing_text = String::from_utf8_lossy(&existing.stdout); 483 + if existing_text.lines().any(|l| l.trim() == value) { 484 + continue; 485 + } 486 + let status = std::process::Command::new("git") 487 + .arg("--git-dir") 488 + .arg(&git_dir) 489 + .args(["config", "--add", &key, value]) 490 + .status()?; 491 + if !status.success() { 492 + return Err(Error::Parse(format!("git config --add {key} failed"))); 493 + } 494 + } 495 + Ok(()) 496 + } 497 + 437 498 /// Write a zip archive containing every blob in the workspace. Layout in the 438 499 /// zip mirrors the logical key namespace (`tasks/<id>`, `archive/<id>`, 439 500 /// `attrs/<id>`, `backlinks/<id>`, `index`, `next`, `remotes`). ··· 987 1048 988 1049 // Editing an archived task should leave it archived, not resurrect it. 989 1050 ws.drop(TaskIdentifier::Id(id3)).unwrap(); 990 - assert_eq!(backend::task_location(ws.store(), id3).unwrap(), Some(Loc::Archived)); 1051 + assert_eq!( 1052 + backend::task_location(ws.store(), id3).unwrap(), 1053 + Some(Loc::Archived) 1054 + ); 991 1055 { 992 1056 let mut task = ws.task(TaskIdentifier::Id(id3)).unwrap(); 993 1057 task.body = "edited while archived".into(); ··· 1030 1094 // command_drop 1031 1095 ws.drop(TaskIdentifier::Id(id1)).unwrap(); 1032 1096 assert!(!ws.read_stack().unwrap().iter().any(|i| i.id == id1)); 1033 - assert_eq!(backend::task_location(ws.store(), id1).unwrap(), Some(Loc::Archived)); 1097 + assert_eq!( 1098 + backend::task_location(ws.store(), id1).unwrap(), 1099 + Some(Loc::Archived) 1100 + ); 1034 1101 1035 1102 // command_reopen 1036 1103 ws.reopen(TaskIdentifier::Id(id1)).unwrap(); 1037 1104 assert!(ws.read_stack().unwrap().iter().any(|i| i.id == id1)); 1038 - assert_eq!(backend::task_location(ws.store(), id1).unwrap(), Some(Loc::Active)); 1105 + assert_eq!( 1106 + backend::task_location(ws.store(), id1).unwrap(), 1107 + Some(Loc::Active) 1108 + ); 1039 1109 1040 1110 // command_clean: orphan a task in active that isn't on the stack 1041 1111 backend::write_task(ws.store(), Id(99_999), "orphan", "", Loc::Active).unwrap(); 1042 - assert!(backend::list_active(ws.store()).unwrap().contains(&Id(99_999))); 1112 + assert!( 1113 + backend::list_active(ws.store()) 1114 + .unwrap() 1115 + .contains(&Id(99_999)) 1116 + ); 1043 1117 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))); 1118 + assert!( 1119 + !backend::list_active(ws.store()) 1120 + .unwrap() 1121 + .contains(&Id(99_999)) 1122 + ); 1123 + assert!( 1124 + backend::list_archive(ws.store()) 1125 + .unwrap() 1126 + .contains(&Id(99_999)) 1127 + ); 1046 1128 1047 1129 // command_remote (List/Add/Remove) 1048 1130 assert!(ws.read_remotes().unwrap().is_empty()); ··· 1078 1160 Workspace::init(dir.path().to_path_buf()).unwrap(); 1079 1161 let ws = Workspace::from_path(dir.path().to_path_buf()).unwrap(); 1080 1162 run_every_command(&ws); 1163 + } 1164 + 1165 + #[test] 1166 + fn test_git_push_pull_and_refspec_config() { 1167 + // Set up a bare "remote" and a working repo, both git-backed tsk 1168 + // workspaces. Push from one, pull into another. 1169 + let dir = tempfile::tempdir().unwrap(); 1170 + let remote_dir = dir.path().join("remote.git"); 1171 + let work_dir = dir.path().join("work"); 1172 + let clone_dir = dir.path().join("clone"); 1173 + std::fs::create_dir_all(&remote_dir).unwrap(); 1174 + std::fs::create_dir_all(&work_dir).unwrap(); 1175 + std::fs::create_dir_all(&clone_dir).unwrap(); 1176 + 1177 + // Bare remote. 1178 + let s = std::process::Command::new("git") 1179 + .args(["init", "--bare", "-q"]) 1180 + .current_dir(&remote_dir) 1181 + .status() 1182 + .unwrap(); 1183 + assert!(s.success()); 1184 + 1185 + // Working repo + tsk init. 1186 + run_git_init(&work_dir); 1187 + Workspace::init(work_dir.clone()).unwrap(); 1188 + let ws = Workspace::from_path(work_dir.clone()).unwrap(); 1189 + // Add the bare repo as `origin`. 1190 + let s = std::process::Command::new("git") 1191 + .args(["remote", "add", "origin"]) 1192 + .arg(&remote_dir) 1193 + .current_dir(&work_dir) 1194 + .status() 1195 + .unwrap(); 1196 + assert!(s.success()); 1197 + 1198 + // Pushing without configured refspecs (using the explicit refspec form). 1199 + let t = ws.new_task("task one".into(), "body".into()).unwrap(); 1200 + let id = t.id; 1201 + ws.push_task(t).unwrap(); 1202 + ws.git_push_refs("origin").unwrap(); 1203 + 1204 + // Confirm refs landed on the remote. 1205 + let out = std::process::Command::new("git") 1206 + .args(["--git-dir"]) 1207 + .arg(&remote_dir) 1208 + .args(["for-each-ref", "--format=%(refname)", "refs/tsk/"]) 1209 + .output() 1210 + .unwrap(); 1211 + let names = String::from_utf8_lossy(&out.stdout); 1212 + assert!(names.contains(&format!("refs/tsk/tasks/{}", id.0)), "{names}"); 1213 + assert!(names.contains("refs/tsk/index")); 1214 + 1215 + // Now configure refspecs on the working repo and confirm `git push origin` 1216 + // (with no refspec) sends refs/tsk/*. 1217 + ws.configure_git_remote_refspecs("origin").unwrap(); 1218 + let cfg = std::process::Command::new("git") 1219 + .args(["config", "--get-all", "remote.origin.push"]) 1220 + .current_dir(&work_dir) 1221 + .output() 1222 + .unwrap(); 1223 + let push_cfg = String::from_utf8_lossy(&cfg.stdout); 1224 + assert!(push_cfg.lines().any(|l| l.trim() == "refs/tsk/*:refs/tsk/*")); 1225 + // Idempotent: running again does not duplicate. 1226 + ws.configure_git_remote_refspecs("origin").unwrap(); 1227 + let cfg2 = std::process::Command::new("git") 1228 + .args(["config", "--get-all", "remote.origin.push"]) 1229 + .current_dir(&work_dir) 1230 + .output() 1231 + .unwrap(); 1232 + let push_cfg2 = String::from_utf8_lossy(&cfg2.stdout); 1233 + assert_eq!( 1234 + push_cfg.lines().filter(|l| l.trim() == "refs/tsk/*:refs/tsk/*").count(), 1235 + push_cfg2.lines().filter(|l| l.trim() == "refs/tsk/*:refs/tsk/*").count() 1236 + ); 1237 + 1238 + // Pull side: a fresh repo set up to fetch from the same remote, then 1239 + // pulling tsk refs in. 1240 + run_git_init(&clone_dir); 1241 + Workspace::init(clone_dir.clone()).unwrap(); 1242 + let cws = Workspace::from_path(clone_dir.clone()).unwrap(); 1243 + let s = std::process::Command::new("git") 1244 + .args(["remote", "add", "origin"]) 1245 + .arg(&remote_dir) 1246 + .current_dir(&clone_dir) 1247 + .status() 1248 + .unwrap(); 1249 + assert!(s.success()); 1250 + 1251 + cws.git_pull_refs("origin").unwrap(); 1252 + // The pulled-in workspace can read the task. 1253 + let pulled = cws.task(TaskIdentifier::Id(id)).unwrap(); 1254 + assert_eq!(pulled.title, "task one"); 1255 + let stack = cws.read_stack().unwrap(); 1256 + assert!(stack.iter().any(|i| i.id == id)); 1257 + 1258 + // Errors when invoked on a file-backed workspace. 1259 + let file_dir = dir.path().join("file"); 1260 + std::fs::create_dir_all(&file_dir).unwrap(); 1261 + Workspace::init(file_dir.clone()).unwrap(); 1262 + let fws = Workspace::from_path(file_dir).unwrap(); 1263 + assert!(fws.git_push_refs("origin").is_err()); 1264 + assert!(fws.git_pull_refs("origin").is_err()); 1265 + assert!(fws.configure_git_remote_refspecs("origin").is_err()); 1081 1266 } 1082 1267 1083 1268 #[test]