A file-based task manager
0
fork

Configure Feed

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

Retain authorship across export/import like git rebase

patch::import_task was using the parsed From: header for both the
author and committer of each rebuilt commit, so the act of importing
masqueraded as the original sender. Match git rebase semantics
instead: author = parsed sender, committer = local user.

Two new tests cover:
- single import: Alice creates → Bob imports → root commit author is
Alice, committer is Bob.
- two-hop chain: Alice creates → Bob imports + edits + exports →
Alice imports Bob's mbox; root still authored by Alice, edit
authored by Bob, both committed by the importing party.

Local edits (object::update) already pick up the local signature for
both fields, which is correct for a fresh local commit; no change
needed there.

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

+93 -3
+93 -3
src/patch.rs
··· 247 247 tb.insert(name.as_str(), oid, 0o100644)?; 248 248 } 249 249 let tree_oid = tb.write()?; 250 - let sig = Signature::new(&e.author_name, &e.author_email, &e.when)?; 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()); 251 258 let parents: Vec<git2::Commit> = prev.into_iter().map(|o| repo.find_commit(o).unwrap()).collect(); 252 259 let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); 253 260 let commit_oid = repo.commit( 254 261 None, 255 - &sig, 256 - &sig, 262 + &author, 263 + &committer, 257 264 &e.message, 258 265 &repo.find_tree(tree_oid)?, 259 266 &parent_refs, ··· 557 564 let task = object::read(&dst, &res.stable).unwrap().unwrap(); 558 565 assert!(task.content.contains("From the desk")); 559 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 + ); 560 650 } 561 651 562 652 #[test]