A file-based task manager
0
fork

Configure Feed

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

Add tsk log task / log namespace for ref edit history

Walks the commit chain on a tsk ref and prints it newest-first in a
git-log-style oneline + author + relative-time layout. tsk log task
takes a -T tsk-N; tsk log namespace defaults to the active namespace.
The same log_ref helper covers any future per-ref view (queue, etc.).

Tests: workspace tests for both task and namespace history; lib test
covers the relative_time formatter at every breakpoint.

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

+173
+10
AGENTS.md
··· 65 65 This keeps the user's `tsk` queue free of agent-internal bookkeeping while 66 66 still surfacing the work for later. 67 67 68 + ## History 69 + 70 + Every task and namespace is a commit chain in git, so edits are 71 + auditable: 72 + 73 + ``` 74 + tsk log task -T tsk-N # all commits on a task's tree 75 + tsk log namespace [<name>] # id assignments / drops in a namespace 76 + ``` 77 + 68 78 ## Migrations 69 79 70 80 Storage and conventions evolve. A single command runs every known one-shot
+79
src/lib.rs
··· 110 110 /// Currently: backfill `status=open` on tasks without a status property. 111 111 /// New migrations land here as they're added. 112 112 FixUp, 113 + /// Print the commit history of a tsk ref. Newest commit first. 114 + Log { 115 + #[command(subcommand)] 116 + target: LogTarget, 117 + }, 113 118 /// Print refspec/setup hints for `git push`/`git fetch` to include `refs/tsk/*`. 114 119 GitSetup { 115 120 /// Configure push/fetch refspecs on the named remote (default: origin). ··· 185 190 #[arg(short = 's')] 186 191 shell: Shell, 187 192 }, 193 + } 194 + 195 + #[derive(Subcommand)] 196 + enum LogTarget { 197 + /// Edit history of a single task. 198 + Task { 199 + #[command(flatten)] 200 + task_id: TaskId, 201 + }, 202 + /// Edit history of a namespace tree (id assignments, drops, shares). 203 + /// Defaults to the active namespace. 204 + Namespace { name: Option<String> }, 188 205 } 189 206 190 207 #[derive(Subcommand)] ··· 316 333 Workspace::from_path(dir)?.deprioritize(task_id.into()) 317 334 } 318 335 Commands::Clean => Workspace::from_path(dir)?.clean(), 336 + Commands::Log { target } => command_log(dir, target), 319 337 Commands::FixUp => { 320 338 let ws = Workspace::from_path(dir)?; 321 339 let n = ws.backfill_status()?; ··· 576 594 Ok(()) 577 595 } 578 596 597 + fn command_log(dir: PathBuf, target: LogTarget) -> Result<()> { 598 + let ws = Workspace::from_path(dir)?; 599 + let commits = match target { 600 + LogTarget::Task { task_id } => ws.log_task(task_id.into())?, 601 + LogTarget::Namespace { name } => { 602 + ws.log_namespace(&name.unwrap_or_else(|| ws.namespace()))? 603 + } 604 + }; 605 + for c in commits { 606 + // git-log --oneline-style: short oid, summary, then author + date below. 607 + let short = &c.oid[..c.oid.len().min(8)]; 608 + println!("{short} {}", c.summary); 609 + println!(" {} ({})", c.author, format_unix(c.timestamp)); 610 + } 611 + Ok(()) 612 + } 613 + 614 + fn format_unix(ts: i64) -> String { 615 + let now = std::time::SystemTime::now() 616 + .duration_since(std::time::UNIX_EPOCH) 617 + .map(|d| d.as_secs() as i64) 618 + .unwrap_or(0); 619 + let delta = now - ts; 620 + if delta < 0 { 621 + return "in the future".to_string(); 622 + } 623 + relative_time(delta as u64) 624 + } 625 + 626 + fn relative_time(secs: u64) -> String { 627 + const M: u64 = 60; 628 + const H: u64 = 60 * M; 629 + const D: u64 = 24 * H; 630 + if secs < M { 631 + format!("{secs}s ago") 632 + } else if secs < H { 633 + format!("{}m ago", secs / M) 634 + } else if secs < D { 635 + format!("{}h ago", secs / H) 636 + } else if secs < 30 * D { 637 + format!("{}d ago", secs / D) 638 + } else if secs < 365 * D { 639 + format!("{}mo ago", secs / (30 * D)) 640 + } else { 641 + format!("{}y ago", secs / (365 * D)) 642 + } 643 + } 644 + 579 645 fn command_prop(dir: PathBuf, action: PropAction) -> Result<()> { 580 646 let ws = Workspace::from_path(dir)?; 581 647 match action { ··· 754 820 #[cfg(test)] 755 821 mod tests { 756 822 use super::*; 823 + 824 + #[test] 825 + fn relative_time_breakpoints() { 826 + assert_eq!(relative_time(0), "0s ago"); 827 + assert_eq!(relative_time(59), "59s ago"); 828 + assert_eq!(relative_time(60), "1m ago"); 829 + assert_eq!(relative_time(3599), "59m ago"); 830 + assert_eq!(relative_time(3600), "1h ago"); 831 + assert_eq!(relative_time(86_399), "23h ago"); 832 + assert_eq!(relative_time(86_400), "1d ago"); 833 + assert_eq!(relative_time(30 * 86_400), "1mo ago"); 834 + assert_eq!(relative_time(365 * 86_400), "1y ago"); 835 + } 757 836 758 837 #[test] 759 838 fn picker_marks_current_and_appends_sentinel() {
+84
src/workspace.rs
··· 92 92 } 93 93 } 94 94 95 + /// One commit on a tsk ref's history. 96 + pub struct LogCommit { 97 + pub oid: String, 98 + pub timestamp: i64, 99 + pub author: String, 100 + pub summary: String, 101 + } 102 + 95 103 /// One pending inbox item in the active queue. 96 104 pub struct InboxItem { 97 105 pub key: String, ··· 402 410 }); 403 411 } 404 412 Ok(out) 413 + } 414 + 415 + /// One commit on a tsk ref (task / namespace / queue). 416 + pub fn log_ref(&self, refname: &str) -> Result<Vec<LogCommit>> { 417 + let repo = self.repo()?; 418 + let Ok(r) = repo.find_reference(refname) else { 419 + return Err(Error::Parse(format!("ref {refname} not found"))); 420 + }; 421 + let Some(target) = r.target() else { 422 + return Ok(Vec::new()); 423 + }; 424 + let mut out = Vec::new(); 425 + let mut current = repo.find_commit(target).ok(); 426 + while let Some(c) = current { 427 + out.push(LogCommit { 428 + oid: c.id().to_string(), 429 + timestamp: c.time().seconds(), 430 + author: format!( 431 + "{} <{}>", 432 + c.author().name().unwrap_or(""), 433 + c.author().email().unwrap_or("") 434 + ), 435 + summary: c.summary().unwrap_or("").to_string(), 436 + }); 437 + current = c.parent(0).ok(); 438 + } 439 + Ok(out) 440 + } 441 + 442 + /// History of edits to a single task. 443 + pub fn log_task(&self, identifier: TaskIdentifier) -> Result<Vec<LogCommit>> { 444 + let (_, stable) = self.resolve(identifier)?; 445 + self.log_ref(&stable.refname()) 446 + } 447 + 448 + /// History of edits to a namespace's tree (id assignments, drops, shares). 449 + pub fn log_namespace(&self, name: &str) -> Result<Vec<LogCommit>> { 450 + self.log_ref(&namespace::refname(name)) 405 451 } 406 452 407 453 /// Set `status=open` on every task in the active namespace that has no ··· 838 884 assert_eq!(pulled.0, id.0); 839 885 let stack = ws.read_stack().unwrap(); 840 886 assert_eq!(stack.len(), 1); 887 + } 888 + 889 + #[test] 890 + fn log_task_walks_commit_chain_newest_first() { 891 + let (_d, ws) = fresh_workspace(); 892 + let t = ws.new_task("v1".into(), "".into()).unwrap(); 893 + let id = t.id; 894 + ws.push_task(t).unwrap(); 895 + 896 + // Two edits (each appends a commit). 897 + let mut t = ws.task(TaskIdentifier::Id(id)).unwrap(); 898 + t.title = "v2".into(); 899 + ws.save_task(&t).unwrap(); 900 + let mut t = ws.task(TaskIdentifier::Id(id)).unwrap(); 901 + t.title = "v3".into(); 902 + ws.save_task(&t).unwrap(); 903 + 904 + let log = ws.log_task(TaskIdentifier::Id(id)).unwrap(); 905 + // create + 2 edits = 3 commits. 906 + assert_eq!(log.len(), 3); 907 + // Newest first. 908 + assert_eq!(log[0].summary, "edit"); 909 + assert_eq!(log[1].summary, "edit"); 910 + assert_eq!(log[2].summary, "create"); 911 + } 912 + 913 + #[test] 914 + fn log_namespace_walks_id_assignments() { 915 + let (_d, ws) = fresh_workspace(); 916 + let t1 = ws.new_task("a".into(), "".into()).unwrap(); 917 + ws.push_task(t1).unwrap(); 918 + let t2 = ws.new_task("b".into(), "".into()).unwrap(); 919 + ws.push_task(t2).unwrap(); 920 + 921 + let log = ws.log_namespace("tsk").unwrap(); 922 + // Two id-assignments. 923 + assert!(log.len() >= 2, "got {}", log.len()); 924 + assert_eq!(log[0].summary, "assign-id"); 841 925 } 842 926 843 927 #[test]