A file-based task manager
0
fork

Configure Feed

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

Add namespaces to the git backend

A single git repo can now host multiple isolated tsk workspaces.
Refs live under refs/tsk/<namespace>/... — different contributors
can share a repo without sharing tasks.

Storage:
- GitStore now carries a namespace string; every ref it reads/writes
is prefixed with refs/tsk/<namespace>/.
- The current namespace is stored in .tsk/namespace; absent or empty
means the "default" namespace.
- On open, any pre-existing non-namespaced refs (refs/tsk/tasks/<id>,
refs/tsk/index, etc.) are renamed under refs/tsk/default/ so older
workspaces self-heal.

CLI:
- tsk namespace list — list every namespace, marking the current one
- tsk namespace current
- tsk namespace switch <name> / tsk namespace create <name>
- tsk switch <name> — shorthand for switch
- tsk namespace delete <name> [-y] — refuses to delete the active
namespace; prompts for confirmation when the namespace has refs.

Tests cover full round-trip (state isolation, switch back, list,
delete) and the legacy non-namespaced ref migration.

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

+366 -14
+126 -12
src/backend.rs
··· 22 22 use std::str::FromStr; 23 23 24 24 pub const GIT_BACKED_MARKER: &str = "git-backed"; 25 - const REF_PREFIX: &str = "refs/tsk"; 25 + pub const NAMESPACE_FILE: &str = "namespace"; 26 + pub const DEFAULT_NAMESPACE: &str = "default"; 27 + const REF_ROOT: &str = "refs/tsk"; 26 28 27 29 /// A logical blob store. Keys are forward-slash separated strings. 28 30 pub trait Store: Send + Sync { ··· 111 113 112 114 pub struct GitStore { 113 115 git_dir: PathBuf, 116 + namespace: String, 114 117 } 115 118 116 119 impl GitStore { 117 120 pub fn open(git_dir: PathBuf) -> Result<Self> { 121 + Self::open_namespace(git_dir, DEFAULT_NAMESPACE.to_string()) 122 + } 123 + 124 + pub fn open_namespace(git_dir: PathBuf, namespace: String) -> Result<Self> { 118 125 Repository::open(&git_dir)?; 119 - Ok(Self { git_dir }) 126 + Ok(Self { git_dir, namespace }) 120 127 } 121 128 122 129 fn repo(&self) -> Result<Repository> { 123 130 Ok(Repository::open(&self.git_dir)?) 124 131 } 125 132 126 - fn refname(key: &str) -> String { 127 - format!("{REF_PREFIX}/{key}") 133 + /// Prefix every namespace's refs share, e.g. `refs/tsk/<ns>`. 134 + fn ns_prefix(&self) -> String { 135 + format!("{REF_ROOT}/{}", self.namespace) 136 + } 137 + 138 + fn refname(&self, key: &str) -> String { 139 + format!("{}/{}", self.ns_prefix(), key) 140 + } 141 + 142 + /// Names of every ref starting with the given prefix. 143 + fn refs_starting_with(&self, prefix: &str) -> Result<Vec<String>> { 144 + Ok(self 145 + .repo()? 146 + .references()? 147 + .filter_map(|r| r.ok().and_then(|r| r.name().map(str::to_string))) 148 + .filter(|n| n.starts_with(prefix)) 149 + .collect()) 150 + } 151 + 152 + /// Number of refs currently under this store's namespace. 153 + pub fn namespace_ref_count(&self) -> Result<usize> { 154 + Ok(self 155 + .refs_starting_with(&format!("{}/", self.ns_prefix()))? 156 + .len()) 157 + } 158 + 159 + /// Delete every ref under this store's namespace. Returns the count. 160 + pub fn delete_namespace_refs(&self) -> Result<usize> { 161 + let repo = self.repo()?; 162 + let names = self.refs_starting_with(&format!("{}/", self.ns_prefix()))?; 163 + let count = names.len(); 164 + for n in names { 165 + if let Some(mut r) = try_ref(&repo, &n)? { 166 + r.delete()?; 167 + } 168 + } 169 + Ok(count) 170 + } 171 + 172 + /// List the namespaces present in this repo (any directory under refs/tsk/ 173 + /// containing at least one ref). 174 + pub fn list_namespaces(&self) -> Result<Vec<String>> { 175 + let strip = format!("{REF_ROOT}/"); 176 + let mut out: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); 177 + for name in self.refs_starting_with(&strip)? { 178 + if let Some(rest) = name.strip_prefix(&strip) 179 + && let Some((ns, _)) = rest.split_once('/') 180 + { 181 + out.insert(ns.to_string()); 182 + } 183 + } 184 + Ok(out.into_iter().collect()) 128 185 } 129 186 } 130 187 ··· 140 197 impl Store for GitStore { 141 198 fn read(&self, key: &str) -> Result<Option<Vec<u8>>> { 142 199 let repo = self.repo()?; 143 - let Some(r) = try_ref(&repo, &Self::refname(key))? else { 200 + let Some(r) = try_ref(&repo, &self.refname(key))? else { 144 201 return Ok(None); 145 202 }; 146 203 let blob = r.peel(ObjectType::Blob)?; ··· 155 212 fn write(&self, key: &str, data: &[u8]) -> Result<()> { 156 213 let repo = self.repo()?; 157 214 let oid = repo.blob(data)?; 158 - repo.reference(&Self::refname(key), oid, true, "tsk write")?; 215 + repo.reference(&self.refname(key), oid, true, "tsk write")?; 159 216 Ok(()) 160 217 } 161 218 162 219 fn delete(&self, key: &str) -> Result<()> { 163 220 let repo = self.repo()?; 164 - if let Some(mut r) = try_ref(&repo, &Self::refname(key))? { 221 + if let Some(mut r) = try_ref(&repo, &self.refname(key))? { 165 222 r.delete()?; 166 223 } 167 224 Ok(()) 168 225 } 169 226 170 227 fn exists(&self, key: &str) -> Result<bool> { 171 - Ok(try_ref(&self.repo()?, &Self::refname(key))?.is_some()) 228 + Ok(try_ref(&self.repo()?, &self.refname(key))?.is_some()) 172 229 } 173 230 174 231 fn list(&self, prefix: &str) -> Result<Vec<String>> { 175 232 let repo = self.repo()?; 176 - let strip = format!("{REF_PREFIX}/"); 177 - repo.references_glob(&format!("{REF_PREFIX}/{prefix}/*"))? 233 + let strip = format!("{}/", self.ns_prefix()); 234 + repo.references_glob(&format!("{}/{}/*", self.ns_prefix(), prefix))? 178 235 .filter_map(|r| { 179 236 r.ok() 180 237 .and_then(|r| { ··· 359 416 .flatten() 360 417 } 361 418 419 + pub fn read_namespace(tsk_dir: &Path) -> String { 420 + fs::read_to_string(tsk_dir.join(NAMESPACE_FILE)) 421 + .ok() 422 + .map(|s| s.trim().to_string()) 423 + .filter(|s| !s.is_empty()) 424 + .unwrap_or_else(|| DEFAULT_NAMESPACE.to_string()) 425 + } 426 + 427 + pub fn write_namespace(tsk_dir: &Path, namespace: &str) -> Result<()> { 428 + fs::write(tsk_dir.join(NAMESPACE_FILE), namespace.as_bytes())?; 429 + Ok(()) 430 + } 431 + 362 432 pub fn store_for(tsk_dir: &Path) -> Result<Box<dyn Store>> { 363 433 let marker = tsk_dir.join(GIT_BACKED_MARKER); 364 434 if marker.exists() { 365 - let git_dir = fs::read_to_string(&marker)?.trim().to_string(); 366 - let store = GitStore::open(PathBuf::from(git_dir))?; 435 + let git_dir = PathBuf::from(fs::read_to_string(&marker)?.trim()); 436 + // First: rename any non-namespaced refs into the default namespace, so 437 + // workspaces created before namespacing keep working seamlessly. 438 + let probe = GitStore::open(git_dir.clone())?; 439 + upgrade_to_namespaced(&probe)?; 440 + let ns = read_namespace(tsk_dir); 441 + let store = GitStore::open_namespace(git_dir, ns)?; 367 442 upgrade_legacy_keys(&store)?; 368 443 Ok(Box::new(store)) 369 444 } else { 370 445 Ok(Box::new(FileStore::new(tsk_dir.to_path_buf()))) 371 446 } 447 + } 448 + 449 + /// Move any non-namespaced refs (`refs/tsk/<key>`, `refs/tsk/<bucket>/<id>`) 450 + /// into the `default` namespace (`refs/tsk/default/...`). Idempotent. 451 + fn upgrade_to_namespaced(probe: &GitStore) -> Result<()> { 452 + let repo = probe.repo()?; 453 + let strip = format!("{REF_ROOT}/"); 454 + let mut moves: Vec<(String, String)> = Vec::new(); 455 + for r in repo.references_glob(&format!("{REF_ROOT}/*"))? { 456 + let r = r?; 457 + let Some(name) = r.name() else { continue }; 458 + let Some(rest) = name.strip_prefix(&strip) else { 459 + continue; 460 + }; 461 + // Skip already-namespaced refs: first segment is a known top-level key, 462 + // any other first segment is treated as a namespace. 463 + let first = rest.split('/').next().unwrap_or(""); 464 + let is_legacy = matches!( 465 + first, 466 + "tasks" | "archive" | "attrs" | "backlinks" | "index" | "next" | "remotes" 467 + ); 468 + if is_legacy { 469 + moves.push(( 470 + name.to_string(), 471 + format!("{REF_ROOT}/{DEFAULT_NAMESPACE}/{rest}"), 472 + )); 473 + } 474 + } 475 + for (old, new) in moves { 476 + if let Some(r) = try_ref(&repo, &old)? 477 + && let Some(oid) = r.target() 478 + { 479 + repo.reference(&new, oid, true, "tsk namespace upgrade")?; 480 + if let Some(mut r) = try_ref(&repo, &old)? { 481 + r.delete()?; 482 + } 483 + } 484 + } 485 + Ok(()) 372 486 } 373 487 374 488 /// Rename legacy-scheme refs (`tasks/tsk-N.tsk`) to the current scheme
+81
src/main.rs
··· 221 221 /// task data is copied into refs/tsk/* and the on-disk files are removed. 222 222 Migrate, 223 223 224 + /// Manage namespaces within a git-backed workspace. Namespaces let multiple 225 + /// people share the same git repo without sharing tasks; refs live under 226 + /// refs/tsk/<namespace>/. 227 + Namespace { 228 + #[command(subcommand)] 229 + action: NamespaceAction, 230 + }, 231 + 232 + /// Switch to a different namespace. Shorthand for `tsk namespace switch`. 233 + Switch { name: String }, 234 + 224 235 /// Reopens an archived task, recreating the symlink and adding it back to the stack. 225 236 Reopen { 226 237 #[command(flatten)] 227 238 task_id: TaskId, 239 + }, 240 + } 241 + 242 + #[derive(Subcommand)] 243 + enum NamespaceAction { 244 + /// List all namespaces with refs in this repo. 245 + List, 246 + /// Print the current namespace name. 247 + Current, 248 + /// Switch to (create on first push of) the given namespace. 249 + Switch { name: String }, 250 + /// Create an empty namespace and switch to it. 251 + Create { name: String }, 252 + /// Delete every ref under the given namespace. Refuses if the namespace is 253 + /// the active one. Prompts for confirmation when it has tasks unless -y. 254 + Delete { 255 + name: String, 256 + /// Skip the confirmation prompt. 257 + #[arg(short = 'y', default_value_t = false)] 258 + yes: bool, 228 259 }, 229 260 } 230 261 ··· 358 389 Commands::Export { output } => command_export(dir, output), 359 390 Commands::Migrate => command_migrate(dir), 360 391 Commands::Reopen { task_id } => command_reopen(dir, task_id), 392 + Commands::Namespace { action } => command_namespace(dir, action), 393 + Commands::Switch { name } => command_namespace_switch(dir, &name), 361 394 } 362 395 } 363 396 ··· 692 725 "Migrated workspace to git refs (git dir: {})", 693 726 git_dir.display() 694 727 ); 728 + Ok(()) 729 + } 730 + 731 + fn command_namespace_switch(dir: PathBuf, name: &str) -> Result<()> { 732 + let ws = Workspace::from_path(dir)?; 733 + ws.switch_namespace(name)?; 734 + eprintln!("Switched to namespace '{name}'"); 735 + Ok(()) 736 + } 737 + 738 + fn command_namespace(dir: PathBuf, action: NamespaceAction) -> Result<()> { 739 + let ws = Workspace::from_path(dir)?; 740 + match action { 741 + NamespaceAction::Current => { 742 + println!("{}", ws.namespace()); 743 + } 744 + NamespaceAction::List => { 745 + let cur = ws.namespace(); 746 + for ns in ws.list_namespaces()? { 747 + let marker = if ns == cur { "* " } else { " " }; 748 + println!("{marker}{ns}"); 749 + } 750 + } 751 + NamespaceAction::Switch { name } | NamespaceAction::Create { name } => { 752 + ws.switch_namespace(&name)?; 753 + eprintln!("Switched to namespace '{name}'"); 754 + } 755 + NamespaceAction::Delete { name, yes } => { 756 + let count = ws.namespace_ref_count(&name)?; 757 + if count == 0 { 758 + eprintln!("Namespace '{name}' has no refs."); 759 + return Ok(()); 760 + } 761 + if !yes { 762 + eprint!("Namespace '{name}' has {count} refs. Delete? [y/N] "); 763 + use std::io::Write as _; 764 + io::stderr().flush()?; 765 + let mut answer = String::new(); 766 + io::stdin().read_line(&mut answer)?; 767 + if !matches!(answer.trim(), "y" | "Y" | "yes") { 768 + eprintln!("Aborted."); 769 + return Ok(()); 770 + } 771 + } 772 + let n = ws.delete_namespace(&name)?; 773 + eprintln!("Deleted {n} refs from namespace '{name}'"); 774 + } 775 + } 695 776 Ok(()) 696 777 } 697 778
+159 -2
src/workspace.rs
··· 64 64 } 65 65 } 66 66 67 + /// Reject namespace names that contain `/` or other characters problematic in 68 + /// a git ref path. 69 + fn validate_namespace(name: &str) -> Result<()> { 70 + if name.is_empty() { 71 + return Err(Error::Parse("Namespace name cannot be empty".into())); 72 + } 73 + if !name 74 + .chars() 75 + .all(|c| c.is_alphanumeric() || c == '_' || c == '-') 76 + { 77 + return Err(Error::Parse(format!( 78 + "Namespace '{name}' must contain only alphanumerics, '-', or '_'" 79 + ))); 80 + } 81 + Ok(()) 82 + } 83 + 67 84 pub struct Workspace { 68 85 /// The path to the .tsk marker directory. 69 86 pub path: PathBuf, ··· 110 127 111 128 pub fn is_git_backed(&self) -> bool { 112 129 self.path.join(backend::GIT_BACKED_MARKER).exists() 130 + } 131 + 132 + /// Name of the namespace this workspace is currently using. Always 133 + /// `"default"` for file-backed workspaces. 134 + pub fn namespace(&self) -> String { 135 + backend::read_namespace(&self.path) 136 + } 137 + 138 + /// List the namespaces present in the underlying git repo. Errors for 139 + /// file-backed workspaces. 140 + pub fn list_namespaces(&self) -> Result<Vec<String>> { 141 + if !self.is_git_backed() { 142 + return Err(Error::Parse("Workspace is not git-backed".into())); 143 + } 144 + let marker = std::fs::read_to_string(self.path.join(backend::GIT_BACKED_MARKER))?; 145 + let store = backend::GitStore::open(PathBuf::from(marker.trim()))?; 146 + store.list_namespaces() 147 + } 148 + 149 + /// Switch the workspace to a different namespace by writing the namespace 150 + /// marker file. The namespace need not exist yet — the next mutation 151 + /// creates refs under it. 152 + pub fn switch_namespace(&self, name: &str) -> Result<()> { 153 + if !self.is_git_backed() { 154 + return Err(Error::Parse("Workspace is not git-backed".into())); 155 + } 156 + validate_namespace(name)?; 157 + backend::write_namespace(&self.path, name) 158 + } 159 + 160 + /// Delete every ref belonging to the given namespace. Errors if the 161 + /// namespace is the currently active one. Returns the number of refs 162 + /// deleted (caller can prompt before invoking if non-zero). 163 + pub fn delete_namespace(&self, name: &str) -> Result<usize> { 164 + if !self.is_git_backed() { 165 + return Err(Error::Parse("Workspace is not git-backed".into())); 166 + } 167 + if name == self.namespace() { 168 + return Err(Error::Parse( 169 + "Cannot delete the currently active namespace; switch first".into(), 170 + )); 171 + } 172 + let marker = std::fs::read_to_string(self.path.join(backend::GIT_BACKED_MARKER))?; 173 + let store = 174 + backend::GitStore::open_namespace(PathBuf::from(marker.trim()), name.to_string())?; 175 + store.delete_namespace_refs() 176 + } 177 + 178 + /// Number of refs currently in the given namespace; useful for prompting 179 + /// before deletion. 180 + pub fn namespace_ref_count(&self, name: &str) -> Result<usize> { 181 + if !self.is_git_backed() { 182 + return Err(Error::Parse("Workspace is not git-backed".into())); 183 + } 184 + let marker = std::fs::read_to_string(self.path.join(backend::GIT_BACKED_MARKER))?; 185 + let store = 186 + backend::GitStore::open_namespace(PathBuf::from(marker.trim()), name.to_string())?; 187 + store.namespace_ref_count() 113 188 } 114 189 115 190 fn resolve(&self, identifier: TaskIdentifier) -> Result<Id> { ··· 1138 1213 .unwrap(); 1139 1214 let names = String::from_utf8_lossy(&out.stdout); 1140 1215 assert!( 1141 - names.contains(&format!("refs/tsk/tasks/{}", id.0)), 1216 + names.contains(&format!("refs/tsk/default/tasks/{}", id.0)), 1142 1217 "{names}" 1143 1218 ); 1144 - assert!(names.contains("refs/tsk/index")); 1219 + assert!(names.contains("refs/tsk/default/index")); 1145 1220 1146 1221 // Now configure refspecs on the working repo and confirm `git push origin` 1147 1222 // (with no refspec) sends refs/tsk/*. ··· 1204 1279 assert!(fws.git_push_refs("origin").is_err()); 1205 1280 assert!(fws.git_pull_refs("origin").is_err()); 1206 1281 assert!(fws.configure_git_remote_refspecs("origin").is_err()); 1282 + } 1283 + 1284 + #[test] 1285 + fn test_namespaces_isolate_state() { 1286 + let dir = tempfile::tempdir().unwrap(); 1287 + let root = dir.path().to_path_buf(); 1288 + run_git_init(&root); 1289 + Workspace::init(root.clone()).unwrap(); 1290 + let ws = Workspace::from_path(root.clone()).unwrap(); 1291 + assert_eq!(ws.namespace(), "default"); 1292 + 1293 + // Push a task in the default namespace. 1294 + let t = ws.new_task("default-task".into(), "x".into()).unwrap(); 1295 + let default_id = t.id; 1296 + ws.push_task(t).unwrap(); 1297 + 1298 + // Switch to a new namespace; stack should appear empty. 1299 + ws.switch_namespace("alice").unwrap(); 1300 + let ws2 = Workspace::from_path(root.clone()).unwrap(); 1301 + assert_eq!(ws2.namespace(), "alice"); 1302 + assert_eq!(ws2.read_stack().unwrap().iter().count(), 0); 1303 + // ID counter resets per-namespace because `next` is namespaced. 1304 + let alice_t = ws2.new_task("alice-task".into(), "y".into()).unwrap(); 1305 + assert_eq!(alice_t.id, Id(1)); 1306 + ws2.push_task(alice_t).unwrap(); 1307 + 1308 + // Switch back; the original task is still there. 1309 + ws2.switch_namespace("default").unwrap(); 1310 + let ws3 = Workspace::from_path(root.clone()).unwrap(); 1311 + let stack = ws3.read_stack().unwrap(); 1312 + assert_eq!(stack.iter().count(), 1); 1313 + assert_eq!(stack.iter().next().unwrap().id, default_id); 1314 + 1315 + // Both namespaces appear in the listing. 1316 + let mut nss = ws3.list_namespaces().unwrap(); 1317 + nss.sort(); 1318 + assert_eq!(nss, vec!["alice".to_string(), "default".to_string()]); 1319 + 1320 + // Cannot delete the active namespace. 1321 + assert!(ws3.delete_namespace("default").is_err()); 1322 + 1323 + // Deleting alice succeeds and reduces the namespace list. 1324 + let n = ws3.delete_namespace("alice").unwrap(); 1325 + assert!(n > 0); 1326 + assert_eq!(ws3.list_namespaces().unwrap(), vec!["default".to_string()]); 1327 + 1328 + // Invalid namespace names rejected. 1329 + assert!(ws3.switch_namespace("").is_err()); 1330 + assert!(ws3.switch_namespace("a/b").is_err()); 1331 + assert!(ws3.switch_namespace("a b").is_err()); 1332 + } 1333 + 1334 + #[test] 1335 + fn test_legacy_non_namespaced_refs_upgraded() { 1336 + // A repo whose refs were created before namespacing should get its 1337 + // refs/tsk/<key>/* moved under refs/tsk/default/ on first open. 1338 + let dir = tempfile::tempdir().unwrap(); 1339 + let root = dir.path().to_path_buf(); 1340 + run_git_init(&root); 1341 + // Manually init only the tsk marker (skip Workspace::init's namespace 1342 + // logic) so we can plant legacy refs. 1343 + let tsk_dir = root.join(".tsk"); 1344 + std::fs::create_dir(&tsk_dir).unwrap(); 1345 + std::fs::write( 1346 + tsk_dir.join(backend::GIT_BACKED_MARKER), 1347 + root.join(".git").to_string_lossy().as_bytes(), 1348 + ) 1349 + .unwrap(); 1350 + // Plant a legacy ref directly via the bare GitStore. 1351 + let bare = backend::GitStore::open(root.join(".git")).unwrap(); 1352 + <dyn Store>::write(&bare, "tasks/1", b"legacy\n\nbody").unwrap(); 1353 + 1354 + // Open via Workspace — should auto-migrate. 1355 + let ws = Workspace::from_path(root.clone()).unwrap(); 1356 + assert_eq!(ws.namespace(), "default"); 1357 + let t = ws.task(TaskIdentifier::Id(Id(1))).unwrap(); 1358 + assert_eq!(t.title, "legacy"); 1359 + // Confirm at the git ref level: refs/tsk/tasks/1 is gone, the 1360 + // namespaced refs/tsk/default/tasks/1 is present. 1361 + let repo = git2::Repository::open(root.join(".git")).unwrap(); 1362 + assert!(repo.find_reference("refs/tsk/tasks/1").is_err()); 1363 + assert!(repo.find_reference("refs/tsk/default/tasks/1").is_ok()); 1207 1364 } 1208 1365 1209 1366 #[test]