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
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}