A file-based task manager
0
fork

Configure Feed

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

Move user-local namespace and queue selectors into .git/tsk/

The .tsk/ directory was tracked by git, so switching namespace or queue
showed up as a modified file in every clone — and the per-clone choice
leaked into the shared history. State now lives in <git-dir>/tsk/, which
git doesn't track by definition. tsk init is idempotent and migrates any
existing .tsk/ namespace and queue files into the new location, then
removes the legacy directory so it stops appearing in git status.

Drops the now-redundant util module (the old .tsk-discovery walk is gone).

Tests: 2 new workspace tests cover (1) init creating nothing in the
working tree and (2) the legacy migration path.

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

+82 -57
-1
.tsk/git-dir
··· 1 - /home/noah/repos/tsk/.git
-1
.tsk/namespace
··· 1 - tsk
-1
.tsk/queue
··· 1 - tsk
-1
src/lib.rs
··· 5 5 mod properties; 6 6 mod queue; 7 7 mod task; 8 - mod util; 9 8 mod workspace; 10 9 11 10 use clap::{Args, CommandFactory, Parser, Subcommand};
-22
src/util.rs
··· 1 - use crate::errors::Result; 2 - use std::fs; 3 - use std::os::unix::fs::MetadataExt; 4 - use std::path::{Path, PathBuf}; 5 - 6 - /// Recursively searches upwards for a directory 7 - pub fn find_parent_with_dir( 8 - dir: PathBuf, 9 - searching_for: impl AsRef<Path>, 10 - ) -> Result<Option<PathBuf>> { 11 - let mut d = dir.join(&searching_for); 12 - while d.pop() { 13 - let check = d.join(&searching_for); 14 - if check.exists() { 15 - if fs::metadata(&check)?.dev() != fs::metadata(&dir)?.dev() { 16 - return Ok(None); 17 - } 18 - return Ok(Some(check)); 19 - } 20 - } 21 - Ok(None) 22 - }
+82 -31
src/workspace.rs
··· 9 9 10 10 use crate::errors::{Error, Result}; 11 11 use crate::object::{self, StableId, Task as TaskObj}; 12 - use crate::{namespace, properties, queue, util}; 12 + use crate::{namespace, properties, queue}; 13 13 use git2::Repository; 14 14 use std::collections::BTreeMap; 15 15 use std::fmt::Display; ··· 18 18 19 19 const NAMESPACE_FILE: &str = "namespace"; 20 20 const QUEUE_FILE: &str = "queue"; 21 - const GIT_DIR_FILE: &str = "git-dir"; 21 + /// User-local state lives under `<git-dir>/<STATE_DIR>/` so it isn't tracked 22 + /// by the enclosing repo (the `.git/` directory is by definition not in the 23 + /// working tree). Each clone gets its own active namespace + queue. 24 + const STATE_DIR: &str = "tsk"; 22 25 23 26 /// A human-readable task identifier (`tsk-N`). Always namespace-scoped: the 24 27 /// integer N has no meaning without the namespace it was minted in. ··· 93 96 } 94 97 95 98 pub struct Workspace { 96 - /// The `.tsk/` directory. 99 + /// The user-local state directory: `<git-dir>/tsk/`. Holds the 100 + /// `namespace` and `queue` selectors — both per-clone, not pushed. 97 101 pub path: PathBuf, 98 - /// The enclosing git repo's `.git` (or bare) directory. 102 + /// The enclosing git repo's `.git` directory. 99 103 pub git_dir: PathBuf, 100 104 } 101 105 102 106 impl Workspace { 103 - /// Initialize a `.tsk/` marker inside an existing git repo. Errors if no 104 - /// git repo encloses `path` or if `.tsk/` already exists. 107 + /// Bootstrap user-local state in `<git-dir>/tsk/`. Idempotent: existing 108 + /// state files are left alone so re-init doesn't reset the active 109 + /// namespace/queue. Errors if `path` isn't inside a git repository. 105 110 pub fn init(path: PathBuf) -> Result<()> { 106 - let tsk_dir = path.join(".tsk"); 107 - if tsk_dir.exists() { 108 - return Err(Error::AlreadyInitialized); 109 - } 110 111 let git_dir = find_git_dir(&path) 111 112 .ok_or_else(|| Error::Parse("tsk requires an enclosing git repository".into()))?; 112 - std::fs::create_dir(&tsk_dir)?; 113 - std::fs::write(tsk_dir.join(GIT_DIR_FILE), git_dir.to_string_lossy().as_bytes())?; 114 - std::fs::write(tsk_dir.join(NAMESPACE_FILE), namespace::DEFAULT_NS.as_bytes())?; 115 - std::fs::write(tsk_dir.join(QUEUE_FILE), queue::DEFAULT_QUEUE.as_bytes())?; 113 + let state_dir = git_dir.join(STATE_DIR); 114 + std::fs::create_dir_all(&state_dir)?; 115 + // Lift any pre-existing selectors out of a legacy `.tsk/` directory 116 + // *before* writing defaults, so the migrated values win. 117 + if let Some(workdir) = git_dir.parent() { 118 + let legacy = workdir.join(".tsk"); 119 + if legacy.is_dir() { 120 + for name in [NAMESPACE_FILE, QUEUE_FILE] { 121 + let src = legacy.join(name); 122 + let dst = state_dir.join(name); 123 + if src.exists() && !dst.exists() { 124 + let _ = std::fs::copy(&src, &dst); 125 + } 126 + } 127 + let _ = std::fs::remove_dir_all(&legacy); 128 + } 129 + } 130 + let ns = state_dir.join(NAMESPACE_FILE); 131 + if !ns.exists() { 132 + std::fs::write(&ns, namespace::DEFAULT_NS.as_bytes())?; 133 + } 134 + let q = state_dir.join(QUEUE_FILE); 135 + if !q.exists() { 136 + std::fs::write(&q, queue::DEFAULT_QUEUE.as_bytes())?; 137 + } 116 138 Ok(()) 117 139 } 118 140 119 141 pub fn from_path(path: PathBuf) -> Result<Self> { 120 - let tsk_dir = util::find_parent_with_dir(path.clone(), ".tsk")?; 121 - let tsk_dir = match tsk_dir { 122 - Some(d) => d, 123 - None => { 124 - // Auto-bootstrap: if we're inside a git repo, behave as if 125 - // `tsk init` was run there. This keeps the `git tsk` UX 126 - // friction-free — users don't need an explicit init step. 127 - let git_dir = find_git_dir(&path).ok_or(Error::Uninitialized)?; 128 - let workdir = git_dir.parent().unwrap_or(&path).to_path_buf(); 129 - Self::init(workdir.clone())?; 130 - workdir.join(".tsk") 131 - } 132 - }; 133 - let git_dir = std::fs::read_to_string(tsk_dir.join(GIT_DIR_FILE))? 134 - .trim() 135 - .into(); 142 + let git_dir = find_git_dir(&path).ok_or(Error::Uninitialized)?; 143 + let state_dir = git_dir.join(STATE_DIR); 144 + if !state_dir.exists() { 145 + // Auto-bootstrap so `git tsk <anything>` works without an 146 + // explicit init step. 147 + Self::init(path)?; 148 + } 136 149 Ok(Self { 137 - path: tsk_dir, 150 + path: state_dir, 138 151 git_dir, 139 152 }) 140 153 } ··· 790 803 assert_eq!(pulled.0, id.0); 791 804 let stack = ws.read_stack().unwrap(); 792 805 assert_eq!(stack.len(), 1); 806 + } 807 + 808 + #[test] 809 + fn init_does_not_create_files_in_working_tree() { 810 + let dir = tempfile::tempdir().unwrap(); 811 + run_git_init(dir.path()); 812 + Workspace::init(dir.path().to_path_buf()).unwrap(); 813 + // The only directory entries in the working tree should be `.git` 814 + // (from `git init`) — no `.tsk` and nothing else. 815 + let entries: Vec<_> = std::fs::read_dir(dir.path()) 816 + .unwrap() 817 + .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned())) 818 + .collect(); 819 + assert_eq!( 820 + entries, 821 + vec![".git".to_string()], 822 + "no user-local state should land in the working tree" 823 + ); 824 + // The state files should live under <git-dir>/tsk/. 825 + assert!(dir.path().join(".git/tsk/namespace").exists()); 826 + assert!(dir.path().join(".git/tsk/queue").exists()); 827 + } 828 + 829 + #[test] 830 + fn legacy_dot_tsk_directory_is_migrated_and_removed() { 831 + let dir = tempfile::tempdir().unwrap(); 832 + run_git_init(dir.path()); 833 + // Simulate an old workspace with a tracked `.tsk/namespace` already. 834 + let legacy = dir.path().join(".tsk"); 835 + std::fs::create_dir(&legacy).unwrap(); 836 + std::fs::write(legacy.join("namespace"), b"alpha").unwrap(); 837 + std::fs::write(legacy.join("queue"), b"review").unwrap(); 838 + 839 + Workspace::init(dir.path().to_path_buf()).unwrap(); 840 + let ws = Workspace::from_path(dir.path().to_path_buf()).unwrap(); 841 + assert_eq!(ws.namespace(), "alpha", "legacy namespace must migrate"); 842 + assert_eq!(ws.queue(), "review", "legacy queue must migrate"); 843 + assert!(!legacy.exists(), "legacy .tsk/ must be removed"); 793 844 } 794 845 795 846 #[test]