A file-based task manager
0
fork

Configure Feed

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

at noah/git-backend-2 1582 lines 60 kB view raw
1//! High-level workspace API. Orchestrates [`object`], [`namespace`], and 2//! [`queue`] to back the CLI commands. 3//! 4//! Per-clone state lives under `<git-dir>/tsk/` (not tracked, not pushed). 5//! Two files select the active namespace and queue (defaults: `tsk` / `tsk`): 6//! `<git-dir>/tsk/namespace` and `<git-dir>/tsk/queue`. 7 8use crate::errors::{Error, Result}; 9use crate::object::{self, StableId, Task as TaskObj}; 10use crate::patch; 11use crate::{merge, namespace, properties, queue}; 12use git2::Repository; 13use std::collections::BTreeMap; 14use std::fmt::Display; 15use std::path::PathBuf; 16use std::str::FromStr; 17use std::sync::OnceLock; 18 19/// Process-wide override for the active queue, set once by the CLI's 20/// `-q/--queue` flag. When `Some`, `Workspace::queue()` returns this 21/// value instead of reading `<git-dir>/tsk/queue`. 22static QUEUE_OVERRIDE: OnceLock<Option<String>> = OnceLock::new(); 23 24pub fn set_queue_override(q: Option<String>) { 25 let _ = QUEUE_OVERRIDE.set(q); 26} 27 28#[derive(Debug)] 29pub struct ImportOutcome { 30 pub stable: StableId, 31 pub commits_imported: usize, 32 pub bound_human: Option<u32>, 33} 34 35const NAMESPACE_FILE: &str = "namespace"; 36const QUEUE_FILE: &str = "queue"; 37const REMOTE_FILE: &str = "remote"; 38pub const DEFAULT_REMOTE: &str = "origin"; 39/// Auto-managed property holding the task's lifecycle state. Set to 40/// `STATUS_OPEN` on creation and flipped to `STATUS_DONE` by [`Workspace::drop`]. 41pub const STATUS_KEY: &str = "status"; 42pub const STATUS_OPEN: &str = "open"; 43pub const STATUS_DONE: &str = "done"; 44/// User-local state lives under `<git-dir>/<STATE_DIR>/` so it isn't tracked 45/// by the enclosing repo (the `.git/` directory is by definition not in the 46/// working tree). Each clone gets its own active namespace + queue. 47const STATE_DIR: &str = "tsk"; 48 49/// A human-readable task identifier (`tsk-N`). Always namespace-scoped: the 50/// integer N has no meaning without the namespace it was minted in. 51#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] 52pub struct Id(pub u32); 53 54impl FromStr for Id { 55 type Err = Error; 56 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { 57 let upper = s.to_uppercase(); 58 let s = upper 59 .trim() 60 .strip_prefix("TSK-") 61 .ok_or(Self::Err::Parse(format!("expected tsk- prefix. Got {s}")))?; 62 Ok(Self(s.parse()?)) 63 } 64} 65 66impl Display for Id { 67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 68 write!(f, "tsk-{}", self.0) 69 } 70} 71 72impl From<u32> for Id { 73 fn from(v: u32) -> Self { 74 Id(v) 75 } 76} 77 78#[derive(Clone)] 79pub enum TaskIdentifier { 80 Id(Id), 81 /// Index into the active queue's stack (0 = top). 82 Relative(u32), 83} 84 85impl From<Id> for TaskIdentifier { 86 fn from(v: Id) -> Self { 87 TaskIdentifier::Id(v) 88 } 89} 90 91/// One row of a queue listing. 92pub struct StackEntry { 93 pub id: Id, 94 pub stable: StableId, 95 pub title: String, 96} 97 98/// User-facing task: human id (in active namespace) + content + properties. 99/// Each property holds zero or more text values. 100#[derive(Debug)] 101pub struct Task { 102 #[allow(dead_code)] // exposed for callers; constructed by workspace 103 pub id: Id, 104 #[allow(dead_code)] // exposed for callers; constructed by workspace 105 pub stable: StableId, 106 pub title: String, 107 pub body: String, 108 pub attributes: BTreeMap<String, Vec<String>>, 109} 110 111impl Display for Task { 112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 113 write!(f, "{}\n\n{}", self.title, self.body) 114 } 115} 116 117/// One commit on a tsk ref's history. 118pub struct LogCommit { 119 pub oid: String, 120 pub timestamp: i64, 121 pub author: String, 122 pub summary: String, 123} 124 125/// One pending inbox item in the active queue. 126pub struct InboxItem { 127 pub key: String, 128 pub source_queue: String, 129 #[allow(dead_code)] // exposed for callers; constructed by workspace 130 pub stable: StableId, 131 pub title: String, 132} 133 134pub struct Workspace { 135 /// The user-local state directory: `<git-dir>/tsk/`. Holds the 136 /// `namespace` and `queue` selectors — both per-clone, not pushed. 137 pub path: PathBuf, 138 /// The enclosing git repo's `.git` directory. 139 pub git_dir: PathBuf, 140} 141 142impl Workspace { 143 /// Bootstrap user-local state in `<git-dir>/tsk/`. Idempotent: existing 144 /// state files are left alone so re-init doesn't reset the active 145 /// namespace/queue. Errors if `path` isn't inside a git repository. 146 pub fn init(path: PathBuf) -> Result<()> { 147 let git_dir = find_git_dir(&path) 148 .ok_or_else(|| Error::Parse("tsk requires an enclosing git repository".into()))?; 149 let state_dir = git_dir.join(STATE_DIR); 150 std::fs::create_dir_all(&state_dir)?; 151 let ns = state_dir.join(NAMESPACE_FILE); 152 if !ns.exists() { 153 std::fs::write(&ns, namespace::DEFAULT_NS.as_bytes())?; 154 } 155 let q = state_dir.join(QUEUE_FILE); 156 if !q.exists() { 157 std::fs::write(&q, queue::DEFAULT_QUEUE.as_bytes())?; 158 } 159 Ok(()) 160 } 161 162 pub fn from_path(path: PathBuf) -> Result<Self> { 163 let git_dir = find_git_dir(&path).ok_or(Error::Uninitialized)?; 164 let state_dir = git_dir.join(STATE_DIR); 165 if !state_dir.exists() { 166 // Auto-bootstrap so `git tsk <anything>` works without an 167 // explicit init step. 168 Self::init(path)?; 169 } 170 Ok(Self { 171 path: state_dir, 172 git_dir, 173 }) 174 } 175 176 fn repo(&self) -> Result<Repository> { 177 Ok(Repository::open(&self.git_dir)?) 178 } 179 180 fn read_selector(&self, file: &str, default: &str) -> String { 181 std::fs::read_to_string(self.path.join(file)) 182 .ok() 183 .map(|s| s.trim().to_string()) 184 .filter(|s| !s.is_empty()) 185 .unwrap_or_else(|| default.to_string()) 186 } 187 188 pub fn namespace(&self) -> String { 189 self.read_selector(NAMESPACE_FILE, namespace::DEFAULT_NS) 190 } 191 192 pub fn queue(&self) -> String { 193 if let Some(Some(q)) = QUEUE_OVERRIDE.get() { 194 return q.clone(); 195 } 196 self.read_selector(QUEUE_FILE, queue::DEFAULT_QUEUE) 197 } 198 199 pub fn switch_namespace(&self, name: &str) -> Result<()> { 200 namespace::validate_name(name)?; 201 std::fs::write(self.path.join(NAMESPACE_FILE), name.as_bytes())?; 202 Ok(()) 203 } 204 205 pub fn switch_queue(&self, name: &str) -> Result<()> { 206 queue::validate_name(name)?; 207 std::fs::write(self.path.join(QUEUE_FILE), name.as_bytes())?; 208 Ok(()) 209 } 210 211 /// Persist a clone-local default remote so `tsk git-push` / 212 /// `tsk git-pull` (and the auto-push paths) target it without an 213 /// explicit `<remote>` arg. 214 pub fn default_remote(&self) -> String { 215 self.read_selector(REMOTE_FILE, DEFAULT_REMOTE) 216 } 217 218 /// Persist `name` as the default remote. Errors if `name` isn't a 219 /// configured git remote — tsk only ever uses remotes the host repo 220 /// already knows about. 221 pub fn set_default_remote(&self, name: &str) -> Result<()> { 222 let known = self.git_remotes()?; 223 if !known.iter().any(|r| r == name) { 224 return Err(Error::Parse(format!( 225 "no such git remote '{name}'; configured: {}", 226 if known.is_empty() { 227 "<none>".into() 228 } else { 229 known.join(", ") 230 } 231 ))); 232 } 233 std::fs::write(self.path.join(REMOTE_FILE), name.as_bytes())?; 234 Ok(()) 235 } 236 237 pub fn git_remotes(&self) -> Result<Vec<String>> { 238 let out = self.git().arg("remote").output()?; 239 if !out.status.success() { 240 return Err(Error::Parse("git remote failed".into())); 241 } 242 Ok(String::from_utf8_lossy(&out.stdout) 243 .lines() 244 .map(|s| s.trim().to_string()) 245 .filter(|s| !s.is_empty()) 246 .collect()) 247 } 248 249 pub fn list_namespaces(&self) -> Result<Vec<String>> { 250 namespace::list_names(&self.repo()?) 251 } 252 253 pub fn list_queues(&self) -> Result<Vec<String>> { 254 queue::list_names(&self.repo()?) 255 } 256 257 pub fn create_queue(&self, name: &str, can_pull: Option<bool>) -> Result<()> { 258 queue::validate_name(name)?; 259 let repo = self.repo()?; 260 let mut q = queue::read(&repo, name)?; 261 if let Some(cp) = can_pull { 262 q.can_pull = cp; 263 } 264 queue::write(&repo, name, &q, "create queue") 265 } 266 267 fn resolve(&self, identifier: TaskIdentifier) -> Result<(Id, StableId)> { 268 match identifier { 269 TaskIdentifier::Id(id) => { 270 let stable = namespace::lookup(&self.repo()?, &self.namespace(), id.0)? 271 .ok_or_else(|| Error::Parse(format!("Task {id} not found in namespace")))?; 272 Ok((id, stable)) 273 } 274 TaskIdentifier::Relative(r) => { 275 let stack = self.read_stack()?; 276 let entry = stack.into_iter().nth(r as usize).ok_or(Error::NoTasks)?; 277 Ok((entry.id, entry.stable)) 278 } 279 } 280 } 281 282 fn make_task(id: Id, stable: StableId, obj: TaskObj) -> Task { 283 Task { 284 title: obj.title().to_string(), 285 body: obj.body().to_string(), 286 attributes: obj.properties, 287 id, 288 stable, 289 } 290 } 291 292 fn read_task_obj(repo: &Repository, stable: &StableId) -> Result<TaskObj> { 293 object::read(repo, stable)? 294 .ok_or_else(|| Error::Parse(format!("task {stable} content missing"))) 295 } 296 297 fn title_for(repo: &Repository, stable: &StableId) -> Result<String> { 298 Ok(object::read(repo, stable)? 299 .map(|t| t.title().to_string()) 300 .unwrap_or_default()) 301 } 302 303 /// Create a task — or, when the content matches an existing task, 304 /// reopen / re-bind it instead of clobbering. 305 /// 306 /// Stable id is content-addressed (SHA-1 of the content blob), so two 307 /// `new_task` calls with the same body produce the same stable id and 308 /// would collide on the task ref. Branches: 309 /// 310 /// - ref doesn't exist → fresh create. 311 /// - ref exists, bound in active namespace, status=done → reopen 312 /// (flip status back to `open`, return the existing human id). 313 /// - ref exists, bound in active namespace, status=open → idempotent; 314 /// return the existing human id without touching the task tree. 315 /// - ref exists, bound in another namespace but not the active one → 316 /// error with a hint to use `tsk share` or `tsk reopen -T`. 317 /// - ref exists, unbound everywhere → bind it in the active namespace. 318 pub fn new_task(&self, title: String, body: String) -> Result<Task> { 319 let repo = self.repo()?; 320 let content = if body.is_empty() { 321 title.trim().to_string() 322 } else { 323 format!("{}\n\n{}", title.trim(), body.trim()) 324 }; 325 // Compute the stable id without writing anything. 326 let content_oid = repo.blob(content.as_bytes())?; 327 let stable = StableId(content_oid.to_string()); 328 let active_ns = self.namespace(); 329 330 if repo.find_reference(&stable.refname()).is_err() { 331 let mut obj = TaskObj::new(content); 332 obj.properties 333 .insert(STATUS_KEY.into(), vec![STATUS_OPEN.into()]); 334 let stable = object::create(&repo, &obj, "create")?; 335 properties::reindex_task(&repo, &stable, &obj.properties)?; 336 let human = 337 namespace::assign_id(&repo, &active_ns, stable.clone(), "assign-id")?; 338 return Ok(Self::make_task(Id(human), stable, obj)); 339 } 340 341 // Ref already exists. Decide between reopen / idempotent / bind / error. 342 if let Some(human) = namespace::human_for(&repo, &active_ns, &stable)? { 343 let mut obj = Self::read_task_obj(&repo, &stable)?; 344 let is_done = obj 345 .properties 346 .get(STATUS_KEY) 347 .is_some_and(|v| v.iter().any(|s| s == STATUS_DONE)); 348 if is_done { 349 obj.properties 350 .insert(STATUS_KEY.into(), vec![STATUS_OPEN.into()]); 351 object::update(&repo, &stable, &obj, "reopen")?; 352 properties::reindex_task(&repo, &stable, &obj.properties)?; 353 } 354 return Ok(Self::make_task(Id(human), stable, obj)); 355 } 356 357 // Not bound here. Refuse if it lives in another namespace; otherwise bind. 358 let mut elsewhere = Vec::new(); 359 for ns_name in namespace::list_names(&repo)? { 360 if ns_name == active_ns { 361 continue; 362 } 363 if let Some(h) = namespace::human_for(&repo, &ns_name, &stable)? { 364 elsewhere.push(format!("{ns_name}-{h}")); 365 } 366 } 367 if !elsewhere.is_empty() { 368 return Err(Error::Parse(format!( 369 "task with this content is already bound at {} — use `tsk share {active_ns} -T <id>` or `tsk reopen -T <id>` to bind it into '{active_ns}'", 370 elsewhere.join(", ") 371 ))); 372 } 373 let obj = Self::read_task_obj(&repo, &stable)?; 374 let human = namespace::assign_id(&repo, &active_ns, stable.clone(), "assign-id")?; 375 Ok(Self::make_task(Id(human), stable, obj)) 376 } 377 378 pub fn task(&self, identifier: TaskIdentifier) -> Result<Task> { 379 let (id, stable) = self.resolve(identifier)?; 380 let obj = Self::read_task_obj(&self.repo()?, &stable)?; 381 Ok(Self::make_task(id, stable, obj)) 382 } 383 384 /// Persist any in-memory edits to a task. Returns `true` when the 385 /// underlying `object::update` actually wrote a new commit (i.e. the 386 /// resulting tree differs from the current tip); `false` on a no-op. 387 pub fn save_task(&self, task: &Task) -> Result<bool> { 388 let repo = self.repo()?; 389 let content = if task.body.is_empty() { 390 task.title.trim().to_string() 391 } else { 392 format!("{}\n\n{}", task.title.trim(), task.body.trim()) 393 }; 394 let task_obj = TaskObj { 395 content, 396 properties: task.attributes.clone(), 397 }; 398 let wrote = object::update(&repo, &task.stable, &task_obj, "edit")?; 399 properties::reindex_task(&repo, &task.stable, &task.attributes)?; 400 Ok(wrote) 401 } 402 403 /// Append a value to a property on a task. If the value is already 404 /// present, this is a no-op. Persists both the task tree and the index. 405 pub fn add_property_value( 406 &self, 407 identifier: TaskIdentifier, 408 key: &str, 409 value: &str, 410 ) -> Result<()> { 411 let mut task = self.task(identifier)?; 412 let entry = task.attributes.entry(key.to_string()).or_default(); 413 if !entry.iter().any(|v| v == value) { 414 entry.push(value.to_string()); 415 } 416 self.save_task(&task)?; 417 Ok(()) 418 } 419 420 /// Replace the entire value list for a property. 421 pub fn set_property( 422 &self, 423 identifier: TaskIdentifier, 424 key: &str, 425 values: Vec<String>, 426 ) -> Result<()> { 427 let mut task = self.task(identifier)?; 428 if values.is_empty() { 429 task.attributes.remove(key); 430 } else { 431 task.attributes.insert(key.to_string(), values); 432 } 433 self.save_task(&task)?; 434 Ok(()) 435 } 436 437 /// Remove a single value from a property, or the whole property if 438 /// `value` is None. 439 pub fn unset_property( 440 &self, 441 identifier: TaskIdentifier, 442 key: &str, 443 value: Option<&str>, 444 ) -> Result<()> { 445 let mut task = self.task(identifier)?; 446 match value { 447 None => { 448 task.attributes.remove(key); 449 } 450 Some(v) => { 451 if let Some(entry) = task.attributes.get_mut(key) { 452 entry.retain(|x| x != v); 453 if entry.is_empty() { 454 task.attributes.remove(key); 455 } 456 } 457 } 458 } 459 self.save_task(&task)?; 460 Ok(()) 461 } 462 463 pub fn property_keys(&self) -> Result<Vec<String>> { 464 properties::list_keys(&self.repo()?) 465 } 466 467 pub fn property_values(&self, key: &str) -> Result<Vec<String>> { 468 properties::values_for(&self.repo()?, key) 469 } 470 471 /// Find tasks (by human id, scoped to active namespace) that have 472 /// `key` set; if `value` is supplied, restricts to entries containing 473 /// that value. 474 pub fn find_by_property( 475 &self, 476 key: &str, 477 value: Option<&str>, 478 ) -> Result<Vec<(Id, StableId, String)>> { 479 let repo = self.repo()?; 480 let by_stable = ns_reverse(&namespace::read(&repo, &self.namespace())?); 481 let mut out = Vec::new(); 482 for stable in properties::find(&repo, key, value)? { 483 let Some(&human) = by_stable.get(&stable) else { continue }; 484 out.push((Id(human), stable.clone(), Self::title_for(&repo, &stable)?)); 485 } 486 Ok(out) 487 } 488 489 pub fn push_task(&self, task: Task) -> Result<()> { 490 queue::push_top(&self.repo()?, &self.queue(), task.stable, "push") 491 } 492 493 pub fn append_task(&self, task: Task) -> Result<()> { 494 queue::push_bottom(&self.repo()?, &self.queue(), task.stable, "append") 495 } 496 497 pub fn read_stack(&self) -> Result<Vec<StackEntry>> { 498 let repo = self.repo()?; 499 let by_stable = ns_reverse(&namespace::read(&repo, &self.namespace())?); 500 let mut out = Vec::new(); 501 for stable in queue::read(&repo, &self.queue())?.index { 502 // Skip tasks not visible in the active namespace (different ns owns them). 503 let Some(&human) = by_stable.get(&stable) else { continue }; 504 let title = Self::title_for(&repo, &stable)?; 505 out.push(StackEntry { id: Id(human), stable, title }); 506 } 507 Ok(out) 508 } 509 510 /// Every (human id, stable id, title) bound in the given namespace, 511 /// sorted by human id ascending. Independent of any queue. 512 pub fn list_namespace_tasks(&self, name: &str) -> Result<Vec<StackEntry>> { 513 let repo = self.repo()?; 514 let mut out = Vec::new(); 515 for (human, stable) in namespace::read(&repo, name)?.mapping { 516 let title = Self::title_for(&repo, &stable)?; 517 out.push(StackEntry { id: Id(human), stable, title }); 518 } 519 Ok(out) 520 } 521 522 /// One commit on a tsk ref (task / namespace / queue). 523 pub fn log_ref(&self, refname: &str) -> Result<Vec<LogCommit>> { 524 let repo = self.repo()?; 525 let Ok(r) = repo.find_reference(refname) else { 526 return Err(Error::Parse(format!("ref {refname} not found"))); 527 }; 528 let Some(target) = r.target() else { 529 return Ok(Vec::new()); 530 }; 531 let mut out = Vec::new(); 532 let mut current = repo.find_commit(target).ok(); 533 while let Some(c) = current { 534 out.push(LogCommit { 535 oid: c.id().to_string(), 536 timestamp: c.time().seconds(), 537 author: format!( 538 "{} <{}>", 539 c.author().name().unwrap_or(""), 540 c.author().email().unwrap_or("") 541 ), 542 summary: c.summary().unwrap_or("").to_string(), 543 }); 544 current = c.parent(0).ok(); 545 } 546 Ok(out) 547 } 548 549 /// Export a task as an mbox-format patch series. With `bind=true`, the 550 /// root entry carries the active namespace's human id so the recipient 551 /// can opt in to mirroring the binding on import. 552 #[allow(dead_code)] // single-task wrapper, kept for callers that don't care about batch 553 pub fn export_task(&self, identifier: TaskIdentifier, bind: bool) -> Result<String> { 554 self.export_tasks(&[identifier], bind) 555 } 556 557 /// Export multiple tasks as a single concatenated mbox stream. 558 /// Each task's full commit chain is emitted in order; the importer 559 /// groups them back by stable id. 560 pub fn export_tasks( 561 &self, 562 identifiers: &[TaskIdentifier], 563 bind: bool, 564 ) -> Result<String> { 565 let repo = self.repo()?; 566 let mut out = String::new(); 567 for ident in identifiers { 568 let (id, stable) = self.resolve(ident.clone())?; 569 let opts = patch::ExportOpts { 570 bind: if bind { 571 Some((self.namespace(), id.0)) 572 } else { 573 None 574 }, 575 }; 576 out.push_str(&patch::export_task(&repo, &stable, &opts)?); 577 } 578 Ok(out) 579 } 580 581 /// Import a task from an mbox patch series produced by `export_task`. 582 /// On `bind=true`, also bind the imported stable id into the active 583 /// namespace (reusing the existing human id if already bound). 584 pub fn import_task(&self, mbox: &str, bind: bool) -> Result<Vec<ImportOutcome>> { 585 let repo = self.repo()?; 586 let results = patch::import_mbox(&repo, mbox)?; 587 let mut out = Vec::with_capacity(results.len()); 588 for res in results { 589 let bound_human = if bind { 590 Some(namespace::ensure_bound( 591 &repo, 592 &self.namespace(), 593 res.stable.clone(), 594 "import-bind", 595 )?) 596 } else { 597 None 598 }; 599 out.push(ImportOutcome { 600 stable: res.stable, 601 commits_imported: res.commits_imported, 602 bound_human, 603 }); 604 } 605 Ok(out) 606 } 607 608 /// History of edits to a single task. 609 pub fn log_task(&self, identifier: TaskIdentifier) -> Result<Vec<LogCommit>> { 610 let (_, stable) = self.resolve(identifier)?; 611 self.log_ref(&stable.refname()) 612 } 613 614 /// History of edits to a namespace's tree (id assignments, drops, shares). 615 pub fn log_namespace(&self, name: &str) -> Result<Vec<LogCommit>> { 616 self.log_ref(&namespace::refname(name)) 617 } 618 619 /// History of edits to a queue's tree (pushes, drops, inbox moves). 620 pub fn log_queue(&self, name: &str) -> Result<Vec<LogCommit>> { 621 self.log_ref(&queue::refname(name)) 622 } 623 624 /// Set `status=open` on every task in the active namespace that has no 625 /// status yet. Skips tasks already marked done. Returns the number of 626 /// tasks updated. One-shot migration for tasks created before 627 /// auto-status existed. 628 pub fn backfill_status(&self) -> Result<usize> { 629 let repo = self.repo()?; 630 let ns = namespace::read(&repo, &self.namespace())?; 631 let mut updated = 0usize; 632 for (human, _stable) in ns.mapping.iter() { 633 let mut task = self.task(TaskIdentifier::Id(Id(*human)))?; 634 if task.attributes.contains_key(STATUS_KEY) { 635 continue; 636 } 637 task.attributes 638 .insert(STATUS_KEY.into(), vec![STATUS_OPEN.into()]); 639 self.save_task(&task)?; 640 updated += 1; 641 } 642 Ok(updated) 643 } 644 645 /// Re-save every task in the active namespace whose property blobs are 646 /// in the legacy line-split encoding, rewriting them as size-prefixed. 647 /// Returns the number of tasks rewritten. Idempotent — `save_task` 648 /// no-ops on already-migrated tasks. 649 pub fn migrate_property_encoding(&self) -> Result<usize> { 650 let ns = namespace::read(&self.repo()?, &self.namespace())?; 651 let mut rewritten = 0; 652 for human in ns.mapping.keys() { 653 let task = self.task(TaskIdentifier::Id(Id(*human)))?; 654 if self.save_task(&task)? { 655 rewritten += 1; 656 } 657 } 658 Ok(rewritten) 659 } 660 661 /// Prune empty / orphan refs under `refs/tsk/*` and recover from 662 /// partial multi-ref writes. Returns 663 /// `(queues_dropped, prop_orphans_dropped, ghost_bindings_dropped, orphan_queue_entries_dropped)`. 664 /// 665 /// Recovers: 666 /// - empty queues (no index, no inbox) other than the default `tsk` 667 /// queue, which always exists by convention; 668 /// - property index entries pointing at task refs that no longer 669 /// resolve (the index ref itself is auto-deleted by `properties::set` 670 /// when its last entry goes); 671 /// - ghost namespace bindings (`human → stable` where stable's task 672 /// ref doesn't resolve) — left behind if a crash hit between 673 /// `object::create` and `namespace::assign_id` and the task object 674 /// was later GC'd, or if a remote namespace ref was force-pushed 675 /// ahead of its task refs; 676 /// - queue index entries pointing at missing task refs (same root 677 /// cause); also covered by `tsk clean` for the active queue. 678 /// 679 /// Task object refs are left alone — task history is preserved 680 /// intentionally — and namespace `next` counters are valid even when 681 /// no live binding uses the latest id. 682 pub fn gc_refs(&self) -> Result<(usize, usize, usize, usize)> { 683 let repo = self.repo()?; 684 let task_exists = |s: &StableId| repo.find_reference(&s.refname()).is_ok(); 685 let mut queues_pruned = 0; 686 let mut prop_orphans = 0; 687 let mut ghost_bindings = 0; 688 let mut orphan_queue_entries = 0; 689 690 // Empty queues + orphan queue index entries. 691 for name in queue::list_names(&repo)? { 692 let mut q = queue::read(&repo, &name)?; 693 let before = q.index.len(); 694 q.index.retain(&task_exists); 695 if q.index.len() != before { 696 let removed = before - q.index.len(); 697 orphan_queue_entries += removed; 698 queue::write(&repo, &name, &q, "gc-orphan-queue")?; 699 } 700 if name != queue::DEFAULT_QUEUE && q.index.is_empty() && q.inbox.is_empty() { 701 if let Ok(mut r) = repo.find_reference(&queue::refname(&name)) { 702 r.delete()?; 703 queues_pruned += 1; 704 } 705 } 706 } 707 708 // Orphan property index entries. 709 for key in properties::list_keys(&repo)? { 710 for (stable, _vals) in properties::read(&repo, &key)? { 711 if !task_exists(&stable) { 712 properties::set(&repo, &key, &stable, &[], "gc-orphan")?; 713 prop_orphans += 1; 714 } 715 } 716 } 717 718 // Ghost namespace bindings (human → stable with no task ref). 719 for ns_name in namespace::list_names(&repo)? { 720 let mut ns = namespace::read(&repo, &ns_name)?; 721 let before = ns.mapping.len(); 722 ns.mapping.retain(|_, s| task_exists(s)); 723 if ns.mapping.len() != before { 724 ghost_bindings += before - ns.mapping.len(); 725 namespace::write(&repo, &ns_name, &ns, "gc-ghost-bindings")?; 726 } 727 } 728 729 Ok((queues_pruned, prop_orphans, ghost_bindings, orphan_queue_entries)) 730 } 731 732 /// Flip a task back to `status=open` and push it to the top of the 733 /// active queue. Idempotent — already-open tasks are unchanged on 734 /// disk; the queue push deduplicates on the existing entry. 735 pub fn reopen(&self, identifier: TaskIdentifier) -> Result<Id> { 736 let mut task = self.task(identifier)?; 737 task.attributes 738 .insert(STATUS_KEY.into(), vec![STATUS_OPEN.into()]); 739 self.save_task(&task)?; 740 queue::push_top(&self.repo()?, &self.queue(), task.stable, "reopen")?; 741 Ok(task.id) 742 } 743 744 /// Drop a task from the active queue and mark it `status=done`. The 745 /// namespace binding is kept so the task remains addressable by its 746 /// human id (and discoverable via `tsk prop find status done`); the 747 /// task object's commit history is preserved either way. 748 pub fn drop(&self, identifier: TaskIdentifier) -> Result<Option<Id>> { 749 let (id, stable) = self.resolve(identifier)?; 750 let repo = self.repo()?; 751 queue::remove(&repo, &self.queue(), &stable, "drop")?; 752 // Flip status=done in the task's tree + index. 753 let mut task = self.task(TaskIdentifier::Id(id))?; 754 task.attributes 755 .insert(STATUS_KEY.into(), vec![STATUS_DONE.into()]); 756 self.save_task(&task)?; 757 Ok(Some(id)) 758 } 759 760 fn mutate_index<F: FnOnce(&mut Vec<StableId>)>(&self, f: F, msg: &str) -> Result<()> { 761 let repo = self.repo()?; 762 let mut q = queue::read(&repo, &self.queue())?; 763 f(&mut q.index); 764 queue::write(&repo, &self.queue(), &q, msg) 765 } 766 767 pub fn swap_top(&self) -> Result<()> { 768 self.mutate_index( 769 |idx| { 770 if idx.len() >= 2 { 771 idx.swap(0, 1); 772 } 773 }, 774 "swap", 775 ) 776 } 777 778 fn rotate_top3(&self, third_to_top: bool) -> Result<()> { 779 self.mutate_index( 780 |idx| { 781 if idx.len() >= 3 { 782 if third_to_top { 783 let c = idx.remove(2); 784 idx.insert(0, c); 785 } else { 786 let a = idx.remove(0); 787 idx.insert(2, a); 788 } 789 } 790 }, 791 "rotate", 792 ) 793 } 794 795 pub fn rot(&self) -> Result<()> { 796 self.rotate_top3(true) 797 } 798 799 pub fn tor(&self) -> Result<()> { 800 self.rotate_top3(false) 801 } 802 803 fn move_in_index(&self, identifier: TaskIdentifier, to_front: bool) -> Result<()> { 804 let (_, stable) = self.resolve(identifier)?; 805 self.mutate_index( 806 |idx| { 807 idx.retain(|s| s != &stable); 808 if to_front { 809 idx.insert(0, stable); 810 } else { 811 idx.push(stable); 812 } 813 }, 814 if to_front { "prioritize" } else { "deprioritize" }, 815 ) 816 } 817 818 pub fn prioritize(&self, identifier: TaskIdentifier) -> Result<()> { 819 self.move_in_index(identifier, true) 820 } 821 822 pub fn deprioritize(&self, identifier: TaskIdentifier) -> Result<()> { 823 self.move_in_index(identifier, false) 824 } 825 826 /// Drop entries from the active queue's index whose stable ids no longer 827 /// resolve to a task object. 828 pub fn clean(&self) -> Result<()> { 829 let repo = self.repo()?; 830 let mut q = queue::read(&repo, &self.queue())?; 831 let before = q.index.len(); 832 q.index.retain(|s| { 833 repo.find_reference(&s.refname()) 834 .ok() 835 .and_then(|r| r.target()) 836 .is_some() 837 }); 838 if q.index.len() != before { 839 queue::write(&repo, &self.queue(), &q, "clean")?; 840 } 841 Ok(()) 842 } 843 844 /// Share a task into another namespace by binding the same stable id to 845 /// the next human id in `target_ns`. 846 pub fn share(&self, identifier: TaskIdentifier, target_ns: &str) -> Result<u32> { 847 let cur = self.namespace(); 848 if target_ns == cur { 849 return Err(Error::Parse( 850 "Refusing to share a task into its own namespace".into(), 851 )); 852 } 853 namespace::validate_name(target_ns)?; 854 let (_, stable) = self.resolve(identifier)?; 855 let repo = self.repo()?; 856 namespace::assign_id(&repo, target_ns, stable, "share") 857 } 858 859 /// Move a task from the active queue's index into `target_queue`'s inbox. 860 pub fn assign_to_queue( 861 &self, 862 identifier: TaskIdentifier, 863 target_queue: &str, 864 ) -> Result<(String, StableId)> { 865 let cur = self.queue(); 866 if target_queue == cur { 867 return Err(Error::Parse( 868 "Refusing to assign a task to its own queue".into(), 869 )); 870 } 871 queue::validate_name(target_queue)?; 872 let (id, stable) = self.resolve(identifier)?; 873 let repo = self.repo()?; 874 let key = queue::inbox_key(&cur, id.0); 875 queue::add_to_inbox(&repo, target_queue, key.clone(), stable.clone(), "assign")?; 876 queue::remove(&repo, &cur, &stable, "assigned-out")?; 877 Ok((key, stable)) 878 } 879 880 pub fn list_inbox(&self) -> Result<Vec<InboxItem>> { 881 let repo = self.repo()?; 882 let mut out = Vec::new(); 883 for (key, stable) in queue::read(&repo, &self.queue())?.inbox { 884 let source_queue = key 885 .rsplit_once('-') 886 .map(|(s, _)| s.to_string()) 887 .unwrap_or_else(|| key.clone()); 888 let title = Self::title_for(&repo, &stable)?; 889 out.push(InboxItem { key, source_queue, stable, title }); 890 } 891 Ok(out) 892 } 893 894 /// Accept an inbox item: bind to a human id in the active namespace 895 /// (if not already), and push onto the top of the active queue. 896 pub fn accept_inbox(&self, key: &str) -> Result<Id> { 897 let repo = self.repo()?; 898 let stable = queue::take_from_inbox(&repo, &self.queue(), key, "accept")? 899 .ok_or_else(|| Error::Parse(format!("Inbox item '{key}' not found")))?; 900 let human = 901 namespace::ensure_bound(&repo, &self.namespace(), stable.clone(), "accept-bind")?; 902 queue::push_top(&repo, &self.queue(), stable, "accept-push")?; 903 Ok(Id(human)) 904 } 905 906 /// Reject an inbox item: remove it from the active queue's inbox and 907 /// bounce it back to the sender's inbox so they see the return. The 908 /// source queue is recovered from the key (`<src>-<seq>`); the return 909 /// key is `<active>-<seq>` so each round-trip is uniquely identified. 910 pub fn reject_inbox(&self, key: &str) -> Result<()> { 911 let repo = self.repo()?; 912 let stable = queue::take_from_inbox(&repo, &self.queue(), key, "reject")? 913 .ok_or_else(|| Error::Parse(format!("Inbox item '{key}' not found")))?; 914 if let Some((src, seq)) = key.rsplit_once('-') { 915 let cur = self.queue(); 916 if src != cur { 917 let return_key = format!("{cur}-{seq}"); 918 queue::add_to_inbox(&repo, src, return_key, stable, "reject-return")?; 919 } 920 } 921 Ok(()) 922 } 923 924 /// Pull a task from a foreign queue's index into the active queue's 925 /// index. Only allowed if the source queue's `can_pull` is true. 926 pub fn pull_from_queue(&self, source_queue: &str, identifier: TaskIdentifier) -> Result<Id> { 927 let cur = self.queue(); 928 if source_queue == cur { 929 return Err(Error::Parse("Source queue equals active queue".into())); 930 } 931 let repo = self.repo()?; 932 let src = queue::read(&repo, source_queue)?; 933 if !src.can_pull { 934 return Err(Error::Parse(format!( 935 "Queue '{source_queue}' has can-pull=false; refusing" 936 ))); 937 } 938 let (_, stable) = self.resolve(identifier)?; 939 if !src.index.iter().any(|s| s == &stable) { 940 return Err(Error::Parse(format!( 941 "Task not present in queue '{source_queue}'" 942 ))); 943 } 944 queue::remove(&repo, source_queue, &stable, "pulled-out")?; 945 queue::push_top(&repo, &cur, stable.clone(), "pull")?; 946 let human = namespace::ensure_bound(&repo, &self.namespace(), stable, "pull-bind")?; 947 Ok(Id(human)) 948 } 949 950 fn git(&self) -> std::process::Command { 951 let mut c = std::process::Command::new("git"); 952 c.arg("--git-dir").arg(&self.git_dir); 953 c 954 } 955 956 pub fn configure_git_remote_refspecs(&self, remote: &str) -> Result<()> { 957 for (key, value) in [ 958 (format!("remote.{remote}.push"), "refs/tsk/*:refs/tsk/*"), 959 (format!("remote.{remote}.fetch"), "+refs/tsk/*:refs/tsk/*"), 960 ] { 961 let existing = self.git().args(["config", "--get-all", &key]).output()?; 962 if String::from_utf8_lossy(&existing.stdout) 963 .lines() 964 .any(|l| l.trim() == value) 965 { 966 continue; 967 } 968 if !self 969 .git() 970 .args(["config", "--add", &key, value]) 971 .status()? 972 .success() 973 { 974 return Err(Error::Parse("git config failed".into())); 975 } 976 } 977 Ok(()) 978 } 979 980 pub fn git_push(&self, remote: &str) -> Result<()> { 981 if !self 982 .git() 983 .args(["push", remote, "refs/tsk/*:refs/tsk/*"]) 984 .status()? 985 .success() 986 { 987 return Err(Error::Parse("git push failed".into())); 988 } 989 Ok(()) 990 } 991 992 #[allow(dead_code)] // CLI calls git_pull_with_strategy directly; kept for API symmetry. 993 pub fn git_pull(&self, remote: &str) -> Result<()> { 994 self.git_pull_with_strategy(remote, merge::Strategy::default())?; 995 Ok(()) 996 } 997 998 /// Push only the named refs to `remote`. Each ref is sent as its own 999 /// `<ref>:<ref>` refspec (no force) so the operation refuses 1000 /// non-fast-forward updates the same way `git_push` does. 1001 pub fn git_push_refs(&self, remote: &str, refs: &[String]) -> Result<()> { 1002 if refs.is_empty() { 1003 return Ok(()); 1004 } 1005 let mut cmd = self.git(); 1006 cmd.args(["push", remote]); 1007 for r in refs { 1008 cmd.arg(format!("{r}:{r}")); 1009 } 1010 if !cmd.status()?.success() { 1011 return Err(Error::Parse("git push failed".into())); 1012 } 1013 Ok(()) 1014 } 1015 1016 /// Fetch only the named refs from `remote`, force-updating each. Used 1017 /// by paths (e.g. `tsk inbox`) that need a single ref refreshed without 1018 /// the wire cost of a full `git_pull`. 1019 pub fn git_fetch_refs(&self, remote: &str, refs: &[String]) -> Result<()> { 1020 if refs.is_empty() { 1021 return Ok(()); 1022 } 1023 let mut cmd = self.git(); 1024 cmd.args(["fetch", "--refmap=", remote]); 1025 for r in refs { 1026 cmd.arg(format!("+{r}:{r}")); 1027 } 1028 if !cmd.status()?.success() { 1029 return Err(Error::Parse("git fetch failed".into())); 1030 } 1031 Ok(()) 1032 } 1033 1034 /// Refs to push after `assign_to_queue`: the target queue (gained an 1035 /// inbox entry), the task ref itself (so the receiver can read the 1036 /// body), and every property index that already references this task. 1037 pub fn refs_for_assign_out( 1038 &self, 1039 target_queue: &str, 1040 stable: &StableId, 1041 ) -> Result<Vec<String>> { 1042 let repo = self.repo()?; 1043 let mut refs = vec![queue::refname(target_queue), stable.refname()]; 1044 for key in properties::list_keys(&repo)? { 1045 let entries = properties::read(&repo, &key)?; 1046 if entries.contains_key(stable) { 1047 refs.push(properties::refname(&key)); 1048 } 1049 } 1050 Ok(refs) 1051 } 1052 1053 /// Refs to push after `accept_inbox`: the active queue (entry moved 1054 /// from inbox to index) and the active namespace (the receiver may have 1055 /// allocated a new human id binding the accepted task). 1056 pub fn refs_for_accept_inbox(&self) -> Vec<String> { 1057 vec![ 1058 queue::refname(&self.queue()), 1059 namespace::refname(&self.namespace()), 1060 ] 1061 } 1062 1063 /// Refs to push after `reject_inbox`: the active queue (entry left the 1064 /// inbox) and the source queue (entry was bounced back into its inbox). 1065 pub fn refs_for_reject_inbox(&self, source_queue: &str) -> Vec<String> { 1066 vec![ 1067 queue::refname(&self.queue()), 1068 queue::refname(source_queue), 1069 ] 1070 } 1071 1072 /// Refs to fetch before listing the inbox: just the active queue. 1073 pub fn refs_for_inbox_pull(&self) -> Vec<String> { 1074 vec![queue::refname(&self.queue())] 1075 } 1076 1077 /// Fetch into a non-clobbering shadow namespace, then reconcile each 1078 /// task ref under the chosen strategy (default `merge`). Non-task refs 1079 /// (namespaces/queues/property indices) still force-update from the 1080 /// remote — better merging for those is tracked separately. 1081 pub fn git_pull_with_strategy( 1082 &self, 1083 remote: &str, 1084 strategy: merge::Strategy, 1085 ) -> Result<merge::PullOutcome> { 1086 // `--refmap=` disables the remote's configured fetch refspec so our 1087 // explicit refspec is the *only* one applied; otherwise git also 1088 // performs the configured `+refs/tsk/*:refs/tsk/*` mapping and 1089 // clobbers local task refs before we get a chance to reconcile. 1090 let refspec = format!("+refs/tsk/*:{}{remote}/*", merge::FETCH_PREFIX); 1091 if !self 1092 .git() 1093 .args(["fetch", "--prune", "--refmap=", remote]) 1094 .arg(&refspec) 1095 .status()? 1096 .success() 1097 { 1098 return Err(Error::Parse("git fetch failed".into())); 1099 } 1100 let repo = self.repo()?; 1101 let tasks = merge::reconcile_task_refs(&repo, remote, strategy)?; 1102 let namespaces = merge::reconcile_namespace_refs(&repo, remote)?; 1103 let queues = merge::reconcile_queue_refs(&repo, remote)?; 1104 merge::fast_forward_non_task_refs(&repo, remote)?; 1105 Ok(merge::PullOutcome { tasks, namespaces, queues }) 1106 } 1107} 1108 1109/// `stable → human` reverse of a namespace mapping for O(log n) visibility checks. 1110fn ns_reverse(ns: &namespace::Namespace) -> BTreeMap<StableId, u32> { 1111 ns.mapping.iter().map(|(h, s)| (s.clone(), *h)).collect() 1112} 1113 1114pub fn find_git_dir(start: &std::path::Path) -> Option<PathBuf> { 1115 let mut cur = Some(start.to_path_buf()); 1116 while let Some(p) = cur { 1117 let candidate = p.join(".git"); 1118 if candidate.exists() { 1119 return Some(candidate); 1120 } 1121 cur = p.parent().map(|q| q.to_path_buf()); 1122 } 1123 None 1124} 1125 1126#[cfg(test)] 1127mod test { 1128 use super::*; 1129 1130 fn run_git_init(p: &std::path::Path) { 1131 let s = std::process::Command::new("git") 1132 .args(["init", "-q", "-b", "main"]) 1133 .current_dir(p) 1134 .status() 1135 .unwrap(); 1136 assert!(s.success()); 1137 let _ = std::process::Command::new("git") 1138 .args(["config", "user.name", "Test"]) 1139 .current_dir(p) 1140 .status(); 1141 let _ = std::process::Command::new("git") 1142 .args(["config", "user.email", "t@e"]) 1143 .current_dir(p) 1144 .status(); 1145 } 1146 1147 fn fresh_workspace() -> (tempfile::TempDir, Workspace) { 1148 let dir = tempfile::tempdir().unwrap(); 1149 run_git_init(dir.path()); 1150 Workspace::init(dir.path().to_path_buf()).unwrap(); 1151 let ws = Workspace::from_path(dir.path().to_path_buf()).unwrap(); 1152 (dir, ws) 1153 } 1154 1155 #[test] 1156 fn push_list_drop_round_trip() { 1157 let (_d, ws) = fresh_workspace(); 1158 let t1 = ws.new_task("first".into(), "body 1".into()).unwrap(); 1159 let id1 = t1.id; 1160 ws.push_task(t1).unwrap(); 1161 let t2 = ws.new_task("second".into(), "".into()).unwrap(); 1162 let id2 = t2.id; 1163 ws.push_task(t2).unwrap(); 1164 let stack = ws.read_stack().unwrap(); 1165 assert_eq!( 1166 stack.iter().map(|e| e.id).collect::<Vec<_>>(), 1167 vec![id2, id1] 1168 ); 1169 let read = ws.task(TaskIdentifier::Id(id1)).unwrap(); 1170 assert_eq!(read.title, "first"); 1171 assert_eq!(read.body, "body 1"); 1172 let dropped = ws.drop(TaskIdentifier::Id(id1)).unwrap(); 1173 assert_eq!(dropped, Some(id1)); 1174 let stack = ws.read_stack().unwrap(); 1175 assert_eq!(stack.iter().map(|e| e.id).collect::<Vec<_>>(), vec![id2]); 1176 } 1177 1178 #[test] 1179 fn id_allocation_monotonic_across_drops() { 1180 let (_d, ws) = fresh_workspace(); 1181 let t1 = ws.new_task("a".into(), "".into()).unwrap(); 1182 let id1 = t1.id; 1183 ws.push_task(t1).unwrap(); 1184 ws.drop(TaskIdentifier::Id(id1)).unwrap(); 1185 let t2 = ws.new_task("b".into(), "".into()).unwrap(); 1186 assert_eq!(t2.id.0, id1.0 + 1, "ids must not be reused after drop"); 1187 } 1188 1189 #[test] 1190 fn edit_appends_history() { 1191 let (_d, ws) = fresh_workspace(); 1192 let t = ws.new_task("v1".into(), "body".into()).unwrap(); 1193 let id = t.id; 1194 let stable = t.stable.clone(); 1195 ws.push_task(t).unwrap(); 1196 let mut t = ws.task(TaskIdentifier::Id(id)).unwrap(); 1197 t.title = "v2".into(); 1198 ws.save_task(&t).unwrap(); 1199 let read = ws.task(TaskIdentifier::Id(id)).unwrap(); 1200 assert_eq!(read.title, "v2"); 1201 assert_eq!(read.stable, stable, "stable id must not change on edit"); 1202 let repo = ws.repo().unwrap(); 1203 let head = repo 1204 .find_reference(&stable.refname()) 1205 .unwrap() 1206 .target() 1207 .unwrap(); 1208 let commit = repo.find_commit(head).unwrap(); 1209 assert_eq!(commit.parent_count(), 1); 1210 } 1211 1212 #[test] 1213 fn share_to_other_namespace() { 1214 let (_d, ws) = fresh_workspace(); 1215 let t = ws.new_task("shared".into(), "".into()).unwrap(); 1216 let id_in_tsk = t.id; 1217 let stable = t.stable.clone(); 1218 ws.push_task(t).unwrap(); 1219 let h = ws.share(TaskIdentifier::Id(id_in_tsk), "alpha").unwrap(); 1220 ws.switch_namespace("alpha").unwrap(); 1221 let task_in_alpha = ws.task(TaskIdentifier::Id(Id(h))).unwrap(); 1222 assert_eq!(task_in_alpha.stable, stable); 1223 assert_eq!(task_in_alpha.title, "shared"); 1224 } 1225 1226 #[test] 1227 fn assign_moves_to_target_inbox() { 1228 let (_d, ws) = fresh_workspace(); 1229 ws.create_queue("review", None).unwrap(); 1230 let t = ws.new_task("for review".into(), "".into()).unwrap(); 1231 let id = t.id; 1232 ws.push_task(t).unwrap(); 1233 let key = ws 1234 .assign_to_queue(TaskIdentifier::Id(id), "review") 1235 .unwrap() 1236 .0; 1237 let stack = ws.read_stack().unwrap(); 1238 assert!(stack.is_empty()); 1239 ws.switch_queue("review").unwrap(); 1240 let inbox = ws.list_inbox().unwrap(); 1241 assert_eq!(inbox.len(), 1); 1242 assert_eq!(inbox[0].key, key); 1243 let accepted = ws.accept_inbox(&key).unwrap(); 1244 assert_eq!(accepted.0, id.0); 1245 let stack = ws.read_stack().unwrap(); 1246 assert_eq!(stack.len(), 1); 1247 } 1248 1249 #[test] 1250 fn reject_returns_to_source_inbox() { 1251 let (_d, ws) = fresh_workspace(); 1252 ws.create_queue("review", None).unwrap(); 1253 let t = ws.new_task("bounce me".into(), "".into()).unwrap(); 1254 let id = t.id; 1255 let stable = t.stable.clone(); 1256 ws.push_task(t).unwrap(); 1257 let assign_key = ws 1258 .assign_to_queue(TaskIdentifier::Id(id), "review") 1259 .unwrap() 1260 .0; 1261 ws.switch_queue("review").unwrap(); 1262 ws.reject_inbox(&assign_key).unwrap(); 1263 let inbox_here = ws.list_inbox().unwrap(); 1264 assert!(inbox_here.is_empty(), "rejected item must leave receiver inbox"); 1265 ws.switch_queue("tsk").unwrap(); 1266 let returned = ws.list_inbox().unwrap(); 1267 assert_eq!(returned.len(), 1, "rejected item must land in sender inbox"); 1268 assert_eq!(returned[0].source_queue, "review"); 1269 assert_eq!(returned[0].stable, stable); 1270 } 1271 1272 #[test] 1273 fn pull_only_when_can_pull() { 1274 let (_d, ws) = fresh_workspace(); 1275 ws.create_queue("private", Some(false)).unwrap(); 1276 ws.switch_queue("private").unwrap(); 1277 let t = ws.new_task("private task".into(), "".into()).unwrap(); 1278 let id = t.id; 1279 ws.push_task(t).unwrap(); 1280 ws.switch_queue("tsk").unwrap(); 1281 let r = ws.pull_from_queue("private", TaskIdentifier::Id(id)); 1282 assert!(r.is_err(), "pull from can-pull=false queue must fail"); 1283 ws.create_queue("private", Some(true)).unwrap(); 1284 let pulled = ws.pull_from_queue("private", TaskIdentifier::Id(id)).unwrap(); 1285 assert_eq!(pulled.0, id.0); 1286 let stack = ws.read_stack().unwrap(); 1287 assert_eq!(stack.len(), 1); 1288 } 1289 1290 #[test] 1291 fn list_namespace_tasks_shows_all_bindings_including_dropped() { 1292 let (_d, ws) = fresh_workspace(); 1293 let t1 = ws.new_task("alpha".into(), "".into()).unwrap(); 1294 let id1 = t1.id; 1295 ws.push_task(t1).unwrap(); 1296 let t2 = ws.new_task("beta".into(), "".into()).unwrap(); 1297 let id2 = t2.id; 1298 ws.push_task(t2).unwrap(); 1299 // Dropped tasks keep their namespace binding (per status property design). 1300 ws.drop(TaskIdentifier::Id(id1)).unwrap(); 1301 1302 let listed = ws.list_namespace_tasks("tsk").unwrap(); 1303 let ids: Vec<_> = listed.iter().map(|e| e.id).collect(); 1304 assert_eq!(ids, vec![id1, id2]); 1305 let titles: Vec<_> = listed.iter().map(|e| e.title.as_str()).collect(); 1306 assert_eq!(titles, vec!["alpha", "beta"]); 1307 } 1308 1309 #[test] 1310 fn log_task_walks_commit_chain_newest_first() { 1311 let (_d, ws) = fresh_workspace(); 1312 let t = ws.new_task("v1".into(), "".into()).unwrap(); 1313 let id = t.id; 1314 ws.push_task(t).unwrap(); 1315 1316 // Two edits (each appends a commit). 1317 let mut t = ws.task(TaskIdentifier::Id(id)).unwrap(); 1318 t.title = "v2".into(); 1319 ws.save_task(&t).unwrap(); 1320 let mut t = ws.task(TaskIdentifier::Id(id)).unwrap(); 1321 t.title = "v3".into(); 1322 ws.save_task(&t).unwrap(); 1323 1324 let log = ws.log_task(TaskIdentifier::Id(id)).unwrap(); 1325 // create + 2 edits = 3 commits. 1326 assert_eq!(log.len(), 3); 1327 // Newest first. 1328 assert_eq!(log[0].summary, "edit"); 1329 assert_eq!(log[1].summary, "edit"); 1330 assert_eq!(log[2].summary, "create"); 1331 } 1332 1333 #[test] 1334 fn log_namespace_walks_id_assignments() { 1335 let (_d, ws) = fresh_workspace(); 1336 let t1 = ws.new_task("a".into(), "".into()).unwrap(); 1337 ws.push_task(t1).unwrap(); 1338 let t2 = ws.new_task("b".into(), "".into()).unwrap(); 1339 ws.push_task(t2).unwrap(); 1340 1341 let log = ws.log_namespace("tsk").unwrap(); 1342 // Two id-assignments. 1343 assert!(log.len() >= 2, "got {}", log.len()); 1344 assert_eq!(log[0].summary, "assign-id tsk-2"); 1345 assert_eq!(log[1].summary, "assign-id tsk-1"); 1346 } 1347 1348 #[test] 1349 fn backfill_status_marks_legacy_tasks_open_and_skips_done() { 1350 let (_d, ws) = fresh_workspace(); 1351 1352 // Simulate a legacy task: bind a stable id with no status property. 1353 let repo = ws.repo().unwrap(); 1354 let raw = object::Task::new("legacy task"); 1355 let stable = object::create(&repo, &raw, "create").unwrap(); 1356 let h_legacy = 1357 namespace::assign_id(&repo, &ws.namespace(), stable.clone(), "assign").unwrap(); 1358 queue::push_top(&repo, &ws.queue(), stable, "push").unwrap(); 1359 1360 // Plus a fresh task that already has status=open and a dropped one. 1361 let t_open = ws.new_task("fresh open".into(), "".into()).unwrap(); 1362 let id_open = t_open.id; 1363 ws.push_task(t_open).unwrap(); 1364 let t_done = ws.new_task("will drop".into(), "".into()).unwrap(); 1365 let id_done = t_done.id; 1366 ws.push_task(t_done).unwrap(); 1367 ws.drop(TaskIdentifier::Id(id_done)).unwrap(); 1368 1369 // Pre-condition: the legacy task has no status. 1370 let read = ws.task(TaskIdentifier::Id(Id(h_legacy))).unwrap(); 1371 assert!(!read.attributes.contains_key(STATUS_KEY)); 1372 1373 let n = ws.backfill_status().unwrap(); 1374 assert_eq!(n, 1, "only the legacy task gets backfilled"); 1375 1376 // Legacy is now open, fresh-open stays open, dropped stays done. 1377 let read = ws.task(TaskIdentifier::Id(Id(h_legacy))).unwrap(); 1378 assert_eq!( 1379 read.attributes.get(STATUS_KEY), 1380 Some(&vec![STATUS_OPEN.to_string()]) 1381 ); 1382 let read = ws.task(TaskIdentifier::Id(id_open)).unwrap(); 1383 assert_eq!( 1384 read.attributes.get(STATUS_KEY), 1385 Some(&vec![STATUS_OPEN.to_string()]) 1386 ); 1387 let read = ws.task(TaskIdentifier::Id(id_done)).unwrap(); 1388 assert_eq!( 1389 read.attributes.get(STATUS_KEY), 1390 Some(&vec![STATUS_DONE.to_string()]) 1391 ); 1392 1393 // Re-running is a no-op. 1394 let n = ws.backfill_status().unwrap(); 1395 assert_eq!(n, 0); 1396 } 1397 1398 #[test] 1399 fn new_task_starts_open_drop_marks_done() { 1400 let (_d, ws) = fresh_workspace(); 1401 let t = ws.new_task("a".into(), "".into()).unwrap(); 1402 assert_eq!( 1403 t.attributes.get(STATUS_KEY), 1404 Some(&vec![STATUS_OPEN.to_string()]) 1405 ); 1406 let id = t.id; 1407 ws.push_task(t).unwrap(); 1408 1409 // Index reflects the new open task. 1410 let opens = ws 1411 .find_by_property(STATUS_KEY, Some(STATUS_OPEN)) 1412 .unwrap(); 1413 assert_eq!(opens.len(), 1); 1414 assert_eq!(opens[0].0, id); 1415 1416 ws.drop(TaskIdentifier::Id(id)).unwrap(); 1417 1418 // Status flipped, queue empty, namespace binding kept. 1419 let read = ws.task(TaskIdentifier::Id(id)).unwrap(); 1420 assert_eq!( 1421 read.attributes.get(STATUS_KEY), 1422 Some(&vec![STATUS_DONE.to_string()]) 1423 ); 1424 assert!(ws.read_stack().unwrap().is_empty()); 1425 let dones = ws 1426 .find_by_property(STATUS_KEY, Some(STATUS_DONE)) 1427 .unwrap(); 1428 assert_eq!(dones.len(), 1); 1429 assert_eq!(dones[0].0, id); 1430 } 1431 1432 #[test] 1433 fn init_does_not_create_files_in_working_tree() { 1434 let dir = tempfile::tempdir().unwrap(); 1435 run_git_init(dir.path()); 1436 Workspace::init(dir.path().to_path_buf()).unwrap(); 1437 // The only directory entries in the working tree should be `.git` 1438 // (from `git init`) — no `.tsk` and nothing else. 1439 let entries: Vec<_> = std::fs::read_dir(dir.path()) 1440 .unwrap() 1441 .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned())) 1442 .collect(); 1443 assert_eq!( 1444 entries, 1445 vec![".git".to_string()], 1446 "no user-local state should land in the working tree" 1447 ); 1448 // The state files should live under <git-dir>/tsk/. 1449 assert!(dir.path().join(".git/tsk/namespace").exists()); 1450 assert!(dir.path().join(".git/tsk/queue").exists()); 1451 } 1452 1453 #[test] 1454 fn duplicate_content_in_active_ns_done_reopens() { 1455 let (_d, ws) = fresh_workspace(); 1456 let t = ws.new_task("clean kitchen".into(), "".into()).unwrap(); 1457 let original_id = t.id; 1458 let stable = t.stable.clone(); 1459 ws.push_task(t).unwrap(); 1460 ws.drop(TaskIdentifier::Id(original_id)).unwrap(); 1461 // Task is now status=done. Re-creating with the same content should 1462 // reopen rather than mint a new id. 1463 let again = ws.new_task("clean kitchen".into(), "".into()).unwrap(); 1464 assert_eq!(again.id, original_id, "reopened task keeps its human id"); 1465 assert_eq!(again.stable, stable); 1466 assert_eq!( 1467 again.attributes.get(STATUS_KEY).unwrap(), 1468 &vec![STATUS_OPEN.to_string()], 1469 "reopen flips status back to open" 1470 ); 1471 } 1472 1473 #[test] 1474 fn duplicate_content_open_in_active_ns_is_idempotent() { 1475 let (_d, ws) = fresh_workspace(); 1476 let first = ws.new_task("write report".into(), "".into()).unwrap(); 1477 let id = first.id; 1478 ws.push_task(first).unwrap(); 1479 let second = ws.new_task("write report".into(), "".into()).unwrap(); 1480 assert_eq!(second.id, id, "same content returns the same id"); 1481 } 1482 1483 #[test] 1484 fn duplicate_content_bound_only_in_other_ns_errors() { 1485 let (_d, ws) = fresh_workspace(); 1486 let t = ws.new_task("file taxes".into(), "".into()).unwrap(); 1487 let id = t.id; 1488 ws.push_task(t).unwrap(); 1489 // Move the binding from the default `tsk` namespace into `alpha`, 1490 // leaving the active `tsk` namespace without a binding. 1491 ws.share(TaskIdentifier::Id(id), "alpha").unwrap(); 1492 // Manually unbind from `tsk` (simulating "this content lives only 1493 // in another namespace"). 1494 let repo = ws.repo().unwrap(); 1495 namespace::unassign_id(&repo, "tsk", id.0, "test-unbind").unwrap(); 1496 // Now creating with the same content should refuse. 1497 let err = ws 1498 .new_task("file taxes".into(), "".into()) 1499 .expect_err("must error when content lives only in another ns"); 1500 let msg = format!("{err}"); 1501 assert!( 1502 msg.contains("alpha-"), 1503 "error should reference the foreign binding: {msg}" 1504 ); 1505 } 1506 1507 #[test] 1508 fn duplicate_content_unbound_everywhere_binds_in_active_ns() { 1509 let (_d, ws) = fresh_workspace(); 1510 let t = ws.new_task("legacy task".into(), "".into()).unwrap(); 1511 let id = t.id; 1512 let stable = t.stable.clone(); 1513 ws.push_task(t).unwrap(); 1514 // Forcibly unbind everywhere to simulate an orphaned task ref. 1515 let repo = ws.repo().unwrap(); 1516 namespace::unassign_id(&repo, "tsk", id.0, "test-unbind").unwrap(); 1517 // Same content again should re-bind into active ns with a new id. 1518 let again = ws.new_task("legacy task".into(), "".into()).unwrap(); 1519 assert_eq!(again.stable, stable, "stable id must match the orphaned ref"); 1520 assert_ne!( 1521 again.id, id, 1522 "rebinding allocates a fresh human id from `next`" 1523 ); 1524 } 1525 1526 #[test] 1527 fn gc_refs_prunes_all_drift_classes() { 1528 let (_d, ws) = fresh_workspace(); 1529 let repo = ws.repo().unwrap(); 1530 1531 // 1. Empty non-default queue → pruned. 1532 ws.create_queue("empty", None).unwrap(); 1533 1534 // 2. Orphan property index entry pointing at a missing task ref. 1535 let orphan = StableId("0".repeat(40)); 1536 properties::set(&repo, "ghost", &orphan, &["x".into()], "test").unwrap(); 1537 1538 // 3. Ghost namespace binding: assign_id binds before the task ref 1539 // exists (simulating a partial multi-ref write). 1540 namespace::assign_id(&repo, "tsk", orphan.clone(), "ghost-bind").unwrap(); 1541 1542 // 4. Orphan queue index entry pointing at the same missing stable. 1543 queue::push_top(&repo, "tsk", orphan.clone(), "orphan-push").unwrap(); 1544 1545 let (queues, props, ghosts, qe) = ws.gc_refs().unwrap(); 1546 assert_eq!(queues, 1, "empty queue pruned"); 1547 assert_eq!(props, 1, "orphan property entry pruned"); 1548 assert_eq!(ghosts, 1, "ghost namespace binding dropped"); 1549 assert_eq!(qe, 1, "orphan queue index entry dropped"); 1550 assert!(repo.find_reference(&queue::refname("empty")).is_err()); 1551 assert!(repo.find_reference(&properties::refname("ghost")).is_err()); 1552 assert!(namespace::human_for(&repo, "tsk", &orphan).unwrap().is_none()); 1553 1554 // Idempotent: second pass changes nothing. 1555 assert_eq!(ws.gc_refs().unwrap(), (0, 0, 0, 0)); 1556 } 1557 1558 #[test] 1559 fn rot_tor_swap_round_trip() { 1560 let (_d, ws) = fresh_workspace(); 1561 let mut ids = Vec::new(); 1562 for n in 0..3 { 1563 let t = ws.new_task(format!("t{n}"), "".into()).unwrap(); 1564 ids.push(t.id); 1565 ws.push_task(t).unwrap(); 1566 } 1567 ws.swap_top().unwrap(); 1568 let s = ws.read_stack().unwrap(); 1569 assert_eq!( 1570 s.iter().map(|e| e.id).collect::<Vec<_>>(), 1571 vec![ids[1], ids[2], ids[0]] 1572 ); 1573 ws.swap_top().unwrap(); 1574 ws.rot().unwrap(); 1575 ws.tor().unwrap(); 1576 let s = ws.read_stack().unwrap(); 1577 assert_eq!( 1578 s.iter().map(|e| e.id).collect::<Vec<_>>(), 1579 vec![ids[2], ids[1], ids[0]] 1580 ); 1581 } 1582}