╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ event.zig ────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
400 // define more test events 401 // 402 403 const events_to_consume2 = [_]evt.EventWithId{ 404 .{ 405 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 406 .author = author, 407 .event = .{ 408 .issue = .{ 409 .title = "Kanban card status badge falls behind after drag", 410 .description = "Moving an issue between columns updates the card position immediately, but the status badge keeps showing the previous state until refresh.", 411 .tags = "bug kanban frontend", 412 }, 413 }, 414 }, 415 .{ 416 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 417 .author = author, 418 .event = .{ 419 .issue = .{ 420 .title = "Assignee autocomplete omits recently invited users", 421 .description = "Users invited during the current session do not appear in the assignee picker until the project page is reloaded.", 422 .tags = "bug assignees api", 423 }, 424 }, 425 }, 426 .{ 427 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 428 .author = author, 429 .event = .{ 430 .issue = .{ 431 .title = "Add due date warning for issues blocked by dependencies", 432 .description = "Show a warning when an issue's due date is earlier than an unresolved blocking issue so planners can adjust the schedule.", 433 .tags = "enhancement planning dependencies", 434 }, 435 }, 436 }, 437 }; 438 439 // 440 // insert issues as commits in the repo 441 // 442 443 { 444 var json: std.Io.Writer.Allocating = .init(allocator); 445 defer json.deinit(); 446 447 for (events_to_consume2, 0..) |event, i| { 448 json.clearRetainingCapacity(); 449 450 try std.json.Stringify.value(event, .{}, &json.writer); 451 452 // commit the event into a special branch 453 _ = try repo.commitAtRef( 454 io, 455 allocator, 456 // make the first commit a child of the first commit on the events branch 457 .{ .parent_oids = if (i == 0) &.{std.fmt.bytesToHex(first_oid, .lower)} else null, .message = json.written() }, 458 null, 459 other_events_ref, 460 ); 461 } 462 } 463 464 // 465 // check out the events branch and merge the other events branch into it 466 // 467 468 const merge_oid = blk: { 469 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 470 defer result.deinit(); 471 472 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 473 defer merge.deinit(); 474 475 try std.testing.expect(.success == merge.result); 476 477 break :blk merge.result.success.oid; 478 }; 479 480 // 481 // consume events into the database 482 // 483 484 { 485 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 486 487 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 488 489 // get the map of issues 490 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 491 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 492 493 // make sure one of the original issues is still there 494 { 495 // get the issue out of the map 496 var first_issue_id: [evt.event_id_size]u8 = undefined; 497 _ = try std.fmt.hexToBytes(&first_issue_id, &events_to_consume[0].id); 498 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_issue_id)) orelse return error.NotFound; 499 const first_issue_map = try Repo.DB.HashMap(.read_only).init(first_issue_cursor); 500 const first_issue = try evt.read(evt.Issue.Record, Repo.DB, repo_opts.hash, &arena, first_issue_map); 501 502 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.description, first_issue.event.description); 503 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.tags, first_issue.event.tags); 504 } 505 506 // make sure one of the new issues is there 507 { 508 // get the issue out of the map 509 var first_issue_id: [evt.event_id_size]u8 = undefined; 510 _ = try std.fmt.hexToBytes(&first_issue_id, &events_to_consume2[0].id); 511 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_issue_id)) orelse return error.NotFound; 512 const first_issue_map = try Repo.DB.HashMap(.read_only).init(first_issue_cursor); 513 const first_issue = try evt.read(evt.Issue.Record, Repo.DB, repo_opts.hash, &arena, first_issue_map); 514 515 try std.testing.expectEqualStrings(events_to_consume2[0].event.issue.?.description, first_issue.event.description); 516 try std.testing.expectEqualStrings(events_to_consume2[0].event.issue.?.tags, first_issue.event.tags); 517 } 518 519 // the ordered issue set unions both branches' additions 520 { 521 const status_to_issues_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "status->issue-id-set")) orelse return error.NotFound; 522 const status_to_issues = try Repo.DB.SortedMap(.read_only).init(status_to_issues_cursor); 523 const issue_id_set_cursor = try status_to_issues.getCursor("open") orelse return error.NotFound; 524 const issue_id_set = try Repo.DB.SortedSet(.read_only).init(issue_id_set_cursor); 525 try std.testing.expectEqual(6, try issue_id_set.count()); 526 527 var found = [_]bool{false} ** 6; 528 var iter = try issue_id_set.iteratorFromIndex(0); 529 while (try iter.next()) |id_cursor_val| { 530 var id_cursor = id_cursor_val; 531 const id_kv = try id_cursor.readKeyValuePair(); 532 var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 533 _ = try id_kv.key_cursor.readBytes(&order_key); 534 const id_hex = std.fmt.bytesToHex(order_key[@sizeOf(u64)..].*, .lower); 535 for (events_to_consume, 0..) |event, i| { 536 if (std.mem.eql(u8, &id_hex, &event.id)) found[i] = true; 537 } 538 for (events_to_consume2, 0..) |event, i| { 539 if (std.mem.eql(u8, &id_hex, &event.id)) found[events_to_consume.len + i] = true; 540 } 541 } 542 for (found) |f| try std.testing.expect(f); 543 } 544 } 545 546 // 547 // insert issues as commits in the repo 548 // 549 550 { 551 var json: std.Io.Writer.Allocating = .init(allocator); 552 defer json.deinit(); 553 554 for (events_to_consume, 0..) |event, i| { 555 json.clearRetainingCapacity(); 556 557 try std.json.Stringify.value(event, .{}, &json.writer); 558 559 // commit the event into a special branch 560 _ = try repo.commitAtRef( 561 io, 562 allocator, 563 // make the first event have no parents so we completely rebase the branch, 564 // undoing the merge that we did above 565 .{ .parent_oids = if (i == 0) &.{} else null, .message = json.written() }, 566 null, 567 evt.events_ref, 568 ); 569 } 570 } 571 572 // 573 // consume events into the database 574 // 575 576 { 577 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 578 579 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 580 581 // get the map of issues 582 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 583 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 584 585 // make sure the new issue is not there 586 { 587 // get the issue out of the map 588 var first_issue_id: [evt.event_id_size]u8 = undefined; 589 _ = try std.fmt.hexToBytes(&first_issue_id, &events_to_consume2[0].id); 590 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_issue_id)); 591 try std.testing.expect(null == first_issue_cursor); 592 } 593 } 594 595 // 596 // define test events that modify the same issue in conflicting ways 597 // 598 599 const events_to_consume3 = [_]evt.EventWithId{ 600 .{ 601 .id = events_to_consume[0].id, 602 .author = author, 603 .event = .{ 604 .issue = .{ 605 .title = "Login form clears password on validation error", 606 .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", 607 .tags = "bug priority-low ui", 608 }, 609 }, 610 }, 611 .{ 612 .id = events_to_consume[0].id, 613 .author = author, 614 .event = .{ 615 .issue = .{ 616 .title = "Login form clears password on validation error", 617 .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", 618 .tags = "bug priority-medium ui", 619 }, 620 }, 621 }, 622 }; 623 624 // 625 // insert issues as commits in the repo on different branches 626 // 627 628 var conflicting_oids: [events_to_consume3.len][hash.byteLen(repo_opts.hash)]u8 = undefined; 629 630 { 631 var json: std.Io.Writer.Allocating = .init(allocator); 632 defer json.deinit(); 633 634 for (events_to_consume3, 0..) |event, i| { 635 json.clearRetainingCapacity(); 636 637 try std.json.Stringify.value(event, .{}, &json.writer); 638 639 // commit the event into a special branch 640 const oid = try repo.commitAtRef( 641 io, 642 allocator, 643 .{ .parent_oids = &.{merge_oid}, .message = json.written() }, 644 null, 645 switch (i) { 646 0 => evt.events_ref, 647 1 => other_events_ref, 648 else => unreachable, 649 }, 650 ); 651 _ = try std.fmt.hexToBytes(&conflicting_oids[i], &oid); 652 } 653 } 654 655 // 656 // check out the events branch and merge the other events branch into it 657 // 658 659 const conflicting_merge_oid = blk: { 660 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 661 defer result.deinit(); 662 663 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 664 defer merge.deinit(); 665 666 try std.testing.expect(.success == merge.result); 667 668 break :blk merge.result.success.oid; 669 }; 670 671 // 672 // consume events into the database 673 // 674 675 var issue_id: [evt.event_id_size]u8 = undefined; 676 _ = try std.fmt.hexToBytes(&issue_id, &events_to_consume[0].id); 677 678 { 679 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 680 681 // consuming the merge again must be a no-op rather than replaying it 682 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 683 684 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 685 686 // the first parent is the events branch, so its version stays live 687 const issue = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &issue_id); 688 try std.testing.expectEqualStrings("bug priority-low ui", issue.event.tags); 689 690 // the issue is listed as conflicted, with the merge base recorded 691 const conflicts_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.conflicts_key)) orelse return error.NotFound; 692 const conflicts = try Repo.DB.SortedMap(.read_only).init(conflicts_cursor); 693 try std.testing.expectEqual(1, try conflicts.count()); 694 695 const order_key = evt.orderKeyDesc(issue.created_order, &issue_id); 696 const conflict_cursor = try conflicts.getCursor(&order_key) orelse return error.NotFound; 697 const conflict = try Repo.DB.HashMap(.read_only).init(conflict_cursor); 698 699 const base_cursor = try conflict.getCursor(hash.hashInt(repo_opts.hash, evt.base_record_key)) orelse return error.NotFound; 700 const base = try evt.read(evt.Issue.Record, Repo.DB, repo_opts.hash, &arena, try Repo.DB.HashMap(.read_only).init(base_cursor)); 701 try std.testing.expectEqualStrings("bug priority-high ui", base.event.tags); 702 703 // tags is the one field both sides changed differently 704 const fields_cursor = try conflict.getCursor(hash.hashInt(repo_opts.hash, evt.conflicted_fields_key)) orelse return error.NotFound; 705 const conflicted_fields = try fields_cursor.readBytesAlloc(arena.allocator(), null); 706 try std.testing.expectEqualStrings("tags", conflicted_fields); 707 708 // their whole version is recorded, not just the conflicting field 709 const their_cursor = try conflict.getCursor(hash.hashInt(repo_opts.hash, evt.their_record_key)) orelse return error.NotFound; 710 const theirs = try evt.read(evt.Issue.Record, Repo.DB, repo_opts.hash, &arena, try Repo.DB.HashMap(.read_only).init(their_cursor)); 711 try std.testing.expectEqualStrings("bug priority-medium ui", theirs.event.tags); 712 try std.testing.expectEqualStrings(issue.event.title, theirs.event.title); 713 try std.testing.expectEqualStrings(issue.event.description, theirs.event.description); 714 715 // their value is attributed to the commit that set it on their branch 716 const oids_cursor = try conflict.getCursor(hash.hashInt(repo_opts.hash, evt.their_field_to_oid_key)) orelse return error.NotFound; 717 const oids = try Repo.DB.SortedMap(.read_only).init(oids_cursor); 718 const oid_cursor = try oids.getCursor("tags") orelse return error.NotFound; 719 var their_oid: [hash.byteLen(repo_opts.hash)]u8 = undefined; 720 _ = try oid_cursor.readBytes(&their_oid); 721 try std.testing.expectEqualSlices(u8, &conflicting_oids[1], &their_oid); 722 723 // ours stays attributed to the commit on the events branch 724 try std.testing.expectEqualSlices(u8, &conflicting_oids[0], &try readOid(Repo.DB, repo_opts.hash, haxy_moment, &issue_id, "tags")); 725 726 // the losing side's tag didn't leak into the tag index 727 const tag_to_issues_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "tag+status->issue-id-set")) orelse return error.NotFound; 728 const tag_to_issues = try Repo.DB.SortedMap(.read_only).init(tag_to_issues_cursor); 729 try std.testing.expect(null != try tag_to_issues.getCursor("priority-low open")); 730 try std.testing.expect(null == try tag_to_issues.getCursor("priority-medium open")); 731 } 732 733 // 734 // a merge that touches the issue without conflicting leaves the conflict 735 // alone, since only events settle one 736 // 737 738 { 739 const their_issue = events_to_consume3[1].event.issue orelse return error.NotFound; 740 741 var json: std.Io.Writer.Allocating = .init(allocator); 742 defer json.deinit(); 743 744 // the other branch never saw the conflict and rewrites an unrelated 745 // field 746 const event = evt.EventWithId{ .id = events_to_consume3[1].id, .author = author, .event = .{ .issue = .{ 747 .title = their_issue.title, 748 .description = "Rewritten on a branch that never merged.", 749 .tags = their_issue.tags, 750 } } }; 751 try std.json.Stringify.value(event, .{}, &json.writer); 752 _ = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, other_events_ref); 753 754 { 755 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 756 defer result.deinit(); 757 758 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 759 defer merge.deinit(); 760 761 try std.testing.expect(.success == merge.result); 762 } 763 764 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 765 766 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 767 768 // their edit came over, so the merge really did touch the issue 769 const issue = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &issue_id); 770 try std.testing.expectEqualStrings("Rewritten on a branch that never merged.", issue.event.description); 771 772 // and the conflict is still waiting to be resolved 773 const conflicts_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.conflicts_key)) orelse return error.NotFound; 774 const conflicts = try Repo.DB.SortedMap(.read_only).init(conflicts_cursor); 775 try std.testing.expectEqual(1, try conflicts.count()); 776 } 777 778 // 779 // any event settles the conflict, even one that changes no conflicting 780 // field 781 // 782 783 { 784 try evt.Issue.update(.xit, repo_opts, io, allocator, &repo, &issue_id, .{ .status = .closed }, author); 785 786 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 787 const conflicts_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.conflicts_key)) orelse return error.NotFound; 788 const conflicts = try Repo.DB.SortedMap(.read_only).init(conflicts_cursor); 789 try std.testing.expectEqual(0, try conflicts.count()); 790 } 791 792 // 793 // edit the issue, so the merge below has a resolution to preserve 794 // 795 796 { 797 try evt.Issue.update(.xit, repo_opts, io, allocator, &repo, &issue_id, .{ .fields = .{ 798 .title = "Login form clears password on validation error", 799 .tags = "bug priority-medium ui", 800 .description = "Submitting an invalid email address resets the password field.", 801 } }, author); 802 } 803 804 // 805 // merging a branch that still holds the settled conflict must not bring it 806 // back 807 // 808 809 { 810 const conflicted_issue = events_to_consume3[0].event.issue orelse return error.NotFound; 811 812 var json: std.Io.Writer.Allocating = .init(allocator); 813 defer json.deinit(); 814 815 // the other branch changes the record on a commit that predates the 816 // resolution, so it still carries the conflict 817 const event = evt.EventWithId{ .id = events_to_consume3[0].id, .author = author, .event = .{ .issue = .{ 818 .title = conflicted_issue.title, 819 .description = conflicted_issue.description, 820 .tags = conflicted_issue.tags, 821 .status = .closed, 822 } } }; 823 try std.json.Stringify.value(event, .{}, &json.writer); 824 _ = try repo.commitAtRef(io, allocator, .{ .parent_oids = &.{conflicting_merge_oid}, .message = json.written() }, null, other_events_ref); 825 826 { 827 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 828 defer result.deinit(); 829 830 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 831 defer merge.deinit(); 832 833 try std.testing.expect(.success == merge.result); 834 } 835 836 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 837 838 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 839 840 const conflicts_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.conflicts_key)) orelse return error.NotFound; 841 const conflicts = try Repo.DB.SortedMap(.read_only).init(conflicts_cursor); 842 try std.testing.expectEqual(0, try conflicts.count()); 843 844 // and the resolution stands 845 const issue = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &issue_id); 846 try std.testing.expectEqualStrings("bug priority-medium ui", issue.event.tags); 847 } 848 849 // 850 // diverge again, with each side editing different fields of one issue and 851 // only the other side touching a second issue 852 // 853 854 const second_issue = events_to_consume[1].event.issue orelse return error.NotFound; 855 const third_issue = events_to_consume[2].event.issue orelse return error.NotFound; 856 857 var retitle_oid: [hash.byteLen(repo_opts.hash)]u8 = undefined; 858 var rewrite_oid: [hash.byteLen(repo_opts.hash)]u8 = undefined; 859 860 { 861 const base_oid = try repo.readRef(io, evt.events_ref) orelse return error.NotFound; 862 863 var json: std.Io.Writer.Allocating = .init(allocator); 864 defer json.deinit(); 865 866 // the events branch only retitles the second issue 867 { 868 const event = evt.EventWithId{ .id = events_to_consume[1].id, .author = author, .event = .{ .issue = .{ 869 .title = "Search results ignore the archived project filter", 870 .description = second_issue.description, 871 .tags = second_issue.tags, 872 } } }; 873 try std.json.Stringify.value(event, .{}, &json.writer); 874 const oid = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, evt.events_ref); 875 _ = try std.fmt.hexToBytes(&retitle_oid, &oid); 876 } 877 878 // the other branch rewrites the second issue's description and drops one 879 // of its tags, then retags the third issue 880 { 881 json.clearRetainingCapacity(); 882 const event = evt.EventWithId{ .id = events_to_consume[1].id, .author = author, .event = .{ .issue = .{ 883 .title = second_issue.title, 884 .description = "Archived projects are ranked before the flag is applied.", 885 .tags = "bug search", 886 } } }; 887 try std.json.Stringify.value(event, .{}, &json.writer); 888 const oid = try repo.commitAtRef(io, allocator, .{ .parent_oids = &.{base_oid}, .message = json.written() }, null, other_events_ref); 889 _ = try std.fmt.hexToBytes(&rewrite_oid, &oid); 890 } 891 { 892 json.clearRetainingCapacity(); 893 const event = evt.EventWithId{ .id = events_to_consume[2].id, .author = author, .event = .{ .issue = .{ 894 .title = third_issue.title, 895 .description = third_issue.description, 896 .tags = "enhancement roadmap", 897 } } }; 898 try std.json.Stringify.value(event, .{}, &json.writer); 899 _ = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, other_events_ref); 900 } 901 } 902 903 { 904 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 905 defer result.deinit(); 906 907 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 908 defer merge.deinit(); 909 910 try std.testing.expect(.success == merge.result); 911 } 912 913 { 914 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 915 916 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 917 918 var second_id: [evt.event_id_size]u8 = undefined; 919 _ = try std.fmt.hexToBytes(&second_id, &events_to_consume[1].id); 920 921 // each side's edit to a different field survives, without conflicting 922 const merged = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &second_id); 923 try std.testing.expectEqualStrings("Search results ignore the archived project filter", merged.event.title); 924 try std.testing.expectEqualStrings("Archived projects are ranked before the flag is applied.", merged.event.description); 925 try std.testing.expectEqualStrings("bug search", merged.event.tags); 926 927 // each merged field is attributed to the branch it came from 928 try std.testing.expectEqualSlices(u8, &retitle_oid, &try readOid(Repo.DB, repo_opts.hash, haxy_moment, &second_id, "title")); 929 try std.testing.expectEqualSlices(u8, &rewrite_oid, &try readOid(Repo.DB, repo_opts.hash, haxy_moment, &second_id, "description")); 930 931 const conflicts_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.conflicts_key)) orelse return error.NotFound; 932 const conflicts = try Repo.DB.SortedMap(.read_only).init(conflicts_cursor); 933 try std.testing.expectEqual(0, try conflicts.count()); 934 935 // tags the merged-in side dropped don't survive in the tag index, 936 // whether we edited that issue or left it alone 937 const tag_to_issues_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "tag+status->issue-id-set")) orelse return error.NotFound; 938 const tag_to_issues = try Repo.DB.SortedMap(.read_only).init(tag_to_issues_cursor); 939 940 var third_id: [evt.event_id_size]u8 = undefined; 941 _ = try std.fmt.hexToBytes(&third_id, &events_to_consume[2].id); 942 const retagged = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &third_id); 943 try std.testing.expectEqualStrings("enhancement roadmap", retagged.event.tags); 944 945 try std.testing.expect(null == try tag_to_issues.getCursor("backend open")); 946 try std.testing.expect(null == try tag_to_issues.getCursor("preferences open")); 947 try std.testing.expect(null != try tag_to_issues.getCursor("roadmap open")); 948 try std.testing.expect(null != try tag_to_issues.getCursor("enhancement open")); 949 } 950 951 // 952 // a removal on the other branch carries over, unless we edited the issue, 953 // in which case the edit wins 954 // 955 956 { 957 const edited_issue = events_to_consume2[1].event.issue orelse return error.NotFound; 958 959 const base_oid = try repo.readRef(io, evt.events_ref) orelse return error.NotFound; 960 961 var json: std.Io.Writer.Allocating = .init(allocator); 962 defer json.deinit(); 963 964 // we retitle one of the two the other branch is about to drop 965 { 966 const event = evt.EventWithId{ .id = events_to_consume2[1].id, .author = author, .event = .{ .issue = .{ 967 .title = "Kept by the edit", 968 .description = edited_issue.description, 969 .tags = edited_issue.tags, 970 } } }; 971 try std.json.Stringify.value(event, .{}, &json.writer); 972 _ = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, evt.events_ref); 973 } 974 975 // the other branch removes both 976 { 977 json.clearRetainingCapacity(); 978 const event = evt.EventWithId{ .id = events_to_consume2[0].id, .author = author, .event = .{ .issue = null } }; 979 try std.json.Stringify.value(event, .{}, &json.writer); 980 _ = try repo.commitAtRef(io, allocator, .{ .parent_oids = &.{base_oid}, .message = json.written() }, null, other_events_ref); 981 } 982 { 983 json.clearRetainingCapacity(); 984 const event = evt.EventWithId{ .id = events_to_consume2[1].id, .author = author, .event = .{ .issue = null } }; 985 try std.json.Stringify.value(event, .{}, &json.writer); 986 _ = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, other_events_ref); 987 } 988 989 { 990 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 991 defer result.deinit(); 992 993 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 994 defer merge.deinit(); 995 996 try std.testing.expect(.success == merge.result); 997 } 998 999 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 1000 1001 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 1002 1003 // the one we left alone is removed, and its tags left the index 1004 var dropped_id: [evt.event_id_size]u8 = undefined; 1005 _ = try std.fmt.hexToBytes(&dropped_id, &events_to_consume2[0].id); 1006 const dropped = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &dropped_id); 1007 try std.testing.expect(dropped.removed); 1008 1009 const tag_to_issues_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "tag+status->issue-id-set")) orelse return error.NotFound; 1010 const tag_to_issues = try Repo.DB.SortedMap(.read_only).init(tag_to_issues_cursor); 1011 try std.testing.expect(null == try tag_to_issues.getCursor("kanban open")); 1012 1013 // the one we edited survives, with our edit 1014 var kept_id: [evt.event_id_size]u8 = undefined; 1015 _ = try std.fmt.hexToBytes(&kept_id, &events_to_consume2[1].id); 1016 const kept = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &kept_id); 1017 try std.testing.expect(!kept.removed); 1018 try std.testing.expectEqualStrings("Kept by the edit", kept.event.title); 1019 } 1020 } 1021 1022 // the commit that last set `field_name` on the issue `id` names 1023 fn readOid( 1024 comptime DB: type, 1025 comptime hash_kind: hash.HashKind, 1026 haxy_moment: DB.HashMap(.read_only), 1027 id: *const [evt.event_id_size]u8, 1028 comptime field_name: []const u8, 1029 ) ![hash.byteLen(hash_kind)]u8 { 1030 const id_to_field_to_oid_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, evt.Issue.id_to_field_to_oid_key)) orelse return error.NotFound; 1031 const id_to_field_to_oid = try DB.HashMap(.read_only).init(id_to_field_to_oid_cursor); 1032 const field_oids_cursor = try id_to_field_to_oid.getCursor(hash.hashInt(hash_kind, id)) orelse return error.NotFound; 1033 const field_oids = try DB.SortedMap(.read_only).init(field_oids_cursor); 1034 const cursor = try field_oids.getCursor(field_name) orelse return error.NotFound; 1035 var oid: [hash.byteLen(hash_kind)]u8 = undefined; 1036 _ = try cursor.readBytes(&oid); 1037 return oid; 1038 } 1039 1040 // the issue `id` names, read out of `event-id->issue` 1041 fn readIssue( 1042 comptime DB: type, 1043 comptime hash_kind: hash.HashKind, 1044 haxy_moment: DB.HashMap(.read_only), 1045 arena: *std.heap.ArenaAllocator, 1046 id: *const [evt.event_id_size]u8, 1047 ) !evt.Issue.Record { 1048 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, "event-id->issue")) orelse return error.NotFound; 1049 const event_id_to_issue = try DB.HashMap(.read_only).init(event_id_to_issue_cursor); 1050 const issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(hash_kind, id)) orelse return error.NotFound; 1051 const issue_map = try DB.HashMap(.read_only).init(issue_cursor); 1052 return try evt.read(evt.Issue.Record, DB, hash_kind, arena, issue_map); 1053 } 1054