A file-based task manager
0
fork

Configure Feed

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

Add per-task and per-namespace edit log

Every workspace mutation now appends a line to log/<id>: created,
edited, prop-set <key>, prop-unset <key>, archived, reopened, and
links-changed (only when the link set actually changed). Log entries
include a unix timestamp and the author from the user's git config
(user.name <user.email>) so multi-contributor workflows can attribute
changes.

CLI:
- tsk log -T <tsk-id> — log for a single task, newest first
- tsk log — every event in the current namespace, newest
first, in git-log style (event/author/date/detail)

Logs are part of all_keys, so they're included in tsk export and
tsk migrate. Tests cover all event kinds plus the export round-trip.

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

+256 -5
+66
src/backend.rs
··· 387 387 ) 388 388 } 389 389 390 + /// One line of a task's edit log. Lines are tab-separated: 391 + /// `<unix-ts>\t<event>\t<detail>\t<author>` — empty fields allowed. 392 + #[derive(Clone, Debug, Eq, PartialEq)] 393 + pub struct LogEntry { 394 + pub id: Id, 395 + pub timestamp: u64, 396 + pub event: String, 397 + pub detail: String, 398 + pub author: String, 399 + } 400 + 401 + impl LogEntry { 402 + fn parse(id: Id, line: &str) -> Option<Self> { 403 + let mut p = line.splitn(4, '\t'); 404 + let timestamp = p.next()?.parse().ok()?; 405 + Some(Self { 406 + id, 407 + timestamp, 408 + event: p.next().unwrap_or("").to_string(), 409 + detail: p.next().unwrap_or("").to_string(), 410 + author: p.next().unwrap_or("").to_string(), 411 + }) 412 + } 413 + } 414 + 415 + /// Append a single log entry for a task. The blob `log/<id>` grows over time; 416 + /// it is never rewritten. 417 + pub fn append_log( 418 + store: &dyn Store, 419 + id: Id, 420 + event: &str, 421 + detail: Option<&str>, 422 + author: &str, 423 + ) -> Result<()> { 424 + let ts = std::time::SystemTime::now() 425 + .duration_since(std::time::UNIX_EPOCH) 426 + .map(|d| d.as_secs()) 427 + .unwrap_or(0); 428 + let line = format!("{ts}\t{event}\t{}\t{author}\n", detail.unwrap_or("")); 429 + let key = format!("log/{}", id.0); 430 + let existing = read_text(store, &key)?; 431 + store.write(&key, format!("{existing}{line}").as_bytes()) 432 + } 433 + 434 + pub fn read_log(store: &dyn Store, id: Id) -> Result<Vec<LogEntry>> { 435 + Ok(read_text(store, &format!("log/{}", id.0))? 436 + .lines() 437 + .filter_map(|l| LogEntry::parse(id, l)) 438 + .collect()) 439 + } 440 + 441 + /// Read every per-task log in the workspace and merge into a single feed 442 + /// sorted by timestamp ascending. 443 + pub fn read_all_logs(store: &dyn Store) -> Result<Vec<LogEntry>> { 444 + let mut all = Vec::new(); 445 + for key in store.list("log")? { 446 + if let Some(idstr) = key.strip_prefix("log/") 447 + && let Ok(n) = idstr.parse::<u32>() 448 + { 449 + all.extend(read_log(store, Id(n))?); 450 + } 451 + } 452 + all.sort_by_key(|e| e.timestamp); 453 + Ok(all) 454 + } 455 + 390 456 pub fn read_remotes(store: &dyn Store) -> Result<Vec<Remote>> { 391 457 Ok(read_text(store, "remotes")? 392 458 .lines()
+75
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 + /// Print the event log. Without -T, prints every event in the current 225 + /// namespace, newest first, in git-log style. With -T, scopes to one task. 226 + Log { 227 + /// Optionally scope to a single task by tsk-ID. 228 + #[arg(short = 'T', value_name = "TSK-ID", value_parser = parse_id)] 229 + tsk_id: Option<Id>, 230 + }, 231 + 224 232 /// Get/set/find tasks by property. Properties are arbitrary key/value 225 233 /// pairs stored alongside a task; some are synthetic (state, has-links, 226 234 /// references, referenced-by) and computed on read. ··· 422 430 Commands::Export { output } => command_export(dir, output), 423 431 Commands::Migrate => command_migrate(dir), 424 432 Commands::Reopen { task_id } => command_reopen(dir, task_id), 433 + Commands::Log { tsk_id } => command_log(dir, tsk_id), 425 434 Commands::Prop { action } => command_prop(dir, action), 426 435 Commands::Namespace { action } => command_namespace(dir, action), 427 436 Commands::Switch { name } => command_namespace_switch(dir, &name), ··· 760 769 git_dir.display() 761 770 ); 762 771 Ok(()) 772 + } 773 + 774 + fn command_log(dir: PathBuf, tsk_id: Option<Id>) -> Result<()> { 775 + let ws = Workspace::from_path(dir)?; 776 + let mut entries = match tsk_id { 777 + Some(id) => ws.read_log(id)?, 778 + None => ws.read_namespace_log()?, 779 + }; 780 + if entries.is_empty() { 781 + eprintln!("No log entries."); 782 + return Ok(()); 783 + } 784 + // Newest first, git-log style. 785 + entries.reverse(); 786 + for (i, e) in entries.iter().enumerate() { 787 + if i > 0 { 788 + println!(); 789 + } 790 + let header = if tsk_id.is_some() { 791 + format!("event {}", e.event) 792 + } else { 793 + format!("event {} {}", e.id, e.event) 794 + }; 795 + println!("{header}"); 796 + if !e.author.is_empty() { 797 + println!("Author: {}", e.author); 798 + } 799 + let ts = std::time::UNIX_EPOCH + std::time::Duration::from_secs(e.timestamp); 800 + println!("Date: {}", format_systemtime(ts)); 801 + if !e.detail.is_empty() { 802 + println!(); 803 + println!(" {}", e.detail); 804 + } 805 + } 806 + Ok(()) 807 + } 808 + 809 + fn format_systemtime(t: std::time::SystemTime) -> String { 810 + let secs = t 811 + .duration_since(std::time::UNIX_EPOCH) 812 + .map(|d| d.as_secs()) 813 + .unwrap_or(0); 814 + // Lightweight RFC3339-ish formatter: split into Y-m-d H:M:S UTC. Avoids 815 + // pulling in chrono just for this. 816 + let (y, mo, d, h, mi, s) = ymd_hms_utc(secs); 817 + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z") 818 + } 819 + 820 + fn ymd_hms_utc(secs: u64) -> (u64, u32, u32, u32, u32, u32) { 821 + let day = secs / 86_400; 822 + let rem = secs % 86_400; 823 + let h = (rem / 3600) as u32; 824 + let mi = ((rem % 3600) / 60) as u32; 825 + let s = (rem % 60) as u32; 826 + // Civil-from-days (Howard Hinnant). Stable for all valid u64 epoch days. 827 + let z = day as i64 + 719_468; 828 + let era = z.div_euclid(146_097); 829 + let doe = (z - era * 146_097) as u64; 830 + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; 831 + let y = yoe as i64 + era * 400; 832 + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); 833 + let mp = (5 * doy + 2) / 153; 834 + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; 835 + let mo = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; 836 + let y = if mo <= 2 { y + 1 } else { y }; 837 + (y as u64, mo, d, h, mi, s) 763 838 } 764 839 765 840 fn command_prop(dir: PathBuf, action: PropAction) -> Result<()> {
+115 -5
src/workspace.rs
··· 211 211 pub fn new_task(&self, title: String, body: String) -> Result<Task> { 212 212 let id = self.next_id()?; 213 213 backend::write_task(self.store(), id, &title, &body, Loc::Active)?; 214 + self.log(id, "created", None)?; 214 215 Ok(Task { 215 216 id, 216 217 title, ··· 219 220 }) 220 221 } 221 222 223 + /// Per-task event log, oldest first. 224 + pub fn read_log(&self, id: Id) -> Result<Vec<backend::LogEntry>> { 225 + backend::read_log(self.store(), id) 226 + } 227 + 228 + /// Every event in this namespace, merged and sorted by timestamp ascending. 229 + pub fn read_namespace_log(&self) -> Result<Vec<backend::LogEntry>> { 230 + backend::read_all_logs(self.store()) 231 + } 232 + 233 + fn log(&self, id: Id, event: &str, detail: Option<&str>) -> Result<()> { 234 + let author = self.git_author().unwrap_or_default(); 235 + backend::append_log(self.store(), id, event, detail, &author) 236 + } 237 + 238 + /// `Name <email>` from the user's git config, if available. Falls back to 239 + /// just one of the two if only one is set, or `None` otherwise. 240 + pub fn git_author(&self) -> Option<String> { 241 + if !self.is_git_backed() { 242 + return None; 243 + } 244 + let marker = std::fs::read_to_string(self.path.join(backend::GIT_BACKED_MARKER)).ok()?; 245 + let repo = git2::Repository::open(PathBuf::from(marker.trim())).ok()?; 246 + let cfg = repo.config().ok()?.snapshot().ok()?; 247 + let name = cfg.get_string("user.name").ok(); 248 + let email = cfg.get_string("user.email").ok(); 249 + match (name, email) { 250 + (Some(n), Some(e)) => Some(format!("{n} <{e}>")), 251 + (Some(n), None) => Some(n), 252 + (None, Some(e)) => Some(e), 253 + (None, None) => None, 254 + } 255 + } 256 + 222 257 pub fn task(&self, identifier: TaskIdentifier) -> Result<Task> { 223 258 let id = self.resolve(identifier)?; 224 259 let (title, body, _loc) = backend::read_task(self.store(), id)? ··· 238 273 }; 239 274 backend::write_task(self.store(), task.id, &task.title, &task.body, loc)?; 240 275 backend::write_attrs(self.store(), task.id, &task.attributes)?; 276 + self.log(task.id, "edited", None)?; 241 277 // After editing, refresh stack title for this id. 242 278 self.update_stack_title(task.id, &task.title)?; 243 279 Ok(()) ··· 263 299 pub fn set_property(&self, id: Id, key: &str, value: &str) -> Result<()> { 264 300 let mut attrs = backend::read_attrs(self.store(), id)?; 265 301 attrs.insert(key.to_string(), value.to_string()); 266 - backend::write_attrs(self.store(), id, &attrs) 302 + backend::write_attrs(self.store(), id, &attrs)?; 303 + self.log(id, "prop-set", Some(key)) 267 304 } 268 305 269 306 /// Remove a property from a task. No-op if not present. ··· 271 308 let mut attrs = backend::read_attrs(self.store(), id)?; 272 309 if attrs.remove(key).is_some() { 273 310 backend::write_attrs(self.store(), id, &attrs)?; 311 + self.log(id, "prop-unset", Some(key))?; 274 312 } 275 313 Ok(()) 276 314 } ··· 348 386 pub fn handle_metadata(&self, tsk: &Task, pre_links: Option<HashSet<Id>>) -> Result<()> { 349 387 if let Some(parsed_task) = parse_task(&tsk.to_string()) { 350 388 let internal_links = parsed_task.intenal_links(); 351 - for link in &internal_links { 389 + let added: HashSet<Id> = match &pre_links { 390 + Some(pre) => internal_links.difference(pre).copied().collect(), 391 + None => internal_links.clone(), 392 + }; 393 + for link in &added { 352 394 self.add_backlink(*link, tsk.id)?; 353 395 } 396 + let mut removed_count = 0; 354 397 if let Some(pre_links) = pre_links { 355 - let removed_links = pre_links.difference(&internal_links); 356 - for link in removed_links { 398 + for link in pre_links.difference(&internal_links) { 357 399 self.remove_backlink(*link, tsk.id)?; 400 + removed_count += 1; 358 401 } 402 + } 403 + if !added.is_empty() || removed_count > 0 { 404 + self.log(tsk.id, "links-changed", None)?; 359 405 } 360 406 } 361 407 Ok(()) ··· 433 479 if backend::task_location(self.store(), id)? == Some(Loc::Active) { 434 480 backend::move_task(self.store(), id, Loc::Archived)?; 435 481 } 482 + self.log(id, "archived", None)?; 436 483 Ok(removed) 437 484 } 438 485 ··· 615 662 /// Every logical blob key that currently exists in the workspace. 616 663 fn all_keys(&self) -> Result<Vec<String>> { 617 664 let mut keys: Vec<String> = Vec::new(); 618 - for prefix in ["tasks", "archive", "attrs", "backlinks"] { 665 + for prefix in ["tasks", "archive", "attrs", "backlinks", "log"] { 619 666 keys.extend(self.store().list(prefix)?); 620 667 } 621 668 for top in ["index", "next", "remotes"] { ··· 694 741 modify_time: std::time::SystemTime::now(), 695 742 }); 696 743 stack.save(self.store())?; 744 + self.log(id, "reopened", None)?; 697 745 Ok(id) 698 746 } 699 747 } ··· 1366 1414 assert!(fws.git_push_refs("origin").is_err()); 1367 1415 assert!(fws.git_pull_refs("origin").is_err()); 1368 1416 assert!(fws.configure_git_remote_refspecs("origin").is_err()); 1417 + } 1418 + 1419 + #[test] 1420 + fn test_edit_log_records_mutations() { 1421 + let (_d, file, git) = setup_dual(); 1422 + for ws in [&file, &git] { 1423 + let t = ws.new_task("first".into(), "body".into()).unwrap(); 1424 + let id = t.id; 1425 + ws.push_task(t).unwrap(); 1426 + 1427 + ws.set_property(id, "priority", "high").unwrap(); 1428 + ws.unset_property(id, "priority").unwrap(); 1429 + // Edit via the same path command_edit uses. 1430 + let mut reread = ws.task(TaskIdentifier::Id(id)).unwrap(); 1431 + reread.title = "edited".into(); 1432 + ws.save_task(&reread).unwrap(); 1433 + 1434 + // Trigger handle_metadata so links-changed fires. 1435 + let other = ws.new_task("other".into(), "".into()).unwrap(); 1436 + let other_id = other.id; 1437 + ws.push_task(other).unwrap(); 1438 + let linker = Task { 1439 + id, 1440 + title: "edited".into(), 1441 + body: format!("see [[{other_id}]]"), 1442 + attributes: Default::default(), 1443 + }; 1444 + ws.handle_metadata(&linker, Some(HashSet::new())).unwrap(); 1445 + // Same links as before → no log entry added. 1446 + let mut same_links = HashSet::new(); 1447 + same_links.insert(other_id); 1448 + ws.handle_metadata(&linker, Some(same_links)).unwrap(); 1449 + 1450 + ws.drop(TaskIdentifier::Id(id)).unwrap(); 1451 + ws.reopen(TaskIdentifier::Id(id)).unwrap(); 1452 + 1453 + let log = ws.read_log(id).unwrap(); 1454 + let events: Vec<&str> = log.iter().map(|e| e.event.as_str()).collect(); 1455 + assert_eq!( 1456 + events, 1457 + vec![ 1458 + "created", 1459 + "prop-set", 1460 + "prop-unset", 1461 + "edited", 1462 + "links-changed", 1463 + "archived", 1464 + "reopened", 1465 + ], 1466 + "got: {events:?}" 1467 + ); 1468 + 1469 + // Logs are included in export. 1470 + let dest = ws.path.join("export.zip"); 1471 + ws.export_zip(&dest).unwrap(); 1472 + let f = std::fs::File::open(&dest).unwrap(); 1473 + let zip = zip::ZipArchive::new(f).unwrap(); 1474 + let names: std::collections::HashSet<String> = 1475 + zip.file_names().map(|s| s.to_string()).collect(); 1476 + assert!(names.contains(&format!("log/{}", id.0))); 1477 + std::fs::remove_file(&dest).unwrap(); 1478 + } 1369 1479 } 1370 1480 1371 1481 #[test]