╭ changed ───────────────╮ │src/test/event/patch.zig│ ╰────────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 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, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯