╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ────╮ │src/patch.zig│ ╰─────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │821 │ │822 var events: [2]evt.EventWithId = undefined; │ │823 var event_count: usize = 0; │ │631 - const current_revision = try evt.PatchRev.readById(DB, repo_opts.hash, haxy_moment, &arena, &revision_id);│ │824 var tree_entries: [2]evt.EventTreeEntry = undefined; │ │825 const needs_import = if (current_revision) |existing| existing.removed else true; │ │826 if (needs_import) { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ───╮ │src/push.zig│ ╰────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │87 var response = xit.net_server_receive_pack.Response.init(allocator); │ │88 defer response.deinit(); │ │89 │ │90 + var updates = xit.net_server_receive_pack.AppliedRefUpdates.init(allocator);│ │91 + defer updates.deinit(); │ │92 const Ctx = struct { │ │93 + updates: *xit.net_server_receive_pack.AppliedRefUpdates, │ │94 core: *rp.Repo(.xit, repo_opts).Core, │ │95 io: std.Io, │ │96 allocator: std.mem.Allocator, │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │104 pub fn run(ctx: @This(), cursor: *DB.Cursor(.read_write)) !void { │ │105 var moment = try DB.HashMap(.read_write).init(cursor.*); │ │106 const state = State(.read_write){ .core = ctx.core, .extra = .{ .moment = &moment } }; │ │104 - var updates = xit.net_server_receive_pack.AppliedRefUpdates.init(ctx.allocator); │ │105 - defer updates.deinit(); │ │107 + │ │108 var receive_options = ctx.options; │ │107 - receive_options.applied_ref_updates = &updates; │ │109 + receive_options.applied_ref_updates = ctx.updates; │ │110 receive_options.deferred_response = ctx.response; │ │111 try xit.net_server_receive_pack.run(.xit, repo_opts, state, ctx.io, ctx.allocator, ctx.reader, ctx.writer, receive_options);│ │112 try writeReceivedPatches(repo_opts, state, ctx.io, ctx.allocator, ctx.response, ctx.writer); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │116 // transaction and must commit regardless. │ │117 if (null != try rf.readRecur(.xit, repo_opts, state.readOnly(), ctx.io, .{ .ref = evt.events_ref })) { │ │118 _ = try evt.consumeInTransaction(.repo, .xit, repo_opts, state, &ctx.core.db, &moment, ctx.io, ctx.allocator, evt.events_ref); │ │117 - try pch.detectMerged(repo_opts, state, &ctx.core.db, &moment, ctx.io, ctx.allocator, ctx.repo_root_path, updates.items.items, ctx.error_writer); │ │119 + try pch.detectMerged(repo_opts, state, &ctx.core.db, &moment, ctx.io, ctx.allocator, ctx.repo_root_path, ctx.updates.items.items, ctx.error_writer);│ │120 } │ │121 try xit.undo.writeMessage(repo_opts, state, .push); │ │122 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │130 break :blk history.appendContext( │ │131 .{ .slot = try history.getSlot(-1) },│ │132 Ctx{ │ │133 + .updates = &updates, │ │134 .core = &repo.core, │ │135 .io = io, │ │136 .allocator = allocator, │ ╰──────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │149 return err; │ │150 }; │ │151 │ │152 + pch.refreshBranches(.server, .xit, repo_opts, io, allocator, repo, updates.items.items) catch |err| { │ │153 + serve_common.logError(io, error_writer, "failed to refresh branch patches: {s}\n", .{@errorName(err)});│ │154 + }; │ │155 response.progress(writer, "Checking merges...\n") catch {}; │ │156 pch.refreshMergeability(repo_opts, io, allocator, repo, null); │ │157 try response.finish(writer, null); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────╮ │248 null; │ │249 │ │250 // reuse an identical revision or record a new one │ │251 + var revision_arena = std.heap.ArenaAllocator.init(ctx.allocator);│ │252 + defer revision_arena.deinit(); │ │253 var events: [2]evt.EventWithId = undefined; │ │254 var event_count: usize = 0; │ │247 - var base_tree_oid: [hash.hexLen(repo_opts.hash)]u8 = undefined; │ │248 - var head_tree_oid: [hash.hexLen(repo_opts.hash)]u8 = undefined; │ │249 - var tree_entries: [2]evt.EventTreeEntry = undefined; │ │255 if (existing_revision) |latest| { │ │256 if (ctx.published) ctx.revision_id_maybe.* = latest.id; │ │257 } else { │ ╰──────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │253 - var base_object = try obj.Object(.xit, repo_opts).initCommit(state.readOnly(), ctx.io, ctx.allocator, &base_oid); │ │254 - defer base_object.deinit(); │ │255 - var source_object = try obj.Object(.xit, repo_opts).initCommit(state.readOnly(), ctx.io, ctx.allocator, &source_oid); │ │256 - defer source_object.deinit(); │ │257 - base_tree_oid = base_object.content.commit.tree; │ │258 - head_tree_oid = source_object.content.commit.tree; │ │259 - │ │260 - var revision_id: [evt.event_id_size]u8 = undefined; │ │261 - ctx.io.random(&revision_id); │ │262 - const revision_hex = std.fmt.bytesToHex(revision_id, .lower); │ │263 - const revision_event: evt.PatchRev = .{ │ │264 - .base_oid = &base_oid, │ │265 - .source_oid = &source_oid, │ │266 - .message = ctx.title, │ │267 - }; │ │268 - const identity = try std.fmt.allocPrint(ctx.allocator, "{s} <{s}>", .{ ctx.author.name, ctx.author.email }); │ │269 - defer ctx.allocator.free(identity); │ │270 - const patch_oid = try evt.PatchRev.writeSquashCommit( │ │271 - .xit, │ │272 - repo_opts, │ │273 - state, │ │274 - ctx.io, │ │275 - ctx.allocator, │ │276 - revision_event, │ │277 - &head_tree_oid, │ │278 - identity, │ │279 - identity, │ │280 - ctx.timestamp, │ │281 - ); │ │282 - tree_entries = .{ │ │283 - .{ .tree = .{ .name = "base", .oid = &base_tree_oid } }, │ │284 - .{ .tree = .{ .name = "head", .oid = &head_tree_oid } }, │ │285 - }; │ │286 - events[event_count] = .{ │ │287 - .id = revision_hex, │ │288 - .timestamp = ctx.timestamp, │ │289 - .author = ctx.author, │ │290 - .tree_entries = &tree_entries, │ │291 - .event = .{ .patchrev = revision_event }, │ │292 - }; │ │258 + const prepared = try evt.PatchRev.prepare(.xit, repo_opts, state, ctx.io, &revision_arena, &base_oid, &source_oid, ctx.title, ctx.author, ctx.timestamp);│ │259 + events[event_count] = prepared.event; │ │260 event_count += 1; │ │294 - ctx.revision_id_maybe.* = revision_id; │ │261 + ctx.revision_id_maybe.* = try evt.parseEventId(&prepared.event.id); │ │262 │ │263 if (!ctx.published) { │ │264 var patch = ctx.patch.event; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────╮ │298 - patch.revision = .{ │ │299 - .id = revision_hex, │ │300 - .squash_oid = &patch_oid, │ │301 - .source_oid = &source_oid, │ │302 - }; │ │265 + patch.revision = prepared.revision;│ │266 events[event_count] = .{ │ │267 .id = ctx.patch_id, │ │268 .timestamp = ctx.timestamp, │ ╰────────────────────────────────────────────────────────────────╯ ╭ changed ───────────────╮ │src/test/event/patch.zig│ ╰────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 12 const Repo = rp.Repo(.xit, repo_opts); │ │ 13 const author = evt.CommitAuthor{ .name = "alice", .email = "alice@example.test" }; │ │ 14 │ │ 15 +test "branch patch lifecycle" { │ │ 16 + try testBranchPatch(.git, .sha1); │ │ 17 + try testBranchPatch(.git, .sha256); │ │ 18 + try testBranchPatch(.xit, .sha1); │ │ 19 + try testBranchPatch(.xit, .sha256); │ │ 20 +} │ │ 21 + │ │ 22 +fn testBranchPatch(comptime kind: rp.RepoKind, comptime hash_kind: hash.HashKind) !void { │ │ 23 + const io = std.testing.io; │ │ 24 + const allocator = std.testing.allocator; │ │ 25 + const opts: rp.RepoOpts(kind) = .{ .is_test = true, .hash = hash_kind }; │ │ 26 + var temp = std.testing.tmpDir(.{}); │ │ 27 + defer temp.cleanup(); │ │ 28 + const path = try temp.dir.realPathFileAlloc(io, ".", allocator); │ │ 29 + defer allocator.free(path); │ │ 30 + var repo = try rp.Repo(kind, opts).init(io, allocator, .{ .path = path }); │ │ 31 + defer repo.deinit(io, allocator); │ │ 32 + var arena = std.heap.ArenaAllocator.init(allocator); │ │ 33 + defer arena.deinit(); │ │ 34 + │ │ 35 + // create two branches with a shared base │ │ 36 + { │ │ 37 + const file = try repo.core.work_dir.createFile(io, "base.txt", .{}); │ │ 38 + defer file.close(io); │ │ 39 + try file.writeStreamingAll(io, "base\n"); │ │ 40 + } │ │ 41 + try repo.add(io, allocator, &.{"base.txt"}); │ │ 42 + const base = try repo.commit(io, allocator, .{ .message = "base" }); │ │ 43 + try repo.addBranch(io, .{ .name = "feature" }); │ │ 44 + const source = try repo.commitAtRef(io, allocator, .{ .message = "feature" }, null, .{ .kind = .head, .name = "feature" }); │ │ 45 + const id = [_]u8{7} ** evt.event_id_size; │ │ 46 + const patch = evt.Patch{ .title = "branch patch", .tags = "feature", .description = "existing branch", .source_branch = "feature", .target_branch = "master" }; │ │ 47 + │ │ 48 + // submission stores the patch and revision in this repo │ │ 49 + try pch.writeBranchPatch(.local, kind, opts, io, allocator, &repo, std.fmt.bytesToHex(id, .lower), patch, null, author); │ │ 50 + const first = (try evt.Patch.readFromRepo(kind, opts, io, allocator, &arena, &repo, &id)) orelse return error.NotFound; │ │ 51 + const first_revision = first.event.revision orelse return error.NotFound; │ │ 52 + try std.testing.expectEqualStrings(&source, first_revision.source_oid); │ │ 53 + try std.testing.expectEqualStrings(&base, &(try repo.readRef(io, .{ .kind = .head, .name = "master" }) orelse return error.NotFound)); │ │ 54 + │ │ 55 + // an unchanged refresh does not add events │ │ 56 + const event_tip = (try repo.readRef(io, evt.events_ref)) orelse return error.NotFound; │ │ 57 + try pch.refreshBranches(.local, kind, opts, io, allocator, &repo, null); │ │ 58 + try std.testing.expectEqualStrings(&event_tip, &(try repo.readRef(io, evt.events_ref) orelse return error.NotFound)); │ │ 59 + │ │ 60 + // a source update replaces the revision, not the patch metadata │ │ 61 + const next = try repo.commitAtRef(io, allocator, .{ .message = "next feature" }, null, .{ .kind = .head, .name = "feature" }); │ │ 62 + try pch.refreshBranches(.local, kind, opts, io, allocator, &repo, null); │ │ 63 + const updated = (try evt.Patch.readFromRepo(kind, opts, io, allocator, &arena, &repo, &id)) orelse return error.NotFound; │ │ 64 + const revision = updated.event.revision orelse return error.NotFound; │ │ 65 + try std.testing.expectEqualStrings(&next, revision.source_oid); │ │ 66 + try std.testing.expect(!std.mem.eql(u8, &first_revision.id, &revision.id)); │ │ 67 + try std.testing.expectEqualStrings(patch.title, updated.event.title); │ │ 68 + │ │ 69 + // changing the target captures a fresh revision, without changing the source │ │ 70 + try repo.addBranch(io, .{ .name = "other-target" }); │ │ 71 + try evt.Patch.update(.local, kind, opts, io, allocator, &repo, &id, .{ .fields = .{ │ │ 72 + .title = patch.title, │ │ 73 + .tags = patch.tags, │ │ 74 + .description = patch.description, │ │ 75 + .target_branch = "other-target", │ │ 76 + } }, author); │ │ 77 + const retargeted = (try evt.Patch.readFromRepo(kind, opts, io, allocator, &arena, &repo, &id)) orelse return error.NotFound; │ │ 78 + try std.testing.expectEqualStrings("other-target", retargeted.event.target_branch); │ │ 79 + try std.testing.expectEqualStrings("feature", retargeted.event.source_branch orelse return error.NotFound); │ │ 80 + try std.testing.expect(retargeted.event.revision != null); │ │ 81 + │ │ 82 + // clearing the revision must not let a stale edit overwrite a newer patch │ │ 83 + var stale_edit = updated.event; │ │ 84 + stale_edit.revision = null; │ │ 85 + const before_stale = (try repo.readRef(io, evt.events_ref)) orelse return error.NotFound; │ │ 86 + try std.testing.expectError(error.PatchOutOfDate, pch.writeBranchPatch(.local, kind, opts, io, allocator, &repo, std.fmt.bytesToHex(id, .lower), stale_edit, updated.event, author)); │ │ 87 + try std.testing.expectEqualStrings(&before_stale, &(try repo.readRef(io, evt.events_ref) orelse return error.NotFound)); │ │ 88 + │ │ 89 + // an unrelated target leaves the patch and its revision intact │ │ 90 + _ = try repo.commitAtRef(io, allocator, .{ .message = "unrelated", .parent_oids = &.{} }, null, .{ .kind = .head, .name = "unrelated" }); │ │ 91 + const before_edit = (try repo.readRef(io, evt.events_ref)) orelse return error.NotFound; │ │ 92 + try std.testing.expectError(error.UnrelatedBranches, evt.Patch.update(.local, kind, opts, io, allocator, &repo, &id, .{ .fields = .{ │ │ 93 + .title = "edited title", │ │ 94 + .tags = patch.tags, │ │ 95 + .description = patch.description, │ │ 96 + .target_branch = "unrelated", │ │ 97 + } }, author)); │ │ 98 + try std.testing.expectEqualStrings(&before_edit, &(try repo.readRef(io, evt.events_ref) orelse return error.NotFound)); │ │ 99 + │ │100 + // missing branches fail without writing events │ │101 + var invalid = patch; │ │102 + invalid.source_branch = "missing"; │ │103 + try std.testing.expectError(error.InvalidSourceBranch, pch.writeBranchPatch(.local, kind, opts, io, allocator, &repo, std.fmt.bytesToHex(id, .lower), invalid, retargeted.event, author));│ │104 + invalid = patch; │ │105 + invalid.target_branch = "missing"; │ │106 + try std.testing.expectError(error.InvalidTargetBranch, pch.writeBranchPatch(.local, kind, opts, io, allocator, &repo, std.fmt.bytesToHex(id, .lower), invalid, retargeted.event, author));│ │107 + │ │108 + // removing the patch leaves its branch intact │ │109 + try evt.remove(.local, .repo, kind, opts, io, allocator, &repo, &id, .patch, author); │ │110 + try pch.refreshBranches(.local, kind, opts, io, allocator, &repo, null); │ │111 + const removed = (try evt.Patch.readFromRepo(kind, opts, io, allocator, &arena, &repo, &id)) orelse return error.NotFound; │ │112 + try std.testing.expect(removed.removed); │ │113 + try std.testing.expectEqualStrings(&next, &(try repo.readRef(io, .{ .kind = .head, .name = "feature" }) orelse return error.NotFound)); │ │114 + │ │115 + // an edit started before removal must not restore the patch │ │116 + stale_edit = retargeted.event; │ │117 + stale_edit.revision = null; │ │118 + const before_restore = (try repo.readRef(io, evt.events_ref)) orelse return error.NotFound; │ │119 + try std.testing.expectError(error.PatchOutOfDate, pch.writeBranchPatch(.local, kind, opts, io, allocator, &repo, std.fmt.bytesToHex(id, .lower), stale_edit, retargeted.event, author)); │ │120 + try std.testing.expectEqualStrings(&before_restore, &(try repo.readRef(io, evt.events_ref) orelse return error.NotFound)); │ │121 +} │ │122 + │ │123 +test "branch patches merge without a fork" { │ │124 + try testBranchMerge(.source); │ │125 + try testBranchMerge(.squash); │ │126 +} │ │127 + │ │128 +fn testBranchMerge(selection: pch.MergeRevision) !void { │ │129 + const io = std.testing.io; │ │130 + const allocator = std.testing.allocator; │ │131 + var temp = std.testing.tmpDir(.{}); │ │132 + defer temp.cleanup(); │ │133 + const path = try temp.dir.realPathFileAlloc(io, ".", allocator); │ │134 + defer allocator.free(path); │ │135 + var repo = try Repo.init(io, allocator, .{ .path = path }); │ │136 + defer repo.deinit(io, allocator); │ │137 + { │ │138 + const file = try repo.core.work_dir.createFile(io, "base.txt", .{}); │ │139 + defer file.close(io); │ │140 + try file.writeStreamingAll(io, "base\n"); │ │141 + } │ │142 + try repo.add(io, allocator, &.{"base.txt"}); │ │143 + _ = try repo.commit(io, allocator, .{ .message = "base" }); │ │144 + try repo.addBranch(io, .{ .name = "feature" }); │ │145 + _ = try repo.commitAtRef(io, allocator, .{ .message = "remove file" }, null, .{ .kind = .head, .name = "feature" }); │ │146 + const id = [_]u8{9} ** evt.event_id_size; │ │147 + const id_hex = std.fmt.bytesToHex(id, .lower); │ │148 + try pch.writeBranchPatch(.server, .xit, repo_opts, io, allocator, &repo, id_hex, .{ │ │149 + .title = "merge branch", │ │150 + .description = "", │ │151 + .tags = "", │ │152 + .source_branch = "feature", │ │153 + .target_branch = "master", │ │154 + }, null, author); │ │155 + var arena = std.heap.ArenaAllocator.init(allocator); │ │156 + defer arena.deinit(); │ │157 + const before = (try evt.Patch.readFromRepo(.xit, repo_opts, io, allocator, &arena, &repo, &id)) orelse return error.NotFound; │ │158 + const revision = before.event.revision orelse return error.NotFound; │ │159 + │ │160 + // both merge styles use the stored revision, without fork data │ │161 + try pch.merge(repo_opts, io, allocator, path, &repo, .{ .id = id_hex, .revision = selection, .author = author, .timestamp = 100 }); │ │162 + const merged = (try evt.Patch.readFromRepo(.xit, repo_opts, io, allocator, &arena, &repo, &id)) orelse return error.NotFound; │ │163 + const oid = switch (merged.event.status) { │ │164 + .merged => |value| value, │ │165 + else => return error.NotMerged, │ │166 + }; │ │167 + try std.testing.expectEqualStrings(if (selection == .source) revision.source_oid else revision.squash_oid, oid); │ │168 + │ │169 + // the source survives, and merged patches no longer follow it │ │170 + _ = try repo.commitAtRef(io, allocator, .{ .message = "later" }, null, .{ .kind = .head, .name = "feature" }); │ │171 + const tip = (try repo.readRef(io, evt.events_ref)) orelse return error.NotFound; │ │172 + try pch.refreshBranches(.server, .xit, repo_opts, io, allocator, &repo, null); │ │173 + try std.testing.expectEqualStrings(&tip, &(try repo.readRef(io, evt.events_ref) orelse return error.NotFound)); │ │174 +} │ │175 + │ │176 fn commitTree( │ │177 io: std.Io, │ │178 allocator: std.mem.Allocator, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯