A file-based task manager
0
fork

Configure Feed

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

Store tsk refs as commit-backed history

Each refs/tsk/<ns>/<key> ref now points at a commit chain instead of a
single blob. The commit's tree carries one file (`content`) holding the
blob; future writes append commits with the prior tip as parent. `git
log refs/tsk/<ns>/tasks/<id>` walks the audit trail; `git show
refs/tsk/<ns>/tasks/<id>:content` reads the current state.

Inbox refs (`inbox/<src>-<id>`) intentionally stay blob-backed — they
are short-lived (deleted on accept) and gain nothing from history.

Storage:
- Store gains `write_with_meta(key, data, event, detail)` (with `write`
now defaulting to `event="write"`). FileStore ignores the meta;
GitStore turns it into a commit message via format_commit_message,
which is a single function for easy format changes later.
- GitStore::read peels through commit→tree→`content`, falling back to
raw blob so it keeps reading legacy/inbox refs unchanged.
- merge_blob produces a merge commit (parents = local + remote) when
either side is commit-backed; falls back to a blob OID otherwise.
- Cross-namespace link rewrites in the id-collision rebase now go
through write_with_meta so each ref-update lands as a commit.

Migration: `tsk migrate-history` walks every refs/tsk/<ns>/<key>
(except inbox/*) and wraps any remaining blob ref in a single
"migrated" commit. Idempotent — already-commit refs are skipped.

A few high-traffic call sites pass rich event metadata through:
- new_task → `tsk(tasks/N): created`
- save_task → `tsk(tasks/N): edited` / `tsk(attrs/N): edited`
- set_property / unset_property → `tsk(attrs/N): prop-set <key>` etc.
Other writes still get the generic "write" subject; opting another
caller into rich messages is a one-call-site change.

Tests cover the commit-chain shape (4 commits after a create + 3
edits), inbox refs staying blob-backed, the migration converting
legacy blob refs, and idempotency.

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

+378 -38
+180 -16
src/backend.rs
··· 14 14 15 15 use crate::errors::{Error, Result}; 16 16 use crate::workspace::{Id, Remote}; 17 - use git2::{ObjectType, Reference, Repository}; 17 + use git2::{Reference, Repository}; 18 18 use std::collections::{BTreeMap, HashSet}; 19 19 use std::fs::{self, OpenOptions}; 20 20 use std::io::Write; ··· 29 29 /// A logical blob store. Keys are forward-slash separated strings. 30 30 pub trait Store: Send + Sync { 31 31 fn read(&self, key: &str) -> Result<Option<Vec<u8>>>; 32 - fn write(&self, key: &str, data: &[u8]) -> Result<()>; 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<()>; 33 45 fn delete(&self, key: &str) -> Result<()>; 34 46 fn exists(&self, key: &str) -> Result<bool>; 35 47 /// List all keys with the given prefix (no trailing slash). Returns full keys. 36 48 fn list(&self, prefix: &str) -> Result<Vec<String>>; 37 49 } 38 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 + 39 70 // ─── FileStore ────────────────────────────────────────────────────────────── 40 71 41 72 pub struct FileStore { ··· 62 93 } 63 94 } 64 95 65 - fn write(&self, key: &str, data: &[u8]) -> Result<()> { 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. 66 104 let p = self.path(key); 67 105 if let Some(parent) = p.parent() { 68 106 fs::create_dir_all(parent)?; ··· 194 232 } 195 233 } 196 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 + 197 302 impl Store for GitStore { 198 303 fn read(&self, key: &str) -> Result<Option<Vec<u8>>> { 199 304 let repo = self.repo()?; 200 305 let Some(r) = try_ref(&repo, &self.refname(key))? else { 201 306 return Ok(None); 202 307 }; 203 - let blob = r.peel(ObjectType::Blob)?; 204 - Ok(Some( 205 - blob.as_blob() 206 - .ok_or_else(|| Error::Parse("not a blob".into()))? 207 - .content() 208 - .to_vec(), 209 - )) 308 + let Some(target) = r.target() else { 309 + return Ok(None); 310 + }; 311 + Ok(Some(read_blob_at(&repo, target)?)) 210 312 } 211 313 212 - fn write(&self, key: &str, data: &[u8]) -> Result<()> { 314 + fn write_with_meta( 315 + &self, 316 + key: &str, 317 + data: &[u8], 318 + event: &str, 319 + detail: Option<&str>, 320 + ) -> Result<()> { 213 321 let repo = self.repo()?; 214 - let oid = repo.blob(data)?; 215 - repo.reference(&self.refname(key), oid, true, "tsk write")?; 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)?; 216 350 Ok(()) 217 351 } 218 352 ··· 292 426 } 293 427 294 428 pub fn write_task(store: &dyn Store, id: Id, title: &str, body: &str, loc: Loc) -> Result<()> { 429 + write_task_with_event(store, id, title, body, loc, "write", None) 430 + } 431 + 432 + pub fn write_task_with_event( 433 + store: &dyn Store, 434 + id: Id, 435 + title: &str, 436 + body: &str, 437 + loc: Loc, 438 + event: &str, 439 + detail: Option<&str>, 440 + ) -> Result<()> { 295 441 let payload = format!("{}\n\n{}", title.trim(), body.trim()); 296 - store.write(&task_key(id, loc == Loc::Archived), payload.as_bytes())?; 297 - Ok(()) 442 + store.write_with_meta( 443 + &task_key(id, loc == Loc::Archived), 444 + payload.as_bytes(), 445 + event, 446 + detail, 447 + ) 298 448 } 299 449 300 450 pub fn move_task(store: &dyn Store, id: Id, to: Loc) -> Result<()> { ··· 369 519 } 370 520 371 521 pub fn write_attrs(store: &dyn Store, id: Id, attrs: &BTreeMap<String, String>) -> Result<()> { 522 + write_attrs_with_event(store, id, attrs, "write", None) 523 + } 524 + 525 + pub fn write_attrs_with_event( 526 + store: &dyn Store, 527 + id: Id, 528 + attrs: &BTreeMap<String, String>, 529 + event: &str, 530 + detail: Option<&str>, 531 + ) -> Result<()> { 372 532 let body = attrs 373 533 .iter() 374 534 .map(|(k, v)| format!("{k}\t{v}\n")) 375 535 .collect::<String>(); 376 - write_or_delete(store, &format!("attrs/{}", id.0), &body) 536 + let key = format!("attrs/{}", id.0); 537 + if body.is_empty() { 538 + return store.delete(&key); 539 + } 540 + store.write_with_meta(&key, body.as_bytes(), event, detail) 377 541 } 378 542 379 543 pub fn read_backlinks(store: &dyn Store, id: Id) -> Result<HashSet<Id>> {
+19
src/main.rs
··· 260 260 /// task data is copied into refs/tsk/* and the on-disk files are removed. 261 261 Migrate, 262 262 263 + /// Convert blob-backed refs/tsk/<ns>/* refs to commit-backed history 264 + /// (one commit per past mutation; future writes append commits). Inbox 265 + /// blobs are intentionally left blob-backed. 266 + MigrateHistory, 267 + 263 268 /// Print the event log. Without -T, prints every event in the current 264 269 /// namespace, newest first, in git-log style. With -T, scopes to one task. 265 270 Log { ··· 482 487 Commands::Accept { key } => command_accept(dir, key), 483 488 Commands::Bundle { output } => command_bundle(dir, output), 484 489 Commands::Migrate => command_migrate(dir), 490 + Commands::MigrateHistory => command_migrate_history(dir), 485 491 Commands::Reopen { task_id } => command_reopen(dir, task_id), 486 492 Commands::Log { tsk_id } => command_log(dir, tsk_id), 487 493 Commands::Prop { action } => command_prop(dir, action), ··· 917 923 }; 918 924 let id = ws.accept_inbox(&key)?; 919 925 eprintln!("Accepted as {id}"); 926 + Ok(()) 927 + } 928 + 929 + fn command_migrate_history(dir: PathBuf) -> Result<()> { 930 + let ws = Workspace::from_path(dir)?; 931 + if !ws.is_git_backed() { 932 + return Err(errors::Error::Parse( 933 + "migrate-history only applies to git-backed workspaces".into(), 934 + )); 935 + } 936 + let marker = std::fs::read_to_string(ws.path.join(backend::GIT_BACKED_MARKER))?; 937 + let n = backend::migrate_to_commit_history(&PathBuf::from(marker.trim()))?; 938 + eprintln!("Converted {n} blob refs to commit-backed history."); 920 939 Ok(()) 921 940 } 922 941
+179 -22
src/workspace.rs
··· 125 125 local_loses: bool, 126 126 } 127 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()); 134 + } 135 + Ok(git2::Signature::now("tsk", "tsk@local")?) 136 + } 137 + 128 138 enum PullAction { 129 139 /// Local already matches remote — nothing to do. 130 140 Skip, ··· 370 380 371 381 pub fn new_task(&self, title: String, body: String) -> Result<Task> { 372 382 let id = self.next_id()?; 373 - backend::write_task(self.store(), id, &title, &body, Loc::Active)?; 383 + backend::write_task_with_event( 384 + self.store(), 385 + id, 386 + &title, 387 + &body, 388 + Loc::Active, 389 + "created", 390 + None, 391 + )?; 374 392 self.log(id, "created", None)?; 375 393 Ok(Task { 376 394 id, ··· 431 449 Some(l) => l, 432 450 None => Loc::Active, 433 451 }; 434 - backend::write_task(self.store(), task.id, &task.title, &task.body, loc)?; 435 - backend::write_attrs(self.store(), task.id, &task.attributes)?; 452 + backend::write_task_with_event( 453 + self.store(), 454 + task.id, 455 + &task.title, 456 + &task.body, 457 + loc, 458 + "edited", 459 + None, 460 + )?; 461 + backend::write_attrs_with_event(self.store(), task.id, &task.attributes, "edited", None)?; 436 462 self.log(task.id, "edited", None)?; 437 463 // After editing, refresh stack title for this id. 438 464 self.update_stack_title(task.id, &task.title)?; ··· 482 508 } 483 509 let mut attrs = backend::read_attrs(self.store(), id)?; 484 510 attrs.insert(key.to_string(), value.to_string()); 485 - backend::write_attrs(self.store(), id, &attrs)?; 511 + backend::write_attrs_with_event(self.store(), id, &attrs, "prop-set", Some(key))?; 486 512 self.log(id, "prop-set", Some(key))?; 487 513 if old_target != new_target { 488 514 if let Some(t) = old_target { ··· 496 522 } else { 497 523 let mut attrs = backend::read_attrs(self.store(), id)?; 498 524 attrs.insert(key.to_string(), value.to_string()); 499 - backend::write_attrs(self.store(), id, &attrs)?; 525 + backend::write_attrs_with_event(self.store(), id, &attrs, "prop-set", Some(key))?; 500 526 self.log(id, "prop-set", Some(key)) 501 527 } 502 528 } ··· 506 532 let mut attrs = backend::read_attrs(self.store(), id)?; 507 533 let removed = attrs.remove(key); 508 534 if let Some(prev) = removed { 509 - backend::write_attrs(self.store(), id, &attrs)?; 535 + backend::write_attrs_with_event(self.store(), id, &attrs, "prop-unset", Some(key))?; 510 536 self.log(id, "prop-unset", Some(key))?; 511 537 if let Some(pair) = inverse_pair_for(key) 512 538 && let Some(t) = parse_internal_link(&prev) ··· 1124 1150 Ok(()) 1125 1151 } 1126 1152 1127 - /// Read a blob by its OID. 1153 + /// Read the content blob given a ref's target OID. Handles both commit- 1154 + /// backed refs (peeling through tree/content) and legacy blob refs. 1128 1155 fn read_oid(&self, repo: &git2::Repository, oid: git2::Oid) -> Result<Vec<u8>> { 1129 - let blob = repo.find_blob(oid)?; 1130 - Ok(blob.content().to_vec()) 1156 + backend::read_blob_at(repo, oid) 1131 1157 } 1132 1158 1133 1159 /// Walk the post-fetch shadow looking for ids that exist on both sides ··· 1431 1457 let to_link = format!("[[{our_ns}/tsk-{}]]", new.0); 1432 1458 let prefix = "refs/tsk/"; 1433 1459 let our_prefix = format!("refs/tsk/{our_ns}/"); 1434 - let mut updates: Vec<(String, Vec<u8>)> = Vec::new(); 1460 + // Per-namespace GitStores so each write goes through the 1461 + // commit-backed write_with_meta path. 1462 + let marker = std::fs::read_to_string(self.path.join(backend::GIT_BACKED_MARKER))?; 1463 + let git_dir = PathBuf::from(marker.trim()); 1464 + let mut updates: Vec<(String, String, String, Vec<u8>)> = Vec::new(); // (ns, key, refname, bytes) 1435 1465 for r in repo.references()? { 1436 1466 let r = r?; 1437 1467 let Some(name) = r.name() else { continue }; ··· 1439 1469 continue; 1440 1470 } 1441 1471 let Some(oid) = r.target() else { continue }; 1442 - let Ok(blob) = repo.find_blob(oid) else { 1443 - continue; 1444 - }; 1445 - let text = String::from_utf8_lossy(blob.content()); 1472 + let bytes = backend::read_blob_at(repo, oid).unwrap_or_default(); 1473 + let text = String::from_utf8_lossy(&bytes); 1446 1474 let new_text = text.replace(&from_link, &to_link); 1447 1475 if new_text != text { 1448 - updates.push((name.to_string(), new_text.into_bytes())); 1476 + // refs/tsk/<ns>/<key> 1477 + let rest = name.strip_prefix(prefix).unwrap_or(""); 1478 + let (ns, key) = rest.split_once('/').unwrap_or(("", "")); 1479 + if !ns.is_empty() && !key.is_empty() { 1480 + updates.push(( 1481 + ns.to_string(), 1482 + key.to_string(), 1483 + name.to_string(), 1484 + new_text.into_bytes(), 1485 + )); 1486 + } 1449 1487 } 1450 1488 } 1451 - for (refname, bytes) in updates { 1452 - let new_oid = repo.blob(&bytes)?; 1453 - repo.reference(&refname, new_oid, true, "tsk renumber cross-ns")?; 1489 + for (ns, key, _refname, bytes) in updates { 1490 + let store = backend::GitStore::open_namespace(git_dir.clone(), ns)?; 1491 + <dyn Store>::write_with_meta( 1492 + &store, 1493 + &key, 1494 + &bytes, 1495 + "renumbered-from", 1496 + Some(&format!("tsk-{}", old.0)), 1497 + )?; 1454 1498 } 1455 1499 Ok(()) 1456 1500 } ··· 1514 1558 Ok(()) 1515 1559 } 1516 1560 1517 - /// Three-way merge for union-mergeable refs (`log/*` and `index`). 1561 + /// Three-way merge for union-mergeable refs (`log/*`, `index`, `next`). 1562 + /// Returns the OID to point the local ref at — a merge commit for 1563 + /// commit-backed keys (parents = local + remote), or a plain blob OID 1564 + /// for the legacy/inbox blob-ref case. 1518 1565 fn merge_blob( 1519 1566 &self, 1520 1567 repo: &git2::Repository, ··· 1532 1579 let merged = if rel.starts_with("log/") || rel.ends_with("/log") { 1533 1580 merge_log(&local_text, &remote_text) 1534 1581 } else if rel == "next" || rel.ends_with("/next") { 1535 - // next counter: always take the max so neither clone reissues an id. 1536 1582 let l: u32 = local_text.trim().parse().unwrap_or(1); 1537 1583 let r: u32 = remote_text.trim().parse().unwrap_or(1); 1538 1584 format!("{}\n", l.max(r)) 1539 1585 } else { 1540 - // index: union of stack item lines, preserving local order then 1541 - // appending remote-only items in their relative order. 1542 1586 merge_index(&local_text, &remote_text) 1543 1587 }; 1588 + // If both sides are commit-backed, write a merge commit; otherwise 1589 + // fall back to a plain blob (inbox keys, or transitional state). 1590 + let local_commit = local.and_then(|o| repo.find_commit(o).ok()); 1591 + let remote_commit = repo.find_commit(remote).ok(); 1592 + if local_commit.is_some() || remote_commit.is_some() { 1593 + let blob_oid = repo.blob(merged.as_bytes())?; 1594 + let mut tb = repo.treebuilder(None)?; 1595 + tb.insert("content", blob_oid, 0o100644)?; 1596 + let tree_oid = tb.write()?; 1597 + let tree = repo.find_tree(tree_oid)?; 1598 + let sig = git_sig(repo)?; 1599 + let parents: Vec<&git2::Commit> = [&local_commit, &remote_commit] 1600 + .iter() 1601 + .filter_map(|c| c.as_ref()) 1602 + .collect(); 1603 + let msg = format!("tsk({rel}): merge"); 1604 + return Ok(repo.commit(None, &sig, &sig, &msg, &tree, &parents)?); 1605 + } 1544 1606 Ok(repo.blob(merged.as_bytes())?) 1545 1607 } 1546 1608 ··· 2926 2988 } 2927 2989 other => panic!("expected one Namespaced link, got {other:?}"), 2928 2990 } 2991 + } 2992 + 2993 + #[test] 2994 + fn test_git_backed_writes_create_commit_history() { 2995 + let dir = tempfile::tempdir().unwrap(); 2996 + let root = dir.path().to_path_buf(); 2997 + run_git_init(&root); 2998 + Workspace::init(root.clone()).unwrap(); 2999 + let ws = Workspace::from_path(root.clone()).unwrap(); 3000 + 3001 + let t = ws.new_task("first".into(), "v0".into()).unwrap(); 3002 + let id = t.id; 3003 + ws.push_task(t).unwrap(); 3004 + // Edit a few times to build a chain. 3005 + for body in ["v1", "v2", "v3"] { 3006 + let mut x = ws.task(TaskIdentifier::Id(id)).unwrap(); 3007 + x.body = body.into(); 3008 + ws.save_task(&x).unwrap(); 3009 + } 3010 + let repo = git2::Repository::open(root.join(".git")).unwrap(); 3011 + let r = repo 3012 + .find_reference(&format!("refs/tsk/default/tasks/{}", id.0)) 3013 + .unwrap(); 3014 + let head = r.target().unwrap(); 3015 + let mut commit = repo.find_commit(head).expect("ref points at a commit"); 3016 + let mut chain_len = 1; 3017 + while let Some(parent) = commit.parents().next() { 3018 + commit = parent; 3019 + chain_len += 1; 3020 + } 3021 + assert!( 3022 + chain_len >= 4, 3023 + "expected at least 4 commits (create + 3 edits), got {chain_len}" 3024 + ); 3025 + // Inbox refs stay blob-backed. 3026 + let other = ws.new_task("for-export".into(), "x".into()).unwrap(); 3027 + let other_id = other.id; 3028 + ws.push_task(other).unwrap(); 3029 + ws.export_to_namespace("alice", other_id).unwrap(); 3030 + ws.switch_namespace("alice").unwrap(); 3031 + let alice = Workspace::from_path(root.clone()).unwrap(); 3032 + let inbox = alice.list_inbox().unwrap(); 3033 + assert_eq!(inbox.len(), 1); 3034 + let inbox_ref = repo 3035 + .find_reference(&format!("refs/tsk/alice/{}", inbox[0].inbox_key)) 3036 + .unwrap(); 3037 + assert!( 3038 + repo.find_commit(inbox_ref.target().unwrap()).is_err(), 3039 + "inbox refs should remain blob-backed" 3040 + ); 3041 + } 3042 + 3043 + #[test] 3044 + fn test_migrate_to_commit_history_converts_legacy_blob_refs() { 3045 + let dir = tempfile::tempdir().unwrap(); 3046 + let root = dir.path().to_path_buf(); 3047 + run_git_init(&root); 3048 + // Hand-write a marker so we can plant blob refs directly via the 3049 + // GitStore API (which still uses commit history); then we'll undo 3050 + // the commit wrapping for one ref and run the migration. 3051 + let tsk_dir = root.join(".tsk"); 3052 + std::fs::create_dir(&tsk_dir).unwrap(); 3053 + std::fs::write( 3054 + tsk_dir.join(backend::GIT_BACKED_MARKER), 3055 + root.join(".git").to_string_lossy().as_bytes(), 3056 + ) 3057 + .unwrap(); 3058 + // Plant a legacy blob ref. 3059 + let repo = git2::Repository::open(root.join(".git")).unwrap(); 3060 + let blob_oid = repo.blob(b"legacy content").unwrap(); 3061 + repo.reference("refs/tsk/default/tasks/1", blob_oid, true, "test setup") 3062 + .unwrap(); 3063 + 3064 + // Reading still works (auto-fallback). 3065 + let ws = Workspace::from_path(root.clone()).unwrap(); 3066 + assert_eq!( 3067 + ws.store().read("tasks/1").unwrap().as_deref(), 3068 + Some(&b"legacy content"[..]) 3069 + ); 3070 + 3071 + // Run the migration. 3072 + let n = backend::migrate_to_commit_history(&root.join(".git")).unwrap(); 3073 + assert_eq!(n, 1); 3074 + 3075 + // Now the ref points at a commit. 3076 + let r = repo.find_reference("refs/tsk/default/tasks/1").unwrap(); 3077 + assert!(repo.find_commit(r.target().unwrap()).is_ok()); 3078 + // And the content is preserved. 3079 + assert_eq!( 3080 + ws.store().read("tasks/1").unwrap().as_deref(), 3081 + Some(&b"legacy content"[..]) 3082 + ); 3083 + // Idempotent. 3084 + let n2 = backend::migrate_to_commit_history(&root.join(".git")).unwrap(); 3085 + assert_eq!(n2, 0); 2929 3086 } 2930 3087 2931 3088 #[test]