╭ patch.zig ────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const evt = @import("../../event.zig"); 3 const xit = @import("xit"); 4 const rp = xit.repo; 5 const obj = xit.object; 6 const hash = xit.hash; 7 const rf = xit.ref; 8 const fork = @import("../../fork.zig"); 9 const pch = @import("../../patch.zig"); 10 const push = @import("../../push.zig"); 11 12 const repo_opts: rp.RepoOpts(.xit) = .{ .is_test = true }; 13 const Repo = rp.Repo(.xit, repo_opts); 14 const author = evt.CommitAuthor{ .name = "alice", .email = "alice@example.test" }; 15 16 test "patch commit counts require a short first-parent path" { 17 const io = std.testing.io; 18 const allocator = std.testing.allocator; 19 var temp = std.testing.tmpDir(.{}); 20 defer temp.cleanup(); 21 const path = try temp.dir.realPathFileAlloc(io, ".", allocator); 22 defer allocator.free(path); 23 var repo = try Repo.init(io, allocator, .{ .path = path }); 24 defer repo.deinit(io, allocator); 25 const branch = rf.Ref{ .kind = .head, .name = "patch" }; 26 27 // a second-parent base must not produce a misleading depth difference 28 const root = try repo.commitAtRef(io, allocator, .{ .message = "root" }, null, branch); 29 const base = try repo.commitAtRef(io, allocator, .{ .message = "base" }, null, branch); 30 const source = try repo.commitAtRef(io, allocator, .{ .message = "source", .parent_oids = &.{root} }, null, branch); 31 var tip = try repo.commitAtRef(io, allocator, .{ .message = "merge", .parent_oids = &.{ source, base } }, null, branch); 32 var moment = try repo.core.latestMoment(); 33 const state = Repo.State(.read_only){ .core = &repo.core, .extra = .{ .moment = &moment } }; 34 try std.testing.expectEqual(null, try evt.PatchRev.commitCount(.xit, repo_opts, state, io, allocator, &base, &tip)); 35 try std.testing.expectEqual(@as(?u64, 2), try evt.PatchRev.commitCount(.xit, repo_opts, state, io, allocator, &root, &tip)); 36 try std.testing.expectEqual(null, try evt.PatchRev.commitCount(.xit, repo_opts, state, io, allocator, &tip, &root)); 37 try std.testing.expectEqual(null, try evt.PatchRev.commitCount(.xit, repo_opts, state, io, allocator, &base, &source)); 38 39 // long ranges omit the count instead of walking unbounded history 40 for (2..256) |_| { 41 tip = try repo.commitAtRef(io, allocator, .{ .message = "next" }, null, branch); 42 } 43 moment = try repo.core.latestMoment(); 44 try std.testing.expectEqual(@as(?u64, 256), try evt.PatchRev.commitCount(.xit, repo_opts, state, io, allocator, &root, &tip)); 45 tip = try repo.commitAtRef(io, allocator, .{ .message = "next" }, null, branch); 46 moment = try repo.core.latestMoment(); 47 try std.testing.expectEqual(null, try evt.PatchRev.commitCount(.xit, repo_opts, state, io, allocator, &root, &tip)); 48 } 49 50 test "branch patch lifecycle" { 51 try testBranchPatch(.git, .sha1); 52 try testBranchPatch(.git, .sha256); 53 try testBranchPatch(.xit, .sha1); 54 try testBranchPatch(.xit, .sha256); 55 } 56 57 fn testBranchPatch(comptime kind: rp.RepoKind, comptime hash_kind: hash.HashKind) !void { 58 const io = std.testing.io; 59 const allocator = std.testing.allocator; 60 const opts: rp.RepoOpts(kind) = .{ .is_test = true, .hash = hash_kind }; 61 var temp = std.testing.tmpDir(.{}); 62 defer temp.cleanup(); 63 const path = try temp.dir.realPathFileAlloc(io, ".", allocator); 64 defer allocator.free(path); 65 var repo = try rp.Repo(kind, opts).init(io, allocator, .{ .path = path }); 66 defer repo.deinit(io, allocator); 67 var arena = std.heap.ArenaAllocator.init(allocator); 68 defer arena.deinit(); 69 70 // create two branches with a shared base 71 { 72 const file = try repo.core.work_dir.createFile(io, "base.txt", .{}); 73 defer file.close(io); 74 try file.writeStreamingAll(io, "base\n"); 75 } 76 try repo.add(io, allocator, &.{"base.txt"}); 77 const base = try repo.commit(io, allocator, .{ .message = "base" }); 78 try repo.addBranch(io, .{ .name = "feature" }); 79 const id = [_]u8{7} ** evt.event_id_size; 80 const patch = evt.Patch{ .title = "branch patch", .tags = "feature", .description = "existing branch", .source_branch = "feature", .target_branch = "master" }; 81 82 // the same branch cannot be both source and target 83 { 84 var invalid = patch; 85 invalid.target_branch = "feature"; 86 try std.testing.expectError(error.SameBranch, pch.writeBranchPatch(.local, kind, opts, io, allocator, &repo, std.fmt.bytesToHex(id, .lower), invalid, null, author)); 87 try std.testing.expectEqual(null, try repo.readRef(io, evt.events_ref)); 88 } 89 90 // distinct branches at the same commit can still create a patch 91 try pch.writeBranchPatch(.local, kind, opts, io, allocator, &repo, std.fmt.bytesToHex(id, .lower), patch, null, author); 92 const first = (try evt.Patch.readFromRepo(kind, opts, io, allocator, &arena, &repo, &id)) orelse return error.NotFound; 93 const first_revision = first.event.revision orelse return error.NotFound; 94 try std.testing.expectEqualStrings(&base, first_revision.source_oid); 95 try std.testing.expectEqual(@as(?u64, if (kind == .xit) 0 else null), first_revision.commit_count); 96 try std.testing.expectEqualStrings(&base, &(try repo.readRef(io, .{ .kind = .head, .name = "master" }) orelse return error.NotFound)); 97 98 // an unchanged refresh does not add events 99 const event_tip = (try repo.readRef(io, evt.events_ref)) orelse return error.NotFound; 100 try pch.refreshBranches(.local, kind, opts, io, allocator, &repo, null, null); 101 try std.testing.expectEqualStrings(&event_tip, &(try repo.readRef(io, evt.events_ref) orelse return error.NotFound)); 102 103 // retargeting to the source leaves the patch and revision unchanged 104 try std.testing.expectError(error.SameBranch, evt.Patch.update(.local, kind, opts, io, allocator, &repo, &id, .{ .fields = .{ 105 .title = patch.title, 106 .tags = patch.tags, 107 .description = patch.description, 108 .target_branch = "feature", 109 } }, author)); 110 try std.testing.expectEqualStrings(&event_tip, &(try repo.readRef(io, evt.events_ref) orelse return error.NotFound)); 111 112 // a source update replaces the revision, not the patch metadata 113 const next = try repo.commitAtRef(io, allocator, .{ .message = "next feature" }, null, .{ .kind = .head, .name = "feature" }); 114 try pch.refreshBranches(.local, kind, opts, io, allocator, &repo, null, null); 115 const updated = (try evt.Patch.readFromRepo(kind, opts, io, allocator, &arena, &repo, &id)) orelse return error.NotFound; 116 const revision = updated.event.revision orelse return error.NotFound; 117 try std.testing.expectEqualStrings(&next, revision.source_oid); 118 try std.testing.expectEqual(@as(?u64, if (kind == .xit) 1 else null), revision.commit_count); 119 try std.testing.expect(!std.mem.eql(u8, &first_revision.id, &revision.id)); 120 try std.testing.expectEqualStrings(patch.title, updated.event.title); 121 122 // changing the target captures a fresh revision, without changing the source 123 try repo.addBranch(io, .{ .name = "other-target" }); 124 try evt.Patch.update(.local, kind, opts, io, allocator, &repo, &id, .{ .fields = .{ 125 .title = patch.title, 126 .tags = patch.tags, 127 .description = patch.description, 128 .target_branch = "other-target", 129 } }, author); 130 const retargeted = (try evt.Patch.readFromRepo(kind, opts, io, allocator, &arena, &repo, &id)) orelse return error.NotFound; 131 try std.testing.expectEqualStrings("other-target", retargeted.event.target_branch); 132 try std.testing.expectEqualStrings("feature", retargeted.event.source_branch orelse return error.NotFound); 133 try std.testing.expect(retargeted.event.revision != null); 134 135 // clearing the revision must not let a stale edit overwrite a newer patch 136 var stale_edit = updated.event; 137 stale_edit.revision = null; 138 const before_stale = (try repo.readRef(io, evt.events_ref)) orelse return error.NotFound; 139 try std.testing.expectError(error.PatchOutOfDate, pch.writeBranchPatch(.local, kind, opts, io, allocator, &repo, std.fmt.bytesToHex(id, .lower), stale_edit, updated.event, author)); 140 try std.testing.expectEqualStrings(&before_stale, &(try repo.readRef(io, evt.events_ref) orelse return error.NotFound)); 141 142 // an unrelated target leaves the patch and its revision intact 143 _ = try repo.commitAtRef(io, allocator, .{ .message = "unrelated", .parent_oids = &.{} }, null, .{ .kind = .head, .name = "unrelated" }); 144 const before_edit = (try repo.readRef(io, evt.events_ref)) orelse return error.NotFound; 145 try std.testing.expectError(error.UnrelatedBranches, evt.Patch.update(.local, kind, opts, io, allocator, &repo, &id, .{ .fields = .{ 146 .title = "edited title", 147 .tags = patch.tags, 148 .description = patch.description, 149 .target_branch = "unrelated", 150 } }, author)); 151 try std.testing.expectEqualStrings(&before_edit, &(try repo.readRef(io, evt.events_ref) orelse return error.NotFound)); 152 153 // missing branches fail without writing events 154 var invalid = patch; 155 invalid.source_branch = "missing"; 156 try std.testing.expectError(error.InvalidSourceBranch, pch.writeBranchPatch(.local, kind, opts, io, allocator, &repo, std.fmt.bytesToHex(id, .lower), invalid, retargeted.event, author)); 157 invalid = patch; 158 invalid.target_branch = "missing"; 159 try std.testing.expectError(error.InvalidTargetBranch, pch.writeBranchPatch(.local, kind, opts, io, allocator, &repo, std.fmt.bytesToHex(id, .lower), invalid, retargeted.event, author)); 160 161 // removing the patch leaves its branch intact 162 try evt.remove(.local, .repo, kind, opts, io, allocator, &repo, &id, .patch, author); 163 try pch.refreshBranches(.local, kind, opts, io, allocator, &repo, null, null); 164 const removed = (try evt.Patch.readFromRepo(kind, opts, io, allocator, &arena, &repo, &id)) orelse return error.NotFound; 165 try std.testing.expect(removed.removed); 166 try std.testing.expectEqualStrings(&next, &(try repo.readRef(io, .{ .kind = .head, .name = "feature" }) orelse return error.NotFound)); 167 168 // an edit started before removal must not restore the patch 169 stale_edit = retargeted.event; 170 stale_edit.revision = null; 171 const before_restore = (try repo.readRef(io, evt.events_ref)) orelse return error.NotFound; 172 try std.testing.expectError(error.PatchOutOfDate, pch.writeBranchPatch(.local, kind, opts, io, allocator, &repo, std.fmt.bytesToHex(id, .lower), stale_edit, retargeted.event, author)); 173 try std.testing.expectEqualStrings(&before_restore, &(try repo.readRef(io, evt.events_ref) orelse return error.NotFound)); 174 } 175 176 test "branch patches merge without a fork" { 177 try testBranchMerge(.source); 178 try testBranchMerge(.squash); 179 } 180 181 fn testBranchMerge(selection: evt.Patch.MergeRevision) !void { 182 const io = std.testing.io; 183 const allocator = std.testing.allocator; 184 const opts: rp.RepoOpts(.xit) = .{ .is_test = true, .ProgressCtx = *push.PushProgress }; 185 var temp = std.testing.tmpDir(.{}); 186 defer temp.cleanup(); 187 const path = try temp.dir.realPathFileAlloc(io, ".", allocator); 188 defer allocator.free(path); 189 var repo = try rp.Repo(.xit, opts).init(io, allocator, .{ .path = path }); 190 defer repo.deinit(io, allocator); 191 { 192 const file = try repo.core.work_dir.createFile(io, "base.txt", .{}); 193 defer file.close(io); 194 try file.writeStreamingAll(io, "base\n"); 195 } 196 try repo.add(io, allocator, &.{"base.txt"}); 197 _ = try repo.commit(io, allocator, .{ .message = "base" }); 198 try repo.addBranch(io, .{ .name = "feature" }); 199 _ = try repo.commitAtRef(io, allocator, .{ .message = "remove file" }, null, .{ .kind = .head, .name = "feature" }); 200 const id = [_]u8{9} ** evt.event_id_size; 201 const id_hex = std.fmt.bytesToHex(id, .lower); 202 try pch.writeBranchPatch(.server, .xit, opts, io, allocator, &repo, id_hex, .{ 203 .title = "merge branch", 204 .description = "", 205 .tags = "", 206 .source_branch = "feature", 207 .target_branch = "master", 208 }, null, author); 209 210 // updating a branch and checking its mergeability counts as one patch 211 _ = try repo.commitAtRef(io, allocator, .{ .message = "revise feature" }, null, .{ .kind = .head, .name = "feature" }); 212 var response = xit.net_server_receive_pack.Response.init(allocator); 213 defer response.deinit(); 214 response.sideband = true; 215 var output = std.Io.Writer.Allocating.init(allocator); 216 defer output.deinit(); 217 var progress = push.PushProgress{ .response = &response, .writer = &output.writer, .label = "Updating patches" }; 218 try pch.refreshBranches(.server, .xit, opts, io, allocator, &repo, null, &progress); 219 try std.testing.expect(std.mem.indexOf(u8, output.written(), "Updating patches: 100% (1/1)\n") != null); 220 221 // closed patches skip source updates and contribute no progress 222 try evt.Patch.update(.server, .xit, opts, io, allocator, &repo, &id, .{ .status = .closed }, author); 223 const next = try repo.commitAtRef(io, allocator, .{ .message = "revise while closed" }, null, .{ .kind = .head, .name = "feature" }); 224 const closed_tip = (try repo.readRef(io, evt.events_ref)) orelse return error.NotFound; 225 const closed_progress_len = output.written().len; 226 try pch.refreshBranches(.server, .xit, opts, io, allocator, &repo, null, &progress); 227 try std.testing.expectEqualStrings(&closed_tip, &(try repo.readRef(io, evt.events_ref) orelse return error.NotFound)); 228 try std.testing.expectEqual(closed_progress_len, output.written().len); 229 230 // reopening captures the latest source and checks both merge styles 231 try evt.Patch.update(.server, .xit, opts, io, allocator, &repo, &id, .{ .status = .open }, author); 232 var arena = std.heap.ArenaAllocator.init(allocator); 233 defer arena.deinit(); 234 const before = (try evt.Patch.readFromRepo(.xit, opts, io, allocator, &arena, &repo, &id)) orelse return error.NotFound; 235 const revision = before.event.revision orelse return error.NotFound; 236 try std.testing.expectEqualStrings(&next, revision.source_oid); 237 { 238 var moment = try repo.core.latestMoment(); 239 const result = try pch.readMergeability(opts, .{ .core = &repo.core, .extra = .{ .moment = &moment } }, io, &arena, &id, before.event); 240 try std.testing.expectEqualDeep(pch.Mergeability{ .source = .clean, .squash = .clean }, result); 241 } 242 243 // a diverged target produces a merge commit in both merge styles 244 { 245 const file = try repo.core.work_dir.createFile(io, "target.txt", .{}); 246 defer file.close(io); 247 try file.writeStreamingAll(io, "target only\n"); 248 } 249 try repo.add(io, allocator, &.{"target.txt"}); 250 const target_oid = try repo.commit(io, allocator, .{ .message = "advance target" }); 251 pch.refreshMergeability(opts, io, allocator, &repo, id); 252 253 // both merge styles use the stored revision, without fork data 254 try pch.merge(opts, io, allocator, path, &repo, .{ .id = id_hex, .revision = selection, .author = author, .timestamp = 100 }); 255 const merged = (try evt.Patch.readFromRepo(.xit, opts, io, allocator, &arena, &repo, &id)) orelse return error.NotFound; 256 const range = switch (merged.event.status) { 257 .merged => |value| value, 258 else => return error.NotMerged, 259 }; 260 const result_oid = (try repo.readRef(io, .{ .kind = .head, .name = "master" })) orelse return error.NotFound; 261 try std.testing.expectEqual(selection, range.revision); 262 try std.testing.expectEqualStrings(&target_oid, range.before_oid); 263 try std.testing.expectEqualStrings(&result_oid, range.after_oid); 264 { 265 var commits = try repo.log(io, allocator, .{ .start_oids = &.{result_oid} }); 266 defer commits.deinit(); 267 const commit = (try commits.next(allocator)) orelse return error.NotFound; 268 defer commit.deinit(); 269 const parents = commit.content.commit.metadata.parent_oids orelse return error.ParentOidsNotFound; 270 try std.testing.expectEqual(2, parents.len); 271 try std.testing.expectEqualStrings(&target_oid, &parents[0]); 272 try std.testing.expectEqualStrings(if (selection == .source) revision.source_oid else revision.squash_oid, &parents[1]); 273 } 274 275 // the source survives, and merged patches no longer follow it 276 _ = try repo.commitAtRef(io, allocator, .{ .message = "later" }, null, .{ .kind = .head, .name = "feature" }); 277 const tip = (try repo.readRef(io, evt.events_ref)) orelse return error.NotFound; 278 const progress_len = output.written().len; 279 try pch.refreshBranches(.server, .xit, opts, io, allocator, &repo, null, &progress); 280 try std.testing.expectEqual(progress_len, output.written().len); 281 try std.testing.expectEqualStrings(&tip, &(try repo.readRef(io, evt.events_ref) orelse return error.NotFound)); 282 } 283 284 fn commitTree( 285 io: std.Io, 286 allocator: std.mem.Allocator, 287 repo: *Repo, 288 oid: *const [hash.hexLen(repo_opts.hash)]u8, 289 ) ![hash.hexLen(repo_opts.hash)]u8 { 290 var moment = try repo.core.latestMoment(); 291 const state = Repo.State(.read_only){ .core = &repo.core, .extra = .{ .moment = &moment } }; 292 var object = try obj.Object(.xit, repo_opts).init(state, io, allocator, oid); 293 defer object.deinit(); 294 return switch (object.content) { 295 .commit => |commit| commit.tree, 296 else => error.InvalidObject, 297 }; 298 } 299 300 fn consumePatchWithRevision( 301 comptime role: evt.RepoRole, 302 io: std.Io, 303 allocator: std.mem.Allocator, 304 repo: *Repo, 305 ref: rf.Ref, 306 revision_id: [evt.event_id_size]u8, 307 revision: evt.PatchRev, 308 tree_entries: []const evt.EventTreeEntry, 309 revision_timestamp: u64, 310 patch_id: [evt.event_id_size]u8, 311 patch_value: evt.Patch, 312 patch_timestamp: u64, 313 ) !void { 314 try evt.consume(.local, role, .xit, repo_opts, io, allocator, repo, ref, &.{.{ 315 .id = std.fmt.bytesToHex(revision_id, .lower), 316 .timestamp = revision_timestamp, 317 .author = author, 318 .tree_entries = tree_entries, 319 .event = .{ .patchrev = revision }, 320 }}); 321 322 var arena = std.heap.ArenaAllocator.init(allocator); 323 defer arena.deinit(); 324 const moment = try evt.currentMoment(repo_opts, repo); 325 const record = (try evt.PatchRev.readById(Repo.DB, repo_opts.hash, moment, &arena, &revision_id)) orelse return error.NotFound; 326 var patch = patch_value; 327 patch.revision = evt.Patch.Revision.fromRecord(revision_id, record); 328 try evt.consume(.local, role, .xit, repo_opts, io, allocator, repo, ref, &.{.{ 329 .id = std.fmt.bytesToHex(patch_id, .lower), 330 .timestamp = patch_timestamp, 331 .author = author, 332 .event = .{ .patch = patch }, 333 }}); 334 } 335 336 test "patch event conflicts, stacking, and gc" { 337 const io = std.testing.io; 338 const allocator = std.testing.allocator; 339 340 // create the temp dir 341 var temp = std.testing.tmpDir(.{}); 342 defer temp.cleanup(); 343 const temp_path = try temp.dir.realPathFileAlloc(io, ".", allocator); 344 defer allocator.free(temp_path); 345 346 const upstream_path = try std.fs.path.join(allocator, &.{ temp_path, "upstream" }); 347 defer allocator.free(upstream_path); 348 const target_path = try std.fs.path.join(allocator, &.{ temp_path, "target" }); 349 defer allocator.free(target_path); 350 const source_path = try std.fs.path.join(allocator, &.{ temp_path, "source" }); 351 defer allocator.free(source_path); 352 353 // 354 // create the upstream repo and its base commit 355 // 356 357 const base_oid = blk: { 358 var upstream = try rp.Repo(.git, .{ .is_test = true }).init(io, allocator, .{ .path = upstream_path }); 359 defer upstream.deinit(io, allocator); 360 const file = try upstream.core.work_dir.createFile(io, "main.zig", .{ .truncate = true }); 361 defer file.close(io); 362 try file.writeStreamingAll(io, "pub fn main() void {}\n"); 363 try upstream.add(io, allocator, &.{"main.zig"}); 364 break :blk try upstream.commit(io, allocator, .{ .author = "alice <alice@example.test>", .message = "initial code", .timestamp = 1 }); 365 }; 366 367 var target = try Repo.clone(io, allocator, upstream_path, target_path, target_path, null, .{}); 368 defer target.deinit(io, allocator); 369 const base_tree_oid = try commitTree(io, allocator, &target, &base_oid); 370 371 // 372 // fork the target and commit the proposed change 373 // 374 375 var source = try Repo.clone(io, allocator, upstream_path, source_path, source_path, null, .{}); 376 defer source.deinit(io, allocator); 377 378 const source_oid = blk: { 379 const file = try source.core.work_dir.createFile(io, "feature.zig", .{ .truncate = true }); 380 defer file.close(io); 381 try file.writeStreamingAll(io, "pub const answer = 42;\n"); 382 try source.add(io, allocator, &.{"feature.zig"}); 383 break :blk try source.commit(io, allocator, .{ .author = "alice <alice@example.test>", .message = "add feature", .timestamp = 2 }); 384 }; 385 const head_tree_oid = try commitTree(io, allocator, &source, &source_oid); 386 387 // 388 // copy the proposed objects without creating a target ref 389 // 390 391 { 392 var source_moment = try source.core.latestMoment(); 393 const source_state = Repo.State(.read_only){ .core = &source.core, .extra = .{ .moment = &source_moment } }; 394 var objects = try obj.ObjectIterator(.xit, repo_opts).init(source_state, io, allocator, .{ .kind = .all }); 395 defer objects.deinit(); 396 try objects.include(&source_oid); 397 try target.copyObjects(.xit, repo_opts, &objects, io, null); 398 } 399 400 // 401 // define the patch revision and patch 402 // 403 404 var prng = std.Random.DefaultPrng.init(std.testing.random_seed); 405 const patchrev_id = evt.EventWithId.randomId(prng.random()); 406 const patch_id = evt.EventWithId.randomId(prng.random()); 407 const patch = evt.Patch{ 408 .title = "add the answer", 409 .description = "adds a reusable answer constant", 410 .tags = "enhancement", 411 .target_branch = "master", 412 }; 413 const tree_entries = [_]evt.EventTreeEntry{ 414 .{ .tree = .{ .name = "base", .oid = &base_tree_oid } }, 415 .{ .tree = .{ .name = "head", .oid = &head_tree_oid } }, 416 }; 417 418 // reject invalid branches even when the event bypasses patch creation 419 { 420 const cases = [_]struct { source: ?[]const u8, target: []const u8, expected_error: anyerror }{ 421 .{ .source = "master", .target = "master", .expected_error = error.SameBranch }, 422 .{ .source = null, .target = evt.events_ref.name, .expected_error = error.InvalidTargetBranch }, 423 }; 424 for (cases) |case| { 425 var invalid = patch; 426 invalid.source_branch = case.source; 427 invalid.target_branch = case.target; 428 try std.testing.expectError(case.expected_error, evt.consume(.local, .repo, .xit, repo_opts, io, allocator, &target, evt.events_ref, &.{.{ 429 .id = std.fmt.bytesToHex(patch_id, .lower), 430 .timestamp = 3, 431 .author = author, 432 .event = .{ .patch = invalid }, 433 }})); 434 try std.testing.expectEqual(null, try target.readRef(io, evt.events_ref)); 435 } 436 } 437 438 // 439 // consume the patch revision and patch 440 // 441 442 { 443 try consumePatchWithRevision( 444 .repo, 445 io, 446 allocator, 447 &target, 448 evt.events_ref, 449 patchrev_id, 450 .{ .base_oid = &base_oid, .source_oid = &source_oid, .message = patch.title }, 451 &tree_entries, 452 3, 453 patch_id, 454 patch, 455 4, 456 ); 457 } 458 459 // 460 // check the records and squash commit 461 // 462 463 var arena = std.heap.ArenaAllocator.init(allocator); 464 defer arena.deinit(); 465 const moment = try evt.currentMoment(repo_opts, &target); 466 const patch_record = (try evt.Patch.readById(Repo.DB, repo_opts.hash, moment, &arena, &patch_id)) orelse return error.NotFound; 467 const patchrev_record = (try evt.PatchRev.readById(Repo.DB, repo_opts.hash, moment, &arena, &patchrev_id)) orelse return error.NotFound; 468 const records_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, evt.Patch.record_map_key)) orelse return error.NotFound; 469 const records = try Repo.DB.HashMap(.read_only).init(records_cursor); 470 const record_cursor = try records.getCursor(hash.hashInt(repo_opts.hash, &patch_id)) orelse return error.NotFound; 471 const record_map = try Repo.DB.HashMap(.read_only).init(record_cursor); 472 const event_cursor = try record_map.getCursor(hash.hashInt(repo_opts.hash, "event")) orelse return error.NotFound; 473 const event_map = try Repo.DB.HashMap(.read_only).init(event_cursor); 474 const revision_cursor = try event_map.getCursor(hash.hashInt(repo_opts.hash, "revision")) orelse return error.NotFound; 475 const revision_map = try Repo.DB.HashMap(.read_only).init(revision_cursor); 476 try std.testing.expect(null != try revision_map.getCursor(hash.hashInt(repo_opts.hash, "id"))); 477 try std.testing.expectEqual(null, try record_map.getCursor(hash.hashInt(repo_opts.hash, "title"))); 478 try std.testing.expectEqualStrings(&base_tree_oid, patchrev_record.base_tree_oid); 479 try std.testing.expectEqualStrings(&head_tree_oid, patchrev_record.head_tree_oid); 480 try std.testing.expectEqualStrings(&std.fmt.bytesToHex(patchrev_id, .lower), &(patch_record.event.revision orelse return error.NotFound).id); 481 482 var initial_patch_oid: [hash.hexLen(repo_opts.hash)]u8 = undefined; 483 @memcpy(&initial_patch_oid, patchrev_record.patch_oid); 484 { 485 var repo_moment = try target.core.latestMoment(); 486 const state = Repo.State(.read_only){ .core = &target.core, .extra = .{ .moment = &repo_moment } }; 487 var patch_object = try obj.Object(.xit, repo_opts).init(state, io, allocator, &initial_patch_oid); 488 defer patch_object.deinit(); 489 switch (patch_object.content) { 490 .commit => |commit| { 491 try std.testing.expectEqualStrings(&head_tree_oid, &commit.tree); 492 const parents = commit.metadata.parent_oids orelse return error.ParentOidsNotFound; 493 try std.testing.expectEqual(1, parents.len); 494 try std.testing.expectEqualStrings(&base_oid, &parents[0]); 495 }, 496 else => return error.InvalidObject, 497 } 498 } 499 500 // 501 // update metadata without creating another patch revision 502 // 503 504 var updated_patch = patch; 505 updated_patch.revision = .{ 506 .id = std.fmt.bytesToHex(patchrev_id, .lower), 507 .squash_oid = &initial_patch_oid, 508 .source_oid = &source_oid, 509 }; 510 updated_patch.title = "add a reusable answer"; 511 { 512 try evt.consume(.local, .repo, .xit, repo_opts, io, allocator, &target, evt.events_ref, &.{.{ 513 .id = std.fmt.bytesToHex(patch_id, .lower), 514 .timestamp = 5, 515 .author = author, 516 .event = .{ .patch = updated_patch }, 517 }}); 518 } 519 520 _ = arena.reset(.retain_capacity); 521 const updated_moment = try evt.currentMoment(repo_opts, &target); 522 const updated = (try evt.Patch.readById(Repo.DB, repo_opts.hash, updated_moment, &arena, &patch_id)) orelse return error.NotFound; 523 try std.testing.expectEqualStrings("add a reusable answer", updated.event.title); 524 525 // 526 // branch the events history before creating competing revisions 527 // 528 529 const side_events_ref: rf.Ref = .{ .kind = .head, .name = "haxy/patch-side" }; 530 { 531 var result = try target.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 532 defer result.deinit(); 533 try target.addBranch(io, .{ .name = side_events_ref.name }); 534 } 535 536 // 537 // create two competing code revisions 538 // 539 540 const source_a_oid = blk: { 541 const file = try source.core.work_dir.createFile(io, "feature.zig", .{ .truncate = true }); 542 defer file.close(io); 543 try file.writeStreamingAll(io, "pub const answer = 43;\n"); 544 try source.add(io, allocator, &.{"feature.zig"}); 545 break :blk try source.commit(io, allocator, .{ .author = "alice <alice@example.test>", .message = "revise feature one way", .timestamp = 6 }); 546 }; 547 const source_a_tree = try commitTree(io, allocator, &source, &source_a_oid); 548 const source_b_oid = blk: { 549 const file = try source.core.work_dir.createFile(io, "feature.zig", .{ .truncate = true }); 550 defer file.close(io); 551 try file.writeStreamingAll(io, "pub const answer = 44;\n"); 552 try source.add(io, allocator, &.{"feature.zig"}); 553 break :blk try source.commit(io, allocator, .{ .author = "alice <alice@example.test>", .message = "revise feature another way", .timestamp = 7 }); 554 }; 555 const source_b_tree = try commitTree(io, allocator, &source, &source_b_oid); 556 { 557 var source_moment = try source.core.latestMoment(); 558 const source_state = Repo.State(.read_only){ .core = &source.core, .extra = .{ .moment = &source_moment } }; 559 var objects = try obj.ObjectIterator(.xit, repo_opts).init(source_state, io, allocator, .{ .kind = .all }); 560 defer objects.deinit(); 561 try objects.include(&source_b_oid); 562 try target.copyObjects(.xit, repo_opts, &objects, io, null); 563 } 564 565 // 566 // select the first revision on the events branch 567 // 568 569 const patchrev_a_id = evt.EventWithId.randomId(prng.random()); 570 const target_entries = [_]evt.EventTreeEntry{ 571 .{ .tree = .{ .name = "base", .oid = &base_tree_oid } }, 572 .{ .tree = .{ .name = "head", .oid = &source_a_tree } }, 573 }; 574 var target_patch = updated_patch; 575 target_patch.status = .closed; 576 { 577 try consumePatchWithRevision(.repo, io, allocator, &target, evt.events_ref, patchrev_a_id, .{ 578 .base_oid = &base_oid, 579 .source_oid = &source_a_oid, 580 .message = updated_patch.title, 581 }, &target_entries, 8, patch_id, target_patch, 9); 582 } 583 584 // 585 // select the second revision on the side branch 586 // 587 588 const patchrev_b_id = evt.EventWithId.randomId(prng.random()); 589 const parent_entries = [_]evt.EventTreeEntry{ 590 .{ .tree = .{ .name = "base", .oid = &base_tree_oid } }, 591 .{ .tree = .{ .name = "head", .oid = &source_b_tree } }, 592 }; 593 var parent_patch = updated_patch; 594 parent_patch.status = .{ .merged = .{ .revision = .source, .before_oid = &base_oid, .after_oid = &source_b_oid } }; 595 { 596 try consumePatchWithRevision(.repo, io, allocator, &target, side_events_ref, patchrev_b_id, .{ 597 .base_oid = &base_oid, 598 .source_oid = &source_b_oid, 599 .message = updated_patch.title, 600 }, &parent_entries, 10, patch_id, parent_patch, 11); 601 } 602 603 // 604 // merge the competing patch revisions 605 // 606 607 { 608 try evt.mergeEvents(.xit, repo_opts, io, allocator, &target, side_events_ref); 609 try evt.consume(.local, .repo, .xit, repo_opts, io, allocator, &target, evt.events_ref, &.{}); 610 } 611 612 // 613 // check the selected revision and conflict 614 // 615 616 _ = arena.reset(.retain_capacity); 617 const merged_moment = try evt.currentMoment(repo_opts, &target); 618 const merged = (try evt.Patch.readById(Repo.DB, repo_opts.hash, merged_moment, &arena, &patch_id)) orelse return error.NotFound; 619 try std.testing.expectEqualStrings(&std.fmt.bytesToHex(patchrev_a_id, .lower), &(merged.event.revision orelse return error.NotFound).id); 620 const merged_patchrev = (try evt.PatchRev.readById(Repo.DB, repo_opts.hash, merged_moment, &arena, &patchrev_a_id)) orelse return error.NotFound; 621 try std.testing.expectEqualStrings(&source_a_oid, merged_patchrev.event.source_oid); 622 try std.testing.expectEqualStrings(&source_a_tree, merged_patchrev.head_tree_oid); 623 624 const conflicts_cursor = try merged_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Patch.conflicts_key)) orelse return error.NotFound; 625 const conflicts = try Repo.DB.SortedMap(.read_only).init(conflicts_cursor); 626 const conflict_cursor = try conflicts.getCursor(&evt.orderKeyDesc(merged.created_order, &patch_id)) orelse return error.NotFound; 627 const conflict = try Repo.DB.HashMap(.read_only).init(conflict_cursor); 628 const fields_cursor = try conflict.getCursor(hash.hashInt(repo_opts.hash, evt.conflicted_fields_key)) orelse return error.NotFound; 629 try std.testing.expectEqualStrings("revision status", try fields_cursor.readBytesAlloc(arena.allocator(), null)); 630 const their_cursor = try conflict.getCursor(hash.hashInt(repo_opts.hash, evt.their_record_key)) orelse return error.NotFound; 631 const theirs = try evt.read(evt.Patch.Record, Repo.DB, repo_opts.hash, &arena, try Repo.DB.HashMap(.read_only).init(their_cursor)); 632 try std.testing.expectEqualStrings(&std.fmt.bytesToHex(patchrev_b_id, .lower), &(theirs.event.revision orelse return error.NotFound).id); 633 634 // 635 // create a patch stacked on the selected revision 636 // 637 638 const child_patchrev_id = evt.EventWithId.randomId(prng.random()); 639 const child_id = evt.EventWithId.randomId(prng.random()); 640 const child_entries = [_]evt.EventTreeEntry{ 641 .{ .tree = .{ .name = "base", .oid = merged_patchrev.head_tree_oid } }, 642 .{ .tree = .{ .name = "head", .oid = &source_b_tree } }, 643 }; 644 { 645 try consumePatchWithRevision(.repo, io, allocator, &target, evt.events_ref, child_patchrev_id, .{ 646 .base_oid = merged_patchrev.patch_oid, 647 .source_oid = &source_b_oid, 648 .message = "stack another answer change", 649 }, &child_entries, 12, child_id, .{ 650 .title = "stack another answer change", 651 .description = "depends on the first patch", 652 .tags = "enhancement", 653 .target_branch = "master", 654 .target_patch_id = std.fmt.bytesToHex(patch_id, .lower), 655 }, 13); 656 } 657 658 // 659 // check the squash commit and reverse index 660 // 661 662 _ = arena.reset(.retain_capacity); 663 const stacked_moment = try evt.currentMoment(repo_opts, &target); 664 const child = (try evt.Patch.readById(Repo.DB, repo_opts.hash, stacked_moment, &arena, &child_id)) orelse return error.NotFound; 665 var selected_patchrev_id: [evt.event_id_size]u8 = undefined; 666 _ = try std.fmt.hexToBytes(&selected_patchrev_id, &(child.event.revision orelse return error.NotFound).id); 667 const child_patchrev = (try evt.PatchRev.readById(Repo.DB, repo_opts.hash, stacked_moment, &arena, &selected_patchrev_id)) orelse return error.NotFound; 668 var child_oid: [hash.hexLen(repo_opts.hash)]u8 = undefined; 669 @memcpy(&child_oid, child_patchrev.patch_oid); 670 { 671 var child_repo_moment = try target.core.latestMoment(); 672 const child_state = Repo.State(.read_only){ .core = &target.core, .extra = .{ .moment = &child_repo_moment } }; 673 var child_object = try obj.Object(.xit, repo_opts).init(child_state, io, allocator, &child_oid); 674 defer child_object.deinit(); 675 const child_commit = switch (child_object.content) { 676 .commit => |commit| commit, 677 else => return error.InvalidObject, 678 }; 679 const child_parents = child_commit.metadata.parent_oids orelse return error.ParentOidsNotFound; 680 try std.testing.expectEqualStrings(child_patchrev.event.base_oid, &child_parents[0]); 681 } 682 683 const targets_cursor = try stacked_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Patch.target_patch_id_to_patch_id_set_key)) orelse return error.NotFound; 684 const targets = try Repo.DB.HashMap(.read_only).init(targets_cursor); 685 const children_cursor = try targets.getCursor(hash.hashInt(repo_opts.hash, &patch_id)) orelse return error.NotFound; 686 const children = try Repo.DB.SortedSet(.read_only).init(children_cursor); 687 try std.testing.expectEqual(1, try children.count()); 688 689 // 690 // close and merge the same revision concurrently 691 // 692 693 const status_side_ref: rf.Ref = .{ .kind = .head, .name = "haxy/patch-status-side" }; 694 { 695 var result = try target.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 696 defer result.deinit(); 697 try target.addBranch(io, .{ .name = status_side_ref.name }); 698 } 699 var closed_child = child.event; 700 closed_child.status = .closed; 701 try evt.consume(.local, .repo, .xit, repo_opts, io, allocator, &target, evt.events_ref, &.{.{ 702 .id = std.fmt.bytesToHex(child_id, .lower), 703 .timestamp = 14, 704 .author = author, 705 .event = .{ .patch = closed_child }, 706 }}); 707 var merged_child = child.event; 708 merged_child.status = .{ .merged = .{ .revision = .source, .before_oid = &base_oid, .after_oid = (child.event.revision orelse return error.NotFound).source_oid } }; 709 try evt.consume(.local, .repo, .xit, repo_opts, io, allocator, &target, status_side_ref, &.{.{ 710 .id = std.fmt.bytesToHex(child_id, .lower), 711 .timestamp = 15, 712 .author = author, 713 .event = .{ .patch = merged_child }, 714 }}); 715 try evt.mergeEvents(.xit, repo_opts, io, allocator, &target, status_side_ref); 716 try evt.consume(.local, .repo, .xit, repo_opts, io, allocator, &target, evt.events_ref, &.{}); 717 718 _ = arena.reset(.retain_capacity); 719 const status_moment = try evt.currentMoment(repo_opts, &target); 720 const merged_child_record = (try evt.Patch.readById(Repo.DB, repo_opts.hash, status_moment, &arena, &child_id)) orelse return error.NotFound; 721 try std.testing.expectEqual(.merged, merged_child_record.event.status.kind()); 722 try std.testing.expectEqual(0, try patchStatusCount(status_moment, .open)); 723 try std.testing.expectEqual(1, try patchStatusCount(status_moment, .closed)); 724 try std.testing.expectEqual(1, try patchStatusCount(status_moment, .merged)); 725 try std.testing.expectEqual(1, try patchTagStatusCount(status_moment, "enhancement", .merged)); 726 const status_conflicts_cursor = try status_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Patch.conflicts_key)) orelse return error.NotFound; 727 const status_conflicts = try Repo.DB.SortedMap(.read_only).init(status_conflicts_cursor); 728 const child_conflict = try status_conflicts.getCursor(&evt.orderKeyDesc(merged_child_record.created_order, &child_id)); 729 try std.testing.expectEqual(null, child_conflict); 730 731 var reopened_child = merged_child_record.event; 732 reopened_child.status = .open; 733 try std.testing.expectError(error.PatchAlreadyMerged, evt.consume(.local, .repo, .xit, repo_opts, io, allocator, &target, evt.events_ref, &.{.{ 734 .id = std.fmt.bytesToHex(child_id, .lower), 735 .timestamp = 16, 736 .author = author, 737 .event = .{ .patch = reopened_child }, 738 }})); 739 740 const invalid_merged_id = evt.EventWithId.randomId(prng.random()); 741 try std.testing.expectError(error.InvalidPatch, evt.consume(.local, .repo, .xit, repo_opts, io, allocator, &target, evt.events_ref, &.{.{ 742 .id = std.fmt.bytesToHex(invalid_merged_id, .lower), 743 .timestamp = 16, 744 .author = author, 745 .event = .{ .patch = .{ 746 .title = "invalid merged patch", 747 .description = "has no revision", 748 .tags = "enhancement", 749 .target_branch = "master", 750 .status = .{ .merged = .{ .revision = .source, .before_oid = &base_oid, .after_oid = &base_oid } }, 751 } }, 752 }})); 753 754 // 755 // retain the ref-free commits during garbage collection 756 // 757 758 { 759 const roots = try evt.PatchRev.gcRoots(Repo.DB, repo_opts.hash, allocator, stacked_moment); 760 defer allocator.free(roots); 761 try std.testing.expectEqual(4, roots.len); 762 _ = try target.garbageCollect(io, allocator, .{ .extra_roots = roots }); 763 764 var after_gc_moment = try target.core.latestMoment(); 765 const after_gc_state = Repo.State(.read_only){ .core = &target.core, .extra = .{ .moment = &after_gc_moment } }; 766 var retained = try obj.Object(.xit, repo_opts).init(after_gc_state, io, allocator, &roots[0]); 767 retained.deinit(); 768 } 769 } 770 771 test "patch lifecycle" { 772 try patchLifecycle(.squash); 773 try patchLifecycle(.source); 774 } 775 776 fn patchLifecycle(merge_revision: evt.Patch.MergeRevision) !void { 777 const io = std.testing.io; 778 const allocator = std.testing.allocator; 779 780 var temp = std.testing.tmpDir(.{}); 781 defer temp.cleanup(); 782 const temp_path = try temp.dir.realPathFileAlloc(io, ".", allocator); 783 defer allocator.free(temp_path); 784 785 const repos_dir = try std.fs.path.join(allocator, &.{ temp_path, "repos" }); 786 defer allocator.free(repos_dir); 787 const admin_path = try std.fs.path.join(allocator, &.{ temp_path, "admin" }); 788 defer allocator.free(admin_path); 789 const upstream_path = try std.fs.path.join(allocator, &.{ temp_path, "upstream" }); 790 defer allocator.free(upstream_path); 791 const source_path = try std.fs.path.join(allocator, &.{ temp_path, "source" }); 792 defer allocator.free(source_path); 793 794 var prng = std.Random.DefaultPrng.init(std.testing.random_seed); 795 const user_id = evt.EventWithId.randomId(prng.random()); 796 const repo_id = evt.EventWithId.randomId(prng.random()); 797 const repo_id_hex = std.fmt.bytesToHex(repo_id, .lower); 798 const target_path = try std.fs.path.join(allocator, &.{ repos_dir, &repo_id_hex }); 799 defer allocator.free(target_path); 800 801 var admin = try rp.Repo(.xit, evt.admin_repo_opts).init(io, allocator, .{ .path = admin_path }); 802 defer admin.deinit(io, allocator); 803 try evt.consume(.server, .admin, .xit, evt.admin_repo_opts, io, allocator, &admin, evt.events_ref, &.{ 804 .{ 805 .id = std.fmt.bytesToHex(user_id, .lower), 806 .timestamp = 1, 807 .author = author, 808 .event = .{ .user = .{ 809 .name = author.name, 810 .email = author.email, 811 .password_hash = "", 812 } }, 813 }, 814 .{ 815 .id = repo_id_hex, 816 .timestamp = 1, 817 .author = author, 818 .event = .{ .repo = .{ 819 .user_id = &user_id, 820 .name = "repo", 821 .description = "", 822 } }, 823 }, 824 }); 825 826 // 827 // create the target repo 828 // 829 830 { 831 var upstream = try rp.Repo(.git, .{ .is_test = true }).init(io, allocator, .{ .path = upstream_path }); 832 defer upstream.deinit(io, allocator); 833 const file = try upstream.core.work_dir.createFile(io, "main.zig", .{ .truncate = true }); 834 defer file.close(io); 835 try file.writeStreamingAll(io, "pub fn main() void {}\n"); 836 try upstream.add(io, allocator, &.{"main.zig"}); 837 _ = try upstream.commit(io, allocator, .{ .author = "alice <alice@example.test>", .message = "initial code", .timestamp = 1 }); 838 } 839 var target = try Repo.clone(io, allocator, upstream_path, target_path, target_path, null, .{}); 840 defer target.deinit(io, allocator); 841 842 // 843 // create the patch draft in the fork 844 // 845 846 const patch_id = evt.EventWithId.randomId(prng.random()); 847 const patch_id_hex = std.fmt.bytesToHex(patch_id, .lower); 848 const draft_path = try fork.create(repo_opts, io, allocator, repos_dir, &admin, .{ 849 .target_branch = "master", 850 .id = patch_id_hex, 851 .user_id = user_id, 852 .repo_id = repo_id, 853 .title = "add the answer", 854 .tags = "enhancement", 855 .description = "adds a reusable answer constant", 856 .author = author, 857 .timestamp = 2, 858 }); 859 defer allocator.free(draft_path); 860 861 // 862 // create and record the proposed revision in the fork 863 // 864 865 const base_oid = (try target.readRef(io, .{ .kind = .head, .name = "master" })) orelse return error.NotFound; 866 const base_tree_oid = try commitTree(io, allocator, &target, &base_oid); 867 var source = try Repo.clone(io, allocator, upstream_path, source_path, source_path, null, .{}); 868 defer source.deinit(io, allocator); 869 const source_oid = blk: { 870 const file = try source.core.work_dir.createFile(io, "feature.zig", .{ .truncate = true }); 871 defer file.close(io); 872 try file.writeStreamingAll(io, "pub const answer = 42;\n"); 873 try source.add(io, allocator, &.{"feature.zig"}); 874 break :blk try source.commit(io, allocator, .{ .author = "alice <alice@example.test>", .message = "add feature", .timestamp = 2 }); 875 }; 876 const head_tree_oid = try commitTree(io, allocator, &source, &source_oid); 877 const first_patchrev_id = evt.EventWithId.randomId(prng.random()); 878 const first_patchrev_hex = std.fmt.bytesToHex(first_patchrev_id, .lower); 879 const first_entries = [_]evt.EventTreeEntry{ 880 .{ .tree = .{ .name = "base", .oid = &base_tree_oid } }, 881 .{ .tree = .{ .name = "head", .oid = &head_tree_oid } }, 882 }; 883 { 884 var draft = try Repo.open(io, allocator, .{ .path = draft_path }); 885 defer draft.deinit(io, allocator); 886 var arena = std.heap.ArenaAllocator.init(allocator); 887 defer arena.deinit(); 888 889 const initial_moment = try evt.currentMoment(repo_opts, &draft); 890 const initial_patch = (try evt.Patch.readById(Repo.DB, repo_opts.hash, initial_moment, &arena, &patch_id)) orelse return error.NotFound; 891 try std.testing.expect(!initial_patch.removed); 892 try std.testing.expectEqualStrings("add the answer", initial_patch.event.title); 893 try std.testing.expectEqual(null, initial_patch.event.revision); 894 try std.testing.expectEqual(null, try evt.PatchRev.readNewest(Repo.DB, repo_opts.hash, initial_moment, &arena)); 895 896 var source_moment = try source.core.latestMoment(); 897 const source_state = Repo.State(.read_only){ .core = &source.core, .extra = .{ .moment = &source_moment } }; 898 var objects = try obj.ObjectIterator(.xit, repo_opts).init(source_state, io, allocator, .{ .kind = .all }); 899 defer objects.deinit(); 900 try objects.include(&source_oid); 901 try draft.copyObjects(.xit, repo_opts, &objects, io, null); 902 try consumePatchWithRevision(.fork, io, allocator, &draft, evt.events_ref, first_patchrev_id, .{ 903 .base_oid = &base_oid, 904 .source_oid = &source_oid, 905 .message = "add the answer", 906 }, &first_entries, 3, patch_id, .{ 907 .title = "add the answer", 908 .tags = "enhancement", 909 .description = "adds a reusable answer constant", 910 .target_branch = "master", 911 }, 3); 912 913 _ = arena.reset(.retain_capacity); 914 const moment = try evt.currentMoment(repo_opts, &draft); 915 const patch = (try evt.Patch.readById(Repo.DB, repo_opts.hash, moment, &arena, &patch_id)) orelse return error.NotFound; 916 try std.testing.expect(!patch.removed); 917 try std.testing.expectEqualStrings(&first_patchrev_hex, &(patch.event.revision orelse return error.NotFound).id); 918 try std.testing.expect(null != try evt.PatchRev.readById(Repo.DB, repo_opts.hash, moment, &arena, &first_patchrev_id)); 919 } 920 try std.testing.expectError(error.NotFound, evt.currentMoment(repo_opts, &target)); 921 922 // 923 // publish another draft before its first push 924 // 925 926 const unpushed_id = evt.EventWithId.randomId(prng.random()); 927 const unpushed_id_hex = std.fmt.bytesToHex(unpushed_id, .lower); 928 const unpushed_path = try fork.create(repo_opts, io, allocator, repos_dir, &admin, .{ 929 .id = unpushed_id_hex, 930 .user_id = user_id, 931 .repo_id = repo_id, 932 .title = "explain the answer", 933 .tags = "documentation", 934 .description = "describes the existing answer", 935 .target_branch = "master", 936 .author = author, 937 .timestamp = 3, 938 }); 939 defer allocator.free(unpushed_path); 940 try pch.publish(repo_opts, io, allocator, &admin, &target, unpushed_path, .{ 941 .id = unpushed_id_hex, 942 .user_id = user_id, 943 .repo_id = repo_id, 944 .author = author, 945 .timestamp = 4, 946 }); 947 { 948 var verify_arena = std.heap.ArenaAllocator.init(allocator); 949 defer verify_arena.deinit(); 950 const target_moment = try evt.currentMoment(repo_opts, &target); 951 const unpushed = (try evt.Patch.readById(Repo.DB, repo_opts.hash, target_moment, &verify_arena, &unpushed_id)) orelse return error.NotFound; 952 try std.testing.expectEqual(null, unpushed.event.revision); 953 954 var unpushed_repo = try Repo.open(io, allocator, .{ .path = unpushed_path }); 955 defer unpushed_repo.deinit(io, allocator); 956 const fork_moment = try evt.currentMoment(repo_opts, &unpushed_repo); 957 const removed = (try evt.Patch.readById(Repo.DB, repo_opts.hash, fork_moment, &verify_arena, &unpushed_id)) orelse return error.NotFound; 958 try std.testing.expect(removed.removed); 959 } 960 961 // 962 // edit the patch metadata in the fork 963 // 964 965 try std.testing.expect(try pch.editDraft(repo_opts, io, allocator, &admin, draft_path, .{ 966 .id = patch_id_hex, 967 .user_id = user_id, 968 .repo_id = repo_id, 969 .title = "explain the answer", 970 .tags = "enhancement documentation", 971 .description = "adds and explains a reusable answer constant", 972 .target_branch = "master", 973 .author = author, 974 .timestamp = 4, 975 })); 976 977 // 978 // publish the metadata and revision pointer 979 // 980 981 try pch.publish(repo_opts, io, allocator, &admin, &target, draft_path, .{ 982 .id = patch_id_hex, 983 .user_id = user_id, 984 .repo_id = repo_id, 985 .author = author, 986 .timestamp = 5, 987 }); 988 try pch.publish(repo_opts, io, allocator, &admin, &target, draft_path, .{ 989 .id = patch_id_hex, 990 .user_id = user_id, 991 .repo_id = repo_id, 992 .author = author, 993 .timestamp = 6, 994 }); 995 996 var arena = std.heap.ArenaAllocator.init(allocator); 997 defer arena.deinit(); 998 const moment = try evt.currentMoment(repo_opts, &target); 999 const patch = (try evt.Patch.readById(Repo.DB, repo_opts.hash, moment, &arena, &patch_id)) orelse return error.NotFound; 1000 try std.testing.expectEqualStrings("explain the answer", patch.event.title); 1001 try std.testing.expectEqualStrings(&first_patchrev_hex, &(patch.event.revision orelse return error.NotFound).id); 1002 try std.testing.expectEqual(.open, patch.event.status); 1003 try std.testing.expectEqual(null, try evt.PatchRev.readById(Repo.DB, repo_opts.hash, moment, &arena, &first_patchrev_id)); 1004 1005 _ = arena.reset(.retain_capacity); 1006 const admin_moment = try evt.currentMoment(evt.admin_repo_opts, &admin); 1007 const published_fork = (try evt.Fork.readById(evt.AdminDB, evt.admin_repo_opts.hash, admin_moment, &arena, &patch_id)) orelse return error.NotFound; 1008 try std.testing.expect(!published_fork.removed); 1009 try std.testing.expectEqual(.publish, published_fork.event.stage); 1010 const draft_key = evt.Fork.draftKey(&repo_id, &user_id); 1011 try std.testing.expectEqual(0, try indexedForkCount(admin_moment, evt.Fork.repo_user_to_draft_id_set_key, &draft_key)); 1012 try std.testing.expectEqual(2, try indexedForkCount(admin_moment, evt.Fork.user_id_to_fork_id_set_key, &user_id)); 1013 var squash_oid: [hash.hexLen(repo_opts.hash)]u8 = undefined; 1014 { 1015 var draft = try Repo.open(io, allocator, .{ .path = draft_path }); 1016 defer draft.deinit(io, allocator); 1017 _ = arena.reset(.retain_capacity); 1018 const draft_moment = try evt.currentMoment(repo_opts, &draft); 1019 const removed = (try evt.Patch.readById(Repo.DB, repo_opts.hash, draft_moment, &arena, &patch_id)) orelse return error.NotFound; 1020 try std.testing.expect(removed.removed); 1021 const revision = (try evt.PatchRev.readById(Repo.DB, repo_opts.hash, draft_moment, &arena, &first_patchrev_id)) orelse return error.NotFound; 1022 @memcpy(&squash_oid, revision.patch_oid); 1023 try std.testing.expectEqualStrings("master", removed.event.target_branch); 1024 try std.testing.expectEqual(hash.hexLen(repo_opts.hash), revision.event_oid.len); 1025 var event_oid: [hash.hexLen(repo_opts.hash)]u8 = undefined; 1026 @memcpy(&event_oid, revision.event_oid); 1027 var repo_moment = try draft.core.latestMoment(); 1028 const state = Repo.State(.read_only){ .core = &draft.core, .extra = .{ .moment = &repo_moment } }; 1029 var event_object = try obj.Object(.xit, repo_opts).initCommit(state, io, allocator, &event_oid); 1030 defer event_object.deinit(); 1031 const trees = try evt.PatchRev.readTrees(.xit, repo_opts, state, io, allocator, &event_object.content.commit.tree); 1032 try std.testing.expectEqualStrings(revision.base_tree_oid, &trees.base); 1033 try std.testing.expectEqualStrings(revision.head_tree_oid, &trees.head); 1034 } 1035 1036 // 1037 // merge the selected revision into the target 1038 // 1039 1040 try evt.Patch.update(.local, .xit, repo_opts, io, allocator, &target, &patch_id, .{ .status = .closed }, author); 1041 try std.testing.expectError(error.PatchClosed, pch.merge(repo_opts, io, allocator, repos_dir, &target, .{ 1042 .id = patch_id_hex, 1043 .revision = merge_revision, 1044 .author = author, 1045 .timestamp = 5, 1046 })); 1047 try evt.Patch.update(.local, .xit, repo_opts, io, allocator, &target, &patch_id, .{ .status = .open }, author); 1048 1049 // run the cache scenario once; both lifecycle runs still perform a merge 1050 if (merge_revision == .source) { 1051 try testMergeability(&target, io, allocator, repos_dir, &patch_id, &base_oid, &source_oid); 1052 } 1053 1054 const selected_oid = switch (merge_revision) { 1055 .squash => squash_oid, 1056 .source => source_oid, 1057 }; 1058 try pch.mergeAndRemoveFork(repo_opts, io, allocator, repos_dir, &admin, &target, .{ 1059 .id = patch_id_hex, 1060 .revision = merge_revision, 1061 .author = author, 1062 .timestamp = 5, 1063 }); 1064 const target_oid = (try target.readRef(io, .{ .kind = .head, .name = "master" })) orelse return error.NotFound; 1065 try std.testing.expectEqualStrings(&selected_oid, &target_oid); 1066 1067 _ = arena.reset(.retain_capacity); 1068 const merged_moment = try evt.currentMoment(repo_opts, &target); 1069 const merged = (try evt.Patch.readById(Repo.DB, repo_opts.hash, merged_moment, &arena, &patch_id)) orelse return error.NotFound; 1070 try std.testing.expectEqual(.merged, merged.event.status.kind()); 1071 const merged_range = switch (merged.event.status) { 1072 .merged => |range| range, 1073 else => return error.InvalidPatch, 1074 }; 1075 try std.testing.expectEqual(merge_revision, merged_range.revision); 1076 try std.testing.expectEqualStrings(&base_oid, merged_range.before_oid); 1077 try std.testing.expectEqualStrings(&selected_oid, merged_range.after_oid); 1078 try std.testing.expect(null != try evt.PatchRev.readById(Repo.DB, repo_opts.hash, merged_moment, &arena, &first_patchrev_id)); 1079 1080 _ = arena.reset(.retain_capacity); 1081 const removed_fork = (try evt.Fork.readById(evt.AdminDB, evt.admin_repo_opts.hash, try evt.currentMoment(evt.admin_repo_opts, &admin), &arena, &patch_id)) orelse return error.NotFound; 1082 try std.testing.expect(removed_fork.removed); 1083 } 1084 1085 fn indexedForkCount( 1086 moment: evt.AdminDB.HashMap(.read_only), 1087 index_key: []const u8, 1088 parent_id: []const u8, 1089 ) !u64 { 1090 const index_cursor = try moment.getCursor(hash.hashInt(evt.admin_repo_opts.hash, index_key)) orelse return 0; 1091 const index = try evt.AdminDB.HashMap(.read_only).init(index_cursor); 1092 const ids_cursor = try index.getCursor(hash.hashInt(evt.admin_repo_opts.hash, parent_id)) orelse return 0; 1093 const ids = try evt.AdminDB.SortedSet(.read_only).init(ids_cursor); 1094 return try ids.count(); 1095 } 1096 1097 fn patchStatusCount(moment: Repo.DB.HashMap(.read_only), status: evt.Patch.StatusKind) !u64 { 1098 const statuses_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, evt.Patch.status_to_id_set_key)) orelse return 0; 1099 const statuses = try Repo.DB.SortedMap(.read_only).init(statuses_cursor); 1100 const ids_cursor = try statuses.getCursor(@tagName(status)) orelse return 0; 1101 const ids = try Repo.DB.SortedSet(.read_only).init(ids_cursor); 1102 return try ids.count(); 1103 } 1104 1105 fn patchTagStatusCount(moment: Repo.DB.HashMap(.read_only), tag: []const u8, status: evt.Patch.StatusKind) !u64 { 1106 const tags_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, evt.Patch.tag_status_to_id_set_key)) orelse return 0; 1107 const tags = try Repo.DB.SortedMap(.read_only).init(tags_cursor); 1108 var key_buffer: evt.Patch.TagStatusKey = undefined; 1109 const ids_cursor = try tags.getCursor(try evt.Patch.tagStatusKey(&key_buffer, tag, status)) orelse return 0; 1110 const ids = try Repo.DB.SortedSet(.read_only).init(ids_cursor); 1111 return try ids.count(); 1112 } 1113 1114 fn readMergeability(repo: *Repo, io: std.Io, allocator: std.mem.Allocator, id: *const [evt.event_id_size]u8, patch: evt.Patch) !pch.Mergeability { 1115 var arena = std.heap.ArenaAllocator.init(allocator); 1116 defer arena.deinit(); 1117 var moment = try repo.core.latestMoment(); 1118 return pch.readMergeability(repo_opts, .{ .core = &repo.core, .extra = .{ .moment = &moment } }, io, &arena, id, patch); 1119 } 1120 1121 fn expectMergeabilityShortBytes(moment: Repo.DB.HashMap(.read_only), id: *const [evt.event_id_size]u8) !void { 1122 const checks = try Repo.DB.HashMap(.read_only).init((try moment.getCursor(hash.hashInt(repo_opts.hash, "patch-id->mergeability"))) orelse return error.NotFound); 1123 const entry = try Repo.DB.HashMap(.read_only).init((try checks.getCursor(hash.hashInt(repo_opts.hash, id))) orelse return error.NotFound); 1124 const result = try Repo.DB.HashMap(.read_only).init((try entry.getCursor(hash.hashInt(repo_opts.hash, "result"))) orelse return error.NotFound); 1125 for ([_][]const u8{ "source", "squash" }) |field| { 1126 const cursor = (try result.getCursor(hash.hashInt(repo_opts.hash, field))) orelse return error.NotFound; 1127 try std.testing.expectEqual(.short_bytes, cursor.slot().tag); 1128 } 1129 } 1130 1131 fn testMergeability( 1132 target: *Repo, 1133 io: std.Io, 1134 allocator: std.mem.Allocator, 1135 repos_dir: []const u8, 1136 id: *const [evt.event_id_size]u8, 1137 base: *const [hash.hexLen(repo_opts.hash)]u8, 1138 source: *const [hash.hexLen(repo_opts.hash)]u8, 1139 ) !void { 1140 var arena = std.heap.ArenaAllocator.init(allocator); 1141 defer arena.deinit(); 1142 const events = try evt.currentMoment(repo_opts, target); 1143 const record = (try evt.Patch.readById(Repo.DB, repo_opts.hash, events, &arena, id)) orelse return error.NotFound; 1144 const patch = record.event; 1145 1146 // publication checks both methods, and reads and cache hits do no work 1147 const clean = pch.Mergeability{ .source = .clean, .squash = .clean }; 1148 try std.testing.expectEqualDeep(clean, try readMergeability(target, io, allocator, id, patch)); 1149 try expectMergeabilityShortBytes(try target.core.latestMoment(), id); 1150 const before = (try target.core.latestMoment()).cursor.slot(); 1151 pch.refreshMergeability(repo_opts, io, allocator, target, id.*); 1152 try std.testing.expectEqualDeep(before, (try target.core.latestMoment()).cursor.slot()); 1153 { 1154 var moment = try target.core.latestMoment(); 1155 const state = Repo.State(.read_only){ .core = &target.core, .extra = .{ .moment = &moment } }; 1156 try std.testing.expectError(error.ObjectNotFound, obj.Object(.xit, repo_opts).initCommit(state, io, allocator, source)); 1157 } 1158 1159 // a target update invalidates the old result before refresh 1160 { 1161 const file = try target.core.work_dir.createFile(io, "feature.zig", .{ .truncate = true }); 1162 defer file.close(io); 1163 try file.writeStreamingAll(io, "pub const answer = 99;\n"); 1164 } 1165 try target.add(io, allocator, &.{"feature.zig"}); 1166 const conflicting_target = try target.commit(io, allocator, .{ .message = "different answer", .timestamp = 20 }); 1167 const input = pch.MergeInput{ .id = std.fmt.bytesToHex(id.*, .lower), .revision = .source, .author = author, .timestamp = 21 }; 1168 var squash_input = input; 1169 squash_input.revision = .squash; 1170 try std.testing.expectEqualDeep(pch.Mergeability{}, try readMergeability(target, io, allocator, id, patch)); 1171 try std.testing.expectError(error.MergeCheckUnavailable, pch.merge(repo_opts, io, allocator, repos_dir, target, input)); 1172 try std.testing.expectError(error.MergeCheckUnavailable, pch.merge(repo_opts, io, allocator, repos_dir, target, squash_input)); 1173 1174 // local edits and server comments leave checks alone 1175 var edit = evt.Patch.Update{ .fields = .{ 1176 .title = "edit the answer", 1177 .tags = patch.tags, 1178 .description = patch.description, 1179 .target_branch = patch.target_branch, 1180 } }; 1181 try evt.Patch.update(.local, .xit, repo_opts, io, allocator, target, id, edit, author); 1182 try std.testing.expectEqualDeep(pch.Mergeability{}, try readMergeability(target, io, allocator, id, patch)); 1183 _ = try evt.Comment.create(.server, .xit, repo_opts, io, allocator, target, &input.id, &input.id, "reviewing the answer", author); 1184 try std.testing.expectEqualDeep(pch.Mergeability{}, try readMergeability(target, io, allocator, id, patch)); 1185 1186 // a server patch edit refreshes its mergeability 1187 edit.fields.title = patch.title; 1188 try evt.Patch.update(.server, .xit, repo_opts, io, allocator, target, id, edit, author); 1189 const events_before = try target.readRef(io, evt.events_ref); 1190 try std.testing.expectEqualDeep(pch.Mergeability{ .source = .conflict, .squash = .conflict }, try readMergeability(target, io, allocator, id, patch)); 1191 try expectMergeabilityShortBytes(try target.core.latestMoment(), id); 1192 try std.testing.expectError(error.MergeConflict, pch.merge(repo_opts, io, allocator, repos_dir, target, input)); 1193 try std.testing.expectError(error.MergeConflict, pch.merge(repo_opts, io, allocator, repos_dir, target, squash_input)); 1194 try std.testing.expectEqual(events_before, try target.readRef(io, evt.events_ref)); 1195 try std.testing.expectEqual(conflicting_target, (try target.readRef(io, .{ .kind = .head, .name = "master" })).?); 1196 try std.testing.expectEqual(null, try target.readRef(io, .{ .kind = .none, .name = "MERGE_HEAD" })); 1197 1198 // deleting and restoring the target cannot reuse the conflict result 1199 { 1200 var detached = try target.switchDir(io, allocator, .{ .target = .{ .oid = base } }); 1201 defer detached.deinit(); 1202 } 1203 try target.removeBranch(io, .{ .name = "master" }); 1204 pch.refreshMergeability(repo_opts, io, allocator, target, null); 1205 try std.testing.expectEqualDeep(pch.Mergeability{}, try readMergeability(target, io, allocator, id, patch)); 1206 try expectMergeabilityShortBytes(try target.core.latestMoment(), id); 1207 try target.addBranch(io, .{ .name = "master" }); 1208 { 1209 var restored = try target.switchDir(io, allocator, .{ .target = .{ .ref = .{ .kind = .head, .name = "master" } } }); 1210 defer restored.deinit(); 1211 } 1212 pch.refreshMergeability(repo_opts, io, allocator, target, null); 1213 try std.testing.expectEqualDeep(clean, try readMergeability(target, io, allocator, id, patch)); 1214 1215 // a missing fork disables merging without changing the accepted events 1216 const draft_path = try fork.forkPath(allocator, repos_dir, &std.fmt.bytesToHex(id.*, .lower)); 1217 defer allocator.free(draft_path); 1218 const away_path = try std.fmt.allocPrint(allocator, "{s}.away", .{draft_path}); 1219 defer allocator.free(away_path); 1220 { 1221 try std.Io.Dir.renameAbsolute(draft_path, away_path, io); 1222 defer std.Io.Dir.renameAbsolute(away_path, draft_path, io) catch {}; 1223 { 1224 const log_level = std.testing.log_level; 1225 std.testing.log_level = .err; 1226 defer std.testing.log_level = log_level; 1227 try std.testing.expectError(error.PatchDataUnavailable, pch.merge(repo_opts, io, allocator, repos_dir, target, input)); 1228 } 1229 try std.testing.expectEqualDeep(pch.Mergeability{}, try readMergeability(target, io, allocator, id, patch)); 1230 try std.testing.expectEqual(events_before, try target.readRef(io, evt.events_ref)); 1231 } 1232 pch.refreshMergeability(repo_opts, io, allocator, target, id.*); 1233 try std.testing.expectEqualDeep(clean, try readMergeability(target, io, allocator, id, patch)); 1234 1235 // a changed revision is unknown even if its target has not moved 1236 var changed = patch; 1237 changed.revision.?.id[0] = if (changed.revision.?.id[0] == 'a') 'b' else 'a'; 1238 try std.testing.expectEqualDeep(pch.Mergeability{}, try readMergeability(target, io, allocator, id, changed)); 1239 } 1240