╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ────╮ │src/patch.zig│ ╰─────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │331 id: *const [evt.event_id_size]u8, │ │332 patch: evt.Patch, │ │333 ) !Mergeability { │ │334 + // reject checks for an outdated source revision │ │335 if (patch.source_branch != null and !try branchCurrent(repo_opts, state, io, arena, patch)) return .{}; │ │336 + │ │337 + // use the cached result only when the target still matches │ │338 const target_oid = try rf.readRecur(.xit, repo_opts, state, io, .{ .ref = .{ .kind = .head, .name = patch.target_branch } }) orelse return .{};│ │339 const haxy_moment = evt.currentMomentFromRepoMoment(repo_opts.hash, state.extra.moment.*) catch |err| switch (err) { │ │340 error.NotFound => return .{}, │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ │366 id_maybe: ?[evt.event_id_size]u8, │ │367 ) !void { │ │368 if (id_maybe) |id| return refreshMergeCheck(repo_opts, io, allocator, target_repo, &id);│ │369 + │ │370 + // collect open patch ids before any refresh changes the database │ │371 const DB = evt.EventDB(repo_opts.hash); │ │372 const moment = evt.currentMoment(repo_opts, target_repo) catch |err| switch (err) { │ │373 error.NotFound => return, │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │381 defer ids.deinit(allocator); │ │382 var iter = try open.iteratorFromIndex(0); │ │383 while (try iter.next()) |cursor| try ids.append(allocator, try evt.readOrderKeyId(DB, cursor));│ │384 + │ │385 + // refresh each collected patch independently │ │386 for (ids.items) |id| refreshMergeability(repo_opts, io, allocator, target_repo, id); │ │387 } │ │388 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │397 const DB = Repo.DB; │ │398 var arena = std.heap.ArenaAllocator.init(allocator); │ │399 defer arena.deinit(); │ │400 + │ │401 + // skip patches that no longer need mergeability checks │ │402 const initial = (try evt.readFromRepo(evt.Patch, .xit, repo_opts, io, allocator, &arena, target_repo, id)) orelse return; │ │403 if (initial.removed or initial.event.status.kind() != .open) return; │ │341 - const repo_root_path = std.fs.path.dirname(target_repo.core.work_path) orelse "."; │ │342 - const path = try fork.forkPath(arena.allocator(), repo_root_path, &std.fmt.bytesToHex(id.*, .lower)); │ │343 - const branch_backed = initial.event.source_branch != null; │ │344 - var fork_repo_maybe: ?Repo = if (branch_backed) null else Repo.open(io, allocator, .{ .path = path, .require_repo_root = true }) catch |err| blk: { │ │345 - std.log.warn("mergeability fork unavailable: {s}", .{@errorName(err)}); │ │346 - break :blk null; │ │347 - }; │ │348 - defer if (fork_repo_maybe) |*repo| repo.deinit(io, allocator); │ │349 - if (fork_repo_maybe) |*repo| try repo.core.db_file.lock(io, .shared); │ │350 - defer if (fork_repo_maybe) |*repo| repo.core.db_file.unlock(io); │ │351 - try target_repo.core.db_file.lock(io, .exclusive); │ │352 - defer target_repo.core.db_file.unlock(io); │ │404 + │ │405 + // acquire fork locks before the target repository lock │ │406 + var checks: MergeCheckInputs(repo_opts) = .{}; │ │407 + defer checks.deinit(io, allocator); │ │408 + try checks.add(io, allocator, target_repo, id.*, initial.event); │ │409 │ │354 - var target_moment = try target_repo.core.latestMoment(); │ │355 - const target_state = Repo.State(.read_only){ .core = &target_repo.core, .extra = .{ .moment = &target_moment } }; │ │356 - const target_events = evt.currentMomentFromRepoMoment(repo_opts.hash, target_moment) catch |err| switch (err) { │ │357 - error.NotFound => return, │ │358 - else => return err, │ │359 - }; │ │360 - const patch_record = (try evt.Patch.readById(DB, repo_opts.hash, target_events, &arena, id)) orelse return; │ │361 - if (patch_record.removed or patch_record.event.status.kind() != .open) return; │ │362 - var newest: ?evt.PatchRev.WithId = null; │ │363 - if (fork_repo_maybe) |*repo| { │ │364 - if (evt.currentMoment(repo_opts, repo)) |events| { │ │365 - newest = evt.PatchRev.readNewest(DB, repo_opts.hash, events, &arena) catch |err| blk: { │ │366 - std.log.warn("mergeability revision unavailable: {s}", .{@errorName(err)}); │ │367 - break :blk null; │ │368 - }; │ │369 - } else |err| std.log.warn("mergeability revision unavailable: {s}", .{@errorName(err)}); │ │370 - } │ │371 - const patch = patch_record.event; │ │372 - const revision = patch.revision orelse return; │ │373 - const target_oid = try rf.readRecur(.xit, repo_opts, target_state, io, .{ .ref = .{ .kind = .head, .name = patch.target_branch } }); │ │374 - var check = MergeCheck{ .revision = revision, .target_branch = patch.target_branch, .target_oid = if (target_oid) |*oid| oid else "" }; │ │375 - const cached = try readMergeCheck(repo_opts, target_events, &arena, id); │ │376 - const current = if (patch.source_branch != null) try branchCurrent(repo_opts, target_state, io, &arena, patch) else if (newest) |latest| std.mem.eql(u8, &std.fmt.bytesToHex(latest.id, .lower), &revision.id) and revision.matches(latest.record) else false;│ │377 - if (current and target_oid != null) { │ │378 - if (cached) |value| if (value.matches(patch, check.target_oid) and value.result.source != .unknown and value.result.squash != .unknown) return; │ │410 + // cancel the cache transaction when the result is unchanged │ │411 + { │ │412 const Ctx = struct { │ │380 - repo: *Repo, │ │381 - fork_repo: ?*Repo, │ │413 + core: *Repo.Core, │ │414 + checks: *MergeCheckInputs(repo_opts), │ │415 io: std.Io, │ │416 allocator: std.mem.Allocator, │ │384 - check: *MergeCheck, │ │417 │ │418 pub fn run(ctx: @This(), cursor: *DB.Cursor(.read_write)) !void { │ │419 var moment = try DB.HashMap(.read_write).init(cursor.*); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │388 - const state = Repo.State(.read_write){ .core = &ctx.repo.core, .extra = .{ .moment = &moment } }; │ │389 - const source = try oidArray(repo_opts.hash, ctx.check.revision.source_oid); │ │390 - const squash = try oidArray(repo_opts.hash, ctx.check.revision.squash_oid); │ │391 - if (ctx.fork_repo) |repo| { │ │392 - var fork_moment = try repo.core.latestMoment(); │ │393 - const fork_state = Repo.State(.read_only){ .core = &repo.core, .extra = .{ .moment = &fork_moment } }; │ │394 - var objects = try obj.ObjectIterator(.xit, repo_opts).init(fork_state, ctx.io, ctx.allocator, .{ .kind = .all }); │ │395 - defer objects.deinit(); │ │396 - try objects.include(&source); │ │397 - try objects.include(&squash); │ │398 - try obj.copyFromObjectIterator(.xit, repo_opts, state, .xit, repo_opts, &objects, ctx.io, null); │ │399 - } │ │400 - const target = rf.Ref{ .kind = .head, .name = ctx.check.target_branch }; │ │401 - ctx.check.result.source = checkCommit(repo_opts, state, ctx.io, ctx.allocator, &source, target); │ │402 - ctx.check.result.squash = checkCommit(repo_opts, state, ctx.io, ctx.allocator, &squash, target); │ │403 - return error.CancelTransaction; │ │420 + const state = Repo.State(.read_write){ .core = ctx.core, .extra = .{ .moment = &moment } }; │ │421 + if (!try ctx.checks.update(state, ctx.io, ctx.allocator)) return error.CancelTransaction; │ │422 } │ │423 }; │ │424 + │ │425 + try target_repo.core.db_file.lock(io, .exclusive); │ │426 + defer target_repo.core.db_file.unlock(io); │ │427 + │ │428 const history = try DB.ArrayList(.read_write).init(target_repo.core.db.rootCursor()); │ │429 history.appendContext(.{ .slot = try history.getSlot(-1) }, Ctx{ │ │408 - .repo = target_repo, │ │409 - .fork_repo = if (fork_repo_maybe) |*repo| repo else null, │ │430 + .core = &target_repo.core, │ │431 + .checks = &checks, │ │432 .io = io, │ │433 .allocator = allocator, │ │412 - .check = &check, │ │434 }) catch |err| switch (err) { │ │435 error.CancelTransaction => {}, │ │415 - else => std.log.warn("mergeability check failed: {s}", .{@errorName(err)}), │ │436 + else => return err, │ │437 }; │ │438 } │ │418 - if (cached) |value| if (evt.fieldEqual(MergeCheck, value, check)) return; │ │419 - const Save = struct { │ │420 - id: [evt.event_id_size]u8, │ │421 - check: MergeCheck, │ │439 +} │ │440 + │ │441 +// prepare fork handles before taking the target lock. this preserves the │ │442 +// fork-before-target lock order used by publishing and merge operations. │ │443 +pub fn MergeCheckInputs(comptime repo_opts: rp.RepoOpts(.xit)) type { │ │444 + const Repo = rp.Repo(.xit, repo_opts); │ │445 + return struct { │ │446 + forks: std.AutoArrayHashMapUnmanaged([evt.event_id_size]u8, ?Repo) = .empty, │ │447 + const Self = @This(); │ │448 + │ │449 + pub fn prepare(self: *Self, io: std.Io, allocator: std.mem.Allocator, repo: *Repo, events: []const evt.EventWithId) !void { │ │450 + for (events) |event| { │ │451 + if (event.event != .patch) continue; │ │452 + const patch = event.event.patch orelse continue; │ │453 + try self.add(io, allocator, repo, try evt.parseEventId(&event.id), patch); │ │454 + } │ │455 + } │ │456 + │ │457 + fn add(self: *Self, io: std.Io, allocator: std.mem.Allocator, repo: *Repo, id: [evt.event_id_size]u8, patch: evt.Patch) !void { │ │458 + // track each open patch once, including branch-backed patches │ │459 + if (patch.status.kind() != .open) return; │ │460 + const entry = try self.forks.getOrPut(allocator, id); │ │461 + if (entry.found_existing) return; │ │462 + entry.value_ptr.* = null; │ │463 + if (patch.source_branch != null) return; │ │464 + │ │465 + // open the fork before acquiring its shared lock │ │466 + const path = try fork.forkPath(allocator, std.fs.path.dirname(repo.core.work_path) orelse ".", &std.fmt.bytesToHex(id, .lower)); │ │467 + defer allocator.free(path); │ │468 + var opened = Repo.open(io, allocator, .{ .path = path, .require_repo_root = true }) catch |err| { │ │469 + std.log.warn("mergeability fork unavailable: {s}", .{@errorName(err)}); │ │470 + return; │ │471 + }; │ │472 + │ │473 + // transfer the locked fork to the collection for cleanup │ │474 + errdefer opened.deinit(io, allocator); │ │475 + try opened.core.db_file.lock(io, .shared); │ │476 + entry.value_ptr.* = opened; │ │477 + } │ │478 + │ │479 + pub fn deinit(self: *Self, io: std.Io, allocator: std.mem.Allocator) void { │ │480 + for (self.forks.values()) |*value| if (value.*) |*repo| { │ │481 + repo.core.db_file.unlock(io); │ │482 + repo.deinit(io, allocator); │ │483 + }; │ │484 + self.forks.deinit(allocator); │ │485 + } │ │486 │ │423 - pub fn run(ctx: @This(), cursor: *DB.Cursor(.read_write)) !void { │ │424 - const moment = try DB.HashMap(.read_write).init(cursor.*); │ │425 - const last_object_id_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, evt.last_object_id_key)) orelse return error.NotFound; │ │426 - var last_object_id: [hash.byteLen(repo_opts.hash)]u8 = undefined; │ │427 - _ = try last_object_id_cursor.readBytes(&last_object_id); │ │428 - const haxy_history = try DB.ArrayList(.read_write).init(try moment.putCursor(hash.hashInt(repo_opts.hash, evt.history_key))); │ │429 - const haxy_moments = try DB.HashMap(.read_write).init(try haxy_history.putCursor(-1)); │ │430 - const haxy_moment = try DB.HashMap(.read_write).init(try haxy_moments.putCursor(hash.bytesToInt(repo_opts.hash, &last_object_id))); │ │431 - const map = try DB.HashMap(.read_write).init(try haxy_moment.putCursor(hash.hashInt(repo_opts.hash, evt.Patch.patch_id_to_mergeability_key))); │ │432 - const entry = try DB.HashMap(.read_write).init(try map.putCursor(hash.hashInt(repo_opts.hash, &ctx.id))); │ │433 - try evt.upsert(MergeCheck, DB, repo_opts.hash, entry, ctx.check); │ │434 - // discard the old repo-level cache as results are rebuilt here │ │435 - _ = try moment.remove(hash.hashInt(repo_opts.hash, evt.Patch.patch_id_to_mergeability_key)); │ │487 + // optional checks may fail without rejecting a patch, but a failed │ │488 + // restoration must abort the transaction so trial writes cannot be │ │489 + // committed. reports whether any cached result changed. │ │490 + pub fn update(self: *Self, state: Repo.State(.read_write), io: std.Io, allocator: std.mem.Allocator) !bool { │ │491 + var arena = std.heap.ArenaAllocator.init(allocator); │ │492 + defer arena.deinit(); │ │493 + var changed = false; │ │494 + for (self.forks.keys(), self.forks.values()) |id, *repo| { │ │495 + const saved = updateMergeCheck(repo_opts, state, io, &arena, &id, if (repo.*) |*value| value else null) catch |err| switch (err) { │ │496 + error.MergeCheckRestoreFailed => return err, │ │497 + else => { │ │498 + std.log.warn("failed to refresh mergeability: {s}", .{@errorName(err)}); │ │499 + continue; │ │500 + }, │ │501 + }; │ │502 + if (saved) changed = true; │ │503 + } │ │504 + return changed; │ │505 } │ │506 }; │ │438 - const history = try DB.ArrayList(.read_write).init(target_repo.core.db.rootCursor()); │ │439 - try history.appendContext(.{ .slot = try history.getSlot(-1) }, Save{ .id = id.*, .check = check }); │ │507 +} │ │508 + │ │509 +// the caller holds the target's exclusive lock, and any fork's shared lock. │ │510 +// this only saves the cache; it never creates a top-level history transaction. │ │511 +fn updateMergeCheck( │ │512 + comptime repo_opts: rp.RepoOpts(.xit), │ │513 + state: rp.Repo(.xit, repo_opts).State(.read_write), │ │514 + io: std.Io, │ │515 + arena: *std.heap.ArenaAllocator, │ │516 + id: *const [evt.event_id_size]u8, │ │517 + fork_repo: ?*rp.Repo(.xit, repo_opts), │ │518 +) !bool { │ │519 + const DB = rp.Repo(.xit, repo_opts).DB; │ │520 + │ │521 + // read the patch from the current transaction │ │522 + const target_events = evt.currentMomentFromRepoMoment(repo_opts.hash, state.extra.moment.readOnly()) catch |err| switch (err) { │ │523 + error.NotFound => return false, │ │524 + else => return err, │ │525 + }; │ │526 + const patch_record = (try evt.Patch.readById(DB, repo_opts.hash, target_events, arena, id)) orelse return false; │ │527 + if (patch_record.removed or patch_record.event.status.kind() != .open) return false; │ │528 + │ │529 + // find the newest fork revision when the patch uses a fork │ │530 + var newest: ?evt.PatchRev.WithId = null; │ │531 + if (fork_repo) |repo| { │ │532 + if (evt.currentMoment(repo_opts, repo)) |events| { │ │533 + newest = evt.PatchRev.readNewest(DB, repo_opts.hash, events, arena) catch |err| blk: { │ │534 + std.log.warn("mergeability revision unavailable: {s}", .{@errorName(err)}); │ │535 + break :blk null; │ │536 + }; │ │537 + } else |err| std.log.warn("mergeability revision unavailable: {s}", .{@errorName(err)}); │ │538 + } │ │539 + │ │540 + // compare the selected revision and target against the cache │ │541 + const patch = patch_record.event; │ │542 + const revision = patch.revision orelse return false; │ │543 + const target_oid = try rf.readRecur(.xit, repo_opts, state.readOnly(), io, .{ .ref = .{ .kind = .head, .name = patch.target_branch } }); │ │544 + var check = MergeCheck{ .revision = revision, .target_branch = patch.target_branch, .target_oid = if (target_oid) |*oid| oid else "" }; │ │545 + const cached = try readMergeCheck(repo_opts, target_events, arena, id); │ │546 + const current = if (patch.source_branch != null) │ │547 + try branchCurrent(repo_opts, state.readOnly(), io, arena, patch) │ │548 + else if (newest) |latest| │ │549 + std.mem.eql(u8, &std.fmt.bytesToHex(latest.id, .lower), &revision.id) and revision.matches(latest.record) │ │550 + else │ │551 + false; │ │552 + │ │553 + // run trial merges only for a current revision with an unresolved check │ │554 + if (current and target_oid != null) { │ │555 + if (cached) |value| if (value.matches(patch, check.target_oid) and value.result.source != .unknown and value.result.squash != .unknown) return false; │ │556 + check.result = try trialMergeCheck(repo_opts, state, io, arena.child_allocator, &check, fork_repo); │ │557 + } │ │558 + │ │559 + // persist the result only when it differs from the cache │ │560 + if (cached) |value| if (evt.fieldEqual(MergeCheck, value, check)) return false; │ │561 + try saveMergeCheck(repo_opts, state.extra.moment.*, id, check); │ │562 + return true; │ │563 +} │ │564 + │ │565 +fn trialMergeCheck( │ │566 + comptime repo_opts: rp.RepoOpts(.xit), │ │567 + state: rp.Repo(.xit, repo_opts).State(.read_write), │ │568 + io: std.Io, │ │569 + allocator: std.mem.Allocator, │ │570 + check: *const MergeCheck, │ │571 + fork_repo: ?*rp.Repo(.xit, repo_opts), │ │572 +) !Mergeability { │ │573 + // freeze the parent state so trial writes copy its nodes │ │574 + const moment = state.extra.moment; │ │575 + const snapshot = moment.slot(); │ │576 + // TODO: use an xitdb savepoint to also reclaim trial bytes on rollback. │ │577 + // restoring the root leaves the appended data unused until gc. │ │578 + try state.core.db.freeze(); │ │579 + moment.* = rp.Repo(.xit, repo_opts).DB.HashMap(.read_write).init(moment.cursor) catch return error.MergeCheckRestoreFailed; │ │580 + │ │581 + // keep the trial result in memory while discarding its state changes │ │582 + const result = calculateMergeCheck(repo_opts, state, io, allocator, check, fork_repo); │ │583 + │ │584 + // restore the parent state before handling any trial error │ │585 + moment.cursor.write(.{ .slot = snapshot }) catch return error.MergeCheckRestoreFailed; │ │586 + moment.* = rp.Repo(.xit, repo_opts).DB.HashMap(.read_write).init(moment.cursor) catch return error.MergeCheckRestoreFailed; │ │587 + │ │588 + // report failed trials as unknown after the parent state is restored │ │589 + return result catch |err| { │ │590 + std.log.warn("mergeability check failed: {s}", .{@errorName(err)}); │ │591 + return .{}; │ │592 + }; │ │593 +} │ │594 + │ │595 +fn calculateMergeCheck( │ │596 + comptime repo_opts: rp.RepoOpts(.xit), │ │597 + state: rp.Repo(.xit, repo_opts).State(.read_write), │ │598 + io: std.Io, │ │599 + allocator: std.mem.Allocator, │ │600 + check: *const MergeCheck, │ │601 + fork_repo: ?*rp.Repo(.xit, repo_opts), │ │602 +) !Mergeability { │ │603 + const source = try oidArray(repo_opts.hash, check.revision.source_oid); │ │604 + const squash = try oidArray(repo_opts.hash, check.revision.squash_oid); │ │605 + │ │606 + // import fork objects into the temporary trial state │ │607 + if (fork_repo) |repo| { │ │608 + var fork_moment = try repo.core.latestMoment(); │ │609 + const fork_state = rp.Repo(.xit, repo_opts).State(.read_only){ .core = &repo.core, .extra = .{ .moment = &fork_moment } }; │ │610 + var objects = try obj.ObjectIterator(.xit, repo_opts).init(fork_state, io, allocator, .{ .kind = .all }); │ │611 + defer objects.deinit(); │ │612 + try objects.include(&source); │ │613 + try objects.include(&squash); │ │614 + try obj.copyFromObjectIterator(.xit, repo_opts, state, .xit, repo_opts, &objects, io, null); │ │615 + } │ │616 + │ │617 + // check source and squash merges against the same target │ │618 + const target = rf.Ref{ .kind = .head, .name = check.target_branch }; │ │619 + return .{ │ │620 + .source = checkCommit(repo_opts, state, io, allocator, &source, target), │ │621 + .squash = checkCommit(repo_opts, state, io, allocator, &squash, target), │ │622 + }; │ │623 } │ │624 │ │625 +fn saveMergeCheck(comptime repo_opts: rp.RepoOpts(.xit), moment: rp.Repo(.xit, repo_opts).DB.HashMap(.read_write), id: *const [evt.event_id_size]u8, check: MergeCheck) !void { │ │626 + const DB = rp.Repo(.xit, repo_opts).DB; │ │627 + │ │628 + // store the check under the current event history moment │ │629 + const last_object_id_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, evt.last_object_id_key)) orelse return error.NotFound; │ │630 + var last_object_id: [hash.byteLen(repo_opts.hash)]u8 = undefined; │ │631 + _ = try last_object_id_cursor.readBytes(&last_object_id); │ │632 + const haxy_history = try DB.ArrayList(.read_write).init(try moment.putCursor(hash.hashInt(repo_opts.hash, evt.history_key))); │ │633 + const haxy_moments = try DB.HashMap(.read_write).init(try haxy_history.putCursor(-1)); │ │634 + const haxy_moment = try DB.HashMap(.read_write).init(try haxy_moments.putCursor(hash.bytesToInt(repo_opts.hash, &last_object_id))); │ │635 + const map = try DB.HashMap(.read_write).init(try haxy_moment.putCursor(hash.hashInt(repo_opts.hash, evt.Patch.patch_id_to_mergeability_key))); │ │636 + const entry = try DB.HashMap(.read_write).init(try map.putCursor(hash.hashInt(repo_opts.hash, id))); │ │637 + try evt.upsert(MergeCheck, DB, repo_opts.hash, entry, check); │ │638 +} │ │639 + │ │640 fn branchCurrent(comptime repo_opts: rp.RepoOpts(.xit), state: rp.Repo(.xit, repo_opts).State(.read_only), io: std.Io, arena: *std.heap.ArenaAllocator, patch: evt.Patch) !bool {│ │641 + // check that the source branch still points to the selected commit │ │642 const branch = patch.source_branch orelse return false; │ │643 const selected = patch.revision orelse return false; │ │644 const oid = (try rf.readRecur(.xit, repo_opts, state, io, .{ .ref = .{ .kind = .head, .name = branch } })) orelse return false; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │645 if (!std.mem.eql(u8, &oid, selected.source_oid)) return false; │ │646 + │ │647 + // verify that the stored revision metadata also matches │ │648 const events = try evt.currentMomentFromRepoMoment(repo_opts.hash, state.extra.moment.*); │ │649 const revision = (try evt.PatchRev.readById(evt.EventDB(repo_opts.hash), repo_opts.hash, events, arena, &try evt.parseEventId(&selected.id))) orelse return false;│ │650 return selected.matches(revision); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────╮ │674 return .unknown; │ │675 }; │ │676 defer result.deinit(); │ │677 + │ │678 + // translate the dry-run outcome into a cached status │ │679 return switch (result.result) { │ │680 .conflict => .conflict, │ │681 .clean, .success, .nothing, .fast_forward => .clean,│ ╰─────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │702 var arena = std.heap.ArenaAllocator.init(allocator); │ │703 defer arena.deinit(); │ │704 │ │705 + // validate the fork ownership and publishing identity │ │706 const admin_moment = try evt.currentMoment(evt.admin_repo_opts, admin_repo); │ │707 const fork_record = (try evt.Fork.readById(evt.AdminDB, evt.admin_repo_opts.hash, admin_moment, &arena, &patch_id)) orelse return error.InvalidPatchDraft;│ │708 if (fork_record.removed or │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │713 !std.mem.eql(u8, user.event.name, input.author.name) or │ │714 !std.mem.eql(u8, user.event.email, input.author.email)) return error.InvalidPatchDraft; │ │715 │ │716 + // read the patch staged in the fork │ │717 var fork_repo = rp.Repo(.xit, repo_opts).open(io, allocator, .{ .path = fork_path }) catch return error.PatchDataUnavailable;│ │718 defer fork_repo.deinit(io, allocator); │ │719 const fork_moment = evt.currentMoment(repo_opts, &fork_repo) catch return error.PatchDataUnavailable; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │720 const local_patch = (try evt.Patch.readById(evt.EventDB(repo_opts.hash), repo_opts.hash, fork_moment, &arena, &patch_id)) orelse return error.PatchDataUnavailable;│ │721 │ │722 + // check whether this patch has already been published │ │723 const target_moment = evt.currentMoment(repo_opts, target_repo) catch |err| switch (err) { │ │724 error.NotFound => null, │ │725 else => |other| return other, │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯