╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ────────╮ │src/test/undo.zig│ ╰─────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │107 _ = try ui.Repo.init(&arena, &session, route); │ │108 } else { │ │109 try std.testing.expectError(error.NotFound, ui.Repo.init(&arena, &session, route)); │ │110 + } │ │111 + if (user == null or !std.mem.eql(u8, user.?, &owner)) { │ │112 try std.testing.expectError(error.Forbidden, Undo.perform(allocator, &session, ui.RoutablePage.repoUndoRoute("alice/demo", 2).?.repo_undo));│ │113 } │ │114 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │112 - var session = try ui.Session.init(&arena, &admin, .{ .user_id = &writer }); │ │115 + var session = try ui.Session.init(&arena, &admin, .{ .user_id = &owner }); │ │116 session.io = io; │ │117 session.repos_dir = repos_dir; │ │118 try Undo.perform(allocator, &session, ui.RoutablePage.repoUndoRoute("alice/demo", 2).?.repo_undo);│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │124 const data = try Undo.init(.{}, &arena, &repo, "alice/demo", null); │ │125 try std.testing.expectEqualStrings("undo", data.items[0].action); │ │126 } │ │124 - // keep the old session/page moment, but revoke its writer's access. │ │125 - repo_event.write_user_ids = ""; │ │127 + // keep the old session/page moment, but transfer ownership to the writer. │ │128 + repo_event.user_id = &writer; │ │129 try evt.consume(.server, .admin, .xit, evt.admin_repo_opts, io, allocator, &admin, evt.events_ref, &.{.{ .id = std.fmt.bytesToHex(repo_id, .lower), .author = author, .event = .{ .repo = repo_event } }});│ │127 - try std.testing.expectError(error.Forbidden, Undo.perform(allocator, &session, ui.RoutablePage.repoUndoRoute("alice/demo", 1).?.repo_undo)); │ │130 + session.next_page = null; │ │131 + Undo.handleRequest(allocator, &session, ui.RoutablePage.repoUndoRoute("alice/demo", 1).?.repo_undo); │ │132 + try std.testing.expectEqualStrings("Forbidden", session.data.undo_failure.?); │ │133 + try std.testing.expectEqual(null, session.next_page); │ │134 │ │135 // public-write repos still require login for history and undo. │ │136 + repo_event.user_id = &owner; │ │137 repo_event.write_access = .public; │ │138 try evt.consume(.server, .admin, .xit, evt.admin_repo_opts, io, allocator, &admin, evt.events_ref, &.{.{ .id = std.fmt.bytesToHex(repo_id, .lower), .author = author, .event = .{ .repo = repo_event } }});│ │139 var anonymous = try ui.Session.init(&arena, &admin, .{}); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │162 try std.testing.expectError(error.NotFound, ui.Repo.init(&arena, &session, ui.RoutablePage.repoUndoRoute("", null).?));│ │163 try std.testing.expectError(error.NotFound, Undo.execute(io, allocator, session.local.?, 1)); │ │164 } else { │ │158 - try Undo.perform(allocator, &session, ui.RoutablePage.repoUndoRoute("", 1).?.repo_undo); │ │165 + Undo.handleRequest(allocator, &session, ui.RoutablePage.repoUndoRoute("", 999).?.repo_undo); │ │166 + try std.testing.expectEqualStrings("InvalidHistoryIndex", session.data.undo_failure.?); │ │167 + try std.testing.expectEqual(null, session.next_page); │ │168 + Undo.handleRequest(allocator, &session, ui.RoutablePage.repoUndoRoute("", 1).?.repo_undo); │ │169 + try std.testing.expectEqual(null, session.data.undo_failure); │ │170 const refreshed = try ui.Repo.init(&arena, &session, session.next_page.?); │ │171 try std.testing.expectEqualStrings("undo", refreshed.undo.?.items[0].action); │ │172 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │184 defer repo.deinit(io, allocator); │ │185 _ = try repo.commitAtRef(io, allocator, .{ .message = "example commit" }, null, .{ .kind = .head, .name = "master" }); │ │186 const data = try Undo.init(opts, &arena, &repo, "", null); │ │176 - try std.testing.expect(std.mem.startsWith(u8, data.items[0].description, "commit -m \"example commit\"\n\n")); │ │187 + try std.testing.expect(std.mem.startsWith(u8, data.items[0].description, "commit -m \"example commit\"")); │ │188 +} │ │189 + │ │190 +test "malformed undo history is shown as an error" { │ │191 + var temp = std.testing.tmpDir(.{}); │ │192 + defer temp.cleanup(); │ │193 + var arena = std.heap.ArenaAllocator.init(allocator); │ │194 + defer arena.deinit(); │ │195 + const path = try temp.dir.realPathFileAlloc(io, ".", arena.allocator()); │ │196 + const Repo = xit.repo.Repo(.xit, .{}); │ │197 + var repo = try Repo.init(io, allocator, .{ .path = path }); │ │198 + defer repo.deinit(io, allocator); │ │199 + │ │200 + // append an invalid undo record without damaging the repository data. │ │201 + const Corrupt = struct { │ │202 + pub fn run(_: @This(), cursor: *Repo.DB.Cursor(.read_write)) !void { │ │203 + const moment = try Repo.DB.HashMap(.read_write).init(cursor.*); │ │204 + try moment.put(xit.hash.hashInt(.sha1, "undo"), .{ .bytes = "bad" }); │ │205 + } │ │206 + }; │ │207 + const history = try Repo.DB.ArrayList(.read_write).init(repo.core.db.rootCursor()); │ │208 + try history.appendContext(.{ .slot = try history.getSlot(-1) }, Corrupt{}); │ │209 + │ │210 + var session = ui.Session{ .arena = &arena, .page_arena = &arena, .io = io, .local = .{ .path = path, .repo_kind = .xit }, .data = .{ .host_kind = .local } };│ │211 + // tabs switch in-page, so every tab carries the undo data and its failure │ │212 + const files = try ui.Repo.init(&arena, &session, ui.RoutablePage.repoFilesRoute("", null, "", "", 0).?); │ │213 + try std.testing.expectEqual(0, files.undo.?.items.len); │ │214 + try std.testing.expectEqualStrings("InvalidUndoRecord", files.undo.?.failure.?); │ │215 + const undo = try ui.Repo.init(&arena, &session, ui.RoutablePage.repoUndoRoute("", null).?); │ │216 + try std.testing.expectEqualStrings("InvalidUndoRecord", undo.undo.?.failure.?); │ │217 + │ │218 + var root = ui.Widget{ .repo_undo = try Undo.View.init(allocator, &undo.undo.?, &session) }; │ │219 + defer root.deinit(allocator); │ │220 + try root.build(allocator, constraint, root.getFocus()); │ │221 + const html = try @import("../web.zig").generateHtml(allocator, &root, &session); │ │222 + defer allocator.free(html); │ │223 + try std.testing.expect(std.mem.indexOf(u8, html, "InvalidUndoRecord") != null); │ │224 } │ │225 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/try.zig│ ╰───────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │679 │ │680 try evt.consume(.local, .repo, .xit, .{}, io, allocator, &template_repo, evt.events_ref, &ours); │ │681 │ │682 - // consume can't root a new branch, so theirs commits by hand │ │683 - var json: std.Io.Writer.Allocating = .init(allocator); │ │684 - defer json.deinit(); │ │685 - for (theirs, 0..) |event, i| { │ │686 - json.clearRetainingCapacity(); │ │687 - try std.json.Stringify.value(event, .{}, &json.writer); │ │688 - const author = try std.fmt.allocPrint(allocator, "{s} <{s}>", .{ event.author.name, event.author.email });│ │689 - defer allocator.free(author); │ │690 - _ = try template_repo.commitAtRef(io, allocator, .{ │ │691 - .author = author, │ │692 - .message = json.written(), │ │693 - .timestamp = event.timestamp, │ │694 - // root the branch at the shared seed tip │ │695 - .parent_oids = if (i == 0) &.{seed_tip} else null, │ │696 - }, null, other_ref); │ │697 - } │ │682 + try commitEventsAtRef(io, allocator, &template_repo, other_ref, &theirs, seed_tip); │ │683 │ │684 { │ │685 var to_events = try template_repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1323 theirs[2].event.patch = theirs_values[2]; │ │1324 │ │1325 try evt.consume(.server, .repo, .xit, .{}, io, allocator, target_repo, evt.events_ref, &ours); │ │1341 - var json: std.Io.Writer.Allocating = .init(allocator); │ │1342 - defer json.deinit(); │ │1343 - for (theirs, 0..) |event, i| { │ │1344 - json.clearRetainingCapacity(); │ │1345 - try std.json.Stringify.value(event, .{}, &json.writer); │ │1346 - const author = try std.fmt.allocPrint(allocator, "{s} <{s}>", .{ event.author.name, event.author.email }); │ │1347 - defer allocator.free(author); │ │1348 - _ = try target_repo.commitAtRef(io, allocator, .{ │ │1349 - .author = author, │ │1350 - .message = json.written(), │ │1351 - .timestamp = event.timestamp, │ │1352 - .parent_oids = if (i == 0) &.{seed_tip} else null, │ │1353 - }, null, other_ref); │ │1354 - } │ │1326 + try commitEventsAtRef(io, allocator, target_repo, other_ref, &theirs, seed_tip); │ │1327 { │ │1328 var merge = try target_repo.mergeAtRef(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_ref }} } } }, evt.events_ref, null);│ │1329 defer merge.deinit(); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1340 }, null, patch_author); │ │1341 } │ │1342 │ │1343 +// commit `events` onto `ref`, rooted at `parent`, as one undoable step. │ │1344 +// consume always roots a new branch at nothing, so the divergent fixtures │ │1345 +// write their side branch here instead, recording the action consume would │ │1346 +fn commitEventsAtRef( │ │1347 + io: std.Io, │ │1348 + allocator: std.mem.Allocator, │ │1349 + repo: *rp.Repo(.xit, .{}), │ │1350 + ref: xit.ref.Ref, │ │1351 + events: []const evt.EventWithId, │ │1352 + parent: [hash.hexLen(.sha1)]u8, │ │1353 +) !void { │ │1354 + const Repo = rp.Repo(.xit, .{}); │ │1355 + const DB = Repo.DB; │ │1356 + const Ctx = struct { │ │1357 + core: *Repo.Core, │ │1358 + io: std.Io, │ │1359 + allocator: std.mem.Allocator, │ │1360 + ref: xit.ref.Ref, │ │1361 + events: []const evt.EventWithId, │ │1362 + parent: [hash.hexLen(.sha1)]u8, │ │1363 + │ │1364 + pub fn run(ctx: @This(), cursor: *DB.Cursor(.read_write)) !void { │ │1365 + var moment = try DB.HashMap(.read_write).init(cursor.*); │ │1366 + const state = Repo.State(.read_write){ .core = ctx.core, .extra = .{ .moment = &moment } }; │ │1367 + try evt.commitEvents(.xit, .{}, state, ctx.io, ctx.allocator, ctx.ref, ctx.events, .{ctx.parent}); │ │1368 + try xit.undo.write(.{}, state, std.Io.Timestamp.now(ctx.io, .real).toSeconds(), .{ .custom = .{ .action = evt.undo_action } });│ │1369 + } │ │1370 + }; │ │1371 + │ │1372 + try repo.core.db_file.lock(io, .exclusive); │ │1373 + defer repo.core.db_file.unlock(io); │ │1374 + │ │1375 + const history = try DB.ArrayList(.read_write).init(repo.core.db.rootCursor()); │ │1376 + try history.appendContext(.{ .slot = try history.getSlot(-1) }, Ctx{ │ │1377 + .core = &repo.core, │ │1378 + .io = io, │ │1379 + .allocator = allocator, │ │1380 + .ref = ref, │ │1381 + .events = events, │ │1382 + .parent = parent, │ │1383 + }); │ │1384 +} │ │1385 + │ │1386 // recursively copy the contents of src_dir into dest_dir │ │1387 fn copyDir(io: std.Io, src_dir: std.Io.Dir, dest_dir: std.Io.Dir) !void { │ │1388 var iter = src_dir.iterate(); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │198 │ │199 // the "key:value" segments of a url tail, collected in any order │ │200 const Params = struct { │ │201 - const ParamKey = enum { start, line, branch, tag, object, base, patchrev, theirs, find, search, from }; │ │201 + const ParamKey = enum { start, line, branch, tag, object, base, patchrev, theirs, find, search, from, moment };│ │202 │ │203 // a branch:/tag:/object: param; the value stays url-encoded │ │204 const RefParam = struct { kind: RefOrOid, value: []const u8 }; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │244 fn start(self: Params) ?usize { │ │245 const value = self.values.get(.start) orelse return 0; │ │246 return std.fmt.parseInt(usize, value, 10) catch null; │ │247 + } │ │248 + │ │249 + // the "moment:<n>" event filter, null when absent │ │250 + fn moment(self: Params) error{BadUrl}!?u64 { │ │251 + const value = self.values.get(.moment) orelse return null; │ │252 + return std.fmt.parseInt(u64, value, 10) catch error.BadUrl; │ │253 } │ │254 │ │255 // the "line:<n>" first-line param (0 when absent), null when malformed│ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯