╭ undo.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const ui = @import("../ui.zig"); 3 const evt = @import("../event.zig"); 4 const xit = @import("xit"); 5 const Undo = ui.Repo.Undo; 6 const allocator = std.testing.allocator; 7 const io = std.testing.io; 8 const constraint: xit.xitui.layout.Constraint = .{ .min_size = .{ .width = null, .height = null }, .max_size = .{ .width = 100, .height = 40 } }; 9 10 test "undo history paginates backwards and undo appends a restorable state" { 11 var temp = std.testing.tmpDir(.{}); 12 defer temp.cleanup(); 13 const path = try temp.dir.realPathFileAlloc(io, ".", allocator); 14 defer allocator.free(path); 15 const opts: xit.repo.RepoOpts(.xit) = .{ .is_test = true }; 16 var repo = try xit.repo.Repo(.xit, opts).init(io, allocator, .{ .path = path }); 17 defer repo.deinit(io, allocator); 18 var arena = std.heap.ArenaAllocator.init(allocator); 19 defer arena.deinit(); 20 const initial = try Undo.init(opts, &arena, &repo, "", null); 21 try std.testing.expectEqual(1, initial.count); 22 for (0..25) |index| { 23 const value = try std.fmt.allocPrint(arena.allocator(), "{d}", .{index}); 24 try repo.addConfig(io, allocator, .{ .name = "undo.value", .value = value }); 25 } 26 const first = try Undo.init(opts, &arena, &repo, "", null); 27 try std.testing.expectEqual(20, first.items.len); 28 try std.testing.expectEqual(25, first.items[0].index); 29 const second = try Undo.init(opts, &arena, &repo, "", first.next); 30 try std.testing.expectEqual(6, second.items.len); 31 try std.testing.expectEqual(5, second.items[0].index); 32 try std.testing.expectEqual(0, second.items[5].index); 33 try std.testing.expectEqual(null, second.next); 34 try std.testing.expectError(error.NotFound, Undo.init(opts, &arena, &repo, "", 26)); 35 try std.testing.expectError(error.InvalidHistoryIndex, repo.undo(io, 0)); 36 try std.testing.expectError(error.InvalidHistoryIndex, repo.undo(io, 26)); 37 38 // undo the newest change, then a range, then undo that undo. 39 try repo.undo(io, 25); 40 try expectConfig(&repo, "23"); 41 try repo.undo(io, 10); 42 try expectConfig(&repo, "8"); 43 const undone = try Undo.init(opts, &arena, &repo, "", null); 44 try std.testing.expectEqual(28, undone.count); 45 try std.testing.expectEqualStrings("undo", undone.items[0].action); 46 try repo.undo(io, undone.items[0].index); 47 try expectConfig(&repo, "23"); 48 } 49 50 fn expectConfig(repo: anytype, expected: []const u8) !void { 51 var config = try repo.listConfig(io, allocator); 52 defer config.deinit(); 53 const section = config.local_sections.get("undo") orelse return error.MissingSection; 54 try std.testing.expectEqualStrings(expected, section.get("value") orelse return error.MissingValue); 55 } 56 57 test "repo undo visibility and fresh writer authorization" { 58 var temp = std.testing.tmpDir(.{}); 59 defer temp.cleanup(); 60 var arena = std.heap.ArenaAllocator.init(allocator); 61 defer arena.deinit(); 62 const aa = arena.allocator(); 63 const base = try temp.dir.realPathFileAlloc(io, ".", aa); 64 const admin_path = try std.fs.path.join(aa, &.{ base, "admin" }); 65 const repos_dir = try std.fs.path.join(aa, &.{ base, "repos" }); 66 var admin = try xit.repo.Repo(.xit, evt.admin_repo_opts).init(io, allocator, .{ .path = admin_path }); 67 defer admin.deinit(io, allocator); 68 const owner = [_]u8{1} ** evt.event_id_size; 69 const writer = [_]u8{2} ** evt.event_id_size; 70 const reader = [_]u8{3} ** evt.event_id_size; 71 const repo_id = [_]u8{4} ** evt.event_id_size; 72 const author: evt.CommitAuthor = .{ .name = "test", .email = "test@example.test" }; 73 const writer_hex = std.fmt.bytesToHex(writer, .lower); 74 var repo_event: evt.Repo = .{ .user_id = &owner, .name = "demo", .description = "", .read_access = .public, .write_user_ids = &writer_hex }; 75 const seed = [_]evt.EventWithId{ 76 .{ .id = std.fmt.bytesToHex(owner, .lower), .author = author, .event = .{ .user = .{ .name = "alice", .email = "alice@example.test", .password_hash = "unused" } } }, 77 .{ .id = writer_hex, .author = author, .event = .{ .user = .{ .name = "writer", .email = "writer@example.test", .password_hash = "unused" } } }, 78 .{ .id = std.fmt.bytesToHex(reader, .lower), .author = author, .event = .{ .user = .{ .name = "reader", .email = "reader@example.test", .password_hash = "unused" } } }, 79 .{ .id = std.fmt.bytesToHex(repo_id, .lower), .author = author, .event = .{ .repo = repo_event } }, 80 }; 81 try evt.consume(.server, .admin, .xit, evt.admin_repo_opts, io, allocator, &admin, evt.events_ref, &seed); 82 const repo_hex = std.fmt.bytesToHex(repo_id, .lower); 83 const path = try std.fs.path.join(aa, &.{ repos_dir, &repo_hex }); 84 { 85 var repo = try xit.repo.Repo(.xit, .{}).init(io, allocator, .{ .path = path }); 86 defer repo.deinit(io, allocator); 87 try repo.addConfig(io, allocator, .{ .name = "undo.value", .value = "first" }); 88 try repo.addConfig(io, allocator, .{ .name = "undo.value", .value = "second" }); 89 } 90 for ([_]?[]const u8{ &owner, &writer, &reader, null }, [_]bool{ true, true, false, false }) |user, allowed| { 91 var session = try ui.Session.init(&arena, &admin, .{ .user_id = user }); 92 session.io = io; 93 session.repos_dir = repos_dir; 94 const files = ui.RoutablePage.repoFilesRoute("alice/demo", null, "", "", 0).?; 95 session.data.current_page = files; 96 const page = try ui.Repo.init(&arena, &session, files); 97 try std.testing.expectEqual(allowed, page.undo != null); 98 var root = ui.Widget{ .repo = try ui.Repo.View.init(allocator, &page, &session) }; 99 defer root.deinit(allocator); 100 try root.build(allocator, constraint, root.getFocus()); 101 const html = try @import("../web.zig").generateHtml(allocator, &root, &session); 102 defer allocator.free(html); 103 try std.testing.expectEqual(allowed, std.mem.indexOf(u8, html, "/repo/alice/demo/undo") != null); 104 const route = ui.RoutablePage.repoUndoRoute("alice/demo", null).?; 105 if (allowed) { 106 _ = try ui.Repo.init(&arena, &session, route); 107 } else { 108 try std.testing.expectError(error.NotFound, ui.Repo.init(&arena, &session, route)); 109 try std.testing.expectError(error.Forbidden, Undo.perform(allocator, &session, ui.RoutablePage.repoUndoRoute("alice/demo", 2).?.repo_undo)); 110 } 111 } 112 var session = try ui.Session.init(&arena, &admin, .{ .user_id = &writer }); 113 session.io = io; 114 session.repos_dir = repos_dir; 115 try Undo.perform(allocator, &session, ui.RoutablePage.repoUndoRoute("alice/demo", 2).?.repo_undo); 116 try std.testing.expectEqual(null, session.next_page.?.repo_undo.index); 117 { 118 var repo = try xit.repo.Repo(.xit, .{}).open(io, allocator, .{ .path = path }); 119 defer repo.deinit(io, allocator); 120 try expectConfig(&repo, "first"); 121 const data = try Undo.init(.{}, &arena, &repo, "alice/demo", null); 122 try std.testing.expectEqualStrings("undo", data.items[0].action); 123 } 124 // keep the old session/page moment, but revoke its writer's access. 125 repo_event.write_user_ids = ""; 126 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)); 128 129 // public-write repos still require login for history and undo. 130 repo_event.write_access = .public; 131 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 } }}); 132 var anonymous = try ui.Session.init(&arena, &admin, .{}); 133 anonymous.io = io; 134 anonymous.repos_dir = repos_dir; 135 try std.testing.expectError(error.NotFound, ui.Repo.init(&arena, &anonymous, ui.RoutablePage.repoUndoRoute("alice/demo", null).?)); 136 } 137 138 test "local git has no undo and local xit can undo and refresh" { 139 inline for ([_]xit.repo.RepoKind{ .git, .xit }) |kind| { 140 var temp = std.testing.tmpDir(.{}); 141 defer temp.cleanup(); 142 var arena = std.heap.ArenaAllocator.init(allocator); 143 defer arena.deinit(); 144 const path = try temp.dir.realPathFileAlloc(io, ".", arena.allocator()); 145 { 146 var repo = try xit.repo.Repo(kind, .{}).init(io, allocator, .{ .path = path }); 147 defer repo.deinit(io, allocator); 148 try repo.addConfig(io, allocator, .{ .name = "undo.value", .value = "first" }); 149 } 150 var session = ui.Session{ .arena = &arena, .page_arena = &arena, .io = io, .local = .{ .path = path, .repo_kind = kind }, .data = .{ .host_kind = .local } }; 151 const files = ui.RoutablePage.repoFilesRoute("", null, "", "", 0).?; 152 const data = try ui.Repo.init(&arena, &session, files); 153 try std.testing.expectEqual(kind == .xit, data.undo != null); 154 if (kind == .git) { 155 try std.testing.expectError(error.NotFound, ui.Repo.init(&arena, &session, ui.RoutablePage.repoUndoRoute("", null).?)); 156 try std.testing.expectError(error.NotFound, Undo.execute(io, allocator, session.local.?, 1)); 157 } else { 158 try Undo.perform(allocator, &session, ui.RoutablePage.repoUndoRoute("", 1).?.repo_undo); 159 const refreshed = try ui.Repo.init(&arena, &session, session.next_page.?); 160 try std.testing.expectEqualStrings("undo", refreshed.undo.?.items[0].action); 161 } 162 } 163 } 164 165 test "commit undo records use the formatted description" { 166 var temp = std.testing.tmpDir(.{}); 167 defer temp.cleanup(); 168 var arena = std.heap.ArenaAllocator.init(allocator); 169 defer arena.deinit(); 170 const path = try temp.dir.realPathFileAlloc(io, ".", arena.allocator()); 171 const opts: xit.repo.RepoOpts(.xit) = .{ .is_test = true }; 172 var repo = try xit.repo.Repo(.xit, opts).init(io, allocator, .{ .path = path }); 173 defer repo.deinit(io, allocator); 174 _ = try repo.commitAtRef(io, allocator, .{ .message = "example commit" }, null, .{ .kind = .head, .name = "master" }); 175 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")); 177 } 178