╭ changed ───╮ │src/push.zig│ ╰────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 85 io: std.Io, │ │ 86 allocator: std.mem.Allocator, │ │ 87 author: ?evt.CommitAuthor, │ │ 88 + updates: []const xit.net_server_receive_pack.AppliedRefUpdate, │ │ 89 ) !void { │ │ 89 - var author_json: std.json.ObjectMap = if (author) |user| │ │ 90 - try .init(allocator, &.{ "username", "email" }, &.{ .{ .string = user.name }, .{ .string = user.email } }) │ │ 90 + // what the payload may take up: the record holds a timestamp and the │ │ 91 + // action kind in the same fixed buffer │ │ 92 + const payload_max_len = repo_opts.max_read_size - @sizeOf(i64) - undo_action.len - 1; │ │ 93 + │ │ 94 + var arena = std.heap.ArenaAllocator.init(allocator); │ │ 95 + defer arena.deinit(); │ │ 96 + const aa = arena.allocator(); │ │ 97 + │ │ 98 + const author_json: std.json.ObjectMap = if (author) |user| │ │ 99 + try .init(aa, &.{ "username", "email" }, &.{ .{ .string = user.name }, .{ .string = user.email } }) │ │100 else │ │101 .empty; │ │ 93 - defer author_json.deinit(allocator); │ │ 94 - var payload: std.json.ObjectMap = try .init(allocator, &.{"author"}, &.{if (author != null) .{ .object = author_json } else .null}); │ │ 95 - defer payload.deinit(allocator); │ │102 + var payload: std.json.ObjectMap = try .init(aa, &.{"author"}, &.{if (author != null) .{ .object = author_json } else .null}); │ │103 + │ │104 + // where each ref landed, so the undo tab can say what the push did to it. │ │105 + // an oid of all zeros means the ref has no side there, which is how a │ │106 + // create and a remove are told apart │ │107 + var refs: std.json.Array = .init(aa); │ │108 + for (updates) |update| { │ │109 + const entry: std.json.ObjectMap = try .init(aa, &.{ "name", "old", "new" }, &.{ │ │110 + .{ .string = update.ref_name }, │ │111 + .{ .string = if (zeroOid(update.old_oid)) "" else update.old_oid }, │ │112 + .{ .string = if (zeroOid(update.new_oid)) "" else update.new_oid }, │ │113 + }); │ │114 + try refs.append(.{ .object = entry }); │ │115 + try payload.put(aa, "refs", .{ .array = refs }); │ │116 + │ │117 + // the record is written into one fixed buffer, so a ref that no │ │118 + // longer fits is left out rather than failing the push │ │119 + if (try payloadLen(payload) > payload_max_len) { │ │120 + _ = refs.pop(); │ │121 + break; │ │122 + } │ │123 + } │ │124 + │ │125 + // the array is stored by value, so the one the payload holds still counts │ │126 + // the entry that was popped until it is replaced here │ │127 + try payload.put(aa, "refs", .{ .array = refs }); │ │128 + │ │129 try xit.undo.write(repo_opts, state, std.Io.Timestamp.now(io, .real).toSeconds(), .{ .custom = .{ .action_kind = undo_action, .payload = payload } });│ │130 +} │ │131 + │ │132 +// the oid a ref update sends when it has no side: all zeros │ │133 +fn zeroOid(oid: []const u8) bool { │ │134 + for (oid) |char| if (char != '0') return false; │ │135 + return true; │ │136 } │ │137 │ │138 +// how long the record's payload would be, to keep it within the buffer │ │139 +fn payloadLen(payload: std.json.ObjectMap) !u64 { │ │140 + var discarding = std.Io.Writer.Discarding.init(&.{}); │ │141 + try std.json.Stringify.value(std.json.Value{ .object = payload }, .{}, &discarding.writer); │ │142 + return discarding.fullCount(); │ │143 +} │ │144 + │ │145 // serve a receive-pack and consume any events it pushed to the events branch, │ │146 // all in one transaction: the push and the views derived from it commit │ │147 // atomically, and a failed consume cancels the push │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │206 _ = try find.refreshInTransaction(repo_opts, state, &moment, ctx.io, ctx.allocator); │ │207 _ = try srch_cmmt.refreshInTransaction(repo_opts, state, &moment, ctx.io, ctx.allocator, ctx.updates.items.items);│ │208 │ │163 - try writeUndo(repo_opts, state, ctx.io, ctx.allocator, ctx.author); │ │209 + try writeUndo(repo_opts, state, ctx.io, ctx.allocator, ctx.author, ctx.updates.items.items); │ │210 } │ │211 }; │ │212 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │378 try evt.commitEvents(.xit, repo_opts, state, ctx.io, ctx.allocator, evt.events_ref, events[0..event_count], null); │ │379 if (!try evt.consumeInTransaction(.fork, .xit, repo_opts, state, &ctx.core.db, &moment, ctx.io, ctx.allocator, evt.events_ref)) return error.CancelTransaction;│ │380 } │ │335 - try writeUndo(repo_opts, state, ctx.io, ctx.allocator, ctx.author); │ │381 + // a fork push tracks no ref updates, so it records no ranges │ │382 + try writeUndo(repo_opts, state, ctx.io, ctx.allocator, ctx.author, &.{}); │ │383 } │ │384 }; │ │385 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/try.zig│ ╰───────────╯ ╭───────────────────────────────────────╮ │11 const rp = xit.repo; │ │12 const hash = xit.hash; │ │13 const obj = xit.object; │ │14 +const rf = xit.ref; │ │15 +const bch = xit.branch; │ │16 const ui = hx.ui; │ │17 const find = hx.find; │ │18 const srch_cmmt = hx.search_commit;│ ╰───────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1364 const opts: rp.RepoOpts(.xit) = .{}; │ │1365 var moment = try DB.HashMap(.read_write).init(cursor.*); │ │1366 const state = Repo.State(.read_write){ .core = ctx.core, .extra = .{ .moment = &moment } };│ │1367 + var arena = std.heap.ArenaAllocator.init(ctx.allocator); │ │1368 + defer arena.deinit(); │ │1369 + const aa = arena.allocator(); │ │1370 │ │1371 // a null tree keeps the parent's content, so the branch advances │ │1372 // without the fixture having to build one │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1368 - const author_line = try std.fmt.allocPrint(ctx.allocator, "{s} <{s}>", .{ ctx.author.name, ctx.author.email }); │ │1369 - defer ctx.allocator.free(author_line); │ │1370 - _ = try obj.writeCommit(.xit, opts, state, ctx.io, ctx.allocator, .{ │ │1373 + const author_line = try std.fmt.allocPrint(aa, "{s} <{s}>", .{ ctx.author.name, ctx.author.email }); │ │1374 + const feature_old = (try rf.readRecur(.xit, opts, state.readOnly(), ctx.io, .{ .ref = .{ .kind = .head, .name = "feature" } })) orelse return error.NotFound;│ │1375 + const feature_new = try obj.writeCommit(.xit, opts, state, ctx.io, ctx.allocator, .{ │ │1376 .message = "Revise the feature branch", │ │1377 .author = author_line, │ │1378 .timestamp = 820, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1379 }, null, .{ .kind = .head, .name = "feature" }); │ │1380 │ │1376 - _ = try pch.refreshBranchesInTransaction(.server, opts, state, &moment, ctx.io, ctx.allocator, null, null, null); │ │1377 - try push.writeUndo(opts, state, ctx.io, ctx.allocator, ctx.author); │ │1381 + // a branch the push creates has no old tip, so its range is open │ │1382 + try bch.add(.xit, opts, state, ctx.io, .{ .name = "docs" }); │ │1383 + const docs_new = (try rf.readRecur(.xit, opts, state.readOnly(), ctx.io, .{ .ref = .{ .kind = .head, .name = "docs" } })) orelse return error.NotFound;│ │1384 + │ │1385 + const updates = [_]xit.net_server_receive_pack.AppliedRefUpdate{ │ │1386 + .{ │ │1387 + .ref_name = try aa.dupe(u8, "refs/heads/feature"), │ │1388 + .old_oid = try aa.dupe(u8, &feature_old), │ │1389 + .new_oid = try aa.dupe(u8, &feature_new), │ │1390 + }, │ │1391 + .{ │ │1392 + .ref_name = try aa.dupe(u8, "refs/heads/docs"), │ │1393 + .old_oid = try aa.dupe(u8, "0" ** hash.hexLen(opts.hash)), │ │1394 + .new_oid = try aa.dupe(u8, &docs_new), │ │1395 + }, │ │1396 + }; │ │1397 + │ │1398 + _ = try pch.refreshBranchesInTransaction(.server, opts, state, &moment, ctx.io, ctx.allocator, &updates, null, null); │ │1399 + try push.writeUndo(opts, state, ctx.io, ctx.allocator, ctx.author, &updates); │ │1400 } │ │1401 }; │ │1402 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ───────────╮ │src/ui/Repo/Undo.zig│ ╰────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │148 if (try pushUser(arena, haxy_moment, record.payload)) |button| try buttons.append(aa, button); │ │149 const count = try momentEventCount(opts, core, moment, history_index) orelse 0; │ │150 if (count > 0) try buttons.append(aa, try eventsButton(arena, identity, history_index, count));│ │151 + try pushRefs(arena, identity, record.payload, &buttons); │ │152 return .{ .action = "push", .description = "received a push", .buttons = buttons.items }; │ │153 } │ │154 if (!std.mem.eql(u8, record.action_kind, evt.undo_action)) return null; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │208 }; │ │209 } │ │210 │ │211 +// a button per ref the push moved, naming what it did to it: a changed branch │ │212 +// links to the commits it added, anything still there to the ref itself │ │213 +fn pushRefs(arena: *std.heap.ArenaAllocator, identity: []const u8, payload: []const u8, buttons: *std.ArrayList(DetailButton)) !void { │ │214 + const head_prefix = "refs/heads/"; │ │215 + const tag_prefix = "refs/tags/"; │ │216 + const Payload = struct { │ │217 + const Ref = struct { name: []const u8, old: []const u8, new: []const u8 }; │ │218 + refs: []const Ref = &.{}, │ │219 + }; │ │220 + const parsed = std.json.parseFromSlice(Payload, arena.child_allocator, payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) {│ │221 + error.OutOfMemory => return err, │ │222 + else => return, │ │223 + }; │ │224 + defer parsed.deinit(); │ │225 + │ │226 + const aa = arena.allocator(); │ │227 + for (parsed.value.refs) |ref| { │ │228 + // anything that is neither a head nor a tag is skipped │ │229 + const head = std.mem.startsWith(u8, ref.name, head_prefix); │ │230 + const prefix = if (head) head_prefix else tag_prefix; │ │231 + if (!std.mem.startsWith(u8, ref.name, prefix)) continue; │ │232 + const name = ref.name[prefix.len..]; │ │233 + │ │234 + // a removed ref has nothing left to link to. a branch that was created │ │235 + // has no range to show either: what it added is whatever the other │ │236 + // refs did not already reach, which a base cannot express │ │237 + const removed = ref.new.len == 0; │ │238 + const created = ref.old.len == 0; │ │239 + const route = if (removed) │ │240 + null │ │241 + else if (head and !created) │ │242 + ui.RoutablePage.repoCommitsRoute(identity, .object, ref.new, 0, "", ref.old) orelse continue │ │243 + else │ │244 + ui.RoutablePage.repoFilesRoute(identity, if (head) .branch else .tag, try ui.urlEncodeRef(aa, name), "", 0) orelse continue; │ │245 + │ │246 + const action = if (removed) "removed" else if (created) "created" else "changed"; │ │247 + try buttons.append(aa, .{ │ │248 + .label = try std.fmt.allocPrint(aa, " {s} {s} ", .{ if (head) "branch" else "tag", action }), │ │249 + .text = try aa.dupe(u8, name), │ │250 + .link = if (route) |value| try std.fmt.allocPrint(aa, "a:{s}", .{try value.toUrl(arena)}) else null, │ │251 + }); │ │252 + } │ │253 +} │ │254 + │ │255 // the push record stores the email of the user whose key the server accepted. │ │256 // resolving it names them as they are now, and leaves the email when no user │ │257 // holds it, which is also what local mode sees with no admin db to read │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯