A file-based task manager
0
fork

Configure Feed

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

Rewrite storage around per-task commit histories

Each task is now a tree {content, title, <prop>...} with its own commit
history at refs/tsk/tasks/<sha-of-initial-content>. Namespaces are trees
mapping human ids to stable ids at refs/tsk/namespaces/<name>. Queues
hold the index, can-pull marker, and inbox at refs/tsk/queues/<name>.

New commands: share (cross-namespace task binding), queue (list/current/
create/switch), pull (pull a task from another queue when can-pull).
Adds git-tsk binary alongside tsk so the tool can be invoked as a git
subcommand. Drops the file-backed mode; tsk now requires a git repo.

Validation: 48 unit tests across object/namespace/queue/workspace plus
tests/multi_user.rs spinning bare origin + two clones for share, assign,
concurrent push, and namespace round-trip.

Out of scope (left as TODOs): prop, follow, find/fzf, bundle, migrate,
log, reopen, foreign remote, internal link translation between human
and stable ids, merge driver for refs/tsk/queues/* on concurrent push.

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

+1682 -5308
.tsk/git-backed .tsk/git-dir
+1 -1
.tsk/namespace
··· 1 - default 1 + tsk
+1
.tsk/queue
··· 1 + tsk
+4
Cargo.toml
··· 14 14 name = "tsk" 15 15 path = "src/main.rs" 16 16 17 + [[bin]] 18 + name = "git-tsk" 19 + path = "src/main.rs" 20 + 17 21 [dependencies] 18 22 clap = { version = "4", features = ["derive", "env"] } 19 23 clap_complete = "4"
-1002
src/backend.rs
··· 1 - //! Storage backends for tsk workspaces. 2 - //! 3 - //! A [`Store`] is a logical key/value blob store. Two impls are provided: 4 - //! 5 - //! - [`FileStore`] keeps blobs as files under `.tsk/`. Used when `tsk init` runs 6 - //! outside a git repository. 7 - //! - [`GitStore`] stores each blob as a git blob, addressed by a ref under 8 - //! `refs/tsk/`. Used when `tsk init` runs inside a git repository — the git 9 - //! refs are the only durable storage; nothing is cached on disk. 10 - //! 11 - //! Higher-level operations (tasks, attrs, backlinks, index, remotes) are 12 - //! implemented as free functions over `dyn Store` so both backends share a 13 - //! single implementation. 14 - 15 - use crate::errors::{Error, Result}; 16 - use crate::workspace::{Id, Remote}; 17 - use git2::{Reference, Repository}; 18 - use std::collections::{BTreeMap, HashSet}; 19 - use std::fs::{self, OpenOptions}; 20 - use std::io::Write; 21 - use std::path::{Path, PathBuf}; 22 - use std::str::FromStr; 23 - 24 - pub const GIT_BACKED_MARKER: &str = "git-backed"; 25 - pub const NAMESPACE_FILE: &str = "namespace"; 26 - pub const DEFAULT_NAMESPACE: &str = "default"; 27 - const REF_ROOT: &str = "refs/tsk"; 28 - 29 - /// A logical blob store. Keys are forward-slash separated strings. 30 - pub trait Store: Send + Sync { 31 - fn read(&self, key: &str) -> Result<Option<Vec<u8>>>; 32 - fn write(&self, key: &str, data: &[u8]) -> Result<()> { 33 - self.write_with_meta(key, data, "write", None) 34 - } 35 - /// Same as [`write`] but with a structured event/detail attached. For 36 - /// the commit-backed git store this becomes the commit message; for the 37 - /// file store it's discarded. 38 - fn write_with_meta( 39 - &self, 40 - key: &str, 41 - data: &[u8], 42 - event: &str, 43 - detail: Option<&str>, 44 - ) -> Result<()>; 45 - fn delete(&self, key: &str) -> Result<()>; 46 - fn exists(&self, key: &str) -> Result<bool>; 47 - /// List all keys with the given prefix (no trailing slash). Returns full keys. 48 - fn list(&self, prefix: &str) -> Result<Vec<String>>; 49 - } 50 - 51 - /// Format a commit message for a write to `key`. Single function so the layout 52 - /// is easy to change later (subject + structured trailer for now). 53 - pub fn format_commit_message(key: &str, event: &str, detail: Option<&str>) -> String { 54 - let subject = match detail { 55 - Some(d) => format!("tsk({key}): {event} {d}"), 56 - None => format!("tsk({key}): {event}"), 57 - }; 58 - format!( 59 - "{subject}\n\n# tsk-meta\nkey: {key}\nevent: {event}\ndetail: {}\n", 60 - detail.unwrap_or("") 61 - ) 62 - } 63 - 64 - /// `inbox/<src-ns>-<src-id>` blobs are intentionally not commit-backed — they 65 - /// are super transient (deleted on accept) and have no useful history. 66 - fn is_blob_only_key(key: &str) -> bool { 67 - key.starts_with("inbox/") 68 - } 69 - 70 - // ─── FileStore ────────────────────────────────────────────────────────────── 71 - 72 - pub struct FileStore { 73 - pub root: PathBuf, 74 - } 75 - 76 - impl FileStore { 77 - pub fn new(root: PathBuf) -> Self { 78 - Self { root } 79 - } 80 - 81 - fn path(&self, key: &str) -> PathBuf { 82 - self.root.join(key) 83 - } 84 - } 85 - 86 - impl Store for FileStore { 87 - fn read(&self, key: &str) -> Result<Option<Vec<u8>>> { 88 - let p = self.path(key); 89 - match fs::read(&p) { 90 - Ok(data) => Ok(Some(data)), 91 - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), 92 - Err(e) => Err(e.into()), 93 - } 94 - } 95 - 96 - fn write_with_meta( 97 - &self, 98 - key: &str, 99 - data: &[u8], 100 - _event: &str, 101 - _detail: Option<&str>, 102 - ) -> Result<()> { 103 - // Meta is discarded; the file backend has no commit history to attach to. 104 - let p = self.path(key); 105 - if let Some(parent) = p.parent() { 106 - fs::create_dir_all(parent)?; 107 - } 108 - let tmp = p.with_extension("tmp"); 109 - let mut f = OpenOptions::new() 110 - .write(true) 111 - .create(true) 112 - .truncate(true) 113 - .open(&tmp)?; 114 - f.write_all(data)?; 115 - f.sync_all()?; 116 - fs::rename(&tmp, &p)?; 117 - Ok(()) 118 - } 119 - 120 - fn delete(&self, key: &str) -> Result<()> { 121 - match fs::remove_file(self.path(key)) { 122 - Ok(()) => Ok(()), 123 - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), 124 - Err(e) => Err(e.into()), 125 - } 126 - } 127 - 128 - fn exists(&self, key: &str) -> Result<bool> { 129 - Ok(self.path(key).exists()) 130 - } 131 - 132 - fn list(&self, prefix: &str) -> Result<Vec<String>> { 133 - let dir = self.path(prefix); 134 - if !dir.is_dir() { 135 - return Ok(Vec::new()); 136 - } 137 - let mut out = Vec::new(); 138 - for entry in fs::read_dir(&dir)? { 139 - let entry = entry?; 140 - if entry.file_type()?.is_file() 141 - && let Some(name) = entry.file_name().to_str() 142 - { 143 - out.push(format!("{prefix}/{name}")); 144 - } 145 - } 146 - Ok(out) 147 - } 148 - } 149 - 150 - // ─── GitStore ─────────────────────────────────────────────────────────────── 151 - 152 - pub struct GitStore { 153 - git_dir: PathBuf, 154 - namespace: String, 155 - } 156 - 157 - impl GitStore { 158 - pub fn open(git_dir: PathBuf) -> Result<Self> { 159 - Self::open_namespace(git_dir, DEFAULT_NAMESPACE.to_string()) 160 - } 161 - 162 - pub fn open_namespace(git_dir: PathBuf, namespace: String) -> Result<Self> { 163 - Repository::open(&git_dir)?; 164 - Ok(Self { git_dir, namespace }) 165 - } 166 - 167 - fn repo(&self) -> Result<Repository> { 168 - Ok(Repository::open(&self.git_dir)?) 169 - } 170 - 171 - /// Prefix every namespace's refs share, e.g. `refs/tsk/<ns>`. 172 - fn ns_prefix(&self) -> String { 173 - format!("{REF_ROOT}/{}", self.namespace) 174 - } 175 - 176 - fn refname(&self, key: &str) -> String { 177 - format!("{}/{}", self.ns_prefix(), key) 178 - } 179 - 180 - /// Names of every ref starting with the given prefix. 181 - fn refs_starting_with(&self, prefix: &str) -> Result<Vec<String>> { 182 - Ok(self 183 - .repo()? 184 - .references()? 185 - .filter_map(|r| r.ok().and_then(|r| r.name().map(str::to_string))) 186 - .filter(|n| n.starts_with(prefix)) 187 - .collect()) 188 - } 189 - 190 - /// Number of refs currently under this store's namespace. 191 - pub fn namespace_ref_count(&self) -> Result<usize> { 192 - Ok(self 193 - .refs_starting_with(&format!("{}/", self.ns_prefix()))? 194 - .len()) 195 - } 196 - 197 - /// Delete every ref under this store's namespace. Returns the count. 198 - pub fn delete_namespace_refs(&self) -> Result<usize> { 199 - let repo = self.repo()?; 200 - let names = self.refs_starting_with(&format!("{}/", self.ns_prefix()))?; 201 - let count = names.len(); 202 - for n in names { 203 - if let Some(mut r) = try_ref(&repo, &n)? { 204 - r.delete()?; 205 - } 206 - } 207 - Ok(count) 208 - } 209 - 210 - /// List the namespaces present in this repo (any directory under refs/tsk/ 211 - /// containing at least one ref). 212 - pub fn list_namespaces(&self) -> Result<Vec<String>> { 213 - let strip = format!("{REF_ROOT}/"); 214 - let mut out: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); 215 - for name in self.refs_starting_with(&strip)? { 216 - if let Some(rest) = name.strip_prefix(&strip) 217 - && let Some((ns, _)) = rest.split_once('/') 218 - { 219 - out.insert(ns.to_string()); 220 - } 221 - } 222 - Ok(out.into_iter().collect()) 223 - } 224 - } 225 - 226 - /// `find_reference` translating NotFound to None. 227 - fn try_ref<'r>(repo: &'r Repository, name: &str) -> Result<Option<Reference<'r>>> { 228 - match repo.find_reference(name) { 229 - Ok(r) => Ok(Some(r)), 230 - Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None), 231 - Err(e) => Err(e.into()), 232 - } 233 - } 234 - 235 - /// Read the underlying content blob given a ref's target OID. Handles both 236 - /// commit-backed refs (peel commit→tree→content file) and legacy blob refs 237 - /// (return the blob directly), so we keep working through migrations. 238 - pub fn read_blob_at(repo: &Repository, oid: git2::Oid) -> Result<Vec<u8>> { 239 - if let Ok(commit) = repo.find_commit(oid) { 240 - let tree = commit.tree()?; 241 - let entry = tree 242 - .get_name("content") 243 - .ok_or_else(|| Error::Parse("commit tree missing 'content'".into()))?; 244 - let blob = entry.to_object(repo)?.peel_to_blob()?; 245 - return Ok(blob.content().to_vec()); 246 - } 247 - let blob = repo.find_blob(oid)?; 248 - Ok(blob.content().to_vec()) 249 - } 250 - 251 - /// Author signature for tsk-generated commits. Falls back to a tsk-specific 252 - /// signature if the user hasn't configured `user.name` / `user.email`. 253 - fn git_signature(repo: &Repository) -> Result<git2::Signature<'static>> { 254 - if let Ok(s) = repo.signature() { 255 - return Ok(s.to_owned()); 256 - } 257 - Ok(git2::Signature::now("tsk", "tsk@local")?) 258 - } 259 - 260 - /// One-shot migration: convert every blob-backed ref under `refs/tsk/<ns>/...` 261 - /// to a single-commit chain, except for inbox/* (intentionally blob-backed). 262 - /// Idempotent — already-commit refs are skipped. 263 - pub fn migrate_to_commit_history(git_dir: &Path) -> Result<usize> { 264 - let repo = Repository::open(git_dir)?; 265 - let prefix = format!("{REF_ROOT}/"); 266 - let mut converted = 0usize; 267 - let names: Vec<String> = repo 268 - .references()? 269 - .filter_map(|r| r.ok().and_then(|r| r.name().map(str::to_string))) 270 - .filter(|n| n.starts_with(&prefix)) 271 - .collect(); 272 - let sig = git_signature(&repo)?; 273 - for refname in names { 274 - let Some(rest) = refname.strip_prefix(&prefix) else { 275 - continue; 276 - }; 277 - let Some((_ns, key)) = rest.split_once('/') else { 278 - continue; 279 - }; 280 - if is_blob_only_key(key) { 281 - continue; 282 - } 283 - let r = repo.find_reference(&refname)?; 284 - let Some(target) = r.target() else { continue }; 285 - // Already a commit? skip. 286 - if repo.find_commit(target).is_ok() { 287 - continue; 288 - } 289 - // Build a one-file tree wrapping the existing blob and commit it. 290 - let mut tb = repo.treebuilder(None)?; 291 - tb.insert("content", target, 0o100644)?; 292 - let tree_oid = tb.write()?; 293 - let tree = repo.find_tree(tree_oid)?; 294 - let msg = format_commit_message(key, "migrated", None); 295 - let commit_oid = repo.commit(None, &sig, &sig, &msg, &tree, &[])?; 296 - repo.reference(&refname, commit_oid, true, &msg)?; 297 - converted += 1; 298 - } 299 - Ok(converted) 300 - } 301 - 302 - impl Store for GitStore { 303 - fn read(&self, key: &str) -> Result<Option<Vec<u8>>> { 304 - let repo = self.repo()?; 305 - let Some(r) = try_ref(&repo, &self.refname(key))? else { 306 - return Ok(None); 307 - }; 308 - let Some(target) = r.target() else { 309 - return Ok(None); 310 - }; 311 - Ok(Some(read_blob_at(&repo, target)?)) 312 - } 313 - 314 - fn write_with_meta( 315 - &self, 316 - key: &str, 317 - data: &[u8], 318 - event: &str, 319 - detail: Option<&str>, 320 - ) -> Result<()> { 321 - let repo = self.repo()?; 322 - let refname = self.refname(key); 323 - if is_blob_only_key(key) { 324 - let oid = repo.blob(data)?; 325 - repo.reference(&refname, oid, true, "tsk write")?; 326 - return Ok(()); 327 - } 328 - // Build a one-file tree {content: <blob>} and commit it onto the 329 - // existing chain (if any) for this ref. 330 - let blob_oid = repo.blob(data)?; 331 - let mut tb = repo.treebuilder(None)?; 332 - tb.insert("content", blob_oid, 0o100644)?; 333 - let tree_oid = tb.write()?; 334 - let tree = repo.find_tree(tree_oid)?; 335 - let sig = git_signature(&repo)?; 336 - let parent_commit = match try_ref(&repo, &refname)? { 337 - Some(r) => r.target().and_then(|oid| repo.find_commit(oid).ok()), 338 - None => None, 339 - }; 340 - // If the parent's tree already matches, skip — don't pile up no-op commits. 341 - if let Some(parent) = &parent_commit 342 - && parent.tree_id() == tree_oid 343 - { 344 - return Ok(()); 345 - } 346 - let parents: Vec<&git2::Commit> = parent_commit.iter().collect(); 347 - let msg = format_commit_message(key, event, detail); 348 - let commit_oid = repo.commit(None, &sig, &sig, &msg, &tree, &parents)?; 349 - repo.reference(&refname, commit_oid, true, &msg)?; 350 - Ok(()) 351 - } 352 - 353 - fn delete(&self, key: &str) -> Result<()> { 354 - let repo = self.repo()?; 355 - if let Some(mut r) = try_ref(&repo, &self.refname(key))? { 356 - r.delete()?; 357 - } 358 - Ok(()) 359 - } 360 - 361 - fn exists(&self, key: &str) -> Result<bool> { 362 - Ok(try_ref(&self.repo()?, &self.refname(key))?.is_some()) 363 - } 364 - 365 - fn list(&self, prefix: &str) -> Result<Vec<String>> { 366 - let repo = self.repo()?; 367 - let strip = format!("{}/", self.ns_prefix()); 368 - repo.references_glob(&format!("{}/{}/*", self.ns_prefix(), prefix))? 369 - .filter_map(|r| { 370 - r.ok() 371 - .and_then(|r| { 372 - r.name() 373 - .and_then(|n| n.strip_prefix(&strip)) 374 - .map(str::to_string) 375 - }) 376 - .map(Ok) 377 - }) 378 - .collect() 379 - } 380 - } 381 - 382 - // ─── High-level operations over any Store ─────────────────────────────────── 383 - 384 - pub fn next_id(store: &dyn Store) -> Result<Id> { 385 - let cur = store 386 - .read("next")? 387 - .map(|b| String::from_utf8_lossy(&b).trim().to_string()) 388 - .unwrap_or_else(|| "1".to_string()); 389 - let id: u32 = cur.parse().unwrap_or(1); 390 - store.write("next", format!("{}\n", id + 1).as_bytes())?; 391 - Ok(Id(id)) 392 - } 393 - 394 - fn task_key(id: Id, archived: bool) -> String { 395 - let bucket = if archived { "archive" } else { "tasks" }; 396 - format!("{bucket}/{}", id.0) 397 - } 398 - 399 - #[derive(Copy, Clone, Eq, PartialEq, Debug)] 400 - pub enum Loc { 401 - Active, 402 - Archived, 403 - } 404 - 405 - pub fn task_location(store: &dyn Store, id: Id) -> Result<Option<Loc>> { 406 - if store.exists(&task_key(id, false))? { 407 - Ok(Some(Loc::Active)) 408 - } else if store.exists(&task_key(id, true))? { 409 - Ok(Some(Loc::Archived)) 410 - } else { 411 - Ok(None) 412 - } 413 - } 414 - 415 - pub fn read_task(store: &dyn Store, id: Id) -> Result<Option<(String, String, Loc)>> { 416 - for (loc, archived) in [(Loc::Active, false), (Loc::Archived, true)] { 417 - if let Some(data) = store.read(&task_key(id, archived))? { 418 - let text = String::from_utf8_lossy(&data); 419 - let mut parts = text.splitn(2, '\n'); 420 - let title = parts.next().unwrap_or("").trim().to_string(); 421 - let body = parts.next().unwrap_or("").trim().to_string(); 422 - return Ok(Some((title, body, loc))); 423 - } 424 - } 425 - Ok(None) 426 - } 427 - 428 - pub fn write_task( 429 - store: &dyn Store, 430 - id: Id, 431 - title: &str, 432 - body: &str, 433 - loc: Loc, 434 - event: &str, 435 - detail: Option<&str>, 436 - ) -> Result<()> { 437 - let payload = format!("{}\n\n{}", title.trim(), body.trim()); 438 - store.write_with_meta( 439 - &task_key(id, loc == Loc::Archived), 440 - payload.as_bytes(), 441 - event, 442 - detail, 443 - ) 444 - } 445 - 446 - pub fn move_task(store: &dyn Store, id: Id, to: Loc) -> Result<()> { 447 - let from_archived = to == Loc::Active; 448 - let from_key = task_key(id, from_archived); 449 - let to_key = task_key(id, to == Loc::Archived); 450 - if from_key == to_key { 451 - return Ok(()); 452 - } 453 - let data = store 454 - .read(&from_key)? 455 - .ok_or_else(|| Error::Parse(format!("task {id} not present at {from_key}")))?; 456 - store.write(&to_key, &data)?; 457 - store.delete(&from_key)?; 458 - Ok(()) 459 - } 460 - 461 - pub fn list_active(store: &dyn Store) -> Result<Vec<Id>> { 462 - list_bucket(store, "tasks") 463 - } 464 - 465 - pub fn list_archive(store: &dyn Store) -> Result<Vec<Id>> { 466 - list_bucket(store, "archive") 467 - } 468 - 469 - fn list_bucket(store: &dyn Store, bucket: &str) -> Result<Vec<Id>> { 470 - let prefix = format!("{bucket}/"); 471 - let mut ids: Vec<Id> = store 472 - .list(bucket)? 473 - .iter() 474 - .filter_map(|k| { 475 - k.strip_prefix(&prefix)? 476 - .trim_end_matches(".tsk") 477 - .parse() 478 - .ok() 479 - .map(Id) 480 - }) 481 - .collect(); 482 - ids.sort_by_key(|i| i.0); 483 - Ok(ids) 484 - } 485 - 486 - /// Read+lossy-decode a blob, returning empty string when absent. 487 - pub fn read_text_blob(store: &dyn Store, key: &str) -> Result<String> { 488 - read_text(store, key) 489 - } 490 - 491 - fn read_text(store: &dyn Store, key: &str) -> Result<String> { 492 - Ok(store 493 - .read(key)? 494 - .map(|b| String::from_utf8_lossy(&b).into_owned()) 495 - .unwrap_or_default()) 496 - } 497 - 498 - /// Write `body` to `key`, or delete the blob if `body` is empty. 499 - fn write_or_delete(store: &dyn Store, key: &str, body: &str) -> Result<()> { 500 - if body.is_empty() { 501 - store.delete(key) 502 - } else { 503 - store.write(key, body.as_bytes()) 504 - } 505 - } 506 - 507 - pub fn read_attrs(store: &dyn Store, id: Id) -> Result<BTreeMap<String, String>> { 508 - Ok(read_text(store, &format!("attrs/{}", id.0))? 509 - .lines() 510 - .filter_map(|l| { 511 - l.split_once('\t') 512 - .map(|(k, v)| (k.to_string(), v.to_string())) 513 - }) 514 - .collect()) 515 - } 516 - 517 - pub fn write_attrs( 518 - store: &dyn Store, 519 - id: Id, 520 - attrs: &BTreeMap<String, String>, 521 - event: &str, 522 - detail: Option<&str>, 523 - ) -> Result<()> { 524 - let body = attrs 525 - .iter() 526 - .map(|(k, v)| format!("{k}\t{v}\n")) 527 - .collect::<String>(); 528 - let key = format!("attrs/{}", id.0); 529 - if body.is_empty() { 530 - return store.delete(&key); 531 - } 532 - store.write_with_meta(&key, body.as_bytes(), event, detail) 533 - } 534 - 535 - pub fn read_backlinks(store: &dyn Store, id: Id) -> Result<HashSet<Id>> { 536 - Ok(read_text(store, &format!("backlinks/{}", id.0))? 537 - .split(',') 538 - .filter_map(|t| Id::from_str(t.trim()).ok()) 539 - .collect()) 540 - } 541 - 542 - pub fn write_backlinks(store: &dyn Store, id: Id, links: &HashSet<Id>) -> Result<()> { 543 - write_or_delete( 544 - store, 545 - &format!("backlinks/{}", id.0), 546 - &itertools::join(links, ","), 547 - ) 548 - } 549 - 550 - /// One line of a task's edit log. Lines are tab-separated: 551 - /// `<unix-ts>\t<event>\t<detail>\t<author>` — empty fields allowed. 552 - #[derive(Clone, Debug, Eq, PartialEq)] 553 - pub struct LogEntry { 554 - pub id: Id, 555 - pub timestamp: u64, 556 - pub event: String, 557 - pub detail: String, 558 - pub author: String, 559 - } 560 - 561 - impl LogEntry { 562 - fn parse(id: Id, line: &str) -> Option<Self> { 563 - let mut p = line.splitn(4, '\t'); 564 - let timestamp = p.next()?.parse().ok()?; 565 - Some(Self { 566 - id, 567 - timestamp, 568 - event: p.next().unwrap_or("").to_string(), 569 - detail: p.next().unwrap_or("").to_string(), 570 - author: p.next().unwrap_or("").to_string(), 571 - }) 572 - } 573 - } 574 - 575 - /// Append a single log entry for a task. The blob `log/<id>` grows over time; 576 - /// it is never rewritten. 577 - pub fn append_log( 578 - store: &dyn Store, 579 - id: Id, 580 - event: &str, 581 - detail: Option<&str>, 582 - author: &str, 583 - ) -> Result<()> { 584 - let ts = std::time::SystemTime::now() 585 - .duration_since(std::time::UNIX_EPOCH) 586 - .map(|d| d.as_secs()) 587 - .unwrap_or(0); 588 - let line = format!("{ts}\t{event}\t{}\t{author}\n", detail.unwrap_or("")); 589 - let key = format!("log/{}", id.0); 590 - let existing = read_text(store, &key)?; 591 - store.write(&key, format!("{existing}{line}").as_bytes()) 592 - } 593 - 594 - pub fn read_log(store: &dyn Store, id: Id) -> Result<Vec<LogEntry>> { 595 - Ok(read_text(store, &format!("log/{}", id.0))? 596 - .lines() 597 - .filter_map(|l| LogEntry::parse(id, l)) 598 - .collect()) 599 - } 600 - 601 - /// Serialized representation of a task pending in another namespace's inbox. 602 - /// 603 - /// On-blob format (inbox/<inbox-id>): 604 - /// ```text 605 - /// source\t<src-namespace>\t<src-id> 606 - /// attr\t<key>\t<value> 607 - /// attr\t<key>\t<value> 608 - /// --- 609 - /// <title> 610 - /// 611 - /// <body> 612 - /// ``` 613 - pub struct InboxPayload { 614 - pub source_namespace: String, 615 - pub source_id: u32, 616 - pub title: String, 617 - pub body: String, 618 - pub attrs: BTreeMap<String, String>, 619 - } 620 - 621 - impl InboxPayload { 622 - pub fn serialize(&self) -> String { 623 - let mut s = format!("source\t{}\t{}\n", self.source_namespace, self.source_id); 624 - for (k, v) in &self.attrs { 625 - s.push_str(&format!("attr\t{k}\t{v}\n")); 626 - } 627 - s.push_str("---\n"); 628 - s.push_str(&format!("{}\n\n{}", self.title.trim(), self.body.trim())); 629 - s 630 - } 631 - 632 - pub fn parse(text: &str) -> Result<Self> { 633 - let mut lines = text.lines(); 634 - let mut source_namespace = String::new(); 635 - let mut source_id: u32 = 0; 636 - let mut attrs = BTreeMap::new(); 637 - for line in &mut lines { 638 - if line == "---" { 639 - break; 640 - } 641 - let parts: Vec<&str> = line.splitn(3, '\t').collect(); 642 - match parts.as_slice() { 643 - ["source", ns, id] => { 644 - source_namespace = ns.to_string(); 645 - source_id = id 646 - .parse() 647 - .map_err(|_| Error::Parse(format!("invalid inbox source id: {id}")))?; 648 - } 649 - ["attr", k, v] => { 650 - attrs.insert(k.to_string(), v.to_string()); 651 - } 652 - _ => {} 653 - } 654 - } 655 - let rest: String = lines.collect::<Vec<_>>().join("\n"); 656 - let mut split = rest.splitn(2, "\n\n"); 657 - let title = split.next().unwrap_or("").trim().to_string(); 658 - let body = split.next().unwrap_or("").trim().to_string(); 659 - Ok(Self { 660 - source_namespace, 661 - source_id, 662 - title, 663 - body, 664 - attrs, 665 - }) 666 - } 667 - } 668 - 669 - /// Stable inbox key for a (namespace, source-id) pair so re-exports overwrite 670 - /// the same slot rather than piling up. 671 - pub fn inbox_key(src_namespace: &str, src_id: u32) -> String { 672 - format!("inbox/{src_namespace}-{src_id}") 673 - } 674 - 675 - /// Read every per-task log in the workspace and merge into a single feed 676 - /// sorted by timestamp ascending. 677 - pub fn read_all_logs(store: &dyn Store) -> Result<Vec<LogEntry>> { 678 - let mut all = Vec::new(); 679 - for key in store.list("log")? { 680 - if let Some(idstr) = key.strip_prefix("log/") 681 - && let Ok(n) = idstr.parse::<u32>() 682 - { 683 - all.extend(read_log(store, Id(n))?); 684 - } 685 - } 686 - all.sort_by_key(|e| e.timestamp); 687 - Ok(all) 688 - } 689 - 690 - pub fn read_remotes(store: &dyn Store) -> Result<Vec<Remote>> { 691 - Ok(read_text(store, "remotes")? 692 - .lines() 693 - .map(str::trim) 694 - .filter(|l| !l.is_empty() && !l.starts_with('#')) 695 - .filter_map(|l| l.split_once('\t')) 696 - .map(|(p, path)| Remote { 697 - prefix: p.trim().into(), 698 - path: PathBuf::from(path.trim()), 699 - }) 700 - .collect()) 701 - } 702 - 703 - pub fn write_remotes(store: &dyn Store, remotes: &[Remote]) -> Result<()> { 704 - let body: String = remotes 705 - .iter() 706 - .map(|r| format!("{}\t{}\n", r.prefix, r.path.display())) 707 - .collect(); 708 - write_or_delete(store, "remotes", &body) 709 - } 710 - 711 - // ─── Detection / construction ────────────────────────────────────────────── 712 - 713 - pub fn detect_git_dir(start: &Path) -> Option<PathBuf> { 714 - crate::util::find_parent_with_dir(start.to_path_buf(), ".git") 715 - .ok() 716 - .flatten() 717 - } 718 - 719 - pub fn read_namespace(tsk_dir: &Path) -> String { 720 - fs::read_to_string(tsk_dir.join(NAMESPACE_FILE)) 721 - .ok() 722 - .map(|s| s.trim().to_string()) 723 - .filter(|s| !s.is_empty()) 724 - .unwrap_or_else(|| DEFAULT_NAMESPACE.to_string()) 725 - } 726 - 727 - pub fn write_namespace(tsk_dir: &Path, namespace: &str) -> Result<()> { 728 - fs::write(tsk_dir.join(NAMESPACE_FILE), namespace.as_bytes())?; 729 - Ok(()) 730 - } 731 - 732 - pub fn store_for(tsk_dir: &Path) -> Result<Box<dyn Store>> { 733 - let marker = tsk_dir.join(GIT_BACKED_MARKER); 734 - if marker.exists() { 735 - let git_dir = PathBuf::from(fs::read_to_string(&marker)?.trim()); 736 - // First: rename any non-namespaced refs into the default namespace, so 737 - // workspaces created before namespacing keep working seamlessly. 738 - let probe = GitStore::open(git_dir.clone())?; 739 - upgrade_to_namespaced(&probe)?; 740 - let ns = read_namespace(tsk_dir); 741 - let store = GitStore::open_namespace(git_dir, ns)?; 742 - upgrade_legacy_keys(&store)?; 743 - Ok(Box::new(store)) 744 - } else { 745 - Ok(Box::new(FileStore::new(tsk_dir.to_path_buf()))) 746 - } 747 - } 748 - 749 - /// Move any non-namespaced refs (`refs/tsk/<key>`, `refs/tsk/<bucket>/<id>`) 750 - /// into the `default` namespace (`refs/tsk/default/...`). Idempotent. 751 - fn upgrade_to_namespaced(probe: &GitStore) -> Result<()> { 752 - let repo = probe.repo()?; 753 - let strip = format!("{REF_ROOT}/"); 754 - let mut moves: Vec<(String, String)> = Vec::new(); 755 - for r in repo.references_glob(&format!("{REF_ROOT}/*"))? { 756 - let r = r?; 757 - let Some(name) = r.name() else { continue }; 758 - let Some(rest) = name.strip_prefix(&strip) else { 759 - continue; 760 - }; 761 - // Skip already-namespaced refs: first segment is a known top-level key, 762 - // any other first segment is treated as a namespace. 763 - let first = rest.split('/').next().unwrap_or(""); 764 - let is_legacy = matches!( 765 - first, 766 - "tasks" | "archive" | "attrs" | "backlinks" | "index" | "next" | "remotes" 767 - ); 768 - if is_legacy { 769 - moves.push(( 770 - name.to_string(), 771 - format!("{REF_ROOT}/{DEFAULT_NAMESPACE}/{rest}"), 772 - )); 773 - } 774 - } 775 - for (old, new) in moves { 776 - if let Some(r) = try_ref(&repo, &old)? 777 - && let Some(oid) = r.target() 778 - { 779 - repo.reference(&new, oid, true, "tsk namespace upgrade")?; 780 - if let Some(mut r) = try_ref(&repo, &old)? { 781 - r.delete()?; 782 - } 783 - } 784 - } 785 - Ok(()) 786 - } 787 - 788 - /// Rename legacy-scheme refs (`tasks/tsk-N.tsk`) to the current scheme 789 - /// (`tasks/N`). Older versions of the git backend named blobs after the file 790 - /// path used by the file backend; the current scheme uses just the integer id. 791 - /// Runs on every open so stale workspaces self-heal on first use. 792 - fn upgrade_legacy_keys(store: &dyn Store) -> Result<()> { 793 - for bucket in ["tasks", "archive"] { 794 - for key in store.list(bucket)? { 795 - // key looks like "tasks/<name>" — strip prefix to get the leaf. 796 - let leaf = key.split('/').next_back().unwrap_or(""); 797 - // Legacy names look like "tsk-N.tsk". New names are just "N". 798 - if let Some(num) = leaf 799 - .strip_prefix("tsk-") 800 - .and_then(|s| s.strip_suffix(".tsk")) 801 - && num.parse::<u32>().is_ok() 802 - { 803 - let new_key = format!("{bucket}/{num}"); 804 - if store.exists(&new_key)? { 805 - // New-scheme blob already present; just drop the legacy one. 806 - store.delete(&key)?; 807 - continue; 808 - } 809 - if let Some(data) = store.read(&key)? { 810 - store.write(&new_key, &data)?; 811 - store.delete(&key)?; 812 - } 813 - } 814 - } 815 - } 816 - Ok(()) 817 - } 818 - 819 - #[cfg(test)] 820 - mod test { 821 - use super::*; 822 - 823 - fn run_git_init(dir: &Path) { 824 - let s = std::process::Command::new("git") 825 - .args(["init", "-q"]) 826 - .current_dir(dir) 827 - .status() 828 - .unwrap(); 829 - assert!(s.success()); 830 - } 831 - 832 - fn store_pair() -> (tempfile::TempDir, Box<dyn Store>, Box<dyn Store>) { 833 - let dir = tempfile::tempdir().unwrap(); 834 - let file_root = dir.path().join("file"); 835 - let git_root = dir.path().join("git"); 836 - fs::create_dir_all(&file_root).unwrap(); 837 - fs::create_dir_all(&git_root).unwrap(); 838 - run_git_init(&git_root); 839 - let f: Box<dyn Store> = Box::new(FileStore::new(file_root)); 840 - let g: Box<dyn Store> = Box::new(GitStore::open(git_root.join(".git")).unwrap()); 841 - (dir, f, g) 842 - } 843 - 844 - #[test] 845 - fn test_basic_blob_ops_both_backends() { 846 - let (_d, file, git) = store_pair(); 847 - for s in [file, git] { 848 - assert_eq!(s.read("missing").unwrap(), None); 849 - assert!(!s.exists("k").unwrap()); 850 - s.write("k", b"hello").unwrap(); 851 - assert!(s.exists("k").unwrap()); 852 - assert_eq!(s.read("k").unwrap().as_deref(), Some(&b"hello"[..])); 853 - s.write("k", b"world").unwrap(); 854 - assert_eq!(s.read("k").unwrap().as_deref(), Some(&b"world"[..])); 855 - s.delete("k").unwrap(); 856 - assert!(!s.exists("k").unwrap()); 857 - // delete nonexistent is fine 858 - s.delete("k").unwrap(); 859 - } 860 - } 861 - 862 - #[test] 863 - fn test_list_both_backends() { 864 - let (_d, file, git) = store_pair(); 865 - for s in [file, git] { 866 - s.write("tasks/1", b"a").unwrap(); 867 - s.write("tasks/2", b"b").unwrap(); 868 - s.write("archive/3", b"c").unwrap(); 869 - let mut tasks = s.list("tasks").unwrap(); 870 - tasks.sort(); 871 - assert_eq!(tasks, vec!["tasks/1", "tasks/2"]); 872 - let arch = s.list("archive").unwrap(); 873 - assert_eq!(arch, vec!["archive/3"]); 874 - assert!(s.list("nothing").unwrap().is_empty()); 875 - } 876 - } 877 - 878 - #[test] 879 - fn test_high_level_task_ops_both_backends() { 880 - let (_d, file, git) = store_pair(); 881 - for s in [file.as_ref(), git.as_ref()] { 882 - let id = next_id(s).unwrap(); 883 - assert_eq!(id, Id(1)); 884 - let id2 = next_id(s).unwrap(); 885 - assert_eq!(id2, Id(2)); 886 - 887 - write_task(s, id, "title", "body", Loc::Active, "write", None).unwrap(); 888 - let (t, b, loc) = read_task(s, id).unwrap().unwrap(); 889 - assert_eq!(t, "title"); 890 - assert_eq!(b, "body"); 891 - assert_eq!(loc, Loc::Active); 892 - 893 - move_task(s, id, Loc::Archived).unwrap(); 894 - assert_eq!(task_location(s, id).unwrap(), Some(Loc::Archived)); 895 - move_task(s, id, Loc::Active).unwrap(); 896 - assert_eq!(task_location(s, id).unwrap(), Some(Loc::Active)); 897 - 898 - let mut attrs = BTreeMap::new(); 899 - attrs.insert("foo".to_string(), "bar".to_string()); 900 - write_attrs(s, id, &attrs, "write", None).unwrap(); 901 - assert_eq!(read_attrs(s, id).unwrap(), attrs); 902 - 903 - let mut bl = HashSet::new(); 904 - bl.insert(Id(7)); 905 - bl.insert(Id(9)); 906 - write_backlinks(s, id, &bl).unwrap(); 907 - assert_eq!(read_backlinks(s, id).unwrap(), bl); 908 - 909 - // Empty attrs/backlinks delete the blob. 910 - write_attrs(s, id, &BTreeMap::new(), "write", None).unwrap(); 911 - assert!(read_attrs(s, id).unwrap().is_empty()); 912 - write_backlinks(s, id, &HashSet::new()).unwrap(); 913 - assert!(read_backlinks(s, id).unwrap().is_empty()); 914 - } 915 - } 916 - 917 - #[test] 918 - fn test_remotes_round_trip_both_backends() { 919 - let (_d, file, git) = store_pair(); 920 - for s in [file.as_ref(), git.as_ref()] { 921 - assert!(read_remotes(s).unwrap().is_empty()); 922 - let remotes = vec![ 923 - Remote { 924 - prefix: "a".into(), 925 - path: PathBuf::from("/x"), 926 - }, 927 - Remote { 928 - prefix: "b".into(), 929 - path: PathBuf::from("/y"), 930 - }, 931 - ]; 932 - write_remotes(s, &remotes).unwrap(); 933 - assert_eq!(read_remotes(s).unwrap(), remotes); 934 - write_remotes(s, &[]).unwrap(); 935 - assert!(read_remotes(s).unwrap().is_empty()); 936 - } 937 - } 938 - 939 - #[test] 940 - fn test_upgrade_legacy_keys_renames_old_scheme() { 941 - let dir = tempfile::tempdir().unwrap(); 942 - let root = dir.path().join("repo"); 943 - fs::create_dir_all(&root).unwrap(); 944 - run_git_init(&root); 945 - let store = GitStore::open(root.join(".git")).unwrap(); 946 - 947 - // Seed legacy-scheme refs (what older versions of tsk wrote). 948 - store.write("tasks/tsk-1.tsk", b"old\n\nbody").unwrap(); 949 - store.write("archive/tsk-2.tsk", b"old2\n\nbody2").unwrap(); 950 - // And one already-correct new-scheme ref alongside. 951 - store.write("tasks/3", b"new\n\nbody3").unwrap(); 952 - 953 - upgrade_legacy_keys(&store).unwrap(); 954 - 955 - // Legacy keys should be gone, new-scheme keys present. 956 - assert!(!store.exists("tasks/tsk-1.tsk").unwrap()); 957 - assert!(!store.exists("archive/tsk-2.tsk").unwrap()); 958 - assert_eq!( 959 - store.read("tasks/1").unwrap().as_deref(), 960 - Some(&b"old\n\nbody"[..]) 961 - ); 962 - assert_eq!( 963 - store.read("archive/2").unwrap().as_deref(), 964 - Some(&b"old2\n\nbody2"[..]) 965 - ); 966 - assert_eq!( 967 - store.read("tasks/3").unwrap().as_deref(), 968 - Some(&b"new\n\nbody3"[..]) 969 - ); 970 - } 971 - 972 - #[test] 973 - fn test_upgrade_legacy_keys_keeps_new_when_both_present() { 974 - let dir = tempfile::tempdir().unwrap(); 975 - let root = dir.path().join("repo"); 976 - fs::create_dir_all(&root).unwrap(); 977 - run_git_init(&root); 978 - let store = GitStore::open(root.join(".git")).unwrap(); 979 - 980 - store.write("tasks/tsk-1.tsk", b"legacy").unwrap(); 981 - store.write("tasks/1", b"current").unwrap(); 982 - upgrade_legacy_keys(&store).unwrap(); 983 - 984 - assert!(!store.exists("tasks/tsk-1.tsk").unwrap()); 985 - assert_eq!( 986 - store.read("tasks/1").unwrap().as_deref(), 987 - Some(&b"current"[..]) 988 - ); 989 - } 990 - 991 - #[test] 992 - fn test_list_active_archive_helpers() { 993 - let (_d, file, git) = store_pair(); 994 - for s in [file.as_ref(), git.as_ref()] { 995 - write_task(s, Id(1), "t1", "", Loc::Active, "write", None).unwrap(); 996 - write_task(s, Id(2), "t2", "", Loc::Archived, "write", None).unwrap(); 997 - write_task(s, Id(3), "t3", "", Loc::Active, "write", None).unwrap(); 998 - assert_eq!(list_active(s).unwrap(), vec![Id(1), Id(3)]); 999 - assert_eq!(list_archive(s).unwrap(), vec![Id(2)]); 1000 - } 1001 - } 1002 - }
+221 -1000
src/main.rs
··· 1 - mod backend; 2 1 mod errors; 3 2 mod fzf; 4 - mod stack; 3 + mod namespace; 4 + mod object; 5 + mod queue; 5 6 mod task; 6 7 mod util; 7 8 mod workspace; 9 + 10 + use clap::{Args, CommandFactory, Parser, Subcommand}; 8 11 use clap_complete::{Shell, generate}; 12 + use edit::edit as open_editor; 9 13 use errors::Result; 10 - use std::io::{self, Write}; 14 + use std::env::current_dir; 15 + use std::io::{self, Read, Write}; 11 16 use std::path::PathBuf; 12 17 use std::process::exit; 13 18 use std::str::FromStr as _; 14 - use std::{env::current_dir, fs::OpenOptions, io::Read}; 15 - use task::ParsedLink; 16 19 use workspace::{Id, Task, TaskIdentifier, Workspace}; 17 20 18 - //use smol; 19 - //use iocraft::prelude::*; 20 - use clap::{Args, CommandFactory, Parser, Subcommand}; 21 - use edit::edit as open_editor; 22 - 23 21 fn default_dir() -> Result<PathBuf> { 24 22 Ok(current_dir()?) 25 23 } 26 24 27 - const NEW_SENTINEL: &str = "<new>"; 28 - 29 - /// Resolve the remote to use for an auto-sync command. If the user supplied 30 - /// `Some("")`, returns None (explicit skip). If `Some(name)`, returns that 31 - /// name. If `None`, returns "origin" if that remote is configured in git; 32 - /// otherwise None (so file-backed workspaces and clones without a configured 33 - /// origin fall through silently). 34 - fn effective_remote(ws: &Workspace, supplied: Option<String>) -> Result<Option<String>> { 35 - if let Some(s) = supplied { 36 - if s.is_empty() { 37 - return Ok(None); 38 - } 39 - return Ok(Some(s)); 40 - } 41 - if !ws.is_git_backed() { 42 - return Ok(None); 43 - } 44 - let marker = std::fs::read_to_string(ws.path.join(backend::GIT_BACKED_MARKER))?; 45 - let repo = git2::Repository::open(PathBuf::from(marker.trim()))?; 46 - if repo.find_remote("origin").is_ok() { 47 - Ok(Some("origin".to_string())) 48 - } else { 49 - Ok(None) 50 - } 51 - } 52 - 53 - /// `[[tsk-N]]` → Some(Id(N)). Anything else (including foreign links) → None. 54 - fn parse_internal_link_for_cli(s: &str) -> Option<Id> { 55 - let inner = s.trim().strip_prefix("[[")?.strip_suffix("]]")?; 56 - Id::from_str(inner).ok() 57 - } 58 - 59 - fn prompt_line(prompt: &str) -> Result<String> { 60 - use std::io::Write as _; 61 - eprint!("{prompt}"); 62 - io::stderr().flush()?; 63 - let mut s = String::new(); 64 - io::stdin().read_line(&mut s)?; 65 - Ok(s.trim_end_matches(['\n', '\r']).to_string()) 66 - } 67 - 68 25 fn parse_id(s: &str) -> std::result::Result<Id, &'static str> { 69 26 Id::from_str(s).map_err(|_| "Unable to parse tsk- ID") 70 27 } 71 28 72 29 #[derive(Parser)] 73 - // TODO: add long_about 74 30 #[command(version, about)] 75 31 struct Cli { 76 32 /// Override the tsk root directory. 77 33 #[arg(short = 'C', env = "TSK_ROOT", value_name = "DIR")] 78 34 dir: Option<PathBuf>, 79 - // TODO: other global options 80 35 #[command(subcommand)] 81 36 command: Commands, 82 37 } 83 38 84 39 #[derive(Subcommand)] 85 40 enum Commands { 86 - /// Initializes a .tsk workspace in the current effective directory, which defaults to PWD. 41 + /// Initialize a `.tsk/` marker in the current git repo. (Auto-created on first use.) 87 42 Init, 88 - /// Creates a new task, automatically assigning it a unique identifider and persisting 43 + /// Create a new task and push it onto the active queue. 89 44 Push { 90 - /// Whether to open $EDITOR to edit the content of the task. The first line if the 91 - /// resulting file will be the task's title. The body follows the title after two newlines, 92 - /// similr to the format of a commit message. 93 45 #[arg(short = 'e', default_value_t = false)] 94 46 edit: bool, 95 - 96 - /// The body of the task. It may be specified as either a string using quotes or the 97 - /// special character '-' to read from stdin. 98 47 #[arg(short = 'b')] 99 48 body: Option<String>, 100 - 101 - /// The title of the task as a raw string. It mus be proceeded by two dashes (--). 102 49 #[command(flatten)] 103 50 title: Title, 104 51 }, 105 - /// Creates a new task just like `push`, but instead of putting it at the top of the stack, it 106 - /// puts it at the bottom 52 + /// Create a new task and append it to the bottom of the active queue. 107 53 Append { 108 - /// Whether to open $EDITOR to edit the content of the task. The first line if the 109 - /// resulting file will be the task's title. The body follows the title after two newlines, 110 - /// similr to the format of a commit message. 111 54 #[arg(short = 'e', default_value_t = false)] 112 55 edit: bool, 113 - 114 - /// The body of the task. It may be specified as either a string using quotes or the 115 - /// special character '-' to read from stdin. 116 56 #[arg(short = 'b')] 117 57 body: Option<String>, 118 - 119 - /// The title of the task as a raw string. It mus be proceeded by two dashes (--). 120 58 #[command(flatten)] 121 59 title: Title, 122 60 }, 123 - /// Print the task stack. This will include just TSK-IDs and the title. 61 + /// Print the active queue's stack (top-of-stack first). 124 62 List { 125 - /// Whether to list all tasks in the task stack. If specified, -c / count is ignored. 126 63 #[arg(short = 'a', default_value_t = false)] 127 64 all: bool, 128 65 #[arg(short = 'c', default_value_t = 10)] 129 66 count: usize, 130 - /// Only print task IDs, one per line. 131 67 #[arg(short = 'q', default_value_t = false)] 132 68 ids_only: bool, 133 69 }, 134 - 135 - /// Swaps the top two tasks on the stack. If there are less than 2 tasks on the stack, there is 136 - /// no effect. 137 - Swap, 138 - 139 - /// Open up an editor to modify the task with the given ID. 140 - Edit { 141 - #[command(flatten)] 142 - task_id: TaskId, 143 - }, 144 - 145 - /// Generates completion for a given shell. 146 - Completion { 147 - #[arg(short = 's')] 148 - shell: Shell, 149 - }, 150 - 151 - /// Use fuzzy finding with `fzf` to search for a task 152 - Find { 153 - #[command(flatten)] 154 - args: FindArgs, 155 - /// Whether to print the a shortened tsk ID (just the integer portion). Defaults to *false* 156 - #[arg(short = 'f', default_value_t = false)] 157 - short_id: bool, 158 - }, 159 - 160 - /// Prints the contents of a task, parsing the body as rich text and formatting it using ANSI 161 - /// escape sequences. 70 + /// Show a task by id. 162 71 Show { 163 - /// Shows raw file attributes for the file 164 72 #[arg(short = 'x', default_value_t = false)] 165 73 show_attrs: bool, 166 - 167 - #[arg(short = 'R', default_value_t = false)] 168 - raw: bool, 169 - /// The [TSK-]ID of the task to display 170 74 #[command(flatten)] 171 75 task_id: TaskId, 172 76 }, 173 - 174 - /// List or follow a link parsed from a task's body. Without -l or -s, 175 - /// prints the numbered list and exits. With -l N, opens link N; URLs go 176 - /// to the system handler, [[tsk-N]] internal links are shown, foreign 177 - /// refs resolve through the configured remote. With -s, pipes the list 178 - /// through fzf and opens the picked one. 179 - Follow { 180 - /// The task whose body will be searched for links. 77 + /// Open `$EDITOR` to modify a task. 78 + Edit { 181 79 #[command(flatten)] 182 80 task_id: TaskId, 183 - /// The index of the link to open. Omit (along with -s) to just list. 184 - #[arg(short = 'l')] 185 - link_index: Option<usize>, 186 - /// fzf-pick a link to open instead of supplying -l. 187 - #[arg(short = 's', default_value_t = false)] 188 - select: bool, 189 - /// When opening an internal link, edit the addressed task instead of showing. 190 - #[arg(short = 'e', default_value_t = false)] 191 - edit: bool, 192 81 }, 193 - 194 - /// Drops the task on the top of the stack and archives it. 82 + /// Drop a task (remove from queue + unbind human id, history retained). 195 83 Drop { 196 - /// The [TSK-]ID of the task to drop. 197 84 #[command(flatten)] 198 85 task_id: TaskId, 199 86 }, 200 - 201 - /// Moves the 3rd item on the stack to the front of the stack, shifting everything else down by 202 - /// one. If there are less than 3 tasks on the stack, has no effect. 87 + /// Swap the top two tasks. 88 + Swap, 89 + /// Rotate top 3: third → top. 203 90 Rot, 204 - /// Moves the task on the top of the stack back behind the 2nd element, shifting the next two 205 - /// task up. 91 + /// Reverse-rotate top 3: top → third. 206 92 Tor, 207 - 208 - /// Prioritizes an arbitrary task to the top of the stack. 93 + /// Move a task to the top of the stack. 209 94 Prioritize { 210 - /// The [TSK-]ID to prioritize. If it exists, it is moved to the top of the stack. 211 95 #[command(flatten)] 212 96 task_id: TaskId, 213 97 }, 214 - 215 - /// Deprioritizes a task to the bottom of the stack. 98 + /// Move a task to the bottom of the stack. 216 99 Deprioritize { 217 - /// The [TSK-]ID to deprioritize. If it exists, it is moved to the bottom of the stack. 218 100 #[command(flatten)] 219 101 task_id: TaskId, 220 102 }, 221 - 222 - /// Cleans up orphaned task files in .tsk/tasks/ that are no longer in the stack index. 103 + /// Drop index entries whose stable ids no longer resolve. 223 104 Clean, 224 - 225 - /// Manage remote workspace mappings for cross-workspace task linking. 226 - Remote { 227 - #[command(subcommand)] 228 - action: RemoteAction, 229 - }, 230 - 231 - /// Sets up git integration by adding .tsk/ to .git/info/exclude or .gitignore. 105 + /// Print refspec/setup hints for `git push`/`git fetch` to include `refs/tsk/*`. 232 106 GitSetup { 233 - /// Use .gitignore instead of .git/info/exclude. 234 - #[arg(short = 'g', default_value_t = false)] 235 - gitignore: bool, 236 - /// Also configure push/fetch refspecs on the named remote so refs/tsk/* 237 - /// is included in `git push <remote>` and `git fetch <remote>`. 107 + /// Configure push/fetch refspecs on the named remote (default: origin). 238 108 #[arg(short = 'r')] 239 109 remote: Option<String>, 240 110 }, 241 - 242 - /// Push refs/tsk/* to a git remote so other clones can pull task state. 243 - /// Defaults to "origin" when configured. 111 + /// Push tsk refs to a git remote (default: origin). 244 112 GitPush { 245 - /// Remote name. Defaults to "origin". 246 113 remote: Option<String>, 247 114 }, 248 - 249 - /// Fetch refs/tsk/* from a git remote, overwriting local task state. 250 - /// Defaults to "origin" when configured. 115 + /// Fetch tsk refs from a git remote (default: origin). 251 116 GitPull { 252 - /// Remote name. Defaults to "origin". 253 117 remote: Option<String>, 254 118 }, 255 - 256 - /// Assign a task to another namespace by sending it to that namespace's 257 - /// inbox. Defaults to the top-of-stack task; use -T to pick a different 258 - /// one. Sets `assigned=[[<ns>/tsk-N]]` on the source. Auto-pushes refs 259 - /// to "origin" when configured; pass -r NAME to use a different remote 260 - /// or -r "" to skip the push. 119 + /// Share a task into another namespace (binds same stable id under that namespace's next human id). 120 + Share { 121 + target: String, 122 + #[command(flatten)] 123 + task_id: TaskId, 124 + }, 125 + /// Move a task from the active queue's index into another queue's inbox. 261 126 Assign { 262 - /// Target namespace. 263 127 target: String, 264 128 #[command(flatten)] 265 129 task_id: TaskId, 266 - #[arg(short = 'r')] 130 + /// Auto-push refs to this remote after assigning. Empty string skips. Default: origin. 131 + #[arg(short = 'R')] 267 132 remote: Option<String>, 268 133 }, 269 - 270 - /// List tasks pending in the current namespace's inbox. Pulls from a 271 - /// remote first so the listing reflects what others have sent. Defaults 272 - /// to "origin" if that remote exists; pass -r "" to skip the pull. 134 + /// Pull a task from another queue's index (only allowed if its can-pull is true). 135 + Pull { 136 + source: String, 137 + #[command(flatten)] 138 + task_id: TaskId, 139 + }, 140 + /// List inbox items pending in the active queue. 273 141 Inbox { 274 - #[arg(short = 'r')] 142 + /// Auto-pull from this remote first. Empty string skips. Default: origin. 143 + #[arg(short = 'R')] 275 144 remote: Option<String>, 276 145 }, 277 - 278 - /// Accept a pending inbox item, creating a new local task with copied 279 - /// content + properties and `source=[[<src-ns>/tsk-N]]` set. 280 - Accept { 281 - /// Inbox key (e.g. `alice-3` or `inbox/alice-3`). With no argument, 282 - /// accepts the first item in the inbox. 283 - key: Option<String>, 284 - }, 285 - 286 - /// Reject a pending inbox item, removing it without creating a local task. 287 - /// Writes a `rejected` event to the source's event log so the assignor 288 - /// sees it. Auto-pushes refs to "origin" when configured; pass -r NAME 289 - /// to use a different remote or -r "" to skip the push. 146 + /// Accept an inbox item by key (no key = first item). 147 + Accept { key: Option<String> }, 148 + /// Reject an inbox item by key (no key = first item). 290 149 Reject { 291 - /// Inbox key (e.g. `alice-3` or `inbox/alice-3`). With no argument, 292 - /// rejects the first item in the inbox. 293 150 key: Option<String>, 294 - #[arg(short = 'r')] 151 + /// Auto-push refs to this remote after rejecting. Empty string skips. Default: origin. 152 + #[arg(short = 'R')] 295 153 remote: Option<String>, 296 154 }, 297 - 298 - /// Bundle the entire workspace into a zip archive. 299 - Bundle { 300 - /// Output path. Defaults to ./tsk.zip. 301 - #[arg(short = 'o')] 302 - output: Option<PathBuf>, 303 - }, 304 - 305 - /// Migrate a file-backed workspace to a git-backed one. The directory must 306 - /// now be inside a git repository (run `git init` first if needed). All 307 - /// task data is copied into refs/tsk/* and the on-disk files are removed. 308 - Migrate, 309 - 310 - /// Convert blob-backed refs/tsk/<ns>/* refs to commit-backed history 311 - /// (one commit per past mutation; future writes append commits). Inbox 312 - /// blobs are intentionally left blob-backed. 313 - MigrateHistory, 314 - 315 - /// Print the event log. Without -T, prints every event in the current 316 - /// namespace, newest first, in git-log style. With -T, scopes to one task. 317 - Log { 318 - /// Optionally scope to a single task by tsk-ID. 319 - #[arg(short = 'T', value_name = "TSK-ID", value_parser = parse_id)] 320 - tsk_id: Option<Id>, 321 - }, 322 - 323 - /// Get/set/find tasks by property. Properties are arbitrary key/value 324 - /// pairs stored alongside a task; some are synthetic (state, has-links, 325 - /// references, referenced-by) and computed on read. 326 - Prop { 327 - #[command(subcommand)] 328 - action: PropAction, 329 - }, 330 - 331 - /// Manage namespaces within a git-backed workspace. Namespaces let multiple 332 - /// people share the same git repo without sharing tasks; refs live under 333 - /// refs/tsk/<namespace>/. 155 + /// Manage namespaces. 334 156 Namespace { 335 157 #[command(subcommand)] 336 158 action: NamespaceAction, 337 159 }, 338 - 339 - /// Switch to a different namespace. Shorthand for `tsk namespace switch`. 340 - /// With no name, fzf-picks from existing namespaces (plus a `<new>` 341 - /// sentinel for creating one on the fly). 342 - Switch { name: Option<String> }, 343 - 344 - /// Reopens an archived task, recreating the symlink and adding it back to the stack. 345 - Reopen { 346 - #[command(flatten)] 347 - task_id: TaskId, 160 + /// Manage queues. 161 + Queue { 162 + #[command(subcommand)] 163 + action: QueueAction, 348 164 }, 349 - } 350 - 351 - #[derive(Subcommand)] 352 - enum PropAction { 353 - /// List all properties on a task (stored + synthetic). 354 - List { 355 - #[command(flatten)] 356 - task_id: TaskId, 357 - }, 358 - /// Set a property. With both KEY and VALUE supplied, sets directly. 359 - /// With KEY but no VALUE, fzf-picks a value from existing values for 360 - /// that key (and, with -l, also from links/URLs in the task body). 361 - /// With neither, fzf-picks the key first, then the value. The fzf list 362 - /// always includes a `<new>` sentinel for entering a fresh string. 363 - Set { 364 - #[command(flatten)] 365 - task_id: TaskId, 366 - /// Property name. If omitted, the user is prompted via fzf. 367 - key: Option<String>, 368 - /// New value. If omitted, the user is prompted via fzf. 369 - value: Option<String>, 370 - /// Also include links/URLs parsed from the task body as value 371 - /// candidates. 372 - #[arg(short = 'l', default_value_t = false)] 373 - from_body: bool, 374 - }, 375 - /// Remove a property from a task. No-op if not set. 376 - Unset { 377 - #[command(flatten)] 378 - task_id: TaskId, 379 - key: String, 380 - }, 381 - /// Find every task whose property KEY equals VALUE. With VALUE omitted, 382 - /// matches any task that has KEY set. With both omitted, fzf-picks the 383 - /// key first, then the value (with `<any>` to skip value-narrowing). 384 - Find { 385 - key: Option<String>, 386 - value: Option<String>, 165 + /// Switch active namespace (shorthand). 166 + Switch { name: String }, 167 + /// Generate shell completion. 168 + Completion { 169 + #[arg(short = 's')] 170 + shell: Shell, 387 171 }, 388 172 } 389 173 390 174 #[derive(Subcommand)] 391 175 enum NamespaceAction { 392 - /// List all namespaces with refs in this repo. 393 176 List, 394 - /// Print the current namespace name. 395 177 Current, 396 - /// Switch to (create on first push of) the given namespace. With no 397 - /// name, fzf-picks from existing namespaces. 398 - Switch { name: Option<String> }, 399 - /// Create an empty namespace and switch to it. 400 - Create { name: String }, 401 - /// Delete every ref under the given namespace. Refuses if the namespace is 402 - /// the active one. Prompts for confirmation when it has tasks unless -y. 403 - Delete { 404 - name: String, 405 - /// Skip the confirmation prompt. 406 - #[arg(short = 'y', default_value_t = false)] 407 - yes: bool, 408 - }, 178 + Switch { name: String }, 409 179 } 410 180 411 181 #[derive(Subcommand)] 412 - enum RemoteAction { 413 - /// List configured remote workspaces. 182 + enum QueueAction { 414 183 List, 415 - /// Add a remote workspace mapping. 416 - Add { 417 - /// The prefix to use for this remote (e.g. "jira", "gl"). 418 - prefix: String, 419 - /// The path to the remote workspace. 420 - path: String, 421 - }, 422 - /// Remove a remote workspace mapping. 423 - Remove { 424 - /// The prefix of the remote to remove. 425 - prefix: String, 184 + Current, 185 + /// Create a new queue. By default `can-pull=false`; use `-p` to make it true. 186 + Create { 187 + name: String, 188 + #[arg(short = 'p', default_value_t = false)] 189 + can_pull: bool, 426 190 }, 191 + Switch { name: String }, 427 192 } 428 193 429 194 #[derive(Args)] 430 195 #[group(required = true, multiple = false)] 431 196 struct Title { 432 - /// The title of the task. This is useful for when you also wish to specify the body of the 433 - /// task as an argument (ie. with -b). 434 197 #[arg(short, value_name = "TITLE")] 435 198 title: Option<String>, 436 - 437 199 #[arg(value_name = "TITLE")] 438 200 title_simple: Option<Vec<String>>, 439 201 } ··· 441 203 #[derive(Args)] 442 204 #[group(required = false, multiple = false)] 443 205 struct TaskId { 444 - /// The ID of the task to select as a plain integer. 445 206 #[arg(short = 't', value_name = "ID")] 446 207 id: Option<u32>, 447 - 448 - /// The ID of the task to select with the 'tsk-' prefix. 449 208 #[arg(short = 'T', value_name = "TSK-ID", value_parser = parse_id)] 450 209 tsk_id: Option<Id>, 451 - 452 - /// Selects a task relative to the top of the stack. 453 - /// If no option is specified, the task selected will be the top of the stack. 454 210 #[arg(short = 'r', value_name = "RELATIVE", default_value_t = 0)] 455 211 relative_id: u32, 456 - 457 - #[command(flatten)] 458 - find: Find, 459 - } 460 - 461 - /// Use fuzzy finding to search for and select a task. 462 - /// Does not support searching task bodies or archived tasks. 463 - #[derive(Args)] 464 - #[group(required = false, multiple = true)] 465 - struct Find { 466 - /// Use fuzzy finding to select a task. 467 - #[arg(short = 'f', value_name = "FIND", default_value_t = false)] 468 - find: bool, 469 - #[command(flatten)] 470 - args: FindArgs, 471 - } 472 - 473 - #[derive(Args)] 474 - #[group(required = false, multiple = false)] 475 - struct FindArgs { 476 - /// Exclude the contents of tasks in the search criteria. 477 - #[arg(short = 'b', default_value_t = false)] 478 - exclude_body: bool, 479 - /// Include archived tasks in the search criteria. Combine with `-b` to include archived 480 - /// bodies in the search criteria. 481 - #[arg(short = 'a', default_value_t = false)] 482 - search_archived: bool, 483 212 } 484 213 485 214 impl From<TaskId> for TaskIdentifier { 486 - fn from(value: TaskId) -> Self { 487 - if let Some(id) = value.id.map(Id::from).or(value.tsk_id) { 215 + fn from(v: TaskId) -> Self { 216 + if let Some(id) = v.id.map(Id::from).or(v.tsk_id) { 488 217 TaskIdentifier::Id(id) 489 - } else if value.find.find { 490 - TaskIdentifier::Find { 491 - exclude_body: value.find.args.exclude_body, 492 - archived: value.find.args.search_archived, 493 - } 494 218 } else { 495 - TaskIdentifier::Relative(value.relative_id) 219 + TaskIdentifier::Relative(v.relative_id) 496 220 } 497 221 } 498 222 } 499 223 224 + fn effective_remote(supplied: Option<String>) -> Option<String> { 225 + supplied 226 + .map(|s| if s.is_empty() { None } else { Some(s) }) 227 + .unwrap_or_else(|| Some("origin".to_string())) 228 + } 229 + 500 230 fn run(cli: Cli) -> Result<()> { 501 231 let dir = match cli.dir { 502 232 Some(d) => d, 503 233 None => default_dir()?, 504 234 }; 505 235 match cli.command { 506 - Commands::Init => command_init(dir), 507 - Commands::Push { edit, body, title } => command_push(dir, edit, body, title), 508 - Commands::Append { edit, body, title } => command_append(dir, edit, body, title), 236 + Commands::Init => Workspace::init(dir), 237 + Commands::Push { edit, body, title } => command_push(dir, edit, body, title, true), 238 + Commands::Append { edit, body, title } => command_push(dir, edit, body, title, false), 509 239 Commands::List { 510 240 all, 511 241 count, 512 242 ids_only, 513 243 } => command_list(dir, all, count, ids_only), 514 - Commands::Swap => command_swap(dir), 515 244 Commands::Show { 516 245 task_id, 517 - raw, 518 246 show_attrs, 519 - } => command_show(dir, task_id, show_attrs, raw), 520 - Commands::Follow { 521 - task_id, 522 - link_index, 523 - select, 524 - edit, 525 - } => command_follow(dir, task_id, link_index, select, edit), 247 + } => command_show(dir, task_id, show_attrs), 526 248 Commands::Edit { task_id } => command_edit(dir, task_id), 527 - Commands::Completion { shell } => command_completion(shell), 528 249 Commands::Drop { task_id } => command_drop(dir, task_id), 529 - Commands::Find { args, short_id } => command_find(dir, short_id, args), 250 + Commands::Swap => Workspace::from_path(dir)?.swap_top(), 530 251 Commands::Rot => Workspace::from_path(dir)?.rot(), 531 252 Commands::Tor => Workspace::from_path(dir)?.tor(), 532 - Commands::Prioritize { task_id } => command_prioritize(dir, task_id), 533 - Commands::Deprioritize { task_id } => command_deprioritize(dir, task_id), 534 - Commands::Clean => command_clean(dir), 535 - Commands::Remote { action } => command_remote(dir, action), 536 - Commands::GitSetup { gitignore, remote } => command_git_setup(dir, gitignore, remote), 537 - Commands::GitPush { remote } => command_git_push(dir, remote), 538 - Commands::GitPull { remote } => command_git_pull(dir, remote), 253 + Commands::Prioritize { task_id } => { 254 + Workspace::from_path(dir)?.prioritize(task_id.into()) 255 + } 256 + Commands::Deprioritize { task_id } => { 257 + Workspace::from_path(dir)?.deprioritize(task_id.into()) 258 + } 259 + Commands::Clean => Workspace::from_path(dir)?.clean(), 260 + Commands::GitSetup { remote } => { 261 + let r = remote.unwrap_or_else(|| "origin".to_string()); 262 + Workspace::from_path(dir)?.configure_git_remote_refspecs(&r) 263 + } 264 + Commands::GitPush { remote } => { 265 + let r = remote.unwrap_or_else(|| "origin".to_string()); 266 + Workspace::from_path(dir)?.git_push(&r) 267 + } 268 + Commands::GitPull { remote } => { 269 + let r = remote.unwrap_or_else(|| "origin".to_string()); 270 + Workspace::from_path(dir)?.git_pull(&r) 271 + } 272 + Commands::Share { target, task_id } => command_share(dir, target, task_id), 539 273 Commands::Assign { 540 274 target, 541 275 task_id, 542 276 remote, 543 277 } => command_assign(dir, target, task_id, remote), 278 + Commands::Pull { source, task_id } => command_pull(dir, source, task_id), 544 279 Commands::Inbox { remote } => command_inbox(dir, remote), 545 280 Commands::Accept { key } => command_accept(dir, key), 546 281 Commands::Reject { key, remote } => command_reject(dir, key, remote), 547 - Commands::Bundle { output } => command_bundle(dir, output), 548 - Commands::Migrate => command_migrate(dir), 549 - Commands::MigrateHistory => command_migrate_history(dir), 550 - Commands::Reopen { task_id } => command_reopen(dir, task_id), 551 - Commands::Log { tsk_id } => command_log(dir, tsk_id), 552 - Commands::Prop { action } => command_prop(dir, action), 553 282 Commands::Namespace { action } => command_namespace(dir, action), 554 - Commands::Switch { name } => command_namespace_switch(dir, name), 283 + Commands::Queue { action } => command_queue(dir, action), 284 + Commands::Switch { name } => Workspace::from_path(dir)?.switch_namespace(&name), 285 + Commands::Completion { shell } => { 286 + generate(shell, &mut Cli::command(), "tsk", &mut io::stdout()); 287 + Ok(()) 288 + } 555 289 } 556 290 } 557 291 ··· 565 299 } 566 300 } 567 301 568 - fn taskid_from_tsk_id(tsk_id: Id) -> TaskId { 569 - TaskId { 570 - tsk_id: Some(tsk_id), 571 - id: None, 572 - relative_id: 0, 573 - find: Find { 574 - find: false, 575 - args: FindArgs { 576 - exclude_body: true, 577 - search_archived: false, 578 - }, 579 - }, 580 - } 581 - } 582 - 583 - fn command_init(dir: PathBuf) -> Result<()> { 584 - Workspace::init(dir) 585 - } 586 - 587 - fn create_task( 588 - workspace: &mut Workspace, 302 + fn read_title_and_body( 589 303 edit: bool, 590 304 body: Option<String>, 591 - title: Title, 592 - ) -> Result<Task> { 593 - let mut title = if let Some(title) = title.title { 594 - title 595 - } else if let Some(title) = title.title_simple { 596 - title.join(" ") 305 + title_arg: Title, 306 + ) -> Result<(String, String)> { 307 + let mut title = if let Some(t) = title_arg.title { 308 + t 309 + } else if let Some(ts) = title_arg.title_simple { 310 + ts.join(" ") 597 311 } else { 598 - "".to_string() 312 + String::new() 599 313 }; 600 - // If no body was explicitly provided and the title contains newlines, 601 - // treat the first line as the title and the rest as the body (like git commit -m) 602 314 let mut body = if body.is_none() { 603 - if let Some((first_line, rest)) = title.split_once('\n') { 604 - let extracted_body = rest.to_string(); 605 - title = first_line.to_string(); 606 - extracted_body 315 + if let Some((first, rest)) = title.split_once('\n') { 316 + let extracted = rest.to_string(); 317 + title = first.to_string(); 318 + extracted 607 319 } else { 608 320 String::new() 609 321 } 610 322 } else { 611 - // Body was explicitly provided, so strip any newlines from title 612 323 title = title.replace(['\n', '\r'], " "); 613 324 body.unwrap_or_default() 614 325 }; 615 326 if body == "-" { 616 - // add newline so you can type directly in the shell 617 - //eprintln!(""); 618 327 body.clear(); 619 - std::io::stdin().read_to_string(&mut body)?; 328 + io::stdin().read_to_string(&mut body)?; 620 329 } 621 330 if edit { 622 331 let new_content = open_editor(format!("{title}\n\n{body}"))?; 623 - if let Some(content) = new_content.split_once("\n") { 624 - title = content.0.to_string(); 625 - body = content.1.to_string(); 332 + if let Some((t, b)) = new_content.split_once('\n') { 333 + title = t.to_string(); 334 + body = b.trim_start_matches('\n').to_string(); 626 335 } 627 336 } 628 - // Ensure title never contains newlines (invariant for index file format) 629 337 title = title.replace(['\n', '\r'], " "); 630 - let task = workspace.new_task(title, body)?; 631 - workspace.handle_metadata(&task, None)?; 632 - Ok(task) 633 - } 634 - 635 - fn command_push(dir: PathBuf, edit: bool, body: Option<String>, title: Title) -> Result<()> { 636 - let mut workspace = Workspace::from_path(dir)?; 637 - let task = create_task(&mut workspace, edit, body, title)?; 638 - workspace.push_task(task) 338 + Ok((title, body)) 639 339 } 640 340 641 - fn command_append(dir: PathBuf, edit: bool, body: Option<String>, title: Title) -> Result<()> { 642 - let mut workspace = Workspace::from_path(dir)?; 643 - let task = create_task(&mut workspace, edit, body, title)?; 644 - workspace.append_task(task) 341 + fn command_push( 342 + dir: PathBuf, 343 + edit: bool, 344 + body: Option<String>, 345 + title: Title, 346 + on_top: bool, 347 + ) -> Result<()> { 348 + let (title, body) = read_title_and_body(edit, body, title)?; 349 + let ws = Workspace::from_path(dir)?; 350 + let task = ws.new_task(title, body)?; 351 + if on_top { 352 + ws.push_task(task) 353 + } else { 354 + ws.append_task(task) 355 + } 645 356 } 646 357 647 358 fn command_list(dir: PathBuf, all: bool, count: usize, ids_only: bool) -> Result<()> { 648 - let workspace = Workspace::from_path(dir)?; 649 - let stack = workspace.read_stack()?; 650 - 651 - if stack.empty() { 359 + let ws = Workspace::from_path(dir)?; 360 + let stack = ws.read_stack()?; 361 + if stack.is_empty() { 652 362 println!("*No tasks*"); 653 - exit(0); 363 + return Ok(()); 654 364 } 655 - 656 - for (_, stack_item) in stack 657 - .into_iter() 658 - .enumerate() 659 - .take_while(|(idx, _)| all || idx < &count) 660 - { 365 + for (i, entry) in stack.iter().enumerate() { 366 + if !all && i >= count { 367 + break; 368 + } 661 369 if ids_only { 662 - println!("{}", stack_item.id); 663 - } else if let Some(parsed) = task::parse(&stack_item.title) { 664 - println!("{}\t{}", stack_item.id, parsed.content.trim()); 370 + println!("{}", entry.id); 665 371 } else { 666 - println!("{stack_item}"); 372 + println!("{}\t{}", entry.id, entry.title); 667 373 } 668 374 } 669 375 Ok(()) 670 376 } 671 377 672 - fn command_swap(dir: PathBuf) -> Result<()> { 673 - let workspace = Workspace::from_path(dir)?; 674 - workspace.swap_top()?; 675 - Ok(()) 676 - } 677 - 678 - fn command_edit(dir: PathBuf, id: TaskId) -> Result<()> { 679 - let workspace = Workspace::from_path(dir)?; 680 - let id: TaskIdentifier = id.into(); 681 - let mut task = workspace.task(id)?; 682 - let pre_links = task::parse(&task.to_string()).map(|pt| pt.intenal_links()); 683 - let new_content = open_editor(format!("{}\n\n{}", task.title.trim(), task.body.trim()))?; 684 - if let Some((title, body)) = new_content.split_once("\n") { 685 - // Ensure title never contains newlines (invariant for index file format) 686 - task.title = title.replace(['\n', '\r'], " "); 687 - task.body = body.to_string(); 688 - workspace.handle_metadata(&task, pre_links)?; 689 - workspace.save_task(&task)?; 690 - } 691 - Ok(()) 692 - } 693 - 694 - fn command_completion(shell: Shell) -> Result<()> { 695 - generate(shell, &mut Cli::command(), "tsk", &mut io::stdout()); 696 - Ok(()) 697 - } 698 - 699 - fn command_drop(dir: PathBuf, task_id: TaskId) -> Result<()> { 700 - if let Some(id) = Workspace::from_path(dir)?.drop(task_id.into())? { 701 - eprint!("Dropped "); 702 - println!("{id}"); 703 - } else { 704 - eprintln!("No task to drop."); 705 - exit(1); 706 - } 707 - Ok(()) 708 - } 709 - 710 - fn command_find(dir: PathBuf, short_id: bool, find_args: FindArgs) -> Result<()> { 711 - let id = Workspace::from_path(dir)?.search(None, !find_args.exclude_body, false)?; 712 - if let Some(id) = id { 713 - if short_id { 714 - // print as integer 715 - println!("{}", id.0); 716 - } else { 717 - println!("{id}"); 718 - } 719 - } else { 720 - eprintln!("No task selected."); 721 - exit(1); 722 - } 723 - Ok(()) 724 - } 725 - 726 - fn command_prioritize(dir: PathBuf, task_id: TaskId) -> Result<()> { 727 - Workspace::from_path(dir)?.prioritize(task_id.into()) 728 - } 729 - 730 - fn command_deprioritize(dir: PathBuf, task_id: TaskId) -> Result<()> { 731 - Workspace::from_path(dir)?.deprioritize(task_id.into()) 732 - } 733 - 734 - fn command_show(dir: PathBuf, task_id: TaskId, show_attrs: bool, raw: bool) -> Result<()> { 378 + fn command_show(dir: PathBuf, task_id: TaskId, show_attrs: bool) -> Result<()> { 735 379 let task = Workspace::from_path(dir)?.task(task_id.into())?; 736 - // YAML front-matter style. YAML is gross, but it's what everyone uses! 737 380 if show_attrs && !task.attributes.is_empty() { 738 381 println!("---"); 739 - for (attr, value) in task.attributes.iter() { 740 - println!("{attr}: \"{value}\""); 382 + for (k, v) in &task.attributes { 383 + println!("{k}: \"{v}\""); 741 384 } 742 385 println!("---"); 743 386 } 744 - match task::parse(&task.to_string()) { 745 - Some(styled_task) if !raw => { 746 - writeln!(io::stdout(), "{}", styled_task.content)?; 747 - } 748 - _ => { 749 - println!("{task}"); 750 - } 751 - } 387 + println!("{task}"); 752 388 Ok(()) 753 389 } 754 390 755 - fn render_link(link: &ParsedLink) -> String { 756 - match link { 757 - ParsedLink::External(url) => url.to_string(), 758 - ParsedLink::Internal(id) => format!("[[{id}]]"), 759 - ParsedLink::Foreign { prefix, id } => format!("[[{prefix}-{id}]]"), 760 - ParsedLink::Namespaced { namespace, id } => format!("[[{namespace}/{id}]]"), 391 + fn command_edit(dir: PathBuf, task_id: TaskId) -> Result<()> { 392 + let ws = Workspace::from_path(dir)?; 393 + let mut task = ws.task(task_id.into())?; 394 + let new_content = open_editor(format!("{}\n\n{}", task.title.trim(), task.body.trim()))?; 395 + if let Some((t, b)) = new_content.split_once('\n') { 396 + task.title = t.replace(['\n', '\r'], " "); 397 + task.body = b.trim_start_matches('\n').to_string(); 398 + ws.save_task(&task)?; 761 399 } 762 - } 763 - 764 - fn command_follow( 765 - dir: PathBuf, 766 - task_id: TaskId, 767 - link_index: Option<usize>, 768 - select: bool, 769 - edit: bool, 770 - ) -> Result<()> { 771 - let task = Workspace::from_path(dir.clone())?.task(task_id.into())?; 772 - let Some(parsed_task) = task::parse(&task.to_string()) else { 773 - eprintln!("Unable to parse any links from body."); 774 - exit(1); 775 - }; 776 - if parsed_task.links.is_empty() { 777 - eprintln!("No links found in {}.", task.id); 778 - return Ok(()); 779 - } 780 - 781 - // Resolve which link index to act on, or fall through to listing. 782 - let idx = match (link_index, select) { 783 - (Some(n), _) => n, 784 - (None, true) => { 785 - let lines: Vec<String> = parsed_task 786 - .links 787 - .iter() 788 - .enumerate() 789 - .map(|(i, l)| format!("{}\t{}", i + 1, render_link(l))) 790 - .collect(); 791 - match fzf::select::<_, usize, _>(lines, ["--delimiter=\t", "--accept-nth=1"])? { 792 - Some(n) => n, 793 - None => { 794 - eprintln!("No link selected."); 795 - exit(1); 796 - } 797 - } 798 - } 799 - (None, false) => { 800 - // Just list. 801 - for (i, link) in parsed_task.links.iter().enumerate() { 802 - println!("{}\t{}", i + 1, render_link(link)); 803 - } 804 - return Ok(()); 805 - } 806 - }; 807 - 808 - if idx == 0 || idx > parsed_task.links.len() { 809 - eprintln!("Link index out of bounds."); 810 - exit(1); 811 - } 812 - match &parsed_task.links[idx - 1] { 813 - ParsedLink::External(url) => { 814 - open::that_detached(url.as_str())?; 815 - Ok(()) 816 - } 817 - ParsedLink::Internal(id) => { 818 - let taskid = taskid_from_tsk_id(*id); 819 - if edit { 820 - command_edit(dir, taskid) 821 - } else { 822 - command_show(dir, taskid, false, false) 823 - } 824 - } 825 - ParsedLink::Foreign { prefix, id } => { 826 - let workspace = Workspace::from_path(dir.clone())?; 827 - if let Some(task) = workspace.resolve_foreign_link(prefix, *id)? { 828 - if edit { 829 - eprintln!("Editing foreign tasks is not supported."); 830 - exit(1); 831 - } else { 832 - println!("{task}"); 833 - } 834 - } else { 835 - eprintln!("Task {prefix}-{id} not found in remote workspace."); 836 - exit(1); 837 - } 838 - Ok(()) 839 - } 840 - ParsedLink::Namespaced { namespace, id } => { 841 - let workspace = Workspace::from_path(dir.clone())?; 842 - if edit { 843 - eprintln!("Editing tasks in another namespace is not supported."); 844 - exit(1); 845 - } 846 - match workspace.resolve_namespaced_link(namespace, *id)? { 847 - Some(task) => { 848 - println!("{task}"); 849 - Ok(()) 850 - } 851 - None => { 852 - eprintln!("Task {namespace}/{id} not found."); 853 - exit(1); 854 - } 855 - } 856 - } 857 - } 858 - } 859 - 860 - fn command_clean(dir: PathBuf) -> Result<()> { 861 - Workspace::from_path(dir)?.clean()?; 862 400 Ok(()) 863 401 } 864 402 865 - fn command_remote(dir: PathBuf, action: RemoteAction) -> Result<()> { 866 - let workspace = Workspace::from_path(dir)?; 867 - match action { 868 - RemoteAction::List => { 869 - let remotes = workspace.read_remotes()?; 870 - if remotes.is_empty() { 871 - println!("No remotes configured."); 872 - } else { 873 - for remote in remotes { 874 - println!("{remote}"); 875 - } 876 - } 877 - } 878 - RemoteAction::Add { prefix, path } => { 879 - workspace.add_remote(&prefix, &path)?; 880 - eprintln!("Added remote '{prefix}' -> {path}"); 881 - } 882 - RemoteAction::Remove { prefix } => { 883 - workspace.remove_remote(&prefix)?; 884 - eprintln!("Removed remote '{prefix}'"); 885 - } 886 - } 887 - Ok(()) 888 - } 889 - 890 - fn command_git_push(dir: PathBuf, remote: Option<String>) -> Result<()> { 891 - let workspace = Workspace::from_path(dir)?; 892 - let r = effective_remote(&workspace, remote)?.ok_or_else(|| { 893 - errors::Error::Parse("No remote specified and no 'origin' configured".into()) 894 - })?; 895 - workspace.git_push_refs(&r) 896 - } 897 - 898 - fn command_git_pull(dir: PathBuf, remote: Option<String>) -> Result<()> { 899 - let workspace = Workspace::from_path(dir)?; 900 - let r = effective_remote(&workspace, remote)?.ok_or_else(|| { 901 - errors::Error::Parse("No remote specified and no 'origin' configured".into()) 902 - })?; 903 - workspace.git_pull_refs(&r) 904 - } 905 - 906 - fn command_git_setup(dir: PathBuf, use_gitignore: bool, remote: Option<String>) -> Result<()> { 907 - let workspace = Workspace::from_path(dir)?; 908 - let git_dir = workspace.path.join(".git"); 909 - if !git_dir.exists() { 910 - eprintln!("No .git directory found at workspace root."); 911 - exit(1); 912 - } 913 - let (ignore_file, label) = if use_gitignore { 914 - (workspace.path.join(".gitignore"), ".gitignore") 403 + fn command_drop(dir: PathBuf, task_id: TaskId) -> Result<()> { 404 + if let Some(id) = Workspace::from_path(dir)?.drop(task_id.into())? { 405 + println!("Dropped {id}"); 406 + Ok(()) 915 407 } else { 916 - let info_dir = git_dir.join("info"); 917 - std::fs::create_dir_all(&info_dir)?; 918 - (info_dir.join("exclude"), ".git/info/exclude") 919 - }; 920 - let content = if ignore_file.exists() { 921 - std::fs::read_to_string(&ignore_file)? 922 - } else { 923 - String::new() 924 - }; 925 - if content.lines().any(|line| line.trim() == ".tsk/") { 926 - eprintln!(".tsk/ is already in {label}."); 927 - return Ok(()); 928 - } 929 - let mut file = OpenOptions::new() 930 - .append(true) 931 - .create(true) 932 - .open(&ignore_file)?; 933 - writeln!(file, ".tsk/")?; 934 - eprintln!("Added .tsk/ to {label}."); 935 - if let Some(remote) = remote { 936 - workspace.configure_git_remote_refspecs(&remote)?; 937 - eprintln!("Configured push/fetch refspecs on remote '{remote}' for refs/tsk/*"); 408 + eprintln!("No task to drop."); 409 + exit(1); 938 410 } 939 - Ok(()) 940 411 } 941 412 942 - fn command_bundle(dir: PathBuf, output: Option<PathBuf>) -> Result<()> { 943 - let workspace = Workspace::from_path(dir)?; 944 - let dest = output.unwrap_or_else(|| PathBuf::from("tsk.zip")); 945 - workspace.export_zip(&dest)?; 946 - eprintln!("Wrote {}", dest.display()); 413 + fn command_share(dir: PathBuf, target: String, task_id: TaskId) -> Result<()> { 414 + let ws = Workspace::from_path(dir)?; 415 + let h = ws.share(task_id.into(), &target)?; 416 + println!("Shared as {target}/tsk-{h}"); 947 417 Ok(()) 948 418 } 949 419 ··· 954 424 remote: Option<String>, 955 425 ) -> Result<()> { 956 426 let ws = Workspace::from_path(dir)?; 957 - let id = ws.task(task_id.into())?.id; 958 - let key = ws.export_to_namespace(&target, id)?; 959 - eprintln!("Sent {id} to namespace '{target}' (inbox key: {key})"); 960 - if let Some(r) = effective_remote(&ws, remote)? { 961 - ws.git_push_refs(&r)?; 427 + let key = ws.assign_to_queue(task_id.into(), &target)?; 428 + println!("Assigned to {target} as {key}"); 429 + if let Some(r) = effective_remote(remote) { 430 + let _ = ws.git_push(&r); 962 431 } 963 432 Ok(()) 964 433 } 965 434 435 + fn command_pull(dir: PathBuf, source: String, task_id: TaskId) -> Result<()> { 436 + let ws = Workspace::from_path(dir)?; 437 + // For pull, the task id is interpreted in the source queue's namespace 438 + // mapping context. Simplification: require the caller to use -T <stable> 439 + // form via human id in active namespace. For v1 we just resolve in 440 + // active namespace; sharing first lets the user reference foreign tasks. 441 + let id = ws.pull_from_queue(&source, task_id.into())?; 442 + println!("Pulled {id}"); 443 + Ok(()) 444 + } 445 + 966 446 fn command_inbox(dir: PathBuf, remote: Option<String>) -> Result<()> { 967 447 let ws = Workspace::from_path(dir)?; 968 - if let Some(r) = effective_remote(&ws, remote)? { 969 - ws.git_pull_refs(&r)?; 448 + if let Some(r) = effective_remote(remote) { 449 + let _ = ws.git_pull(&r); 970 450 } 971 - let items = ws.list_inbox()?; 972 - if items.is_empty() { 973 - println!("Inbox is empty."); 451 + let inbox = ws.list_inbox()?; 452 + if inbox.is_empty() { 453 + println!("*Empty*"); 974 454 return Ok(()); 975 455 } 976 - for item in items { 977 - println!( 978 - "{}\t{}/tsk-{}\t{}", 979 - item.inbox_key.trim_start_matches("inbox/"), 980 - item.source_namespace, 981 - item.source_id, 982 - item.title 983 - ); 456 + for item in inbox { 457 + println!("{}\tfrom {}\t{}", item.key, item.source_queue, item.title); 984 458 } 985 459 Ok(()) 986 460 } ··· 994 468 .into_iter() 995 469 .next() 996 470 .ok_or_else(|| errors::Error::Parse("Inbox is empty".into()))? 997 - .inbox_key 471 + .key 998 472 } 999 473 }; 1000 474 let id = ws.accept_inbox(&key)?; 1001 - eprintln!("Accepted as {id}"); 475 + println!("Accepted as {id}"); 1002 476 Ok(()) 1003 477 } 1004 478 ··· 1011 485 .into_iter() 1012 486 .next() 1013 487 .ok_or_else(|| errors::Error::Parse("Inbox is empty".into()))? 1014 - .inbox_key 488 + .key 1015 489 } 1016 490 }; 1017 - let (src_ns, src_id) = ws.reject_inbox(&key)?; 1018 - eprintln!("Rejected inbox item from {src_ns}/tsk-{src_id}"); 1019 - if let Some(r) = effective_remote(&ws, remote)? { 1020 - ws.git_push_refs(&r)?; 491 + ws.reject_inbox(&key)?; 492 + println!("Rejected {key}"); 493 + if let Some(r) = effective_remote(remote) { 494 + let _ = ws.git_push(&r); 1021 495 } 1022 496 Ok(()) 1023 497 } 1024 498 1025 - fn command_migrate_history(dir: PathBuf) -> Result<()> { 1026 - let ws = Workspace::from_path(dir)?; 1027 - if !ws.is_git_backed() { 1028 - return Err(errors::Error::Parse( 1029 - "migrate-history only applies to git-backed workspaces".into(), 1030 - )); 1031 - } 1032 - let marker = std::fs::read_to_string(ws.path.join(backend::GIT_BACKED_MARKER))?; 1033 - let n = backend::migrate_to_commit_history(&PathBuf::from(marker.trim()))?; 1034 - eprintln!("Converted {n} blob refs to commit-backed history."); 1035 - Ok(()) 1036 - } 1037 - 1038 - fn command_migrate(dir: PathBuf) -> Result<()> { 1039 - let workspace = Workspace::from_path(dir)?; 1040 - let git_dir = workspace.migrate_to_git()?; 1041 - eprintln!( 1042 - "Migrated workspace to git refs (git dir: {})", 1043 - git_dir.display() 1044 - ); 1045 - Ok(()) 1046 - } 1047 - 1048 - fn command_log(dir: PathBuf, tsk_id: Option<Id>) -> Result<()> { 1049 - let ws = Workspace::from_path(dir)?; 1050 - let mut entries = match tsk_id { 1051 - Some(id) => ws.read_log(id)?, 1052 - None => ws.read_namespace_log()?, 1053 - }; 1054 - if entries.is_empty() { 1055 - eprintln!("No log entries."); 1056 - return Ok(()); 1057 - } 1058 - // Newest first, git-log style. 1059 - entries.reverse(); 1060 - for (i, e) in entries.iter().enumerate() { 1061 - if i > 0 { 1062 - println!(); 1063 - } 1064 - let header = if tsk_id.is_some() { 1065 - format!("event {}", e.event) 1066 - } else { 1067 - format!("event {} {}", e.id, e.event) 1068 - }; 1069 - println!("{header}"); 1070 - if !e.author.is_empty() { 1071 - println!("Author: {}", e.author); 1072 - } 1073 - let ts = std::time::UNIX_EPOCH + std::time::Duration::from_secs(e.timestamp); 1074 - println!("Date: {}", format_systemtime(ts)); 1075 - if !e.detail.is_empty() { 1076 - println!(); 1077 - println!(" {}", e.detail); 1078 - } 1079 - } 1080 - Ok(()) 1081 - } 1082 - 1083 - fn format_systemtime(t: std::time::SystemTime) -> String { 1084 - let secs = t 1085 - .duration_since(std::time::UNIX_EPOCH) 1086 - .map(|d| d.as_secs()) 1087 - .unwrap_or(0); 1088 - // Lightweight RFC3339-ish formatter: split into Y-m-d H:M:S UTC. Avoids 1089 - // pulling in chrono just for this. 1090 - let (y, mo, d, h, mi, s) = ymd_hms_utc(secs); 1091 - format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z") 1092 - } 1093 - 1094 - fn ymd_hms_utc(secs: u64) -> (u64, u32, u32, u32, u32, u32) { 1095 - let day = secs / 86_400; 1096 - let rem = secs % 86_400; 1097 - let h = (rem / 3600) as u32; 1098 - let mi = ((rem % 3600) / 60) as u32; 1099 - let s = (rem % 60) as u32; 1100 - // Civil-from-days (Howard Hinnant). Stable for all valid u64 epoch days. 1101 - let z = day as i64 + 719_468; 1102 - let era = z.div_euclid(146_097); 1103 - let doe = (z - era * 146_097) as u64; 1104 - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; 1105 - let y = yoe as i64 + era * 400; 1106 - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); 1107 - let mp = (5 * doy + 2) / 153; 1108 - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; 1109 - let mo = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; 1110 - let y = if mo <= 2 { y + 1 } else { y }; 1111 - (y as u64, mo, d, h, mi, s) 1112 - } 1113 - 1114 - fn command_prop(dir: PathBuf, action: PropAction) -> Result<()> { 499 + fn command_namespace(dir: PathBuf, action: NamespaceAction) -> Result<()> { 1115 500 let ws = Workspace::from_path(dir)?; 1116 501 match action { 1117 - PropAction::List { task_id } => { 1118 - let id = ws.task(task_id.into())?.id; 1119 - for (k, v) in ws.properties(id)? { 1120 - if v.is_empty() { 1121 - println!("{k}"); 1122 - } else { 1123 - println!("{k}\t{v}"); 1124 - } 502 + NamespaceAction::List => { 503 + for n in ws.list_namespaces()? { 504 + println!("{n}"); 1125 505 } 1126 506 } 1127 - PropAction::Set { 1128 - task_id, 1129 - key, 1130 - value, 1131 - from_body, 1132 - } => { 1133 - let id = ws.task(task_id.into())?.id; 1134 - let key = match key { 1135 - Some(k) => k, 1136 - None => { 1137 - let mut candidates = ws.all_property_keys()?; 1138 - candidates.push(NEW_SENTINEL.to_string()); 1139 - let picked = fzf::select::<_, String, _>(candidates, ["--prompt=property> "])? 1140 - .ok_or_else(|| errors::Error::Parse("No property selected".into()))?; 1141 - if picked == NEW_SENTINEL { 1142 - prompt_line("new property name: ")? 1143 - } else { 1144 - picked 1145 - } 1146 - } 1147 - }; 1148 - let value = match value { 1149 - Some(v) => v, 1150 - None => { 1151 - let mut candidates = ws.property_values_for(&key)?; 1152 - if from_body { 1153 - for c in ws.body_candidates(id)? { 1154 - if !candidates.contains(&c) { 1155 - candidates.push(c); 1156 - } 1157 - } 1158 - } 1159 - candidates.push(NEW_SENTINEL.to_string()); 1160 - let picked = fzf::select::<_, String, _>(candidates, ["--prompt=value> "])? 1161 - .ok_or_else(|| errors::Error::Parse("No value selected".into()))?; 1162 - if picked == NEW_SENTINEL { 1163 - prompt_line("new value (empty for unary): ")? 1164 - } else { 1165 - picked 1166 - } 1167 - } 1168 - }; 1169 - ws.set_property(id, &key, &value)?; 1170 - // For duplicates: if the duplicate and original are both still on 1171 - // the stack, prompt to drop the duplicate so they don't both keep 1172 - // showing up in tsk list. 1173 - if key == "duplicates" 1174 - && let Some(target) = parse_internal_link_for_cli(&value) 1175 - { 1176 - let stack = ws.read_stack()?; 1177 - let dup_open = stack.iter().any(|i| i.id == id); 1178 - let orig_open = stack.iter().any(|i| i.id == target); 1179 - if dup_open && orig_open { 1180 - eprint!("{id} duplicates {target} and both are open. Drop {id}? [y/N] "); 1181 - use std::io::Write as _; 1182 - io::stderr().flush()?; 1183 - let mut answer = String::new(); 1184 - io::stdin().read_line(&mut answer)?; 1185 - if matches!(answer.trim(), "y" | "Y" | "yes") { 1186 - ws.drop(workspace::TaskIdentifier::Id(id))?; 1187 - eprintln!("Dropped {id}"); 1188 - } 1189 - } 1190 - } 1191 - } 1192 - PropAction::Unset { task_id, key } => { 1193 - let id = ws.task(task_id.into())?.id; 1194 - ws.unset_property(id, &key)?; 1195 - } 1196 - PropAction::Find { key, value } => { 1197 - const ANY_SENTINEL: &str = "<any>"; 1198 - let prompt_value = key.is_none() && value.is_none(); 1199 - let key = match key { 1200 - Some(k) => k, 1201 - None => { 1202 - let candidates = ws.all_property_keys()?; 1203 - fzf::select::<_, String, _>(candidates, ["--prompt=property> "])? 1204 - .ok_or_else(|| errors::Error::Parse("No property selected".into()))? 1205 - } 1206 - }; 1207 - // Value-prompt only when neither key nor value was supplied — 1208 - // `tsk prop find KEY` keeps its "any task with KEY set" meaning. 1209 - let value = match (value, prompt_value) { 1210 - (Some(v), _) => Some(v), 1211 - (None, false) => None, 1212 - (None, true) => { 1213 - let mut candidates = ws.property_values_for(&key)?; 1214 - candidates.insert(0, ANY_SENTINEL.to_string()); 1215 - let picked = fzf::select::<_, String, _>(candidates, ["--prompt=value> "])?; 1216 - match picked.as_deref() { 1217 - Some(ANY_SENTINEL) | None => None, 1218 - Some(v) => Some(v.to_string()), 1219 - } 1220 - } 1221 - }; 1222 - for id in ws.find_by_property(&key, value.as_deref())? { 1223 - println!("{id}"); 1224 - } 1225 - } 507 + NamespaceAction::Current => println!("{}", ws.namespace()), 508 + NamespaceAction::Switch { name } => ws.switch_namespace(&name)?, 1226 509 } 1227 510 Ok(()) 1228 511 } 1229 512 1230 - fn command_namespace_switch(dir: PathBuf, name: Option<String>) -> Result<()> { 1231 - let ws = Workspace::from_path(dir)?; 1232 - let target = match name { 1233 - Some(n) => n, 1234 - None => pick_namespace(&ws)?, 1235 - }; 1236 - ws.switch_namespace(&target)?; 1237 - eprintln!("Switched to namespace '{target}'"); 1238 - Ok(()) 1239 - } 1240 - 1241 - /// fzf-pick a namespace from the workspace's existing list, with a `<new>` 1242 - /// sentinel for entering one that doesn't exist yet. 1243 - fn pick_namespace(ws: &Workspace) -> Result<String> { 1244 - let mut candidates = ws.list_namespaces()?; 1245 - candidates.push(NEW_SENTINEL.to_string()); 1246 - let picked = fzf::select::<_, String, _>(candidates, ["--prompt=namespace> "])? 1247 - .ok_or_else(|| errors::Error::Parse("No namespace selected".into()))?; 1248 - if picked == NEW_SENTINEL { 1249 - prompt_line("new namespace name: ") 1250 - } else { 1251 - Ok(picked) 1252 - } 1253 - } 1254 - 1255 - fn command_namespace(dir: PathBuf, action: NamespaceAction) -> Result<()> { 513 + fn command_queue(dir: PathBuf, action: QueueAction) -> Result<()> { 1256 514 let ws = Workspace::from_path(dir)?; 1257 515 match action { 1258 - NamespaceAction::Current => { 1259 - println!("{}", ws.namespace()); 1260 - } 1261 - NamespaceAction::List => { 1262 - let cur = ws.namespace(); 1263 - for ns in ws.list_namespaces()? { 1264 - let marker = if ns == cur { "* " } else { " " }; 1265 - println!("{marker}{ns}"); 516 + QueueAction::List => { 517 + for n in ws.list_queues()? { 518 + println!("{n}"); 1266 519 } 1267 520 } 1268 - NamespaceAction::Switch { name } => { 1269 - let target = match name { 1270 - Some(n) => n, 1271 - None => pick_namespace(&ws)?, 1272 - }; 1273 - ws.switch_namespace(&target)?; 1274 - eprintln!("Switched to namespace '{target}'"); 1275 - } 1276 - NamespaceAction::Create { name } => { 1277 - ws.switch_namespace(&name)?; 1278 - eprintln!("Switched to namespace '{name}'"); 1279 - } 1280 - NamespaceAction::Delete { name, yes } => { 1281 - let count = ws.namespace_ref_count(&name)?; 1282 - if count == 0 { 1283 - eprintln!("Namespace '{name}' has no refs."); 1284 - return Ok(()); 1285 - } 1286 - if !yes { 1287 - eprint!("Namespace '{name}' has {count} refs. Delete? [y/N] "); 1288 - use std::io::Write as _; 1289 - io::stderr().flush()?; 1290 - let mut answer = String::new(); 1291 - io::stdin().read_line(&mut answer)?; 1292 - if !matches!(answer.trim(), "y" | "Y" | "yes") { 1293 - eprintln!("Aborted."); 1294 - return Ok(()); 1295 - } 1296 - } 1297 - let n = ws.delete_namespace(&name)?; 1298 - eprintln!("Deleted {n} refs from namespace '{name}'"); 521 + QueueAction::Current => println!("{}", ws.queue()), 522 + QueueAction::Create { name, can_pull } => { 523 + ws.create_queue(&name, Some(can_pull))?; 524 + println!("Created queue '{name}' (can-pull={can_pull})"); 1299 525 } 526 + QueueAction::Switch { name } => ws.switch_queue(&name)?, 1300 527 } 1301 528 Ok(()) 1302 529 } 1303 530 1304 - fn command_reopen(dir: PathBuf, task_id: TaskId) -> Result<()> { 1305 - let workspace = Workspace::from_path(dir)?; 1306 - let id: TaskIdentifier = task_id.into(); 1307 - let reopened_id = workspace.reopen(id)?; 1308 - eprintln!("Reopened "); 1309 - println!("{reopened_id}"); 1310 - Ok(()) 1311 - } 531 + #[allow(dead_code)] 532 + fn _silence_unused(_w: &dyn Write, _t: Task) {}
+256
src/namespace.rs
··· 1 + //! A namespace: a tree mapping human-readable ids → task stable ids. 2 + //! 3 + //! Stored as a commit chain at `refs/tsk/namespaces/<name>`. Tree layout: 4 + //! next → blob: next human id to allocate, e.g. "5\n" 5 + //! ids/<human-id> → blob: 40-hex stable id 6 + //! 7 + //! The default namespace is `tsk`. Namespaces are self-contained (no 8 + //! cross-namespace references at this layer). 9 + 10 + use crate::errors::{Error, Result}; 11 + use crate::object::StableId; 12 + use git2::{Oid, Repository, Signature}; 13 + use std::collections::BTreeMap; 14 + 15 + pub const NS_REF_PREFIX: &str = "refs/tsk/namespaces/"; 16 + pub const DEFAULT_NS: &str = "tsk"; 17 + const NEXT_FILE: &str = "next"; 18 + const IDS_DIR: &str = "ids"; 19 + 20 + pub fn refname(name: &str) -> String { 21 + format!("{NS_REF_PREFIX}{name}") 22 + } 23 + 24 + pub fn validate_name(name: &str) -> Result<()> { 25 + if name.is_empty() { 26 + return Err(Error::Parse("Namespace name cannot be empty".into())); 27 + } 28 + if !name 29 + .chars() 30 + .all(|c| c.is_alphanumeric() || c == '_' || c == '-') 31 + { 32 + return Err(Error::Parse(format!( 33 + "Namespace '{name}' must contain only alphanumerics, '-', or '_'" 34 + ))); 35 + } 36 + Ok(()) 37 + } 38 + 39 + #[derive(Clone, Debug, Default, Eq, PartialEq)] 40 + pub struct Namespace { 41 + pub next: u32, 42 + /// human id → stable id 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 + } 51 + 52 + pub fn read(repo: &Repository, name: &str) -> Result<Namespace> { 53 + let Ok(r) = repo.find_reference(&refname(name)) else { 54 + return Ok(Namespace { 55 + next: 1, 56 + mapping: BTreeMap::new(), 57 + }); 58 + }; 59 + let Some(target) = r.target() else { 60 + return Ok(Namespace { 61 + next: 1, 62 + mapping: BTreeMap::new(), 63 + }); 64 + }; 65 + let tree = repo.find_commit(target)?.tree()?; 66 + let mut ns = Namespace { 67 + next: 1, 68 + mapping: BTreeMap::new(), 69 + }; 70 + if let Some(entry) = tree.get_name(NEXT_FILE) { 71 + let blob = entry.to_object(repo)?.peel_to_blob()?; 72 + ns.next = String::from_utf8_lossy(blob.content()) 73 + .trim() 74 + .parse() 75 + .unwrap_or(1); 76 + } 77 + if let Some(ids_entry) = tree.get_name(IDS_DIR) { 78 + let ids_tree = ids_entry.to_object(repo)?.peel_to_tree()?; 79 + for e in ids_tree.iter() { 80 + let Some(name) = e.name() else { continue }; 81 + let Ok(human) = name.parse::<u32>() else { 82 + continue; 83 + }; 84 + let blob = e.to_object(repo)?.peel_to_blob()?; 85 + let stable = String::from_utf8_lossy(blob.content()).trim().to_string(); 86 + if !stable.is_empty() { 87 + ns.mapping.insert(human, StableId(stable)); 88 + } 89 + } 90 + } 91 + Ok(ns) 92 + } 93 + 94 + fn build_tree(repo: &Repository, ns: &Namespace) -> Result<Oid> { 95 + let mut ids_tb = repo.treebuilder(None)?; 96 + for (human, stable) in &ns.mapping { 97 + let oid = repo.blob(stable.0.as_bytes())?; 98 + ids_tb.insert(human.to_string().as_str(), oid, 0o100644)?; 99 + } 100 + let ids_oid = ids_tb.write()?; 101 + 102 + let mut tb = repo.treebuilder(None)?; 103 + let next_oid = repo.blob(format!("{}\n", ns.next).as_bytes())?; 104 + tb.insert(NEXT_FILE, next_oid, 0o100644)?; 105 + tb.insert(IDS_DIR, ids_oid, 0o040000)?; 106 + Ok(tb.write()?) 107 + } 108 + 109 + pub fn write(repo: &Repository, name: &str, ns: &Namespace, message: &str) -> Result<()> { 110 + validate_name(name)?; 111 + let tree_oid = build_tree(repo, ns)?; 112 + let parent = repo 113 + .find_reference(&refname(name)) 114 + .ok() 115 + .and_then(|r| r.target()) 116 + .and_then(|o| repo.find_commit(o).ok()); 117 + if let Some(p) = &parent 118 + && p.tree_id() == tree_oid 119 + { 120 + return Ok(()); 121 + } 122 + let sig = signature(repo); 123 + let parents: Vec<&git2::Commit> = parent.iter().collect(); 124 + let commit = repo.commit( 125 + None, 126 + &sig, 127 + &sig, 128 + message, 129 + &repo.find_tree(tree_oid)?, 130 + &parents, 131 + )?; 132 + repo.reference(&refname(name), commit, true, message)?; 133 + Ok(()) 134 + } 135 + 136 + /// Allocate the next human id, insert the binding, and persist. Returns the 137 + /// human id assigned. 138 + pub fn assign_id( 139 + repo: &Repository, 140 + name: &str, 141 + stable: StableId, 142 + message: &str, 143 + ) -> Result<u32> { 144 + let mut ns = read(repo, name)?; 145 + let human = ns.next; 146 + ns.next += 1; 147 + ns.mapping.insert(human, stable); 148 + write(repo, name, &ns, message)?; 149 + Ok(human) 150 + } 151 + 152 + pub fn unassign_id(repo: &Repository, name: &str, human: u32, message: &str) -> Result<()> { 153 + let mut ns = read(repo, name)?; 154 + if ns.mapping.remove(&human).is_some() { 155 + write(repo, name, &ns, message)?; 156 + } 157 + Ok(()) 158 + } 159 + 160 + pub fn list_names(repo: &Repository) -> Result<Vec<String>> { 161 + let mut out = Vec::new(); 162 + for r in repo.references_glob(&format!("{NS_REF_PREFIX}*"))? { 163 + let r = r?; 164 + if let Some(name) = r.name() 165 + && let Some(rest) = name.strip_prefix(NS_REF_PREFIX) 166 + { 167 + out.push(rest.to_string()); 168 + } 169 + } 170 + out.sort(); 171 + Ok(out) 172 + } 173 + 174 + pub fn lookup(repo: &Repository, name: &str, human: u32) -> Result<Option<StableId>> { 175 + Ok(read(repo, name)?.mapping.get(&human).cloned()) 176 + } 177 + 178 + /// Reverse lookup: stable → human in the given namespace, if present. 179 + pub fn human_for(repo: &Repository, name: &str, stable: &StableId) -> Result<Option<u32>> { 180 + Ok(read(repo, name)? 181 + .mapping 182 + .iter() 183 + .find(|(_, s)| *s == stable) 184 + .map(|(h, _)| *h)) 185 + } 186 + 187 + #[cfg(test)] 188 + mod test { 189 + use super::*; 190 + use crate::object; 191 + 192 + fn init_repo(p: &std::path::Path) -> Repository { 193 + let r = Repository::init(p).unwrap(); 194 + let mut cfg = r.config().unwrap(); 195 + cfg.set_str("user.name", "T").unwrap(); 196 + cfg.set_str("user.email", "t@e").unwrap(); 197 + r 198 + } 199 + 200 + #[test] 201 + fn empty_namespace_reads_as_default() { 202 + let dir = tempfile::tempdir().unwrap(); 203 + let repo = init_repo(dir.path()); 204 + let ns = read(&repo, "tsk").unwrap(); 205 + assert_eq!(ns.next, 1); 206 + assert!(ns.mapping.is_empty()); 207 + } 208 + 209 + #[test] 210 + fn assign_then_round_trip() { 211 + let dir = tempfile::tempdir().unwrap(); 212 + let repo = init_repo(dir.path()); 213 + let s1 = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 214 + let s2 = object::create(&repo, &object::Task::new("b"), "c").unwrap(); 215 + let h1 = assign_id(&repo, "tsk", s1.clone(), "assign").unwrap(); 216 + let h2 = assign_id(&repo, "tsk", s2.clone(), "assign").unwrap(); 217 + assert_eq!(h1, 1); 218 + assert_eq!(h2, 2); 219 + let ns = read(&repo, "tsk").unwrap(); 220 + assert_eq!(ns.next, 3); 221 + assert_eq!(ns.mapping.get(&1), Some(&s1)); 222 + assert_eq!(ns.mapping.get(&2), Some(&s2)); 223 + assert_eq!(human_for(&repo, "tsk", &s1).unwrap(), Some(1)); 224 + } 225 + 226 + #[test] 227 + fn unassign_removes_only_mapping_keeps_next() { 228 + let dir = tempfile::tempdir().unwrap(); 229 + let repo = init_repo(dir.path()); 230 + let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 231 + let _ = assign_id(&repo, "tsk", s, "assign").unwrap(); 232 + unassign_id(&repo, "tsk", 1, "drop").unwrap(); 233 + let ns = read(&repo, "tsk").unwrap(); 234 + assert!(ns.mapping.is_empty()); 235 + assert_eq!(ns.next, 2, "next must monotonically grow"); 236 + } 237 + 238 + #[test] 239 + fn list_names_returns_known_namespaces() { 240 + let dir = tempfile::tempdir().unwrap(); 241 + let repo = init_repo(dir.path()); 242 + let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 243 + let _ = assign_id(&repo, "tsk", s.clone(), "assign").unwrap(); 244 + let _ = assign_id(&repo, "alpha", s, "assign").unwrap(); 245 + let mut names = list_names(&repo).unwrap(); 246 + names.sort(); 247 + assert_eq!(names, vec!["alpha".to_string(), "tsk".to_string()]); 248 + } 249 + 250 + #[test] 251 + fn validate_name_rejects_bad_input() { 252 + assert!(validate_name("").is_err()); 253 + assert!(validate_name("a/b").is_err()); 254 + assert!(validate_name("ok-name_1").is_ok()); 255 + } 256 + }
+263
src/object.rs
··· 1 + //! A task: a tree of `{content, <prop>...}` blobs with its own commit history. 2 + //! 3 + //! Stable id = SHA-1 hex of the initial `content` blob. Refs: 4 + //! `refs/tsk/tasks/<stable-id>` → latest commit on that task. 5 + //! 6 + //! Tree layout for a task at any commit: 7 + //! content → blob: full task body, first line is the title 8 + //! title → blob: cached title (optional; canonical title is content's first line) 9 + //! <prop-key> → blob: property value (one file per property) 10 + 11 + use crate::errors::Result; 12 + use git2::{Oid, Repository, Signature}; 13 + use std::collections::BTreeMap; 14 + use std::fmt::Display; 15 + 16 + pub const TASK_REF_PREFIX: &str = "refs/tsk/tasks/"; 17 + pub const CONTENT_FILE: &str = "content"; 18 + pub const TITLE_FILE: &str = "title"; 19 + 20 + /// Stable identifier for a task: hex SHA-1 of its initial content blob. 21 + #[derive(Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd)] 22 + pub struct StableId(pub String); 23 + 24 + impl StableId { 25 + pub fn refname(&self) -> String { 26 + format!("{TASK_REF_PREFIX}{}", self.0) 27 + } 28 + #[allow(dead_code)] // used by upcoming display layer 29 + pub fn short(&self) -> &str { 30 + &self.0[..12.min(self.0.len())] 31 + } 32 + } 33 + 34 + impl Display for StableId { 35 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 36 + f.write_str(&self.0) 37 + } 38 + } 39 + 40 + #[derive(Clone, Debug, Default, Eq, PartialEq)] 41 + pub struct Task { 42 + pub content: String, 43 + pub properties: BTreeMap<String, String>, 44 + } 45 + 46 + impl Task { 47 + pub fn new(content: impl Into<String>) -> Self { 48 + Self { 49 + content: content.into(), 50 + properties: BTreeMap::new(), 51 + } 52 + } 53 + 54 + pub fn title(&self) -> &str { 55 + self.content.lines().next().unwrap_or("") 56 + } 57 + 58 + pub fn body(&self) -> &str { 59 + match self.content.split_once('\n') { 60 + Some((_, rest)) => rest.trim_start_matches('\n'), 61 + None => "", 62 + } 63 + } 64 + } 65 + 66 + fn signature(repo: &Repository) -> Signature<'static> { 67 + repo.signature() 68 + .map(|s| s.to_owned()) 69 + .unwrap_or_else(|_| Signature::now("tsk", "tsk@local").unwrap()) 70 + } 71 + 72 + fn build_tree( 73 + repo: &Repository, 74 + content_oid: Oid, 75 + title: &str, 76 + properties: &BTreeMap<String, String>, 77 + ) -> Result<Oid> { 78 + let mut tb = repo.treebuilder(None)?; 79 + tb.insert(CONTENT_FILE, content_oid, 0o100644)?; 80 + let title_oid = repo.blob(title.as_bytes())?; 81 + tb.insert(TITLE_FILE, title_oid, 0o100644)?; 82 + for (k, v) in properties { 83 + if k == CONTENT_FILE || k == TITLE_FILE { 84 + continue; 85 + } 86 + let oid = repo.blob(v.as_bytes())?; 87 + tb.insert(k.as_str(), oid, 0o100644)?; 88 + } 89 + Ok(tb.write()?) 90 + } 91 + 92 + /// Create a brand-new task. Returns its freshly-minted stable id. 93 + pub fn create(repo: &Repository, task: &Task, message: &str) -> Result<StableId> { 94 + let content_oid = repo.blob(task.content.as_bytes())?; 95 + let stable = StableId(content_oid.to_string()); 96 + let tree_oid = build_tree(repo, content_oid, task.title(), &task.properties)?; 97 + let sig = signature(repo); 98 + let commit = repo.commit( 99 + None, 100 + &sig, 101 + &sig, 102 + message, 103 + &repo.find_tree(tree_oid)?, 104 + &[], 105 + )?; 106 + repo.reference(&stable.refname(), commit, true, message)?; 107 + Ok(stable) 108 + } 109 + 110 + /// Append a new commit to a task's history. No-op when the resulting tree 111 + /// matches the parent's tree (idempotent saves). 112 + pub fn update(repo: &Repository, id: &StableId, task: &Task, message: &str) -> Result<()> { 113 + let content_oid = repo.blob(task.content.as_bytes())?; 114 + let tree_oid = build_tree(repo, content_oid, task.title(), &task.properties)?; 115 + let parent = repo 116 + .find_reference(&id.refname()) 117 + .ok() 118 + .and_then(|r| r.target()) 119 + .and_then(|o| repo.find_commit(o).ok()); 120 + if let Some(p) = &parent 121 + && p.tree_id() == tree_oid 122 + { 123 + return Ok(()); 124 + } 125 + let sig = signature(repo); 126 + let parents: Vec<&git2::Commit> = parent.iter().collect(); 127 + let commit = repo.commit( 128 + None, 129 + &sig, 130 + &sig, 131 + message, 132 + &repo.find_tree(tree_oid)?, 133 + &parents, 134 + )?; 135 + repo.reference(&id.refname(), commit, true, message)?; 136 + Ok(()) 137 + } 138 + 139 + pub fn read(repo: &Repository, id: &StableId) -> Result<Option<Task>> { 140 + let Ok(r) = repo.find_reference(&id.refname()) else { 141 + return Ok(None); 142 + }; 143 + let Some(target) = r.target() else { 144 + return Ok(None); 145 + }; 146 + let commit = repo.find_commit(target)?; 147 + let tree = commit.tree()?; 148 + let mut task = Task::default(); 149 + for entry in tree.iter() { 150 + let name = entry.name().unwrap_or("").to_string(); 151 + let blob = entry.to_object(repo)?.peel_to_blob()?; 152 + let val = String::from_utf8_lossy(blob.content()).into_owned(); 153 + match name.as_str() { 154 + CONTENT_FILE => task.content = val, 155 + TITLE_FILE => {} // cache only; canonical title is content's first line 156 + _ => { 157 + task.properties.insert(name, val); 158 + } 159 + } 160 + } 161 + Ok(Some(task)) 162 + } 163 + 164 + #[allow(dead_code)] // exposed for cleanup tooling / future commands 165 + pub fn delete(repo: &Repository, id: &StableId) -> Result<()> { 166 + if let Ok(mut r) = repo.find_reference(&id.refname()) { 167 + r.delete()?; 168 + } 169 + Ok(()) 170 + } 171 + 172 + #[allow(dead_code)] // exposed for cleanup tooling / future commands 173 + pub fn list_all(repo: &Repository) -> Result<Vec<StableId>> { 174 + let mut out = Vec::new(); 175 + for r in repo.references_glob(&format!("{TASK_REF_PREFIX}*"))? { 176 + let r = r?; 177 + if let Some(name) = r.name() 178 + && let Some(rest) = name.strip_prefix(TASK_REF_PREFIX) 179 + { 180 + out.push(StableId(rest.to_string())); 181 + } 182 + } 183 + Ok(out) 184 + } 185 + 186 + #[cfg(test)] 187 + mod test { 188 + use super::*; 189 + use std::path::Path; 190 + 191 + fn init_repo(p: &Path) -> Repository { 192 + let r = Repository::init(p).unwrap(); 193 + let mut cfg = r.config().unwrap(); 194 + cfg.set_str("user.name", "Test").unwrap(); 195 + cfg.set_str("user.email", "t@e").unwrap(); 196 + r 197 + } 198 + 199 + #[test] 200 + fn create_read_round_trip() { 201 + let dir = tempfile::tempdir().unwrap(); 202 + let repo = init_repo(dir.path()); 203 + let mut t = Task::new("Hello\n\nbody text"); 204 + t.properties.insert("priority".into(), "high".into()); 205 + let id = create(&repo, &t, "create").unwrap(); 206 + let read_back = read(&repo, &id).unwrap().unwrap(); 207 + assert_eq!(read_back.content, t.content); 208 + assert_eq!(read_back.properties, t.properties); 209 + assert_eq!(read_back.title(), "Hello"); 210 + assert_eq!(read_back.body(), "body text"); 211 + } 212 + 213 + #[test] 214 + fn update_appends_commit() { 215 + let dir = tempfile::tempdir().unwrap(); 216 + let repo = init_repo(dir.path()); 217 + let t = Task::new("v1"); 218 + let id = create(&repo, &t, "create").unwrap(); 219 + let mut t2 = t.clone(); 220 + t2.content = "v2".into(); 221 + update(&repo, &id, &t2, "edit").unwrap(); 222 + // Two commits in the chain. 223 + let head = repo.find_reference(&id.refname()).unwrap().target().unwrap(); 224 + let head_commit = repo.find_commit(head).unwrap(); 225 + assert_eq!(head_commit.parent_count(), 1); 226 + let read_back = read(&repo, &id).unwrap().unwrap(); 227 + assert_eq!(read_back.content, "v2"); 228 + } 229 + 230 + #[test] 231 + fn update_idempotent_when_tree_unchanged() { 232 + let dir = tempfile::tempdir().unwrap(); 233 + let repo = init_repo(dir.path()); 234 + let t = Task::new("same"); 235 + let id = create(&repo, &t, "create").unwrap(); 236 + let head1 = repo.find_reference(&id.refname()).unwrap().target().unwrap(); 237 + update(&repo, &id, &t, "noop").unwrap(); 238 + let head2 = repo.find_reference(&id.refname()).unwrap().target().unwrap(); 239 + assert_eq!(head1, head2); 240 + } 241 + 242 + #[test] 243 + fn list_all_sees_every_task() { 244 + let dir = tempfile::tempdir().unwrap(); 245 + let repo = init_repo(dir.path()); 246 + let a = create(&repo, &Task::new("a"), "c").unwrap(); 247 + let b = create(&repo, &Task::new("b"), "c").unwrap(); 248 + let mut got = list_all(&repo).unwrap(); 249 + got.sort(); 250 + let mut want = vec![a, b]; 251 + want.sort(); 252 + assert_eq!(got, want); 253 + } 254 + 255 + #[test] 256 + fn stable_id_equals_blob_oid() { 257 + let dir = tempfile::tempdir().unwrap(); 258 + let repo = init_repo(dir.path()); 259 + let id = create(&repo, &Task::new("xyz"), "c").unwrap(); 260 + let direct = repo.blob(b"xyz").unwrap(); 261 + assert_eq!(id.0, direct.to_string()); 262 + } 263 + }
+303
src/queue.rs
··· 1 + //! A queue: an ordered list of tasks plus an inbox of pending assignments. 2 + //! 3 + //! Stored as a commit chain at `refs/tsk/queues/<name>`. Tree layout: 4 + //! index → blob: ordered stable ids (one per line, top-of-stack first) 5 + //! can-pull → blob: "true" or "false" (defaults: true for `tsk`, false otherwise) 6 + //! inbox/<src>-<n> → blob: stable id of a task assigned by queue <src> 7 + //! 8 + //! Queues are pushed/shared (refspec `refs/tsk/*`). The active queue per-user 9 + //! is selected by the `.tsk/queue` file. 10 + 11 + use crate::errors::{Error, Result}; 12 + use crate::object::StableId; 13 + use git2::{Oid, Repository, Signature}; 14 + use std::collections::BTreeMap; 15 + 16 + pub const QUEUE_REF_PREFIX: &str = "refs/tsk/queues/"; 17 + pub const DEFAULT_QUEUE: &str = "tsk"; 18 + const INDEX_FILE: &str = "index"; 19 + const CAN_PULL_FILE: &str = "can-pull"; 20 + const INBOX_DIR: &str = "inbox"; 21 + 22 + pub fn refname(name: &str) -> String { 23 + format!("{QUEUE_REF_PREFIX}{name}") 24 + } 25 + 26 + pub fn validate_name(name: &str) -> Result<()> { 27 + if name.is_empty() { 28 + return Err(Error::Parse("Queue name cannot be empty".into())); 29 + } 30 + if !name 31 + .chars() 32 + .all(|c| c.is_alphanumeric() || c == '_' || c == '-') 33 + { 34 + return Err(Error::Parse(format!( 35 + "Queue '{name}' must contain only alphanumerics, '-', or '_'" 36 + ))); 37 + } 38 + Ok(()) 39 + } 40 + 41 + #[derive(Clone, Debug, Eq, PartialEq)] 42 + pub struct Queue { 43 + /// top-of-stack first 44 + pub index: Vec<StableId>, 45 + pub can_pull: bool, 46 + /// inbox key → stable id of the task being offered 47 + pub inbox: BTreeMap<String, StableId>, 48 + } 49 + 50 + impl Queue { 51 + pub fn new(name: &str) -> Self { 52 + Self { 53 + index: Vec::new(), 54 + can_pull: name == DEFAULT_QUEUE, 55 + inbox: BTreeMap::new(), 56 + } 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 + } 65 + 66 + pub fn read(repo: &Repository, name: &str) -> Result<Queue> { 67 + let Ok(r) = repo.find_reference(&refname(name)) else { 68 + return Ok(Queue::new(name)); 69 + }; 70 + let Some(target) = r.target() else { 71 + return Ok(Queue::new(name)); 72 + }; 73 + let tree = repo.find_commit(target)?.tree()?; 74 + let mut q = Queue::new(name); 75 + if let Some(e) = tree.get_name(INDEX_FILE) { 76 + let blob = e.to_object(repo)?.peel_to_blob()?; 77 + q.index = String::from_utf8_lossy(blob.content()) 78 + .lines() 79 + .filter(|l| !l.trim().is_empty()) 80 + .map(|l| StableId(l.trim().to_string())) 81 + .collect(); 82 + } 83 + if let Some(e) = tree.get_name(CAN_PULL_FILE) { 84 + let blob = e.to_object(repo)?.peel_to_blob()?; 85 + q.can_pull = String::from_utf8_lossy(blob.content()).trim() == "true"; 86 + } 87 + if let Some(e) = tree.get_name(INBOX_DIR) { 88 + let inbox_tree = e.to_object(repo)?.peel_to_tree()?; 89 + for ie in inbox_tree.iter() { 90 + let Some(name) = ie.name() else { continue }; 91 + let blob = ie.to_object(repo)?.peel_to_blob()?; 92 + let stable = String::from_utf8_lossy(blob.content()).trim().to_string(); 93 + if !stable.is_empty() { 94 + q.inbox 95 + .insert(name.to_string(), StableId(stable)); 96 + } 97 + } 98 + } 99 + Ok(q) 100 + } 101 + 102 + fn build_tree(repo: &Repository, q: &Queue) -> Result<Oid> { 103 + let mut tb = repo.treebuilder(None)?; 104 + let index_text: String = q 105 + .index 106 + .iter() 107 + .map(|s| format!("{}\n", s.0)) 108 + .collect(); 109 + let index_oid = repo.blob(index_text.as_bytes())?; 110 + tb.insert(INDEX_FILE, index_oid, 0o100644)?; 111 + let cp = if q.can_pull { "true\n" } else { "false\n" }; 112 + let cp_oid = repo.blob(cp.as_bytes())?; 113 + tb.insert(CAN_PULL_FILE, cp_oid, 0o100644)?; 114 + if !q.inbox.is_empty() { 115 + let mut ib = repo.treebuilder(None)?; 116 + for (k, v) in &q.inbox { 117 + let oid = repo.blob(v.0.as_bytes())?; 118 + ib.insert(k.as_str(), oid, 0o100644)?; 119 + } 120 + let ib_oid = ib.write()?; 121 + tb.insert(INBOX_DIR, ib_oid, 0o040000)?; 122 + } 123 + Ok(tb.write()?) 124 + } 125 + 126 + pub fn write(repo: &Repository, name: &str, q: &Queue, message: &str) -> Result<()> { 127 + validate_name(name)?; 128 + let tree_oid = build_tree(repo, q)?; 129 + let parent = repo 130 + .find_reference(&refname(name)) 131 + .ok() 132 + .and_then(|r| r.target()) 133 + .and_then(|o| repo.find_commit(o).ok()); 134 + if let Some(p) = &parent 135 + && p.tree_id() == tree_oid 136 + { 137 + return Ok(()); 138 + } 139 + let sig = signature(repo); 140 + let parents: Vec<&git2::Commit> = parent.iter().collect(); 141 + let commit = repo.commit( 142 + None, 143 + &sig, 144 + &sig, 145 + message, 146 + &repo.find_tree(tree_oid)?, 147 + &parents, 148 + )?; 149 + repo.reference(&refname(name), commit, true, message)?; 150 + Ok(()) 151 + } 152 + 153 + pub fn list_names(repo: &Repository) -> Result<Vec<String>> { 154 + let mut out = Vec::new(); 155 + for r in repo.references_glob(&format!("{QUEUE_REF_PREFIX}*"))? { 156 + let r = r?; 157 + if let Some(name) = r.name() 158 + && let Some(rest) = name.strip_prefix(QUEUE_REF_PREFIX) 159 + { 160 + out.push(rest.to_string()); 161 + } 162 + } 163 + out.sort(); 164 + Ok(out) 165 + } 166 + 167 + /// Push `stable` onto the top of `name`'s index. Idempotent: if already 168 + /// present, moves it to the top. 169 + pub fn push_top(repo: &Repository, name: &str, stable: StableId, message: &str) -> Result<()> { 170 + let mut q = read(repo, name)?; 171 + q.index.retain(|s| s != &stable); 172 + q.index.insert(0, stable); 173 + write(repo, name, &q, message) 174 + } 175 + 176 + pub fn push_bottom( 177 + repo: &Repository, 178 + name: &str, 179 + stable: StableId, 180 + message: &str, 181 + ) -> Result<()> { 182 + let mut q = read(repo, name)?; 183 + q.index.retain(|s| s != &stable); 184 + q.index.push(stable); 185 + write(repo, name, &q, message) 186 + } 187 + 188 + pub fn remove(repo: &Repository, name: &str, stable: &StableId, message: &str) -> Result<bool> { 189 + let mut q = read(repo, name)?; 190 + let len = q.index.len(); 191 + q.index.retain(|s| s != stable); 192 + if q.index.len() == len { 193 + return Ok(false); 194 + } 195 + write(repo, name, &q, message)?; 196 + Ok(true) 197 + } 198 + 199 + /// Stable inbox key for a `(source-queue, sequence)` pair so re-assigns 200 + /// overwrite the same slot rather than piling up. Sequence is the per-source 201 + /// counter the caller maintains; we don't try to compute it here. 202 + pub fn inbox_key(src_queue: &str, src_seq: u32) -> String { 203 + format!("{src_queue}-{src_seq}") 204 + } 205 + 206 + pub fn add_to_inbox( 207 + repo: &Repository, 208 + name: &str, 209 + key: String, 210 + stable: StableId, 211 + message: &str, 212 + ) -> Result<()> { 213 + let mut q = read(repo, name)?; 214 + q.inbox.insert(key, stable); 215 + write(repo, name, &q, message) 216 + } 217 + 218 + pub fn take_from_inbox( 219 + repo: &Repository, 220 + name: &str, 221 + key: &str, 222 + message: &str, 223 + ) -> Result<Option<StableId>> { 224 + let mut q = read(repo, name)?; 225 + let Some(stable) = q.inbox.remove(key) else { 226 + return Ok(None); 227 + }; 228 + write(repo, name, &q, message)?; 229 + Ok(Some(stable)) 230 + } 231 + 232 + #[cfg(test)] 233 + mod test { 234 + use super::*; 235 + use crate::object; 236 + 237 + fn init_repo(p: &std::path::Path) -> Repository { 238 + let r = Repository::init(p).unwrap(); 239 + let mut cfg = r.config().unwrap(); 240 + cfg.set_str("user.name", "T").unwrap(); 241 + cfg.set_str("user.email", "t@e").unwrap(); 242 + r 243 + } 244 + 245 + #[test] 246 + fn empty_default_queue_is_can_pull() { 247 + let dir = tempfile::tempdir().unwrap(); 248 + let repo = init_repo(dir.path()); 249 + let q = read(&repo, "tsk").unwrap(); 250 + assert!(q.can_pull); 251 + assert!(q.index.is_empty()); 252 + } 253 + 254 + #[test] 255 + fn empty_named_queue_defaults_no_pull() { 256 + let dir = tempfile::tempdir().unwrap(); 257 + let repo = init_repo(dir.path()); 258 + let q = read(&repo, "private").unwrap(); 259 + assert!(!q.can_pull); 260 + } 261 + 262 + #[test] 263 + fn push_pop_round_trip() { 264 + let dir = tempfile::tempdir().unwrap(); 265 + let repo = init_repo(dir.path()); 266 + let s1 = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 267 + let s2 = object::create(&repo, &object::Task::new("b"), "c").unwrap(); 268 + push_top(&repo, "tsk", s1.clone(), "push").unwrap(); 269 + push_top(&repo, "tsk", s2.clone(), "push").unwrap(); 270 + let q = read(&repo, "tsk").unwrap(); 271 + assert_eq!(q.index, vec![s2.clone(), s1.clone()]); 272 + assert!(remove(&repo, "tsk", &s2, "drop").unwrap()); 273 + let q = read(&repo, "tsk").unwrap(); 274 + assert_eq!(q.index, vec![s1]); 275 + } 276 + 277 + #[test] 278 + fn push_top_dedupes_existing() { 279 + let dir = tempfile::tempdir().unwrap(); 280 + let repo = init_repo(dir.path()); 281 + let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 282 + push_top(&repo, "tsk", s.clone(), "push").unwrap(); 283 + push_bottom(&repo, "tsk", s.clone(), "push").unwrap(); // same id, moved to bottom 284 + let q = read(&repo, "tsk").unwrap(); 285 + assert_eq!(q.index.len(), 1); 286 + assert_eq!(q.index[0], s); 287 + } 288 + 289 + #[test] 290 + fn inbox_round_trip() { 291 + let dir = tempfile::tempdir().unwrap(); 292 + let repo = init_repo(dir.path()); 293 + let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 294 + let key = inbox_key("alice", 5); 295 + add_to_inbox(&repo, "bob", key.clone(), s.clone(), "assign").unwrap(); 296 + let q = read(&repo, "bob").unwrap(); 297 + assert_eq!(q.inbox.get(&key), Some(&s)); 298 + let taken = take_from_inbox(&repo, "bob", &key, "accept").unwrap(); 299 + assert_eq!(taken, Some(s)); 300 + let q = read(&repo, "bob").unwrap(); 301 + assert!(q.inbox.is_empty()); 302 + } 303 + }
-152
src/stack.rs
··· 1 - #![allow(dead_code)] 2 - //! The Task stack. Tasks created with `push` end up at the top here. It is invalid for a task that 3 - //! has been completed/archived to be on the stack. 4 - //! 5 - //! The stack is persisted as a single blob keyed `index` in the workspace's 6 - //! [`Store`](crate::backend::Store). Each line is `tsk-N\ttitle\ttimestamp`. 7 - 8 - use crate::backend::Store; 9 - use crate::errors::{Error, Result}; 10 - use std::collections::VecDeque; 11 - use std::collections::vec_deque::Iter; 12 - use std::fmt::Display; 13 - use std::str::FromStr; 14 - use std::time::{Duration, SystemTime, UNIX_EPOCH}; 15 - 16 - use crate::workspace::{Id, Task}; 17 - 18 - #[derive(Clone)] 19 - pub struct StackItem { 20 - pub id: Id, 21 - pub title: String, 22 - pub modify_time: SystemTime, 23 - } 24 - 25 - impl Display for StackItem { 26 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 27 - write!(f, "{}\t{}", self.id, self.title.trim()) 28 - } 29 - } 30 - 31 - impl From<&Task> for StackItem { 32 - fn from(value: &Task) -> Self { 33 - Self { 34 - id: value.id, 35 - title: value.title.replace("\t", " "), 36 - modify_time: SystemTime::now(), 37 - } 38 - } 39 - } 40 - 41 - impl FromStr for StackItem { 42 - type Err = Error; 43 - 44 - fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { 45 - let mut parts = s.trim().split('\t'); 46 - let id: Id = parts 47 - .next() 48 - .ok_or(Error::Parse("Incomplete index line. Missing tsk ID".into()))? 49 - .parse()?; 50 - let title = parts 51 - .next() 52 - .ok_or(Error::Parse("Incomplete index line. Missing title.".into()))? 53 - .trim() 54 - .to_string(); 55 - let index_epoch: u64 = parts.next().unwrap_or("0").parse().unwrap_or(0); 56 - let modify_time = UNIX_EPOCH 57 - .checked_add(Duration::from_secs(index_epoch)) 58 - .unwrap_or(UNIX_EPOCH); 59 - Ok(Self { 60 - id, 61 - title, 62 - modify_time, 63 - }) 64 - } 65 - } 66 - 67 - pub struct TaskStack { 68 - pub all: VecDeque<StackItem>, 69 - } 70 - 71 - impl TaskStack { 72 - pub fn parse(text: &str) -> Result<Self> { 73 - text.lines() 74 - .filter(|l| !l.trim().is_empty()) 75 - .map(str::parse) 76 - .collect::<Result<VecDeque<_>>>() 77 - .map(|all| Self { all }) 78 - } 79 - 80 - pub fn load(store: &dyn Store) -> Result<Self> { 81 - Self::parse(&String::from_utf8_lossy( 82 - &store.read("index")?.unwrap_or_default(), 83 - )) 84 - } 85 - 86 - pub fn serialize(&self) -> String { 87 - self.all 88 - .iter() 89 - .map(|i| { 90 - let ts = i 91 - .modify_time 92 - .duration_since(UNIX_EPOCH) 93 - .map_or(0, |d| d.as_secs()); 94 - format!("{i}\t{ts}\n") 95 - }) 96 - .collect() 97 - } 98 - 99 - pub fn save(&self, store: &dyn Store) -> Result<()> { 100 - store.write("index", self.serialize().as_bytes()) 101 - } 102 - 103 - pub fn push(&mut self, item: StackItem) { 104 - self.all.push_front(item); 105 - } 106 - 107 - pub fn push_back(&mut self, item: StackItem) { 108 - self.all.push_back(item); 109 - } 110 - 111 - pub fn pop(&mut self) -> Option<StackItem> { 112 - self.all.pop_front() 113 - } 114 - 115 - pub fn swap(&mut self) { 116 - let tip = self.all.pop_front(); 117 - let second = self.all.pop_front(); 118 - if let Some((tip, second)) = tip.zip(second) { 119 - self.all.push_front(tip); 120 - self.all.push_front(second); 121 - } 122 - } 123 - 124 - pub fn empty(&self) -> bool { 125 - self.all.is_empty() 126 - } 127 - 128 - pub fn remove(&mut self, index: usize) -> Option<StackItem> { 129 - self.all.remove(index) 130 - } 131 - 132 - pub fn iter(&self) -> Iter<'_, StackItem> { 133 - self.all.iter() 134 - } 135 - 136 - pub fn get(&self, index: usize) -> Option<&StackItem> { 137 - self.all.get(index) 138 - } 139 - 140 - pub fn position(&self, id: Id) -> Option<usize> { 141 - self.all.iter().position(|i| i.id == id) 142 - } 143 - } 144 - 145 - impl IntoIterator for TaskStack { 146 - type Item = StackItem; 147 - type IntoIter = std::collections::vec_deque::IntoIter<Self::Item>; 148 - 149 - fn into_iter(self) -> Self::IntoIter { 150 - self.all.into_iter() 151 - } 152 - }
+460 -3153
src/workspace.rs
··· 1 + //! High-level workspace API. Orchestrates [`object`], [`namespace`], and 2 + //! [`queue`] to back the CLI commands. 3 + //! 4 + //! On disk the workspace is just a `.tsk/` marker directory inside a git 5 + //! repository. `.tsk/namespace` and `.tsk/queue` select the user's active 6 + //! namespace and queue (defaults: `tsk` / `tsk`). 7 + 1 8 #![allow(dead_code)] 2 - //! High-level workspace API. The workspace owns a [`Store`](crate::backend::Store) 3 - //! and exposes typed task / stack / remote operations on top of it. 4 9 5 - use crate::backend::{self, Loc, Store}; 6 10 use crate::errors::{Error, Result}; 7 - use crate::stack::{StackItem, TaskStack}; 8 - use crate::task::parse as parse_task; 9 - use crate::{fzf, util}; 10 - use std::collections::{BTreeMap, HashSet}; 11 + use crate::object::{self, StableId, Task as TaskObj}; 12 + use crate::{namespace, queue, util}; 13 + use git2::Repository; 14 + use std::collections::BTreeMap; 11 15 use std::fmt::Display; 12 16 use std::path::PathBuf; 13 17 use std::str::FromStr; 14 18 15 - /// A unique identifier for a task. When referenced in text, it is prefixed with `tsk-`. 19 + const NAMESPACE_FILE: &str = "namespace"; 20 + const QUEUE_FILE: &str = "queue"; 21 + const GIT_DIR_FILE: &str = "git-dir"; 22 + 23 + /// A human-readable task identifier (`tsk-N`). Always namespace-scoped: the 24 + /// integer N has no meaning without the namespace it was minted in. 16 25 #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] 17 26 pub struct Id(pub u32); 18 27 ··· 35 44 } 36 45 37 46 impl From<u32> for Id { 38 - fn from(value: u32) -> Self { 39 - Id(value) 47 + fn from(v: u32) -> Self { 48 + Id(v) 40 49 } 41 50 } 42 51 43 52 pub enum TaskIdentifier { 44 53 Id(Id), 54 + /// Index into the active queue's stack (0 = top). 45 55 Relative(u32), 46 - Find { exclude_body: bool, archived: bool }, 47 56 } 48 57 49 58 impl From<Id> for TaskIdentifier { 50 - fn from(value: Id) -> Self { 51 - TaskIdentifier::Id(value) 52 - } 53 - } 54 - 55 - #[derive(Clone, Debug, Eq, PartialEq)] 56 - pub struct Remote { 57 - pub prefix: String, 58 - pub path: PathBuf, 59 - } 60 - 61 - impl Display for Remote { 62 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 63 - write!(f, "{}\t{}", self.prefix, self.path.display()) 64 - } 65 - } 66 - 67 - /// A property whose value is a `[[tsk-N]]` link maintains an inverse 68 - /// link-list property on the target task. `forward` is the singular property 69 - /// the user sets; `inverse` is the multi-value list maintained on the target. 70 - struct InversePair { 71 - forward: &'static str, 72 - inverse: &'static str, 73 - cycle_check: bool, 74 - action: &'static str, 75 - } 76 - 77 - const INVERSE_PAIRS: &[InversePair] = &[ 78 - InversePair { 79 - forward: "parent", 80 - inverse: "children", 81 - cycle_check: true, 82 - action: "parent", 83 - }, 84 - InversePair { 85 - forward: "duplicates", 86 - inverse: "duplicated-by", 87 - cycle_check: true, 88 - action: "duplicate", 89 - }, 90 - ]; 91 - 92 - fn inverse_pair_for(key: &str) -> Option<&'static InversePair> { 93 - INVERSE_PAIRS.iter().find(|p| p.forward == key) 94 - } 95 - 96 - /// Parse a single `[[tsk-N]]` wiki-style internal link out of a property 97 - /// value. Whitespace is trimmed; foreign links (`[[ns/tsk-N]]`) are not 98 - /// matched here because the inverse-relation maintenance is intra-namespace. 99 - fn parse_internal_link(s: &str) -> Option<Id> { 100 - let inner = s.trim().strip_prefix("[[")?.strip_suffix("]]")?; 101 - Id::from_str(inner).ok() 102 - } 103 - 104 - /// Parse a comma-separated list of `[[tsk-N]]` links, ignoring entries that 105 - /// don't parse cleanly. 106 - fn parse_link_list(s: &str) -> Vec<Id> { 107 - s.split(',').filter_map(parse_internal_link).collect() 108 - } 109 - 110 - fn format_link_list(ids: &[Id]) -> String { 111 - ids.iter() 112 - .map(|i| format!("[[{i}]]")) 113 - .collect::<Vec<_>>() 114 - .join(",") 115 - } 116 - 117 - /// One id-collision resolution decision computed during `tsk git-pull`. 118 - /// `local_loses` true means we vacate `old_id` locally (renumbering our 119 - /// content to `new_id`) so the remote's blob can take `old_id` cleanly. 120 - /// false means the remote's blob is the loser; we import it at `new_id` 121 - /// while keeping our local `old_id` intact. 122 - struct Renumber { 123 - old_id: Id, 124 - new_id: Id, 125 - local_loses: bool, 126 - } 127 - 128 - /// Author signature for any tsk-generated commit produced from 129 - /// workspace.rs (merges, etc.). Falls back to a tsk identity if the user's 130 - /// git config has no `user.name` / `user.email`. 131 - fn git_sig(repo: &git2::Repository) -> Result<git2::Signature<'static>> { 132 - if let Ok(s) = repo.signature() { 133 - return Ok(s.to_owned()); 59 + fn from(v: Id) -> Self { 60 + TaskIdentifier::Id(v) 134 61 } 135 - Ok(git2::Signature::now("tsk", "tsk@local")?) 136 62 } 137 63 138 - enum PullAction { 139 - /// Local already matches remote — nothing to do. 140 - Skip, 141 - /// Take the remote OID verbatim (local missing or unchanged since last sync). 142 - Take, 143 - /// Both sides moved; the ref is union-mergeable, do a 3-way merge. 144 - Merge, 145 - /// Both sides moved and the ref is not auto-mergeable. 146 - Conflict, 64 + /// One row of a queue listing. 65 + pub struct StackEntry { 66 + pub id: Id, 67 + pub stable: StableId, 68 + pub title: String, 147 69 } 148 70 149 - fn is_mergeable_key(rel: &str) -> bool { 150 - rel.starts_with("log/") 151 - || rel.ends_with("/log") 152 - || rel == "index" 153 - || rel.ends_with("/index") 154 - || rel == "next" 155 - || rel.ends_with("/next") 71 + /// User-facing task: human id (in active namespace) + content + properties. 72 + pub struct Task { 73 + pub id: Id, 74 + pub stable: StableId, 75 + pub title: String, 76 + pub body: String, 77 + pub attributes: BTreeMap<String, String>, 156 78 } 157 79 158 - fn resolve_pull( 159 - local: Option<git2::Oid>, 160 - old_remote: Option<git2::Oid>, 161 - new_remote: git2::Oid, 162 - rel: &str, 163 - ) -> PullAction { 164 - match local { 165 - None => PullAction::Take, 166 - Some(l) if l == new_remote => PullAction::Skip, 167 - Some(l) => match old_remote { 168 - // Local hasn't moved since last sync; remote did → take remote. 169 - Some(o) if o == l => PullAction::Take, 170 - // Remote hasn't moved since last sync; local did → keep local. 171 - Some(o) if o == new_remote => PullAction::Skip, 172 - // Either no shared base, or both moved. 173 - _ => { 174 - if is_mergeable_key(rel) { 175 - PullAction::Merge 176 - } else { 177 - PullAction::Conflict 178 - } 179 - } 180 - }, 181 - } 182 - } 183 - 184 - /// Union of two append-only logs, sorted by the leading unix timestamp on 185 - /// each line. Duplicate lines collapse. 186 - fn merge_log(local: &str, remote: &str) -> String { 187 - let mut all: Vec<&str> = local.lines().chain(remote.lines()).collect(); 188 - all.sort_by_key(|l| { 189 - l.split('\t') 190 - .next() 191 - .and_then(|t| t.parse::<u64>().ok()) 192 - .unwrap_or(0) 193 - }); 194 - all.dedup(); 195 - let mut out = all.join("\n"); 196 - if !out.is_empty() { 197 - out.push('\n'); 198 - } 199 - out 200 - } 201 - 202 - /// Union of two stack indexes preserving local order; remote-only items get 203 - /// appended in their relative order. Items are identified by their leading 204 - /// `tsk-N` field. 205 - fn merge_index(local: &str, remote: &str) -> String { 206 - let key = |line: &str| line.split('\t').next().unwrap_or("").to_string(); 207 - let mut seen: HashSet<String> = HashSet::new(); 208 - let mut out = String::new(); 209 - for line in local.lines() { 210 - if line.trim().is_empty() { 211 - continue; 212 - } 213 - seen.insert(key(line)); 214 - out.push_str(line); 215 - out.push('\n'); 216 - } 217 - for line in remote.lines() { 218 - if line.trim().is_empty() { 219 - continue; 220 - } 221 - if seen.insert(key(line)) { 222 - out.push_str(line); 223 - out.push('\n'); 224 - } 225 - } 226 - out 227 - } 228 - 229 - /// Reject namespace names that contain `/` or other characters problematic in 230 - /// a git ref path. 231 - fn validate_namespace(name: &str) -> Result<()> { 232 - if name.is_empty() { 233 - return Err(Error::Parse("Namespace name cannot be empty".into())); 234 - } 235 - if !name 236 - .chars() 237 - .all(|c| c.is_alphanumeric() || c == '_' || c == '-') 238 - { 239 - return Err(Error::Parse(format!( 240 - "Namespace '{name}' must contain only alphanumerics, '-', or '_'" 241 - ))); 80 + impl Display for Task { 81 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 82 + write!(f, "{}\n\n{}", self.title, self.body) 242 83 } 243 - Ok(()) 244 84 } 245 85 246 - /// Summary of one item in a namespace inbox. 86 + /// One pending inbox item in the active queue. 247 87 pub struct InboxItem { 248 - pub inbox_key: String, 249 - pub source_namespace: String, 250 - pub source_id: u32, 88 + pub key: String, 89 + pub source_queue: String, 90 + pub stable: StableId, 251 91 pub title: String, 252 92 } 253 93 254 94 pub struct Workspace { 255 - /// The path to the .tsk marker directory. 95 + /// The `.tsk/` directory. 256 96 pub path: PathBuf, 257 - store: Box<dyn Store>, 97 + /// The enclosing git repo's `.git` (or bare) directory. 98 + pub git_dir: PathBuf, 258 99 } 259 100 260 101 impl Workspace { 102 + /// Initialize a `.tsk/` marker inside an existing git repo. Errors if no 103 + /// git repo encloses `path` or if `.tsk/` already exists. 261 104 pub fn init(path: PathBuf) -> Result<()> { 262 105 let tsk_dir = path.join(".tsk"); 263 106 if tsk_dir.exists() { 264 107 return Err(Error::AlreadyInitialized); 265 108 } 109 + let git_dir = find_git_dir(&path) 110 + .ok_or_else(|| Error::Parse("tsk requires an enclosing git repository".into()))?; 266 111 std::fs::create_dir(&tsk_dir)?; 267 - // If we're in a git repo, mark this workspace as git-backed and use refs 268 - // for storage. Otherwise fall back to the file backend (tasks live under 269 - // .tsk/). 270 - if let Some(git_dir) = backend::detect_git_dir(&path) { 271 - std::fs::write( 272 - tsk_dir.join(backend::GIT_BACKED_MARKER), 273 - git_dir.to_string_lossy().as_bytes(), 274 - )?; 275 - // GitStore is fully ref-based — no on-disk task data. 276 - } else { 277 - // Pre-create directory tree for the file backend. 278 - std::fs::create_dir(tsk_dir.join("tasks"))?; 279 - std::fs::create_dir(tsk_dir.join("archive"))?; 280 - std::fs::write(tsk_dir.join("next"), b"1\n")?; 281 - } 112 + std::fs::write(tsk_dir.join(GIT_DIR_FILE), git_dir.to_string_lossy().as_bytes())?; 113 + std::fs::write(tsk_dir.join(NAMESPACE_FILE), namespace::DEFAULT_NS.as_bytes())?; 114 + std::fs::write(tsk_dir.join(QUEUE_FILE), queue::DEFAULT_QUEUE.as_bytes())?; 282 115 Ok(()) 283 116 } 284 117 285 118 pub fn from_path(path: PathBuf) -> Result<Self> { 286 - let tsk_dir = util::find_parent_with_dir(path, ".tsk")?.ok_or(Error::Uninitialized)?; 287 - let store = backend::store_for(&tsk_dir)?; 119 + let tsk_dir = util::find_parent_with_dir(path.clone(), ".tsk")?; 120 + let tsk_dir = match tsk_dir { 121 + Some(d) => d, 122 + None => { 123 + // Auto-bootstrap: if we're inside a git repo, behave as if 124 + // `tsk init` was run there. This keeps the `git tsk` UX 125 + // friction-free — users don't need an explicit init step. 126 + let git_dir = find_git_dir(&path).ok_or(Error::Uninitialized)?; 127 + let workdir = git_dir.parent().unwrap_or(&path).to_path_buf(); 128 + Self::init(workdir.clone())?; 129 + workdir.join(".tsk") 130 + } 131 + }; 132 + let git_dir = std::fs::read_to_string(tsk_dir.join(GIT_DIR_FILE))? 133 + .trim() 134 + .into(); 288 135 Ok(Self { 289 136 path: tsk_dir, 290 - store, 137 + git_dir, 291 138 }) 292 139 } 293 140 294 - pub fn store(&self) -> &dyn Store { 295 - self.store.as_ref() 296 - } 297 - 298 - pub fn is_git_backed(&self) -> bool { 299 - self.path.join(backend::GIT_BACKED_MARKER).exists() 141 + fn repo(&self) -> Result<Repository> { 142 + Ok(Repository::open(&self.git_dir)?) 300 143 } 301 144 302 - /// Name of the namespace this workspace is currently using. Always 303 - /// `"default"` for file-backed workspaces. 304 145 pub fn namespace(&self) -> String { 305 - backend::read_namespace(&self.path) 146 + std::fs::read_to_string(self.path.join(NAMESPACE_FILE)) 147 + .ok() 148 + .map(|s| s.trim().to_string()) 149 + .filter(|s| !s.is_empty()) 150 + .unwrap_or_else(|| namespace::DEFAULT_NS.to_string()) 306 151 } 307 152 308 - /// List the namespaces present in the underlying git repo. Errors for 309 - /// file-backed workspaces. 310 - pub fn list_namespaces(&self) -> Result<Vec<String>> { 311 - if !self.is_git_backed() { 312 - return Err(Error::Parse("Workspace is not git-backed".into())); 313 - } 314 - let marker = std::fs::read_to_string(self.path.join(backend::GIT_BACKED_MARKER))?; 315 - let store = backend::GitStore::open(PathBuf::from(marker.trim()))?; 316 - store.list_namespaces() 153 + pub fn queue(&self) -> String { 154 + std::fs::read_to_string(self.path.join(QUEUE_FILE)) 155 + .ok() 156 + .map(|s| s.trim().to_string()) 157 + .filter(|s| !s.is_empty()) 158 + .unwrap_or_else(|| queue::DEFAULT_QUEUE.to_string()) 317 159 } 318 160 319 - /// Switch the workspace to a different namespace by writing the namespace 320 - /// marker file. The namespace need not exist yet — the next mutation 321 - /// creates refs under it. 322 161 pub fn switch_namespace(&self, name: &str) -> Result<()> { 323 - if !self.is_git_backed() { 324 - return Err(Error::Parse("Workspace is not git-backed".into())); 325 - } 326 - validate_namespace(name)?; 327 - backend::write_namespace(&self.path, name) 162 + namespace::validate_name(name)?; 163 + std::fs::write(self.path.join(NAMESPACE_FILE), name.as_bytes())?; 164 + Ok(()) 328 165 } 329 166 330 - /// Delete every ref belonging to the given namespace. Errors if the 331 - /// namespace is the currently active one. Returns the number of refs 332 - /// deleted (caller can prompt before invoking if non-zero). 333 - pub fn delete_namespace(&self, name: &str) -> Result<usize> { 334 - if !self.is_git_backed() { 335 - return Err(Error::Parse("Workspace is not git-backed".into())); 336 - } 337 - if name == self.namespace() { 338 - return Err(Error::Parse( 339 - "Cannot delete the currently active namespace; switch first".into(), 340 - )); 341 - } 342 - let marker = std::fs::read_to_string(self.path.join(backend::GIT_BACKED_MARKER))?; 343 - let store = 344 - backend::GitStore::open_namespace(PathBuf::from(marker.trim()), name.to_string())?; 345 - store.delete_namespace_refs() 167 + pub fn switch_queue(&self, name: &str) -> Result<()> { 168 + queue::validate_name(name)?; 169 + std::fs::write(self.path.join(QUEUE_FILE), name.as_bytes())?; 170 + Ok(()) 171 + } 172 + 173 + pub fn list_namespaces(&self) -> Result<Vec<String>> { 174 + namespace::list_names(&self.repo()?) 175 + } 176 + 177 + pub fn list_queues(&self) -> Result<Vec<String>> { 178 + queue::list_names(&self.repo()?) 346 179 } 347 180 348 - /// Number of refs currently in the given namespace; useful for prompting 349 - /// before deletion. 350 - pub fn namespace_ref_count(&self, name: &str) -> Result<usize> { 351 - if !self.is_git_backed() { 352 - return Err(Error::Parse("Workspace is not git-backed".into())); 181 + pub fn create_queue(&self, name: &str, can_pull: Option<bool>) -> Result<()> { 182 + queue::validate_name(name)?; 183 + let repo = self.repo()?; 184 + let mut q = queue::read(&repo, name)?; 185 + if let Some(cp) = can_pull { 186 + q.can_pull = cp; 353 187 } 354 - let marker = std::fs::read_to_string(self.path.join(backend::GIT_BACKED_MARKER))?; 355 - let store = 356 - backend::GitStore::open_namespace(PathBuf::from(marker.trim()), name.to_string())?; 357 - store.namespace_ref_count() 188 + queue::write(&repo, name, &q, "create queue") 358 189 } 359 190 360 - fn resolve(&self, identifier: TaskIdentifier) -> Result<Id> { 191 + fn resolve(&self, identifier: TaskIdentifier) -> Result<(Id, StableId)> { 361 192 match identifier { 362 - TaskIdentifier::Id(id) => Ok(id), 193 + TaskIdentifier::Id(id) => { 194 + let stable = namespace::lookup(&self.repo()?, &self.namespace(), id.0)? 195 + .ok_or_else(|| Error::Parse(format!("Task {id} not found in namespace")))?; 196 + Ok((id, stable)) 197 + } 363 198 TaskIdentifier::Relative(r) => { 364 199 let stack = self.read_stack()?; 365 - let stack_item = stack.get(r as usize).ok_or(Error::NoTasks)?; 366 - Ok(stack_item.id) 200 + let entry = stack.into_iter().nth(r as usize).ok_or(Error::NoTasks)?; 201 + Ok((entry.id, entry.stable)) 367 202 } 368 - TaskIdentifier::Find { 369 - exclude_body, 370 - archived, 371 - } => self 372 - .search(None, !exclude_body, archived)? 373 - .ok_or(Error::NotSelected), 374 203 } 375 204 } 376 205 377 - pub fn next_id(&self) -> Result<Id> { 378 - backend::next_id(self.store()) 379 - } 380 - 381 206 pub fn new_task(&self, title: String, body: String) -> Result<Task> { 382 - let id = self.next_id()?; 383 - backend::write_task( 384 - self.store(), 385 - id, 386 - &title, 387 - &body, 388 - Loc::Active, 389 - "created", 390 - None, 391 - )?; 392 - self.log(id, "created", None)?; 207 + let repo = self.repo()?; 208 + let content = if body.is_empty() { 209 + title.trim().to_string() 210 + } else { 211 + format!("{}\n\n{}", title.trim(), body.trim()) 212 + }; 213 + let task_obj = TaskObj::new(content); 214 + let stable = object::create(&repo, &task_obj, "create")?; 215 + let human = namespace::assign_id(&repo, &self.namespace(), stable.clone(), "assign-id")?; 393 216 Ok(Task { 394 - id, 395 - title, 396 - body, 397 - attributes: Default::default(), 217 + id: Id(human), 218 + stable, 219 + title: task_obj.title().to_string(), 220 + body: task_obj.body().to_string(), 221 + attributes: BTreeMap::new(), 398 222 }) 399 223 } 400 224 401 - /// Per-task event log, oldest first. 402 - pub fn read_log(&self, id: Id) -> Result<Vec<backend::LogEntry>> { 403 - backend::read_log(self.store(), id) 404 - } 405 - 406 - /// Every event in this namespace, merged and sorted by timestamp ascending. 407 - pub fn read_namespace_log(&self) -> Result<Vec<backend::LogEntry>> { 408 - backend::read_all_logs(self.store()) 409 - } 410 - 411 - fn log(&self, id: Id, event: &str, detail: Option<&str>) -> Result<()> { 412 - let author = self.git_author().unwrap_or_default(); 413 - backend::append_log(self.store(), id, event, detail, &author) 414 - } 415 - 416 - /// `Name <email>` from the user's git config, if available. Falls back to 417 - /// just one of the two if only one is set, or `None` otherwise. 418 - pub fn git_author(&self) -> Option<String> { 419 - if !self.is_git_backed() { 420 - return None; 421 - } 422 - let marker = std::fs::read_to_string(self.path.join(backend::GIT_BACKED_MARKER)).ok()?; 423 - let repo = git2::Repository::open(PathBuf::from(marker.trim())).ok()?; 424 - let cfg = repo.config().ok()?.snapshot().ok()?; 425 - let name = cfg.get_string("user.name").ok(); 426 - let email = cfg.get_string("user.email").ok(); 427 - match (name, email) { 428 - (Some(n), Some(e)) => Some(format!("{n} <{e}>")), 429 - (Some(n), None) => Some(n), 430 - (None, Some(e)) => Some(e), 431 - (None, None) => None, 432 - } 433 - } 434 - 435 225 pub fn task(&self, identifier: TaskIdentifier) -> Result<Task> { 436 - let id = self.resolve(identifier)?; 437 - let (title, body, _loc) = backend::read_task(self.store(), id)? 438 - .ok_or_else(|| Error::Parse(format!("Task {id} not found")))?; 226 + let (id, stable) = self.resolve(identifier)?; 227 + let repo = self.repo()?; 228 + let task_obj = object::read(&repo, &stable)? 229 + .ok_or_else(|| Error::Parse(format!("Task {id} content missing")))?; 439 230 Ok(Task { 440 231 id, 441 - title, 442 - body, 443 - attributes: backend::read_attrs(self.store(), id)?, 232 + stable, 233 + title: task_obj.title().to_string(), 234 + body: task_obj.body().to_string(), 235 + attributes: task_obj.properties, 444 236 }) 445 237 } 446 238 447 239 pub fn save_task(&self, task: &Task) -> Result<()> { 448 - let loc = match backend::task_location(self.store(), task.id)? { 449 - Some(l) => l, 450 - None => Loc::Active, 451 - }; 452 - backend::write_task( 453 - self.store(), 454 - task.id, 455 - &task.title, 456 - &task.body, 457 - loc, 458 - "edited", 459 - None, 460 - )?; 461 - backend::write_attrs(self.store(), task.id, &task.attributes, "edited", None)?; 462 - self.log(task.id, "edited", None)?; 463 - // After editing, refresh stack title for this id. 464 - self.update_stack_title(task.id, &task.title)?; 465 - Ok(()) 466 - } 467 - 468 - fn update_stack_title(&self, id: Id, title: &str) -> Result<()> { 469 - let mut stack = self.read_stack()?; 470 - let mut changed = false; 471 - for item in stack.all.iter_mut() { 472 - if item.id == id { 473 - item.title = title.replace('\t', " "); 474 - changed = true; 475 - } 476 - } 477 - if changed { 478 - stack.save(self.store())?; 479 - } 480 - Ok(()) 481 - } 482 - 483 - /// Set a single property (a.k.a attribute) on a task. Empty value is 484 - /// allowed for unary properties. 485 - /// 486 - /// Side effect: when `key == "parent"` and the value parses as an 487 - /// internal link `[[tsk-N]]`, the inverse entry is added to the parent's 488 - /// `children` property (comma-separated link list). A previous parent 489 - /// (if any) has the child removed from its `children`. Self-parents and 490 - /// cycles are rejected. 491 - pub fn set_property(&self, id: Id, key: &str, value: &str) -> Result<()> { 492 - let old_value = backend::read_attrs(self.store(), id)?.get(key).cloned(); 493 - if let Some(pair) = inverse_pair_for(key) { 494 - let old_target = old_value.as_deref().and_then(parse_internal_link); 495 - let new_target = parse_internal_link(value); 496 - if let Some(t) = new_target { 497 - if t == id { 498 - return Err(Error::Parse(format!( 499 - "Refusing to set {key}={t}: a task cannot {} itself", 500 - pair.action 501 - ))); 502 - } 503 - if pair.cycle_check && self.would_form_chain_cycle(id, t, pair.forward)? { 504 - return Err(Error::Parse(format!( 505 - "Refusing to set {key}={t}: would form a cycle" 506 - ))); 507 - } 508 - } 509 - let mut attrs = backend::read_attrs(self.store(), id)?; 510 - attrs.insert(key.to_string(), value.to_string()); 511 - backend::write_attrs(self.store(), id, &attrs, "prop-set", Some(key))?; 512 - self.log(id, "prop-set", Some(key))?; 513 - if old_target != new_target { 514 - if let Some(t) = old_target { 515 - self.update_inverse_list(t, id, pair.inverse, /* add */ false)?; 516 - } 517 - if let Some(t) = new_target { 518 - self.update_inverse_list(t, id, pair.inverse, /* add */ true)?; 519 - } 520 - } 521 - Ok(()) 522 - } else { 523 - let mut attrs = backend::read_attrs(self.store(), id)?; 524 - attrs.insert(key.to_string(), value.to_string()); 525 - backend::write_attrs(self.store(), id, &attrs, "prop-set", Some(key))?; 526 - self.log(id, "prop-set", Some(key)) 527 - } 528 - } 529 - 530 - /// Remove a property from a task. No-op if not present. 531 - pub fn unset_property(&self, id: Id, key: &str) -> Result<()> { 532 - let mut attrs = backend::read_attrs(self.store(), id)?; 533 - let removed = attrs.remove(key); 534 - if let Some(prev) = removed { 535 - backend::write_attrs(self.store(), id, &attrs, "prop-unset", Some(key))?; 536 - self.log(id, "prop-unset", Some(key))?; 537 - if let Some(pair) = inverse_pair_for(key) 538 - && let Some(t) = parse_internal_link(&prev) 539 - { 540 - self.update_inverse_list(t, id, pair.inverse, /* add */ false)?; 541 - } 542 - } 543 - Ok(()) 544 - } 545 - 546 - /// Walk up `start`'s `forward_key` chain and return true if `subject` 547 - /// would appear in it (i.e. setting `subject.<forward_key> = start` would 548 - /// cycle). 549 - fn would_form_chain_cycle(&self, subject: Id, start: Id, forward_key: &str) -> Result<bool> { 550 - let mut cur = Some(start); 551 - let mut visited: HashSet<Id> = HashSet::new(); 552 - while let Some(c) = cur { 553 - if c == subject { 554 - return Ok(true); 555 - } 556 - if !visited.insert(c) { 557 - // Pre-existing cycle upstream; not our problem to enforce. 558 - return Ok(false); 559 - } 560 - cur = backend::read_attrs(self.store(), c)? 561 - .get(forward_key) 562 - .and_then(|v| parse_internal_link(v)); 563 - } 564 - Ok(false) 565 - } 566 - 567 - /// Add or remove `subject` in the `inverse_key` link list on `target`. 568 - fn update_inverse_list( 569 - &self, 570 - target: Id, 571 - subject: Id, 572 - inverse_key: &str, 573 - add: bool, 574 - ) -> Result<()> { 575 - let mut attrs = backend::read_attrs(self.store(), target)?; 576 - let mut ids = parse_link_list(attrs.get(inverse_key).map(String::as_str).unwrap_or("")); 577 - let before = ids.len(); 578 - if add { 579 - if !ids.contains(&subject) { 580 - ids.push(subject); 581 - } 240 + let repo = self.repo()?; 241 + let content = if task.body.is_empty() { 242 + task.title.trim().to_string() 582 243 } else { 583 - ids.retain(|i| *i != subject); 584 - } 585 - if ids.len() == before && add { 586 - return Ok(()); 587 - } 588 - if ids.len() == before && !add { 589 - return Ok(()); 590 - } 591 - if ids.is_empty() { 592 - attrs.remove(inverse_key); 593 - } else { 594 - attrs.insert(inverse_key.to_string(), format_link_list(&ids)); 595 - } 596 - backend::write_attrs(self.store(), target, &attrs, "prop-set", Some(inverse_key))?; 597 - self.log(target, "prop-set", Some(inverse_key))?; 598 - Ok(()) 599 - } 600 - 601 - /// All properties on a task, both stored and synthetic (state, has-links, 602 - /// references, referenced-by). 603 - pub fn properties(&self, id: Id) -> Result<BTreeMap<String, String>> { 604 - let mut props = backend::read_attrs(self.store(), id)?; 605 - let synth = self.synthetic_properties(id)?; 606 - for (k, v) in synth { 607 - props.entry(k).or_insert(v); 608 - } 609 - Ok(props) 610 - } 611 - 612 - fn synthetic_properties(&self, id: Id) -> Result<BTreeMap<String, String>> { 613 - let mut out = BTreeMap::new(); 614 - let Some((_, body, loc)) = backend::read_task(self.store(), id)? else { 615 - return Ok(out); 244 + format!("{}\n\n{}", task.title.trim(), task.body.trim()) 616 245 }; 617 - out.insert( 618 - "state".into(), 619 - match loc { 620 - Loc::Active => "open".into(), 621 - Loc::Archived => "archived".into(), 622 - }, 623 - ); 624 - let parsed = parse_task(&format!("\n\n{body}")); 625 - let refs: Vec<String> = parsed 626 - .as_ref() 627 - .map(|p| { 628 - p.intenal_links() 629 - .iter() 630 - .map(|i| format!("[[{i}]]")) 631 - .collect() 632 - }) 633 - .unwrap_or_default(); 634 - out.insert( 635 - "has-links".into(), 636 - if refs.is_empty() { "false" } else { "true" }.into(), 637 - ); 638 - if !refs.is_empty() { 639 - out.insert("references".into(), refs.join(",")); 640 - } 641 - let backrefs = backend::read_backlinks(self.store(), id)?; 642 - if !backrefs.is_empty() { 643 - let joined: Vec<String> = backrefs.iter().map(|i| format!("[[{i}]]")).collect(); 644 - out.insert("referenced-by".into(), joined.join(",")); 645 - } 646 - Ok(out) 647 - } 648 - 649 - /// Every property key that has ever been set on any task in this 650 - /// namespace, sorted alphabetically. 651 - pub fn all_property_keys(&self) -> Result<Vec<String>> { 652 - let mut seen: std::collections::BTreeSet<String> = Default::default(); 653 - let mut ids: Vec<Id> = backend::list_active(self.store())?; 654 - ids.extend(backend::list_archive(self.store())?); 655 - for id in ids { 656 - for k in backend::read_attrs(self.store(), id)?.into_keys() { 657 - seen.insert(k); 658 - } 659 - } 660 - Ok(seen.into_iter().collect()) 246 + let task_obj = TaskObj { 247 + content, 248 + properties: task.attributes.clone(), 249 + }; 250 + object::update(&repo, &task.stable, &task_obj, "edit") 661 251 } 662 252 663 - /// Every distinct value seen for a given property `key` across the 664 - /// workspace, sorted alphabetically. 665 - pub fn property_values_for(&self, key: &str) -> Result<Vec<String>> { 666 - let mut seen: std::collections::BTreeSet<String> = Default::default(); 667 - let mut ids: Vec<Id> = backend::list_active(self.store())?; 668 - ids.extend(backend::list_archive(self.store())?); 669 - for id in ids { 670 - if let Some(v) = backend::read_attrs(self.store(), id)?.get(key) { 671 - seen.insert(v.clone()); 672 - } 673 - } 674 - Ok(seen.into_iter().collect()) 253 + pub fn push_task(&self, task: Task) -> Result<()> { 254 + queue::push_top(&self.repo()?, &self.queue(), task.stable, "push") 675 255 } 676 256 677 - /// Candidate values pulled from a task's body: every link the parser 678 - /// found, rendered as `[[tsk-N]]` / `[[ns-N]]` / URL strings. 679 - pub fn body_candidates(&self, id: Id) -> Result<Vec<String>> { 680 - let task = self.task(TaskIdentifier::Id(id))?; 681 - let Some(parsed) = parse_task(&task.to_string()) else { 682 - return Ok(Vec::new()); 683 - }; 684 - Ok(parsed 685 - .links 686 - .iter() 687 - .map(|l| match l { 688 - crate::task::ParsedLink::External(u) => u.to_string(), 689 - crate::task::ParsedLink::Internal(i) => format!("[[{i}]]"), 690 - crate::task::ParsedLink::Foreign { prefix, id } => format!("[[{prefix}-{id}]]"), 691 - crate::task::ParsedLink::Namespaced { namespace, id } => { 692 - format!("[[{namespace}/{id}]]") 693 - } 694 - }) 695 - .collect()) 257 + pub fn append_task(&self, task: Task) -> Result<()> { 258 + queue::push_bottom(&self.repo()?, &self.queue(), task.stable, "append") 696 259 } 697 260 698 - /// Find every task whose property `key` is set (and equals `value`, if 699 - /// provided). Scans both active and archived. Includes synthetic 700 - /// properties so `state=archived`, `has-links=true`, etc. work. 701 - pub fn find_by_property(&self, key: &str, value: Option<&str>) -> Result<Vec<Id>> { 702 - let mut ids: Vec<Id> = backend::list_active(self.store())?; 703 - ids.extend(backend::list_archive(self.store())?); 704 - ids.sort_by_key(|i| i.0); 705 - ids.dedup(); 706 - Ok(ids 707 - .into_iter() 708 - .filter_map(|id| { 709 - let props = self.properties(id).ok()?; 710 - let v = props.get(key)?; 711 - if value.is_none_or(|target| v == target) { 712 - Some(id) 713 - } else { 714 - None 715 - } 716 - }) 717 - .collect()) 718 - } 719 - 720 - pub fn handle_metadata(&self, tsk: &Task, pre_links: Option<HashSet<Id>>) -> Result<()> { 721 - if let Some(parsed_task) = parse_task(&tsk.to_string()) { 722 - let internal_links = parsed_task.intenal_links(); 723 - let added: HashSet<Id> = match &pre_links { 724 - Some(pre) => internal_links.difference(pre).copied().collect(), 725 - None => internal_links.clone(), 261 + pub fn read_stack(&self) -> Result<Vec<StackEntry>> { 262 + let repo = self.repo()?; 263 + let q = queue::read(&repo, &self.queue())?; 264 + let ns_name = self.namespace(); 265 + let ns = namespace::read(&repo, &ns_name)?; 266 + let mut by_stable: BTreeMap<&StableId, u32> = BTreeMap::new(); 267 + for (h, s) in &ns.mapping { 268 + by_stable.insert(s, *h); 269 + } 270 + let mut out = Vec::with_capacity(q.index.len()); 271 + for stable in q.index { 272 + // Skip tasks not visible in the active namespace (different ns owns them). 273 + let Some(&human) = by_stable.get(&stable) else { 274 + continue; 726 275 }; 727 - for link in &added { 728 - self.add_backlink(*link, tsk.id)?; 729 - } 730 - let mut removed_count = 0; 731 - if let Some(pre_links) = pre_links { 732 - for link in pre_links.difference(&internal_links) { 733 - self.remove_backlink(*link, tsk.id)?; 734 - removed_count += 1; 735 - } 736 - } 737 - if !added.is_empty() || removed_count > 0 { 738 - self.log(tsk.id, "links-changed", None)?; 739 - } 276 + let title = object::read(&repo, &stable)? 277 + .map(|t| t.title().to_string()) 278 + .unwrap_or_default(); 279 + out.push(StackEntry { 280 + id: Id(human), 281 + stable, 282 + title, 283 + }); 740 284 } 741 - Ok(()) 285 + Ok(out) 742 286 } 743 287 744 - fn add_backlink(&self, to: Id, from: Id) -> Result<()> { 745 - let mut links = backend::read_backlinks(self.store(), to)?; 746 - links.insert(from); 747 - backend::write_backlinks(self.store(), to, &links) 748 - } 749 - 750 - fn remove_backlink(&self, to: Id, from: Id) -> Result<()> { 751 - let mut links = backend::read_backlinks(self.store(), to)?; 752 - links.remove(&from); 753 - backend::write_backlinks(self.store(), to, &links) 288 + /// Drop a task from the active queue and unbind its human id in the 289 + /// active namespace. The task object's commit history at 290 + /// `refs/tsk/tasks/<stable>` is preserved. 291 + pub fn drop(&self, identifier: TaskIdentifier) -> Result<Option<Id>> { 292 + let (id, stable) = self.resolve(identifier)?; 293 + let repo = self.repo()?; 294 + queue::remove(&repo, &self.queue(), &stable, "drop")?; 295 + namespace::unassign_id(&repo, &self.namespace(), id.0, "drop")?; 296 + Ok(Some(id)) 754 297 } 755 298 756 - pub fn read_stack(&self) -> Result<TaskStack> { 757 - TaskStack::load(self.store()) 758 - } 759 - 760 - /// Run `f` on the workspace stack and persist the result. 761 - fn mutate_stack<F: FnOnce(&mut TaskStack)>(&self, f: F) -> Result<()> { 762 - let mut stack = self.read_stack()?; 763 - f(&mut stack); 764 - stack.save(self.store()) 765 - } 766 - 767 - pub fn push_task(&self, task: Task) -> Result<()> { 768 - self.mutate_stack(|s| s.push((&task).into())) 769 - } 770 - 771 - pub fn append_task(&self, task: Task) -> Result<()> { 772 - self.mutate_stack(|s| s.push_back((&task).into())) 299 + fn mutate_index<F: FnOnce(&mut Vec<StableId>)>(&self, f: F, msg: &str) -> Result<()> { 300 + let repo = self.repo()?; 301 + let mut q = queue::read(&repo, &self.queue())?; 302 + f(&mut q.index); 303 + queue::write(&repo, &self.queue(), &q, msg) 773 304 } 774 305 775 306 pub fn swap_top(&self) -> Result<()> { 776 - self.mutate_stack(|s| s.swap()) 307 + self.mutate_index( 308 + |idx| { 309 + if idx.len() >= 2 { 310 + idx.swap(0, 1); 311 + } 312 + }, 313 + "swap", 314 + ) 777 315 } 778 316 779 - fn rotate_top3(&self, swap_third_with_top: bool) -> Result<()> { 780 - self.mutate_stack(|stack| { 781 - if let (Some(a), Some(b), Some(c)) = (stack.pop(), stack.pop(), stack.pop()) { 782 - if swap_third_with_top { 783 - stack.push(b); 784 - stack.push(a); 785 - stack.push(c); 786 - } else { 787 - stack.push(a); 788 - stack.push(c); 789 - stack.push(b); 317 + fn rotate_top3(&self, third_to_top: bool) -> Result<()> { 318 + self.mutate_index( 319 + |idx| { 320 + if idx.len() >= 3 { 321 + if third_to_top { 322 + let c = idx.remove(2); 323 + idx.insert(0, c); 324 + } else { 325 + let a = idx.remove(0); 326 + idx.insert(2, a); 327 + } 790 328 } 791 - } 792 - }) 329 + }, 330 + "rotate", 331 + ) 793 332 } 794 333 795 334 pub fn rot(&self) -> Result<()> { 796 335 self.rotate_top3(true) 797 336 } 337 + 798 338 pub fn tor(&self) -> Result<()> { 799 339 self.rotate_top3(false) 800 340 } 801 341 802 - pub fn drop(&self, identifier: TaskIdentifier) -> Result<Option<Id>> { 803 - let id = self.resolve(identifier)?; 804 - let mut stack = self.read_stack()?; 805 - let removed = if let Some(idx) = stack.position(id) { 806 - let item = stack.remove(idx); 807 - stack.save(self.store())?; 808 - item.map(|t| t.id) 809 - } else { 810 - None 811 - }; 812 - // Move the task content to the archive bucket. 813 - if backend::task_location(self.store(), id)? == Some(Loc::Active) { 814 - backend::move_task(self.store(), id, Loc::Archived)?; 815 - } 816 - self.log(id, "archived", None)?; 817 - Ok(removed) 818 - } 819 - 820 - pub fn search( 821 - &self, 822 - stack: Option<TaskStack>, 823 - search_body: bool, 824 - include_archived: bool, 825 - ) -> Result<Option<Id>> { 826 - const BODY_ARGS: &[&str] = &[ 827 - "--no-multi-line", 828 - "--accept-nth=1", 829 - "--delimiter=\t", 830 - "--preview=tsk show -T {1}", 831 - "--preview-window=top", 832 - "--ansi", 833 - "--info-command=tsk show -T {1} | head -n1", 834 - "--info=inline-right", 835 - ]; 836 - const ID_ARGS: &[&str] = &["--delimiter=\t", "--accept-nth=1"]; 837 - let args = if search_body { BODY_ARGS } else { ID_ARGS }; 838 - let stack = stack.map_or_else(|| self.read_stack(), Ok)?; 839 - if include_archived { 840 - let mut seen: HashSet<Id> = HashSet::new(); 841 - let mut all: Vec<SearchTask> = stack 842 - .iter() 843 - .filter_map(|item| self.task(TaskIdentifier::Id(item.id)).ok().map(Task::bare)) 844 - .inspect(|t| { 845 - seen.insert(t.id); 846 - }) 847 - .collect(); 848 - for id in backend::list_archive(self.store())? { 849 - if !seen.contains(&id) 850 - && let Some((title, body, _)) = backend::read_task(self.store(), id)? 851 - { 852 - all.push(SearchTask { id, title, body }); 853 - } 854 - } 855 - fzf::select::<_, Id, _>(all, args) 856 - } else if search_body { 857 - fzf::select::<_, Id, _>( 858 - stack 859 - .into_iter() 860 - .filter_map(|item| self.task(TaskIdentifier::Id(item.id)).ok().map(Task::bare)), 861 - args, 862 - ) 863 - } else { 864 - fzf::select::<_, Id, _>(stack, args) 865 - } 866 - } 867 - 868 - fn move_in_stack(&self, identifier: TaskIdentifier, to_front: bool) -> Result<()> { 869 - let id = self.resolve(identifier)?; 870 - self.mutate_stack(|stack| { 871 - if let Some(idx) = stack.position(id) 872 - && let Some(item) = stack.remove(idx) 873 - { 342 + fn move_in_index(&self, identifier: TaskIdentifier, to_front: bool) -> Result<()> { 343 + let (_, stable) = self.resolve(identifier)?; 344 + self.mutate_index( 345 + |idx| { 346 + idx.retain(|s| s != &stable); 874 347 if to_front { 875 - stack.push(item) 348 + idx.insert(0, stable); 876 349 } else { 877 - stack.push_back(item) 350 + idx.push(stable); 878 351 } 879 - } 880 - }) 352 + }, 353 + if to_front { "prioritize" } else { "deprioritize" }, 354 + ) 881 355 } 882 356 883 357 pub fn prioritize(&self, identifier: TaskIdentifier) -> Result<()> { 884 - self.move_in_stack(identifier, true) 358 + self.move_in_index(identifier, true) 885 359 } 886 360 887 361 pub fn deprioritize(&self, identifier: TaskIdentifier) -> Result<()> { 888 - self.move_in_stack(identifier, false) 362 + self.move_in_index(identifier, false) 889 363 } 890 364 891 - /// Remove "active" task entries that aren't in the index. 365 + /// Drop entries from the active queue's index whose stable ids no longer 366 + /// resolve to a task object. 892 367 pub fn clean(&self) -> Result<()> { 893 - let stack = self.read_stack()?; 894 - let indexed: HashSet<Id> = stack.iter().map(|i| i.id).collect(); 895 - for id in backend::list_active(self.store())? { 896 - if !indexed.contains(&id) { 897 - // Move orphan to archive rather than delete, to avoid data loss. 898 - backend::move_task(self.store(), id, Loc::Archived)?; 899 - eprintln!("Removed orphaned task: {id}"); 900 - } 901 - } 902 - Ok(()) 903 - } 904 - 905 - pub fn read_remotes(&self) -> Result<Vec<Remote>> { 906 - backend::read_remotes(self.store()) 907 - } 908 - 909 - pub fn add_remote(&self, prefix: &str, path: &str) -> Result<()> { 910 - let mut remotes = self.read_remotes()?; 911 - if remotes.iter().any(|r| r.prefix == prefix) { 912 - return Err(Error::Parse(format!("Remote '{prefix}' already exists"))); 913 - } 914 - remotes.push(Remote { 915 - prefix: prefix.to_string(), 916 - path: PathBuf::from(path), 368 + let repo = self.repo()?; 369 + let mut q = queue::read(&repo, &self.queue())?; 370 + let before = q.index.len(); 371 + q.index.retain(|s| { 372 + repo.find_reference(&s.refname()) 373 + .ok() 374 + .and_then(|r| r.target()) 375 + .is_some() 917 376 }); 918 - backend::write_remotes(self.store(), &remotes) 919 - } 920 - 921 - pub fn remove_remote(&self, prefix: &str) -> Result<()> { 922 - let remotes = self.read_remotes()?; 923 - let len = remotes.len(); 924 - let new_remotes: Vec<Remote> = remotes.into_iter().filter(|r| r.prefix != prefix).collect(); 925 - if new_remotes.len() == len { 926 - return Err(Error::Parse(format!("Remote '{prefix}' not found"))); 377 + if q.index.len() != before { 378 + queue::write(&repo, &self.queue(), &q, "clean")?; 927 379 } 928 - backend::write_remotes(self.store(), &new_remotes) 929 - } 930 - 931 - pub fn resolve_foreign_link(&self, prefix: &str, id: u32) -> Result<Option<Task>> { 932 - let remotes = self.read_remotes()?; 933 - let remote = remotes 934 - .iter() 935 - .find(|r| r.prefix == prefix) 936 - .ok_or_else(|| Error::Parse(format!("Unknown remote prefix: {prefix}")))?; 937 - let workspace = Workspace::from_path(remote.path.clone())?; 938 - let task = workspace.task(TaskIdentifier::Id(Id(id)))?; 939 - Ok(Some(task)) 380 + Ok(()) 940 381 } 941 382 942 - /// Resolve a `[[<namespace>/tsk-N]]` link by reading the task in a sibling 943 - /// namespace of the same git repo. Returns `Ok(None)` if the task isn't 944 - /// found, or an error if the workspace isn't git-backed. 945 - pub fn resolve_namespaced_link(&self, namespace: &str, id: Id) -> Result<Option<Task>> { 946 - if !self.is_git_backed() { 383 + /// Share a task into another namespace by binding the same stable id to 384 + /// the next human id in `target_ns`. 385 + pub fn share(&self, identifier: TaskIdentifier, target_ns: &str) -> Result<u32> { 386 + let cur = self.namespace(); 387 + if target_ns == cur { 947 388 return Err(Error::Parse( 948 - "Cross-namespace links only work on git-backed workspaces".into(), 389 + "Refusing to share a task into its own namespace".into(), 949 390 )); 950 391 } 951 - let marker = std::fs::read_to_string(self.path.join(backend::GIT_BACKED_MARKER))?; 952 - let store = 953 - backend::GitStore::open_namespace(PathBuf::from(marker.trim()), namespace.to_string())?; 954 - let Some((title, body, _)) = backend::read_task(&store, id)? else { 955 - return Ok(None); 956 - }; 957 - Ok(Some(Task { 958 - id, 959 - title, 960 - body, 961 - attributes: backend::read_attrs(&store, id)?, 962 - })) 963 - } 964 - 965 - fn require_git_dir(&self) -> Result<PathBuf> { 966 - if !self.is_git_backed() { 967 - return Err(Error::Parse("Workspace is not git-backed".into())); 968 - } 969 - let marker = std::fs::read_to_string(self.path.join(backend::GIT_BACKED_MARKER))?; 970 - Ok(PathBuf::from(marker.trim())) 971 - } 972 - 973 - fn git_cmd(&self) -> Result<std::process::Command> { 974 - let mut c = std::process::Command::new("git"); 975 - c.arg("--git-dir").arg(self.require_git_dir()?); 976 - Ok(c) 977 - } 978 - 979 - fn run_git(&self, args: &[&str]) -> Result<()> { 980 - let status = self.git_cmd()?.args(args).status()?; 981 - if !status.success() { 982 - return Err(Error::Parse(format!("git {args:?} exited with {status}"))); 983 - } 984 - Ok(()) 985 - } 986 - 987 - /// Push every refs/tsk/* ref to the given remote, using the per-ref 988 - /// `--force-with-lease=<ref>:<expected>` so a concurrent push on the 989 - /// remote causes our push to fail rather than silently overwrite. 990 - /// The expected OID is taken from the local 991 - /// `refs/remotes-tsk/<remote>/*` shadow, which is refreshed first. 992 - /// After a successful push, the shadow is updated to match the new state. 993 - pub fn git_push_refs(&self, remote: &str) -> Result<()> { 994 - let _ = self.require_git_dir()?; 995 - // Refresh the shadow so leases match the remote's current state. 996 - let _ = self 997 - .git_cmd()? 998 - .args([ 999 - "fetch", 1000 - remote, 1001 - &format!("+refs/tsk/*:refs/remotes-tsk/{remote}/*"), 1002 - ]) 1003 - .status()?; 1004 - let shadow: BTreeMap<String, git2::Oid> = self.read_shadow(remote)?.into_iter().collect(); 1005 - 1006 - let repo = git2::Repository::open(self.require_git_dir()?)?; 1007 - let mut leases: Vec<String> = Vec::new(); 1008 - let mut refspecs: Vec<String> = Vec::new(); 1009 - let mut local_rests: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); 1010 - for r in repo.references()? { 1011 - let r = r?; 1012 - let Some(name) = r.name() else { continue }; 1013 - let Some(rest) = name.strip_prefix("refs/tsk/") else { 1014 - continue; 1015 - }; 1016 - let Some(local_oid) = r.target() else { 1017 - continue; 1018 - }; 1019 - local_rests.insert(rest.to_string()); 1020 - if shadow.get(rest) == Some(&local_oid) { 1021 - continue; // up to date 1022 - } 1023 - if let Some(expected) = shadow.get(rest) { 1024 - leases.push(format!("--force-with-lease=refs/tsk/{rest}:{expected}")); 1025 - } 1026 - refspecs.push(format!("refs/tsk/{rest}:refs/tsk/{rest}")); 1027 - } 1028 - // Refs that exist on the remote (per shadow) but no longer locally: 1029 - // push as deletions so the remote stays in sync with local removals 1030 - // (e.g. `tsk reject` / `tsk accept` consuming an inbox blob). 1031 - for (rest, expected) in &shadow { 1032 - if !local_rests.contains(rest) { 1033 - leases.push(format!("--force-with-lease=refs/tsk/{rest}:{expected}")); 1034 - refspecs.push(format!(":refs/tsk/{rest}")); 1035 - } 1036 - } 1037 - if refspecs.is_empty() { 1038 - return Ok(()); 1039 - } 1040 - let mut args: Vec<String> = vec!["push".to_string(), remote.to_string()]; 1041 - args.extend(leases); 1042 - args.extend(refspecs); 1043 - let argv: Vec<&str> = args.iter().map(String::as_str).collect(); 1044 - self.run_git(&argv)?; 1045 - self.update_remote_shadow(remote)?; 1046 - Ok(()) 1047 - } 1048 - 1049 - /// Reconcile every refs/tsk/* ref with the remote. Fetch lands in 1050 - /// `refs/remotes-tsk/<remote>/*` (force, since it's our private mirror); 1051 - /// then for each ref we look at three OIDs — local, the previous 1052 - /// fetched-from-remote (the merge base), and the new remote — and pick: 1053 - /// 1054 - /// - local untouched since last sync → take remote 1055 - /// - remote untouched since last sync → keep local 1056 - /// - both moved, ref is union-mergeable (`log/*`, `index`) → 3-way merge 1057 - /// - both moved, ref is not union-mergeable → conflict; abort with a 1058 - /// list of the offending refs. The fetch shadow is updated either way 1059 - /// so a re-run after manual resolution sees the right base. 1060 - pub fn git_pull_refs(&self, remote: &str) -> Result<()> { 1061 - let _ = self.require_git_dir()?; 1062 - // Snapshot pre-fetch shadow so we know the previous remote position. 1063 - let pre_fetch: BTreeMap<String, git2::Oid> = 1064 - self.read_shadow(remote)?.into_iter().collect(); 1065 - // Fetch (force, into our private shadow only). --prune so that refs 1066 - // deleted on the remote are removed from the shadow too — otherwise 1067 - // a stale shadow entry will be re-applied to the local ref below. 1068 - self.run_git(&[ 1069 - "fetch", 1070 - "--prune", 1071 - remote, 1072 - &format!("+refs/tsk/*:refs/remotes-tsk/{remote}/*"), 1073 - ])?; 1074 - let post_fetch: BTreeMap<String, git2::Oid> = 1075 - self.read_shadow(remote)?.into_iter().collect(); 1076 - 1077 - let repo = git2::Repository::open(self.require_git_dir()?)?; 1078 - let mut conflicts: Vec<String> = Vec::new(); 1079 - // Refs marked "handled" by the rebase pass below — the per-ref 1080 - // reconcile skips these because they don't represent a real conflict. 1081 - let mut rebased_handled: HashSet<String> = HashSet::new(); 1082 - // First pass: detect id collisions in our current namespace and 1083 - // resolve them by renumbering the loser locally. 1084 - let renames = self.detect_id_collisions(&repo, &post_fetch)?; 1085 - for r in &renames { 1086 - self.apply_renumber(&repo, r, &post_fetch, &mut rebased_handled)?; 1087 - } 1088 - for (rel, &new_remote) in &post_fetch { 1089 - if rebased_handled.contains(rel) { 1090 - continue; 1091 - } 1092 - let local_refname = format!("refs/tsk/{rel}"); 1093 - let local_oid = repo 1094 - .find_reference(&local_refname) 1095 - .ok() 1096 - .and_then(|r| r.target()); 1097 - let old_remote = pre_fetch.get(rel).copied(); 1098 - match resolve_pull(local_oid, old_remote, new_remote, rel) { 1099 - PullAction::Skip => {} 1100 - PullAction::Take => { 1101 - repo.reference(&local_refname, new_remote, true, "tsk pull")?; 1102 - } 1103 - PullAction::Merge => { 1104 - let merged = self.merge_blob(&repo, rel, local_oid, new_remote)?; 1105 - repo.reference(&local_refname, merged, true, "tsk pull merge")?; 1106 - } 1107 - PullAction::Conflict => conflicts.push(rel.clone()), 1108 - } 1109 - } 1110 - // Apply remote deletions: refs that were in pre_fetch but vanished 1111 - // post_fetch (remote dropped them, e.g. via inbox accept/reject on 1112 - // another clone). If the local ref hasn't moved since the last sync 1113 - // we delete it; if it diverged, treat as a conflict. 1114 - for (rel, &old_remote) in &pre_fetch { 1115 - if post_fetch.contains_key(rel) { 1116 - continue; 1117 - } 1118 - if rebased_handled.contains(rel) { 1119 - continue; 1120 - } 1121 - let local_refname = format!("refs/tsk/{rel}"); 1122 - let local_oid = repo 1123 - .find_reference(&local_refname) 1124 - .ok() 1125 - .and_then(|r| r.target()); 1126 - match local_oid { 1127 - None => {} // already gone 1128 - Some(l) if l == old_remote => { 1129 - if let Ok(mut r) = repo.find_reference(&local_refname) { 1130 - r.delete()?; 1131 - } 1132 - } 1133 - Some(_) => conflicts.push(rel.clone()), 1134 - } 1135 - } 1136 - if !conflicts.is_empty() { 1137 - return Err(Error::Parse(format!( 1138 - "pull conflicts on: {}\n(local and remote both diverged from the last sync; \ 1139 - these refs aren't auto-mergeable. Resolve manually with `git update-ref` \ 1140 - or by editing the corresponding tsk objects.)", 1141 - conflicts.join(", ") 1142 - ))); 1143 - } 1144 - Ok(()) 1145 - } 1146 - 1147 - /// Read every `refs/remotes-tsk/<remote>/*` and return `(rel, oid)` where 1148 - /// `rel` is the path under that prefix (matches the local-side `rel` used 1149 - /// against `refs/tsk/`). 1150 - fn read_shadow(&self, remote: &str) -> Result<Vec<(String, git2::Oid)>> { 1151 - let repo = git2::Repository::open(self.require_git_dir()?)?; 1152 - let prefix = format!("refs/remotes-tsk/{remote}/"); 1153 - let mut out = Vec::new(); 1154 - for r in repo.references()? { 1155 - let r = r?; 1156 - if let Some(name) = r.name() 1157 - && let Some(rest) = name.strip_prefix(&prefix) 1158 - && let Some(oid) = r.target() 1159 - { 1160 - out.push((rest.to_string(), oid)); 1161 - } 1162 - } 1163 - Ok(out) 392 + namespace::validate_name(target_ns)?; 393 + let (_, stable) = self.resolve(identifier)?; 394 + let repo = self.repo()?; 395 + namespace::assign_id(&repo, target_ns, stable, "share") 1164 396 } 1165 397 1166 - /// After a successful push, copy current local `refs/tsk/*` OIDs into 1167 - /// `refs/remotes-tsk/<remote>/*` so the next pull's merge base is correct. 1168 - fn update_remote_shadow(&self, remote: &str) -> Result<()> { 1169 - let repo = git2::Repository::open(self.require_git_dir()?)?; 1170 - let prefix = "refs/tsk/"; 1171 - let dest_prefix = format!("refs/remotes-tsk/{remote}/"); 1172 - let updates: Vec<(String, git2::Oid)> = repo 1173 - .references()? 1174 - .filter_map(|r| { 1175 - let r = r.ok()?; 1176 - let name = r.name()?.to_string(); 1177 - let oid = r.target()?; 1178 - let rest = name.strip_prefix(prefix)?.to_string(); 1179 - Some((rest, oid)) 1180 - }) 1181 - .collect(); 1182 - let local_rests: std::collections::BTreeSet<String> = 1183 - updates.iter().map(|(r, _)| r.clone()).collect(); 1184 - for (rest, oid) in updates { 1185 - repo.reference( 1186 - &format!("{dest_prefix}{rest}"), 1187 - oid, 1188 - true, 1189 - "tsk push shadow", 1190 - )?; 398 + /// Move a task from the active queue's index into `target_queue`'s inbox. 399 + pub fn assign_to_queue( 400 + &self, 401 + identifier: TaskIdentifier, 402 + target_queue: &str, 403 + ) -> Result<String> { 404 + let cur = self.queue(); 405 + if target_queue == cur { 406 + return Err(Error::Parse( 407 + "Refusing to assign a task to its own queue".into(), 408 + )); 1191 409 } 1192 - // Prune shadow entries for refs that no longer exist locally — after a 1193 - // successful push the remote also dropped them, so the shadow must too 1194 - // or a future pull will see them as "remote still has it" and recreate 1195 - // the local ref. 1196 - let stale: Vec<String> = repo 1197 - .references()? 1198 - .filter_map(|r| { 1199 - let r = r.ok()?; 1200 - let name = r.name()?.to_string(); 1201 - let rest = name.strip_prefix(&dest_prefix)?.to_string(); 1202 - if local_rests.contains(&rest) { 1203 - None 1204 - } else { 1205 - Some(name) 1206 - } 1207 - }) 1208 - .collect(); 1209 - for name in stale { 1210 - if let Ok(mut r) = repo.find_reference(&name) { 1211 - r.delete()?; 1212 - } 1213 - } 1214 - Ok(()) 410 + queue::validate_name(target_queue)?; 411 + let (id, stable) = self.resolve(identifier)?; 412 + let repo = self.repo()?; 413 + let key = queue::inbox_key(&cur, id.0); 414 + queue::add_to_inbox(&repo, target_queue, key.clone(), stable.clone(), "assign")?; 415 + queue::remove(&repo, &cur, &stable, "assigned-out")?; 416 + Ok(key) 1215 417 } 1216 418 1217 - /// Read the content blob given a ref's target OID. Handles both commit- 1218 - /// backed refs (peeling through tree/content) and legacy blob refs. 1219 - fn read_oid(&self, repo: &git2::Repository, oid: git2::Oid) -> Result<Vec<u8>> { 1220 - backend::read_blob_at(repo, oid) 1221 - } 1222 - 1223 - /// Walk the post-fetch shadow looking for ids that exist on both sides 1224 - /// with different content. For each such id, decide which side keeps the 1225 - /// id (winner = earlier `created` timestamp; tie-break = lexicographically 1226 - /// smaller blob OID) and queue a [`Renumber`] for the loser. Renumbered 1227 - /// ids are allocated past the highest known id on either side. 1228 - fn detect_id_collisions( 1229 - &self, 1230 - repo: &git2::Repository, 1231 - post_fetch: &BTreeMap<String, git2::Oid>, 1232 - ) -> Result<Vec<Renumber>> { 1233 - let our_ns = self.namespace(); 1234 - // Candidate ids: any tasks/<id> or archive/<id> in the shadow under 1235 - // our namespace. 1236 - let mut candidates: std::collections::BTreeSet<Id> = Default::default(); 1237 - for bucket in ["tasks", "archive"] { 1238 - let prefix = format!("{our_ns}/{bucket}/"); 1239 - for rel in post_fetch.keys() { 1240 - if let Some(rest) = rel.strip_prefix(&prefix) 1241 - && let Ok(n) = rest.parse::<u32>() 1242 - { 1243 - candidates.insert(Id(n)); 1244 - } 1245 - } 1246 - } 1247 - if candidates.is_empty() { 1248 - return Ok(Vec::new()); 1249 - } 1250 - 1251 - // Allocate fresh ids past the highest known on either side. 1252 - let mut next_free = self.highest_known_id(post_fetch)? + 1; 1253 - let local_next: u32 = backend::read_text_blob(self.store(), "next")? 1254 - .trim() 1255 - .parse() 1256 - .unwrap_or(1); 1257 - next_free = next_free.max(local_next); 1258 - 1259 - let mut out = Vec::new(); 1260 - for id in candidates { 1261 - let local_oid = self.local_task_oid(id)?; 1262 - let remote_oid = self.remote_task_oid(post_fetch, &our_ns, id); 1263 - let (Some(local_oid), Some(remote_oid)) = (local_oid, remote_oid) else { 1264 - continue; 1265 - }; 1266 - if local_oid == remote_oid { 1267 - continue; 1268 - } 1269 - let local_create = self.read_local_create_line(id)?; 1270 - let remote_create = self.read_remote_create_line(repo, post_fetch, &our_ns, id)?; 1271 - let (Some(lc), Some(rc)) = (local_create, remote_create) else { 1272 - continue; 1273 - }; 1274 - // Same `created` line on both sides means it's the same logical 1275 - // task being edited in two places — not an id collision. Let the 1276 - // regular reconcile pass handle it. 1277 - if lc == rc { 1278 - continue; 1279 - } 1280 - // Pull out the timestamp from the `created` line for ordering. 1281 - let lc_ts: u64 = lc 1282 - .split('\t') 1283 - .next() 1284 - .and_then(|t| t.parse().ok()) 1285 - .unwrap_or(0); 1286 - let rc_ts: u64 = rc 1287 - .split('\t') 1288 - .next() 1289 - .and_then(|t| t.parse().ok()) 1290 - .unwrap_or(0); 1291 - let local_loses = match lc_ts.cmp(&rc_ts) { 1292 - std::cmp::Ordering::Greater => true, 1293 - std::cmp::Ordering::Less => false, 1294 - std::cmp::Ordering::Equal => local_oid > remote_oid, 1295 - }; 1296 - out.push(Renumber { 1297 - old_id: id, 1298 - new_id: Id(next_free), 1299 - local_loses, 419 + pub fn list_inbox(&self) -> Result<Vec<InboxItem>> { 420 + let repo = self.repo()?; 421 + let q = queue::read(&repo, &self.queue())?; 422 + let mut out = Vec::with_capacity(q.inbox.len()); 423 + for (key, stable) in q.inbox { 424 + let source_queue = key 425 + .rsplit_once('-') 426 + .map(|(s, _)| s.to_string()) 427 + .unwrap_or_else(|| key.clone()); 428 + let title = object::read(&repo, &stable)? 429 + .map(|t| t.title().to_string()) 430 + .unwrap_or_default(); 431 + out.push(InboxItem { 432 + key, 433 + source_queue, 434 + stable, 435 + title, 1300 436 }); 1301 - next_free += 1; 1302 437 } 1303 438 Ok(out) 1304 439 } 1305 440 1306 - fn highest_known_id(&self, post_fetch: &BTreeMap<String, git2::Oid>) -> Result<u32> { 1307 - let our_ns = self.namespace(); 1308 - let mut max_id = 0u32; 1309 - for id in backend::list_active(self.store())? { 1310 - max_id = max_id.max(id.0); 1311 - } 1312 - for id in backend::list_archive(self.store())? { 1313 - max_id = max_id.max(id.0); 1314 - } 1315 - for bucket in ["tasks", "archive"] { 1316 - let prefix = format!("{our_ns}/{bucket}/"); 1317 - for rel in post_fetch.keys() { 1318 - if let Some(rest) = rel.strip_prefix(&prefix) 1319 - && let Ok(n) = rest.parse::<u32>() 1320 - { 1321 - max_id = max_id.max(n); 1322 - } 1323 - } 1324 - } 1325 - Ok(max_id) 1326 - } 1327 - 1328 - fn local_task_oid(&self, id: Id) -> Result<Option<git2::Oid>> { 1329 - let repo = git2::Repository::open(self.require_git_dir()?)?; 1330 - for bucket in ["tasks", "archive"] { 1331 - let refname = format!("refs/tsk/{}/{bucket}/{}", self.namespace(), id.0); 1332 - if let Ok(r) = repo.find_reference(&refname) 1333 - && let Some(oid) = r.target() 1334 - { 1335 - return Ok(Some(oid)); 1336 - } 1337 - } 1338 - Ok(None) 1339 - } 1340 - 1341 - fn remote_task_oid( 1342 - &self, 1343 - post_fetch: &BTreeMap<String, git2::Oid>, 1344 - our_ns: &str, 1345 - id: Id, 1346 - ) -> Option<git2::Oid> { 1347 - for bucket in ["tasks", "archive"] { 1348 - if let Some(&oid) = post_fetch.get(&format!("{our_ns}/{bucket}/{}", id.0)) { 1349 - return Some(oid); 1350 - } 1351 - } 1352 - None 1353 - } 1354 - 1355 - fn read_local_create_line(&self, id: Id) -> Result<Option<String>> { 1356 - let raw = backend::read_text_blob(self.store(), &format!("log/{}", id.0))?; 1357 - Ok(raw 1358 - .lines() 1359 - .find(|l| l.split('\t').nth(1) == Some("created")) 1360 - .map(str::to_string)) 1361 - } 1362 - 1363 - fn read_remote_create_line( 1364 - &self, 1365 - repo: &git2::Repository, 1366 - post_fetch: &BTreeMap<String, git2::Oid>, 1367 - our_ns: &str, 1368 - id: Id, 1369 - ) -> Result<Option<String>> { 1370 - let Some(&oid) = post_fetch.get(&format!("{our_ns}/log/{}", id.0)) else { 1371 - return Ok(None); 441 + /// Accept an inbox item: bind to a human id in the active namespace 442 + /// (if not already), and push onto the top of the active queue. 443 + pub fn accept_inbox(&self, key: &str) -> Result<Id> { 444 + let repo = self.repo()?; 445 + let stable = queue::take_from_inbox(&repo, &self.queue(), key, "accept")? 446 + .ok_or_else(|| Error::Parse(format!("Inbox item '{key}' not found")))?; 447 + let ns_name = self.namespace(); 448 + let human = match namespace::human_for(&repo, &ns_name, &stable)? { 449 + Some(h) => h, 450 + None => namespace::assign_id(&repo, &ns_name, stable.clone(), "accept-bind")?, 1372 451 }; 1373 - let bytes = self.read_oid(repo, oid)?; 1374 - Ok(String::from_utf8_lossy(&bytes) 1375 - .lines() 1376 - .find(|l| l.split('\t').nth(1) == Some("created")) 1377 - .map(str::to_string)) 452 + queue::push_top(&repo, &self.queue(), stable, "accept-push")?; 453 + Ok(Id(human)) 1378 454 } 1379 455 1380 - /// Apply one renumber decision. See [`Renumber`] for the two flavours. 1381 - fn apply_renumber( 1382 - &self, 1383 - repo: &git2::Repository, 1384 - r: &Renumber, 1385 - post_fetch: &BTreeMap<String, git2::Oid>, 1386 - handled: &mut HashSet<String>, 1387 - ) -> Result<()> { 1388 - let our_ns = self.namespace(); 1389 - if r.local_loses { 1390 - self.rename_local(r.old_id, r.new_id)?; 1391 - self.rewrite_intra_ns_links(r.old_id, r.new_id)?; 1392 - self.rewrite_cross_ns_links(repo, &our_ns, r.old_id, r.new_id)?; 1393 - self.bump_next_past(r.new_id)?; 1394 - // Don't mark anything handled — reconcile should now Take remote's 1395 - // <old> blobs (we vacated those keys) and merge log/<old>. 1396 - } else { 1397 - self.import_remote_at_new_id(repo, post_fetch, r.old_id, r.new_id, &our_ns)?; 1398 - self.bump_next_past(r.new_id)?; 1399 - // Suppress reconcile for the remote's loser blobs at <old>; we 1400 - // keep our local <old> intact. 1401 - for kind in ["tasks", "archive", "attrs", "backlinks", "log"] { 1402 - handled.insert(format!("{our_ns}/{kind}/{}", r.old_id.0)); 1403 - } 1404 - } 1405 - // Either way, append a renumbered event to the new id's log so the 1406 - // history is recoverable. 1407 - backend::append_log( 1408 - self.store(), 1409 - r.new_id, 1410 - "renumbered", 1411 - Some(&format!("from tsk-{} (collision rebase)", r.old_id.0)), 1412 - &self.git_author().unwrap_or_default(), 1413 - )?; 456 + pub fn reject_inbox(&self, key: &str) -> Result<()> { 457 + let repo = self.repo()?; 458 + queue::take_from_inbox(&repo, &self.queue(), key, "reject")? 459 + .ok_or_else(|| Error::Parse(format!("Inbox item '{key}' not found")))?; 1414 460 Ok(()) 1415 461 } 1416 462 1417 - /// Rename local blobs from `old` → `new` within our namespace. 1418 - fn rename_local(&self, old: Id, new: Id) -> Result<()> { 1419 - for kind in ["tasks", "archive", "attrs", "backlinks", "log"] { 1420 - let from = format!("{kind}/{}", old.0); 1421 - let to = format!("{kind}/{}", new.0); 1422 - if let Some(data) = self.store().read(&from)? { 1423 - self.store().write(&to, &data)?; 1424 - self.store().delete(&from)?; 1425 - } 463 + /// Pull a task from a foreign queue's index into the active queue's 464 + /// index. Only allowed if the source queue's `can_pull` is true. 465 + pub fn pull_from_queue(&self, source_queue: &str, identifier: TaskIdentifier) -> Result<Id> { 466 + let cur = self.queue(); 467 + if source_queue == cur { 468 + return Err(Error::Parse("Source queue equals active queue".into())); 1426 469 } 1427 - // Index: rewrite the row for old → new. 1428 - let raw = backend::read_text_blob(self.store(), "index")?; 1429 - let mut out = String::with_capacity(raw.len()); 1430 - for line in raw.lines() { 1431 - let mut parts = line.splitn(2, '\t'); 1432 - let id_field = parts.next().unwrap_or(""); 1433 - let rest = parts.next().unwrap_or(""); 1434 - if id_field.parse::<Id>().ok() == Some(old) { 1435 - out.push_str(&format!("{new}\t{rest}\n")); 1436 - } else { 1437 - out.push_str(line); 1438 - out.push('\n'); 1439 - } 470 + let repo = self.repo()?; 471 + let src = queue::read(&repo, source_queue)?; 472 + if !src.can_pull { 473 + return Err(Error::Parse(format!( 474 + "Queue '{source_queue}' has can-pull=false; refusing" 475 + ))); 1440 476 } 1441 - if !raw.is_empty() { 1442 - self.store().write("index", out.as_bytes())?; 477 + let (_, stable) = self.resolve(identifier)?; 478 + if !src.index.iter().any(|s| s == &stable) { 479 + return Err(Error::Parse(format!( 480 + "Task not present in queue '{source_queue}'" 481 + ))); 1443 482 } 1444 - Ok(()) 1445 - } 1446 - 1447 - /// Rewrite every reference to `[[tsk-<old>]]` in our namespace to 1448 - /// `[[tsk-<new>]]` across task content, attrs values, log details, 1449 - /// backlinks, and index titles. 1450 - fn rewrite_intra_ns_links(&self, old: Id, new: Id) -> Result<()> { 1451 - let from_link = format!("[[tsk-{}]]", old.0); 1452 - let to_link = format!("[[tsk-{}]]", new.0); 1453 - for bucket in ["tasks", "archive"] { 1454 - for key in self.store().list(bucket)? { 1455 - if let Some(data) = self.store().read(&key)? { 1456 - let text = String::from_utf8_lossy(&data); 1457 - let new_text = text.replace(&from_link, &to_link); 1458 - if new_text != text { 1459 - self.store().write(&key, new_text.as_bytes())?; 1460 - } 1461 - } 1462 - } 1463 - } 1464 - for key in self.store().list("attrs")? { 1465 - if let Some(data) = self.store().read(&key)? { 1466 - let text = String::from_utf8_lossy(&data); 1467 - let new_text = text.replace(&from_link, &to_link); 1468 - if new_text != text { 1469 - self.store().write(&key, new_text.as_bytes())?; 1470 - } 1471 - } 1472 - } 1473 - for key in self.store().list("log")? { 1474 - if let Some(data) = self.store().read(&key)? { 1475 - let text = String::from_utf8_lossy(&data); 1476 - let new_text = text.replace(&from_link, &to_link); 1477 - if new_text != text { 1478 - self.store().write(&key, new_text.as_bytes())?; 1479 - } 1480 - } 1481 - } 1482 - // Backlinks: stored as comma-separated `tsk-N` (no brackets). 1483 - for key in self.store().list("backlinks")? { 1484 - if let Some(data) = self.store().read(&key)? { 1485 - let text = String::from_utf8_lossy(&data); 1486 - let mapped: Vec<String> = text 1487 - .split(',') 1488 - .map(|t| { 1489 - if t.trim().parse::<Id>().ok() == Some(old) { 1490 - format!("{new}") 1491 - } else { 1492 - t.to_string() 1493 - } 1494 - }) 1495 - .collect(); 1496 - let new_text = mapped.join(","); 1497 - if new_text != text { 1498 - self.store().write(&key, new_text.as_bytes())?; 1499 - } 1500 - } 1501 - } 1502 - // Index titles can also contain links. 1503 - let raw = backend::read_text_blob(self.store(), "index")?; 1504 - let new_index = raw.replace(&from_link, &to_link); 1505 - if new_index != raw { 1506 - self.store().write("index", new_index.as_bytes())?; 1507 - } 1508 - Ok(()) 1509 - } 1510 - 1511 - /// Rewrite cross-namespace references `[[<our_ns>/tsk-<old>]]` → 1512 - /// `[[<our_ns>/tsk-<new>]]` in every other namespace's blobs. 1513 - fn rewrite_cross_ns_links( 1514 - &self, 1515 - repo: &git2::Repository, 1516 - our_ns: &str, 1517 - old: Id, 1518 - new: Id, 1519 - ) -> Result<()> { 1520 - let from_link = format!("[[{our_ns}/tsk-{}]]", old.0); 1521 - let to_link = format!("[[{our_ns}/tsk-{}]]", new.0); 1522 - let prefix = "refs/tsk/"; 1523 - let our_prefix = format!("refs/tsk/{our_ns}/"); 1524 - // Per-namespace GitStores so each write goes through the 1525 - // commit-backed write_with_meta path. 1526 - let marker = std::fs::read_to_string(self.path.join(backend::GIT_BACKED_MARKER))?; 1527 - let git_dir = PathBuf::from(marker.trim()); 1528 - let mut updates: Vec<(String, String, String, Vec<u8>)> = Vec::new(); // (ns, key, refname, bytes) 1529 - for r in repo.references()? { 1530 - let r = r?; 1531 - let Some(name) = r.name() else { continue }; 1532 - if !name.starts_with(prefix) || name.starts_with(&our_prefix) { 1533 - continue; 1534 - } 1535 - let Some(oid) = r.target() else { continue }; 1536 - let bytes = backend::read_blob_at(repo, oid).unwrap_or_default(); 1537 - let text = String::from_utf8_lossy(&bytes); 1538 - let new_text = text.replace(&from_link, &to_link); 1539 - if new_text != text { 1540 - // refs/tsk/<ns>/<key> 1541 - let rest = name.strip_prefix(prefix).unwrap_or(""); 1542 - let (ns, key) = rest.split_once('/').unwrap_or(("", "")); 1543 - if !ns.is_empty() && !key.is_empty() { 1544 - updates.push(( 1545 - ns.to_string(), 1546 - key.to_string(), 1547 - name.to_string(), 1548 - new_text.into_bytes(), 1549 - )); 1550 - } 1551 - } 1552 - } 1553 - for (ns, key, _refname, bytes) in updates { 1554 - let store = backend::GitStore::open_namespace(git_dir.clone(), ns)?; 1555 - <dyn Store>::write_with_meta( 1556 - &store, 1557 - &key, 1558 - &bytes, 1559 - "renumbered-from", 1560 - Some(&format!("tsk-{}", old.0)), 1561 - )?; 1562 - } 1563 - Ok(()) 1564 - } 1565 - 1566 - /// Import remote's <old> blobs (winner stays at <old> locally; remote's 1567 - /// loser content lands at <new>). 1568 - fn import_remote_at_new_id( 1569 - &self, 1570 - repo: &git2::Repository, 1571 - post_fetch: &BTreeMap<String, git2::Oid>, 1572 - old: Id, 1573 - new: Id, 1574 - our_ns: &str, 1575 - ) -> Result<()> { 1576 - // Determine which bucket the remote had it in. 1577 - let bucket = if post_fetch.contains_key(&format!("{our_ns}/tasks/{}", old.0)) { 1578 - "tasks" 1579 - } else if post_fetch.contains_key(&format!("{our_ns}/archive/{}", old.0)) { 1580 - "archive" 1581 - } else { 1582 - return Ok(()); 483 + queue::remove(&repo, source_queue, &stable, "pulled-out")?; 484 + queue::push_top(&repo, &cur, stable.clone(), "pull")?; 485 + let ns_name = self.namespace(); 486 + let human = match namespace::human_for(&repo, &ns_name, &stable)? { 487 + Some(h) => h, 488 + None => namespace::assign_id(&repo, &ns_name, stable, "pull-bind")?, 1583 489 }; 1584 - for kind in ["tasks", "archive", "attrs", "backlinks", "log"] { 1585 - let key = format!("{our_ns}/{kind}/{}", old.0); 1586 - if let Some(&oid) = post_fetch.get(&key) { 1587 - let bytes = self.read_oid(repo, oid)?; 1588 - let local_kind = if kind == "tasks" || kind == "archive" { 1589 - bucket 1590 - } else { 1591 - kind 1592 - }; 1593 - self.store() 1594 - .write(&format!("{local_kind}/{}", new.0), &bytes)?; 1595 - } 1596 - } 1597 - // If the imported task was active on remote, add it to our index too. 1598 - if bucket == "tasks" 1599 - && let Some((title, _, _)) = backend::read_task(self.store(), new)? 1600 - { 1601 - let mut stack = self.read_stack()?; 1602 - stack.push(StackItem { 1603 - id: new, 1604 - title: title.replace('\t', " "), 1605 - modify_time: std::time::SystemTime::now(), 1606 - }); 1607 - stack.save(self.store())?; 1608 - } 1609 - Ok(()) 1610 - } 1611 - 1612 - fn bump_next_past(&self, id: Id) -> Result<()> { 1613 - let cur: u32 = backend::read_text_blob(self.store(), "next")? 1614 - .trim() 1615 - .parse() 1616 - .unwrap_or(1); 1617 - let target = id.0 + 1; 1618 - if target > cur { 1619 - self.store() 1620 - .write("next", format!("{target}\n").as_bytes())?; 1621 - } 1622 - Ok(()) 490 + Ok(Id(human)) 1623 491 } 1624 492 1625 - /// Three-way merge for union-mergeable refs (`log/*`, `index`, `next`). 1626 - /// Returns the OID to point the local ref at — a merge commit for 1627 - /// commit-backed keys (parents = local + remote), or a plain blob OID 1628 - /// for the legacy/inbox blob-ref case. 1629 - fn merge_blob( 1630 - &self, 1631 - repo: &git2::Repository, 1632 - rel: &str, 1633 - local: Option<git2::Oid>, 1634 - remote: git2::Oid, 1635 - ) -> Result<git2::Oid> { 1636 - let local_bytes = match local { 1637 - Some(o) => self.read_oid(repo, o)?, 1638 - None => Vec::new(), 1639 - }; 1640 - let remote_bytes = self.read_oid(repo, remote)?; 1641 - let local_text = String::from_utf8_lossy(&local_bytes); 1642 - let remote_text = String::from_utf8_lossy(&remote_bytes); 1643 - let merged = if rel.starts_with("log/") || rel.ends_with("/log") { 1644 - merge_log(&local_text, &remote_text) 1645 - } else if rel == "next" || rel.ends_with("/next") { 1646 - let l: u32 = local_text.trim().parse().unwrap_or(1); 1647 - let r: u32 = remote_text.trim().parse().unwrap_or(1); 1648 - format!("{}\n", l.max(r)) 1649 - } else { 1650 - merge_index(&local_text, &remote_text) 1651 - }; 1652 - // If both sides are commit-backed, write a merge commit; otherwise 1653 - // fall back to a plain blob (inbox keys, or transitional state). 1654 - let local_commit = local.and_then(|o| repo.find_commit(o).ok()); 1655 - let remote_commit = repo.find_commit(remote).ok(); 1656 - if local_commit.is_some() || remote_commit.is_some() { 1657 - let blob_oid = repo.blob(merged.as_bytes())?; 1658 - let mut tb = repo.treebuilder(None)?; 1659 - tb.insert("content", blob_oid, 0o100644)?; 1660 - let tree_oid = tb.write()?; 1661 - let tree = repo.find_tree(tree_oid)?; 1662 - let sig = git_sig(repo)?; 1663 - let parents: Vec<&git2::Commit> = [&local_commit, &remote_commit] 1664 - .iter() 1665 - .filter_map(|c| c.as_ref()) 1666 - .collect(); 1667 - let msg = format!("tsk({rel}): merge"); 1668 - return Ok(repo.commit(None, &sig, &sig, &msg, &tree, &parents)?); 1669 - } 1670 - Ok(repo.blob(merged.as_bytes())?) 1671 - } 1672 - 1673 - /// Configure git so future `git push <remote>` / `git fetch <remote>` 1674 - /// include the tsk ref namespace. Idempotent. 1675 493 pub fn configure_git_remote_refspecs(&self, remote: &str) -> Result<()> { 1676 494 for (key, value) in [ 1677 495 (format!("remote.{remote}.push"), "refs/tsk/*:refs/tsk/*"), 1678 496 (format!("remote.{remote}.fetch"), "+refs/tsk/*:refs/tsk/*"), 1679 497 ] { 1680 - let existing = self 1681 - .git_cmd()? 498 + let cmd = std::process::Command::new("git") 499 + .arg("--git-dir") 500 + .arg(&self.git_dir) 1682 501 .args(["config", "--get-all", &key]) 1683 502 .output()?; 1684 - if String::from_utf8_lossy(&existing.stdout) 503 + if String::from_utf8_lossy(&cmd.stdout) 1685 504 .lines() 1686 505 .any(|l| l.trim() == value) 1687 506 { 1688 507 continue; 1689 508 } 1690 - self.run_git(&["config", "--add", &key, value])?; 1691 - } 1692 - Ok(()) 1693 - } 1694 - 1695 - /// Every logical blob key that currently exists in the workspace. 1696 - fn all_keys(&self) -> Result<Vec<String>> { 1697 - let mut keys: Vec<String> = Vec::new(); 1698 - for prefix in ["tasks", "archive", "attrs", "backlinks", "log", "inbox"] { 1699 - keys.extend(self.store().list(prefix)?); 1700 - } 1701 - for top in ["index", "next", "remotes"] { 1702 - if self.store().exists(top)? { 1703 - keys.push(top.into()); 509 + let s = std::process::Command::new("git") 510 + .arg("--git-dir") 511 + .arg(&self.git_dir) 512 + .args(["config", "--add", &key, value]) 513 + .status()?; 514 + if !s.success() { 515 + return Err(Error::Parse("git config failed".into())); 1704 516 } 1705 517 } 1706 - keys.sort(); 1707 - Ok(keys) 1708 - } 1709 - 1710 - /// Write a zip archive containing every blob in the workspace. Layout in the 1711 - /// zip mirrors the logical key namespace. 1712 - pub fn export_zip(&self, dest: &std::path::Path) -> Result<()> { 1713 - let mut writer = zip::ZipWriter::new(std::fs::File::create(dest)?); 1714 - let opts = zip::write::SimpleFileOptions::default() 1715 - .compression_method(zip::CompressionMethod::Deflated); 1716 - use std::io::Write as _; 1717 - for key in self.all_keys()? { 1718 - if let Some(data) = self.store().read(&key)? { 1719 - writer 1720 - .start_file(&key, opts) 1721 - .map_err(|e| Error::Parse(format!("zip: {e}")))?; 1722 - writer.write_all(&data)?; 1723 - } 1724 - } 1725 - writer 1726 - .finish() 1727 - .map_err(|e| Error::Parse(format!("zip: {e}")))?; 1728 518 Ok(()) 1729 519 } 1730 520 1731 - /// Migrate a file-backed workspace to a git-backed one. Returns Err if the 1732 - /// workspace is already git-backed or if no enclosing git repo is found. 1733 - /// Send a task to another namespace's inbox in the same git repo. Sets 1734 - /// `assigned=[[<target_ns>/tsk-<id>]]` on the source after a successful 1735 - /// write so it can be tracked. Returns the inbox key used in the target. 1736 - pub fn export_to_namespace(&self, target_ns: &str, src_id: Id) -> Result<String> { 1737 - if !self.is_git_backed() { 1738 - return Err(Error::Parse( 1739 - "Cross-namespace export only works on git-backed workspaces".into(), 1740 - )); 1741 - } 1742 - validate_namespace(target_ns)?; 1743 - let cur = self.namespace(); 1744 - if target_ns == cur { 1745 - return Err(Error::Parse( 1746 - "Refusing to export a task to its own namespace".into(), 1747 - )); 1748 - } 1749 - let task = self.task(TaskIdentifier::Id(src_id))?; 1750 - let attrs = backend::read_attrs(self.store(), src_id)?; 1751 - let payload = backend::InboxPayload { 1752 - source_namespace: cur, 1753 - source_id: src_id.0, 1754 - title: task.title.clone(), 1755 - body: task.body.clone(), 1756 - attrs, 1757 - }; 1758 - let marker = std::fs::read_to_string(self.path.join(backend::GIT_BACKED_MARKER))?; 1759 - let target = 1760 - backend::GitStore::open_namespace(PathBuf::from(marker.trim()), target_ns.to_string())?; 1761 - let key = backend::inbox_key(&payload.source_namespace, payload.source_id); 1762 - <dyn Store>::write(&target, &key, payload.serialize().as_bytes())?; 1763 - 1764 - // Mark the source with where it was sent. 1765 - let assigned_link = format!("[[{target_ns}/tsk-{}]]", src_id.0); 1766 - let mut my_attrs = backend::read_attrs(self.store(), src_id)?; 1767 - my_attrs.insert("assigned".into(), assigned_link.clone()); 1768 - backend::write_attrs( 1769 - self.store(), 1770 - src_id, 1771 - &my_attrs, 1772 - "assigned", 1773 - Some(&assigned_link), 1774 - )?; 1775 - self.log(src_id, "assigned", Some(&assigned_link))?; 1776 - Ok(key) 1777 - } 1778 - 1779 - /// Item pending in the current namespace's inbox. 1780 - pub fn list_inbox(&self) -> Result<Vec<InboxItem>> { 1781 - let mut out = Vec::new(); 1782 - for key in self.store().list("inbox")? { 1783 - if let Some(data) = self.store().read(&key)? { 1784 - let payload = backend::InboxPayload::parse(&String::from_utf8_lossy(&data))?; 1785 - out.push(InboxItem { 1786 - inbox_key: key, 1787 - source_namespace: payload.source_namespace, 1788 - source_id: payload.source_id, 1789 - title: payload.title, 1790 - }); 1791 - } 1792 - } 1793 - out.sort_by(|a, b| a.inbox_key.cmp(&b.inbox_key)); 1794 - Ok(out) 1795 - } 1796 - 1797 - /// Accept a pending inbox item: create a new local task with copied 1798 - /// title/body/attrs, set `source=[[<src-ns>/tsk-<src-id>]]`, push it on 1799 - /// the stack, and remove the inbox blob. 1800 - pub fn accept_inbox(&self, inbox_key: &str) -> Result<Id> { 1801 - let key = if inbox_key.starts_with("inbox/") { 1802 - inbox_key.to_string() 1803 - } else { 1804 - format!("inbox/{inbox_key}") 1805 - }; 1806 - let data = self 1807 - .store() 1808 - .read(&key)? 1809 - .ok_or_else(|| Error::Parse(format!("Inbox item '{inbox_key}' not found")))?; 1810 - let payload = backend::InboxPayload::parse(&String::from_utf8_lossy(&data))?; 1811 - 1812 - let task = self.new_task(payload.title.clone(), payload.body.clone())?; 1813 - let new_id = task.id; 1814 - self.push_task(task)?; 1815 - 1816 - let mut attrs = payload.attrs; 1817 - attrs.insert( 1818 - "source".into(), 1819 - format!("[[{}/tsk-{}]]", payload.source_namespace, payload.source_id), 1820 - ); 1821 - // Drop any "assigned" carried over — it was set by the source workspace 1822 - // before export; the new local copy isn't itself assigned anywhere. 1823 - attrs.remove("assigned"); 1824 - backend::write_attrs(self.store(), new_id, &attrs, "accepted", None)?; 1825 - self.store().delete(&key)?; 1826 - self.log( 1827 - new_id, 1828 - "accepted", 1829 - Some(&format!( 1830 - "[[{}/tsk-{}]]", 1831 - payload.source_namespace, payload.source_id 1832 - )), 1833 - )?; 1834 - Ok(new_id) 1835 - } 1836 - 1837 - /// Reject a pending inbox item: write a `rejected` event to the source's 1838 - /// event log so the assignor sees it, then delete the inbox blob without 1839 - /// creating a local task. 1840 - pub fn reject_inbox(&self, inbox_key: &str) -> Result<(String, u32)> { 1841 - let key = if inbox_key.starts_with("inbox/") { 1842 - inbox_key.to_string() 1843 - } else { 1844 - format!("inbox/{inbox_key}") 1845 - }; 1846 - let data = self 1847 - .store() 1848 - .read(&key)? 1849 - .ok_or_else(|| Error::Parse(format!("Inbox item '{inbox_key}' not found")))?; 1850 - let payload = backend::InboxPayload::parse(&String::from_utf8_lossy(&data))?; 1851 - 1852 - let cur = self.namespace(); 1853 - let detail = format!("[[{}/inbox]]", cur); 1854 - let author = self.git_author().unwrap_or_default(); 1855 - let marker = std::fs::read_to_string(self.path.join(backend::GIT_BACKED_MARKER))?; 1856 - let src_store = backend::GitStore::open_namespace( 1857 - PathBuf::from(marker.trim()), 1858 - payload.source_namespace.clone(), 1859 - )?; 1860 - backend::append_log( 1861 - &src_store, 1862 - Id(payload.source_id), 1863 - "rejected", 1864 - Some(&detail), 1865 - &author, 1866 - )?; 1867 - self.store().delete(&key)?; 1868 - Ok((payload.source_namespace, payload.source_id)) 1869 - } 1870 - 1871 - pub fn migrate_to_git(&self) -> Result<PathBuf> { 1872 - if self.is_git_backed() { 1873 - return Err(Error::Parse("Workspace is already git-backed".into())); 1874 - } 1875 - let git_dir = backend::detect_git_dir(&self.path) 1876 - .ok_or_else(|| Error::Parse("No enclosing git repository found".into()))?; 1877 - let dest = backend::GitStore::open(git_dir.clone())?; 1878 - for key in self.all_keys()? { 1879 - if let Some(data) = self.store().read(&key)? { 1880 - dest.write(&key, &data)?; 1881 - } 1882 - } 1883 - for entry in std::fs::read_dir(&self.path)? { 1884 - let p = entry?.path(); 1885 - if p.is_dir() { 1886 - std::fs::remove_dir_all(&p)? 1887 - } else { 1888 - std::fs::remove_file(&p)? 1889 - } 1890 - } 1891 - std::fs::write( 1892 - self.path.join(backend::GIT_BACKED_MARKER), 1893 - git_dir.to_string_lossy().as_bytes(), 1894 - )?; 1895 - Ok(git_dir) 1896 - } 1897 - 1898 - pub fn reopen(&self, identifier: TaskIdentifier) -> Result<Id> { 1899 - let id = self.resolve(identifier)?; 1900 - match backend::task_location(self.store(), id)? { 1901 - None => return Err(Error::Parse(format!("Task {id} not found in archive"))), 1902 - Some(Loc::Active) => return Err(Error::Parse(format!("Task {id} is already open"))), 1903 - Some(Loc::Archived) => {} 521 + pub fn git_push(&self, remote: &str) -> Result<()> { 522 + let s = std::process::Command::new("git") 523 + .arg("--git-dir") 524 + .arg(&self.git_dir) 525 + .args(["push", remote, "refs/tsk/*:refs/tsk/*"]) 526 + .status()?; 527 + if !s.success() { 528 + return Err(Error::Parse("git push failed".into())); 1904 529 } 1905 - backend::move_task(self.store(), id, Loc::Active)?; 1906 - let (title, _, _) = backend::read_task(self.store(), id)? 1907 - .ok_or_else(|| Error::Parse(format!("Task {id} content missing after move")))?; 1908 - let mut stack = self.read_stack()?; 1909 - stack.push(StackItem { 1910 - id, 1911 - title: title.replace('\t', " "), 1912 - modify_time: std::time::SystemTime::now(), 1913 - }); 1914 - stack.save(self.store())?; 1915 - self.log(id, "reopened", None)?; 1916 - Ok(id) 530 + Ok(()) 1917 531 } 1918 - } 1919 532 1920 - pub struct Task { 1921 - pub id: Id, 1922 - pub title: String, 1923 - pub body: String, 1924 - pub attributes: BTreeMap<String, String>, 1925 - } 1926 - 1927 - impl Display for Task { 1928 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 1929 - write!(f, "{}\n\n{}", self.title, &self.body) 1930 - } 1931 - } 1932 - 1933 - impl Task { 1934 - fn bare(self) -> SearchTask { 1935 - SearchTask { 1936 - id: self.id, 1937 - title: self.title, 1938 - body: self.body, 533 + pub fn git_pull(&self, remote: &str) -> Result<()> { 534 + let s = std::process::Command::new("git") 535 + .arg("--git-dir") 536 + .arg(&self.git_dir) 537 + .args(["fetch", "--prune", remote, "+refs/tsk/*:refs/tsk/*"]) 538 + .status()?; 539 + if !s.success() { 540 + return Err(Error::Parse("git fetch failed".into())); 1939 541 } 542 + Ok(()) 1940 543 } 1941 544 } 1942 545 1943 - pub struct SearchTask { 1944 - pub id: Id, 1945 - pub title: String, 1946 - pub body: String, 1947 - } 1948 - 1949 - impl Display for SearchTask { 1950 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 1951 - write!(f, "{}\t{}", self.id, self.title.trim())?; 1952 - if !self.body.is_empty() { 1953 - write!(f, "\n\n{}", self.body)?; 546 + pub fn find_git_dir(start: &std::path::Path) -> Option<PathBuf> { 547 + let mut cur = Some(start.to_path_buf()); 548 + while let Some(p) = cur { 549 + let candidate = p.join(".git"); 550 + if candidate.exists() { 551 + return Some(candidate); 1954 552 } 1955 - Ok(()) 553 + cur = p.parent().map(|q| q.to_path_buf()); 1956 554 } 555 + None 1957 556 } 1958 557 1959 558 #[cfg(test)] 1960 559 mod test { 1961 560 use super::*; 1962 561 1963 - fn run_git_init(dir: &std::path::Path) { 562 + fn run_git_init(p: &std::path::Path) { 1964 563 let s = std::process::Command::new("git") 1965 - .args(["init", "-q"]) 1966 - .current_dir(dir) 564 + .args(["init", "-q", "-b", "main"]) 565 + .current_dir(p) 1967 566 .status() 1968 567 .unwrap(); 1969 568 assert!(s.success()); 1970 - } 1971 - 1972 - /// Create both a file-backed and a git-backed workspace for the same test. 1973 - fn setup_dual() -> (tempfile::TempDir, Workspace, Workspace) { 1974 - let dir = tempfile::tempdir().unwrap(); 1975 - let file_root = dir.path().join("file"); 1976 - let git_root = dir.path().join("git"); 1977 - std::fs::create_dir_all(&file_root).unwrap(); 1978 - std::fs::create_dir_all(&git_root).unwrap(); 1979 - run_git_init(&git_root); 1980 - Workspace::init(file_root.clone()).unwrap(); 1981 - Workspace::init(git_root.clone()).unwrap(); 1982 - let f = Workspace::from_path(file_root).unwrap(); 1983 - let g = Workspace::from_path(git_root).unwrap(); 1984 - assert!( 1985 - !f.is_git_backed(), 1986 - "file workspace should not be git-backed" 1987 - ); 1988 - assert!(g.is_git_backed(), "git workspace should be git-backed"); 1989 - (dir, f, g) 1990 - } 1991 - 1992 - fn run_full_lifecycle(ws: &Workspace) { 1993 - // Push two tasks, drop one, verify state. 1994 - let t1 = ws 1995 - .new_task("First".to_string(), "body one".to_string()) 1996 - .unwrap(); 1997 - let id1 = t1.id; 1998 - ws.push_task(t1).unwrap(); 1999 - let t2 = ws 2000 - .new_task("Second".to_string(), "body two".to_string()) 2001 - .unwrap(); 2002 - let id2 = t2.id; 2003 - ws.push_task(t2).unwrap(); 2004 - 2005 - let stack = ws.read_stack().unwrap(); 2006 - assert_eq!(stack.iter().count(), 2); 2007 - assert_eq!(stack.iter().next().unwrap().id, id2, "newest on top"); 2008 - 2009 - // Read back the task content. 2010 - let read = ws.task(TaskIdentifier::Id(id1)).unwrap(); 2011 - assert_eq!(read.title, "First"); 2012 - assert_eq!(read.body, "body one"); 2013 - 2014 - // Drop top. 2015 - ws.drop(TaskIdentifier::Id(id2)).unwrap(); 2016 - let stack = ws.read_stack().unwrap(); 2017 - assert_eq!(stack.iter().count(), 1); 2018 - assert_eq!(stack.iter().next().unwrap().id, id1); 2019 - 2020 - // Reopen. 2021 - ws.reopen(TaskIdentifier::Id(id2)).unwrap(); 2022 - let stack = ws.read_stack().unwrap(); 2023 - assert_eq!(stack.iter().count(), 2); 2024 - 2025 - // Reopen non-archived fails. 2026 - assert!(ws.reopen(TaskIdentifier::Id(id1)).is_err()); 2027 - 2028 - // Edit and save. 2029 - let mut t = ws.task(TaskIdentifier::Id(id1)).unwrap(); 2030 - t.title = "First (edited)".into(); 2031 - t.body = "new body".into(); 2032 - ws.save_task(&t).unwrap(); 2033 - let read = ws.task(TaskIdentifier::Id(id1)).unwrap(); 2034 - assert_eq!(read.title, "First (edited)"); 2035 - let stack = ws.read_stack().unwrap(); 2036 - let item = stack.iter().find(|i| i.id == id1).unwrap(); 2037 - assert_eq!( 2038 - item.title, "First (edited)", 2039 - "stack title should refresh on save" 2040 - ); 2041 - 2042 - // Remotes. 2043 - ws.add_remote("up", "/path").unwrap(); 2044 - let remotes = ws.read_remotes().unwrap(); 2045 - assert_eq!(remotes.len(), 1); 2046 - ws.remove_remote("up").unwrap(); 2047 - assert!(ws.read_remotes().unwrap().is_empty()); 2048 - 2049 - // Backlinks. 2050 - ws.handle_metadata( 2051 - &Task { 2052 - id: id1, 2053 - title: "x".into(), 2054 - body: format!("see [[{id2}]]"), 2055 - attributes: Default::default(), 2056 - }, 2057 - None, 2058 - ) 2059 - .unwrap(); 2060 - let bl = backend::read_backlinks(ws.store(), id2).unwrap(); 2061 - assert!(bl.contains(&id1)); 569 + let _ = std::process::Command::new("git") 570 + .args(["config", "user.name", "Test"]) 571 + .current_dir(p) 572 + .status(); 573 + let _ = std::process::Command::new("git") 574 + .args(["config", "user.email", "t@e"]) 575 + .current_dir(p) 576 + .status(); 2062 577 } 2063 578 2064 - #[test] 2065 - fn test_full_lifecycle_file_backend() { 2066 - let dir = tempfile::tempdir().unwrap(); 2067 - Workspace::init(dir.path().to_path_buf()).unwrap(); 2068 - let ws = Workspace::from_path(dir.path().to_path_buf()).unwrap(); 2069 - assert!(!ws.is_git_backed()); 2070 - run_full_lifecycle(&ws); 2071 - } 2072 - 2073 - #[test] 2074 - fn test_full_lifecycle_git_backend() { 579 + fn fresh_workspace() -> (tempfile::TempDir, Workspace) { 2075 580 let dir = tempfile::tempdir().unwrap(); 2076 581 run_git_init(dir.path()); 2077 582 Workspace::init(dir.path().to_path_buf()).unwrap(); 2078 583 let ws = Workspace::from_path(dir.path().to_path_buf()).unwrap(); 2079 - assert!(ws.is_git_backed()); 2080 - run_full_lifecycle(&ws); 2081 - } 2082 - 2083 - #[test] 2084 - fn test_init_picks_backend_correctly() { 2085 - let (_d, f, g) = setup_dual(); 2086 - assert!(!f.is_git_backed()); 2087 - assert!(g.is_git_backed()); 584 + (dir, ws) 2088 585 } 2089 586 2090 587 #[test] 2091 - fn test_clean_archives_orphaned_tasks() { 2092 - let (_d, file, git) = setup_dual(); 2093 - for ws in [&file, &git] { 2094 - // Push a task, then directly orphan it in the store. 2095 - let t = ws.new_task("Indexed".into(), "ok".into()).unwrap(); 2096 - ws.push_task(t).unwrap(); 2097 - // Write an unindexed task directly to the store. 2098 - backend::write_task( 2099 - ws.store(), 2100 - Id(999), 2101 - "orphan", 2102 - "", 2103 - Loc::Active, 2104 - "write", 2105 - None, 2106 - ) 2107 - .unwrap(); 2108 - 2109 - let active_before = backend::list_active(ws.store()).unwrap(); 2110 - assert!(active_before.contains(&Id(999))); 2111 - ws.clean().unwrap(); 2112 - let active_after = backend::list_active(ws.store()).unwrap(); 2113 - assert!(!active_after.contains(&Id(999))); 2114 - let archived = backend::list_archive(ws.store()).unwrap(); 2115 - assert!(archived.contains(&Id(999))); 2116 - } 2117 - } 2118 - 2119 - #[test] 2120 - fn test_remote_persistence() { 2121 - let (_d, file, git) = setup_dual(); 2122 - for ws in [&file, &git] { 2123 - ws.add_remote("a", "/x").unwrap(); 2124 - ws.add_remote("b", "/y").unwrap(); 2125 - let ws2 = Workspace::from_path(ws.path.clone()).unwrap(); 2126 - assert_eq!(ws2.read_remotes().unwrap().len(), 2); 2127 - assert!(ws.add_remote("a", "/z").is_err()); 2128 - assert!(ws.remove_remote("nope").is_err()); 2129 - ws.remove_remote("a").unwrap(); 2130 - assert_eq!(ws.read_remotes().unwrap().len(), 1); 2131 - } 2132 - } 2133 - 2134 - #[test] 2135 - fn test_search_archived_round_trip() { 2136 - let (_d, file, git) = setup_dual(); 2137 - for ws in [&file, &git] { 2138 - let t = ws.new_task("Archived".into(), "a".into()).unwrap(); 2139 - let id = t.id; 2140 - ws.push_task(t).unwrap(); 2141 - ws.drop(TaskIdentifier::Id(id)).unwrap(); 2142 - assert_eq!( 2143 - backend::task_location(ws.store(), id).unwrap(), 2144 - Some(Loc::Archived) 2145 - ); 2146 - } 2147 - } 2148 - 2149 - #[test] 2150 - fn test_rot_tor_swap() { 2151 - let (_d, file, git) = setup_dual(); 2152 - for ws in [&file, &git] { 2153 - let mut ids = Vec::new(); 2154 - for n in 0..3 { 2155 - let t = ws.new_task(format!("t{n}"), "".into()).unwrap(); 2156 - ids.push(t.id); 2157 - ws.push_task(t).unwrap(); 2158 - } 2159 - // Stack now: [ids[2], ids[1], ids[0]] 2160 - ws.swap_top().unwrap(); 2161 - let s = ws.read_stack().unwrap(); 2162 - let order: Vec<_> = s.iter().map(|i| i.id).collect(); 2163 - assert_eq!(order, vec![ids[1], ids[2], ids[0]]); 2164 - ws.swap_top().unwrap(); // back 2165 - ws.rot().unwrap(); 2166 - ws.tor().unwrap(); 2167 - let s = ws.read_stack().unwrap(); 2168 - let order: Vec<_> = s.iter().map(|i| i.id).collect(); 2169 - assert_eq!( 2170 - order, 2171 - vec![ids[2], ids[1], ids[0]], 2172 - "rot then tor is identity" 2173 - ); 2174 - } 2175 - } 2176 - 2177 - #[test] 2178 - fn test_remote_display() { 2179 - let r = Remote { 2180 - prefix: "jira".into(), 2181 - path: PathBuf::from("/p"), 2182 - }; 2183 - assert_eq!(r.to_string(), "jira\t/p"); 2184 - } 2185 - 2186 - #[test] 2187 - fn test_bare_task_display() { 2188 - let t = SearchTask { 2189 - id: Id(1), 2190 - title: "x".into(), 2191 - body: "y".into(), 2192 - }; 2193 - assert_eq!(t.to_string(), "tsk-1\tx\n\ny"); 2194 - } 2195 - 2196 - #[test] 2197 - fn test_task_display() { 2198 - let t = Task { 2199 - id: Id(1), 2200 - title: "x".into(), 2201 - body: "y".into(), 2202 - attributes: Default::default(), 2203 - }; 2204 - assert_eq!(t.to_string(), "x\n\ny"); 2205 - } 2206 - 2207 - #[test] 2208 - fn test_export_zip_both_backends() { 2209 - let (_d, file, git) = setup_dual(); 2210 - for ws in [&file, &git] { 2211 - let t = ws.new_task("t1".into(), "b1".into()).unwrap(); 2212 - let id = t.id; 2213 - ws.push_task(t).unwrap(); 2214 - ws.add_remote("up", "/p").unwrap(); 2215 - 2216 - let out = ws.path.join("export.zip"); 2217 - ws.export_zip(&out).unwrap(); 2218 - assert!(out.exists() && std::fs::metadata(&out).unwrap().len() > 0); 2219 - 2220 - let f = std::fs::File::open(&out).unwrap(); 2221 - let mut zip = zip::ZipArchive::new(f).unwrap(); 2222 - let names: std::collections::HashSet<String> = (0..zip.len()) 2223 - .map(|i| zip.by_index(i).unwrap().name().to_string()) 2224 - .collect(); 2225 - assert!(names.contains(&format!("tasks/{}", id.0))); 2226 - assert!(names.contains("index")); 2227 - assert!(names.contains("next")); 2228 - assert!(names.contains("remotes")); 2229 - 2230 - // Round-trip the task content. 2231 - use std::io::Read as _; 2232 - let mut entry = zip.by_name(&format!("tasks/{}", id.0)).unwrap(); 2233 - let mut buf = String::new(); 2234 - entry.read_to_string(&mut buf).unwrap(); 2235 - assert!(buf.starts_with("t1")); 2236 - assert!(buf.contains("b1")); 2237 - } 2238 - } 2239 - 2240 - #[test] 2241 - fn test_migrate_file_to_git() { 2242 - let dir = tempfile::tempdir().unwrap(); 2243 - let root = dir.path().to_path_buf(); 2244 - // Init as file-backed (no git yet). 2245 - Workspace::init(root.clone()).unwrap(); 2246 - let ws = Workspace::from_path(root.clone()).unwrap(); 2247 - assert!(!ws.is_git_backed()); 2248 - 2249 - // Populate some state. 2250 - let t1 = ws.new_task("Active".into(), "body1".into()).unwrap(); 588 + fn push_list_drop_round_trip() { 589 + let (_d, ws) = fresh_workspace(); 590 + let t1 = ws.new_task("first".into(), "body 1".into()).unwrap(); 2251 591 let id1 = t1.id; 2252 592 ws.push_task(t1).unwrap(); 2253 - let t2 = ws.new_task("Will archive".into(), "body2".into()).unwrap(); 593 + let t2 = ws.new_task("second".into(), "".into()).unwrap(); 2254 594 let id2 = t2.id; 2255 595 ws.push_task(t2).unwrap(); 2256 - ws.drop(TaskIdentifier::Id(id2)).unwrap(); 2257 - ws.add_remote("up", "/path").unwrap(); 2258 - let mut t = ws.task(TaskIdentifier::Id(id1)).unwrap(); 2259 - t.attributes.insert("k".into(), "v".into()); 2260 - ws.save_task(&t).unwrap(); 2261 - ws.handle_metadata( 2262 - &Task { 2263 - id: id1, 2264 - title: "x".into(), 2265 - body: format!("see [[{id2}]]"), 2266 - attributes: Default::default(), 2267 - }, 2268 - None, 2269 - ) 2270 - .unwrap(); 2271 - 2272 - // Migration before git init must fail. 2273 - assert!(ws.migrate_to_git().is_err()); 2274 - 2275 - // Now turn the directory into a git repo and migrate. 2276 - run_git_init(&root); 2277 - ws.migrate_to_git().unwrap(); 2278 - 2279 - // Re-open the workspace (picks up the new marker → GitStore). 2280 - let ws2 = Workspace::from_path(root.clone()).unwrap(); 2281 - assert!(ws2.is_git_backed()); 2282 - 2283 - // All on-disk task data should be gone except the marker. 2284 - let entries: Vec<_> = std::fs::read_dir(ws2.path.clone()) 2285 - .unwrap() 2286 - .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().to_string())) 2287 - .collect(); 2288 - assert_eq!(entries, vec!["git-backed".to_string()]); 2289 - 2290 - // State preserved. 2291 - let stack = ws2.read_stack().unwrap(); 2292 - let ids: Vec<_> = stack.iter().map(|i| i.id).collect(); 2293 - assert_eq!(ids, vec![id1]); 2294 - let read = ws2.task(TaskIdentifier::Id(id1)).unwrap(); 2295 - assert_eq!(read.title, "Active"); 2296 - assert_eq!(read.attributes.get("k"), Some(&"v".to_string())); 596 + let stack = ws.read_stack().unwrap(); 2297 597 assert_eq!( 2298 - backend::task_location(ws2.store(), id2).unwrap(), 2299 - Some(Loc::Archived) 598 + stack.iter().map(|e| e.id).collect::<Vec<_>>(), 599 + vec![id2, id1] 2300 600 ); 2301 - let bl = backend::read_backlinks(ws2.store(), id2).unwrap(); 2302 - assert!(bl.contains(&id1)); 2303 - assert_eq!(ws2.read_remotes().unwrap().len(), 1); 2304 - 2305 - // Migrating an already-git-backed workspace fails. 2306 - assert!(ws2.migrate_to_git().is_err()); 601 + let read = ws.task(TaskIdentifier::Id(id1)).unwrap(); 602 + assert_eq!(read.title, "first"); 603 + assert_eq!(read.body, "body 1"); 604 + let dropped = ws.drop(TaskIdentifier::Id(id1)).unwrap(); 605 + assert_eq!(dropped, Some(id1)); 606 + let stack = ws.read_stack().unwrap(); 607 + assert_eq!(stack.iter().map(|e| e.id).collect::<Vec<_>>(), vec![id2]); 2307 608 } 2308 609 2309 - /// Runs through every command's workspace-level logic against `ws`. Mirrors 2310 - /// what main.rs's `command_*` functions do (sans interactive bits like fzf 2311 - /// and $EDITOR). 2312 - fn run_every_command(ws: &Workspace) { 2313 - // command_push (twice): create_task → handle_metadata → push_task 2314 - let t1 = ws.new_task("first".into(), "body1".into()).unwrap(); 610 + #[test] 611 + fn id_allocation_monotonic_across_drops() { 612 + let (_d, ws) = fresh_workspace(); 613 + let t1 = ws.new_task("a".into(), "".into()).unwrap(); 2315 614 let id1 = t1.id; 2316 - ws.handle_metadata(&t1, None).unwrap(); 2317 615 ws.push_task(t1).unwrap(); 2318 - 2319 - let t2 = ws.new_task("second".into(), "body2".into()).unwrap(); 2320 - let id2 = t2.id; 2321 - ws.handle_metadata(&t2, None).unwrap(); 2322 - ws.push_task(t2).unwrap(); 2323 - 2324 - // command_append: append_task at the bottom 2325 - let t3 = ws.new_task("third".into(), "".into()).unwrap(); 2326 - let id3 = t3.id; 2327 - ws.append_task(t3).unwrap(); 2328 - 2329 - // command_list: stack reads in expected order 2330 - let stack = ws.read_stack().unwrap(); 2331 - let order: Vec<_> = stack.iter().map(|i| i.id).collect(); 2332 - assert_eq!(order, vec![id2, id1, id3], "{order:?}"); 2333 - 2334 - // command_show: read by id 2335 - let shown = ws.task(TaskIdentifier::Id(id1)).unwrap(); 2336 - assert_eq!(shown.title, "first"); 2337 - assert_eq!(shown.body, "body1"); 2338 - 2339 - // command_show: read by relative position 2340 - let top = ws.task(TaskIdentifier::Relative(0)).unwrap(); 2341 - assert_eq!(top.id, id2); 2342 - 2343 - // command_edit: this is the regression suspected by the user. Mirror the 2344 - // exact code path command_edit uses, sans open_editor. 2345 - { 2346 - let mut task = ws.task(TaskIdentifier::Id(id1)).unwrap(); 2347 - let pre_links = parse_task(&task.to_string()).map(|pt| pt.intenal_links()); 2348 - let new_content = format!("edited title [[{id3}]]\n\nedited body"); 2349 - let (title, body) = new_content.split_once('\n').unwrap(); 2350 - task.title = title.replace(['\n', '\r'], " "); 2351 - task.body = body.to_string(); 2352 - ws.handle_metadata(&task, pre_links).unwrap(); 2353 - ws.save_task(&task).unwrap(); 2354 - 2355 - let reread = ws.task(TaskIdentifier::Id(id1)).unwrap(); 2356 - assert!(reread.title.starts_with("edited title"), "{}", reread.title); 2357 - assert_eq!(reread.body.trim(), "edited body"); 2358 - // Stack title refreshed. 2359 - let s = ws.read_stack().unwrap(); 2360 - let item = s.iter().find(|i| i.id == id1).unwrap(); 2361 - assert!(item.title.starts_with("edited title")); 2362 - // Backlink from id1 → id3 should now exist. 2363 - let bl3 = backend::read_backlinks(ws.store(), id3).unwrap(); 2364 - assert!(bl3.contains(&id1), "edit should add backlinks: {bl3:?}"); 2365 - } 2366 - 2367 - // Editing an archived task should leave it archived, not resurrect it. 2368 - ws.drop(TaskIdentifier::Id(id3)).unwrap(); 2369 - assert_eq!( 2370 - backend::task_location(ws.store(), id3).unwrap(), 2371 - Some(Loc::Archived) 2372 - ); 2373 - { 2374 - let mut task = ws.task(TaskIdentifier::Id(id3)).unwrap(); 2375 - task.body = "edited while archived".into(); 2376 - ws.save_task(&task).unwrap(); 2377 - assert_eq!( 2378 - backend::task_location(ws.store(), id3).unwrap(), 2379 - Some(Loc::Archived), 2380 - "save_task must preserve archive location" 2381 - ); 2382 - let reread = ws.task(TaskIdentifier::Id(id3)).unwrap(); 2383 - assert_eq!(reread.body, "edited while archived"); 2384 - } 2385 - // Reopen so subsequent stack ops have it back. 2386 - ws.reopen(TaskIdentifier::Id(id3)).unwrap(); 2387 - 2388 - // command_swap 2389 - let before: Vec<_> = ws.read_stack().unwrap().iter().map(|i| i.id).collect(); 2390 - ws.swap_top().unwrap(); 2391 - let after: Vec<_> = ws.read_stack().unwrap().iter().map(|i| i.id).collect(); 2392 - assert_eq!(after[0], before[1]); 2393 - assert_eq!(after[1], before[0]); 2394 - ws.swap_top().unwrap(); 2395 - 2396 - // command_rot / command_tor are inverses 2397 - let before: Vec<_> = ws.read_stack().unwrap().iter().map(|i| i.id).collect(); 2398 - ws.rot().unwrap(); 2399 - ws.tor().unwrap(); 2400 - let after: Vec<_> = ws.read_stack().unwrap().iter().map(|i| i.id).collect(); 2401 - assert_eq!(before, after); 2402 - 2403 - // command_prioritize 2404 - ws.prioritize(TaskIdentifier::Id(id1)).unwrap(); 2405 - assert_eq!(ws.read_stack().unwrap().iter().next().unwrap().id, id1); 2406 - 2407 - // command_deprioritize 2408 - ws.deprioritize(TaskIdentifier::Id(id1)).unwrap(); 2409 - let s = ws.read_stack().unwrap(); 2410 - assert_eq!(s.iter().last().unwrap().id, id1); 2411 - 2412 - // command_drop 2413 616 ws.drop(TaskIdentifier::Id(id1)).unwrap(); 2414 - assert!(!ws.read_stack().unwrap().iter().any(|i| i.id == id1)); 2415 - assert_eq!( 2416 - backend::task_location(ws.store(), id1).unwrap(), 2417 - Some(Loc::Archived) 2418 - ); 2419 - 2420 - // command_reopen 2421 - ws.reopen(TaskIdentifier::Id(id1)).unwrap(); 2422 - assert!(ws.read_stack().unwrap().iter().any(|i| i.id == id1)); 2423 - assert_eq!( 2424 - backend::task_location(ws.store(), id1).unwrap(), 2425 - Some(Loc::Active) 2426 - ); 2427 - 2428 - // command_clean: orphan a task in active that isn't on the stack 2429 - backend::write_task( 2430 - ws.store(), 2431 - Id(99_999), 2432 - "orphan", 2433 - "", 2434 - Loc::Active, 2435 - "write", 2436 - None, 2437 - ) 2438 - .unwrap(); 2439 - assert!( 2440 - backend::list_active(ws.store()) 2441 - .unwrap() 2442 - .contains(&Id(99_999)) 2443 - ); 2444 - ws.clean().unwrap(); 2445 - assert!( 2446 - !backend::list_active(ws.store()) 2447 - .unwrap() 2448 - .contains(&Id(99_999)) 2449 - ); 2450 - assert!( 2451 - backend::list_archive(ws.store()) 2452 - .unwrap() 2453 - .contains(&Id(99_999)) 2454 - ); 2455 - 2456 - // command_remote (List/Add/Remove) 2457 - assert!(ws.read_remotes().unwrap().is_empty()); 2458 - ws.add_remote("up", "/tmp/p").unwrap(); 2459 - assert_eq!(ws.read_remotes().unwrap().len(), 1); 2460 - assert!(ws.add_remote("up", "/tmp/q").is_err()); // duplicate 2461 - assert!(ws.remove_remote("nope").is_err()); // nonexistent 2462 - ws.remove_remote("up").unwrap(); 2463 - assert!(ws.read_remotes().unwrap().is_empty()); 2464 - 2465 - // command_export: writes a zip with all blobs 2466 - let dest = ws.path.join("exp.zip"); 2467 - ws.export_zip(&dest).unwrap(); 2468 - assert!(dest.exists()); 2469 - let f = std::fs::File::open(&dest).unwrap(); 2470 - let zip = zip::ZipArchive::new(f).unwrap(); 2471 - assert!(zip.len() >= 2, "export contains at least index + tasks"); 2472 - std::fs::remove_file(&dest).unwrap(); 2473 - } 2474 - 2475 - #[test] 2476 - fn test_every_command_file_backend() { 2477 - let dir = tempfile::tempdir().unwrap(); 2478 - Workspace::init(dir.path().to_path_buf()).unwrap(); 2479 - let ws = Workspace::from_path(dir.path().to_path_buf()).unwrap(); 2480 - run_every_command(&ws); 2481 - } 2482 - 2483 - #[test] 2484 - fn test_every_command_git_backend() { 2485 - let dir = tempfile::tempdir().unwrap(); 2486 - run_git_init(dir.path()); 2487 - Workspace::init(dir.path().to_path_buf()).unwrap(); 2488 - let ws = Workspace::from_path(dir.path().to_path_buf()).unwrap(); 2489 - run_every_command(&ws); 2490 - } 2491 - 2492 - /// Two clones independently create tsk-1 offline. After B pulls, the 2493 - /// later-created (B's) is renumbered locally; A's content takes tsk-1, 2494 - /// and B's body link to tsk-1 from another task is rewritten to the new 2495 - /// id. 2496 - #[test] 2497 - fn test_pull_rebases_id_collisions() { 2498 - let dir = tempfile::tempdir().unwrap(); 2499 - let remote_dir = dir.path().join("remote.git"); 2500 - let a_dir = dir.path().join("a"); 2501 - let b_dir = dir.path().join("b"); 2502 - std::fs::create_dir_all(&remote_dir).unwrap(); 2503 - std::fs::create_dir_all(&a_dir).unwrap(); 2504 - std::fs::create_dir_all(&b_dir).unwrap(); 2505 - assert!( 2506 - std::process::Command::new("git") 2507 - .args(["init", "--bare", "-q"]) 2508 - .current_dir(&remote_dir) 2509 - .status() 2510 - .unwrap() 2511 - .success() 2512 - ); 2513 - let init_clone = |path: &std::path::Path| { 2514 - run_git_init(path); 2515 - std::process::Command::new("git") 2516 - .args(["remote", "add", "origin"]) 2517 - .arg(&remote_dir) 2518 - .current_dir(path) 2519 - .status() 2520 - .unwrap(); 2521 - Workspace::init(path.to_path_buf()).unwrap(); 2522 - Workspace::from_path(path.to_path_buf()).unwrap() 2523 - }; 2524 - let a = init_clone(&a_dir); 2525 - let b = init_clone(&b_dir); 2526 - 2527 - // A creates tsk-1 first. 2528 - let ta = a.new_task("a-task".into(), "from A".into()).unwrap(); 2529 - let id_a = ta.id; 2530 - a.push_task(ta).unwrap(); 2531 - // Sleep so B's created timestamp is strictly later. The rebase keys 2532 - // off seconds-resolution unix timestamps in the log. 2533 - std::thread::sleep(std::time::Duration::from_secs(2)); 2534 - // B creates tsk-1 too (offline; doesn't see A's push). B also has 2535 - // another task (tsk-2) whose body links to tsk-1 — that link must be 2536 - // rewritten to the new id post-rebase. 2537 - let tb = b.new_task("b-task".into(), "from B".into()).unwrap(); 2538 - let id_b = tb.id; 2539 - b.push_task(tb).unwrap(); 2540 - assert_eq!(id_a.0, 1); 2541 - assert_eq!(id_b.0, 1); 2542 - let tb2 = b 2543 - .new_task("b-other".into(), format!("see [[tsk-{}]]", id_b.0)) 2544 - .unwrap(); 2545 - let id_b2 = tb2.id; 2546 - b.handle_metadata(&tb2, None).unwrap(); 2547 - b.push_task(tb2).unwrap(); 2548 - 2549 - // A pushes; B pulls. 2550 - a.git_push_refs("origin").unwrap(); 2551 - b.git_pull_refs("origin").unwrap(); 2552 - 2553 - // Tsk-1 should now contain A's content. 2554 - let one = b.task(TaskIdentifier::Id(id_a)).unwrap(); 2555 - assert_eq!(one.title, "a-task", "tsk-1 should be A's after rebase"); 2556 - 2557 - // B's original tsk-1 should have been moved to a fresh id past 2. 2558 - let stack = b.read_stack().unwrap(); 2559 - let renumbered_id = stack 2560 - .iter() 2561 - .map(|i| i.id) 2562 - .find(|id| { 2563 - id.0 != id_a.0 2564 - && id.0 != id_b2.0 2565 - && b.task(TaskIdentifier::Id(*id)) 2566 - .map(|t| t.title == "b-task") 2567 - .unwrap_or(false) 2568 - }) 2569 - .expect("renumbered b-task in stack"); 2570 - assert!( 2571 - renumbered_id.0 >= 3, 2572 - "renumbered past collisions: {renumbered_id}" 2573 - ); 2574 - 2575 - // B's other task's body link should now point at the renumbered id. 2576 - let other = b.task(TaskIdentifier::Id(id_b2)).unwrap(); 2577 - assert!( 2578 - other.body.contains(&format!("[[tsk-{}]]", renumbered_id.0)), 2579 - "expected body to reference new id, got: {}", 2580 - other.body 2581 - ); 2582 - 2583 - // A `renumbered` log entry should exist on the new id. 2584 - let log = b.read_log(renumbered_id).unwrap(); 2585 - assert!( 2586 - log.iter().any(|e| e.event == "renumbered"), 2587 - "renumbered event missing: {:?}", 2588 - log 2589 - ); 2590 - } 2591 - 2592 - /// Two clones diverge: clone A pushes, clone B edits locally, then B 2593 - /// pulls. Mergeable refs (index, log) auto-merge; a divergent task body 2594 - /// is reported as a conflict. 2595 - #[test] 2596 - fn test_pull_resolves_or_reports_conflicts() { 2597 - let dir = tempfile::tempdir().unwrap(); 2598 - let remote_dir = dir.path().join("remote.git"); 2599 - let a_dir = dir.path().join("a"); 2600 - let b_dir = dir.path().join("b"); 2601 - std::fs::create_dir_all(&remote_dir).unwrap(); 2602 - std::fs::create_dir_all(&a_dir).unwrap(); 2603 - std::fs::create_dir_all(&b_dir).unwrap(); 2604 - 2605 - let s = std::process::Command::new("git") 2606 - .args(["init", "--bare", "-q"]) 2607 - .current_dir(&remote_dir) 2608 - .status() 2609 - .unwrap(); 2610 - assert!(s.success()); 2611 - 2612 - let init_clone = |path: &std::path::Path| { 2613 - run_git_init(path); 2614 - std::process::Command::new("git") 2615 - .args(["remote", "add", "origin"]) 2616 - .arg(&remote_dir) 2617 - .current_dir(path) 2618 - .status() 2619 - .unwrap(); 2620 - Workspace::init(path.to_path_buf()).unwrap(); 2621 - Workspace::from_path(path.to_path_buf()).unwrap() 2622 - }; 2623 - let a = init_clone(&a_dir); 2624 - let b = init_clone(&b_dir); 2625 - 2626 - // A pushes a task that B will start from. 2627 - let t = a.new_task("shared".into(), "v0".into()).unwrap(); 2628 - let id = t.id; 2629 - a.push_task(t).unwrap(); 2630 - a.git_push_refs("origin").unwrap(); 2631 - b.git_pull_refs("origin").unwrap(); 2632 - assert_eq!(b.task(TaskIdentifier::Id(id)).unwrap().title, "shared"); 2633 - 2634 - // Both diverge: 2635 - // - A pushes a second task (touches index + new tasks/2 + log/2). 2636 - // - B edits the original task body locally (touches tasks/1 + log/1). 2637 - let t2 = a.new_task("a-only".into(), "v1".into()).unwrap(); 2638 - let a2_id = t2.id; 2639 - a.push_task(t2).unwrap(); 2640 - a.git_push_refs("origin").unwrap(); 2641 - 2642 - let mut local = b.task(TaskIdentifier::Id(id)).unwrap(); 2643 - local.body = "v0-edit".into(); 2644 - b.save_task(&local).unwrap(); 2645 - 2646 - // B pulls: tasks/<a2_id> is new → take. index moved both sides → merge. 2647 - // log/<id> moved both sides → merge. tasks/1 moved on B only → keep 2648 - // local. So no conflicts. 2649 - b.git_pull_refs("origin").unwrap(); 2650 - // B's edit survived… 2651 - assert_eq!(b.task(TaskIdentifier::Id(id)).unwrap().body, "v0-edit"); 2652 - // …and A's new task arrived. 2653 - assert_eq!(b.task(TaskIdentifier::Id(a2_id)).unwrap().title, "a-only"); 2654 - // Stack contains both ids. 2655 - let ids: HashSet<Id> = b.read_stack().unwrap().iter().map(|i| i.id).collect(); 2656 - assert!(ids.contains(&id)); 2657 - assert!(ids.contains(&a2_id)); 2658 - 2659 - // Now both edit the same task body, then B pulls → conflict. 2660 - let mut on_a = a.task(TaskIdentifier::Id(id)).unwrap(); 2661 - on_a.body = "a-edit".into(); 2662 - a.save_task(&on_a).unwrap(); 2663 - a.git_push_refs("origin").unwrap(); 2664 - 2665 - let mut on_b = b.task(TaskIdentifier::Id(id)).unwrap(); 2666 - on_b.body = "b-edit".into(); 2667 - b.save_task(&on_b).unwrap(); 2668 - 2669 - let err = b.git_pull_refs("origin").unwrap_err(); 2670 - let msg = format!("{err}"); 2671 - assert!(msg.contains("conflicts on"), "{msg}"); 2672 - assert!( 2673 - msg.contains(&format!("default/tasks/{}", id.0)), 2674 - "expected the diverged task ref in error: {msg}" 2675 - ); 2676 - // B's local edit is preserved through the failed pull. 2677 - assert_eq!(b.task(TaskIdentifier::Id(id)).unwrap().body, "b-edit"); 617 + let t2 = ws.new_task("b".into(), "".into()).unwrap(); 618 + assert_eq!(t2.id.0, id1.0 + 1, "ids must not be reused after drop"); 2678 619 } 2679 620 2680 621 #[test] 2681 - fn test_git_push_pull_and_refspec_config() { 2682 - // Set up a bare "remote" and a working repo, both git-backed tsk 2683 - // workspaces. Push from one, pull into another. 2684 - let dir = tempfile::tempdir().unwrap(); 2685 - let remote_dir = dir.path().join("remote.git"); 2686 - let work_dir = dir.path().join("work"); 2687 - let clone_dir = dir.path().join("clone"); 2688 - std::fs::create_dir_all(&remote_dir).unwrap(); 2689 - std::fs::create_dir_all(&work_dir).unwrap(); 2690 - std::fs::create_dir_all(&clone_dir).unwrap(); 2691 - 2692 - // Bare remote. 2693 - let s = std::process::Command::new("git") 2694 - .args(["init", "--bare", "-q"]) 2695 - .current_dir(&remote_dir) 2696 - .status() 2697 - .unwrap(); 2698 - assert!(s.success()); 2699 - 2700 - // Working repo + tsk init. 2701 - run_git_init(&work_dir); 2702 - Workspace::init(work_dir.clone()).unwrap(); 2703 - let ws = Workspace::from_path(work_dir.clone()).unwrap(); 2704 - // Add the bare repo as `origin`. 2705 - let s = std::process::Command::new("git") 2706 - .args(["remote", "add", "origin"]) 2707 - .arg(&remote_dir) 2708 - .current_dir(&work_dir) 2709 - .status() 2710 - .unwrap(); 2711 - assert!(s.success()); 2712 - 2713 - // Pushing without configured refspecs (using the explicit refspec form). 2714 - let t = ws.new_task("task one".into(), "body".into()).unwrap(); 622 + fn edit_appends_history() { 623 + let (_d, ws) = fresh_workspace(); 624 + let t = ws.new_task("v1".into(), "body".into()).unwrap(); 2715 625 let id = t.id; 626 + let stable = t.stable.clone(); 2716 627 ws.push_task(t).unwrap(); 2717 - ws.git_push_refs("origin").unwrap(); 2718 - 2719 - // Confirm refs landed on the remote. 2720 - let out = std::process::Command::new("git") 2721 - .args(["--git-dir"]) 2722 - .arg(&remote_dir) 2723 - .args(["for-each-ref", "--format=%(refname)", "refs/tsk/"]) 2724 - .output() 2725 - .unwrap(); 2726 - let names = String::from_utf8_lossy(&out.stdout); 2727 - assert!( 2728 - names.contains(&format!("refs/tsk/default/tasks/{}", id.0)), 2729 - "{names}" 2730 - ); 2731 - assert!(names.contains("refs/tsk/default/index")); 2732 - 2733 - // Now configure refspecs on the working repo and confirm `git push origin` 2734 - // (with no refspec) sends refs/tsk/*. 2735 - ws.configure_git_remote_refspecs("origin").unwrap(); 2736 - let cfg = std::process::Command::new("git") 2737 - .args(["config", "--get-all", "remote.origin.push"]) 2738 - .current_dir(&work_dir) 2739 - .output() 2740 - .unwrap(); 2741 - let push_cfg = String::from_utf8_lossy(&cfg.stdout); 2742 - assert!( 2743 - push_cfg 2744 - .lines() 2745 - .any(|l| l.trim() == "refs/tsk/*:refs/tsk/*") 2746 - ); 2747 - // Idempotent: running again does not duplicate. 2748 - ws.configure_git_remote_refspecs("origin").unwrap(); 2749 - let cfg2 = std::process::Command::new("git") 2750 - .args(["config", "--get-all", "remote.origin.push"]) 2751 - .current_dir(&work_dir) 2752 - .output() 2753 - .unwrap(); 2754 - let push_cfg2 = String::from_utf8_lossy(&cfg2.stdout); 2755 - assert_eq!( 2756 - push_cfg 2757 - .lines() 2758 - .filter(|l| l.trim() == "refs/tsk/*:refs/tsk/*") 2759 - .count(), 2760 - push_cfg2 2761 - .lines() 2762 - .filter(|l| l.trim() == "refs/tsk/*:refs/tsk/*") 2763 - .count() 2764 - ); 2765 - 2766 - // Pull side: a fresh repo set up to fetch from the same remote, then 2767 - // pulling tsk refs in. 2768 - run_git_init(&clone_dir); 2769 - Workspace::init(clone_dir.clone()).unwrap(); 2770 - let cws = Workspace::from_path(clone_dir.clone()).unwrap(); 2771 - let s = std::process::Command::new("git") 2772 - .args(["remote", "add", "origin"]) 2773 - .arg(&remote_dir) 2774 - .current_dir(&clone_dir) 2775 - .status() 628 + let mut t = ws.task(TaskIdentifier::Id(id)).unwrap(); 629 + t.title = "v2".into(); 630 + ws.save_task(&t).unwrap(); 631 + let read = ws.task(TaskIdentifier::Id(id)).unwrap(); 632 + assert_eq!(read.title, "v2"); 633 + assert_eq!(read.stable, stable, "stable id must not change on edit"); 634 + let repo = ws.repo().unwrap(); 635 + let head = repo 636 + .find_reference(&stable.refname()) 637 + .unwrap() 638 + .target() 2776 639 .unwrap(); 2777 - assert!(s.success()); 2778 - 2779 - cws.git_pull_refs("origin").unwrap(); 2780 - // The pulled-in workspace can read the task. 2781 - let pulled = cws.task(TaskIdentifier::Id(id)).unwrap(); 2782 - assert_eq!(pulled.title, "task one"); 2783 - let stack = cws.read_stack().unwrap(); 2784 - assert!(stack.iter().any(|i| i.id == id)); 2785 - 2786 - // Errors when invoked on a file-backed workspace. 2787 - let file_dir = dir.path().join("file"); 2788 - std::fs::create_dir_all(&file_dir).unwrap(); 2789 - Workspace::init(file_dir.clone()).unwrap(); 2790 - let fws = Workspace::from_path(file_dir).unwrap(); 2791 - assert!(fws.git_push_refs("origin").is_err()); 2792 - assert!(fws.git_pull_refs("origin").is_err()); 2793 - assert!(fws.configure_git_remote_refspecs("origin").is_err()); 2794 - } 2795 - 2796 - #[test] 2797 - fn test_edit_log_records_mutations() { 2798 - let (_d, file, git) = setup_dual(); 2799 - for ws in [&file, &git] { 2800 - let t = ws.new_task("first".into(), "body".into()).unwrap(); 2801 - let id = t.id; 2802 - ws.push_task(t).unwrap(); 2803 - 2804 - ws.set_property(id, "priority", "high").unwrap(); 2805 - ws.unset_property(id, "priority").unwrap(); 2806 - // Edit via the same path command_edit uses. 2807 - let mut reread = ws.task(TaskIdentifier::Id(id)).unwrap(); 2808 - reread.title = "edited".into(); 2809 - ws.save_task(&reread).unwrap(); 2810 - 2811 - // Trigger handle_metadata so links-changed fires. 2812 - let other = ws.new_task("other".into(), "".into()).unwrap(); 2813 - let other_id = other.id; 2814 - ws.push_task(other).unwrap(); 2815 - let linker = Task { 2816 - id, 2817 - title: "edited".into(), 2818 - body: format!("see [[{other_id}]]"), 2819 - attributes: Default::default(), 2820 - }; 2821 - ws.handle_metadata(&linker, Some(HashSet::new())).unwrap(); 2822 - // Same links as before → no log entry added. 2823 - let mut same_links = HashSet::new(); 2824 - same_links.insert(other_id); 2825 - ws.handle_metadata(&linker, Some(same_links)).unwrap(); 2826 - 2827 - ws.drop(TaskIdentifier::Id(id)).unwrap(); 2828 - ws.reopen(TaskIdentifier::Id(id)).unwrap(); 2829 - 2830 - let log = ws.read_log(id).unwrap(); 2831 - let events: Vec<&str> = log.iter().map(|e| e.event.as_str()).collect(); 2832 - assert_eq!( 2833 - events, 2834 - vec![ 2835 - "created", 2836 - "prop-set", 2837 - "prop-unset", 2838 - "edited", 2839 - "links-changed", 2840 - "archived", 2841 - "reopened", 2842 - ], 2843 - "got: {events:?}" 2844 - ); 2845 - 2846 - // Logs are included in export. 2847 - let dest = ws.path.join("export.zip"); 2848 - ws.export_zip(&dest).unwrap(); 2849 - let f = std::fs::File::open(&dest).unwrap(); 2850 - let zip = zip::ZipArchive::new(f).unwrap(); 2851 - let names: std::collections::HashSet<String> = 2852 - zip.file_names().map(|s| s.to_string()).collect(); 2853 - assert!(names.contains(&format!("log/{}", id.0))); 2854 - std::fs::remove_file(&dest).unwrap(); 2855 - } 2856 - } 2857 - 2858 - #[test] 2859 - fn test_property_candidate_queries() { 2860 - let (_d, file, git) = setup_dual(); 2861 - for ws in [&file, &git] { 2862 - let t1 = ws 2863 - .new_task("a".into(), "see <https://x.example> end".into()) 2864 - .unwrap(); 2865 - let id1 = t1.id; 2866 - ws.push_task(t1).unwrap(); 2867 - let t2 = ws.new_task("b".into(), "and [[tsk-1]]".into()).unwrap(); 2868 - let id2 = t2.id; 2869 - ws.push_task(t2).unwrap(); 2870 - ws.set_property(id1, "priority", "high").unwrap(); 2871 - ws.set_property(id2, "priority", "low").unwrap(); 2872 - ws.set_property(id1, "tag", "urgent").unwrap(); 2873 - 2874 - let mut keys = ws.all_property_keys().unwrap(); 2875 - keys.sort(); 2876 - assert_eq!(keys, vec!["priority".to_string(), "tag".to_string()]); 2877 - 2878 - let mut vals = ws.property_values_for("priority").unwrap(); 2879 - vals.sort(); 2880 - assert_eq!(vals, vec!["high".to_string(), "low".to_string()]); 2881 - assert!(ws.property_values_for("missing").unwrap().is_empty()); 2882 - 2883 - let body_cands = ws.body_candidates(id1).unwrap(); 2884 - assert!(body_cands.iter().any(|c| c.contains("x.example"))); 2885 - let body_cands = ws.body_candidates(id2).unwrap(); 2886 - assert!(body_cands.iter().any(|c| c == &format!("[[{id1}]]"))); 2887 - } 640 + let commit = repo.find_commit(head).unwrap(); 641 + assert_eq!(commit.parent_count(), 1); 2888 642 } 2889 643 2890 644 #[test] 2891 - fn test_duplicates_property_maintains_duplicated_by_inverse() { 2892 - let (_d, file, git) = setup_dual(); 2893 - for ws in [&file, &git] { 2894 - let orig = ws.new_task("orig".into(), "".into()).unwrap(); 2895 - let orig_id = orig.id; 2896 - ws.push_task(orig).unwrap(); 2897 - let dup1 = ws.new_task("dup1".into(), "".into()).unwrap(); 2898 - let dup1_id = dup1.id; 2899 - ws.push_task(dup1).unwrap(); 2900 - let dup2 = ws.new_task("dup2".into(), "".into()).unwrap(); 2901 - let dup2_id = dup2.id; 2902 - ws.push_task(dup2).unwrap(); 2903 - 2904 - ws.set_property(dup1_id, "duplicates", &format!("[[{orig_id}]]")) 2905 - .unwrap(); 2906 - ws.set_property(dup2_id, "duplicates", &format!("[[{orig_id}]]")) 2907 - .unwrap(); 2908 - let dby = backend::read_attrs(ws.store(), orig_id) 2909 - .unwrap() 2910 - .get("duplicated-by") 2911 - .cloned() 2912 - .unwrap_or_default(); 2913 - assert!(dby.contains(&format!("[[{dup1_id}]]")), "{dby}"); 2914 - assert!(dby.contains(&format!("[[{dup2_id}]]")), "{dby}"); 2915 - 2916 - // Unset removes from inverse list. 2917 - ws.unset_property(dup1_id, "duplicates").unwrap(); 2918 - let dby = backend::read_attrs(ws.store(), orig_id) 2919 - .unwrap() 2920 - .get("duplicated-by") 2921 - .cloned() 2922 - .unwrap_or_default(); 2923 - assert!(!dby.contains(&format!("[[{dup1_id}]]"))); 2924 - assert!(dby.contains(&format!("[[{dup2_id}]]"))); 2925 - 2926 - // Self-reference rejected. 2927 - assert!( 2928 - ws.set_property(dup2_id, "duplicates", &format!("[[{dup2_id}]]")) 2929 - .is_err() 2930 - ); 2931 - // Cycle rejected (orig.duplicates = dup2 but dup2 already 2932 - // duplicates orig). 2933 - assert!( 2934 - ws.set_property(orig_id, "duplicates", &format!("[[{dup2_id}]]")) 2935 - .is_err() 2936 - ); 2937 - } 645 + fn share_to_other_namespace() { 646 + let (_d, ws) = fresh_workspace(); 647 + let t = ws.new_task("shared".into(), "".into()).unwrap(); 648 + let id_in_tsk = t.id; 649 + let stable = t.stable.clone(); 650 + ws.push_task(t).unwrap(); 651 + let h = ws.share(TaskIdentifier::Id(id_in_tsk), "alpha").unwrap(); 652 + ws.switch_namespace("alpha").unwrap(); 653 + let task_in_alpha = ws.task(TaskIdentifier::Id(Id(h))).unwrap(); 654 + assert_eq!(task_in_alpha.stable, stable); 655 + assert_eq!(task_in_alpha.title, "shared"); 2938 656 } 2939 657 2940 658 #[test] 2941 - fn test_parent_property_maintains_children_inverse() { 2942 - let (_d, file, git) = setup_dual(); 2943 - for ws in [&file, &git] { 2944 - let p = ws.new_task("parent".into(), "".into()).unwrap(); 2945 - let parent_id = p.id; 2946 - ws.push_task(p).unwrap(); 2947 - let c1 = ws.new_task("child1".into(), "".into()).unwrap(); 2948 - let c1_id = c1.id; 2949 - ws.push_task(c1).unwrap(); 2950 - let c2 = ws.new_task("child2".into(), "".into()).unwrap(); 2951 - let c2_id = c2.id; 2952 - ws.push_task(c2).unwrap(); 2953 - 2954 - // Set parents on both children → parent gets a children list. 2955 - ws.set_property(c1_id, "parent", &format!("[[{parent_id}]]")) 2956 - .unwrap(); 2957 - ws.set_property(c2_id, "parent", &format!("[[{parent_id}]]")) 2958 - .unwrap(); 2959 - let parent_props = backend::read_attrs(ws.store(), parent_id).unwrap(); 2960 - let children = parent_props.get("children").cloned().unwrap_or_default(); 2961 - assert!( 2962 - children.contains(&format!("[[{c1_id}]]")), 2963 - "expected c1 in {children}" 2964 - ); 2965 - assert!( 2966 - children.contains(&format!("[[{c2_id}]]")), 2967 - "expected c2 in {children}" 2968 - ); 2969 - 2970 - // Unset the parent on c1 → it disappears from parent's children. 2971 - ws.unset_property(c1_id, "parent").unwrap(); 2972 - let parent_props = backend::read_attrs(ws.store(), parent_id).unwrap(); 2973 - let children = parent_props.get("children").cloned().unwrap_or_default(); 2974 - assert!( 2975 - !children.contains(&format!("[[{c1_id}]]")), 2976 - "c1 should be gone: {children}" 2977 - ); 2978 - assert!(children.contains(&format!("[[{c2_id}]]"))); 2979 - 2980 - // Re-parent c2 to a different parent → c2 leaves old parent's 2981 - // children list. 2982 - let p2 = ws.new_task("parent2".into(), "".into()).unwrap(); 2983 - let p2_id = p2.id; 2984 - ws.push_task(p2).unwrap(); 2985 - ws.set_property(c2_id, "parent", &format!("[[{p2_id}]]")) 2986 - .unwrap(); 2987 - let old = backend::read_attrs(ws.store(), parent_id).unwrap(); 2988 - assert!(!old.contains_key("children"), "old parent should be empty"); 2989 - let new = backend::read_attrs(ws.store(), p2_id).unwrap(); 2990 - assert!( 2991 - new.get("children") 2992 - .unwrap() 2993 - .contains(&format!("[[{c2_id}]]")) 2994 - ); 2995 - 2996 - // Self-parent is rejected. 2997 - assert!( 2998 - ws.set_property(c2_id, "parent", &format!("[[{c2_id}]]")) 2999 - .is_err() 3000 - ); 3001 - // Cycle (p2.parent = c2 — c2's parent is already p2) is rejected. 3002 - assert!( 3003 - ws.set_property(p2_id, "parent", &format!("[[{c2_id}]]")) 3004 - .is_err() 3005 - ); 3006 - 3007 - // Non-link values store fine without inverse maintenance. 3008 - ws.set_property(c2_id, "tag", "important").unwrap(); 3009 - assert_eq!( 3010 - backend::read_attrs(ws.store(), c2_id) 3011 - .unwrap() 3012 - .get("tag") 3013 - .map(String::as_str), 3014 - Some("important") 3015 - ); 3016 - } 3017 - } 3018 - 3019 - #[test] 3020 - fn test_properties_set_unset_list_find() { 3021 - let (_d, file, git) = setup_dual(); 3022 - for ws in [&file, &git] { 3023 - // Push two tasks; mark one with priority=high. 3024 - let t1 = ws.new_task("first".into(), "body".into()).unwrap(); 3025 - let id1 = t1.id; 3026 - ws.push_task(t1).unwrap(); 3027 - let t2 = ws 3028 - .new_task("second".into(), "see [[tsk-1]]".into()) 3029 - .unwrap(); 3030 - let id2 = t2.id; 3031 - ws.handle_metadata(&t2, None).unwrap(); 3032 - ws.push_task(t2).unwrap(); 3033 - 3034 - ws.set_property(id1, "priority", "high").unwrap(); 3035 - ws.set_property(id1, "tag", "").unwrap(); 3036 - 3037 - // Stored properties round-trip. 3038 - let props = ws.properties(id1).unwrap(); 3039 - assert_eq!(props.get("priority").map(String::as_str), Some("high")); 3040 - assert_eq!(props.get("tag").map(String::as_str), Some("")); 3041 - 3042 - // Synthetic properties present. 3043 - assert_eq!(props.get("state").map(String::as_str), Some("open")); 3044 - assert_eq!(props.get("has-links").map(String::as_str), Some("false")); 3045 - // referenced-by on id1 contains id2 (the linker). 3046 - assert!( 3047 - props 3048 - .get("referenced-by") 3049 - .unwrap() 3050 - .contains(&format!("[[{id2}]]")) 3051 - ); 3052 - 3053 - let props2 = ws.properties(id2).unwrap(); 3054 - assert_eq!(props2.get("has-links").map(String::as_str), Some("true")); 3055 - assert!( 3056 - props2 3057 - .get("references") 3058 - .unwrap() 3059 - .contains(&format!("[[{id1}]]")) 3060 - ); 3061 - 3062 - // Find by stored property + value. 3063 - let by_priority = ws.find_by_property("priority", Some("high")).unwrap(); 3064 - assert_eq!(by_priority, vec![id1]); 3065 - // Find by presence (any value). 3066 - let any_priority = ws.find_by_property("priority", None).unwrap(); 3067 - assert_eq!(any_priority, vec![id1]); 3068 - // Find by synthetic property. 3069 - let open = ws.find_by_property("state", Some("open")).unwrap(); 3070 - assert!(open.contains(&id1) && open.contains(&id2)); 3071 - ws.drop(TaskIdentifier::Id(id2)).unwrap(); 3072 - let archived = ws.find_by_property("state", Some("archived")).unwrap(); 3073 - assert_eq!(archived, vec![id2]); 3074 - 3075 - // Unset removes the property. 3076 - ws.unset_property(id1, "priority").unwrap(); 3077 - assert!(!ws.properties(id1).unwrap().contains_key("priority")); 3078 - // Unset of non-existent is fine. 3079 - ws.unset_property(id1, "nope").unwrap(); 3080 - } 3081 - } 3082 - 3083 - #[test] 3084 - fn test_parsed_links_returns_all_kinds() { 3085 - // Verifies the data the `tsk links` command consumes: internal, 3086 - // foreign, raw URL, and labeled markdown link should all surface. 3087 - let body = "see <https://a.example> and [[tsk-1]] and [b](https://b.example) and [[gh-99]]"; 3088 - let parsed = parse_task(&format!("\n\n{body}")).expect("parse"); 3089 - let kinds: Vec<&str> = parsed 3090 - .links 3091 - .iter() 3092 - .map(|l| match l { 3093 - crate::task::ParsedLink::External(_) => "ext", 3094 - crate::task::ParsedLink::Internal(_) => "int", 3095 - crate::task::ParsedLink::Foreign { .. } => "for", 3096 - crate::task::ParsedLink::Namespaced { .. } => "ns", 3097 - }) 3098 - .collect(); 3099 - assert_eq!(kinds, vec!["ext", "int", "ext", "for"]); 3100 - } 3101 - 3102 - #[test] 3103 - fn test_parsed_namespaced_link() { 3104 - let body = "see [[default/tsk-1]] in default ns"; 3105 - let parsed = parse_task(&format!("\n\n{body}")).expect("parse"); 3106 - match parsed.links.as_slice() { 3107 - [crate::task::ParsedLink::Namespaced { namespace, id }] => { 3108 - assert_eq!(namespace, "default"); 3109 - assert_eq!(id.0, 1); 3110 - } 3111 - other => panic!("expected one Namespaced link, got {other:?}"), 3112 - } 3113 - } 3114 - 3115 - #[test] 3116 - fn test_git_backed_writes_create_commit_history() { 3117 - let dir = tempfile::tempdir().unwrap(); 3118 - let root = dir.path().to_path_buf(); 3119 - run_git_init(&root); 3120 - Workspace::init(root.clone()).unwrap(); 3121 - let ws = Workspace::from_path(root.clone()).unwrap(); 3122 - 3123 - let t = ws.new_task("first".into(), "v0".into()).unwrap(); 659 + fn assign_moves_to_target_inbox() { 660 + let (_d, ws) = fresh_workspace(); 661 + ws.create_queue("review", None).unwrap(); 662 + let t = ws.new_task("for review".into(), "".into()).unwrap(); 3124 663 let id = t.id; 3125 664 ws.push_task(t).unwrap(); 3126 - // Edit a few times to build a chain. 3127 - for body in ["v1", "v2", "v3"] { 3128 - let mut x = ws.task(TaskIdentifier::Id(id)).unwrap(); 3129 - x.body = body.into(); 3130 - ws.save_task(&x).unwrap(); 3131 - } 3132 - let repo = git2::Repository::open(root.join(".git")).unwrap(); 3133 - let r = repo 3134 - .find_reference(&format!("refs/tsk/default/tasks/{}", id.0)) 665 + let key = ws 666 + .assign_to_queue(TaskIdentifier::Id(id), "review") 3135 667 .unwrap(); 3136 - let head = r.target().unwrap(); 3137 - let mut commit = repo.find_commit(head).expect("ref points at a commit"); 3138 - let mut chain_len = 1; 3139 - while let Some(parent) = commit.parents().next() { 3140 - commit = parent; 3141 - chain_len += 1; 3142 - } 3143 - assert!( 3144 - chain_len >= 4, 3145 - "expected at least 4 commits (create + 3 edits), got {chain_len}" 3146 - ); 3147 - // Inbox refs stay blob-backed. 3148 - let other = ws.new_task("for-export".into(), "x".into()).unwrap(); 3149 - let other_id = other.id; 3150 - ws.push_task(other).unwrap(); 3151 - ws.export_to_namespace("alice", other_id).unwrap(); 3152 - ws.switch_namespace("alice").unwrap(); 3153 - let alice = Workspace::from_path(root.clone()).unwrap(); 3154 - let inbox = alice.list_inbox().unwrap(); 668 + let stack = ws.read_stack().unwrap(); 669 + assert!(stack.is_empty()); 670 + ws.switch_queue("review").unwrap(); 671 + let inbox = ws.list_inbox().unwrap(); 3155 672 assert_eq!(inbox.len(), 1); 3156 - let inbox_ref = repo 3157 - .find_reference(&format!("refs/tsk/alice/{}", inbox[0].inbox_key)) 3158 - .unwrap(); 3159 - assert!( 3160 - repo.find_commit(inbox_ref.target().unwrap()).is_err(), 3161 - "inbox refs should remain blob-backed" 3162 - ); 673 + assert_eq!(inbox[0].key, key); 674 + let accepted = ws.accept_inbox(&key).unwrap(); 675 + assert_eq!(accepted.0, id.0); 676 + let stack = ws.read_stack().unwrap(); 677 + assert_eq!(stack.len(), 1); 3163 678 } 3164 679 3165 680 #[test] 3166 - fn test_migrate_to_commit_history_converts_legacy_blob_refs() { 3167 - let dir = tempfile::tempdir().unwrap(); 3168 - let root = dir.path().to_path_buf(); 3169 - run_git_init(&root); 3170 - // Hand-write a marker so we can plant blob refs directly via the 3171 - // GitStore API (which still uses commit history); then we'll undo 3172 - // the commit wrapping for one ref and run the migration. 3173 - let tsk_dir = root.join(".tsk"); 3174 - std::fs::create_dir(&tsk_dir).unwrap(); 3175 - std::fs::write( 3176 - tsk_dir.join(backend::GIT_BACKED_MARKER), 3177 - root.join(".git").to_string_lossy().as_bytes(), 3178 - ) 3179 - .unwrap(); 3180 - // Plant a legacy blob ref. 3181 - let repo = git2::Repository::open(root.join(".git")).unwrap(); 3182 - let blob_oid = repo.blob(b"legacy content").unwrap(); 3183 - repo.reference("refs/tsk/default/tasks/1", blob_oid, true, "test setup") 3184 - .unwrap(); 3185 - 3186 - // Reading still works (auto-fallback). 3187 - let ws = Workspace::from_path(root.clone()).unwrap(); 3188 - assert_eq!( 3189 - ws.store().read("tasks/1").unwrap().as_deref(), 3190 - Some(&b"legacy content"[..]) 3191 - ); 3192 - 3193 - // Run the migration. 3194 - let n = backend::migrate_to_commit_history(&root.join(".git")).unwrap(); 3195 - assert_eq!(n, 1); 3196 - 3197 - // Now the ref points at a commit. 3198 - let r = repo.find_reference("refs/tsk/default/tasks/1").unwrap(); 3199 - assert!(repo.find_commit(r.target().unwrap()).is_ok()); 3200 - // And the content is preserved. 3201 - assert_eq!( 3202 - ws.store().read("tasks/1").unwrap().as_deref(), 3203 - Some(&b"legacy content"[..]) 3204 - ); 3205 - // Idempotent. 3206 - let n2 = backend::migrate_to_commit_history(&root.join(".git")).unwrap(); 3207 - assert_eq!(n2, 0); 3208 - } 3209 - 3210 - #[test] 3211 - fn test_resolve_namespaced_link_reads_sibling_namespace() { 3212 - let dir = tempfile::tempdir().unwrap(); 3213 - let root = dir.path().to_path_buf(); 3214 - run_git_init(&root); 3215 - Workspace::init(root.clone()).unwrap(); 3216 - let ws = Workspace::from_path(root.clone()).unwrap(); 3217 - 3218 - // Create a task in default. 3219 - let t = ws.new_task("the-original".into(), "body".into()).unwrap(); 681 + fn pull_only_when_can_pull() { 682 + let (_d, ws) = fresh_workspace(); 683 + ws.create_queue("private", Some(false)).unwrap(); 684 + ws.switch_queue("private").unwrap(); 685 + let t = ws.new_task("private task".into(), "".into()).unwrap(); 3220 686 let id = t.id; 3221 687 ws.push_task(t).unwrap(); 3222 - 3223 - // Switch to alice and look up the link from there. 3224 - ws.switch_namespace("alice").unwrap(); 3225 - let alice = Workspace::from_path(root.clone()).unwrap(); 3226 - let resolved = alice 3227 - .resolve_namespaced_link("default", id) 3228 - .unwrap() 3229 - .expect("should find original"); 3230 - assert_eq!(resolved.title, "the-original"); 3231 - assert_eq!(resolved.body, "body"); 3232 - 3233 - // Missing namespace → None. 3234 - assert!(alice.resolve_namespaced_link("nope", id).unwrap().is_none()); 3235 - // Missing id in real namespace → None. 3236 - assert!( 3237 - alice 3238 - .resolve_namespaced_link("default", Id(9999)) 3239 - .unwrap() 3240 - .is_none() 3241 - ); 688 + ws.switch_queue("tsk").unwrap(); 689 + let r = ws.pull_from_queue("private", TaskIdentifier::Id(id)); 690 + assert!(r.is_err(), "pull from can-pull=false queue must fail"); 691 + ws.create_queue("private", Some(true)).unwrap(); 692 + let pulled = ws.pull_from_queue("private", TaskIdentifier::Id(id)).unwrap(); 693 + assert_eq!(pulled.0, id.0); 694 + let stack = ws.read_stack().unwrap(); 695 + assert_eq!(stack.len(), 1); 3242 696 } 3243 697 3244 698 #[test] 3245 - fn test_export_and_accept_across_namespaces() { 3246 - let dir = tempfile::tempdir().unwrap(); 3247 - let root = dir.path().to_path_buf(); 3248 - run_git_init(&root); 3249 - Workspace::init(root.clone()).unwrap(); 3250 - let ws = Workspace::from_path(root.clone()).unwrap(); 3251 - 3252 - // Source task in default namespace. 3253 - let t = ws 3254 - .new_task("send me".into(), "see [[tsk-1]]".into()) 3255 - .unwrap(); 3256 - let src_id = t.id; 3257 - ws.push_task(t).unwrap(); 3258 - ws.set_property(src_id, "priority", "high").unwrap(); 3259 - 3260 - // Export to alice. 3261 - let key = ws.export_to_namespace("alice", src_id).unwrap(); 3262 - // Source got the assigned property. 3263 - let src_attrs = ws.properties(src_id).unwrap(); 699 + fn rot_tor_swap_round_trip() { 700 + let (_d, ws) = fresh_workspace(); 701 + let mut ids = Vec::new(); 702 + for n in 0..3 { 703 + let t = ws.new_task(format!("t{n}"), "".into()).unwrap(); 704 + ids.push(t.id); 705 + ws.push_task(t).unwrap(); 706 + } 707 + ws.swap_top().unwrap(); 708 + let s = ws.read_stack().unwrap(); 3264 709 assert_eq!( 3265 - src_attrs.get("assigned").map(String::as_str), 3266 - Some("[[alice/tsk-1]]") 3267 - ); 3268 - 3269 - // Switch to alice and inspect the inbox. 3270 - ws.switch_namespace("alice").unwrap(); 3271 - let alice = Workspace::from_path(root.clone()).unwrap(); 3272 - let items = alice.list_inbox().unwrap(); 3273 - assert_eq!(items.len(), 1); 3274 - assert_eq!(items[0].source_namespace, "default"); 3275 - assert_eq!(items[0].source_id, src_id.0); 3276 - assert_eq!(items[0].title, "send me"); 3277 - 3278 - // Accept it. 3279 - let new_id = alice.accept_inbox(&key).unwrap(); 3280 - let accepted = alice.task(TaskIdentifier::Id(new_id)).unwrap(); 3281 - assert_eq!(accepted.title, "send me"); 3282 - let accepted_props = alice.properties(new_id).unwrap(); 3283 - assert_eq!( 3284 - accepted_props.get("source").map(String::as_str), 3285 - Some(&format!("[[default/tsk-{}]]", src_id.0)[..]) 710 + s.iter().map(|e| e.id).collect::<Vec<_>>(), 711 + vec![ids[1], ids[2], ids[0]] 3286 712 ); 3287 - // priority property carried over. 713 + ws.swap_top().unwrap(); 714 + ws.rot().unwrap(); 715 + ws.tor().unwrap(); 716 + let s = ws.read_stack().unwrap(); 3288 717 assert_eq!( 3289 - accepted_props.get("priority").map(String::as_str), 3290 - Some("high") 718 + s.iter().map(|e| e.id).collect::<Vec<_>>(), 719 + vec![ids[2], ids[1], ids[0]] 3291 720 ); 3292 - // 'assigned' should NOT be inherited on the new copy. 3293 - assert!(!accepted_props.contains_key("assigned")); 3294 - // Inbox cleared. 3295 - assert!(alice.list_inbox().unwrap().is_empty()); 3296 - 3297 - // Cannot export to your own namespace. 3298 - let t2 = alice.new_task("local".into(), "".into()).unwrap(); 3299 - let local_id = t2.id; 3300 - alice.push_task(t2).unwrap(); 3301 - assert!(alice.export_to_namespace("alice", local_id).is_err()); 3302 - 3303 - // Logs include the cross-namespace events. 3304 - let src_log_events: Vec<String> = ws 3305 - .read_log(src_id) 3306 - .unwrap() 3307 - .iter() 3308 - .map(|e| e.event.clone()) 3309 - .collect(); 3310 - assert!(src_log_events.contains(&"assigned".to_string())); 3311 - let dst_log_events: Vec<String> = alice 3312 - .read_log(new_id) 3313 - .unwrap() 3314 - .iter() 3315 - .map(|e| e.event.clone()) 3316 - .collect(); 3317 - assert!(dst_log_events.contains(&"accepted".to_string())); 3318 - } 3319 - 3320 - #[test] 3321 - fn test_namespaces_isolate_state() { 3322 - let dir = tempfile::tempdir().unwrap(); 3323 - let root = dir.path().to_path_buf(); 3324 - run_git_init(&root); 3325 - Workspace::init(root.clone()).unwrap(); 3326 - let ws = Workspace::from_path(root.clone()).unwrap(); 3327 - assert_eq!(ws.namespace(), "default"); 3328 - 3329 - // Push a task in the default namespace. 3330 - let t = ws.new_task("default-task".into(), "x".into()).unwrap(); 3331 - let default_id = t.id; 3332 - ws.push_task(t).unwrap(); 3333 - 3334 - // Switch to a new namespace; stack should appear empty. 3335 - ws.switch_namespace("alice").unwrap(); 3336 - let ws2 = Workspace::from_path(root.clone()).unwrap(); 3337 - assert_eq!(ws2.namespace(), "alice"); 3338 - assert_eq!(ws2.read_stack().unwrap().iter().count(), 0); 3339 - // ID counter resets per-namespace because `next` is namespaced. 3340 - let alice_t = ws2.new_task("alice-task".into(), "y".into()).unwrap(); 3341 - assert_eq!(alice_t.id, Id(1)); 3342 - ws2.push_task(alice_t).unwrap(); 3343 - 3344 - // Switch back; the original task is still there. 3345 - ws2.switch_namespace("default").unwrap(); 3346 - let ws3 = Workspace::from_path(root.clone()).unwrap(); 3347 - let stack = ws3.read_stack().unwrap(); 3348 - assert_eq!(stack.iter().count(), 1); 3349 - assert_eq!(stack.iter().next().unwrap().id, default_id); 3350 - 3351 - // Both namespaces appear in the listing. 3352 - let mut nss = ws3.list_namespaces().unwrap(); 3353 - nss.sort(); 3354 - assert_eq!(nss, vec!["alice".to_string(), "default".to_string()]); 3355 - 3356 - // Cannot delete the active namespace. 3357 - assert!(ws3.delete_namespace("default").is_err()); 3358 - 3359 - // Deleting alice succeeds and reduces the namespace list. 3360 - let n = ws3.delete_namespace("alice").unwrap(); 3361 - assert!(n > 0); 3362 - assert_eq!(ws3.list_namespaces().unwrap(), vec!["default".to_string()]); 3363 - 3364 - // Invalid namespace names rejected. 3365 - assert!(ws3.switch_namespace("").is_err()); 3366 - assert!(ws3.switch_namespace("a/b").is_err()); 3367 - assert!(ws3.switch_namespace("a b").is_err()); 3368 - } 3369 - 3370 - #[test] 3371 - fn test_legacy_non_namespaced_refs_upgraded() { 3372 - // A repo whose refs were created before namespacing should get its 3373 - // refs/tsk/<key>/* moved under refs/tsk/default/ on first open. 3374 - let dir = tempfile::tempdir().unwrap(); 3375 - let root = dir.path().to_path_buf(); 3376 - run_git_init(&root); 3377 - // Manually init only the tsk marker (skip Workspace::init's namespace 3378 - // logic) so we can plant legacy refs. 3379 - let tsk_dir = root.join(".tsk"); 3380 - std::fs::create_dir(&tsk_dir).unwrap(); 3381 - std::fs::write( 3382 - tsk_dir.join(backend::GIT_BACKED_MARKER), 3383 - root.join(".git").to_string_lossy().as_bytes(), 3384 - ) 3385 - .unwrap(); 3386 - // Plant a legacy ref directly via the bare GitStore. 3387 - let bare = backend::GitStore::open(root.join(".git")).unwrap(); 3388 - <dyn Store>::write(&bare, "tasks/1", b"legacy\n\nbody").unwrap(); 3389 - 3390 - // Open via Workspace — should auto-migrate. 3391 - let ws = Workspace::from_path(root.clone()).unwrap(); 3392 - assert_eq!(ws.namespace(), "default"); 3393 - let t = ws.task(TaskIdentifier::Id(Id(1))).unwrap(); 3394 - assert_eq!(t.title, "legacy"); 3395 - // Confirm at the git ref level: refs/tsk/tasks/1 is gone, the 3396 - // namespaced refs/tsk/default/tasks/1 is present. 3397 - let repo = git2::Repository::open(root.join(".git")).unwrap(); 3398 - assert!(repo.find_reference("refs/tsk/tasks/1").is_err()); 3399 - assert!(repo.find_reference("refs/tsk/default/tasks/1").is_ok()); 3400 - } 3401 - 3402 - #[test] 3403 - fn test_attrs_round_trip() { 3404 - let (_d, file, git) = setup_dual(); 3405 - for ws in [&file, &git] { 3406 - let mut t = ws.new_task("t".into(), "b".into()).unwrap(); 3407 - t.attributes.insert("k1".into(), "v1".into()); 3408 - t.attributes.insert("k2".into(), "v2".into()); 3409 - ws.save_task(&t).unwrap(); 3410 - let reread = ws.task(TaskIdentifier::Id(t.id)).unwrap(); 3411 - assert_eq!(reread.attributes.get("k1"), Some(&"v1".to_string())); 3412 - assert_eq!(reread.attributes.get("k2"), Some(&"v2".to_string())); 3413 - } 3414 721 } 3415 722 }
+173
tests/multi_user.rs
··· 1 + //! End-to-end multi-clone tests. Spins up a bare "origin" repo and two 2 + //! working clones; exercises the user-visible commands across them via the 3 + //! compiled `tsk` binary. 4 + 5 + use std::path::{Path, PathBuf}; 6 + use std::process::Command; 7 + 8 + fn tsk_bin() -> PathBuf { 9 + // Cargo sets CARGO_BIN_EXE_<name> for each [[bin]] when running tests. 10 + PathBuf::from(env!("CARGO_BIN_EXE_tsk")) 11 + } 12 + 13 + fn run(cmd: &mut Command) -> (i32, String, String) { 14 + let out = cmd.output().expect("spawn failed"); 15 + ( 16 + out.status.code().unwrap_or(-1), 17 + String::from_utf8_lossy(&out.stdout).into_owned(), 18 + String::from_utf8_lossy(&out.stderr).into_owned(), 19 + ) 20 + } 21 + 22 + fn git(dir: &Path, args: &[&str]) -> String { 23 + let out = Command::new("git") 24 + .current_dir(dir) 25 + .args(args) 26 + .output() 27 + .expect("git spawn failed"); 28 + assert!( 29 + out.status.success(), 30 + "git {args:?} failed: {}", 31 + String::from_utf8_lossy(&out.stderr) 32 + ); 33 + String::from_utf8_lossy(&out.stdout).into_owned() 34 + } 35 + 36 + fn tsk(dir: &Path, args: &[&str]) -> (i32, String, String) { 37 + run(Command::new(tsk_bin()).current_dir(dir).args(args)) 38 + } 39 + 40 + fn tsk_ok(dir: &Path, args: &[&str]) -> String { 41 + let (code, stdout, stderr) = tsk(dir, args); 42 + assert_eq!( 43 + code, 0, 44 + "tsk {args:?} failed in {dir:?}: stdout={stdout} stderr={stderr}" 45 + ); 46 + stdout 47 + } 48 + 49 + fn make_clone(origin: &Path, dest: &Path, name: &str, email: &str) { 50 + let _ = Command::new("git") 51 + .args(["clone", "-q", origin.to_str().unwrap(), dest.to_str().unwrap()]) 52 + .status() 53 + .expect("git clone"); 54 + git(dest, &["config", "user.name", name]); 55 + git(dest, &["config", "user.email", email]); 56 + // Configure tsk refspecs so plain `git push`/`git fetch` carries them too. 57 + tsk_ok(dest, &["git-setup"]); 58 + } 59 + 60 + fn setup_two_clones() -> (tempfile::TempDir, PathBuf, PathBuf) { 61 + let dir = tempfile::tempdir().unwrap(); 62 + let origin = dir.path().join("origin.git"); 63 + Command::new("git") 64 + .args(["init", "-q", "--bare", origin.to_str().unwrap()]) 65 + .status() 66 + .unwrap(); 67 + let alice = dir.path().join("alice"); 68 + let bob = dir.path().join("bob"); 69 + make_clone(&origin, &alice, "Alice", "a@x"); 70 + make_clone(&origin, &bob, "Bob", "b@x"); 71 + // Each clone needs at least one commit on the default branch before 72 + // tsk can push refs (origin must accept ref updates). 73 + std::fs::write(alice.join("README"), b"hi").unwrap(); 74 + git(&alice, &["add", "README"]); 75 + git(&alice, &["commit", "-q", "-m", "init"]); 76 + git(&alice, &["push", "-q", "origin", "HEAD:refs/heads/main"]); 77 + git(&bob, &["pull", "-q", "origin", "main"]); 78 + (dir, alice, bob) 79 + } 80 + 81 + #[test] 82 + fn share_and_pull_between_clones() { 83 + let (_dir, alice, bob) = setup_two_clones(); 84 + 85 + // Alice creates a task and pushes. 86 + tsk_ok(&alice, &["push", "Alice's task"]); 87 + tsk_ok(&alice, &["git-push"]); 88 + 89 + // Bob pulls and sees nothing in his stack (different namespace mapping 90 + // exists, but the queue index is shared). 91 + tsk_ok(&bob, &["git-pull"]); 92 + let listed = tsk_ok(&bob, &["list"]); 93 + // Bob hasn't bound a human id in his namespace, but he's on the same 94 + // default namespace `tsk`, and he pulled Alice's namespace state too — 95 + // so the task IS visible. 96 + assert!( 97 + listed.contains("Alice's task"), 98 + "shared default namespace + queue: bob should see alice's task. got: {listed:?}" 99 + ); 100 + } 101 + 102 + #[test] 103 + fn assign_to_other_queue_visible_after_push_pull() { 104 + let (_dir, alice, bob) = setup_two_clones(); 105 + 106 + // Bob creates a "review" queue ahead of time. 107 + tsk_ok(&bob, &["queue", "create", "review"]); 108 + tsk_ok(&bob, &["git-push"]); 109 + 110 + // Alice pulls the queue, creates a task, assigns to review. 111 + tsk_ok(&alice, &["git-pull"]); 112 + tsk_ok(&alice, &["push", "needs review"]); 113 + let assign_out = tsk_ok(&alice, &["assign", "review", "-R", ""]); 114 + assert!(assign_out.contains("Assigned to review"), "got {assign_out}"); 115 + tsk_ok(&alice, &["git-push"]); 116 + 117 + // Bob switches to review, pulls, sees inbox. 118 + tsk_ok(&bob, &["queue", "switch", "review"]); 119 + tsk_ok(&bob, &["git-pull"]); 120 + let inbox = tsk_ok(&bob, &["inbox", "-R", ""]); 121 + assert!( 122 + inbox.contains("needs review"), 123 + "bob should see assigned task in inbox: {inbox}" 124 + ); 125 + } 126 + 127 + #[test] 128 + fn concurrent_pushes_dont_clobber() { 129 + let (_dir, alice, bob) = setup_two_clones(); 130 + 131 + // Both push a task concurrently before either has pulled. 132 + tsk_ok(&alice, &["push", "alice work"]); 133 + tsk_ok(&bob, &["push", "bob work"]); 134 + 135 + // Alice pushes first. 136 + tsk_ok(&alice, &["git-push"]); 137 + // Bob's push will be rejected by git's non-fast-forward protection on 138 + // the namespace ref (since alice already updated it). Verify it errors 139 + // and didn't silently overwrite. 140 + let (code, _, stderr) = tsk(&bob, &["git-push"]); 141 + assert_ne!( 142 + code, 0, 143 + "bob's push should fail (non-fast-forward); stderr={stderr}" 144 + ); 145 + 146 + // After bob pulls, his local refs are overwritten with alice's state 147 + // (v1 has no merge driver for refs/tsk/queues/* — that's tracked for 148 + // a follow-up). The safety property we DO have is that the failed 149 + // push above didn't silently win. 150 + let (_, _, _) = tsk(&bob, &["git-pull"]); 151 + let listed = tsk_ok(&bob, &["list"]); 152 + assert!( 153 + listed.contains("alice work"), 154 + "after force-pull bob inherits alice's queue state: {listed}" 155 + ); 156 + } 157 + 158 + #[test] 159 + fn share_into_namespace_round_trip() { 160 + let (_dir, alice, _bob) = setup_two_clones(); 161 + 162 + let push_out = tsk_ok(&alice, &["push", "to share"]); 163 + drop(push_out); 164 + tsk_ok(&alice, &["share", "alpha", "-r", "0"]); 165 + 166 + // Switch to alpha; the task should be visible there. 167 + tsk_ok(&alice, &["namespace", "switch", "alpha"]); 168 + // The shared task isn't on alpha's queue (queues are per-queue, not 169 + // per-namespace), but `tsk show -T tsk-1` should resolve the binding. 170 + let (code, stdout, stderr) = tsk(&alice, &["show", "-T", "tsk-1"]); 171 + assert_eq!(code, 0, "show should succeed: stderr={stderr}"); 172 + assert!(stdout.contains("to share"), "got {stdout}"); 173 + }