╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ try.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
349 \\ 350 \\```sh 351 \\sample-repo --help 352 \\``` 353 \\ 354 \\| Option | Description | 355 \\| ----------- | ------------------------------ | 356 \\| `--verbose` | Print extra diagnostic output | 357 \\| `--quiet` | Suppress all non-error output | 358 \\| `--version` | Print the version and exit | 359 \\ 360 \\## Contributing 361 \\ 362 \\Contributions are welcome! Please open an issue to discuss 363 \\any significant changes before submitting a pull request. 364 \\See the docs under `docs/dev` for more details. 365 \\ 366 \\## A Heading Too Long To Fit On One Line In The Subtitle Font 367 \\ 368 \\Long headings wrap onto more lines in their font. 369 \\ 370 \\## License 371 \\ 372 \\Released under the MIT License. See `LICENSE` for the full 373 \\text. 374 \\ 375 ); 376 377 try repo_dir.createDirPath(io, "docs/dev"); 378 const doc = try repo_dir.createFile(io, "docs/dev/contribute.md", .{}); 379 defer doc.close(io); 380 try doc.writeStreamingAll(io, "To contribute, please make a pull request"); 381 } 382 383 try template_repo.add(io, allocator, &.{ "README.md", "docs/dev/contribute.md" }); 384 _ = try template_repo.commit(io, allocator, .{ .message = "let there be light" }); 385 386 // tag every commit in creation order, three versions at a time 387 var tag_num: usize = 1; 388 try addNextTags(&template_repo, io, allocator, &tag_num); 389 390 // a batch of commits so the commits tab has more than one page to 391 // paginate through. each rewrites a few files with scattered line 392 // edits, so every commit is a multi-file diff with several separate 393 // hunks to look at. stepped timestamps vary the date column. 394 const base_ts: u64 = 1_700_000_000; // 2023-11-14 395 const edit_files = [_][]const u8{ "src/alpha.txt", "src/beta.txt", "src/gamma.txt" }; 396 // commit subjects, cycled then padded to a varying length. 397 const subjects = [_][]const u8{ 398 "fix off-by-one in scatter loop", 399 "encode and decode buffers", 400 "tune kernel socket timeouts", 401 "render matrix vector product", 402 "gather falcon and otter stats", 403 "refactor badger walrus module", 404 "drop dead ferret marmot branch", 405 }; 406 const commit_count = 30; 407 var c: usize = 0; 408 while (c < commit_count) : (c += 1) { 409 { 410 var repo_dir = try cwd.openDir(io, template_path, .{}); 411 defer repo_dir.close(io); 412 try repo_dir.createDirPath(io, "src"); 413 for (edit_files, 0..) |path, fi| { 414 try writeScatterFile(io, allocator, repo_dir, path, fi, c); 415 } 416 } 417 try template_repo.add(io, allocator, &edit_files); 418 // a cycling subject padded to a c-varying length so the commit 419 // list shows messages of different widths, capped at 120. 420 var msg_writer = std.Io.Writer.Allocating.init(allocator); 421 defer msg_writer.deinit(); 422 try msg_writer.writer.print("{s}", .{subjects[c % subjects.len]}); 423 const msg_target = 16 + (c * 41) % 96; 424 var mw = c; 425 while (true) : (mw += 1) { 426 const word = scatter_words[mw % scatter_words.len]; 427 const len = msg_writer.written().len; 428 if (len >= msg_target or len + 1 + word.len > 120) break; 429 try msg_writer.writer.print(" {s}", .{word}); 430 } 431 // the newest commit's message links to the project and runs 432 // past what the detail pane reads, so it shows the truncated 433 // message and its link. 434 if (c == commit_count - 1) { 435 try msg_writer.writer.writeAll("\n\nsee https://github.com/xit-vcs/haxy"); 436 for (0..ui.detail_preview_lines) |line| { 437 try msg_writer.writer.print("\n{d} {s}", .{ line, scatter_words[line % scatter_words.len] }); 438 } 439 } 440 const message = try arena.allocator().dupe(u8, msg_writer.written()); 441 // the newest commit was committed by someone other than its 442 // author, so the detail pane shows a committer box. 443 const committer: ?[]const u8 = if (c == commit_count - 1) "alice <alice@example.test>" else null; 444 _ = try template_repo.commit(io, allocator, .{ .message = message, .committer = committer, .timestamp = base_ts + c * std.time.s_per_day }); 445 try addNextTags(&template_repo, io, allocator, &tag_num); 446 } 447 448 // two more branches forked off master, each adding a single commit 449 // that makes a scattered edit to one file. tag each new commit too, 450 // and switch back to master after each so the next branch also 451 // forks from master and the template ends up back on master. 452 const branch_data = [_]struct { 453 name: []const u8, 454 file: []const u8, 455 fi: usize, 456 message: []const u8, 457 rev: usize, 458 }{ 459 .{ .name = "extra", .file = "src/alpha.txt", .fi = 0, .message = "scatter alpha on the extra branch", .rev = 30 }, 460 .{ .name = "feature", .file = "src/beta.txt", .fi = 1, .message = "scatter beta on the feature branch", .rev = 31 }, 461 }; 462 for (branch_data) |b| { 463 try template_repo.addBranch(io, .{ .name = b.name }); 464 { 465 var to_branch = try template_repo.switchDir(io, allocator, .{ .target = .{ .ref = .{ .kind = .head, .name = b.name } } }); 466 defer to_branch.deinit(); 467 } 468 469 { 470 var repo_dir = try cwd.openDir(io, template_path, .{}); 471 defer repo_dir.close(io); 472 try writeScatterFile(io, allocator, repo_dir, b.file, b.fi, b.rev); 473 } 474 try template_repo.add(io, allocator, &.{b.file}); 475 _ = try template_repo.commit(io, allocator, .{ .message = b.message, .timestamp = base_ts + b.rev * std.time.s_per_day }); 476 try addNextTags(&template_repo, io, allocator, &tag_num); 477 478 { 479 var to_master = try template_repo.switchDir(io, allocator, .{ .target = .{ .ref = .{ .kind = .head, .name = "master" } } }); 480 defer to_master.deinit(); 481 } 482 } 483 484 // the timestamps issue's description, in paragraphs, so the 485 // seeded conflict below can insert and delete among them 486 const tz_p1 = "All timestamps in the activity feed render in `UTC` regardless of the system timezone."; 487 const tz_p2 = "The tooltip on each entry repeats the same UTC value, so there is *no way* to see the local time without converting by hand."; 488 const tz_p3 = "A quick survey of [other clients](https://github.com/xit-vcs/haxy) shows every one of them rendering local time by default."; 489 const tz_p4 = "Convert to local time and include the **offset** in tooltips."; 490 491 // the sync issue's description, in paragraphs, so each side of its 492 // seeded conflict can rework a different one 493 const sync_p1 = "The sync engine only has *unit tests* with mocked transports."; 494 const sync_p2 = "Failures seen in production involve reconnects and **partial writes**, which the mocks can't reproduce."; 495 const sync_p3 = "Add end-to-end tests that run two instances against a real local server."; 496 497 // seed issues so every repo's issue tracker has content 498 const issue_data = [_]struct { 499 title: []const u8, 500 description: []const u8, 501 labels: []const u8, 502 status: evt.Issue.Status = .open, 503 }{ 504 .{ 505 .title = "Crash on startup when config file is missing", 506 .description = "Running the program without a `config.toml` present dereferences a null pointer and segfaults. Fall back to the built-in defaults and log a **warning** instead.", 507 .labels = "bug priority-high crash", 508 }, 509 .{ 510 .title = "Support dark mode in the settings panel", 511 .description = "The settings panel is *always* rendered with the light palette even when the rest of the app is in dark mode. Read the active theme and pick colors accordingly.", 512 .labels = "enhancement ui theme", 513 }, 514 .{ 515 .title = "Memory leak when reconnecting after network failure", 516 .description = "Each reconnect allocates a new connection state without freeing the previous one.\n\n> After a flaky network session, memory usage grows by several megabytes per hour.", 517 .labels = "bug memory networking", 518 .status = .closed, 519 }, 520 .{ 521 .title = "Document the plugin API", 522 .description = "The plugin interface has no documentation beyond the header comments. Add a guide covering:\n\n- the lifecycle hooks\n- the event callbacks\n- a minimal working plugin", 523 .labels = "documentation plugins", 524 }, 525 .{ 526 .title = "Slow file indexing on large directories", 527 .description = "Indexing a directory with more than 100k files takes several minutes because every entry is `stat`'d twice. Cache the first stat result and batch the reads.", 528 .labels = "performance indexing", 529 }, 530 .{ 531 .title = "Add keyboard shortcut for quick search", 532 .description = "Opening the search box currently requires clicking the toolbar icon. Bind a shortcut and show it in the tooltip so **keyboard users** can search without the mouse.", 533 .labels = "enhancement ux keyboard", 534 }, 535 .{ 536 .title = "Unicode filenames are garbled in the export dialog", 537 .description = "Filenames containing non-ASCII characters display as replacement characters in the export dialog. The dialog decodes the path as *Latin-1* instead of *UTF-8*.", 538 .labels = "bug unicode i18n", 539 }, 540 .{ 541 .title = "Flaky test: integration suite times out on CI", 542 .description = "The integration suite intermittently exceeds the CI time limit because the server fixture waits for a fixed 30 seconds:\n\n```\nstd.Io.sleep(io, .fromSeconds(30), .awake);\n```\n\nPoll for readiness instead of sleeping.", 543 .labels = "bug testing ci", 544 }, 545 .{ 546 .title = "Upgrade bundled zlib to the latest release", 547 .description = "The vendored zlib is two major releases behind and misses several upstream fixes.\n\n- [ ] update the bundled copy\n- [ ] re-run the compression benchmarks", 548 .labels = "dependencies maintenance", 549 }, 550 .{ 551 .title = "Progress bar overshoots 100% during resumed downloads", 552 .description = "Resuming a partial download counts the already-downloaded bytes twice, so the progress bar reads up to **150%**. Subtract the resume offset from the total.", 553 .labels = "bug ui downloads", 554 .status = .closed, 555 }, 556 .{ 557 .title = "Config parser rejects trailing commas", 558 .description = "A trailing comma after the last entry in a config block is reported as a syntax error:\n\n```\n[server]\nport = 8080,\n```\n\nMost editors add one automatically, so accept it.", 559 .labels = "bug config parser", 560 }, 561 .{ 562 .title = "Add a --json flag to the status command", 563 .description = "Scripts currently scrape the human-readable `status` output, which breaks whenever the format changes. Emit a stable machine-readable JSON form behind a `--json` flag.", 564 .labels = "enhancement cli", 565 }, 566 .{ 567 .title = "Race condition between autosave and manual save", 568 .description = "Saving manually while an autosave is in flight can interleave the two writes and **corrupt the file**. Serialize saves through a single queue.", 569 .labels = "bug priority-high data-loss", 570 }, 571 .{ 572 .title = "Reduce binary size of release builds", 573 .description = "The release binary has grown past 40 MB, mostly from:\n\n1. debug info\n2. an unused bundled font\n\nStrip symbols and drop the font from the default build.", 574 .labels = "performance build", 575 }, 576 .{ 577 .title = "Tooltips flicker when the cursor moves between adjacent buttons", 578 .description = "Moving the cursor across a toolbar hides and re-shows the tooltip for *every* button. Keep the tooltip open with a short grace period between neighbors.", 579 .labels = "bug ui polish", 580 }, 581 .{ 582 .title = "Support environment variable expansion in config paths", 583 .description = "Paths in the config file are taken literally, so shared configs can't refer to `$HOME` portably. Expand environment variables when loading.", 584 .labels = "enhancement config", 585 }, 586 .{ 587 .title = "Log rotation deletes the newest file instead of the oldest", 588 .description = "When the log directory hits its size cap, the rotation logic sorts by name rather than `mtime` and removes the **most recent** log. Sort by modification time.", 589 .labels = "bug logging", 590 .status = .closed, 591 }, 592 .{ 593 .title = "Add man pages for all subcommands", 594 .description = "Only the top-level command has a man page. Generate one per subcommand from the existing `--help` text as part of the release build.", 595 .labels = "documentation cli", 596 }, 597 .{ 598 .title = "High CPU usage while idle in the background", 599 .description = "The main loop polls for file changes every 10 ms even when no window is visible.\n\n> Switch to native file watching and idle at zero CPU.", 600 .labels = "performance priority-high", 601 }, 602 .{ 603 .title = "Paste from clipboard drops the final newline", 604 .description = "Pasting text that ends with a newline silently trims it, which breaks pasted shell snippets. Preserve the clipboard content *exactly*.", 605 .labels = "bug editor clipboard", 606 }, 607 .{ 608 .title = "Improve error message for expired credentials", 609 .description = "## Problem\n\nAn expired token currently surfaces as a bare `401` with no guidance.\n\n## Fix\n\nDetect the expiry case and tell the user how to re-authenticate, linking to the [setup guide](https://github.com/xit-vcs/haxy).", 610 .labels = "enhancement ux auth", 611 }, 612 .{ 613 .title = "Crash when window is resized during startup animation", 614 .description = "Resizing the window while the splash animation is running dereferences a **freed** layout node. Cancel the animation before rebuilding the layout.", 615 .labels = "bug crash ui", 616 }, 617 .{ 618 .title = "Add integration tests for the sync engine", 619 .description = sync_p1 ++ "\n\n" ++ sync_p2 ++ "\n\n" ++ sync_p3, 620 .labels = "testing sync", 621 }, 622 .{ 623 .title = "Timestamps display in UTC instead of local time", 624 .description = tz_p1 ++ "\n\n" ++ tz_p2 ++ "\n\n" ++ tz_p3 ++ "\n\n" ++ tz_p4, 625 .labels = "bug i18n time", 626 }, 627 .{ 628 .title = "Deprecate the legacy plugin format", 629 .description = "Both plugin formats are currently loaded, doubling the maintenance surface.\n\n- [ ] warn on legacy plugins this release\n- [ ] drop support in the next", 630 .labels = "maintenance plugins", 631 }, 632 }; 633 634 var issue_events: [issue_data.len]evt.EventWithId = undefined; 635 for (issue_data, 0..) |issue, i| { 636 // the fifth-newest issue's description runs past what the detail 637 // pane shows, so it shows the truncated description and its 638 // link. 639 const description = if (i == issue_data.len - 5) 640 try longDescription(arena.allocator(), issue.description) 641 else 642 issue.description; 643 issue_events[i] = .{ 644 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 645 // stepped timestamps so the issues list in a stable order 646 .timestamp = @intCast(i + 1), 647 .author = .{ .name = user_data[i % user_data.len].name, .email = user_data[i % user_data.len].email }, 648 .event = .{ 649 .issue = .{ 650 .title = issue.title, 651 .description = description, 652 .labels = issue.labels, 653 .status = issue.status, 654 }, 655 }, 656 }; 657 } 658 try evt.consume(.local, .repo, .xit, .{}, io, allocator, &template_repo, evt.events_ref, &issue_events); 659 // the tip is the branch point for the conflicting edits below 660 const seed_tip = (try template_repo.readRef(io, evt.events_ref)) orelse unreachable; 661 662 // two divergent edits per conflicted issue: ours on the events 663 // branch, theirs on a temp branch rooted at the seed tip, then a 664 // merge. the 4th-newest issue conflicts on title and labels; the 665 // 3rd-newest on its description with every hunk auto-resolving 666 // (each side reworks a different paragraph); the 2nd-newest on 667 // its description with a removal conflict (ours removes a 668 // paragraph theirs rewords) and an insertion conflict (each side 669 // appends a different closing paragraph). 670 { 671 const other_ref: xit.ref.Ref = .{ .kind = .head, .name = "haxy/other" }; 672 const title_issue = issue_data[issue_data.len - 4]; 673 const sync_issue = issue_data[issue_data.len - 3]; 674 const desc_issue = issue_data[issue_data.len - 2]; 675 676 const ours = [_]evt.EventWithId{ .{ 677 .id = issue_events[issue_data.len - 4].id, 678 .timestamp = 100, 679 .author = .{ .name = user_data[1].name, .email = user_data[1].email }, 680 .event = .{ .issue = .{ 681 .title = "Crash when resizing the window during the splash animation", 682 .description = title_issue.description, 683 .labels = "bug crash ui priority-high", 684 } }, 685 }, .{ 686 .id = issue_events[issue_data.len - 3].id, 687 .timestamp = 101, 688 .author = .{ .name = user_data[1].name, .email = user_data[1].email }, 689 .event = .{ .issue = .{ 690 .title = sync_issue.title, 691 .description = "The sync engine's coverage is *unit tests* only, with every transport mocked out." ++ "\n\n" ++ 692 sync_p2 ++ "\n\n" ++ sync_p3, 693 .labels = sync_issue.labels, 694 } }, 695 }, .{ 696 .id = issue_events[issue_data.len - 2].id, 697 .timestamp = 102, 698 .author = .{ .name = user_data[1].name, .email = user_data[1].email }, 699 .event = .{ .issue = .{ 700 .title = desc_issue.title, 701 .description = tz_p1 ++ "\n\n" ++ tz_p2 ++ "\n\n" ++ tz_p4 ++ "\n\n" ++ 702 "> The confusion is worst for teams spread across timezones, who each read a different wall-clock time from the same feed.", 703 .labels = desc_issue.labels, 704 } }, 705 } }; 706 const theirs = [_]evt.EventWithId{ .{ 707 .id = issue_events[issue_data.len - 4].id, 708 .timestamp = 103, 709 .author = .{ .name = user_data[2].name, .email = user_data[2].email }, 710 .event = .{ .issue = .{ 711 .title = "Segfault on early window resize", 712 .description = title_issue.description, 713 .labels = "bug crash rendering", 714 } }, 715 }, .{ 716 .id = issue_events[issue_data.len - 3].id, 717 .timestamp = 104, 718 .author = .{ .name = user_data[2].name, .email = user_data[2].email }, 719 .event = .{ .issue = .{ 720 .title = sync_issue.title, 721 .description = sync_p1 ++ "\n\n" ++ sync_p2 ++ "\n\n" ++ 722 "Add end-to-end tests that drive two live instances against a local server on **every** CI run.", 723 .labels = sync_issue.labels, 724 } }, 725 }, .{ 726 .id = issue_events[issue_data.len - 2].id, 727 .timestamp = 105, 728 .author = .{ .name = user_data[2].name, .email = user_data[2].email }, 729 .event = .{ .issue = .{ 730 .title = desc_issue.title, 731 .description = tz_p1 ++ "\n\n" ++ tz_p2 ++ "\n\n" ++ 732 "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" ++ 733 "Log exports inherit the same UTC rendering, so downstream tooling has to *guess* the source timezone.", 734 .labels = desc_issue.labels, 735 } }, 736 } }; 737 738 try evt.consume(.local, .repo, .xit, .{}, io, allocator, &template_repo, evt.events_ref, &ours); 739 740 try commitEventsAtRef(io, allocator, &template_repo, other_ref, &theirs, seed_tip); 741 742 { 743 var to_events = try template_repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 744 defer to_events.deinit(); 745 } 746 { 747 var merge = try template_repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_ref }} } } }, null); 748 defer merge.deinit(); 749 if (merge.result != .success) return error.MergeFailed; 750 } 751 { 752 var to_master = try template_repo.switchDir(io, allocator, .{ .target = .{ .ref = .{ .kind = .head, .name = "master" } } }); 753 defer to_master.deinit(); 754 } 755 try template_repo.removeBranch(io, .{ .name = other_ref.name }); 756 } 757 758 try evt.consume(.local, .repo, .xit, .{}, io, allocator, &template_repo, evt.events_ref, &.{}); 759 760 // seed a small comment tree on the newest issue 761 var comment_ids: [5][evt.event_id_size]u8 = undefined; 762 for (&comment_ids) |*id| id.* = evt.EventWithId.randomId(prng.random()); 763 764 const comment_bodies = [_][]const u8{ 765 "I can take this. The compatibility scanner already reports which repositories still use the **legacy** format.", 766 "Can we keep loading legacy plugins for *one release* after the warning lands? That would give downstream maintainers time to migrate.", 767 "That sounds good. I'll include the scanner output in the warning so each affected plugin is named:\n\n```\nwarning: plugins/foo.so uses the legacy format\n```", 768 "Please include the replacement manifest path too. That should make the warning actionable without opening the [migration guide](https://github.com/xit-vcs/haxy).", 769 "I'll add release-note text once the warning format is settled:\n\n- [x] draft the summary\n- [ ] list the affected plugins", 770 }; 771 772 var comment_events: [comment_ids.len]evt.EventWithId = undefined; 773 for (&comment_events, 0..) |*event, i| { 774 event.* = .{ 775 .id = std.fmt.bytesToHex(comment_ids[i], .lower), 776 .timestamp = @intCast(200 + i), 777 .author = .{ .name = user_data[(i + 1) % user_data.len].name, .email = user_data[(i + 1) % user_data.len].email }, 778 .event = .{ .comment = .{ 779 .thread_id = issue_events[issue_events.len - 1].id, 780 .parent_id = switch (i) { 781 0, 1 => issue_events[issue_events.len - 1].id, 782 2, 4 => std.fmt.bytesToHex(comment_ids[0], .lower), 783 3 => std.fmt.bytesToHex(comment_ids[2], .lower), 784 else => unreachable, 785 }, 786 .body = comment_bodies[i], 787 } }, 788 }; 789 } 790 try evt.consume(.local, .repo, .xit, .{}, io, allocator, &template_repo, evt.events_ref, &comment_events); 791 792 const discussion_data = [_]struct { 793 title: []const u8, 794 description: []const u8, 795 labels: []const u8, 796 }{ 797 .{ 798 .title = "How should plugins declare capabilities?", 799 .description = "I'd like the manifest to make **privileged** capabilities explicit without making simple plugins verbose.", 800 .labels = "plugins design", 801 }, 802 .{ 803 .title = "Ideas for making large repositories faster", 804 .description = "This is a place to collect *profiling results* and discuss which indexing work is worth pursuing first.", 805 .labels = "performance indexing", 806 }, 807 .{ 808 .title = "What should the next release focus on?", 809 .description = "Let's compare the most important reliability fixes with the larger features already in progress, starting from https://github.com/xit-vcs/haxy.", 810 .labels = "release planning", 811 }, 812 .{ 813 .title = "Improving keyboard navigation", 814 .description = "Share workflows that still **require a mouse** and suggestions for making their focus behavior predictable.", 815 .labels = "ui keyboard accessibility", 816 }, 817 .{ 818 .title = "Configuration format discussion", 819 .description = "Should the next configuration format favor *strict validation* or accept common conveniences such as trailing commas?", 820 .labels = "config design", 821 }, 822 }; 823 824 var discussion_events: [discussion_data.len]evt.EventWithId = undefined; 825 for (discussion_data, 0..) |discussion, i| { 826 discussion_events[i] = .{ 827 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 828 .timestamp = @intCast(300 + i), 829 .author = .{ .name = user_data[i % user_data.len].name, .email = user_data[i % user_data.len].email }, 830 .event = .{ .discuss = .{ 831 .title = discussion.title, 832 .description = discussion.description, 833 .labels = discussion.labels, 834 } }, 835 }; 836 } 837 try evt.consume(.local, .repo, .xit, .{}, io, allocator, &template_repo, evt.events_ref, &discussion_events); 838 839 var discussion_comment_ids: [discussion_data.len][3][evt.event_id_size]u8 = undefined; 840 for (&discussion_comment_ids) |*ids| { 841 for (ids) |*id| id.* = evt.EventWithId.randomId(prng.random()); 842 } 843 var discussion_comment_events: [discussion_data.len * 3]evt.EventWithId = undefined; 844 for (&discussion_comment_events, 0..) |*event, i| { 845 const discussion_index = i / 3; 846 const comment_index = i % 3; 847 event.* = .{ 848 .id = std.fmt.bytesToHex(discussion_comment_ids[discussion_index][comment_index], .lower), 849 .timestamp = @intCast(400 + i), 850 .author = .{ .name = user_data[(i + 1) % user_data.len].name, .email = user_data[(i + 1) % user_data.len].email }, 851 .event = .{ .comment = .{ 852 .thread_id = discussion_events[discussion_index].id, 853 .parent_id = if (comment_index < 2) 854 discussion_events[discussion_index].id 855 else 856 std.fmt.bytesToHex(discussion_comment_ids[discussion_index][0], .lower), 857 .body = switch (comment_index) { 858 0 => "My preference is to start with the **smallest useful version** and expand it after we have real usage to learn from.", 859 1 => "It would help to *write down* the tradeoffs we are accepting so the decision is easy to revisit later.", 860 2 => "> A narrow first version\n\nAgreed. It should also make it easier to keep the implementation understandable.", 861 else => unreachable, 862 }, 863 } }, 864 }; 865 } 866 try evt.consume(.local, .repo, .xit, .{}, io, allocator, &template_repo, evt.events_ref, &discussion_comment_events); 867 try template_repo.patchAll(io, allocator, null); 868 try template_repo.addConfig(io, allocator, .{ .name = "core.bare", .value = "true" }); 869 try transaction(io, &template_repo, Reindex{ .io = io, .allocator = allocator, .core = &template_repo.core, .commits = true }); 870 // clear history only after indexing has written its transactions. 871 { 872 const moment = try evt.currentMoment(.{}, &template_repo); 873 const roots = try evt.PatchRev.gcRoots(rp.Repo(.xit, .{}).DB, .sha1, allocator, moment); 874 defer allocator.free(roots); 875 _ = try template_repo.garbageCollect(io, allocator, .{ .extra_roots = roots }); 876 } 877 } 878 879 // copy the template to each repo's on-disk location, named by its 880 // hex-encoded event id. the template repo is deinitialized above, so its 881 // db file is fully written before we copy it. 882 { 883 var template_dir = try cwd.openDir(io, template_path, .{}); 884 defer template_dir.close(io); 885 var template_repo_dir = try template_dir.openDir(io, ".xit", .{ .iterate = true }); 886 defer template_repo_dir.close(io); 887 888 for (repo_event_ids, 0..) |id_bytes, repo_index| { 889 const repo_path = try evt.repoPath(arena.allocator(), users_dir, &user_ids[repo_data[repo_index].user_index], &id_bytes); 890 { 891 var dest_dir = try cwd.createDirPathOpen(io, repo_path, .{}); 892 defer dest_dir.close(io); 893 var dest_repo_dir = try dest_dir.createDirPathOpen(io, ".xit", .{}); 894 defer dest_repo_dir.close(io); 895 try copyDir(io, template_repo_dir, dest_repo_dir); 896 } 897 898 if (repo_index + 1 == repo_data.len) { 899 var target_repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path, .require_repo_root = true }); 900 defer target_repo.deinit(io, allocator); 901 try seedPatches(io, allocator, server_path, &repo, &target_repo, &repo_event_ids[repo_index], &user_ids[repo_data[repo_index].user_index], &admin_user_id, prng.random()); 902 } 903 } 904 } 905 906 break :blk try ui.Session.init(&session_arena, &repo, .{ .user_id = &admin_user_id }); 907 }; 908 session.is_terminal = true; 909 session.color = std.mem.eql(u8, init.environ_map.get("NO_COLOR") orelse "", ""); 910 session.data.git_ssh_prefix = git_ssh_prefix; 911 912 // start the server 913 914 // let the native TUI's page builders open the on-disk repos for the file tree 915 session.io = io; 916 session.users_dir = try std.Io.Dir.path.join(session_arena.allocator(), &.{ server_path, "users" }); 917 918 // leave a one-shot session for the first browser to hit the web ui, so it 919 // starts logged in as admin 920 { 921 var data_dir = try cwd.createDirPathOpen(io, server_path, .{}); 922 defer data_dir.close(io); 923 const store = try hx.web.SessionStore.init(io, data_dir); 924 defer store.deinit(); 925 const token = try store.create(&admin_user_id); 926 try store.offerAutoLogin(&token); 927 } 928 929 if (cli) { 930 var stdout_writer = std.Io.File.stdout().writer(io, &.{}); 931 var stderr_writer = std.Io.File.stderr().writer(io, &.{}); 932 const run_opts = hx.main.RunOpts{ .out = &stdout_writer.interface, .err = &stderr_writer.interface }; 933 934 const Runnable = struct { 935 io: std.Io, 936 key_path: []const u8, 937 938 pub fn run(self: @This(), web_port: u16, http_port: ?u16, ssh_port_maybe: ?u16) !void { 939 _ = web_port; 940 _ = http_port; 941 const ssh_port = ssh_port_maybe orelse return error.SshDisabled; 942 std.debug.print( 943 \\ 944 \\connect to the TUI with: 945 \\ ssh -p {d} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null localhost 946 \\ 947 \\create a git repo and push over SSH: 948 \\ mkdir -p temp-try/client/test 949 \\ cd temp-try/client/test 950 \\ git init 951 \\ echo "hello" > hello.txt 952 \\ git add hello.txt 953 \\ git commit -m "let there be light" 954 \\ 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 955 \\ 956 \\to quit, press enter. 957 \\ 958 , .{ ssh_port, ssh_port, self.key_path }); 959 // portable stdin read via std.Io — equivalent to the 960 // std.posix.read(STDIN_FILENO, ...) that doesn't compile 961 // on windows. 962 var buf: [1]u8 = undefined; 963 var stdin_reader = std.Io.File.stdin().reader(self.io, &buf); 964 _ = stdin_reader.interface.takeByte() catch {}; 965 } 966 }; 967 968 try srv.run(.xit, .{}, io, allocator, cwd_path, serve_options, run_opts.err, Runnable{ .io = io, .key_path = key_path }); 969 } else { 970 var null_writer = std.Io.Writer.Discarding.init(&.{}); 971 const run_opts = hx.main.RunOpts{ .out = &null_writer.writer, .err = &null_writer.writer }; 972 973 const Runnable = struct { 974 io: std.Io, 975 allocator: std.mem.Allocator, 976 session: *ui.Session, 977 repo: *Repo, 978 979 pub fn run(self: @This(), web_port: u16, http_port: ?u16, ssh_port: ?u16) !void { 980 // launch the TUI; the footer points at whatever port was bound 981 self.session.web_port = web_port; 982 self.session.data.git_http_port = http_port; 983 self.session.data.git_ssh_port = ssh_port; 984 try hx.ui.run(self.io, self.allocator, self.session, self.repo); 985 } 986 }; 987 988 try srv.run(.xit, .{}, io, allocator, cwd_path, serve_options, run_opts.err, Runnable{ .io = io, .allocator = allocator, .session = &session, .repo = &repo }); 989 } 990 } 991 992 // words cycled through to pad generated lines into prose-like content 993 const scatter_words = [_][]const u8{ 994 "lorem", "ipsum", "dolor", "sit", "amet", "consectetur", 995 "quantum", "vector", "matrix", "buffer", "kernel", "socket", 996 "falcon", "otter", "badger", "walrus", "ferret", "marmot", 997 "scatter", "gather", "encode", "decode", "render", "commit", 998 }; 999 1000 fn longDescription(allocator: std.mem.Allocator, initial: []const u8) ![]u8 { 1001 var writer = std.Io.Writer.Allocating.init(allocator); 1002 defer writer.deinit(); 1003 try writer.writer.print("{s}\n", .{initial}); 1004 for (0..ui.detail_preview_lines) |line| 1005 try writer.writer.print("\n- {d} {s}", .{ line, scatter_words[line % scatter_words.len] }); 1006 return writer.toOwnedSlice(); 1007 } 1008 1009 // write `path` as 40 lines of mostly-stable filler. revision `c` and file 1010 // index `fi` shift which lines change and the words used, so each (c, fi) 1011 // yields a distinct multi-hunk diff against the previous revision. untouched 1012 // lines stay byte-identical across revisions, so a bump in `c` is a scatter of 1013 // small hunks rather than a full rewrite. 1014 fn writeScatterFile(io: std.Io, allocator: std.mem.Allocator, repo_dir: std.Io.Dir, path: []const u8, fi: usize, c: usize) !void { 1015 const content = try scatterContent(allocator, fi, c, null); 1016 defer allocator.free(content); 1017 const file = try repo_dir.createFile(io, path, .{}); 1018 defer file.close(io); 1019 try file.writeStreamingAll(io, content); 1020 } 1021 1022 const ScatterEdit = struct { line: usize, text: []const u8 }; 1023 1024 fn scatterContent(allocator: std.mem.Allocator, fi: usize, c: usize, replacement: ?ScatterEdit) ![]u8 { 1025 var writer = std.Io.Writer.Allocating.init(allocator); 1026 defer writer.deinit(); 1027 for (0..40) |line| { 1028 if (replacement) |edit| if (line == edit.line) { 1029 try writer.writer.print("{s}\n", .{edit.text}); 1030 continue; 1031 }; 1032 const start = writer.written().len; 1033 if ((line + fi + c) % 8 == 0) { 1034 const tag = scatter_words[(line + fi + c * 3) % scatter_words.len]; 1035 try writer.writer.print("rev {d} {s}", .{ c, tag }); 1036 } 1037 const target = 40 + (line * 17 + fi * 23) % 70; 1038 var w = line + fi; 1039 while (true) : (w += 1) { 1040 const word = scatter_words[w % scatter_words.len]; 1041 const len = writer.written().len - start; 1042 if (len >= target or len + 1 + word.len > 120) break; 1043 if (len == 0) 1044 try writer.writer.print("{s}", .{word}) 1045 else 1046 try writer.writer.print(" {s}", .{word}); 1047 } 1048 try writer.writer.writeByte('\n'); 1049 } 1050 return writer.toOwnedSlice(); 1051 } 1052 1053 // tag the current HEAD with the next three sequential versions, so the refs 1054 // tab has enough tags to page through 1055 fn addNextTags(repo: *rp.Repo(.xit, .{}), io: std.Io, allocator: std.mem.Allocator, n: *usize) !void { 1056 for (0..3) |_| { 1057 var buf: [16]u8 = undefined; 1058 const name = try std.fmt.bufPrint(&buf, "v{d}", .{n.*}); 1059 _ = try repo.addTag(io, allocator, .{ .name = name }); 1060 n.* += 1; 1061 } 1062 } 1063 1064 fn commitTree( 1065 io: std.Io, 1066 allocator: std.mem.Allocator, 1067 repo: *rp.Repo(.xit, .{}), 1068 oid: *const [hash.hexLen(.sha1)]u8, 1069 ) ![hash.hexLen(.sha1)]u8 { 1070 var moment = try repo.core.latestMoment(); 1071 const state = rp.Repo(.xit, .{}).State(.read_only){ .core = &repo.core, .extra = .{ .moment = &moment } }; 1072 var object = try obj.Object(.xit, .{}).init(state, io, allocator, oid); 1073 defer object.deinit(); 1074 return switch (object.content) { 1075 .commit => |commit| commit.tree, 1076 else => error.InvalidObject, 1077 }; 1078 } 1079 1080 fn seedPatchRevision( 1081 io: std.Io, 1082 allocator: std.mem.Allocator, 1083 users_dir: []const u8, 1084 forker_id: *const [evt.event_id_size]u8, 1085 patch_id: *const [evt.event_id_size]u8, 1086 title: []const u8, 1087 author: evt.CommitAuthor, 1088 timestamp: u64, 1089 random: std.Random, 1090 base_oid: [hash.hexLen(.sha1)]u8, 1091 path: []const u8, 1092 contents: []const []const u8, 1093 // a base file the last commit removes 1094 removed_path: ?[]const u8, 1095 ) !void { 1096 const patch_hex = std.fmt.bytesToHex(patch_id.*, .lower); 1097 const fork_path = try fork.forkPath(allocator, users_dir, forker_id, patch_id); 1098 defer allocator.free(fork_path); 1099 var fork_repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = fork_path, .require_repo_root = true }); 1100 defer fork_repo.deinit(io, allocator); 1101 1102 try fork_repo.addConfig(io, allocator, .{ .name = "core.bare", .value = "false" }); 1103 { 1104 var reset = try fork_repo.reset(io, allocator, .{ .target = .{ .oid = &base_oid } }); 1105 defer reset.deinit(); 1106 } 1107 const base_tree_oid = try commitTree(io, allocator, &fork_repo, &base_oid); 1108 var source_oid = base_oid; 1109 var fork_dir = try std.Io.Dir.cwd().openDir(io, fork_path, .{}); 1110 defer fork_dir.close(io); 1111 var writer = std.Io.Writer.Allocating.init(allocator); 1112 defer writer.deinit(); 1113 if (std.Io.Dir.path.dirname(path)) |dir| try fork_dir.createDirPath(io, dir); 1114 const commit_count = contents.len + @intFromBool(removed_path != null); 1115 for (contents, 0..) |content, i| { 1116 { 1117 const file = try fork_dir.createFile(io, path, .{}); 1118 defer file.close(io); 1119 try file.writeStreamingAll(io, content); 1120 } 1121 try fork_repo.add(io, allocator, &.{path}); 1122 writer.clearRetainingCapacity(); 1123 try writer.writer.print("{s} ({d}/{d})", .{ title, i + 1, commit_count }); 1124 source_oid = try fork_repo.commit(io, allocator, .{ .message = writer.written(), .timestamp = timestamp + i }); 1125 } 1126 if (removed_path) |removed| { 1127 // the bare fork never checked the base files out, so only the index drops it 1128 try fork_repo.remove(io, allocator, &.{removed}, .{ .update_work_dir = false }); 1129 writer.clearRetainingCapacity(); 1130 try writer.writer.print("{s} ({d}/{d})", .{ title, commit_count, commit_count }); 1131 source_oid = try fork_repo.commit(io, allocator, .{ .message = writer.written(), .timestamp = timestamp + contents.len }); 1132 } 1133 try fork_repo.patchAll(io, allocator, null); 1134 try fork_repo.addConfig(io, allocator, .{ .name = "core.bare", .value = "true" }); 1135 try transaction(io, &fork_repo, Reindex{ .io = io, .allocator = allocator, .core = &fork_repo.core, .commits = false }); 1136 _ = try fork_repo.garbageCollect(io, allocator, .{}); 1137 try fork_dir.deleteFile(io, path); 1138 const revision_id = evt.EventWithId.randomId(random); 1139 const head_tree_oid = try commitTree(io, allocator, &fork_repo, &source_oid); 1140 const revision: evt.PatchRev = .{ 1141 .base_oid = &base_oid, 1142 .source_oid = &source_oid, 1143 .message = title, 1144 }; 1145 const revision_timestamp = timestamp + 3; 1146 const tree_entries = [_]evt.EventTreeEntry{ 1147 .{ .tree = .{ .name = "base", .oid = &base_tree_oid } }, 1148 .{ .tree = .{ .name = "head", .oid = &head_tree_oid } }, 1149 }; 1150 try evt.consume(.{ .server = .{ .users_dir = users_dir } }, .fork, .xit, .{}, io, allocator, &fork_repo, evt.events_ref, &.{.{ 1151 .id = std.fmt.bytesToHex(revision_id, .lower), 1152 .timestamp = revision_timestamp, 1153 .author = author, 1154 .tree_entries = &tree_entries, 1155 .event = .{ .patchrev = revision }, 1156 }}); 1157 1158 var arena = std.heap.ArenaAllocator.init(allocator); 1159 defer arena.deinit(); 1160 const moment = try evt.currentMoment(.{}, &fork_repo); 1161 const revision_record = (try evt.PatchRev.readById(evt.EventDB(.sha1), .sha1, moment, &arena, &revision_id)) orelse return error.NotFound; 1162 const patch_record = (try evt.Patch.readById(evt.EventDB(.sha1), .sha1, moment, &arena, patch_id)) orelse return error.NotFound; 1163 var patch = patch_record.event; 1164 patch.revision = evt.Patch.Revision.fromRecord(revision_id, revision_record); 1165 try evt.consume(.{ .server = .{ .users_dir = users_dir } }, .fork, .xit, .{}, io, allocator, &fork_repo, evt.events_ref, &.{.{ 1166 .id = patch_hex, 1167 .timestamp = revision_timestamp + 1, 1168 .author = author, 1169 .event = .{ .patch = patch }, 1170 }}); 1171 } 1172 1173 fn seedPatches( 1174 io: std.Io, 1175 allocator: std.mem.Allocator, 1176 server_path: []const u8, 1177 admin_repo: *rp.Repo(.xit, evt.admin_repo_opts), 1178 target_repo: *rp.Repo(.xit, .{}), 1179 repo_id: *const [evt.event_id_size]u8, 1180 repo_user_id: *const [evt.event_id_size]u8, 1181 user_id: *const [evt.event_id_size]u8, 1182 random: std.Random, 1183 ) !void { 1184 const patch_data = [_]struct { 1185 title: []const u8, 1186 description: []const u8, 1187 labels: []const u8, 1188 status: ?evt.Patch.StatusKind, 1189 alpha_edit: ?ScatterEdit = null, 1190 // based behind master, so merging it makes a merge commit 1191 behind_master: bool = false, 1192 // a base file the patch removes 1193 removed_path: ?[]const u8 = null, 1194 }{ 1195 .{ 1196 .title = "Draft a faster dependency scanner", 1197 .description = "Rework dependency discovery so a **large workspace** can be scanned without repeatedly opening the same manifests.", 1198 .labels = "performance build", 1199 .status = null, 1200 }, 1201 .{ 1202 .title = "Remove the legacy configuration loader", 1203 .description = "Delete the compatibility loader now that the replacement format has shipped and the migration warning has been available for a *full release*.", 1204 .labels = "cleanup config", 1205 .status = .merged, 1206 .behind_master = true, 1207 }, 1208 .{ 1209 .title = "Cache parsed manifests between commands", 1210 .description = "Keep parsed manifests in the command `Context` so consecutive operations do not repeat identical filesystem and parsing work.", 1211 .labels = "performance cache", 1212 .status = .closed, 1213 }, 1214 .{ 1215 .title = "Add structured output to the inspect command", 1216 .description = "Add a stable JSON representation of `inspect` results for scripts and editor integrations.", 1217 .labels = "enhancement cli", 1218 .status = .open, 1219 .removed_path = "docs/dev/contribute.md", 1220 }, 1221 .{ 1222 .title = "Edit an adjacent line in alpha.txt", 1223 .description = "This patch can be cleanly merged by haxy, while git throws a **merge conflict**!", 1224 .labels = "merge", 1225 .status = .open, 1226 .alpha_edit = .{ .line = 2, .text = "adjust alpha beside the latest scatter edit" }, 1227 }, 1228 .{ 1229 .title = "Edit a conflicting line in alpha.txt", 1230 .description = "Both source and squash merges conflict with a different edit to the *same line* on master.", 1231 .labels = "merge", 1232 .status = .open, 1233 .alpha_edit = .{ .line = 3, .text = "replace alpha with a conflicting edit" }, 1234 }, 1235 }; 1236 1237 const users_dir = try std.Io.Dir.path.join(allocator, &.{ server_path, "users" }); 1238 defer allocator.free(users_dir); 1239 const patch_author = evt.CommitAuthor{ .name = "admin", .email = "admin@example.test" }; 1240 var patch_ids: [patch_data.len][evt.event_id_size]u8 = undefined; 1241 for (patch_data, 0..) |patch, i| { 1242 const timestamp: u64 = @intCast(500 + i * 10); 1243 patch_ids[i] = evt.EventWithId.randomId(random); 1244 const patch_hex = std.fmt.bytesToHex(patch_ids[i], .lower); 1245 const path = try fork.create(.{}, io, allocator, users_dir, .{ 1246 .id = patch_hex, 1247 .user_id = user_id.*, 1248 .repo_id = repo_id.*, 1249 .repo_user_id = repo_user_id.*, 1250 .title = patch.title, 1251 .description = patch.description, 1252 .labels = patch.labels, 1253 .target_branch = "master", 1254 .author = patch_author, 1255 .timestamp = timestamp, 1256 }); 1257 allocator.free(path); 1258 1259 // prepare the patch's base and file contents, then seed its commits and revision 1260 { 1261 var arena = std.heap.ArenaAllocator.init(allocator); 1262 defer arena.deinit(); 1263 const aa = arena.allocator(); 1264 var base_oid = (try target_repo.readRef(io, .{ .kind = .head, .name = "master" })) orelse return error.NotFound; 1265 if (patch.alpha_edit != null or patch.behind_master) { 1266 // v30 precedes the last scatter commit, even after the sample merge 1267 const tag_oid = (try target_repo.readRef(io, .{ .kind = .tag, .name = "v30" })) orelse return error.NotFound; 1268 var moment = try target_repo.core.latestMoment(); 1269 const state = rp.Repo(.xit, .{}).State(.read_only){ .core = &target_repo.core, .extra = .{ .moment = &moment } }; 1270 var tag = try obj.Object(.xit, .{}).init(state, io, allocator, &tag_oid); 1271 defer tag.deinit(); 1272 base_oid = switch (tag.content) { 1273 .tag => |value| value.target, 1274 else => return error.InvalidObject, 1275 }; 1276 } 1277 var contents: [3][]const u8 = undefined; 1278 const file_path: []const u8, const count: usize = if (patch.alpha_edit) |edit| blk: { 1279 // rev 29 first changes line 4; edit that line or the one above it 1280 contents[0] = try scatterContent(aa, 0, 28, edit); 1281 break :blk .{ "src/alpha.txt", 1 }; 1282 } else blk: { 1283 var writer = std.Io.Writer.Allocating.init(allocator); 1284 defer writer.deinit(); 1285 for (&contents, 0..) |*content, part| { 1286 try writer.writer.print("{s}: part {d}\n", .{ patch.title, part + 1 }); 1287 content.* = try aa.dupe(u8, writer.written()); 1288 } 1289 break :blk .{ "patch.txt", contents.len }; 1290 }; 1291 try seedPatchRevision(io, allocator, users_dir, user_id, &patch_ids[i], patch.title, patch_author, timestamp + 1, random, base_oid, file_path, contents[0..count], patch.removed_path); 1292 } 1293 const status = patch.status orelse continue; 1294 try pch.publish(.{}, io, allocator, users_dir, admin_repo, target_repo, .{ 1295 .id = patch_hex, 1296 .user_id = user_id.*, 1297 .repo_id = repo_id.*, 1298 .author = patch_author, 1299 .timestamp = timestamp + 6, 1300 }); 1301 1302 if (status == .merged) { 1303 try pch.mergeAndRemoveFork(.{}, io, allocator, users_dir, target_repo, .{ 1304 .id = patch_hex, 1305 .revision = .source, 1306 .author = patch_author, 1307 .timestamp = timestamp + 7, 1308 }); 1309 } else if (status != .open) { 1310 try evt.Patch.update(.{ .server = .{ .users_dir = users_dir } }, .xit, .{}, io, allocator, target_repo, &patch_ids[i], .{ .status = status }, patch_author); 1311 } 1312 } 1313 1314 // seed a small comment tree on the newest patch 1315 var comment_ids: [5][evt.event_id_size]u8 = undefined; 1316 for (&comment_ids) |*id| id.* = evt.EventWithId.randomId(random); 1317 const comment_bodies = [_][]const u8{ 1318 "This also needs to preserve the **executable bit** for files nested inside generated directories.", 1319 "I tested both paths:\n\n- the archive path\n- the direct-copy path\n\nBoth currently lose the mode.", 1320 "The `ArchiveWriter` is the right place to centralize it, since every exporter already goes through that layer.", 1321 "Please include a regression fixture with a *mix* of executable and ordinary files.", 1322 "I'll add that fixture and cover both supported archive formats:\n\n1. tar\n2. zip", 1323 }; 1324 var comments: [comment_ids.len]evt.EventWithId = undefined; 1325 for (&comments, 0..) |*comment, i| { 1326 comment.* = .{ 1327 .id = std.fmt.bytesToHex(comment_ids[i], .lower), 1328 .timestamp = @intCast(700 + i), 1329 .author = .{ .name = if (i % 2 == 0) "alice" else "bobby", .email = if (i % 2 == 0) "alice@example.test" else "bobby@example.test" }, 1330 .event = .{ .comment = .{ 1331 .thread_id = std.fmt.bytesToHex(patch_ids[patch_ids.len - 1], .lower), 1332 .parent_id = switch (i) { 1333 0, 1 => std.fmt.bytesToHex(patch_ids[patch_ids.len - 1], .lower), 1334 2, 4 => std.fmt.bytesToHex(comment_ids[0], .lower), 1335 3 => std.fmt.bytesToHex(comment_ids[2], .lower), 1336 else => unreachable, 1337 }, 1338 .body = comment_bodies[i], 1339 } }, 1340 }; 1341 } 1342 try evt.consume(.{ .server = .{ .users_dir = users_dir } }, .repo, .xit, .{}, io, allocator, target_repo, evt.events_ref, &comments); 1343 1344 // create divergent metadata edits on the next three patches 1345 var patch_arena = std.heap.ArenaAllocator.init(allocator); 1346 defer patch_arena.deinit(); 1347 const moment = try evt.currentMoment(.{}, target_repo); 1348 var values: [3]evt.Patch = undefined; 1349 for (1..4) |i| { 1350 const record = (try evt.Patch.readById(evt.EventDB(.sha1), .sha1, moment, &patch_arena, &patch_ids[i])) orelse return error.NotFound; 1351 values[i - 1] = record.event; 1352 } 1353 var ours_values = values; 1354 ours_values[0].title = "Drop the legacy configuration loader"; 1355 ours_values[0].labels = "cleanup config breaking"; 1356 const closed_description = try longDescription( 1357 patch_arena.allocator(), 1358 "## Summary\n\nCache parsed manifests for the lifetime of a command invocation and invalidate entries when their files change.", 1359 ); 1360 ours_values[1].description = closed_description; 1361 ours_values[2].description = "Add JSON output to `inspect` with versioned field names and deterministic object ordering."; 1362 var theirs_values = values; 1363 theirs_values[0].title = "Delete compatibility configuration support"; 1364 theirs_values[0].labels = "config maintenance"; 1365 theirs_values[1].description = "Keep a process-wide manifest cache shared by **every** command and refresh it after writes."; 1366 theirs_values[2].description = "Expose `inspect` results as newline-delimited JSON so callers can stream large repositories."; 1367 1368 const seed_tip = (try target_repo.readRef(io, evt.events_ref)) orelse return error.NotFound; 1369 const other_ref = xit.ref.Ref{ .kind = .head, .name = "haxy/patch-other" }; 1370 var ours = [_]evt.EventWithId{ 1371 .{ 1372 .id = std.fmt.bytesToHex(patch_ids[1], .lower), 1373 .timestamp = 800, 1374 .author = .{ .name = "alice", .email = "alice@example.test" }, 1375 .event = .{ .patch = ours_values[0] }, 1376 }, 1377 .{ 1378 .id = std.fmt.bytesToHex(patch_ids[2], .lower), 1379 .timestamp = 801, 1380 .author = .{ .name = "alice", .email = "alice@example.test" }, 1381 .event = .{ .patch = ours_values[1] }, 1382 }, 1383 .{ 1384 .id = std.fmt.bytesToHex(patch_ids[3], .lower), 1385 .timestamp = 802, 1386 .author = .{ .name = "alice", .email = "alice@example.test" }, 1387 .event = .{ .patch = ours_values[2] }, 1388 }, 1389 }; 1390 1391 var theirs = ours; 1392 theirs[0].timestamp = 810; 1393 theirs[0].author = .{ .name = "bobby", .email = "bobby@example.test" }; 1394 theirs[0].event.patch = theirs_values[0]; 1395 theirs[1].timestamp = 811; 1396 theirs[1].author = .{ .name = "bobby", .email = "bobby@example.test" }; 1397 theirs[1].event.patch = theirs_values[1]; 1398 theirs[2].timestamp = 812; 1399 theirs[2].author = .{ .name = "bobby", .email = "bobby@example.test" }; 1400 theirs[2].event.patch = theirs_values[2]; 1401 1402 try evt.consume(.{ .server = .{ .users_dir = users_dir } }, .repo, .xit, .{}, io, allocator, target_repo, evt.events_ref, &ours); 1403 try commitEventsAtRef(io, allocator, target_repo, other_ref, &theirs, seed_tip); 1404 { 1405 var merge = try target_repo.mergeAtRef(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_ref }} } } }, evt.events_ref, null); 1406 defer merge.deinit(); 1407 if (merge.result != .success) return error.MergeFailed; 1408 } 1409 try target_repo.removeBranch(io, .{ .name = other_ref.name }); 1410 try evt.consume(.{ .server = .{ .users_dir = users_dir } }, .repo, .xit, .{}, io, allocator, target_repo, evt.events_ref, &.{}); 1411 try pch.writeBranchPatch(.{ .server = .{ .users_dir = users_dir } }, .xit, .{}, io, allocator, target_repo, std.fmt.bytesToHex(evt.EventWithId.randomId(random), .lower), .{ 1412 .title = "Merge the existing feature branch", 1413 .description = "This patch tracks the `feature` branch in this repo without creating a fork.", 1414 .labels = "feature", 1415 .source_branch = "feature", 1416 .target_branch = "master", 1417 }, null, patch_author); 1418 1419 // a push onto the branch that patch tracks, so the undo tab shows the 1420 // action and the patch refresh it sets off 1421 try seedPush(io, allocator, target_repo, users_dir, patch_author); 1422 try pch.refreshBranches(.{ .server = .{ .users_dir = users_dir } }, .xit, .{}, io, allocator, target_repo, null, null); 1423 } 1424 1425 // stand in for a client push, which the fixture has no server to receive: one 1426 // transaction moves the branch, refreshes the patches tracking it and records 1427 // the action, the way receive-pack does 1428 fn seedPush(io: std.Io, allocator: std.mem.Allocator, repo: *rp.Repo(.xit, .{}), users_dir: []const u8, author: evt.CommitAuthor) !void { 1429 const Repo = rp.Repo(.xit, .{}); 1430 const DB = Repo.DB; 1431 const Ctx = struct { 1432 core: *Repo.Core, 1433 io: std.Io, 1434 allocator: std.mem.Allocator, 1435 users_dir: []const u8, 1436 author: evt.CommitAuthor, 1437 1438 pub fn run(ctx: @This(), cursor: *DB.Cursor(.read_write)) !void { 1439 const opts: rp.RepoOpts(.xit) = .{}; 1440 var moment = try DB.HashMap(.read_write).init(cursor.*); 1441 const state = Repo.State(.read_write){ .core = ctx.core, .extra = .{ .moment = &moment } }; 1442 var arena = std.heap.ArenaAllocator.init(ctx.allocator); 1443 defer arena.deinit(); 1444 const aa = arena.allocator(); 1445 1446 // a null tree keeps the parent's content, so the branch advances 1447 // without the fixture having to build one 1448 const author_line = try aa.print("{s} <{s}>", .{ ctx.author.name, ctx.author.email }); 1449 const feature_old = (try rf.readRecur(.xit, opts, state.readOnly(), ctx.io, .{ .ref = .{ .kind = .head, .name = "feature" } })) orelse return error.NotFound; 1450 const feature_new = try obj.writeCommit(.xit, opts, state, ctx.io, ctx.allocator, .{ 1451 .message = "Revise the feature branch", 1452 .author = author_line, 1453 .timestamp = 820, 1454 }, null, .{ .kind = .head, .name = "feature" }); 1455 1456 // a branch the push creates has no old tip, so its range is open 1457 try bch.add(.xit, opts, state, ctx.io, .{ .name = "docs" }); 1458 const docs_new = (try rf.readRecur(.xit, opts, state.readOnly(), ctx.io, .{ .ref = .{ .kind = .head, .name = "docs" } })) orelse return error.NotFound; 1459 1460 const updates = [_]xit.net_server_receive_pack.AppliedRefUpdate{ 1461 .{ 1462 .ref_name = try aa.dupe(u8, "refs/heads/feature"), 1463 .old_oid = try aa.dupe(u8, &feature_old), 1464 .new_oid = try aa.dupe(u8, &feature_new), 1465 }, 1466 .{ 1467 .ref_name = try aa.dupe(u8, "refs/heads/docs"), 1468 .old_oid = try aa.dupe(u8, &@as([hash.hexLen(opts.hash)]u8, @splat('0'))), 1469 .new_oid = try aa.dupe(u8, &docs_new), 1470 }, 1471 }; 1472 1473 _ = try pch.refreshBranchesInTransaction(.{ .server = .{ .users_dir = ctx.users_dir } }, opts, state, &moment, ctx.io, ctx.allocator, &updates, null, null); 1474 try push.writeUndo(opts, state, ctx.io, ctx.allocator, ctx.author, &updates); 1475 } 1476 }; 1477 1478 try transaction(io, repo, Ctx{ .core = &repo.core, .io = io, .allocator = allocator, .users_dir = users_dir, .author = author }); 1479 } 1480 1481 // commit `events` onto `ref`, rooted at `parent`, as one transaction. 1482 // consume always roots a new branch at nothing, so the divergent fixtures 1483 // write their side branch here instead. nothing is consumed, so it records 1484 // xit's own commit action rather than one of haxy's event actions 1485 fn commitEventsAtRef( 1486 io: std.Io, 1487 allocator: std.mem.Allocator, 1488 repo: *rp.Repo(.xit, .{}), 1489 ref: xit.ref.Ref, 1490 events: []const evt.EventWithId, 1491 parent: [hash.hexLen(.sha1)]u8, 1492 ) !void { 1493 const Repo = rp.Repo(.xit, .{}); 1494 const DB = Repo.DB; 1495 const Ctx = struct { 1496 core: *Repo.Core, 1497 io: std.Io, 1498 allocator: std.mem.Allocator, 1499 ref: xit.ref.Ref, 1500 events: []const evt.EventWithId, 1501 parent: [hash.hexLen(.sha1)]u8, 1502 1503 pub fn run(ctx: @This(), cursor: *DB.Cursor(.read_write)) !void { 1504 const opts: rp.RepoOpts(.xit) = .{}; 1505 var moment = try DB.HashMap(.read_write).init(cursor.*); 1506 const state = Repo.State(.read_write){ .core = ctx.core, .extra = .{ .moment = &moment } }; 1507 try evt.commitEvents(.xit, opts, state, ctx.io, ctx.allocator, ctx.ref, ctx.events, .{ctx.parent}); 1508 const tip = (try xit.ref.readRecur(.xit, opts, state.readOnly(), ctx.io, .{ .ref = ctx.ref })) orelse return error.NotFound; 1509 try xit.undo.write(opts, state, std.Io.Timestamp.now(ctx.io, .real).toSeconds(), .{ 1510 .commit = try .init("seed conflicting events", tip, opts.max_read_size), 1511 }); 1512 } 1513 }; 1514 1515 try transaction(io, repo, Ctx{ 1516 .core = &repo.core, 1517 .io = io, 1518 .allocator = allocator, 1519 .ref = ref, 1520 .events = events, 1521 .parent = parent, 1522 }); 1523 } 1524 1525 // append `ctx` to the repo's history as one transaction. the fixtures write a 1526 // few states the normal paths have no way to produce 1527 // fixtures commit directly, so the ref tips are indexed by hand. a fork gets 1528 // no commit index, as in production. 1529 const Reindex = struct { 1530 io: std.Io, 1531 allocator: std.mem.Allocator, 1532 core: *rp.Repo(.xit, .{}).Core, 1533 commits: bool, 1534 1535 pub fn run(ctx: @This(), cursor: *rp.Repo(.xit, .{}).DB.Cursor(.read_write)) !void { 1536 var moment = try rp.Repo(.xit, .{}).DB.HashMap(.read_write).init(cursor.*); 1537 const state = rp.Repo(.xit, .{}).State(.read_write){ .core = ctx.core, .extra = .{ .moment = &moment } }; 1538 _ = try find.refreshInTransaction(.{}, state, &moment, ctx.io, ctx.allocator, null); 1539 if (ctx.commits) _ = try srch_cmmt.refreshInTransaction(.{}, state, &moment, ctx.io, ctx.allocator, &.{}, null); 1540 } 1541 }; 1542 1543 fn transaction(io: std.Io, repo: *rp.Repo(.xit, .{}), ctx: anytype) !void { 1544 const DB = rp.Repo(.xit, .{}).DB; 1545 try repo.core.db_file.lock(io, .exclusive); 1546 defer repo.core.db_file.unlock(io); 1547 1548 const history = try DB.ArrayList(.read_write).init(repo.core.db.rootCursor()); 1549 try history.appendContext(.{ .slot = try history.getSlot(-1) }, ctx); 1550 } 1551 1552 // recursively copy the contents of src_dir into dest_dir 1553 fn copyDir(io: std.Io, src_dir: std.Io.Dir, dest_dir: std.Io.Dir) !void { 1554 var iter = src_dir.iterate(); 1555 while (try iter.next(io)) |entry| { 1556 switch (entry.kind) { 1557 .file => try src_dir.copyFile(entry.name, dest_dir, entry.name, io, .{}), 1558 .directory => { 1559 try dest_dir.createDirPath(io, entry.name); 1560 var dest_entry_dir = try dest_dir.openDir(io, entry.name, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false }); 1561 defer dest_entry_dir.close(io); 1562 var src_entry_dir = try src_dir.openDir(io, entry.name, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false }); 1563 defer src_entry_dir.close(io); 1564 try copyDir(io, src_entry_dir, dest_entry_dir); 1565 }, 1566 else => {}, 1567 } 1568 } 1569 } 1570