A file-based task manager
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}