Something went wrong. Try again.
A git-based task manager
Something went wrong. Try again.
Rust
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003//! Storage backends for tsk workspaces.//!//! A [`Store`] is a logical key/value blob store. Two impls are provided://!//! - [`FileStore`] keeps blobs as files under `.tsk/`. Used when `tsk init` runs//! outside a git repository.//! - [`GitStore`] stores each blob as a git blob, addressed by a ref under//! `refs/tsk/`. Used when `tsk init` runs inside a git repository — the git//! refs are the only durable storage; nothing is cached on disk.//!//! Higher-level operations (tasks, attrs, backlinks, index, remotes) are//! implemented as free functions over `dyn Store` so both backends share a//! single implementation.
use crate::errors::{Error, Result};use crate::workspace::{Id, Remote};use git2::{Reference, Repository};use std::collections::{BTreeMap, HashSet};use std::fs::{self, OpenOptions};use std::io::Write;use std::path::{Path, PathBuf};use std::str::FromStr;
pub const GIT_BACKED_MARKER: &str = "git-backed";pub const NAMESPACE_FILE: &str = "namespace";pub const DEFAULT_NAMESPACE: &str = "default";const REF_ROOT: &str = "refs/tsk";
/// A logical blob store. Keys are forward-slash separated strings.pub trait Store: Send + Sync { fn read(&self, key: &str) -> Result<Option<Vec<u8>>>; fn write(&self, key: &str, data: &[u8]) -> Result<()> { self.write_with_meta(key, data, "write", None) } /// Same as [`write`] but with a structured event/detail attached. For /// the commit-backed git store this becomes the commit message; for the /// file store it's discarded. fn write_with_meta( &self, key: &str, data: &[u8], event: &str, detail: Option<&str>, ) -> Result<()>; fn delete(&self, key: &str) -> Result<()>; fn exists(&self, key: &str) -> Result<bool>; /// List all keys with the given prefix (no trailing slash). Returns full keys. fn list(&self, prefix: &str) -> Result<Vec<String>>;}
/// Format a commit message for a write to `key`. Single function so the layout/// is easy to change later (subject + structured trailer for now).pub fn format_commit_message(key: &str, event: &str, detail: Option<&str>) -> String { let subject = match detail { Some(d) => format!("tsk({key}): {event} {d}"), None => format!("tsk({key}): {event}"), }; format!( "{subject}\n\n# tsk-meta\nkey: {key}\nevent: {event}\ndetail: {}\n", detail.unwrap_or("") )}
/// `inbox/<src-ns>-<src-id>` blobs are intentionally not commit-backed — they/// are super transient (deleted on accept) and have no useful history.fn is_blob_only_key(key: &str) -> bool { key.starts_with("inbox/")}
// ─── FileStore ──────────────────────────────────────────────────────────────
pub struct FileStore { pub root: PathBuf,}
impl FileStore { pub fn new(root: PathBuf) -> Self { Self { root } }
fn path(&self, key: &str) -> PathBuf { self.root.join(key) }}
impl Store for FileStore { fn read(&self, key: &str) -> Result<Option<Vec<u8>>> { let p = self.path(key); match fs::read(&p) { Ok(data) => Ok(Some(data)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e.into()), } }
fn write_with_meta( &self, key: &str, data: &[u8], _event: &str, _detail: Option<&str>, ) -> Result<()> { // Meta is discarded; the file backend has no commit history to attach to. let p = self.path(key); if let Some(parent) = p.parent() { fs::create_dir_all(parent)?; } let tmp = p.with_extension("tmp"); let mut f = OpenOptions::new() .write(true) .create(true) .truncate(true) .open(&tmp)?; f.write_all(data)?; f.sync_all()?; fs::rename(&tmp, &p)?; Ok(()) }
fn delete(&self, key: &str) -> Result<()> { match fs::remove_file(self.path(key)) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(e.into()), } }
fn exists(&self, key: &str) -> Result<bool> { Ok(self.path(key).exists()) }
fn list(&self, prefix: &str) -> Result<Vec<String>> { let dir = self.path(prefix); if !dir.is_dir() { return Ok(Vec::new()); } let mut out = Vec::new(); for entry in fs::read_dir(&dir)? { let entry = entry?; if entry.file_type()?.is_file() && let Some(name) = entry.file_name().to_str() { out.push(format!("{prefix}/{name}")); } } Ok(out) }}
// ─── GitStore ───────────────────────────────────────────────────────────────
pub struct GitStore { git_dir: PathBuf, namespace: String,}
impl GitStore { pub fn open(git_dir: PathBuf) -> Result<Self> { Self::open_namespace(git_dir, DEFAULT_NAMESPACE.to_string()) }
pub fn open_namespace(git_dir: PathBuf, namespace: String) -> Result<Self> { Repository::open(&git_dir)?; Ok(Self { git_dir, namespace }) }
fn repo(&self) -> Result<Repository> { Ok(Repository::open(&self.git_dir)?) }
/// Prefix every namespace's refs share, e.g. `refs/tsk/<ns>`. fn ns_prefix(&self) -> String { format!("{REF_ROOT}/{}", self.namespace) }
fn refname(&self, key: &str) -> String { format!("{}/{}", self.ns_prefix(), key) }
/// Names of every ref starting with the given prefix. fn refs_starting_with(&self, prefix: &str) -> Result<Vec<String>> { Ok(self .repo()? .references()? .filter_map(|r| r.ok().and_then(|r| r.name().map(str::to_string))) .filter(|n| n.starts_with(prefix)) .collect()) }
/// Number of refs currently under this store's namespace. pub fn namespace_ref_count(&self) -> Result<usize> { Ok(self .refs_starting_with(&format!("{}/", self.ns_prefix()))? .len()) }
/// Delete every ref under this store's namespace. Returns the count. pub fn delete_namespace_refs(&self) -> Result<usize> { let repo = self.repo()?; let names = self.refs_starting_with(&format!("{}/", self.ns_prefix()))?; let count = names.len(); for n in names { if let Some(mut r) = try_ref(&repo, &n)? { r.delete()?; } } Ok(count) }
/// List the namespaces present in this repo (any directory under refs/tsk/ /// containing at least one ref). pub fn list_namespaces(&self) -> Result<Vec<String>> { let strip = format!("{REF_ROOT}/"); let mut out: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); for name in self.refs_starting_with(&strip)? { if let Some(rest) = name.strip_prefix(&strip) && let Some((ns, _)) = rest.split_once('/') { out.insert(ns.to_string()); } } Ok(out.into_iter().collect()) }}
/// `find_reference` translating NotFound to None.fn try_ref<'r>(repo: &'r Repository, name: &str) -> Result<Option<Reference<'r>>> { match repo.find_reference(name) { Ok(r) => Ok(Some(r)), Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None), Err(e) => Err(e.into()), }}
/// Read the underlying content blob given a ref's target OID. Handles both/// commit-backed refs (peel commit→tree→content file) and legacy blob refs/// (return the blob directly), so we keep working through migrations.pub fn read_blob_at(repo: &Repository, oid: git2::Oid) -> Result<Vec<u8>> { if let Ok(commit) = repo.find_commit(oid) { let tree = commit.tree()?; let entry = tree .get_name("content") .ok_or_else(|| Error::Parse("commit tree missing 'content'".into()))?; let blob = entry.to_object(repo)?.peel_to_blob()?; return Ok(blob.content().to_vec()); } let blob = repo.find_blob(oid)?; Ok(blob.content().to_vec())}
/// Author signature for tsk-generated commits. Falls back to a tsk-specific/// signature if the user hasn't configured `user.name` / `user.email`.fn git_signature(repo: &Repository) -> Result<git2::Signature<'static>> { if let Ok(s) = repo.signature() { return Ok(s.to_owned()); } Ok(git2::Signature::now("tsk", "tsk@local")?)}
/// One-shot migration: convert every blob-backed ref under `refs/tsk/<ns>/...`/// to a single-commit chain, except for inbox/* (intentionally blob-backed)./// Idempotent — already-commit refs are skipped.pub fn migrate_to_commit_history(git_dir: &Path) -> Result<usize> { let repo = Repository::open(git_dir)?; let prefix = format!("{REF_ROOT}/"); let mut converted = 0usize; let names: Vec<String> = repo .references()? .filter_map(|r| r.ok().and_then(|r| r.name().map(str::to_string))) .filter(|n| n.starts_with(&prefix)) .collect(); let sig = git_signature(&repo)?; for refname in names { let Some(rest) = refname.strip_prefix(&prefix) else { continue; }; let Some((_ns, key)) = rest.split_once('/') else { continue; }; if is_blob_only_key(key) { continue; } let r = repo.find_reference(&refname)?; let Some(target) = r.target() else { continue }; // Already a commit? skip. if repo.find_commit(target).is_ok() { continue; } // Build a one-file tree wrapping the existing blob and commit it. let mut tb = repo.treebuilder(None)?; tb.insert("content", target, 0o100644)?; let tree_oid = tb.write()?; let tree = repo.find_tree(tree_oid)?; let msg = format_commit_message(key, "migrated", None); let commit_oid = repo.commit(None, &sig, &sig, &msg, &tree, &[])?; repo.reference(&refname, commit_oid, true, &msg)?; converted += 1; } Ok(converted)}
impl Store for GitStore { fn read(&self, key: &str) -> Result<Option<Vec<u8>>> { let repo = self.repo()?; let Some(r) = try_ref(&repo, &self.refname(key))? else { return Ok(None); }; let Some(target) = r.target() else { return Ok(None); }; Ok(Some(read_blob_at(&repo, target)?)) }
fn write_with_meta( &self, key: &str, data: &[u8], event: &str, detail: Option<&str>, ) -> Result<()> { let repo = self.repo()?; let refname = self.refname(key); if is_blob_only_key(key) { let oid = repo.blob(data)?; repo.reference(&refname, oid, true, "tsk write")?; return Ok(()); } // Build a one-file tree {content: <blob>} and commit it onto the // existing chain (if any) for this ref. let blob_oid = repo.blob(data)?; let mut tb = repo.treebuilder(None)?; tb.insert("content", blob_oid, 0o100644)?; let tree_oid = tb.write()?; let tree = repo.find_tree(tree_oid)?; let sig = git_signature(&repo)?; let parent_commit = match try_ref(&repo, &refname)? { Some(r) => r.target().and_then(|oid| repo.find_commit(oid).ok()), None => None, }; // If the parent's tree already matches, skip — don't pile up no-op commits. if let Some(parent) = &parent_commit && parent.tree_id() == tree_oid { return Ok(()); } let parents: Vec<&git2::Commit> = parent_commit.iter().collect(); let msg = format_commit_message(key, event, detail); let commit_oid = repo.commit(None, &sig, &sig, &msg, &tree, &parents)?; repo.reference(&refname, commit_oid, true, &msg)?; Ok(()) }
fn delete(&self, key: &str) -> Result<()> { let repo = self.repo()?; if let Some(mut r) = try_ref(&repo, &self.refname(key))? { r.delete()?; } Ok(()) }
fn exists(&self, key: &str) -> Result<bool> { Ok(try_ref(&self.repo()?, &self.refname(key))?.is_some()) }
fn list(&self, prefix: &str) -> Result<Vec<String>> { let repo = self.repo()?; let strip = format!("{}/", self.ns_prefix()); repo.references_glob(&format!("{}/{}/*", self.ns_prefix(), prefix))? .filter_map(|r| { r.ok() .and_then(|r| { r.name() .and_then(|n| n.strip_prefix(&strip)) .map(str::to_string) }) .map(Ok) }) .collect() }}
// ─── High-level operations over any Store ───────────────────────────────────
pub fn next_id(store: &dyn Store) -> Result<Id> { let cur = store .read("next")? .map(|b| String::from_utf8_lossy(&b).trim().to_string()) .unwrap_or_else(|| "1".to_string()); let id: u32 = cur.parse().unwrap_or(1); store.write("next", format!("{}\n", id + 1).as_bytes())?; Ok(Id(id))}
fn task_key(id: Id, archived: bool) -> String { let bucket = if archived { "archive" } else { "tasks" }; format!("{bucket}/{}", id.0)}
#[derive(Copy, Clone, Eq, PartialEq, Debug)]pub enum Loc { Active, Archived,}
pub fn task_location(store: &dyn Store, id: Id) -> Result<Option<Loc>> { if store.exists(&task_key(id, false))? { Ok(Some(Loc::Active)) } else if store.exists(&task_key(id, true))? { Ok(Some(Loc::Archived)) } else { Ok(None) }}
pub fn read_task(store: &dyn Store, id: Id) -> Result<Option<(String, String, Loc)>> { for (loc, archived) in [(Loc::Active, false), (Loc::Archived, true)] { if let Some(data) = store.read(&task_key(id, archived))? { let text = String::from_utf8_lossy(&data); let mut parts = text.splitn(2, '\n'); let title = parts.next().unwrap_or("").trim().to_string(); let body = parts.next().unwrap_or("").trim().to_string(); return Ok(Some((title, body, loc))); } } Ok(None)}
pub fn write_task( store: &dyn Store, id: Id, title: &str, body: &str, loc: Loc, event: &str, detail: Option<&str>,) -> Result<()> { let payload = format!("{}\n\n{}", title.trim(), body.trim()); store.write_with_meta( &task_key(id, loc == Loc::Archived), payload.as_bytes(), event, detail, )}
pub fn move_task(store: &dyn Store, id: Id, to: Loc) -> Result<()> { let from_archived = to == Loc::Active; let from_key = task_key(id, from_archived); let to_key = task_key(id, to == Loc::Archived); if from_key == to_key { return Ok(()); } let data = store .read(&from_key)? .ok_or_else(|| Error::Parse(format!("task {id} not present at {from_key}")))?; store.write(&to_key, &data)?; store.delete(&from_key)?; Ok(())}
pub fn list_active(store: &dyn Store) -> Result<Vec<Id>> { list_bucket(store, "tasks")}
pub fn list_archive(store: &dyn Store) -> Result<Vec<Id>> { list_bucket(store, "archive")}
fn list_bucket(store: &dyn Store, bucket: &str) -> Result<Vec<Id>> { let prefix = format!("{bucket}/"); let mut ids: Vec<Id> = store .list(bucket)? .iter() .filter_map(|k| { k.strip_prefix(&prefix)? .trim_end_matches(".tsk") .parse() .ok() .map(Id) }) .collect(); ids.sort_by_key(|i| i.0); Ok(ids)}
/// Read+lossy-decode a blob, returning empty string when absent.pub fn read_text_blob(store: &dyn Store, key: &str) -> Result<String> { read_text(store, key)}
fn read_text(store: &dyn Store, key: &str) -> Result<String> { Ok(store .read(key)? .map(|b| String::from_utf8_lossy(&b).into_owned()) .unwrap_or_default())}
/// Write `body` to `key`, or delete the blob if `body` is empty.fn write_or_delete(store: &dyn Store, key: &str, body: &str) -> Result<()> { if body.is_empty() { store.delete(key) } else { store.write(key, body.as_bytes()) }}
pub fn read_attrs(store: &dyn Store, id: Id) -> Result<BTreeMap<String, String>> { Ok(read_text(store, &format!("attrs/{}", id.0))? .lines() .filter_map(|l| { l.split_once('\t') .map(|(k, v)| (k.to_string(), v.to_string())) }) .collect())}
pub fn write_attrs( store: &dyn Store, id: Id, attrs: &BTreeMap<String, String>, event: &str, detail: Option<&str>,) -> Result<()> { let body = attrs .iter() .map(|(k, v)| format!("{k}\t{v}\n")) .collect::<String>(); let key = format!("attrs/{}", id.0); if body.is_empty() { return store.delete(&key); } store.write_with_meta(&key, body.as_bytes(), event, detail)}
pub fn read_backlinks(store: &dyn Store, id: Id) -> Result<HashSet<Id>> { Ok(read_text(store, &format!("backlinks/{}", id.0))? .split(',') .filter_map(|t| Id::from_str(t.trim()).ok()) .collect())}
pub fn write_backlinks(store: &dyn Store, id: Id, links: &HashSet<Id>) -> Result<()> { write_or_delete( store, &format!("backlinks/{}", id.0), &itertools::join(links, ","), )}
/// One line of a task's edit log. Lines are tab-separated:/// `<unix-ts>\t<event>\t<detail>\t<author>` — empty fields allowed.#[derive(Clone, Debug, Eq, PartialEq)]pub struct LogEntry { pub id: Id, pub timestamp: u64, pub event: String, pub detail: String, pub author: String,}
impl LogEntry { fn parse(id: Id, line: &str) -> Option<Self> { let mut p = line.splitn(4, '\t'); let timestamp = p.next()?.parse().ok()?; Some(Self { id, timestamp, event: p.next().unwrap_or("").to_string(), detail: p.next().unwrap_or("").to_string(), author: p.next().unwrap_or("").to_string(), }) }}
/// Append a single log entry for a task. The blob `log/<id>` grows over time;/// it is never rewritten.pub fn append_log( store: &dyn Store, id: Id, event: &str, detail: Option<&str>, author: &str,) -> Result<()> { let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let line = format!("{ts}\t{event}\t{}\t{author}\n", detail.unwrap_or("")); let key = format!("log/{}", id.0); let existing = read_text(store, &key)?; store.write(&key, format!("{existing}{line}").as_bytes())}
pub fn read_log(store: &dyn Store, id: Id) -> Result<Vec<LogEntry>> { Ok(read_text(store, &format!("log/{}", id.0))? .lines() .filter_map(|l| LogEntry::parse(id, l)) .collect())}
/// Serialized representation of a task pending in another namespace's inbox.////// On-blob format (inbox/<inbox-id>):/// ```text/// source\t<src-namespace>\t<src-id>/// attr\t<key>\t<value>/// attr\t<key>\t<value>/// ---/// <title>////// <body>/// ```pub struct InboxPayload { pub source_namespace: String, pub source_id: u32, pub title: String, pub body: String, pub attrs: BTreeMap<String, String>,}
impl InboxPayload { pub fn serialize(&self) -> String { let mut s = format!("source\t{}\t{}\n", self.source_namespace, self.source_id); for (k, v) in &self.attrs { s.push_str(&format!("attr\t{k}\t{v}\n")); } s.push_str("---\n"); s.push_str(&format!("{}\n\n{}", self.title.trim(), self.body.trim())); s }
pub fn parse(text: &str) -> Result<Self> { let mut lines = text.lines(); let mut source_namespace = String::new(); let mut source_id: u32 = 0; let mut attrs = BTreeMap::new(); for line in &mut lines { if line == "---" { break; } let parts: Vec<&str> = line.splitn(3, '\t').collect(); match parts.as_slice() { ["source", ns, id] => { source_namespace = ns.to_string(); source_id = id .parse() .map_err(|_| Error::Parse(format!("invalid inbox source id: {id}")))?; } ["attr", k, v] => { attrs.insert(k.to_string(), v.to_string()); } _ => {} } } let rest: String = lines.collect::<Vec<_>>().join("\n"); let mut split = rest.splitn(2, "\n\n"); let title = split.next().unwrap_or("").trim().to_string(); let body = split.next().unwrap_or("").trim().to_string(); Ok(Self { source_namespace, source_id, title, body, attrs, }) }}
/// Stable inbox key for a (namespace, source-id) pair so re-exports overwrite/// the same slot rather than piling up.pub fn inbox_key(src_namespace: &str, src_id: u32) -> String { format!("inbox/{src_namespace}-{src_id}")}
/// Read every per-task log in the workspace and merge into a single feed/// sorted by timestamp ascending.pub fn read_all_logs(store: &dyn Store) -> Result<Vec<LogEntry>> { let mut all = Vec::new(); for key in store.list("log")? { if let Some(idstr) = key.strip_prefix("log/") && let Ok(n) = idstr.parse::<u32>() { all.extend(read_log(store, Id(n))?); } } all.sort_by_key(|e| e.timestamp); Ok(all)}
pub fn read_remotes(store: &dyn Store) -> Result<Vec<Remote>> { Ok(read_text(store, "remotes")? .lines() .map(str::trim) .filter(|l| !l.is_empty() && !l.starts_with('#')) .filter_map(|l| l.split_once('\t')) .map(|(p, path)| Remote { prefix: p.trim().into(), path: PathBuf::from(path.trim()), }) .collect())}
pub fn write_remotes(store: &dyn Store, remotes: &[Remote]) -> Result<()> { let body: String = remotes .iter() .map(|r| format!("{}\t{}\n", r.prefix, r.path.display())) .collect(); write_or_delete(store, "remotes", &body)}
// ─── Detection / construction ──────────────────────────────────────────────
pub fn detect_git_dir(start: &Path) -> Option<PathBuf> { crate::util::find_parent_with_dir(start.to_path_buf(), ".git") .ok() .flatten()}
pub fn read_namespace(tsk_dir: &Path) -> String { fs::read_to_string(tsk_dir.join(NAMESPACE_FILE)) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| DEFAULT_NAMESPACE.to_string())}
pub fn write_namespace(tsk_dir: &Path, namespace: &str) -> Result<()> { fs::write(tsk_dir.join(NAMESPACE_FILE), namespace.as_bytes())?; Ok(())}
pub fn store_for(tsk_dir: &Path) -> Result<Box<dyn Store>> { let marker = tsk_dir.join(GIT_BACKED_MARKER); if marker.exists() { let git_dir = PathBuf::from(fs::read_to_string(&marker)?.trim()); // First: rename any non-namespaced refs into the default namespace, so // workspaces created before namespacing keep working seamlessly. let probe = GitStore::open(git_dir.clone())?; upgrade_to_namespaced(&probe)?; let ns = read_namespace(tsk_dir); let store = GitStore::open_namespace(git_dir, ns)?; upgrade_legacy_keys(&store)?; Ok(Box::new(store)) } else { Ok(Box::new(FileStore::new(tsk_dir.to_path_buf()))) }}
/// Move any non-namespaced refs (`refs/tsk/<key>`, `refs/tsk/<bucket>/<id>`)/// into the `default` namespace (`refs/tsk/default/...`). Idempotent.fn upgrade_to_namespaced(probe: &GitStore) -> Result<()> { let repo = probe.repo()?; let strip = format!("{REF_ROOT}/"); let mut moves: Vec<(String, String)> = Vec::new(); for r in repo.references_glob(&format!("{REF_ROOT}/*"))? { let r = r?; let Some(name) = r.name() else { continue }; let Some(rest) = name.strip_prefix(&strip) else { continue; }; // Skip already-namespaced refs: first segment is a known top-level key, // any other first segment is treated as a namespace. let first = rest.split('/').next().unwrap_or(""); let is_legacy = matches!( first, "tasks" | "archive" | "attrs" | "backlinks" | "index" | "next" | "remotes" ); if is_legacy { moves.push(( name.to_string(), format!("{REF_ROOT}/{DEFAULT_NAMESPACE}/{rest}"), )); } } for (old, new) in moves { if let Some(r) = try_ref(&repo, &old)? && let Some(oid) = r.target() { repo.reference(&new, oid, true, "tsk namespace upgrade")?; if let Some(mut r) = try_ref(&repo, &old)? { r.delete()?; } } } Ok(())}
/// Rename legacy-scheme refs (`tasks/tsk-N.tsk`) to the current scheme/// (`tasks/N`). Older versions of the git backend named blobs after the file/// path used by the file backend; the current scheme uses just the integer id./// Runs on every open so stale workspaces self-heal on first use.fn upgrade_legacy_keys(store: &dyn Store) -> Result<()> { for bucket in ["tasks", "archive"] { for key in store.list(bucket)? { // key looks like "tasks/<name>" — strip prefix to get the leaf. let leaf = key.split('/').next_back().unwrap_or(""); // Legacy names look like "tsk-N.tsk". New names are just "N". if let Some(num) = leaf .strip_prefix("tsk-") .and_then(|s| s.strip_suffix(".tsk")) && num.parse::<u32>().is_ok() { let new_key = format!("{bucket}/{num}"); if store.exists(&new_key)? { // New-scheme blob already present; just drop the legacy one. store.delete(&key)?; continue; } if let Some(data) = store.read(&key)? { store.write(&new_key, &data)?; store.delete(&key)?; } } } } Ok(())}
#[cfg(test)]mod test { use super::*;
fn run_git_init(dir: &Path) { let s = std::process::Command::new("git") .args(["init", "-q"]) .current_dir(dir) .status() .unwrap(); assert!(s.success()); }
fn store_pair() -> (tempfile::TempDir, Box<dyn Store>, Box<dyn Store>) { let dir = tempfile::tempdir().unwrap(); let file_root = dir.path().join("file"); let git_root = dir.path().join("git"); fs::create_dir_all(&file_root).unwrap(); fs::create_dir_all(&git_root).unwrap(); run_git_init(&git_root); let f: Box<dyn Store> = Box::new(FileStore::new(file_root)); let g: Box<dyn Store> = Box::new(GitStore::open(git_root.join(".git")).unwrap()); (dir, f, g) }
#[test] fn test_basic_blob_ops_both_backends() { let (_d, file, git) = store_pair(); for s in [file, git] { assert_eq!(s.read("missing").unwrap(), None); assert!(!s.exists("k").unwrap()); s.write("k", b"hello").unwrap(); assert!(s.exists("k").unwrap()); assert_eq!(s.read("k").unwrap().as_deref(), Some(&b"hello"[..])); s.write("k", b"world").unwrap(); assert_eq!(s.read("k").unwrap().as_deref(), Some(&b"world"[..])); s.delete("k").unwrap(); assert!(!s.exists("k").unwrap()); // delete nonexistent is fine s.delete("k").unwrap(); } }
#[test] fn test_list_both_backends() { let (_d, file, git) = store_pair(); for s in [file, git] { s.write("tasks/1", b"a").unwrap(); s.write("tasks/2", b"b").unwrap(); s.write("archive/3", b"c").unwrap(); let mut tasks = s.list("tasks").unwrap(); tasks.sort(); assert_eq!(tasks, vec!["tasks/1", "tasks/2"]); let arch = s.list("archive").unwrap(); assert_eq!(arch, vec!["archive/3"]); assert!(s.list("nothing").unwrap().is_empty()); } }
#[test] fn test_high_level_task_ops_both_backends() { let (_d, file, git) = store_pair(); for s in [file.as_ref(), git.as_ref()] { let id = next_id(s).unwrap(); assert_eq!(id, Id(1)); let id2 = next_id(s).unwrap(); assert_eq!(id2, Id(2));
write_task(s, id, "title", "body", Loc::Active, "write", None).unwrap(); let (t, b, loc) = read_task(s, id).unwrap().unwrap(); assert_eq!(t, "title"); assert_eq!(b, "body"); assert_eq!(loc, Loc::Active);
move_task(s, id, Loc::Archived).unwrap(); assert_eq!(task_location(s, id).unwrap(), Some(Loc::Archived)); move_task(s, id, Loc::Active).unwrap(); assert_eq!(task_location(s, id).unwrap(), Some(Loc::Active));
let mut attrs = BTreeMap::new(); attrs.insert("foo".to_string(), "bar".to_string()); write_attrs(s, id, &attrs, "write", None).unwrap(); assert_eq!(read_attrs(s, id).unwrap(), attrs);
let mut bl = HashSet::new(); bl.insert(Id(7)); bl.insert(Id(9)); write_backlinks(s, id, &bl).unwrap(); assert_eq!(read_backlinks(s, id).unwrap(), bl);
// Empty attrs/backlinks delete the blob. write_attrs(s, id, &BTreeMap::new(), "write", None).unwrap(); assert!(read_attrs(s, id).unwrap().is_empty()); write_backlinks(s, id, &HashSet::new()).unwrap(); assert!(read_backlinks(s, id).unwrap().is_empty()); } }
#[test] fn test_remotes_round_trip_both_backends() { let (_d, file, git) = store_pair(); for s in [file.as_ref(), git.as_ref()] { assert!(read_remotes(s).unwrap().is_empty()); let remotes = vec![ Remote { prefix: "a".into(), path: PathBuf::from("/x"), }, Remote { prefix: "b".into(), path: PathBuf::from("/y"), }, ]; write_remotes(s, &remotes).unwrap(); assert_eq!(read_remotes(s).unwrap(), remotes); write_remotes(s, &[]).unwrap(); assert!(read_remotes(s).unwrap().is_empty()); } }
#[test] fn test_upgrade_legacy_keys_renames_old_scheme() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("repo"); fs::create_dir_all(&root).unwrap(); run_git_init(&root); let store = GitStore::open(root.join(".git")).unwrap();
// Seed legacy-scheme refs (what older versions of tsk wrote). store.write("tasks/tsk-1.tsk", b"old\n\nbody").unwrap(); store.write("archive/tsk-2.tsk", b"old2\n\nbody2").unwrap(); // And one already-correct new-scheme ref alongside. store.write("tasks/3", b"new\n\nbody3").unwrap();
upgrade_legacy_keys(&store).unwrap();
// Legacy keys should be gone, new-scheme keys present. assert!(!store.exists("tasks/tsk-1.tsk").unwrap()); assert!(!store.exists("archive/tsk-2.tsk").unwrap()); assert_eq!( store.read("tasks/1").unwrap().as_deref(), Some(&b"old\n\nbody"[..]) ); assert_eq!( store.read("archive/2").unwrap().as_deref(), Some(&b"old2\n\nbody2"[..]) ); assert_eq!( store.read("tasks/3").unwrap().as_deref(), Some(&b"new\n\nbody3"[..]) ); }
#[test] fn test_upgrade_legacy_keys_keeps_new_when_both_present() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("repo"); fs::create_dir_all(&root).unwrap(); run_git_init(&root); let store = GitStore::open(root.join(".git")).unwrap();
store.write("tasks/tsk-1.tsk", b"legacy").unwrap(); store.write("tasks/1", b"current").unwrap(); upgrade_legacy_keys(&store).unwrap();
assert!(!store.exists("tasks/tsk-1.tsk").unwrap()); assert_eq!( store.read("tasks/1").unwrap().as_deref(), Some(&b"current"[..]) ); }
#[test] fn test_list_active_archive_helpers() { let (_d, file, git) = store_pair(); for s in [file.as_ref(), git.as_ref()] { write_task(s, Id(1), "t1", "", Loc::Active, "write", None).unwrap(); write_task(s, Id(2), "t2", "", Loc::Archived, "write", None).unwrap(); write_task(s, Id(3), "t3", "", Loc::Active, "write", None).unwrap(); assert_eq!(list_active(s).unwrap(), vec![Id(1), Id(3)]); assert_eq!(list_archive(s).unwrap(), vec![Id(2)]); } }}