A file-based task manager
0
fork

Configure Feed

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

at 18d2d3f49b1fd85b6e09a665ecd7502be7c6209f 671 lines 25 kB view raw
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 13use crate::errors::{Error, Result}; 14use crate::object::{CONTENT_FILE, StableId, TITLE_FILE}; 15use git2::{Oid, Repository, Signature, Time}; 16use std::collections::BTreeMap; 17use std::fmt::Write as _; 18 19const MBOX_DATE: &str = "Mon Sep 17 00:00:00 2001"; 20const TREE_DELIM: &str = "---tsk-tree---"; 21const END_DELIM: &str = "---end---"; 22 23/// Standard mbox `From `-mangling: any line matching `^>*From ` gets one 24/// extra `>` on export so a strict mbox reader can't mistake it for an 25/// entry separator. Inverse on import. 26fn mangle_from(s: &str) -> String { 27 let mut out = String::with_capacity(s.len() + 8); 28 for line in s.split_inclusive('\n') { 29 let arrows = line.bytes().take_while(|b| *b == b'>').count(); 30 if line.len() >= arrows + 5 && &line.as_bytes()[arrows..arrows + 5] == b"From " { 31 out.push('>'); 32 } 33 out.push_str(line); 34 } 35 out 36} 37 38fn unmangle_from(s: &str) -> String { 39 let mut out = String::with_capacity(s.len()); 40 for line in s.split_inclusive('\n') { 41 let arrows = line.bytes().take_while(|b| *b == b'>').count(); 42 if arrows >= 1 43 && line.len() >= arrows + 5 44 && &line.as_bytes()[arrows..arrows + 5] == b"From " 45 { 46 out.push_str(&line[1..]); 47 } else { 48 out.push_str(line); 49 } 50 } 51 out 52} 53 54pub struct ExportOpts { 55 /// If set, embed `X-Tsk-Namespace: <ns>-<human>` on the root entry so 56 /// the recipient can opt in to binding the task into their namespace. 57 pub bind: Option<(String, u32)>, 58} 59 60pub fn export_task( 61 repo: &Repository, 62 stable: &StableId, 63 opts: &ExportOpts, 64) -> Result<String> { 65 let r = repo.find_reference(&stable.refname())?; 66 let tip = r.target().ok_or_else(|| Error::Parse("task ref empty".into()))?; 67 // Collect root → tip. 68 let mut chain: Vec<Oid> = Vec::new(); 69 let mut cur = Some(repo.find_commit(tip)?); 70 while let Some(c) = cur { 71 chain.push(c.id()); 72 cur = c.parent(0).ok(); 73 } 74 chain.reverse(); 75 let mut out = String::new(); 76 for (idx, oid) in chain.iter().enumerate() { 77 let commit = repo.find_commit(*oid)?; 78 let parent = commit.parent(0).ok().map(|p| p.id()); 79 let bind = if idx == 0 { opts.bind.as_ref() } else { None }; 80 write_entry(&mut out, repo, &commit, parent, stable, bind)?; 81 } 82 Ok(out) 83} 84 85fn fmt_git_time(t: Time) -> String { 86 let off = t.offset_minutes(); 87 let sign = if off >= 0 { '+' } else { '-' }; 88 let off = off.abs(); 89 format!("{} {}{:02}{:02}", t.seconds(), sign, off / 60, off % 60) 90} 91 92fn parse_git_time(s: &str) -> Result<Time> { 93 let (secs, offset) = s 94 .split_once(' ') 95 .ok_or_else(|| Error::Parse(format!("bad date: {s}")))?; 96 let secs: i64 = secs 97 .parse() 98 .map_err(|_| Error::Parse(format!("bad date secs: {secs}")))?; 99 let (sign, rest) = offset 100 .split_at_checked(1) 101 .ok_or_else(|| Error::Parse(format!("bad offset: {offset}")))?; 102 let off_min: i32 = if rest.len() == 4 { 103 let h: i32 = rest[..2] 104 .parse() 105 .map_err(|_| Error::Parse(format!("bad offset: {offset}")))?; 106 let m: i32 = rest[2..] 107 .parse() 108 .map_err(|_| Error::Parse(format!("bad offset: {offset}")))?; 109 h * 60 + m 110 } else { 111 return Err(Error::Parse(format!("bad offset: {offset}"))); 112 }; 113 let off_min = if sign == "-" { -off_min } else { off_min }; 114 Ok(Time::new(secs, off_min)) 115} 116 117fn write_entry( 118 out: &mut String, 119 repo: &Repository, 120 commit: &git2::Commit, 121 parent: Option<Oid>, 122 stable: &StableId, 123 bind: Option<&(String, u32)>, 124) -> Result<()> { 125 let author = commit.author(); 126 let summary = commit.summary().unwrap_or(""); 127 let message = commit.message().unwrap_or(""); 128 writeln!(out, "From {} {MBOX_DATE}", commit.id()).unwrap(); 129 writeln!( 130 out, 131 "From: {} <{}>", 132 author.name().unwrap_or(""), 133 author.email().unwrap_or("") 134 ) 135 .unwrap(); 136 writeln!(out, "Date: {}", fmt_git_time(author.when())).unwrap(); 137 writeln!(out, "Subject: [PATCH tsk] {summary}").unwrap(); 138 writeln!(out, "X-Tsk-Stable-Id: {stable}").unwrap(); 139 writeln!( 140 out, 141 "X-Tsk-Parent: {}", 142 parent.map(|o| o.to_string()).unwrap_or_else(|| "none".into()) 143 ) 144 .unwrap(); 145 if let Some((ns, human)) = bind { 146 writeln!(out, "X-Tsk-Namespace: {ns}-{human}").unwrap(); 147 } 148 writeln!(out).unwrap(); 149 let mangled_msg = mangle_from(message); 150 out.push_str(&mangled_msg); 151 if !mangled_msg.ends_with('\n') { 152 out.push('\n'); 153 } 154 writeln!(out).unwrap(); 155 writeln!(out, "{TREE_DELIM}").unwrap(); 156 let tree = commit.tree()?; 157 // Iterate in tree order (already sorted by name). 158 for entry in tree.iter() { 159 let Some(name) = entry.name() else { continue }; 160 if name == TITLE_FILE { 161 // title is a cache; reconstructible from content. Skip. 162 continue; 163 } 164 let blob = entry.to_object(repo)?.peel_to_blob()?; 165 let bytes = blob.content(); 166 let as_str = 167 std::str::from_utf8(bytes).map_err(|e| Error::Parse(e.to_string()))?; 168 let mangled = mangle_from(as_str); 169 writeln!(out, "file: {name}").unwrap(); 170 writeln!(out, "size: {}", mangled.len()).unwrap(); 171 // Mangled bytes; size counts post-mangling. Importer reads `size` 172 // bytes verbatim then runs the inverse unmangle. 173 out.push_str(&mangled); 174 out.push('\n'); 175 } 176 writeln!(out, "{END_DELIM}").unwrap(); 177 writeln!(out).unwrap(); 178 Ok(()) 179} 180 181#[derive(Debug)] 182struct Entry { 183 author_name: String, 184 author_email: String, 185 when: Time, 186 message: String, 187 stable: String, 188 ns_bind: Option<(String, u32)>, 189 files: BTreeMap<String, Vec<u8>>, 190} 191 192#[derive(Debug)] 193pub struct ImportResult { 194 pub stable: StableId, 195 pub commits_imported: usize, 196 /// Hint from the sender: namespace + human id under which they had this 197 /// task bound. Currently unused by the workspace layer (recipient decides 198 /// binding) but parsed and exposed so future commands can honor it. 199 #[allow(dead_code)] 200 pub ns_bind: Option<(String, u32)>, 201} 202 203pub fn import_task(repo: &Repository, mbox: &str) -> Result<ImportResult> { 204 let entries = parse_mbox(mbox)?; 205 if entries.is_empty() { 206 return Err(Error::Parse("no patch entries found".into())); 207 } 208 let stable_hex = entries[0].stable.clone(); 209 let ns_bind = entries[0].ns_bind.clone(); 210 let mut prev: Option<Oid> = None; 211 for (idx, e) in entries.iter().enumerate() { 212 if e.stable != stable_hex { 213 return Err(Error::Parse(format!( 214 "stable id mismatch across entries: {} vs {}", 215 stable_hex, e.stable 216 ))); 217 } 218 // Build the tree from the file map. 219 let mut tb = repo.treebuilder(None)?; 220 let content = e 221 .files 222 .get(CONTENT_FILE) 223 .ok_or_else(|| Error::Parse("entry missing 'content' file".into()))?; 224 let content_oid = repo.blob(content)?; 225 if idx == 0 { 226 // Verify stable id == sha of root content blob. 227 if content_oid.to_string() != stable_hex { 228 return Err(Error::Parse(format!( 229 "stable id verification failed: expected {stable_hex}, content sha is {content_oid}" 230 ))); 231 } 232 } 233 tb.insert(CONTENT_FILE, content_oid, 0o100644)?; 234 // Re-derive title cache from content. 235 let title = std::str::from_utf8(content) 236 .map_err(|e| Error::Parse(e.to_string()))? 237 .lines() 238 .next() 239 .unwrap_or(""); 240 let title_oid = repo.blob(title.as_bytes())?; 241 tb.insert(TITLE_FILE, title_oid, 0o100644)?; 242 for (name, bytes) in &e.files { 243 if name == CONTENT_FILE || name == TITLE_FILE { 244 continue; 245 } 246 let oid = repo.blob(bytes)?; 247 tb.insert(name.as_str(), oid, 0o100644)?; 248 } 249 let tree_oid = tb.write()?; 250 // Author = original sender (from the From: / Date: headers). 251 // Committer = local user — same shape as `git rebase`, so the 252 // history records who applied the import while preserving authorship. 253 let author = Signature::new(&e.author_name, &e.author_email, &e.when)?; 254 let committer = repo 255 .signature() 256 .map(|s| s.to_owned()) 257 .unwrap_or_else(|_| Signature::now("tsk", "tsk@local").unwrap()); 258 let parents: Vec<git2::Commit> = prev.into_iter().map(|o| repo.find_commit(o).unwrap()).collect(); 259 let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); 260 let commit_oid = repo.commit( 261 None, 262 &author, 263 &committer, 264 &e.message, 265 &repo.find_tree(tree_oid)?, 266 &parent_refs, 267 )?; 268 prev = Some(commit_oid); 269 } 270 let stable = StableId(stable_hex); 271 repo.reference(&stable.refname(), prev.unwrap(), true, "import")?; 272 Ok(ImportResult { 273 stable, 274 commits_imported: entries.len(), 275 ns_bind, 276 }) 277} 278 279fn parse_mbox(s: &str) -> Result<Vec<Entry>> { 280 let mut entries = Vec::new(); 281 // Split on lines starting with "From " (the mbox separator). The "From " 282 // line is part of the entry it introduces. 283 let mut starts: Vec<usize> = Vec::new(); 284 let bytes = s.as_bytes(); 285 if bytes.starts_with(b"From ") { 286 starts.push(0); 287 } 288 let mut i = 0; 289 while i + 5 < bytes.len() { 290 if bytes[i] == b'\n' && &bytes[i + 1..i + 6] == b"From " { 291 starts.push(i + 1); 292 } 293 i += 1; 294 } 295 starts.push(bytes.len()); 296 for win in starts.windows(2) { 297 let chunk = &s[win[0]..win[1]]; 298 if chunk.trim().is_empty() { 299 continue; 300 } 301 entries.push(parse_entry(chunk)?); 302 } 303 Ok(entries) 304} 305 306fn parse_entry(chunk: &str) -> Result<Entry> { 307 let mut lines = chunk.split_inclusive('\n'); 308 // First line: "From <oid> Mon Sep 17 ..." 309 let _ = lines.next(); 310 let mut author_name = String::new(); 311 let mut author_email = String::new(); 312 let mut when: Option<Time> = None; 313 let mut subject = String::new(); 314 let mut stable = String::new(); 315 let mut ns_bind: Option<(String, u32)> = None; 316 // Headers until blank line. 317 for line in lines.by_ref() { 318 let trimmed = line.trim_end_matches(['\n', '\r']); 319 if trimmed.is_empty() { 320 break; 321 } 322 if let Some(v) = trimmed.strip_prefix("From: ") { 323 // "Name <email>" 324 if let Some((n, rest)) = v.split_once(" <") { 325 author_name = n.to_string(); 326 author_email = rest.trim_end_matches('>').to_string(); 327 } else { 328 author_name = v.to_string(); 329 } 330 } else if let Some(v) = trimmed.strip_prefix("Date: ") { 331 when = Some(parse_git_time(v)?); 332 } else if let Some(v) = trimmed.strip_prefix("Subject: ") { 333 subject = v.strip_prefix("[PATCH tsk] ").unwrap_or(v).to_string(); 334 } else if let Some(v) = trimmed.strip_prefix("X-Tsk-Stable-Id: ") { 335 stable = v.to_string(); 336 } else if let Some(_v) = trimmed.strip_prefix("X-Tsk-Parent: ") { 337 // Informational only; we use the previous imported commit as parent. 338 } else if let Some(v) = trimmed.strip_prefix("X-Tsk-Namespace: ") { 339 if let Some((ns, h)) = v.rsplit_once('-') 340 && let Ok(human) = h.parse::<u32>() 341 { 342 ns_bind = Some((ns.to_string(), human)); 343 } 344 } 345 } 346 if stable.is_empty() { 347 return Err(Error::Parse("missing X-Tsk-Stable-Id".into())); 348 } 349 let when = when.ok_or_else(|| Error::Parse("missing Date".into()))?; 350 // Body up to TREE_DELIM line. 351 let mut message = String::new(); 352 let mut in_tree = false; 353 for line in lines.by_ref() { 354 let trimmed = line.trim_end_matches(['\n', '\r']); 355 if trimmed == TREE_DELIM { 356 in_tree = true; 357 break; 358 } 359 message.push_str(line); 360 } 361 if !in_tree { 362 return Err(Error::Parse("missing tree delimiter".into())); 363 } 364 // Restore commit message: drop the trailing blank line we emitted. 365 while message.ends_with("\n\n") { 366 message.pop(); 367 } 368 // If the message is just the subject line (no body), use subject. 369 let message = if message.trim().is_empty() { 370 subject 371 } else { 372 unmangle_from(message.trim_end_matches('\n')) 373 }; 374 // Parse file blocks until END_DELIM. 375 let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new(); 376 // We need byte-level reads for sizes, so switch back to slice indexing. 377 // Compute remaining input from `lines`. 378 let remaining = lines.collect::<String>(); 379 let mut rest = remaining.as_bytes(); 380 loop { 381 // Read line. 382 let nl = rest 383 .iter() 384 .position(|b| *b == b'\n') 385 .ok_or_else(|| Error::Parse("unexpected eof in tree".into()))?; 386 let line = std::str::from_utf8(&rest[..nl]).map_err(|e| Error::Parse(e.to_string()))?; 387 let line_trim = line.trim_end_matches('\r'); 388 rest = &rest[nl + 1..]; 389 if line_trim == END_DELIM { 390 break; 391 } 392 let name = line_trim 393 .strip_prefix("file: ") 394 .ok_or_else(|| Error::Parse(format!("expected 'file:' got: {line_trim:?}")))? 395 .to_string(); 396 let nl = rest 397 .iter() 398 .position(|b| *b == b'\n') 399 .ok_or_else(|| Error::Parse("unexpected eof reading size".into()))?; 400 let size_line = std::str::from_utf8(&rest[..nl]).map_err(|e| Error::Parse(e.to_string()))?; 401 let size_line = size_line.trim_end_matches('\r'); 402 rest = &rest[nl + 1..]; 403 let size: usize = size_line 404 .strip_prefix("size: ") 405 .ok_or_else(|| Error::Parse(format!("expected 'size:' got: {size_line:?}")))? 406 .parse() 407 .map_err(|_| Error::Parse(format!("bad size: {size_line}")))?; 408 if rest.len() < size + 1 { 409 return Err(Error::Parse("truncated file body".into())); 410 } 411 let mangled = std::str::from_utf8(&rest[..size]) 412 .map_err(|e| Error::Parse(e.to_string()))?; 413 let bytes = unmangle_from(mangled).into_bytes(); 414 // Trailing \n separator (not part of size). 415 if rest[size] != b'\n' { 416 return Err(Error::Parse("missing newline after file body".into())); 417 } 418 rest = &rest[size + 1..]; 419 files.insert(name, bytes); 420 } 421 Ok(Entry { 422 author_name, 423 author_email, 424 when, 425 message, 426 stable, 427 ns_bind, 428 files, 429 }) 430} 431 432#[cfg(test)] 433mod test { 434 use super::*; 435 use crate::object::{self, Task}; 436 use std::path::Path; 437 438 fn init_repo(p: &Path) -> Repository { 439 let r = Repository::init(p).unwrap(); 440 let mut cfg = r.config().unwrap(); 441 cfg.set_str("user.name", "Tester").unwrap(); 442 cfg.set_str("user.email", "t@e").unwrap(); 443 r 444 } 445 446 #[test] 447 fn round_trip_single_commit() { 448 let dir = tempfile::tempdir().unwrap(); 449 let src = init_repo(dir.path()); 450 let mut t = Task::new("Hello\n\nbody text"); 451 t.properties.insert("status".into(), vec!["open".into()]); 452 let stable = object::create(&src, &t, "create").unwrap(); 453 454 let mbox = export_task(&src, &stable, &ExportOpts { bind: None }).unwrap(); 455 assert!(mbox.starts_with("From ")); 456 assert!(mbox.contains("X-Tsk-Stable-Id:")); 457 assert!(mbox.contains(TREE_DELIM)); 458 459 let dst_dir = tempfile::tempdir().unwrap(); 460 let dst = init_repo(dst_dir.path()); 461 let res = import_task(&dst, &mbox).unwrap(); 462 assert_eq!(res.stable, stable); 463 let read_back = object::read(&dst, &res.stable).unwrap().unwrap(); 464 assert_eq!(read_back.content, t.content); 465 assert_eq!(read_back.properties, t.properties); 466 } 467 468 #[test] 469 fn round_trip_preserves_history() { 470 let dir = tempfile::tempdir().unwrap(); 471 let src = init_repo(dir.path()); 472 let t = Task::new("v1"); 473 let stable = object::create(&src, &t, "create").unwrap(); 474 let mut t2 = t.clone(); 475 t2.content = "v2".into(); 476 object::update(&src, &stable, &t2, "edit-2").unwrap(); 477 let mut t3 = t2.clone(); 478 t3.content = "v3".into(); 479 object::update(&src, &stable, &t3, "edit-3").unwrap(); 480 481 let mbox = export_task(&src, &stable, &ExportOpts { bind: None }).unwrap(); 482 483 let dst_dir = tempfile::tempdir().unwrap(); 484 let dst = init_repo(dst_dir.path()); 485 let res = import_task(&dst, &mbox).unwrap(); 486 assert_eq!(res.commits_imported, 3); 487 488 let head = dst 489 .find_reference(&res.stable.refname()) 490 .unwrap() 491 .target() 492 .unwrap(); 493 let tip = dst.find_commit(head).unwrap(); 494 assert_eq!(tip.summary().unwrap(), "edit-3"); 495 let mid = tip.parent(0).unwrap(); 496 assert_eq!(mid.summary().unwrap(), "edit-2"); 497 let root = mid.parent(0).unwrap(); 498 assert_eq!(root.summary().unwrap(), "create"); 499 } 500 501 #[test] 502 fn tamper_detected_via_stable_id_check() { 503 let dir = tempfile::tempdir().unwrap(); 504 let src = init_repo(dir.path()); 505 let stable = 506 object::create(&src, &Task::new("original content"), "create").unwrap(); 507 let mbox = export_task(&src, &stable, &ExportOpts { bind: None }).unwrap(); 508 // Flip the content body without updating the stable id header. 509 // Equal-length substitution so size-prefix parsing still aligns; only 510 // the SHA check should reject it. 511 let tampered = mbox.replace("original content", "OVERRIDDEN BYTES"); 512 assert_eq!("original content".len(), "OVERRIDDEN BYTES".len()); 513 let dst_dir = tempfile::tempdir().unwrap(); 514 let dst = init_repo(dst_dir.path()); 515 let err = import_task(&dst, &tampered).unwrap_err(); 516 let msg = format!("{err}"); 517 assert!( 518 msg.contains("stable id verification failed"), 519 "expected verification error, got: {msg}" 520 ); 521 } 522 523 #[test] 524 fn from_mangling_round_trip() { 525 // Content has both a bare `From ` line and an already-quoted one. 526 let s = "preamble\nFrom the desk of...\n>From me\nbody\n"; 527 let mangled = mangle_from(s); 528 assert_eq!( 529 mangled, 530 "preamble\n>From the desk of...\n>>From me\nbody\n", 531 "every ^>*From line gets one extra '>'", 532 ); 533 assert_eq!(unmangle_from(&mangled), s); 534 } 535 536 #[test] 537 fn export_survives_strict_mbox_split() { 538 // A naive mbox parser splits entries on `\n` followed by `From `. 539 // Confirm the export only contains exactly one such boundary per 540 // commit, no matter what's in the body. 541 let dir = tempfile::tempdir().unwrap(); 542 let src = init_repo(dir.path()); 543 let stable = object::create( 544 &src, 545 &Task::new("evil\n\nFrom the desk of darth vader\nFrom another rogue line"), 546 "create", 547 ) 548 .unwrap(); 549 let mbox = export_task(&src, &stable, &ExportOpts { bind: None }).unwrap(); 550 551 // Count `\nFrom ` occurrences (real entry boundaries). For our 552 // single-commit export there should be exactly zero (the `From ` 553 // separator at the very start of the mbox isn't preceded by `\n`). 554 let interior_boundaries = mbox.match_indices("\nFrom ").count(); 555 assert_eq!( 556 interior_boundaries, 0, 557 "no spurious entry boundaries should appear in the body: {mbox}" 558 ); 559 560 // And the round-trip still reconstructs the original content. 561 let dst_dir = tempfile::tempdir().unwrap(); 562 let dst = init_repo(dst_dir.path()); 563 let res = import_task(&dst, &mbox).unwrap(); 564 let task = object::read(&dst, &res.stable).unwrap().unwrap(); 565 assert!(task.content.contains("From the desk")); 566 assert!(task.content.contains("From another rogue")); 567 } 568 569 fn init_repo_as(p: &Path, name: &str, email: &str) -> Repository { 570 let r = Repository::init(p).unwrap(); 571 let mut cfg = r.config().unwrap(); 572 cfg.set_str("user.name", name).unwrap(); 573 cfg.set_str("user.email", email).unwrap(); 574 r 575 } 576 577 #[test] 578 fn import_preserves_author_sets_local_committer() { 579 // Alice creates → exports. Bob imports. 580 let alice_dir = tempfile::tempdir().unwrap(); 581 let alice_repo = init_repo_as(alice_dir.path(), "Alice", "a@x"); 582 let stable = 583 object::create(&alice_repo, &Task::new("from alice"), "create").unwrap(); 584 let mbox = export_task(&alice_repo, &stable, &ExportOpts { bind: None }).unwrap(); 585 586 let bob_dir = tempfile::tempdir().unwrap(); 587 let bob_repo = init_repo_as(bob_dir.path(), "Bob", "b@x"); 588 let res = import_task(&bob_repo, &mbox).unwrap(); 589 let head = bob_repo 590 .find_reference(&res.stable.refname()) 591 .unwrap() 592 .target() 593 .unwrap(); 594 let commit = bob_repo.find_commit(head).unwrap(); 595 assert_eq!(commit.author().name().unwrap(), "Alice"); 596 assert_eq!(commit.committer().name().unwrap(), "Bob"); 597 } 598 599 #[test] 600 fn rebase_style_authorship_across_import_chain() { 601 // Alice creates v1 → exports. Bob imports, edits, exports. 602 // Alice imports Bob's mbox: root commit still authored by Alice, 603 // second commit authored by Bob. 604 let alice_dir = tempfile::tempdir().unwrap(); 605 let alice_repo = init_repo_as(alice_dir.path(), "Alice", "a@x"); 606 let stable = 607 object::create(&alice_repo, &Task::new("from alice"), "create").unwrap(); 608 let alice_mbox = 609 export_task(&alice_repo, &stable, &ExportOpts { bind: None }).unwrap(); 610 611 let bob_dir = tempfile::tempdir().unwrap(); 612 let bob_repo = init_repo_as(bob_dir.path(), "Bob", "b@x"); 613 import_task(&bob_repo, &alice_mbox).unwrap(); 614 // Bob edits — append a property without changing content (so the 615 // stable id stays the same). 616 let mut bobs_task = object::read(&bob_repo, &stable).unwrap().unwrap(); 617 bobs_task 618 .properties 619 .insert("priority".into(), vec!["high".into()]); 620 object::update(&bob_repo, &stable, &bobs_task, "bob's edit").unwrap(); 621 let bob_mbox = export_task(&bob_repo, &stable, &ExportOpts { bind: None }).unwrap(); 622 623 // Alice imports Bob's mbox into a fresh clone. Force-overwrite is fine 624 // because the import deliberately replaces the task ref. 625 let alice2_dir = tempfile::tempdir().unwrap(); 626 let alice2_repo = init_repo_as(alice2_dir.path(), "Alice", "a@x"); 627 let res = import_task(&alice2_repo, &bob_mbox).unwrap(); 628 let head = alice2_repo 629 .find_reference(&res.stable.refname()) 630 .unwrap() 631 .target() 632 .unwrap(); 633 let tip = alice2_repo.find_commit(head).unwrap(); 634 assert_eq!( 635 tip.author().name().unwrap(), 636 "Bob", 637 "the edit commit's author must be Bob" 638 ); 639 assert_eq!( 640 tip.committer().name().unwrap(), 641 "Alice", 642 "Alice imported, so the committer is Alice" 643 ); 644 let root = tip.parent(0).unwrap(); 645 assert_eq!( 646 root.author().name().unwrap(), 647 "Alice", 648 "the root commit's author must still be Alice across two hops" 649 ); 650 } 651 652 #[test] 653 fn ns_bind_header_round_trips() { 654 let dir = tempfile::tempdir().unwrap(); 655 let src = init_repo(dir.path()); 656 let stable = object::create(&src, &Task::new("bind me"), "create").unwrap(); 657 let mbox = export_task( 658 &src, 659 &stable, 660 &ExportOpts { 661 bind: Some(("alpha".into(), 7)), 662 }, 663 ) 664 .unwrap(); 665 assert!(mbox.contains("X-Tsk-Namespace: alpha-7")); 666 let dst_dir = tempfile::tempdir().unwrap(); 667 let dst = init_repo(dst_dir.path()); 668 let res = import_task(&dst, &mbox).unwrap(); 669 assert_eq!(res.ns_bind, Some(("alpha".to_string(), 7))); 670 } 671}