A file-based task manager
0
fork

Configure Feed

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

at noah/git-backend-2 728 lines 28 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 203/// Convenience wrapper for the single-task path: parse the mbox, expect 204/// exactly one task's chain, import it. 205#[allow(dead_code)] // kept for tests and external callers that want strict single-task semantics 206pub fn import_task(repo: &Repository, mbox: &str) -> Result<ImportResult> { 207 let mut all = import_mbox(repo, mbox)?; 208 if all.len() > 1 { 209 return Err(Error::Parse(format!( 210 "expected one task; mbox contained {}", 211 all.len() 212 ))); 213 } 214 all.pop() 215 .ok_or_else(|| Error::Parse("no patch entries found".into())) 216} 217 218/// Import every task in an mbox stream. Entries are grouped by their 219/// `X-Tsk-Stable-Id` header (consecutive entries with the same stable id 220/// belong to the same task's chain) and each group is imported in order. 221pub fn import_mbox(repo: &Repository, mbox: &str) -> Result<Vec<ImportResult>> { 222 let entries = parse_mbox(mbox)?; 223 if entries.is_empty() { 224 return Err(Error::Parse("no patch entries found".into())); 225 } 226 // Group consecutive entries by stable id. 227 let mut groups: Vec<Vec<Entry>> = Vec::new(); 228 for e in entries { 229 match groups.last_mut() { 230 Some(g) if g[0].stable == e.stable => g.push(e), 231 _ => groups.push(vec![e]), 232 } 233 } 234 let mut out = Vec::with_capacity(groups.len()); 235 for group in groups { 236 out.push(import_one_chain(repo, &group)?); 237 } 238 Ok(out) 239} 240 241fn import_one_chain(repo: &Repository, entries: &[Entry]) -> Result<ImportResult> { 242 let stable_hex = entries[0].stable.clone(); 243 let ns_bind = entries[0].ns_bind.clone(); 244 let mut prev: Option<Oid> = None; 245 for (idx, e) in entries.iter().enumerate() { 246 if e.stable != stable_hex { 247 return Err(Error::Parse(format!( 248 "stable id mismatch within chain: {} vs {}", 249 stable_hex, e.stable 250 ))); 251 } 252 // Build the tree from the file map. 253 let mut tb = repo.treebuilder(None)?; 254 let content = e 255 .files 256 .get(CONTENT_FILE) 257 .ok_or_else(|| Error::Parse("entry missing 'content' file".into()))?; 258 let content_oid = repo.blob(content)?; 259 if idx == 0 { 260 // Verify stable id == sha of root content blob. 261 if content_oid.to_string() != stable_hex { 262 return Err(Error::Parse(format!( 263 "stable id verification failed: expected {stable_hex}, content sha is {content_oid}" 264 ))); 265 } 266 } 267 tb.insert(CONTENT_FILE, content_oid, 0o100644)?; 268 // Re-derive title cache from content. 269 let title = std::str::from_utf8(content) 270 .map_err(|e| Error::Parse(e.to_string()))? 271 .lines() 272 .next() 273 .unwrap_or(""); 274 let title_oid = repo.blob(title.as_bytes())?; 275 tb.insert(TITLE_FILE, title_oid, 0o100644)?; 276 for (name, bytes) in &e.files { 277 if name == CONTENT_FILE || name == TITLE_FILE { 278 continue; 279 } 280 let oid = repo.blob(bytes)?; 281 tb.insert(name.as_str(), oid, 0o100644)?; 282 } 283 let tree_oid = tb.write()?; 284 // Author = original sender (from the From: / Date: headers). 285 // Committer = local user — same shape as `git rebase`, so the 286 // history records who applied the import while preserving authorship. 287 let author = Signature::new(&e.author_name, &e.author_email, &e.when)?; 288 let committer = crate::object::signature(repo); 289 let parents: Vec<git2::Commit> = prev.into_iter().map(|o| repo.find_commit(o).unwrap()).collect(); 290 let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); 291 let commit_oid = repo.commit( 292 None, 293 &author, 294 &committer, 295 &e.message, 296 &repo.find_tree(tree_oid)?, 297 &parent_refs, 298 )?; 299 prev = Some(commit_oid); 300 } 301 let stable = StableId(stable_hex); 302 repo.reference(&stable.refname(), prev.unwrap(), true, "import")?; 303 Ok(ImportResult { 304 stable, 305 commits_imported: entries.len(), 306 ns_bind, 307 }) 308} 309 310fn parse_mbox(s: &str) -> Result<Vec<Entry>> { 311 let mut entries = Vec::new(); 312 // Split on lines starting with "From " (the mbox separator). The "From " 313 // line is part of the entry it introduces. 314 let mut starts: Vec<usize> = Vec::new(); 315 let bytes = s.as_bytes(); 316 if bytes.starts_with(b"From ") { 317 starts.push(0); 318 } 319 let mut i = 0; 320 while i + 5 < bytes.len() { 321 if bytes[i] == b'\n' && &bytes[i + 1..i + 6] == b"From " { 322 starts.push(i + 1); 323 } 324 i += 1; 325 } 326 starts.push(bytes.len()); 327 for win in starts.windows(2) { 328 let chunk = &s[win[0]..win[1]]; 329 if chunk.trim().is_empty() { 330 continue; 331 } 332 entries.push(parse_entry(chunk)?); 333 } 334 Ok(entries) 335} 336 337/// Consume one `\n`-terminated line; trailing `\r` is stripped. 338fn pop_line<'a>(rest: &mut &'a [u8], eof_msg: &str) -> Result<&'a str> { 339 let nl = rest 340 .iter() 341 .position(|b| *b == b'\n') 342 .ok_or_else(|| Error::Parse(eof_msg.into()))?; 343 let line = std::str::from_utf8(&rest[..nl]) 344 .map_err(|e| Error::Parse(e.to_string()))? 345 .trim_end_matches('\r'); 346 *rest = &rest[nl + 1..]; 347 Ok(line) 348} 349 350fn parse_entry(chunk: &str) -> Result<Entry> { 351 let mut lines = chunk.split_inclusive('\n'); 352 // First line: "From <oid> Mon Sep 17 ..." 353 let _ = lines.next(); 354 let mut author_name = String::new(); 355 let mut author_email = String::new(); 356 let mut when: Option<Time> = None; 357 let mut subject = String::new(); 358 let mut stable = String::new(); 359 let mut ns_bind: Option<(String, u32)> = None; 360 // Headers until blank line. 361 for line in lines.by_ref() { 362 let trimmed = line.trim_end_matches(['\n', '\r']); 363 if trimmed.is_empty() { 364 break; 365 } 366 if let Some(v) = trimmed.strip_prefix("From: ") { 367 // "Name <email>" 368 if let Some((n, rest)) = v.split_once(" <") { 369 author_name = n.to_string(); 370 author_email = rest.trim_end_matches('>').to_string(); 371 } else { 372 author_name = v.to_string(); 373 } 374 } else if let Some(v) = trimmed.strip_prefix("Date: ") { 375 when = Some(parse_git_time(v)?); 376 } else if let Some(v) = trimmed.strip_prefix("Subject: ") { 377 subject = v.strip_prefix("[PATCH tsk] ").unwrap_or(v).to_string(); 378 } else if let Some(v) = trimmed.strip_prefix("X-Tsk-Stable-Id: ") { 379 stable = v.to_string(); 380 } else if let Some(_v) = trimmed.strip_prefix("X-Tsk-Parent: ") { 381 // Informational only; we use the previous imported commit as parent. 382 } else if let Some(v) = trimmed.strip_prefix("X-Tsk-Namespace: ") { 383 if let Some((ns, h)) = v.rsplit_once('-') 384 && let Ok(human) = h.parse::<u32>() 385 { 386 ns_bind = Some((ns.to_string(), human)); 387 } 388 } 389 } 390 if stable.is_empty() { 391 return Err(Error::Parse("missing X-Tsk-Stable-Id".into())); 392 } 393 let when = when.ok_or_else(|| Error::Parse("missing Date".into()))?; 394 // Body up to TREE_DELIM line. 395 let mut message = String::new(); 396 let mut in_tree = false; 397 for line in lines.by_ref() { 398 let trimmed = line.trim_end_matches(['\n', '\r']); 399 if trimmed == TREE_DELIM { 400 in_tree = true; 401 break; 402 } 403 message.push_str(line); 404 } 405 if !in_tree { 406 return Err(Error::Parse("missing tree delimiter".into())); 407 } 408 // Restore commit message: drop the trailing blank line we emitted. 409 while message.ends_with("\n\n") { 410 message.pop(); 411 } 412 // If the message is just the subject line (no body), use subject. 413 let message = if message.trim().is_empty() { 414 subject 415 } else { 416 unmangle_from(message.trim_end_matches('\n')) 417 }; 418 // Parse file blocks until END_DELIM. We need byte-level reads for the 419 // size-prefixed bodies, so switch from `lines` to slice indexing. 420 let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new(); 421 let remaining = lines.collect::<String>(); 422 let mut rest = remaining.as_bytes(); 423 loop { 424 let line = pop_line(&mut rest, "unexpected eof in tree")?; 425 if line == END_DELIM { 426 break; 427 } 428 let name = line 429 .strip_prefix("file: ") 430 .ok_or_else(|| Error::Parse(format!("expected 'file:' got: {line:?}")))? 431 .to_string(); 432 let size_line = pop_line(&mut rest, "unexpected eof reading size")?; 433 let size: usize = size_line 434 .strip_prefix("size: ") 435 .ok_or_else(|| Error::Parse(format!("expected 'size:' got: {size_line:?}")))? 436 .parse() 437 .map_err(|_| Error::Parse(format!("bad size: {size_line}")))?; 438 if rest.len() < size + 1 { 439 return Err(Error::Parse("truncated file body".into())); 440 } 441 let mangled = std::str::from_utf8(&rest[..size]) 442 .map_err(|e| Error::Parse(e.to_string()))?; 443 if rest[size] != b'\n' { 444 return Err(Error::Parse("missing newline after file body".into())); 445 } 446 rest = &rest[size + 1..]; 447 files.insert(name, unmangle_from(mangled).into_bytes()); 448 } 449 Ok(Entry { 450 author_name, 451 author_email, 452 when, 453 message, 454 stable, 455 ns_bind, 456 files, 457 }) 458} 459 460#[cfg(test)] 461mod test { 462 use super::*; 463 use crate::object::{self, Task}; 464 use std::path::Path; 465 466 fn init_repo(p: &Path) -> Repository { 467 let r = Repository::init(p).unwrap(); 468 let mut cfg = r.config().unwrap(); 469 cfg.set_str("user.name", "Tester").unwrap(); 470 cfg.set_str("user.email", "t@e").unwrap(); 471 r 472 } 473 474 #[test] 475 fn round_trip_single_commit() { 476 let dir = tempfile::tempdir().unwrap(); 477 let src = init_repo(dir.path()); 478 let mut t = Task::new("Hello\n\nbody text"); 479 t.properties.insert("status".into(), vec!["open".into()]); 480 let stable = object::create(&src, &t, "create").unwrap(); 481 482 let mbox = export_task(&src, &stable, &ExportOpts { bind: None }).unwrap(); 483 assert!(mbox.starts_with("From ")); 484 assert!(mbox.contains("X-Tsk-Stable-Id:")); 485 assert!(mbox.contains(TREE_DELIM)); 486 487 let dst_dir = tempfile::tempdir().unwrap(); 488 let dst = init_repo(dst_dir.path()); 489 let res = import_task(&dst, &mbox).unwrap(); 490 assert_eq!(res.stable, stable); 491 let read_back = object::read(&dst, &res.stable).unwrap().unwrap(); 492 assert_eq!(read_back.content, t.content); 493 assert_eq!(read_back.properties, t.properties); 494 } 495 496 #[test] 497 fn round_trip_preserves_history() { 498 let dir = tempfile::tempdir().unwrap(); 499 let src = init_repo(dir.path()); 500 let t = Task::new("v1"); 501 let stable = object::create(&src, &t, "create").unwrap(); 502 let mut t2 = t.clone(); 503 t2.content = "v2".into(); 504 object::update(&src, &stable, &t2, "edit-2").unwrap(); 505 let mut t3 = t2.clone(); 506 t3.content = "v3".into(); 507 object::update(&src, &stable, &t3, "edit-3").unwrap(); 508 509 let mbox = export_task(&src, &stable, &ExportOpts { bind: None }).unwrap(); 510 511 let dst_dir = tempfile::tempdir().unwrap(); 512 let dst = init_repo(dst_dir.path()); 513 let res = import_task(&dst, &mbox).unwrap(); 514 assert_eq!(res.commits_imported, 3); 515 516 let head = dst 517 .find_reference(&res.stable.refname()) 518 .unwrap() 519 .target() 520 .unwrap(); 521 let tip = dst.find_commit(head).unwrap(); 522 assert_eq!(tip.summary().unwrap(), "edit-3"); 523 let mid = tip.parent(0).unwrap(); 524 assert_eq!(mid.summary().unwrap(), "edit-2"); 525 let root = mid.parent(0).unwrap(); 526 assert_eq!(root.summary().unwrap(), "create"); 527 } 528 529 #[test] 530 fn tamper_detected_via_stable_id_check() { 531 let dir = tempfile::tempdir().unwrap(); 532 let src = init_repo(dir.path()); 533 let stable = 534 object::create(&src, &Task::new("original content"), "create").unwrap(); 535 let mbox = export_task(&src, &stable, &ExportOpts { bind: None }).unwrap(); 536 // Flip the content body without updating the stable id header. 537 // Equal-length substitution so size-prefix parsing still aligns; only 538 // the SHA check should reject it. 539 let tampered = mbox.replace("original content", "OVERRIDDEN BYTES"); 540 assert_eq!("original content".len(), "OVERRIDDEN BYTES".len()); 541 let dst_dir = tempfile::tempdir().unwrap(); 542 let dst = init_repo(dst_dir.path()); 543 let err = import_task(&dst, &tampered).unwrap_err(); 544 let msg = format!("{err}"); 545 assert!( 546 msg.contains("stable id verification failed"), 547 "expected verification error, got: {msg}" 548 ); 549 } 550 551 #[test] 552 fn from_mangling_round_trip() { 553 // Content has both a bare `From ` line and an already-quoted one. 554 let s = "preamble\nFrom the desk of...\n>From me\nbody\n"; 555 let mangled = mangle_from(s); 556 assert_eq!( 557 mangled, 558 "preamble\n>From the desk of...\n>>From me\nbody\n", 559 "every ^>*From line gets one extra '>'", 560 ); 561 assert_eq!(unmangle_from(&mangled), s); 562 } 563 564 #[test] 565 fn export_survives_strict_mbox_split() { 566 // A naive mbox parser splits entries on `\n` followed by `From `. 567 // Confirm the export only contains exactly one such boundary per 568 // commit, no matter what's in the body. 569 let dir = tempfile::tempdir().unwrap(); 570 let src = init_repo(dir.path()); 571 let stable = object::create( 572 &src, 573 &Task::new("evil\n\nFrom the desk of darth vader\nFrom another rogue line"), 574 "create", 575 ) 576 .unwrap(); 577 let mbox = export_task(&src, &stable, &ExportOpts { bind: None }).unwrap(); 578 579 // Count `\nFrom ` occurrences (real entry boundaries). For our 580 // single-commit export there should be exactly zero (the `From ` 581 // separator at the very start of the mbox isn't preceded by `\n`). 582 let interior_boundaries = mbox.match_indices("\nFrom ").count(); 583 assert_eq!( 584 interior_boundaries, 0, 585 "no spurious entry boundaries should appear in the body: {mbox}" 586 ); 587 588 // And the round-trip still reconstructs the original content. 589 let dst_dir = tempfile::tempdir().unwrap(); 590 let dst = init_repo(dst_dir.path()); 591 let res = import_task(&dst, &mbox).unwrap(); 592 let task = object::read(&dst, &res.stable).unwrap().unwrap(); 593 assert!(task.content.contains("From the desk")); 594 assert!(task.content.contains("From another rogue")); 595 } 596 597 fn init_repo_as(p: &Path, name: &str, email: &str) -> Repository { 598 let r = Repository::init(p).unwrap(); 599 let mut cfg = r.config().unwrap(); 600 cfg.set_str("user.name", name).unwrap(); 601 cfg.set_str("user.email", email).unwrap(); 602 r 603 } 604 605 #[test] 606 fn import_preserves_author_sets_local_committer() { 607 // Alice creates → exports. Bob imports. 608 let alice_dir = tempfile::tempdir().unwrap(); 609 let alice_repo = init_repo_as(alice_dir.path(), "Alice", "a@x"); 610 let stable = 611 object::create(&alice_repo, &Task::new("from alice"), "create").unwrap(); 612 let mbox = export_task(&alice_repo, &stable, &ExportOpts { bind: None }).unwrap(); 613 614 let bob_dir = tempfile::tempdir().unwrap(); 615 let bob_repo = init_repo_as(bob_dir.path(), "Bob", "b@x"); 616 let res = import_task(&bob_repo, &mbox).unwrap(); 617 let head = bob_repo 618 .find_reference(&res.stable.refname()) 619 .unwrap() 620 .target() 621 .unwrap(); 622 let commit = bob_repo.find_commit(head).unwrap(); 623 assert_eq!(commit.author().name().unwrap(), "Alice"); 624 assert_eq!(commit.committer().name().unwrap(), "Bob"); 625 } 626 627 #[test] 628 fn rebase_style_authorship_across_import_chain() { 629 // Alice creates v1 → exports. Bob imports, edits, exports. 630 // Alice imports Bob's mbox: root commit still authored by Alice, 631 // second commit authored by Bob. 632 let alice_dir = tempfile::tempdir().unwrap(); 633 let alice_repo = init_repo_as(alice_dir.path(), "Alice", "a@x"); 634 let stable = 635 object::create(&alice_repo, &Task::new("from alice"), "create").unwrap(); 636 let alice_mbox = 637 export_task(&alice_repo, &stable, &ExportOpts { bind: None }).unwrap(); 638 639 let bob_dir = tempfile::tempdir().unwrap(); 640 let bob_repo = init_repo_as(bob_dir.path(), "Bob", "b@x"); 641 import_task(&bob_repo, &alice_mbox).unwrap(); 642 // Bob edits — append a property without changing content (so the 643 // stable id stays the same). 644 let mut bobs_task = object::read(&bob_repo, &stable).unwrap().unwrap(); 645 bobs_task 646 .properties 647 .insert("priority".into(), vec!["high".into()]); 648 object::update(&bob_repo, &stable, &bobs_task, "bob's edit").unwrap(); 649 let bob_mbox = export_task(&bob_repo, &stable, &ExportOpts { bind: None }).unwrap(); 650 651 // Alice imports Bob's mbox into a fresh clone. Force-overwrite is fine 652 // because the import deliberately replaces the task ref. 653 let alice2_dir = tempfile::tempdir().unwrap(); 654 let alice2_repo = init_repo_as(alice2_dir.path(), "Alice", "a@x"); 655 let res = import_task(&alice2_repo, &bob_mbox).unwrap(); 656 let head = alice2_repo 657 .find_reference(&res.stable.refname()) 658 .unwrap() 659 .target() 660 .unwrap(); 661 let tip = alice2_repo.find_commit(head).unwrap(); 662 assert_eq!( 663 tip.author().name().unwrap(), 664 "Bob", 665 "the edit commit's author must be Bob" 666 ); 667 assert_eq!( 668 tip.committer().name().unwrap(), 669 "Alice", 670 "Alice imported, so the committer is Alice" 671 ); 672 let root = tip.parent(0).unwrap(); 673 assert_eq!( 674 root.author().name().unwrap(), 675 "Alice", 676 "the root commit's author must still be Alice across two hops" 677 ); 678 } 679 680 #[test] 681 fn multi_task_mbox_imports_all_chains() { 682 let dir = tempfile::tempdir().unwrap(); 683 let src = init_repo(dir.path()); 684 let s1 = object::create(&src, &Task::new("first task"), "create").unwrap(); 685 let s2 = object::create(&src, &Task::new("second task"), "create").unwrap(); 686 // Add an edit to s2 so it has a multi-commit chain — the grouping 687 // logic must keep both of s2's entries together. 688 let mut t2 = object::read(&src, &s2).unwrap().unwrap(); 689 t2.properties.insert("priority".into(), vec!["low".into()]); 690 object::update(&src, &s2, &t2, "edit-second").unwrap(); 691 692 let mbox1 = export_task(&src, &s1, &ExportOpts { bind: None }).unwrap(); 693 let mbox2 = export_task(&src, &s2, &ExportOpts { bind: None }).unwrap(); 694 let combined = format!("{mbox1}{mbox2}"); 695 696 let dst_dir = tempfile::tempdir().unwrap(); 697 let dst = init_repo(dst_dir.path()); 698 let outcomes = import_mbox(&dst, &combined).unwrap(); 699 assert_eq!(outcomes.len(), 2, "two chains must yield two outcomes"); 700 assert_eq!(outcomes[0].stable, s1); 701 assert_eq!(outcomes[0].commits_imported, 1); 702 assert_eq!(outcomes[1].stable, s2); 703 assert_eq!(outcomes[1].commits_imported, 2); 704 // Both task refs landed in the destination repo. 705 assert!(dst.find_reference(&s1.refname()).is_ok()); 706 assert!(dst.find_reference(&s2.refname()).is_ok()); 707 } 708 709 #[test] 710 fn ns_bind_header_round_trips() { 711 let dir = tempfile::tempdir().unwrap(); 712 let src = init_repo(dir.path()); 713 let stable = object::create(&src, &Task::new("bind me"), "create").unwrap(); 714 let mbox = export_task( 715 &src, 716 &stable, 717 &ExportOpts { 718 bind: Some(("alpha".into(), 7)), 719 }, 720 ) 721 .unwrap(); 722 assert!(mbox.contains("X-Tsk-Namespace: alpha-7")); 723 let dst_dir = tempfile::tempdir().unwrap(); 724 let dst = init_repo(dst_dir.path()); 725 let res = import_task(&dst, &mbox).unwrap(); 726 assert_eq!(res.ns_bind, Some(("alpha".to_string(), 7))); 727 } 728}