╭ changed ──────────╮ │src/event/Patch.zig│ ╰───────────────────╯ ╭───────────────────────────────────────────────────────╮ │ 8 tags: []const u8, // space-separated │ │ 9 target_ref: []const u8, │ │10 target_patch_id: ?[evt.event_id_size * 2]u8 = null,│ │11 -source_url: []const u8, │ │12 -source_ref: []const u8, │ │11 patchrev_id: [evt.event_id_size * 2]u8, │ │12 │ │13 pub const Record = struct { │ ╰───────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮ │64 break :blk value; │ │65 }; │ │66 │ │69 - if (!fieldsValid(record.event.title, record.event.tags) or │ │70 - record.event.target_ref.len == 0 or │ │71 - record.event.source_url.len == 0 or │ │72 - record.event.source_ref.len == 0) │ │73 - { │ │67 + if (!fieldsValid(record.event.title, record.event.tags) or record.event.target_ref.len == 0) {│ │68 return error.InvalidPatch; │ │69 } │ │70 const patchrev_id = try evt.parseEventId(&record.event.patchrev_id); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │77 record.created_order = existing.created_order; │ │78 record.author_email = existing.author_email; │ │79 if (event_oid != null) { │ │86 - if (!std.mem.eql(u8, existing.event.target_ref, record.event.target_ref) or │ │87 - !std.meta.eql(existing.event.target_patch_id, record.event.target_patch_id) or│ │88 - !std.mem.eql(u8, existing.event.source_url, record.event.source_url) or │ │89 - !std.mem.eql(u8, existing.event.source_ref, record.event.source_ref)) │ │90 - { │ │91 - return error.PatchTargetChanged; │ │80 + if (!std.meta.eql(existing.event.target_patch_id, record.event.target_patch_id)) {│ │81 + return error.TargetPatchChanged; │ │82 } │ │83 } │ │84 if (event_oid != null or record.removed) { │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────╮ │src/serve_ssh.zig│ ╰─────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │10 const ssh = @import("./serve_ssh_protocol.zig"); │ │11 const evt = @import("./event.zig"); │ │12 const serve_common = @import("./serve_common.zig"); │ │13 +const fork = @import("./fork.zig"); │ │14 │ │15 // listener resource limits. the watchdog gives unauthenticated peers a hard │ │16 // deadline, detects idle non-interactive sessions from protocol progress, and│ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │328 defer parsed.deinit(allocator); │ │329 const repo_prefix = "/repo/"; │ │330 const repo_identity = if (std.mem.startsWith(u8, parsed.dir, repo_prefix)) parsed.dir[repo_prefix.len..] else parsed.dir; │ │331 + │ │332 + if (fork.parseRoute(repo_identity)) |route| { │ │333 + if (evt.parseOwnerRepoPath(route.identity) == null) return writeError(sess, "repo path must be <owner>/<repo>"); │ │334 + if (parsed.service == .receive_pack and !xit.ref.validateName(route.target)) return writeError(sess, "invalid target branch"); │ │335 + │ │336 + const draft_path = try fork.forkPath(allocator, handler.repo_root_path, &route.id); │ │337 + defer allocator.free(draft_path); │ │338 + var draft = rp.AnyRepo(.xit, .{}).open(io, allocator, .{ .path = draft_path }) catch │ │339 + return writeError(sess, "patch draft not found"); │ │340 + defer draft.deinit(io, allocator); │ │341 + switch (draft) { │ │342 + inline else => |*repo| { │ │343 + const author_maybe = try fork.draftAuthor(repo.self_repo_opts, repo, io, allocator); │ │344 + defer if (author_maybe) |author| allocator.free(author); │ │345 + const author = author_maybe orelse return writeError(sess, "invalid patch draft"); │ │346 + if (builtin.mode != .Debug and !try isKeyAuthorized(io, allocator, handler.admin_repo_path, author, &sess.fingerprint))│ │347 + return writeError(sess, "unauthorized: this SSH key is not registered to the patch author"); │ │348 + var reader_buf: [4096]u8 = undefined; │ │349 + var writer_buf: [4096]u8 = undefined; │ │350 + var reader = ssh.SessionReader.init(sess, &reader_buf); │ │351 + var writer = ssh.SessionWriter.init(sess, &writer_buf); │ │352 + switch (parsed.service) { │ │353 + .upload_pack => try repo.uploadPack(io, allocator, &reader.interface, &writer.interface, .{}), │ │354 + .receive_pack => try repo.receivePack(io, allocator, &reader.interface, &writer.interface, .{}), │ │355 + } │ │356 + writer.interface.flush() catch {}; │ │357 + if (parsed.service == .receive_pack) { │ │358 + if (try repo.readRef(io, fork.ref) == null) return writeError(sess, "push HEAD to refs/heads/patch"); │ │359 + try fork.setTarget(repo.self_repo_opts, repo, io, allocator, route.target); │ │360 + } │ │361 + }, │ │362 + } │ │363 + try sess.exit(0); │ │364 + return; │ │365 + } │ │366 │ │367 const create_if_missing = parsed.service == .receive_pack; │ │368 const any_repo_opts: rp.AnyRepoOpts(.xit) = .{}; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ───────────────╮ │src/test/event/patch.zig│ ╰────────────────────────╯ ╭──────────────────────────────────────────────────────────────╮ │ 5 const obj = xit.object; │ │ 6 const hash = xit.hash; │ │ 7 const rf = xit.ref; │ │ 8 +const fork = @import("../../fork.zig"); │ │ 9 │ │10 const repo_opts: rp.RepoOpts(.xit) = .{ .is_test = true };│ │11 const Repo = rp.Repo(.xit, repo_opts); │ ╰──────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────╮ │111 .description = "adds a reusable answer constant", │ │112 .tags = "enhancement", │ │113 .target_ref = "refs/heads/master", │ │113 - .source_url = source_path, │ │114 - .source_ref = "refs/heads/master", │ │114 .patchrev_id = std.fmt.bytesToHex(patchrev_id, .lower),│ │115 }; │ │116 const tree_entries = [_]evt.EventTreeEntry{ │ ╰────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────╮ │349 .tags = "enhancement", │ │350 .target_ref = patch.target_ref, │ │351 .target_patch_id = std.fmt.bytesToHex(patch_id, .lower), │ │353 - .source_url = patch.source_url, │ │354 - .source_ref = patch.source_ref, │ │352 .patchrev_id = std.fmt.bytesToHex(child_patchrev_id, .lower),│ │353 } }, │ │354 }, │ ╰──────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │402 retained.deinit(); │ │403 } │ │404 } │ │405 + │ │406 +test "publish a staged patch" { │ │407 + const io = std.testing.io; │ │408 + const allocator = std.testing.allocator; │ │409 + const temp_dir_name = "temp-staged-patch"; │ │410 + const cwd = std.Io.Dir.cwd(); │ │411 + │ │412 + if (cwd.openDir(io, temp_dir_name, .{})) |dir_value| { │ │413 + var dir = dir_value; │ │414 + dir.close(io); │ │415 + try cwd.deleteTree(io, temp_dir_name); │ │416 + } else |_| {} │ │417 + var temp_dir = try cwd.createDirPathOpen(io, temp_dir_name, .{}); │ │418 + defer cwd.deleteTree(io, temp_dir_name) catch {}; │ │419 + defer temp_dir.close(io); │ │420 + │ │421 + const cwd_path = try std.process.currentPathAlloc(io, allocator); │ │422 + defer allocator.free(cwd_path); │ │423 + const root = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name }); │ │424 + defer allocator.free(root); │ │425 + const repos_dir = try std.fs.path.join(allocator, &.{ root, "repos" }); │ │426 + defer allocator.free(repos_dir); │ │427 + const upstream_path = try std.fs.path.join(allocator, &.{ root, "upstream" }); │ │428 + defer allocator.free(upstream_path); │ │429 + const target_path = try std.fs.path.join(allocator, &.{ repos_dir, "target" }); │ │430 + defer allocator.free(target_path); │ │431 + │ │432 + // │ │433 + // create the target repo │ │434 + // │ │435 + │ │436 + { │ │437 + var upstream = try rp.Repo(.git, .{ .is_test = true }).init(io, allocator, .{ .path = upstream_path }); │ │438 + defer upstream.deinit(io, allocator); │ │439 + const file = try upstream.core.work_dir.createFile(io, "main.zig", .{ .truncate = true }); │ │440 + defer file.close(io); │ │441 + try file.writeStreamingAll(io, "pub fn main() void {}\n"); │ │442 + try upstream.add(io, allocator, &.{"main.zig"}); │ │443 + _ = try upstream.commit(io, allocator, .{ .author = "alice <alice@example.test>", .message = "initial code", .timestamp = 1 }); │ │444 + } │ │445 + var target = try Repo.clone(io, allocator, upstream_path, target_path, target_path, null, .{}); │ │446 + defer target.deinit(io, allocator); │ │447 + try target.addBranch(io, .{ .name = "next" }); │ │448 + │ │449 + var prng = std.Random.DefaultPrng.init(std.testing.random_seed); │ │450 + const unused_id = evt.EventWithId.randomId(prng.random()); │ │451 + const unused_hex = std.fmt.bytesToHex(unused_id, .lower); │ │452 + │ │453 + const route_text = try std.fmt.allocPrint(allocator, "alice/repo/patch:{s}/branch:feature/topic", .{&unused_hex}); │ │454 + defer allocator.free(route_text); │ │455 + const route = fork.parseRoute(route_text) orelse return error.InvalidRoute; │ │456 + try std.testing.expectEqualStrings("alice/repo", route.identity); │ │457 + try std.testing.expectEqualStrings("feature/topic", route.target); │ │458 + │ │459 + // │ │460 + // reserve an author-bound draft │ │461 + // │ │462 + │ │463 + const unused_path = try fork.create(repo_opts, io, allocator, repos_dir, &unused_hex, author.name); │ │464 + defer allocator.free(unused_path); │ │465 + var unused = try Repo.open(io, allocator, .{ .path = unused_path }); │ │466 + defer unused.deinit(io, allocator); │ │467 + const stored_author = (try fork.draftAuthor(repo_opts, &unused, io, allocator)) orelse return error.NotFound; │ │468 + defer allocator.free(stored_author); │ │469 + try std.testing.expectEqualStrings(author.name, stored_author); │ │470 + var head_buf: [rf.MAX_REF_CONTENT_SIZE]u8 = undefined; │ │471 + const head = try unused.head(io, &head_buf); │ │472 + switch (head) { │ │473 + .ref => |head_ref| try std.testing.expectEqualStrings(fork.ref.name, head_ref.name), │ │474 + .oid => return error.InvalidRef, │ │475 + } │ │476 │ │477 + // │ │478 + // push a proposed commit into another draft │ │479 + // │ │480 + │ │481 + const patch_id = evt.EventWithId.randomId(prng.random()); │ │482 + const patch_id_hex = std.fmt.bytesToHex(patch_id, .lower); │ │483 + const draft_path = try fork.forkPath(allocator, repos_dir, &patch_id_hex); │ │484 + defer allocator.free(draft_path); │ │485 + try cwd.createDirPath(io, std.fs.path.dirname(draft_path) orelse return error.InvalidPath); │ │486 + var draft = try Repo.clone(io, allocator, upstream_path, draft_path, draft_path, null, .{}); │ │487 + { │ │488 + defer draft.deinit(io, allocator); │ │489 + try draft.addConfig(io, allocator, .{ .name = "haxy.author", .value = author.name }); │ │490 + try draft.addConfig(io, allocator, .{ .name = "haxy.target", .value = "master" }); │ │491 + const file = try draft.core.work_dir.createFile(io, "feature.zig", .{ .truncate = true }); │ │492 + defer file.close(io); │ │493 + try file.writeStreamingAll(io, "pub const answer = 42;\n"); │ │494 + try draft.add(io, allocator, &.{"feature.zig"}); │ │495 + _ = try draft.commit(io, allocator, .{ .author = "alice <alice@example.test>", .message = "add feature", .timestamp = 2 }); │ │496 + try draft.addBranch(io, .{ .name = fork.ref.name }); │ │497 + } │ │498 + │ │499 + // │ │500 + // publish the staged code and metadata │ │501 + // │ │502 + │ │503 + try fork.publish(repo_opts, io, allocator, &target, draft_path, .{ │ │504 + .id = patch_id_hex, │ │505 + .title = "add the answer", │ │506 + .tags = "enhancement", │ │507 + .description = "adds a reusable answer constant", │ │508 + .author = author, │ │509 + .timestamp = 3, │ │510 + }); │ │511 + │ │512 + var arena = std.heap.ArenaAllocator.init(allocator); │ │513 + defer arena.deinit(); │ │514 + var moment = try evt.currentMoment(repo_opts, &target); │ │515 + const patch = (try evt.Patch.readById(Repo.DB, repo_opts.hash, moment, &arena, &patch_id)) orelse return error.NotFound; │ │516 + try std.testing.expectEqualStrings("refs/heads/master", patch.event.target_ref); │ │517 + const first_patchrev_id = try evt.parseEventId(&patch.event.patchrev_id); │ │518 + const first_patchrev = (try evt.PatchRev.readById(Repo.DB, repo_opts.hash, moment, &arena, &first_patchrev_id)) orelse return error.NotFound;│ │519 + try std.testing.expectEqualStrings("add feature", first_patchrev.event.message); │ │520 + │ │521 + // │ │522 + // edit metadata without replacing the code revision │ │523 + // │ │524 + │ │525 + try fork.publish(repo_opts, io, allocator, &target, draft_path, .{ │ │526 + .id = patch_id_hex, │ │527 + .title = "add a reusable answer", │ │528 + .tags = "enhancement", │ │529 + .description = "adds a reusable answer constant", │ │530 + .author = author, │ │531 + .timestamp = 4, │ │532 + }); │ │533 + _ = arena.reset(.retain_capacity); │ │534 + moment = try evt.currentMoment(repo_opts, &target); │ │535 + const edited = (try evt.Patch.readById(Repo.DB, repo_opts.hash, moment, &arena, &patch_id)) orelse return error.NotFound; │ │536 + try std.testing.expectEqualStrings(&patch.event.patchrev_id, &edited.event.patchrev_id); │ │537 + │ │538 + // │ │539 + // retargeting creates another code revision │ │540 + // │ │541 + │ │542 + var reopened = try Repo.open(io, allocator, .{ .path = draft_path }); │ │543 + defer reopened.deinit(io, allocator); │ │544 + try fork.setTarget(repo_opts, &reopened, io, allocator, "next"); │ │545 + try fork.publish(repo_opts, io, allocator, &target, draft_path, .{ │ │546 + .id = patch_id_hex, │ │547 + .title = edited.event.title, │ │548 + .tags = edited.event.tags, │ │549 + .description = edited.event.description, │ │550 + .author = author, │ │551 + .timestamp = 5, │ │552 + }); │ │553 + _ = arena.reset(.retain_capacity); │ │554 + moment = try evt.currentMoment(repo_opts, &target); │ │555 + const retargeted = (try evt.Patch.readById(Repo.DB, repo_opts.hash, moment, &arena, &patch_id)) orelse return error.NotFound; │ │556 + try std.testing.expectEqualStrings("refs/heads/next", retargeted.event.target_ref); │ │557 + try std.testing.expect(!std.mem.eql(u8, &edited.event.patchrev_id, &retargeted.event.patchrev_id)); │ │558 +} │ │559 + │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ added ─────╮ │src/fork.zig│ ╰────────────╯