A file-based task manager
0
fork

Configure Feed

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

Add tsk export / tsk import for mbox-format offline transfer

Each task commit becomes one mbox entry: standard `From <sha>` separator,
RFC-822 headers (including X-Tsk-Stable-Id, X-Tsk-Parent, optional
X-Tsk-Namespace), commit message, and a length-prefixed dump of every
file in the task tree between `---tsk-tree---` and `---end---`. Length
prefixes avoid any escaping of mbox `From ` lines inside content.

Stable ids are SHA-1 of the root content blob, so import re-hashes and
rejects mismatches — tampered patches don't go through. Recipient opts
in to namespace binding via `tsk import --bind`; the sender's namespace
hint is parsed but not auto-applied.

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

+608
+14
AGENTS.md
··· 96 96 tsk inbox # auto-pulls then lists pending inbox items 97 97 ``` 98 98 99 + ## Offline transfer (email, etc.) 100 + 101 + Export a task as an mbox-format patch series and pipe it anywhere: 102 + 103 + ``` 104 + tsk export -T tsk-N [--bind] > task.mbox # one mbox entry per commit 105 + tsk import [--bind] < task.mbox # rebuild + verify on receiver 106 + ``` 107 + 108 + `--bind` on export embeds your namespace+human-id; `--bind` on import binds 109 + the task into the receiver's active namespace. Stable ids are content- 110 + addressed, so the receiver verifies the SHA on import — tampered patches 111 + are rejected. 112 + 99 113 ## Conventions for agents working this repo 100 114 101 115 - Always `tsk list` first. Don't invent work; pick the top task or ask.
+49
src/lib.rs
··· 2 2 mod fzf; 3 3 mod namespace; 4 4 mod object; 5 + mod patch; 5 6 mod properties; 6 7 mod queue; 7 8 mod task; ··· 110 111 /// Currently: backfill `status=open` on tasks without a status property. 111 112 /// New migrations land here as they're added. 112 113 FixUp, 114 + /// Export a task as an mbox-format patch series (one entry per commit). 115 + /// Pipe to a file for offline transfer; recipient runs `tsk import`. 116 + Export { 117 + #[command(flatten)] 118 + task_id: TaskId, 119 + /// Embed the task's namespace+human-id so the recipient can opt in 120 + /// to binding it on import. 121 + #[arg(long)] 122 + bind: bool, 123 + }, 124 + /// Import a task from an mbox-format patch series (read from stdin). 125 + /// Verifies stable id; rejects tampered patches. 126 + Import { 127 + /// Bind the imported task into the active namespace, allocating a 128 + /// fresh human id (or reusing an existing binding to the same stable id). 129 + #[arg(long)] 130 + bind: bool, 131 + }, 113 132 /// Print the commit history of a tsk ref. Newest commit first. 114 133 Log { 115 134 #[command(subcommand)] ··· 336 355 Workspace::from_path(dir)?.deprioritize(task_id.into()) 337 356 } 338 357 Commands::Clean => Workspace::from_path(dir)?.clean(), 358 + Commands::Export { task_id, bind } => command_export(dir, task_id.into(), bind), 359 + Commands::Import { bind } => command_import(dir, bind), 339 360 Commands::Log { target } => command_log(dir, target), 340 361 Commands::FixUp => { 341 362 let ws = Workspace::from_path(dir)?; ··· 598 619 if let Some(r) = effective_remote(remote) { 599 620 let _ = ws.git_push(&r); 600 621 } 622 + Ok(()) 623 + } 624 + 625 + fn command_export( 626 + dir: PathBuf, 627 + identifier: TaskIdentifier, 628 + bind: bool, 629 + ) -> Result<()> { 630 + let ws = Workspace::from_path(dir)?; 631 + let mbox = ws.export_task(identifier, bind)?; 632 + print!("{mbox}"); 633 + Ok(()) 634 + } 635 + 636 + fn command_import(dir: PathBuf, bind: bool) -> Result<()> { 637 + let ws = Workspace::from_path(dir)?; 638 + let mut buf = String::new(); 639 + std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?; 640 + let res = ws.import_task(&buf, bind)?; 641 + let bound = if let Some(id) = res.bound_human { 642 + format!(" bound as {}-{}", ws.namespace(), id) 643 + } else { 644 + String::new() 645 + }; 646 + println!( 647 + "Imported {} commit(s) for task {}{bound}", 648 + res.commits_imported, res.stable 649 + ); 601 650 Ok(()) 602 651 } 603 652
+498
src/patch.rs
··· 1 + //! Mbox-format patch series for offline task transfer. 2 + //! 3 + //! Each task commit is one mbox entry (`From <sha> Mon Sep 17 00:00:00 2001` 4 + //! separator + RFC-822 headers + body). The body holds the commit message, 5 + //! a `---tsk-tree---` marker, then a length-prefixed dump of every file in 6 + //! the task tree, terminated by `---end---`. Length-prefix avoids any need 7 + //! to escape mbox `From ` lines inside file contents. 8 + //! 9 + //! Stable id is content-addressed (= SHA-1 of the root `content` blob), so 10 + //! `import_task` recomputes it and rejects mismatches — tampering is 11 + //! detectable. 12 + 13 + use crate::errors::{Error, Result}; 14 + use crate::object::{CONTENT_FILE, StableId, TITLE_FILE}; 15 + use git2::{Oid, Repository, Signature, Time}; 16 + use std::collections::BTreeMap; 17 + use std::fmt::Write as _; 18 + 19 + const MBOX_DATE: &str = "Mon Sep 17 00:00:00 2001"; 20 + const TREE_DELIM: &str = "---tsk-tree---"; 21 + const END_DELIM: &str = "---end---"; 22 + 23 + pub struct ExportOpts { 24 + /// If set, embed `X-Tsk-Namespace: <ns>-<human>` on the root entry so 25 + /// the recipient can opt in to binding the task into their namespace. 26 + pub bind: Option<(String, u32)>, 27 + } 28 + 29 + pub fn export_task( 30 + repo: &Repository, 31 + stable: &StableId, 32 + opts: &ExportOpts, 33 + ) -> Result<String> { 34 + let r = repo.find_reference(&stable.refname())?; 35 + let tip = r.target().ok_or_else(|| Error::Parse("task ref empty".into()))?; 36 + // Collect root → tip. 37 + let mut chain: Vec<Oid> = Vec::new(); 38 + let mut cur = Some(repo.find_commit(tip)?); 39 + while let Some(c) = cur { 40 + chain.push(c.id()); 41 + cur = c.parent(0).ok(); 42 + } 43 + chain.reverse(); 44 + let mut out = String::new(); 45 + for (idx, oid) in chain.iter().enumerate() { 46 + let commit = repo.find_commit(*oid)?; 47 + let parent = commit.parent(0).ok().map(|p| p.id()); 48 + let bind = if idx == 0 { opts.bind.as_ref() } else { None }; 49 + write_entry(&mut out, repo, &commit, parent, stable, bind)?; 50 + } 51 + Ok(out) 52 + } 53 + 54 + fn fmt_git_time(t: Time) -> String { 55 + let off = t.offset_minutes(); 56 + let sign = if off >= 0 { '+' } else { '-' }; 57 + let off = off.abs(); 58 + format!("{} {}{:02}{:02}", t.seconds(), sign, off / 60, off % 60) 59 + } 60 + 61 + fn parse_git_time(s: &str) -> Result<Time> { 62 + let (secs, offset) = s 63 + .split_once(' ') 64 + .ok_or_else(|| Error::Parse(format!("bad date: {s}")))?; 65 + let secs: i64 = secs 66 + .parse() 67 + .map_err(|_| Error::Parse(format!("bad date secs: {secs}")))?; 68 + let (sign, rest) = offset 69 + .split_at_checked(1) 70 + .ok_or_else(|| Error::Parse(format!("bad offset: {offset}")))?; 71 + let off_min: i32 = if rest.len() == 4 { 72 + let h: i32 = rest[..2] 73 + .parse() 74 + .map_err(|_| Error::Parse(format!("bad offset: {offset}")))?; 75 + let m: i32 = rest[2..] 76 + .parse() 77 + .map_err(|_| Error::Parse(format!("bad offset: {offset}")))?; 78 + h * 60 + m 79 + } else { 80 + return Err(Error::Parse(format!("bad offset: {offset}"))); 81 + }; 82 + let off_min = if sign == "-" { -off_min } else { off_min }; 83 + Ok(Time::new(secs, off_min)) 84 + } 85 + 86 + fn write_entry( 87 + out: &mut String, 88 + repo: &Repository, 89 + commit: &git2::Commit, 90 + parent: Option<Oid>, 91 + stable: &StableId, 92 + bind: Option<&(String, u32)>, 93 + ) -> Result<()> { 94 + let author = commit.author(); 95 + let summary = commit.summary().unwrap_or(""); 96 + let message = commit.message().unwrap_or(""); 97 + writeln!(out, "From {} {MBOX_DATE}", commit.id()).unwrap(); 98 + writeln!( 99 + out, 100 + "From: {} <{}>", 101 + author.name().unwrap_or(""), 102 + author.email().unwrap_or("") 103 + ) 104 + .unwrap(); 105 + writeln!(out, "Date: {}", fmt_git_time(author.when())).unwrap(); 106 + writeln!(out, "Subject: [PATCH tsk] {summary}").unwrap(); 107 + writeln!(out, "X-Tsk-Stable-Id: {stable}").unwrap(); 108 + writeln!( 109 + out, 110 + "X-Tsk-Parent: {}", 111 + parent.map(|o| o.to_string()).unwrap_or_else(|| "none".into()) 112 + ) 113 + .unwrap(); 114 + if let Some((ns, human)) = bind { 115 + writeln!(out, "X-Tsk-Namespace: {ns}-{human}").unwrap(); 116 + } 117 + writeln!(out).unwrap(); 118 + out.push_str(message); 119 + if !message.ends_with('\n') { 120 + out.push('\n'); 121 + } 122 + writeln!(out).unwrap(); 123 + writeln!(out, "{TREE_DELIM}").unwrap(); 124 + let tree = commit.tree()?; 125 + // Iterate in tree order (already sorted by name). 126 + for entry in tree.iter() { 127 + let Some(name) = entry.name() else { continue }; 128 + if name == TITLE_FILE { 129 + // title is a cache; reconstructible from content. Skip. 130 + continue; 131 + } 132 + let blob = entry.to_object(repo)?.peel_to_blob()?; 133 + let bytes = blob.content(); 134 + writeln!(out, "file: {name}").unwrap(); 135 + writeln!(out, "size: {}", bytes.len()).unwrap(); 136 + // Bytes verbatim. They may contain newlines or arbitrary text; size 137 + // is the authoritative delimiter. 138 + out.push_str(std::str::from_utf8(bytes).map_err(|e| Error::Parse(e.to_string()))?); 139 + out.push('\n'); 140 + } 141 + writeln!(out, "{END_DELIM}").unwrap(); 142 + writeln!(out).unwrap(); 143 + Ok(()) 144 + } 145 + 146 + #[derive(Debug)] 147 + struct Entry { 148 + author_name: String, 149 + author_email: String, 150 + when: Time, 151 + message: String, 152 + stable: String, 153 + ns_bind: Option<(String, u32)>, 154 + files: BTreeMap<String, Vec<u8>>, 155 + } 156 + 157 + #[derive(Debug)] 158 + pub struct ImportResult { 159 + pub stable: StableId, 160 + pub commits_imported: usize, 161 + /// Hint from the sender: namespace + human id under which they had this 162 + /// task bound. Currently unused by the workspace layer (recipient decides 163 + /// binding) but parsed and exposed so future commands can honor it. 164 + #[allow(dead_code)] 165 + pub ns_bind: Option<(String, u32)>, 166 + } 167 + 168 + pub fn import_task(repo: &Repository, mbox: &str) -> Result<ImportResult> { 169 + let entries = parse_mbox(mbox)?; 170 + if entries.is_empty() { 171 + return Err(Error::Parse("no patch entries found".into())); 172 + } 173 + let stable_hex = entries[0].stable.clone(); 174 + let ns_bind = entries[0].ns_bind.clone(); 175 + let mut prev: Option<Oid> = None; 176 + for (idx, e) in entries.iter().enumerate() { 177 + if e.stable != stable_hex { 178 + return Err(Error::Parse(format!( 179 + "stable id mismatch across entries: {} vs {}", 180 + stable_hex, e.stable 181 + ))); 182 + } 183 + // Build the tree from the file map. 184 + let mut tb = repo.treebuilder(None)?; 185 + let content = e 186 + .files 187 + .get(CONTENT_FILE) 188 + .ok_or_else(|| Error::Parse("entry missing 'content' file".into()))?; 189 + let content_oid = repo.blob(content)?; 190 + if idx == 0 { 191 + // Verify stable id == sha of root content blob. 192 + if content_oid.to_string() != stable_hex { 193 + return Err(Error::Parse(format!( 194 + "stable id verification failed: expected {stable_hex}, content sha is {content_oid}" 195 + ))); 196 + } 197 + } 198 + tb.insert(CONTENT_FILE, content_oid, 0o100644)?; 199 + // Re-derive title cache from content. 200 + let title = std::str::from_utf8(content) 201 + .map_err(|e| Error::Parse(e.to_string()))? 202 + .lines() 203 + .next() 204 + .unwrap_or(""); 205 + let title_oid = repo.blob(title.as_bytes())?; 206 + tb.insert(TITLE_FILE, title_oid, 0o100644)?; 207 + for (name, bytes) in &e.files { 208 + if name == CONTENT_FILE || name == TITLE_FILE { 209 + continue; 210 + } 211 + let oid = repo.blob(bytes)?; 212 + tb.insert(name.as_str(), oid, 0o100644)?; 213 + } 214 + let tree_oid = tb.write()?; 215 + let sig = Signature::new(&e.author_name, &e.author_email, &e.when)?; 216 + let parents: Vec<git2::Commit> = prev.into_iter().map(|o| repo.find_commit(o).unwrap()).collect(); 217 + let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); 218 + let commit_oid = repo.commit( 219 + None, 220 + &sig, 221 + &sig, 222 + &e.message, 223 + &repo.find_tree(tree_oid)?, 224 + &parent_refs, 225 + )?; 226 + prev = Some(commit_oid); 227 + } 228 + let stable = StableId(stable_hex); 229 + repo.reference(&stable.refname(), prev.unwrap(), true, "import")?; 230 + Ok(ImportResult { 231 + stable, 232 + commits_imported: entries.len(), 233 + ns_bind, 234 + }) 235 + } 236 + 237 + fn parse_mbox(s: &str) -> Result<Vec<Entry>> { 238 + let mut entries = Vec::new(); 239 + // Split on lines starting with "From " (the mbox separator). The "From " 240 + // line is part of the entry it introduces. 241 + let mut starts: Vec<usize> = Vec::new(); 242 + let bytes = s.as_bytes(); 243 + if bytes.starts_with(b"From ") { 244 + starts.push(0); 245 + } 246 + let mut i = 0; 247 + while i + 5 < bytes.len() { 248 + if bytes[i] == b'\n' && &bytes[i + 1..i + 6] == b"From " { 249 + starts.push(i + 1); 250 + } 251 + i += 1; 252 + } 253 + starts.push(bytes.len()); 254 + for win in starts.windows(2) { 255 + let chunk = &s[win[0]..win[1]]; 256 + if chunk.trim().is_empty() { 257 + continue; 258 + } 259 + entries.push(parse_entry(chunk)?); 260 + } 261 + Ok(entries) 262 + } 263 + 264 + fn parse_entry(chunk: &str) -> Result<Entry> { 265 + let mut lines = chunk.split_inclusive('\n'); 266 + // First line: "From <oid> Mon Sep 17 ..." 267 + let _ = lines.next(); 268 + let mut author_name = String::new(); 269 + let mut author_email = String::new(); 270 + let mut when: Option<Time> = None; 271 + let mut subject = String::new(); 272 + let mut stable = String::new(); 273 + let mut ns_bind: Option<(String, u32)> = None; 274 + // Headers until blank line. 275 + for line in lines.by_ref() { 276 + let trimmed = line.trim_end_matches(['\n', '\r']); 277 + if trimmed.is_empty() { 278 + break; 279 + } 280 + if let Some(v) = trimmed.strip_prefix("From: ") { 281 + // "Name <email>" 282 + if let Some((n, rest)) = v.split_once(" <") { 283 + author_name = n.to_string(); 284 + author_email = rest.trim_end_matches('>').to_string(); 285 + } else { 286 + author_name = v.to_string(); 287 + } 288 + } else if let Some(v) = trimmed.strip_prefix("Date: ") { 289 + when = Some(parse_git_time(v)?); 290 + } else if let Some(v) = trimmed.strip_prefix("Subject: ") { 291 + subject = v.strip_prefix("[PATCH tsk] ").unwrap_or(v).to_string(); 292 + } else if let Some(v) = trimmed.strip_prefix("X-Tsk-Stable-Id: ") { 293 + stable = v.to_string(); 294 + } else if let Some(_v) = trimmed.strip_prefix("X-Tsk-Parent: ") { 295 + // Informational only; we use the previous imported commit as parent. 296 + } else if let Some(v) = trimmed.strip_prefix("X-Tsk-Namespace: ") { 297 + if let Some((ns, h)) = v.rsplit_once('-') 298 + && let Ok(human) = h.parse::<u32>() 299 + { 300 + ns_bind = Some((ns.to_string(), human)); 301 + } 302 + } 303 + } 304 + if stable.is_empty() { 305 + return Err(Error::Parse("missing X-Tsk-Stable-Id".into())); 306 + } 307 + let when = when.ok_or_else(|| Error::Parse("missing Date".into()))?; 308 + // Body up to TREE_DELIM line. 309 + let mut message = String::new(); 310 + let mut in_tree = false; 311 + for line in lines.by_ref() { 312 + let trimmed = line.trim_end_matches(['\n', '\r']); 313 + if trimmed == TREE_DELIM { 314 + in_tree = true; 315 + break; 316 + } 317 + message.push_str(line); 318 + } 319 + if !in_tree { 320 + return Err(Error::Parse("missing tree delimiter".into())); 321 + } 322 + // Restore commit message: drop the trailing blank line we emitted. 323 + while message.ends_with("\n\n") { 324 + message.pop(); 325 + } 326 + // If the message is just the subject line (no body), use subject. 327 + let message = if message.trim().is_empty() { 328 + subject 329 + } else { 330 + message.trim_end_matches('\n').to_string() 331 + }; 332 + // Parse file blocks until END_DELIM. 333 + let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new(); 334 + // We need byte-level reads for sizes, so switch back to slice indexing. 335 + // Compute remaining input from `lines`. 336 + let remaining = lines.collect::<String>(); 337 + let mut rest = remaining.as_bytes(); 338 + loop { 339 + // Read line. 340 + let nl = rest 341 + .iter() 342 + .position(|b| *b == b'\n') 343 + .ok_or_else(|| Error::Parse("unexpected eof in tree".into()))?; 344 + let line = std::str::from_utf8(&rest[..nl]).map_err(|e| Error::Parse(e.to_string()))?; 345 + let line_trim = line.trim_end_matches('\r'); 346 + rest = &rest[nl + 1..]; 347 + if line_trim == END_DELIM { 348 + break; 349 + } 350 + let name = line_trim 351 + .strip_prefix("file: ") 352 + .ok_or_else(|| Error::Parse(format!("expected 'file:' got: {line_trim:?}")))? 353 + .to_string(); 354 + let nl = rest 355 + .iter() 356 + .position(|b| *b == b'\n') 357 + .ok_or_else(|| Error::Parse("unexpected eof reading size".into()))?; 358 + let size_line = std::str::from_utf8(&rest[..nl]).map_err(|e| Error::Parse(e.to_string()))?; 359 + let size_line = size_line.trim_end_matches('\r'); 360 + rest = &rest[nl + 1..]; 361 + let size: usize = size_line 362 + .strip_prefix("size: ") 363 + .ok_or_else(|| Error::Parse(format!("expected 'size:' got: {size_line:?}")))? 364 + .parse() 365 + .map_err(|_| Error::Parse(format!("bad size: {size_line}")))?; 366 + if rest.len() < size + 1 { 367 + return Err(Error::Parse("truncated file body".into())); 368 + } 369 + let bytes = rest[..size].to_vec(); 370 + // Trailing \n separator (not part of size). 371 + if rest[size] != b'\n' { 372 + return Err(Error::Parse("missing newline after file body".into())); 373 + } 374 + rest = &rest[size + 1..]; 375 + files.insert(name, bytes); 376 + } 377 + Ok(Entry { 378 + author_name, 379 + author_email, 380 + when, 381 + message, 382 + stable, 383 + ns_bind, 384 + files, 385 + }) 386 + } 387 + 388 + #[cfg(test)] 389 + mod test { 390 + use super::*; 391 + use crate::object::{self, Task}; 392 + use std::path::Path; 393 + 394 + fn init_repo(p: &Path) -> Repository { 395 + let r = Repository::init(p).unwrap(); 396 + let mut cfg = r.config().unwrap(); 397 + cfg.set_str("user.name", "Tester").unwrap(); 398 + cfg.set_str("user.email", "t@e").unwrap(); 399 + r 400 + } 401 + 402 + #[test] 403 + fn round_trip_single_commit() { 404 + let dir = tempfile::tempdir().unwrap(); 405 + let src = init_repo(dir.path()); 406 + let mut t = Task::new("Hello\n\nbody text"); 407 + t.properties.insert("status".into(), vec!["open".into()]); 408 + let stable = object::create(&src, &t, "create").unwrap(); 409 + 410 + let mbox = export_task(&src, &stable, &ExportOpts { bind: None }).unwrap(); 411 + assert!(mbox.starts_with("From ")); 412 + assert!(mbox.contains("X-Tsk-Stable-Id:")); 413 + assert!(mbox.contains(TREE_DELIM)); 414 + 415 + let dst_dir = tempfile::tempdir().unwrap(); 416 + let dst = init_repo(dst_dir.path()); 417 + let res = import_task(&dst, &mbox).unwrap(); 418 + assert_eq!(res.stable, stable); 419 + let read_back = object::read(&dst, &res.stable).unwrap().unwrap(); 420 + assert_eq!(read_back.content, t.content); 421 + assert_eq!(read_back.properties, t.properties); 422 + } 423 + 424 + #[test] 425 + fn round_trip_preserves_history() { 426 + let dir = tempfile::tempdir().unwrap(); 427 + let src = init_repo(dir.path()); 428 + let t = Task::new("v1"); 429 + let stable = object::create(&src, &t, "create").unwrap(); 430 + let mut t2 = t.clone(); 431 + t2.content = "v2".into(); 432 + object::update(&src, &stable, &t2, "edit-2").unwrap(); 433 + let mut t3 = t2.clone(); 434 + t3.content = "v3".into(); 435 + object::update(&src, &stable, &t3, "edit-3").unwrap(); 436 + 437 + let mbox = export_task(&src, &stable, &ExportOpts { bind: None }).unwrap(); 438 + 439 + let dst_dir = tempfile::tempdir().unwrap(); 440 + let dst = init_repo(dst_dir.path()); 441 + let res = import_task(&dst, &mbox).unwrap(); 442 + assert_eq!(res.commits_imported, 3); 443 + 444 + let head = dst 445 + .find_reference(&res.stable.refname()) 446 + .unwrap() 447 + .target() 448 + .unwrap(); 449 + let tip = dst.find_commit(head).unwrap(); 450 + assert_eq!(tip.summary().unwrap(), "edit-3"); 451 + let mid = tip.parent(0).unwrap(); 452 + assert_eq!(mid.summary().unwrap(), "edit-2"); 453 + let root = mid.parent(0).unwrap(); 454 + assert_eq!(root.summary().unwrap(), "create"); 455 + } 456 + 457 + #[test] 458 + fn tamper_detected_via_stable_id_check() { 459 + let dir = tempfile::tempdir().unwrap(); 460 + let src = init_repo(dir.path()); 461 + let stable = 462 + object::create(&src, &Task::new("original content"), "create").unwrap(); 463 + let mbox = export_task(&src, &stable, &ExportOpts { bind: None }).unwrap(); 464 + // Flip the content body without updating the stable id header. 465 + // Equal-length substitution so size-prefix parsing still aligns; only 466 + // the SHA check should reject it. 467 + let tampered = mbox.replace("original content", "OVERRIDDEN BYTES"); 468 + assert_eq!("original content".len(), "OVERRIDDEN BYTES".len()); 469 + let dst_dir = tempfile::tempdir().unwrap(); 470 + let dst = init_repo(dst_dir.path()); 471 + let err = import_task(&dst, &tampered).unwrap_err(); 472 + let msg = format!("{err}"); 473 + assert!( 474 + msg.contains("stable id verification failed"), 475 + "expected verification error, got: {msg}" 476 + ); 477 + } 478 + 479 + #[test] 480 + fn ns_bind_header_round_trips() { 481 + let dir = tempfile::tempdir().unwrap(); 482 + let src = init_repo(dir.path()); 483 + let stable = object::create(&src, &Task::new("bind me"), "create").unwrap(); 484 + let mbox = export_task( 485 + &src, 486 + &stable, 487 + &ExportOpts { 488 + bind: Some(("alpha".into(), 7)), 489 + }, 490 + ) 491 + .unwrap(); 492 + assert!(mbox.contains("X-Tsk-Namespace: alpha-7")); 493 + let dst_dir = tempfile::tempdir().unwrap(); 494 + let dst = init_repo(dst_dir.path()); 495 + let res = import_task(&dst, &mbox).unwrap(); 496 + assert_eq!(res.ns_bind, Some(("alpha".to_string(), 7))); 497 + } 498 + }
+47
src/workspace.rs
··· 9 9 10 10 use crate::errors::{Error, Result}; 11 11 use crate::object::{self, StableId, Task as TaskObj}; 12 + use crate::patch; 12 13 use crate::{namespace, properties, queue}; 13 14 use git2::Repository; 14 15 use std::collections::BTreeMap; 15 16 use std::fmt::Display; 16 17 use std::path::PathBuf; 17 18 use std::str::FromStr; 19 + 20 + #[derive(Debug)] 21 + pub struct ImportOutcome { 22 + pub stable: StableId, 23 + pub commits_imported: usize, 24 + pub bound_human: Option<u32>, 25 + } 18 26 19 27 const NAMESPACE_FILE: &str = "namespace"; 20 28 const QUEUE_FILE: &str = "queue"; ··· 456 464 current = c.parent(0).ok(); 457 465 } 458 466 Ok(out) 467 + } 468 + 469 + /// Export a task as an mbox-format patch series. With `bind=true`, the 470 + /// root entry carries the active namespace's human id so the recipient 471 + /// can opt in to mirroring the binding on import. 472 + pub fn export_task(&self, identifier: TaskIdentifier, bind: bool) -> Result<String> { 473 + let (id, stable) = self.resolve(identifier)?; 474 + let opts = patch::ExportOpts { 475 + bind: if bind { 476 + Some((self.namespace(), id.0)) 477 + } else { 478 + None 479 + }, 480 + }; 481 + let repo = self.repo()?; 482 + patch::export_task(&repo, &stable, &opts) 483 + } 484 + 485 + /// Import a task from an mbox patch series produced by `export_task`. 486 + /// On `bind=true`, also bind the imported stable id into the active 487 + /// namespace (reusing the existing human id if already bound). 488 + pub fn import_task(&self, mbox: &str, bind: bool) -> Result<ImportOutcome> { 489 + let repo = self.repo()?; 490 + let res = patch::import_task(&repo, mbox)?; 491 + let bound_human = if bind { 492 + let ns = self.namespace(); 493 + let human = match namespace::human_for(&repo, &ns, &res.stable)? { 494 + Some(h) => h, 495 + None => namespace::assign_id(&repo, &ns, res.stable.clone(), "import-bind")?, 496 + }; 497 + Some(human) 498 + } else { 499 + None 500 + }; 501 + Ok(ImportOutcome { 502 + stable: res.stable, 503 + commits_imported: res.commits_imported, 504 + bound_human, 505 + }) 459 506 } 460 507 461 508 /// History of edits to a single task.