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