╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ try.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
549 .description = "Resizing the window while the splash animation is running dereferences a freed layout node. Cancel the animation before rebuilding the layout.", 550 .tags = "bug crash ui", 551 }, 552 .{ 553 .title = "Add integration tests for the sync engine", 554 .description = sync_p1 ++ "\n\n" ++ sync_p2 ++ "\n\n" ++ sync_p3, 555 .tags = "testing sync", 556 }, 557 .{ 558 .title = "Timestamps display in UTC instead of local time", 559 .description = tz_p1 ++ "\n\n" ++ tz_p2 ++ "\n\n" ++ tz_p3 ++ "\n\n" ++ tz_p4, 560 .tags = "bug i18n time", 561 }, 562 .{ 563 .title = "Deprecate the legacy plugin format", 564 .description = "Both plugin formats are currently loaded, doubling the maintenance surface. Warn on legacy plugins this release and drop support in the next.", 565 .tags = "maintenance plugins", 566 }, 567 }; 568 569 var issue_events: [issue_data.len]evt.EventWithId = undefined; 570 for (issue_data, 0..) |issue, i| { 571 // the fifth-newest issue's description runs past what the detail 572 // pane shows, so it shows the truncated description and its 573 // link. 574 const description = if (i == issue_data.len - 5) 575 try longDescription(arena.allocator(), issue.description) 576 else 577 issue.description; 578 issue_events[i] = .{ 579 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 580 // stepped timestamps so the issues list in a stable order 581 .timestamp = @intCast(i + 1), 582 .author = .{ .name = user_data[i % user_data.len].name, .email = user_data[i % user_data.len].email }, 583 .event = .{ 584 .issue = .{ 585 .title = issue.title, 586 .description = description, 587 .tags = issue.tags, 588 .status = issue.status, 589 }, 590 }, 591 }; 592 } 593 try evt.consume(.repo, .xit, .{}, io, allocator, &template_repo, evt.events_ref, &issue_events); 594 // the tip is the branch point for the conflicting edits below 595 const seed_tip = (try template_repo.readRef(io, evt.events_ref)) orelse unreachable; 596 597 // two divergent edits per conflicted issue: ours on the events 598 // branch, theirs on a temp branch rooted at the seed tip, then a 599 // merge. the 4th-newest issue conflicts on title and tags; the 600 // 3rd-newest on its description with every hunk auto-resolving 601 // (each side reworks a different paragraph); the 2nd-newest on 602 // its description with a removal conflict (ours removes a 603 // paragraph theirs rewords) and an insertion conflict (each side 604 // appends a different closing paragraph). 605 { 606 const other_ref: xit.ref.Ref = .{ .kind = .head, .name = "haxy/other" }; 607 const title_issue = issue_data[issue_data.len - 4]; 608 const sync_issue = issue_data[issue_data.len - 3]; 609 const desc_issue = issue_data[issue_data.len - 2]; 610 611 const ours = [_]evt.EventWithId{ .{ 612 .id = issue_events[issue_data.len - 4].id, 613 .timestamp = 100, 614 .author = .{ .name = user_data[1].name, .email = user_data[1].email }, 615 .event = .{ .issue = .{ 616 .title = "Crash when resizing the window during the splash animation", 617 .description = title_issue.description, 618 .tags = "bug crash ui priority-high", 619 } }, 620 }, .{ 621 .id = issue_events[issue_data.len - 3].id, 622 .timestamp = 101, 623 .author = .{ .name = user_data[1].name, .email = user_data[1].email }, 624 .event = .{ .issue = .{ 625 .title = sync_issue.title, 626 .description = "The sync engine's coverage is unit tests only, with every transport mocked out." ++ "\n\n" ++ 627 sync_p2 ++ "\n\n" ++ sync_p3, 628 .tags = sync_issue.tags, 629 } }, 630 }, .{ 631 .id = issue_events[issue_data.len - 2].id, 632 .timestamp = 102, 633 .author = .{ .name = user_data[1].name, .email = user_data[1].email }, 634 .event = .{ .issue = .{ 635 .title = desc_issue.title, 636 .description = tz_p1 ++ "\n\n" ++ tz_p2 ++ "\n\n" ++ tz_p4 ++ "\n\n" ++ 637 "The confusion is worst for teams spread across timezones, who each read a different wall-clock time from the same feed.", 638 .tags = desc_issue.tags, 639 } }, 640 } }; 641 const theirs = [_]evt.EventWithId{ .{ 642 .id = issue_events[issue_data.len - 4].id, 643 .timestamp = 103, 644 .author = .{ .name = user_data[2].name, .email = user_data[2].email }, 645 .event = .{ .issue = .{ 646 .title = "Segfault on early window resize", 647 .description = title_issue.description, 648 .tags = "bug crash rendering", 649 } }, 650 }, .{ 651 .id = issue_events[issue_data.len - 3].id, 652 .timestamp = 104, 653 .author = .{ .name = user_data[2].name, .email = user_data[2].email }, 654 .event = .{ .issue = .{ 655 .title = sync_issue.title, 656 .description = sync_p1 ++ "\n\n" ++ sync_p2 ++ "\n\n" ++ 657 "Add end-to-end tests that drive two live instances against a local server on every CI run.", 658 .tags = sync_issue.tags, 659 } }, 660 }, .{ 661 .id = issue_events[issue_data.len - 2].id, 662 .timestamp = 105, 663 .author = .{ .name = user_data[2].name, .email = user_data[2].email }, 664 .event = .{ .issue = .{ 665 .title = desc_issue.title, 666 .description = tz_p1 ++ "\n\n" ++ tz_p2 ++ "\n\n" ++ 667 "Most other clients already render local time by default, which makes our UTC output stand out as a bug." ++ "\n\n" ++ tz_p4 ++ "\n\n" ++ 668 "Log exports inherit the same UTC rendering, so downstream tooling has to guess the source timezone.", 669 .tags = desc_issue.tags, 670 } }, 671 } }; 672 673 try evt.consume(.repo, .xit, .{}, io, allocator, &template_repo, evt.events_ref, &ours); 674 675 // consume can't root a new branch, so theirs commits by hand 676 var json: std.Io.Writer.Allocating = .init(allocator); 677 defer json.deinit(); 678 for (theirs, 0..) |event, i| { 679 json.clearRetainingCapacity(); 680 try std.json.Stringify.value(event, .{}, &json.writer); 681 const author = try std.fmt.allocPrint(allocator, "{s} <{s}>", .{ event.author.name, event.author.email }); 682 defer allocator.free(author); 683 _ = try template_repo.commitAtRef(io, allocator, .{ 684 .author = author, 685 .message = json.written(), 686 .timestamp = event.timestamp, 687 // root the branch at the shared seed tip 688 .parent_oids = if (i == 0) &.{seed_tip} else null, 689 }, null, other_ref); 690 } 691 692 { 693 var to_events = try template_repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 694 defer to_events.deinit(); 695 } 696 { 697 var merge = try template_repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_ref }} } } }, null); 698 defer merge.deinit(); 699 if (merge.result != .success) return error.MergeFailed; 700 } 701 { 702 var to_master = try template_repo.switchDir(io, allocator, .{ .target = .{ .ref = .{ .kind = .head, .name = "master" } } }); 703 defer to_master.deinit(); 704 } 705 try template_repo.removeBranch(io, .{ .name = other_ref.name }); 706 } 707 708 try evt.consume(.repo, .xit, .{}, io, allocator, &template_repo, evt.events_ref, &.{}); 709 710 // seed a small comment tree on the newest issue 711 var comment_ids: [5][evt.event_id_size]u8 = undefined; 712 for (&comment_ids) |*id| id.* = evt.EventWithId.randomId(prng.random()); 713 714 const comment_bodies = [_][]const u8{ 715 "I can take this. The compatibility scanner already reports which repositories still use the legacy format.", 716 "Can we keep loading legacy plugins for one release after the warning lands? That would give downstream maintainers time to migrate.", 717 "That sounds good. I'll include the scanner output in the warning so each affected plugin is named.", 718 "Please include the replacement manifest path too. That should make the warning actionable without opening the migration guide.", 719 "I'll add release-note text once the warning format is settled.", 720 }; 721 722 var comment_events: [comment_ids.len]evt.EventWithId = undefined; 723 for (&comment_events, 0..) |*event, i| { 724 event.* = .{ 725 .id = std.fmt.bytesToHex(comment_ids[i], .lower), 726 .timestamp = @intCast(200 + i), 727 .author = .{ .name = user_data[(i + 1) % user_data.len].name, .email = user_data[(i + 1) % user_data.len].email }, 728 .event = .{ .comment = .{ 729 .thread_id = issue_events[issue_events.len - 1].id, 730 .parent_id = switch (i) { 731 0, 1 => issue_events[issue_events.len - 1].id, 732 2, 4 => std.fmt.bytesToHex(comment_ids[0], .lower), 733 3 => std.fmt.bytesToHex(comment_ids[2], .lower), 734 else => unreachable, 735 }, 736 .body = comment_bodies[i], 737 } }, 738 }; 739 } 740 try evt.consume(.repo, .xit, .{}, io, allocator, &template_repo, evt.events_ref, &comment_events); 741 742 const discussion_data = [_]struct { 743 title: []const u8, 744 description: []const u8, 745 tags: []const u8, 746 }{ 747 .{ 748 .title = "How should plugins declare capabilities?", 749 .description = "I'd like the manifest to make privileged capabilities explicit without making simple plugins verbose.", 750 .tags = "plugins design", 751 }, 752 .{ 753 .title = "Ideas for making large repositories faster", 754 .description = "This is a place to collect profiling results and discuss which indexing work is worth pursuing first.", 755 .tags = "performance indexing", 756 }, 757 .{ 758 .title = "What should the next release focus on?", 759 .description = "Let's compare the most important reliability fixes with the larger features already in progress.", 760 .tags = "release planning", 761 }, 762 .{ 763 .title = "Improving keyboard navigation", 764 .description = "Share workflows that still require a mouse and suggestions for making their focus behavior predictable.", 765 .tags = "ui keyboard accessibility", 766 }, 767 .{ 768 .title = "Configuration format discussion", 769 .description = "Should the next configuration format favor strict validation or accept common conveniences such as trailing commas?", 770 .tags = "config design", 771 }, 772 }; 773 774 var discussion_events: [discussion_data.len]evt.EventWithId = undefined; 775 for (discussion_data, 0..) |discussion, i| { 776 discussion_events[i] = .{ 777 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 778 .timestamp = @intCast(300 + i), 779 .author = .{ .name = user_data[i % user_data.len].name, .email = user_data[i % user_data.len].email }, 780 .event = .{ .discuss = .{ 781 .title = discussion.title, 782 .description = discussion.description, 783 .tags = discussion.tags, 784 } }, 785 }; 786 } 787 try evt.consume(.repo, .xit, .{}, io, allocator, &template_repo, evt.events_ref, &discussion_events); 788 789 var discussion_comment_ids: [discussion_data.len][3][evt.event_id_size]u8 = undefined; 790 for (&discussion_comment_ids) |*ids| { 791 for (ids) |*id| id.* = evt.EventWithId.randomId(prng.random()); 792 } 793 var discussion_comment_events: [discussion_data.len * 3]evt.EventWithId = undefined; 794 for (&discussion_comment_events, 0..) |*event, i| { 795 const discussion_index = i / 3; 796 const comment_index = i % 3; 797 event.* = .{ 798 .id = std.fmt.bytesToHex(discussion_comment_ids[discussion_index][comment_index], .lower), 799 .timestamp = @intCast(400 + i), 800 .author = .{ .name = user_data[(i + 1) % user_data.len].name, .email = user_data[(i + 1) % user_data.len].email }, 801 .event = .{ .comment = .{ 802 .thread_id = discussion_events[discussion_index].id, 803 .parent_id = if (comment_index < 2) 804 discussion_events[discussion_index].id 805 else 806 std.fmt.bytesToHex(discussion_comment_ids[discussion_index][0], .lower), 807 .body = switch (comment_index) { 808 0 => "My preference is to start with the smallest useful version and expand it after we have real usage to learn from.", 809 1 => "It would help to write down the tradeoffs we are accepting so the decision is easy to revisit later.", 810 2 => "Agreed. A narrow first version should also make it easier to keep the implementation understandable.", 811 else => unreachable, 812 }, 813 } }, 814 }; 815 } 816 try evt.consume(.repo, .xit, .{}, io, allocator, &template_repo, evt.events_ref, &discussion_comment_events); 817 try template_repo.addConfig(io, allocator, .{ .name = "core.bare", .value = "true" }); 818 } 819 820 // copy the template to each repo's on-disk location, named by its 821 // hex-encoded event id. the template repo is deinitialized above, so its 822 // db file is fully written before we copy it. 823 { 824 var template_dir = try cwd.openDir(io, template_path, .{}); 825 defer template_dir.close(io); 826 var template_repo_dir = try template_dir.openDir(io, ".xit", .{ .iterate = true }); 827 defer template_repo_dir.close(io); 828 829 for (repo_event_ids, 0..) |id_bytes, repo_index| { 830 const repo_id = std.fmt.bytesToHex(id_bytes, .lower); 831 const repo_path = try std.fs.path.join(arena.allocator(), &.{ server_path, "repos", &repo_id }); 832 { 833 var dest_dir = try cwd.createDirPathOpen(io, repo_path, .{}); 834 defer dest_dir.close(io); 835 var dest_repo_dir = try dest_dir.createDirPathOpen(io, ".xit", .{}); 836 defer dest_repo_dir.close(io); 837 try copyDir(io, template_repo_dir, dest_repo_dir); 838 } 839 840 if (repo_index + 1 == repo_data.len) { 841 var target_repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path, .require_repo_root = true }); 842 defer target_repo.deinit(io, allocator); 843 try seedPatches(io, allocator, server_path, &repo, &target_repo, &repo_event_ids[repo_index], &admin_user_id, prng.random()); 844 } 845 } 846 } 847 848 break :blk try ui.Session.init(&session_arena, &repo, .{ .user_id = &admin_user_id }); 849 }; 850 session.is_terminal = true; 851 session.data.git_ssh_prefix = git_ssh_prefix; 852 853 // start the server 854 855 // let the native TUI's page builders open the on-disk repos for the file tree 856 session.io = io; 857 session.repos_dir = try std.fs.path.join(session_arena.allocator(), &.{ server_path, "repos" }); 858 859 // leave a one-shot session for the first browser to hit the web ui, so it 860 // starts logged in as admin 861 { 862 var data_dir = try cwd.createDirPathOpen(io, server_path, .{}); 863 defer data_dir.close(io); 864 const store = try hx.web.SessionStore.init(io, data_dir); 865 defer store.deinit(); 866 const token = try store.create(&admin_user_id); 867 try store.offerAutoLogin(&token); 868 } 869 870 if (cli) { 871 var stdout_writer = std.Io.File.stdout().writer(io, &.{}); 872 var stderr_writer = std.Io.File.stderr().writer(io, &.{}); 873 const run_opts = hx.main.RunOpts{ .out = &stdout_writer.interface, .err = &stderr_writer.interface }; 874 875 const Runnable = struct { 876 io: std.Io, 877 key_path: []const u8, 878 879 pub fn run(self: @This(), web_port: u16, http_port: ?u16, ssh_port_maybe: ?u16) !void { 880 _ = web_port; 881 _ = http_port; 882 const ssh_port = ssh_port_maybe orelse return error.SshDisabled; 883 std.debug.print( 884 \\ 885 \\connect to the TUI with: 886 \\ ssh -p {d} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null localhost 887 \\ 888 \\create a git repo and push over SSH: 889 \\ mkdir -p temp-try/client/test 890 \\ cd temp-try/client/test 891 \\ git init 892 \\ echo "hello" > hello.txt 893 \\ git add hello.txt 894 \\ git commit -m "let there be light" 895 \\ GIT_SSH_COMMAND='ssh -p {d} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes -i {s}' git push localhost:admin/test HEAD:master 896 \\ 897 \\to quit, press enter. 898 \\ 899 , .{ ssh_port, ssh_port, self.key_path }); 900 // portable stdin read via std.Io — equivalent to the 901 // std.posix.read(STDIN_FILENO, ...) that doesn't compile 902 // on windows. 903 var buf: [1]u8 = undefined; 904 var stdin_reader = std.Io.File.stdin().reader(self.io, &buf); 905 _ = stdin_reader.interface.takeByte() catch {}; 906 } 907 }; 908 909 try srv.run(.xit, .{}, io, allocator, cwd_path, serve_options, run_opts.err, Runnable{ .io = io, .key_path = key_path }); 910 } else { 911 var null_writer = std.Io.Writer.Discarding.init(&.{}); 912 const run_opts = hx.main.RunOpts{ .out = &null_writer.writer, .err = &null_writer.writer }; 913 914 const Runnable = struct { 915 io: std.Io, 916 allocator: std.mem.Allocator, 917 session: *ui.Session, 918 repo: *Repo, 919 920 pub fn run(self: @This(), web_port: u16, http_port: ?u16, ssh_port: ?u16) !void { 921 // launch the TUI; the footer points at whatever port was bound 922 self.session.web_port = web_port; 923 self.session.data.git_http_port = http_port; 924 self.session.data.git_ssh_port = ssh_port; 925 try hx.ui.run(self.io, self.allocator, self.session, self.repo); 926 } 927 }; 928 929 try srv.run(.xit, .{}, io, allocator, cwd_path, serve_options, run_opts.err, Runnable{ .io = io, .allocator = allocator, .session = &session, .repo = &repo }); 930 } 931 } 932 933 // words cycled through to pad generated lines into prose-like content 934 const scatter_words = [_][]const u8{ 935 "lorem", "ipsum", "dolor", "sit", "amet", "consectetur", 936 "quantum", "vector", "matrix", "buffer", "kernel", "socket", 937 "falcon", "otter", "badger", "walrus", "ferret", "marmot", 938 "scatter", "gather", "encode", "decode", "render", "commit", 939 }; 940 941 fn longDescription(allocator: std.mem.Allocator, initial: []const u8) ![]u8 { 942 var writer = std.Io.Writer.Allocating.init(allocator); 943 defer writer.deinit(); 944 try writer.writer.print("{s}", .{initial}); 945 for (0..ui.detail_preview_lines) |line| 946 try writer.writer.print("\n{d} {s}", .{ line, scatter_words[line % scatter_words.len] }); 947 return writer.toOwnedSlice(); 948 } 949 950 // write `path` as 40 lines of mostly-stable filler. revision `c` and file 951 // index `fi` shift which lines change and the words used, so each (c, fi) 952 // yields a distinct multi-hunk diff against the previous revision. untouched 953 // lines stay byte-identical across revisions, so a bump in `c` is a scatter of 954 // small hunks rather than a full rewrite. 955 fn writeScatterFile(io: std.Io, allocator: std.mem.Allocator, repo_dir: std.Io.Dir, path: []const u8, fi: usize, c: usize) !void { 956 const file = try repo_dir.createFile(io, path, .{}); 957 defer file.close(io); 958 var writer = std.Io.Writer.Allocating.init(allocator); 959 defer writer.deinit(); 960 for (0..40) |line| { 961 const start = writer.written().len; 962 if ((line + fi + c) % 8 == 0) { 963 const tag = scatter_words[(line + fi + c * 3) % scatter_words.len]; 964 try writer.writer.print("rev {d} {s}", .{ c, tag }); 965 } 966 const target = 40 + (line * 17 + fi * 23) % 70; 967 var w = line + fi; 968 while (true) : (w += 1) { 969 const word = scatter_words[w % scatter_words.len]; 970 const len = writer.written().len - start; 971 if (len >= target or len + 1 + word.len > 120) break; 972 if (len == 0) 973 try writer.writer.print("{s}", .{word}) 974 else 975 try writer.writer.print(" {s}", .{word}); 976 } 977 try writer.writer.writeByte('\n'); 978 } 979 try file.writeStreamingAll(io, writer.written()); 980 } 981 982 // tag the current HEAD as the next sequential version: v1, v2, and so on 983 fn addNextTag(repo: *rp.Repo(.xit, .{}), io: std.Io, allocator: std.mem.Allocator, n: *usize) !void { 984 var buf: [16]u8 = undefined; 985 const name = try std.fmt.bufPrint(&buf, "v{d}", .{n.*}); 986 _ = try repo.addTag(io, allocator, .{ .name = name }); 987 n.* += 1; 988 } 989 990 fn commitTree( 991 io: std.Io, 992 allocator: std.mem.Allocator, 993 repo: *rp.Repo(.xit, .{}), 994 oid: *const [hash.hexLen(.sha1)]u8, 995 ) ![hash.hexLen(.sha1)]u8 { 996 var moment = try repo.core.latestMoment(); 997 const state = rp.Repo(.xit, .{}).State(.read_only){ .core = &repo.core, .extra = .{ .moment = &moment } }; 998 var object = try obj.Object(.xit, .{}).init(state, io, allocator, oid); 999 defer object.deinit(); 1000 return switch (object.content) { 1001 .commit => |commit| commit.tree, 1002 else => error.InvalidObject, 1003 }; 1004 } 1005 1006 fn seedPatchRevision( 1007 io: std.Io, 1008 allocator: std.mem.Allocator, 1009 repos_path: []const u8, 1010 patch_id: *const [evt.event_id_size]u8, 1011 title: []const u8, 1012 author: evt.CommitAuthor, 1013 timestamp: u64, 1014 random: std.Random, 1015 ) !void { 1016 const patch_hex = std.fmt.bytesToHex(patch_id.*, .lower); 1017 const fork_path = try fork.forkPath(allocator, repos_path, &patch_hex); 1018 defer allocator.free(fork_path); 1019 var fork_repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = fork_path, .require_repo_root = true }); 1020 defer fork_repo.deinit(io, allocator); 1021 1022 const base_oid = (try fork_repo.readRef(io, fork.ref)) orelse return error.NotFound; 1023 const base_tree_oid = try commitTree(io, allocator, &fork_repo, &base_oid); 1024 try fork_repo.addConfig(io, allocator, .{ .name = "core.bare", .value = "false" }); 1025 var source_oid = base_oid; 1026 var fork_dir = try std.Io.Dir.cwd().openDir(io, fork_path, .{}); 1027 defer fork_dir.close(io); 1028 var writer = std.Io.Writer.Allocating.init(allocator); 1029 defer writer.deinit(); 1030 for (0..3) |i| { 1031 writer.clearRetainingCapacity(); 1032 for (0..i + 1) |line| try writer.writer.print("{s}: part {d}\n", .{ title, line + 1 }); 1033 { 1034 const file = try fork_dir.createFile(io, "patch.txt", .{}); 1035 defer file.close(io); 1036 try file.writeStreamingAll(io, writer.written()); 1037 } 1038 try fork_repo.add(io, allocator, &.{"patch.txt"}); 1039 writer.clearRetainingCapacity(); 1040 try writer.writer.print("{s} ({d}/3)", .{ title, i + 1 }); 1041 source_oid = try fork_repo.commit(io, allocator, .{ .message = writer.written(), .timestamp = timestamp + i }); 1042 } 1043 try fork_repo.addConfig(io, allocator, .{ .name = "core.bare", .value = "true" }); 1044 try fork_dir.deleteFile(io, "patch.txt"); 1045 const revision_id = evt.EventWithId.randomId(random); 1046 const head_tree_oid = try commitTree(io, allocator, &fork_repo, &source_oid); 1047 const revision: evt.PatchRev = .{ 1048 .base_oid = &base_oid, 1049 .source_oid = &source_oid, 1050 .message = title, 1051 }; 1052 const revision_timestamp = timestamp + 3; 1053 const tree_entries = [_]evt.EventTreeEntry{ 1054 .{ .tree = .{ .name = "base", .oid = &base_tree_oid } }, 1055 .{ .tree = .{ .name = "head", .oid = &head_tree_oid } }, 1056 }; 1057 try evt.consume(.fork, .xit, .{}, io, allocator, &fork_repo, evt.events_ref, &.{.{ 1058 .id = std.fmt.bytesToHex(revision_id, .lower), 1059 .timestamp = revision_timestamp, 1060 .author = author, 1061 .tree_entries = &tree_entries, 1062 .event = .{ .patchrev = revision }, 1063 }}); 1064 1065 var arena = std.heap.ArenaAllocator.init(allocator); 1066 defer arena.deinit(); 1067 const moment = try evt.currentMoment(.{}, &fork_repo); 1068 const revision_record = (try evt.PatchRev.readById(evt.EventDB(.sha1), .sha1, moment, &arena, &revision_id)) orelse return error.NotFound; 1069 const patch_record = (try evt.Patch.readById(evt.EventDB(.sha1), .sha1, moment, &arena, patch_id)) orelse return error.NotFound; 1070 var patch = patch_record.event; 1071 patch.revision = evt.Patch.Revision.fromRecord(revision_id, revision_record); 1072 try evt.consume(.fork, .xit, .{}, io, allocator, &fork_repo, evt.events_ref, &.{.{ 1073 .id = patch_hex, 1074 .timestamp = revision_timestamp + 1, 1075 .author = author, 1076 .event = .{ .patch = patch }, 1077 }}); 1078 } 1079 1080 fn seedPatches( 1081 io: std.Io, 1082 allocator: std.mem.Allocator, 1083 server_path: []const u8, 1084 admin_repo: *rp.Repo(.xit, evt.admin_repo_opts), 1085 target_repo: *rp.Repo(.xit, .{}), 1086 repo_id: *const [evt.event_id_size]u8, 1087 user_id: *const [evt.event_id_size]u8, 1088 random: std.Random, 1089 ) !void { 1090 const patch_data = [_]struct { 1091 title: []const u8, 1092 description: []const u8, 1093 tags: []const u8, 1094 status: ?evt.Patch.StatusKind, 1095 }{ 1096 .{ 1097 .title = "Draft a faster dependency scanner", 1098 .description = "Rework dependency discovery so a large workspace can be scanned without repeatedly opening the same manifests.", 1099 .tags = "performance build", 1100 .status = null, 1101 }, 1102 .{ 1103 .title = "Remove the legacy configuration loader", 1104 .description = "Delete the compatibility loader now that the replacement format has shipped and the migration warning has been available for a full release.", 1105 .tags = "cleanup config", 1106 .status = .merged, 1107 }, 1108 .{ 1109 .title = "Cache parsed manifests between commands", 1110 .description = "Keep parsed manifests in the command context so consecutive operations do not repeat identical filesystem and parsing work.", 1111 .tags = "performance cache", 1112 .status = .closed, 1113 }, 1114 .{ 1115 .title = "Add structured output to the inspect command", 1116 .description = "Add a stable JSON representation of inspect results for scripts and editor integrations.", 1117 .tags = "enhancement cli", 1118 .status = .open, 1119 }, 1120 .{ 1121 .title = "Preserve file permissions during export", 1122 .description = "Carry executable bits through archive exports so unpacked command-line tools remain runnable without a manual chmod step.", 1123 .tags = "bug export permissions", 1124 .status = .open, 1125 }, 1126 }; 1127 1128 const repos_path = try std.fs.path.join(allocator, &.{ server_path, "repos" }); 1129 defer allocator.free(repos_path); 1130 const patch_author = evt.CommitAuthor{ .name = "admin", .email = "admin@example.test" }; 1131 var patch_ids: [patch_data.len][evt.event_id_size]u8 = undefined; 1132 for (patch_data, 0..) |patch, i| { 1133 const timestamp: u64 = @intCast(500 + i * 10); 1134 patch_ids[i] = evt.EventWithId.randomId(random); 1135 const patch_hex = std.fmt.bytesToHex(patch_ids[i], .lower); 1136 const path = try fork.create(.{}, io, allocator, repos_path, admin_repo, .{ 1137 .id = patch_hex, 1138 .user_id = user_id.*, 1139 .repo_id = repo_id.*, 1140 .title = patch.title, 1141 .description = patch.description, 1142 .tags = patch.tags, 1143 .target_branch = "master", 1144 .author = patch_author, 1145 .timestamp = timestamp, 1146 }); 1147 allocator.free(path); 1148 1149 try seedPatchRevision(io, allocator, repos_path, &patch_ids[i], patch.title, patch_author, timestamp + 1, random); 1150 const status = patch.status orelse continue; 1151 const fork_path = try fork.forkPath(allocator, repos_path, &patch_hex); 1152 defer allocator.free(fork_path); 1153 try pch.publish(.{}, io, allocator, admin_repo, target_repo, fork_path, .{ 1154 .id = patch_hex, 1155 .user_id = user_id.*, 1156 .repo_id = repo_id.*, 1157 .author = patch_author, 1158 .timestamp = timestamp + 6, 1159 }); 1160 1161 if (status == .merged) { 1162 try pch.mergeAndRemoveFork(.{}, io, allocator, repos_path, admin_repo, target_repo, .{ 1163 .id = patch_hex, 1164 .revision = .source, 1165 .author = patch_author, 1166 .timestamp = timestamp + 7, 1167 }); 1168 } else if (status != .open) { 1169 try evt.Patch.update(.xit, .{}, io, allocator, target_repo, &patch_ids[i], .{ .status = status }, patch_author); 1170 } 1171 } 1172 1173 // seed a small comment tree on the newest patch 1174 var comment_ids: [5][evt.event_id_size]u8 = undefined; 1175 for (&comment_ids) |*id| id.* = evt.EventWithId.randomId(random); 1176 const comment_bodies = [_][]const u8{ 1177 "This also needs to preserve the executable bit for files nested inside generated directories.", 1178 "I tested the archive path and the direct-copy path; both currently lose the mode.", 1179 "The archive writer is the right place to centralize it, since every exporter already goes through that layer.", 1180 "Please include a regression fixture with a mix of executable and ordinary files.", 1181 "I'll add that fixture and cover both supported archive formats.", 1182 }; 1183 var comments: [comment_ids.len]evt.EventWithId = undefined; 1184 for (&comments, 0..) |*comment, i| { 1185 comment.* = .{ 1186 .id = std.fmt.bytesToHex(comment_ids[i], .lower), 1187 .timestamp = @intCast(700 + i), 1188 .author = .{ .name = if (i % 2 == 0) "alice" else "bob", .email = if (i % 2 == 0) "alice@example.test" else "bob@example.test" }, 1189 .event = .{ .comment = .{ 1190 .thread_id = std.fmt.bytesToHex(patch_ids[patch_ids.len - 1], .lower), 1191 .parent_id = switch (i) { 1192 0, 1 => std.fmt.bytesToHex(patch_ids[patch_ids.len - 1], .lower), 1193 2, 4 => std.fmt.bytesToHex(comment_ids[0], .lower), 1194 3 => std.fmt.bytesToHex(comment_ids[2], .lower), 1195 else => unreachable, 1196 }, 1197 .body = comment_bodies[i], 1198 } }, 1199 }; 1200 } 1201 try evt.consume(.repo, .xit, .{}, io, allocator, target_repo, evt.events_ref, &comments); 1202 1203 // create divergent metadata edits on the next three patches 1204 var patch_arena = std.heap.ArenaAllocator.init(allocator); 1205 defer patch_arena.deinit(); 1206 const moment = try evt.currentMoment(.{}, target_repo); 1207 var values: [3]evt.Patch = undefined; 1208 for (1..4) |i| { 1209 const record = (try evt.Patch.readById(evt.EventDB(.sha1), .sha1, moment, &patch_arena, &patch_ids[i])) orelse return error.NotFound; 1210 values[i - 1] = record.event; 1211 } 1212 var ours_values = values; 1213 ours_values[0].title = "Drop the legacy configuration loader"; 1214 ours_values[0].tags = "cleanup config breaking"; 1215 const closed_description = try longDescription( 1216 patch_arena.allocator(), 1217 "Cache parsed manifests for the lifetime of a command invocation and invalidate entries when their files change.", 1218 ); 1219 ours_values[1].description = closed_description; 1220 ours_values[2].description = "Add JSON output to inspect with versioned field names and deterministic object ordering."; 1221 var theirs_values = values; 1222 theirs_values[0].title = "Delete compatibility configuration support"; 1223 theirs_values[0].tags = "config maintenance"; 1224 theirs_values[1].description = "Keep a process-wide manifest cache shared by every command and refresh it after writes."; 1225 theirs_values[2].description = "Expose inspect results as newline-delimited JSON so callers can stream large repositories."; 1226 1227 const seed_tip = (try target_repo.readRef(io, evt.events_ref)) orelse return error.NotFound; 1228 const other_ref = xit.ref.Ref{ .kind = .head, .name = "haxy/patch-other" }; 1229 var ours = [_]evt.EventWithId{ 1230 .{ 1231 .id = std.fmt.bytesToHex(patch_ids[1], .lower), 1232 .timestamp = 800, 1233 .author = .{ .name = "alice", .email = "alice@example.test" }, 1234 .event = .{ .patch = ours_values[0] }, 1235 }, 1236 .{ 1237 .id = std.fmt.bytesToHex(patch_ids[2], .lower), 1238 .timestamp = 801, 1239 .author = .{ .name = "alice", .email = "alice@example.test" }, 1240 .event = .{ .patch = ours_values[1] }, 1241 }, 1242 .{ 1243 .id = std.fmt.bytesToHex(patch_ids[3], .lower), 1244 .timestamp = 802, 1245 .author = .{ .name = "alice", .email = "alice@example.test" }, 1246 .event = .{ .patch = ours_values[2] }, 1247 }, 1248 }; 1249 1250 var theirs = ours; 1251 theirs[0].timestamp = 810; 1252 theirs[0].author = .{ .name = "bob", .email = "bob@example.test" }; 1253 theirs[0].event.patch = theirs_values[0]; 1254 theirs[1].timestamp = 811; 1255 theirs[1].author = .{ .name = "bob", .email = "bob@example.test" }; 1256 theirs[1].event.patch = theirs_values[1]; 1257 theirs[2].timestamp = 812; 1258 theirs[2].author = .{ .name = "bob", .email = "bob@example.test" }; 1259 theirs[2].event.patch = theirs_values[2]; 1260 1261 try evt.consume(.repo, .xit, .{}, io, allocator, target_repo, evt.events_ref, &ours); 1262 var json: std.Io.Writer.Allocating = .init(allocator); 1263 defer json.deinit(); 1264 for (theirs, 0..) |event, i| { 1265 json.clearRetainingCapacity(); 1266 try std.json.Stringify.value(event, .{}, &json.writer); 1267 const author = try std.fmt.allocPrint(allocator, "{s} <{s}>", .{ event.author.name, event.author.email }); 1268 defer allocator.free(author); 1269 _ = try target_repo.commitAtRef(io, allocator, .{ 1270 .author = author, 1271 .message = json.written(), 1272 .timestamp = event.timestamp, 1273 .parent_oids = if (i == 0) &.{seed_tip} else null, 1274 }, null, other_ref); 1275 } 1276 { 1277 var merge = try target_repo.mergeAtRef(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_ref }} } } }, evt.events_ref, null); 1278 defer merge.deinit(); 1279 if (merge.result != .success) return error.MergeFailed; 1280 } 1281 try target_repo.removeBranch(io, .{ .name = other_ref.name }); 1282 try evt.consume(.repo, .xit, .{}, io, allocator, target_repo, evt.events_ref, &.{}); 1283 } 1284 1285 // recursively copy the contents of src_dir into dest_dir 1286 fn copyDir(io: std.Io, src_dir: std.Io.Dir, dest_dir: std.Io.Dir) !void { 1287 var iter = src_dir.iterate(); 1288 while (try iter.next(io)) |entry| { 1289 switch (entry.kind) { 1290 .file => try src_dir.copyFile(entry.name, dest_dir, entry.name, io, .{}), 1291 .directory => { 1292 try dest_dir.createDirPath(io, entry.name); 1293 var dest_entry_dir = try dest_dir.openDir(io, entry.name, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false }); 1294 defer dest_entry_dir.close(io); 1295 var src_entry_dir = try src_dir.openDir(io, entry.name, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false }); 1296 defer src_entry_dir.close(io); 1297 try copyDir(io, src_entry_dir, dest_entry_dir); 1298 }, 1299 else => {}, 1300 } 1301 } 1302 } 1303