╭ changed ────╮ │build.zig.zon│ ╰─────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 3 .version = "0.0.0", │ │ 4 .dependencies = .{ │ │ 5 .xit = .{ │ │ 6 - .url = "git+https://github.com/xit-vcs/xit#78d45455ab612fb1115cf507f30f719e0222b976",│ │ 7 - .hash = "xit-0.0.0-Ub3nDOlAUwEo23yzEianLWorzMDH1CFS04REq9cyUuYT", │ │ 6 + .url = "git+https://github.com/xit-vcs/xit#f622503b45169d4d0f8dd771eba1ca8224d003b6",│ │ 7 + .hash = "xit-0.0.0-Ub3nDOBDUwGyDDIF3EepLqwUUiTYKpUC0WoDsDFmd0VL", │ │ 8 }, │ │ 9 }, │ │10 .paths = .{ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────╮ │src/event.zig│ ╰─────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1003 .base_tree_oid = &trees.base, │ │1004 .head_tree_oid = &trees.head, │ │1005 .patch_oid = &patch_oid, │ │1006 + .commit_count = PatchRev.commitCount(repo_kind, repo_opts, read_state, io, allocator, event.base_oid, event.source_oid) catch null,│ │1007 }; │ │1008 } else null; │ │1009 try PatchRev.consume(DB, repo_opts.hash, haxy_moment, &current_event_id, record_maybe, &arena, &repo_event_oid); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1625 if (optional_info.child == []const u8) { │ │1626 @field(event, field.name) = try cursor.readBytesAlloc(arena.allocator(), null); │ │1627 } else switch (@typeInfo(optional_info.child)) { │ │1628 + .int => |int_info| switch (int_info.signedness) { │ │1629 + .unsigned => @field(event, field.name) = @intCast(try cursor.readUint()), │ │1630 + .signed => @field(event, field.name) = @intCast(try cursor.readInt()), │ │1631 + }, │ │1632 .array => |array_info| { │ │1633 if (array_info.child != u8) @compileError("unsupported read field type: " ++ @typeName(field.type));│ │1634 var bytes: optional_info.child = undefined; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1857 .optional => |optional_info| { │ │1858 // a missing key is null │ │1859 if (value) |child| { │ │1855 - if (optional_info.child == []const u8) { │ │1856 - try upsertBytes(DB, hash_kind, map, key, child); │ │1857 - } else switch (@typeInfo(optional_info.child)) { │ │1858 - .array => |array_info| { │ │1859 - if (array_info.child != u8) @compileError("unsupported upsert field type: " ++ @typeName(Field));│ │1860 - try upsertBytes(DB, hash_kind, map, key, &child); │ │1861 - }, │ │1862 - .@"struct" => try upsert( │ │1863 - optional_info.child, │ │1864 - DB, │ │1865 - hash_kind, │ │1866 - try DB.HashMap(.read_write).init(try map.putCursor(key)), │ │1867 - child, │ │1868 - ), │ │1869 - else => @compileError("unsupported upsert field type: " ++ @typeName(Field)), │ │1870 - } │ │1860 + try upsertField(DB, hash_kind, map, field_name, optional_info.child, child); │ │1861 } else { │ │1862 _ = try map.remove(key); │ │1863 } │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────╮ │src/event/Patch.zig│ ╰───────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────╮ │19 id: [evt.event_id_size * 2]u8, │ │20 squash_oid: []const u8, │ │21 source_oid: []const u8, │ │22 + commit_count: ?u64 = null, │ │23 │ │24 pub fn fromRecord(id: [evt.event_id_size]u8, record: evt.PatchRev.Record) Revision {│ │25 return .{ │ ╰────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │26 .id = std.fmt.bytesToHex(id, .lower), │ │27 .squash_oid = record.patch_oid, │ │28 .source_oid = record.event.source_oid,│ │29 + .commit_count = record.commit_count, │ │30 }; │ │31 } │ │32 │ ╰──────────────────────────────────────────────────────╯ ╭ changed ─────────────╮ │src/event/PatchRev.zig│ ╰──────────────────────╯ ╭──────────────────────────────────╮ │19 base_tree_oid: []const u8,│ │20 head_tree_oid: []const u8,│ │21 patch_oid: []const u8, │ │22 + commit_count: ?u64 = null,│ │23 }; │ │24 │ │25 const Self = @This(); │ ╰──────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────╮ │27 pub const merge_policy: evt.MergePolicy = .target_wins; │ │28 pub const record_map_key = "event-id->patchrev"; │ │29 pub const all_id_set_key = "patchrev-id-set"; │ │30 + │ │31 +const commit_count_limit = 256; │ │32 │ │33 // prepare a revision event and its patch reference in the caller's arena│ │34 pub fn prepare( │ ╰─────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 60 entries[1] = .{ .tree = .{ .name = "head", .oid = try allocator.dupe(u8, &source_object.content.commit.tree) } }; │ │ 61 return .{ │ │ 62 .event = .{ .id = id_hex, .author = author, .timestamp = timestamp, .tree_entries = entries, .event = .{ .patchrev = revision } },│ │ 60 - .revision = .{ .id = id_hex, .source_oid = revision.source_oid, .squash_oid = try allocator.dupe(u8, &squash) }, │ │ 63 + .revision = .{ │ │ 64 + .id = id_hex, │ │ 65 + .source_oid = revision.source_oid, │ │ 66 + .squash_oid = try allocator.dupe(u8, &squash), │ │ 67 + .commit_count = commitCount(repo_kind, repo_opts, state.readOnly(), io, allocator, base_oid, source_oid) catch null, │ │ 68 + }, │ │ 69 }; │ │ 70 +} │ │ 71 + │ │ 72 +// only show the depth difference if a short first-parent walk reaches the base │ │ 73 +pub fn commitCount( │ │ 74 + comptime repo_kind: rp.RepoKind, │ │ 75 + comptime repo_opts: rp.RepoOpts(repo_kind), │ │ 76 + state: rp.Repo(repo_kind, repo_opts).State(.read_only), │ │ 77 + io: std.Io, │ │ 78 + allocator: std.mem.Allocator, │ │ 79 + base_oid: []const u8, │ │ 80 + source_oid: []const u8, │ │ 81 +) !?u64 { │ │ 82 + switch (repo_kind) { │ │ 83 + .git => return null, │ │ 84 + .xit => { │ │ 85 + const oid_len = comptime hash.hexLen(repo_opts.hash); │ │ 86 + if (base_oid.len != oid_len or source_oid.len != oid_len) return error.InvalidOid; │ │ 87 + const base_count = try state.commitCount(io, allocator, .{ .oid = base_oid[0..oid_len] }); │ │ 88 + const source_count = try state.commitCount(io, allocator, .{ .oid = source_oid[0..oid_len] }); │ │ 89 + const count = std.math.sub(u64, source_count, base_count) catch return null; │ │ 90 + if (count > commit_count_limit) return null; │ │ 91 + │ │ 92 + // depth subtraction is only valid if the base is on the source's first-parent chain │ │ 93 + var oid = source_oid[0..oid_len].*; │ │ 94 + var remaining = count; │ │ 95 + while (remaining > 0) : (remaining -= 1) { │ │ 96 + var object = try obj.Object(.xit, repo_opts).initCommit(state, io, allocator, &oid); │ │ 97 + defer object.deinit(); │ │ 98 + const parent = object.content.commit.metadata.firstParent() orelse return null; │ │ 99 + oid = parent.*; │ │100 + } │ │101 + return if (std.mem.eql(u8, &oid, base_oid)) count else null; │ │102 + }, │ │103 + } │ │104 } │ │105 │ │106 // create the mergeable squash commit represented by this patch revision │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ───────────────╮ │src/test/event/patch.zig│ ╰────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯