A file-based task manager
0
fork

Configure Feed

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

Reduce bloat: 54-line dedup pass, no behavior change

Six small consolidations across the non-test code, each replacing
near-duplicate boilerplate with a single shared helper:

- 5 byte-identical `signature(repo)` helpers (object/namespace/queue/
properties/merge.rs and inline in patch.rs) → one pub(crate) in
object.rs that the others call.
- workspace::namespace/queue: extract `read_selector(file, default)`.
- workspace::new_task: 3 nearly-identical `Task { ... }` constructors
+ 1 in `task()` → one `make_task(id, stable, obj)` plus a
`read_task_obj` lookup helper.
- workspace's git-shell-out methods (configure_git_remote_refspecs,
git_push, git_push_refs, git_fetch_refs, git_pull_with_strategy):
share a `self.git()` builder that pre-fills `--git-dir`.
- lib.rs accept/reject inbox-key fallback: extract `pick_inbox_key`.
- lib.rs `command_namespace_switch` was a 3-line forwarder; inline.

Also drops the dead `_silence_unused` placeholder.

98 tests still pass; tests intentionally untouched (one new
`use git2::Signature` in merge.rs's test mod compensates for the
parent module no longer importing it).

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

+108 -162
+18 -30
src/lib.rs
··· 19 19 use std::path::PathBuf; 20 20 use std::process::exit; 21 21 use std::str::FromStr as _; 22 - use workspace::{Id, Task, TaskIdentifier, Workspace}; 22 + use workspace::{Id, TaskIdentifier, Workspace}; 23 23 24 24 fn default_dir() -> Result<PathBuf> { 25 25 Ok(current_dir()?) ··· 469 469 Commands::Prop { action } => command_prop(dir, action), 470 470 Commands::Namespace { action } => command_namespace(dir, action), 471 471 Commands::Queue { action } => command_queue(dir, action), 472 - Commands::Switch { name } => command_namespace_switch(dir, name), 472 + Commands::Switch { name } => { 473 + resolve_and_switch_namespace(&Workspace::from_path(dir)?, name) 474 + } 473 475 Commands::Completion { shell } => { 474 476 generate(shell, &mut Cli::command(), "tsk", &mut io::stdout()); 475 477 Ok(()) ··· 663 665 Ok(()) 664 666 } 665 667 668 + fn pick_inbox_key(ws: &Workspace, key: Option<String>) -> Result<String> { 669 + if let Some(k) = key { 670 + return Ok(k); 671 + } 672 + Ok(ws 673 + .list_inbox()? 674 + .into_iter() 675 + .next() 676 + .ok_or_else(|| errors::Error::Parse("Inbox is empty".into()))? 677 + .key) 678 + } 679 + 666 680 fn command_accept(dir: PathBuf, key: Option<String>, remote: Option<String>) -> Result<()> { 667 681 let ws = Workspace::from_path(dir)?; 668 - let key = match key { 669 - Some(k) => k, 670 - None => { 671 - ws.list_inbox()? 672 - .into_iter() 673 - .next() 674 - .ok_or_else(|| errors::Error::Parse("Inbox is empty".into()))? 675 - .key 676 - } 677 - }; 682 + let key = pick_inbox_key(&ws, key)?; 678 683 let id = ws.accept_inbox(&key)?; 679 684 println!("Accepted as {id}"); 680 685 if let Some(r) = effective_remote(remote) { ··· 686 691 687 692 fn command_reject(dir: PathBuf, key: Option<String>, remote: Option<String>) -> Result<()> { 688 693 let ws = Workspace::from_path(dir)?; 689 - let key = match key { 690 - Some(k) => k, 691 - None => { 692 - ws.list_inbox()? 693 - .into_iter() 694 - .next() 695 - .ok_or_else(|| errors::Error::Parse("Inbox is empty".into()))? 696 - .key 697 - } 698 - }; 694 + let key = pick_inbox_key(&ws, key)?; 699 695 ws.reject_inbox(&key)?; 700 696 if let Some((src, _)) = key.rsplit_once('-') { 701 697 println!("Rejected {key} (returned to '{src}' inbox)"); ··· 932 928 933 929 const NEW_NS_SENTINEL: &str = "<new>"; 934 930 935 - fn command_namespace_switch(dir: PathBuf, name: Option<String>) -> Result<()> { 936 - let ws = Workspace::from_path(dir)?; 937 - resolve_and_switch_namespace(&ws, name) 938 - } 939 - 940 931 fn resolve_and_switch_namespace(ws: &Workspace, name: Option<String>) -> Result<()> { 941 932 let target = match name { 942 933 Some(n) => n, ··· 998 989 io::stdin().read_line(&mut s)?; 999 990 Ok(s.trim_end_matches(['\n', '\r']).to_string()) 1000 991 } 1001 - 1002 - #[allow(dead_code)] 1003 - fn _silence_unused(_w: &dyn Write, _t: Task) {} 1004 992 1005 993 #[cfg(test)] 1006 994 mod tests {
+6 -11
src/merge.rs
··· 23 23 24 24 use crate::errors::Result; 25 25 use crate::namespace::{self, NS_REF_PREFIX, Namespace}; 26 - use crate::object::{StableId, TASK_REF_PREFIX}; 27 - use git2::{Commit, Oid, Repository, Signature}; 26 + use crate::object::{self, StableId, TASK_REF_PREFIX}; 27 + use git2::{Commit, Oid, Repository}; 28 28 use std::collections::BTreeSet; 29 29 30 30 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] ··· 154 154 return Ok(ReconKind::Conflict); 155 155 } 156 156 let tree_oid = idx.write_tree_to(repo)?; 157 - let sig = signature(repo); 157 + let sig = object::signature(repo); 158 158 let local_commit = repo.find_commit(local)?; 159 159 let remote_commit = repo.find_commit(remote)?; 160 160 let parents: Vec<&Commit> = vec![&local_commit, &remote_commit]; ··· 188 188 cur = parent; 189 189 } 190 190 to_replay.reverse(); 191 - let committer = signature(repo); 191 + let committer = object::signature(repo); 192 192 let mut current = remote; 193 193 for c_oid in to_replay { 194 194 let c = repo.find_commit(c_oid)?; ··· 215 215 Ok(ReconKind::Rebased) 216 216 } 217 217 218 - fn signature(repo: &Repository) -> Signature<'static> { 219 - repo.signature() 220 - .map(|s| s.to_owned()) 221 - .unwrap_or_else(|_| Signature::now("tsk", "tsk@local").unwrap()) 222 - } 223 - 224 218 /// One namespace's reconciliation outcome at pull time. 225 219 #[derive(Debug)] 226 220 pub struct NamespaceReconciliation { ··· 327 321 let tree_oid = namespace::build_tree(repo, &merged)?; 328 322 let local_commit = repo.find_commit(l)?; 329 323 let remote_commit = repo.find_commit(r)?; 330 - let sig = signature(repo); 324 + let sig = object::signature(repo); 331 325 let msg = if renumbers.is_empty() { 332 326 format!("merge-namespace {name}") 333 327 } else { ··· 381 375 mod test { 382 376 use super::*; 383 377 use crate::object::{self, Task}; 378 + use git2::Signature; 384 379 use std::path::Path; 385 380 386 381 fn init_repo(p: &Path) -> Repository {
+3 -9
src/namespace.rs
··· 8 8 //! cross-namespace references at this layer). 9 9 10 10 use crate::errors::{Error, Result}; 11 - use crate::object::StableId; 12 - use git2::{Oid, Repository, Signature}; 11 + use crate::object::{self, StableId}; 12 + use git2::{Oid, Repository}; 13 13 use std::collections::BTreeMap; 14 14 15 15 pub const NS_REF_PREFIX: &str = "refs/tsk/namespaces/"; ··· 41 41 pub next: u32, 42 42 /// human id → stable id 43 43 pub mapping: BTreeMap<u32, StableId>, 44 - } 45 - 46 - fn signature(repo: &Repository) -> Signature<'static> { 47 - repo.signature() 48 - .map(|s| s.to_owned()) 49 - .unwrap_or_else(|_| Signature::now("tsk", "tsk@local").unwrap()) 50 44 } 51 45 52 46 pub fn read(repo: &Repository, name: &str) -> Result<Namespace> { ··· 126 120 { 127 121 return Ok(()); 128 122 } 129 - let sig = signature(repo); 123 + let sig = object::signature(repo); 130 124 let parents: Vec<&git2::Commit> = parent.iter().collect(); 131 125 let commit = repo.commit( 132 126 None,
+5 -1
src/object.rs
··· 65 65 } 66 66 } 67 67 68 - fn signature(repo: &Repository) -> Signature<'static> { 68 + /// Local user's git signature, with a `tsk@local` fallback when the 69 + /// surrounding repo has no `user.name`/`user.email` configured. Shared 70 + /// across the namespace / queue / properties / merge writers so they all 71 + /// stamp commits the same way. 72 + pub(crate) fn signature(repo: &Repository) -> Signature<'static> { 69 73 repo.signature() 70 74 .map(|s| s.to_owned()) 71 75 .unwrap_or_else(|_| Signature::now("tsk", "tsk@local").unwrap())
+1 -4
src/patch.rs
··· 285 285 // Committer = local user — same shape as `git rebase`, so the 286 286 // history records who applied the import while preserving authorship. 287 287 let author = Signature::new(&e.author_name, &e.author_email, &e.when)?; 288 - let committer = repo 289 - .signature() 290 - .map(|s| s.to_owned()) 291 - .unwrap_or_else(|_| Signature::now("tsk", "tsk@local").unwrap()); 288 + let committer = crate::object::signature(repo); 292 289 let parents: Vec<git2::Commit> = prev.into_iter().map(|o| repo.find_commit(o).unwrap()).collect(); 293 290 let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); 294 291 let commit_oid = repo.commit(
+3 -9
src/properties.rs
··· 8 8 //! tree merge; only same-key/same-task races require manual resolution. 9 9 10 10 use crate::errors::Result; 11 - use crate::object::StableId; 11 + use crate::object::{self, StableId}; 12 12 use crate::propvalue; 13 - use git2::{Oid, Repository, Signature}; 13 + use git2::{Oid, Repository}; 14 14 use std::collections::BTreeMap; 15 15 16 16 pub const PROP_REF_PREFIX: &str = "refs/tsk/properties/"; 17 17 18 18 pub fn refname(key: &str) -> String { 19 19 format!("{PROP_REF_PREFIX}{key}") 20 - } 21 - 22 - fn signature(repo: &Repository) -> Signature<'static> { 23 - repo.signature() 24 - .map(|s| s.to_owned()) 25 - .unwrap_or_else(|_| Signature::now("tsk", "tsk@local").unwrap()) 26 20 } 27 21 28 22 /// Read every (stable_id, values) entry currently indexed under `key`. ··· 74 68 { 75 69 return Ok(()); 76 70 } 77 - let sig = signature(repo); 71 + let sig = object::signature(repo); 78 72 let parents: Vec<&git2::Commit> = parent.iter().collect(); 79 73 let commit = repo.commit( 80 74 None,
+3 -9
src/queue.rs
··· 9 9 //! is selected by `<git-dir>/tsk/queue`. 10 10 11 11 use crate::errors::{Error, Result}; 12 - use crate::object::StableId; 13 - use git2::{Oid, Repository, Signature}; 12 + use crate::object::{self, StableId}; 13 + use git2::{Oid, Repository}; 14 14 use std::collections::BTreeMap; 15 15 16 16 pub const QUEUE_REF_PREFIX: &str = "refs/tsk/queues/"; ··· 55 55 inbox: BTreeMap::new(), 56 56 } 57 57 } 58 - } 59 - 60 - fn signature(repo: &Repository) -> Signature<'static> { 61 - repo.signature() 62 - .map(|s| s.to_owned()) 63 - .unwrap_or_else(|_| Signature::now("tsk", "tsk@local").unwrap()) 64 58 } 65 59 66 60 pub fn read(repo: &Repository, name: &str) -> Result<Queue> { ··· 136 130 { 137 131 return Ok(()); 138 132 } 139 - let sig = signature(repo); 133 + let sig = object::signature(repo); 140 134 let parents: Vec<&git2::Commit> = parent.iter().collect(); 141 135 let commit = repo.commit( 142 136 None,
+69 -89
src/workspace.rs
··· 165 165 Ok(Repository::open(&self.git_dir)?) 166 166 } 167 167 168 - pub fn namespace(&self) -> String { 169 - std::fs::read_to_string(self.path.join(NAMESPACE_FILE)) 168 + fn read_selector(&self, file: &str, default: &str) -> String { 169 + std::fs::read_to_string(self.path.join(file)) 170 170 .ok() 171 171 .map(|s| s.trim().to_string()) 172 172 .filter(|s| !s.is_empty()) 173 - .unwrap_or_else(|| namespace::DEFAULT_NS.to_string()) 173 + .unwrap_or_else(|| default.to_string()) 174 + } 175 + 176 + pub fn namespace(&self) -> String { 177 + self.read_selector(NAMESPACE_FILE, namespace::DEFAULT_NS) 174 178 } 175 179 176 180 pub fn queue(&self) -> String { 177 - std::fs::read_to_string(self.path.join(QUEUE_FILE)) 178 - .ok() 179 - .map(|s| s.trim().to_string()) 180 - .filter(|s| !s.is_empty()) 181 - .unwrap_or_else(|| queue::DEFAULT_QUEUE.to_string()) 181 + self.read_selector(QUEUE_FILE, queue::DEFAULT_QUEUE) 182 182 } 183 183 184 184 pub fn switch_namespace(&self, name: &str) -> Result<()> { ··· 226 226 } 227 227 } 228 228 229 + fn make_task(id: Id, stable: StableId, obj: TaskObj) -> Task { 230 + Task { 231 + title: obj.title().to_string(), 232 + body: obj.body().to_string(), 233 + attributes: obj.properties, 234 + id, 235 + stable, 236 + } 237 + } 238 + 239 + fn read_task_obj(repo: &Repository, stable: &StableId) -> Result<TaskObj> { 240 + object::read(repo, stable)? 241 + .ok_or_else(|| Error::Parse(format!("task {stable} content missing"))) 242 + } 243 + 229 244 /// Create a task — or, when the content matches an existing task, 230 245 /// reopen / re-bind it instead of clobbering. 231 246 /// ··· 253 268 let stable = StableId(content_oid.to_string()); 254 269 let active_ns = self.namespace(); 255 270 256 - let exists = repo.find_reference(&stable.refname()).is_ok(); 257 - if !exists { 258 - let mut task_obj = TaskObj::new(content); 259 - task_obj 260 - .properties 271 + if repo.find_reference(&stable.refname()).is_err() { 272 + let mut obj = TaskObj::new(content); 273 + obj.properties 261 274 .insert(STATUS_KEY.into(), vec![STATUS_OPEN.into()]); 262 - let stable = object::create(&repo, &task_obj, "create")?; 263 - properties::reindex_task(&repo, &stable, &task_obj.properties)?; 275 + let stable = object::create(&repo, &obj, "create")?; 276 + properties::reindex_task(&repo, &stable, &obj.properties)?; 264 277 let human = 265 278 namespace::assign_id(&repo, &active_ns, stable.clone(), "assign-id")?; 266 - return Ok(Task { 267 - id: Id(human), 268 - stable, 269 - title: task_obj.title().to_string(), 270 - body: task_obj.body().to_string(), 271 - attributes: task_obj.properties, 272 - }); 279 + return Ok(Self::make_task(Id(human), stable, obj)); 273 280 } 274 281 275 282 // Ref already exists. Decide between reopen / idempotent / bind / error. 276 283 if let Some(human) = namespace::human_for(&repo, &active_ns, &stable)? { 277 - // Bound in active namespace. 278 - let mut task_obj = object::read(&repo, &stable)? 279 - .ok_or_else(|| Error::Parse(format!("task {stable} content missing")))?; 280 - let is_done = task_obj 284 + let mut obj = Self::read_task_obj(&repo, &stable)?; 285 + let is_done = obj 281 286 .properties 282 287 .get(STATUS_KEY) 283 - .map(|v| v.iter().any(|s| s == STATUS_DONE)) 284 - .unwrap_or(false); 288 + .is_some_and(|v| v.iter().any(|s| s == STATUS_DONE)); 285 289 if is_done { 286 - task_obj 287 - .properties 290 + obj.properties 288 291 .insert(STATUS_KEY.into(), vec![STATUS_OPEN.into()]); 289 - object::update(&repo, &stable, &task_obj, "reopen")?; 290 - properties::reindex_task(&repo, &stable, &task_obj.properties)?; 292 + object::update(&repo, &stable, &obj, "reopen")?; 293 + properties::reindex_task(&repo, &stable, &obj.properties)?; 291 294 } 292 - return Ok(Task { 293 - id: Id(human), 294 - stable, 295 - title: task_obj.title().to_string(), 296 - body: task_obj.body().to_string(), 297 - attributes: task_obj.properties, 298 - }); 295 + return Ok(Self::make_task(Id(human), stable, obj)); 299 296 } 300 297 301 298 // Not bound here. Refuse if it lives in another namespace; otherwise bind. ··· 314 311 elsewhere.join(", ") 315 312 ))); 316 313 } 317 - let task_obj = object::read(&repo, &stable)? 318 - .ok_or_else(|| Error::Parse(format!("task {stable} content missing")))?; 314 + let obj = Self::read_task_obj(&repo, &stable)?; 319 315 let human = namespace::assign_id(&repo, &active_ns, stable.clone(), "assign-id")?; 320 - Ok(Task { 321 - id: Id(human), 322 - stable, 323 - title: task_obj.title().to_string(), 324 - body: task_obj.body().to_string(), 325 - attributes: task_obj.properties, 326 - }) 316 + Ok(Self::make_task(Id(human), stable, obj)) 327 317 } 328 318 329 319 pub fn task(&self, identifier: TaskIdentifier) -> Result<Task> { 330 320 let (id, stable) = self.resolve(identifier)?; 331 - let repo = self.repo()?; 332 - let task_obj = object::read(&repo, &stable)? 333 - .ok_or_else(|| Error::Parse(format!("Task {id} content missing")))?; 334 - Ok(Task { 335 - id, 336 - stable, 337 - title: task_obj.title().to_string(), 338 - body: task_obj.body().to_string(), 339 - attributes: task_obj.properties, 340 - }) 321 + let obj = Self::read_task_obj(&self.repo()?, &stable)?; 322 + Ok(Self::make_task(id, stable, obj)) 341 323 } 342 324 343 325 pub fn save_task(&self, task: &Task) -> Result<()> { ··· 875 857 Ok(Id(human)) 876 858 } 877 859 860 + fn git(&self) -> std::process::Command { 861 + let mut c = std::process::Command::new("git"); 862 + c.arg("--git-dir").arg(&self.git_dir); 863 + c 864 + } 865 + 878 866 pub fn configure_git_remote_refspecs(&self, remote: &str) -> Result<()> { 879 867 for (key, value) in [ 880 868 (format!("remote.{remote}.push"), "refs/tsk/*:refs/tsk/*"), 881 869 (format!("remote.{remote}.fetch"), "+refs/tsk/*:refs/tsk/*"), 882 870 ] { 883 - let cmd = std::process::Command::new("git") 884 - .arg("--git-dir") 885 - .arg(&self.git_dir) 886 - .args(["config", "--get-all", &key]) 887 - .output()?; 888 - if String::from_utf8_lossy(&cmd.stdout) 871 + let existing = self.git().args(["config", "--get-all", &key]).output()?; 872 + if String::from_utf8_lossy(&existing.stdout) 889 873 .lines() 890 874 .any(|l| l.trim() == value) 891 875 { 892 876 continue; 893 877 } 894 - let s = std::process::Command::new("git") 895 - .arg("--git-dir") 896 - .arg(&self.git_dir) 878 + if !self 879 + .git() 897 880 .args(["config", "--add", &key, value]) 898 - .status()?; 899 - if !s.success() { 881 + .status()? 882 + .success() 883 + { 900 884 return Err(Error::Parse("git config failed".into())); 901 885 } 902 886 } ··· 904 888 } 905 889 906 890 pub fn git_push(&self, remote: &str) -> Result<()> { 907 - let s = std::process::Command::new("git") 908 - .arg("--git-dir") 909 - .arg(&self.git_dir) 891 + if !self 892 + .git() 910 893 .args(["push", remote, "refs/tsk/*:refs/tsk/*"]) 911 - .status()?; 912 - if !s.success() { 894 + .status()? 895 + .success() 896 + { 913 897 return Err(Error::Parse("git push failed".into())); 914 898 } 915 899 Ok(()) ··· 928 912 if refs.is_empty() { 929 913 return Ok(()); 930 914 } 931 - let mut cmd = std::process::Command::new("git"); 932 - cmd.arg("--git-dir").arg(&self.git_dir).args(["push", remote]); 915 + let mut cmd = self.git(); 916 + cmd.args(["push", remote]); 933 917 for r in refs { 934 918 cmd.arg(format!("{r}:{r}")); 935 919 } 936 - let s = cmd.status()?; 937 - if !s.success() { 920 + if !cmd.status()?.success() { 938 921 return Err(Error::Parse("git push failed".into())); 939 922 } 940 923 Ok(()) ··· 947 930 if refs.is_empty() { 948 931 return Ok(()); 949 932 } 950 - let mut cmd = std::process::Command::new("git"); 951 - cmd.arg("--git-dir") 952 - .arg(&self.git_dir) 953 - .args(["fetch", "--refmap=", remote]); 933 + let mut cmd = self.git(); 934 + cmd.args(["fetch", "--refmap=", remote]); 954 935 for r in refs { 955 936 cmd.arg(format!("+{r}:{r}")); 956 937 } 957 - let s = cmd.status()?; 958 - if !s.success() { 938 + if !cmd.status()?.success() { 959 939 return Err(Error::Parse("git fetch failed".into())); 960 940 } 961 941 Ok(()) ··· 1018 998 // performs the configured `+refs/tsk/*:refs/tsk/*` mapping and 1019 999 // clobbers local task refs before we get a chance to reconcile. 1020 1000 let refspec = format!("+refs/tsk/*:{}{remote}/*", merge::FETCH_PREFIX); 1021 - let s = std::process::Command::new("git") 1022 - .arg("--git-dir") 1023 - .arg(&self.git_dir) 1001 + if !self 1002 + .git() 1024 1003 .args(["fetch", "--prune", "--refmap=", remote]) 1025 1004 .arg(&refspec) 1026 - .status()?; 1027 - if !s.success() { 1005 + .status()? 1006 + .success() 1007 + { 1028 1008 return Err(Error::Parse("git fetch failed".into())); 1029 1009 } 1030 1010 let repo = self.repo()?;