//! `zit daemon` over a real socket: the binary is spawned, a client connects
//! through `std.Io.net.UnixAddress`, and every assertion is on the bytes the
//! daemon wrote back or on the store as the CLI then reads it.
//!
//! Sockets are named relative to the temporary directory (the daemon's cwd)
//! and, from this process, relative to its own cwd: the absolute path of a
//! test directory can be longer than `sockaddr_un` holds.
//!
//! Imported by the e2e root, whose step builds the `zit` binary this spawns.
const std = @import("std");
const repeat = @import("zit").repeat;
const zit = @import("zit");
const build_options = @import("build_options");
const protocol = zit.daemon_protocol;
const net = std.Io.net;
const zit_exe: []const u8 = build_options.zit_exe;
const epoch0: i64 = 1_800_000_000;
const Fx = struct {
gpa: std.mem.Allocator,
io: std.Io,
tmp: std.testing.TmpDir,
arena_state: std.heap.ArenaAllocator,
fn init(fx: *Fx) void {
fx.* = .{
.gpa = std.testing.allocator,
.io = std.testing.io,
.tmp = std.testing.tmpDir(.{}),
.arena_state = .init(std.testing.allocator),
};
}
fn deinit(fx: *Fx) void {
fx.arena_state.deinit();
fx.tmp.cleanup();
}
fn a(fx: *Fx) std.mem.Allocator {
return fx.arena_state.allocator();
}
/// `name` (relative to the temporary directory) as this process names it.
fn here(fx: *Fx, name: []const u8) []const u8 {
return std.fmt.allocPrint(fx.a(), ".zig-cache/tmp/{s}/{s}", .{ fx.tmp.sub_path, name }) catch @panic("oom");
}
fn makeStore(fx: *Fx, name: []const u8) !void {
try fx.tmp.dir.createDir(fx.io, name, .default_dir);
var dir = try fx.tmp.dir.openDir(fx.io, name, .{});
defer dir.close(fx.io);
const res = try runZit(fx, dir, &.{ "init", "-q" }, null);
try expectExit(res, 0);
}
fn storeBytes(fx: *Fx, store: []const u8) ![]u8 {
const path = try std.fmt.allocPrint(fx.a(), "{s}/.zit", .{store});
return fx.tmp.dir.readFileAlloc(fx.io, path, fx.a(), .limited(1 << 30));
}
fn storeSize(fx: *Fx, store: []const u8) !u64 {
const path = try std.fmt.allocPrint(fx.a(), "{s}/.zit", .{store});
return (try fx.tmp.dir.statFile(fx.io, path, .{})).size;
}
/// Copy only the store file, so the copy shares no cache with the source.
fn copyStore(fx: *Fx, from: []const u8, to: []const u8) !void {
try fx.tmp.dir.createDir(fx.io, to, .default_dir);
const bytes = try fx.storeBytes(from);
const path = try std.fmt.allocPrint(fx.a(), "{s}/.zit", .{to});
try fx.tmp.dir.writeFile(fx.io, .{ .sub_path = path, .data = bytes });
}
/// `zit log -R STORE trunk`: the check-ins on trunk, newest first.
fn trunkLog(fx: *Fx, store: []const u8) ![]const []const u8 {
const res = try runZit(fx, fx.tmp.dir, &.{ "log", "-R", store, "trunk" }, null);
try expectExit(res, 0);
var out: std.ArrayList([]const u8) = .empty;
var lines = std.mem.tokenizeScalar(u8, res.stdout, '\n');
while (lines.next()) |line| if (std.mem.startsWith(u8, line, "checkin ")) try out.append(fx.a(), line["checkin ".len..]);
return out.items;
}
};
fn runZit(fx: *Fx, dir: std.Io.Dir, tail: []const []const u8, stdin_file: ?[]const u8) !std.process.RunResult {
var env = std.process.Environ.Map.init(fx.gpa);
defer env.deinit();
try env.put("TZ", "UTC");
try env.put("HOME", "/zit-e2e-isolated-home-does-not-exist");
try env.put("PATH", "/usr/bin:/bin");
const exe = try absExe(fx);
var cmd: std.ArrayList(u8) = .empty;
try cmd.print(fx.a(), "exec '{s}'", .{exe});
for (tail) |t| try cmd.print(fx.a(), " '{s}'", .{t});
if (stdin_file) |f| try cmd.print(fx.a(), " < '{s}'", .{f}) else try cmd.appendSlice(fx.a(), " < /dev/null");
const res = try std.process.run(fx.gpa, fx.io, .{
.argv = &.{ "/bin/sh", "-c", cmd.items },
.cwd = .{ .dir = dir },
.environ_map = &env,
});
// Kept on the arena so a caller need not free them.
const stdout = try fx.a().dupe(u8, res.stdout);
const stderr = try fx.a().dupe(u8, res.stderr);
fx.gpa.free(res.stdout);
fx.gpa.free(res.stderr);
return .{ .term = res.term, .stdout = stdout, .stderr = stderr };
}
fn absExe(fx: *Fx) ![]const u8 {
const cwd_abs = try std.process.currentPathAlloc(fx.io, fx.a());
return std.fs.path.resolve(fx.a(), &.{ cwd_abs, zit_exe });
}
fn expectExit(res: std.process.RunResult, want: u8) !void {
switch (res.term) {
.exited => |code| if (code == want) return,
else => {},
}
std.debug.print("\nzit: expected exit {d}, got {any}:\n{s}{s}\n", .{ want, res.term, res.stdout, res.stderr });
return error.TestUnexpectedResult;
}
const StartOptions = struct {
extra: []const []const u8 = &.{},
env: []const [2][]const u8 = &.{},
stdin_pipe: bool = false,
};
/// A running `zit daemon`, ready once its socket path came back on stdout.
const Daemon = struct {
child: std.process.Child,
fn start(fx: *Fx, store: []const u8, socket: []const u8, opts: StartOptions) !Daemon {
var env = std.process.Environ.Map.init(fx.gpa);
defer env.deinit();
try env.put("TZ", "UTC");
try env.put("HOME", "/zit-e2e-isolated-home-does-not-exist");
try env.put("PATH", "/usr/bin:/bin");
for (opts.env) |pair| try env.put(pair[0], pair[1]);
var argv: std.ArrayList([]const u8) = .empty;
try argv.appendSlice(fx.a(), &.{ try absExe(fx), "daemon", "-R", store, "--socket", socket });
try argv.appendSlice(fx.a(), opts.extra);
// Kept beside the store, so a failure can show what the daemon said.
const err_file = try fx.tmp.dir.createFile(fx.io, "daemon.err", .{ .truncate = false });
defer err_file.close(fx.io);
var child = try std.process.spawn(fx.io, .{
.argv = argv.items,
.cwd = .{ .dir = fx.tmp.dir },
.environ_map = &env,
.stdin = if (opts.stdin_pipe) .pipe else .ignore,
.stdout = .pipe,
.stderr = .{ .file = err_file },
});
errdefer child.kill(fx.io);
var buf: [512]u8 = undefined;
var r = child.stdout.?.readerStreaming(fx.io, &buf);
const line = r.interface.takeDelimiterInclusive('\n') catch return error.DaemonNeverListened;
try std.testing.expectEqualStrings(socket, line[0 .. line.len - 1]);
return .{ .child = child };
}
/// SIGTERM, then the exit status.
fn stop(d: *Daemon, io: std.Io) !std.process.Child.Term {
try std.posix.kill(d.child.id.?, .TERM);
return d.child.wait(io);
}
fn expectExited(fx: *Fx, term: std.process.Child.Term, code: u8) !void {
switch (term) {
.exited => |c| if (c == code) return,
else => {},
}
const said = fx.tmp.dir.readFileAlloc(fx.io, "daemon.err", fx.a(), .limited(1 << 20)) catch "";
std.debug.print("\ndaemon: expected exit {d}, got {any}; it said:\n{s}\n", .{ code, term, said });
return error.TestUnexpectedResult;
}
};
const Client = struct {
stream: net.Stream,
read_buf: []u8,
reader: net.Stream.Reader,
write_buf: [1 << 16]u8,
writer: net.Stream.Writer,
closed: bool,
fn open(fx: *Fx, socket: []const u8) !*Client {
const addr = try net.UnixAddress.init(fx.here(socket));
const c = try fx.a().create(Client);
c.stream = try addr.connect(fx.io);
c.read_buf = try fx.a().alloc(u8, 4 << 20);
c.reader = c.stream.reader(fx.io, c.read_buf);
c.writer = c.stream.writer(fx.io, &c.write_buf);
c.closed = false;
return c;
}
fn close(c: *Client, io: std.Io) void {
if (c.closed) return;
c.stream.close(io);
c.closed = true;
}
fn send(c: *Client, req: protocol.Request, trailers: []const []const u8) !void {
try c.queue(req, trailers);
try c.writer.interface.flush();
}
/// Written but not flushed, for pipelining several requests at once.
fn queue(c: *Client, req: protocol.Request, trailers: []const []const u8) !void {
try protocol.writeRequest(&c.writer.interface, req);
for (trailers) |t| try c.writer.interface.writeAll(t);
}
fn reply(c: *Client, arena: std.mem.Allocator) !std.json.Value {
const line = try c.reader.interface.takeDelimiterInclusive('\n');
return std.json.parseFromSliceLeaky(std.json.Value, arena, try arena.dupe(u8, line), .{});
}
fn take(c: *Client, arena: std.mem.Allocator, n: usize) ![]u8 {
const bytes = try arena.alloc(u8, n);
try c.reader.interface.readSliceAll(bytes);
return bytes;
}
fn call(c: *Client, arena: std.mem.Allocator, req: protocol.Request, trailers: []const []const u8) !std.json.Value {
try c.send(req, trailers);
const v = try c.reply(arena);
try std.testing.expectEqual(req.id.?, v.object.get("id").?.integer);
return v;
}
};
fn get(v: std.json.Value, key: []const u8) std.json.Value {
return v.object.get(key) orelse std.debug.panic("reply has no '{s}'", .{key});
}
fn str(v: std.json.Value, key: []const u8) []const u8 {
return get(v, key).string;
}
fn expectOk(v: std.json.Value) !void {
if (get(v, "ok").bool) return;
std.debug.print("\nrefused: {s}: {s}\n", .{ str(get(v, "error"), "code"), str(get(v, "error"), "message") });
return error.TestUnexpectedResult;
}
fn expectError(v: std.json.Value, code: []const u8, exit: i64) !void {
try std.testing.expect(!get(v, "ok").bool);
try std.testing.expectEqualStrings(code, str(get(v, "error"), "code"));
try std.testing.expectEqual(exit, get(get(v, "error"), "exit").integer);
}
fn put(name: []const u8, bytes: []const u8) protocol.Edit {
return .{ .put = .{ .name = name, .size = bytes.len } };
}
fn commit(key: []const u8, parent: ?[]const u8, comment: []const u8, sec: i64, edits: []const protocol.Edit) protocol.Commit {
return .{ .key = key, .parent = parent, .user = "e", .comment = comment, .time = .{ .epoch = epoch0 + sec, .ms = 7 }, .edits = edits };
}
fn sha3Hex(bytes: []const u8) [64]u8 {
var d: [32]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(bytes, &d, .{});
return std.fmt.bytesToHex(d, .lower);
}
test "daemon: hello, commit, a resend, pipelining, refusals, get, and the socket's guards" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("store");
var d = try Daemon.start(&fx, "store", "s/d.sock", .{});
var running = true;
defer if (running) d.child.kill(fx.io);
// Socket 0600 in a 0700 directory.
const sock_st = try fx.tmp.dir.statFile(fx.io, "s/d.sock", .{});
try std.testing.expectEqual(@as(std.posix.mode_t, 0o600), sock_st.permissions.toMode() & 0o777);
const dir_st = try fx.tmp.dir.statFile(fx.io, "s", .{});
try std.testing.expectEqual(@as(std.posix.mode_t, 0o700), dir_st.permissions.toMode() & 0o777);
// A second daemon on the same store is refused, naming the first.
const second = try runZit(&fx, fx.tmp.dir, &.{ "daemon", "-R", "store", "--socket", "s/other.sock" }, null);
try expectExit(second, 1);
const holder = try std.fmt.allocPrint(a, "another daemon (process {d})", .{d.child.id.?});
try std.testing.expect(std.mem.indexOf(u8, second.stderr, holder) != null);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const hello = try c.call(a, .{ .id = 1, .op = .{ .hello = protocol.version } }, &.{});
try expectOk(hello);
try std.testing.expectEqual(protocol.version, get(hello, "protocol").integer);
try std.testing.expect(std.mem.endsWith(u8, str(hello, "store"), "store"));
try std.testing.expectEqual(@as(usize, 0), get(get(hello, "tip"), "branches").array.items.len);
// A commit, then the same commit again on the same parent.
const seed = try c.call(a, .{ .id = 2, .op = .{ .commit = commit("seed", null, "seed", 0, &.{put("a.txt", "alpha\n")}) } }, &.{"alpha\n"});
try expectOk(seed);
try std.testing.expectEqualStrings("recorded", str(seed, "outcome"));
try std.testing.expectEqualStrings("seed", str(seed, "key"));
try std.testing.expect(get(seed, "parent") == .null);
try std.testing.expectEqualStrings("trunk", str(seed, "branch"));
const p0 = str(seed, "hash");
const edits1 = [_]protocol.Edit{put("b.txt", "beta\n")};
const first = try c.call(a, .{ .id = 3, .op = .{ .commit = commit("k1", p0, "one", 1, &edits1) } }, &.{"beta\n"});
try expectOk(first);
try std.testing.expectEqualStrings("recorded", str(first, "outcome"));
try std.testing.expectEqualStrings(p0, str(first, "parent"));
try std.testing.expect(!get(first, "forked").bool);
const size_before = try fx.storeSize("store");
const again = try c.call(a, .{ .id = 4, .op = .{ .commit = commit("k1", p0, "one", 1, &edits1) } }, &.{"beta\n"});
try expectOk(again);
try std.testing.expectEqualStrings("existing", str(again, "outcome"));
try std.testing.expectEqualStrings(str(first, "hash"), str(again, "hash"));
try std.testing.expect(!get(again, "forked").bool);
try std.testing.expectEqual(size_before, try fx.storeSize("store"));
// Pipelined: three commits written before any reply is read, answered
// in order, each on the last.
var tip = try a.dupe(u8, str(first, "hash"));
{
const names = [_][]const u8{ "p1.txt", "p2.txt", "p3.txt" };
for (names, 0..) |n, i| try c.queue(.{ .id = @intCast(10 + i), .op = .{ .commit = commit(n, null, n, @intCast(10 + i), &.{put(n, n)}) } }, &.{n});
try c.writer.interface.flush();
for (0..names.len) |i| {
const v = try c.reply(a);
try std.testing.expectEqual(@as(i64, @intCast(10 + i)), get(v, "id").integer);
try expectOk(v);
try std.testing.expectEqualStrings(names[i], str(v, "key"));
try std.testing.expectEqualStrings(tip, str(v, "parent"));
tip = try a.dupe(u8, str(v, "hash"));
}
}
// The puts' hash is checked before anything is committed.
{
const bad = protocol.Edit{ .put = .{ .name = "h.txt", .size = 4, .hash = repeat("0", 64) } };
const v = try c.call(a, .{ .id = 20, .op = .{ .commit = commit("h", null, "h", 20, &.{bad}) } }, &.{"oops"});
try expectError(v, "hash_mismatch", 2);
const good_hash = sha3Hex("good");
const good = protocol.Edit{ .put = .{ .name = "h.txt", .size = 4, .hash = &good_hash } };
const ok = try c.call(a, .{ .id = 21, .op = .{ .commit = commit("h", null, "h", 21, &.{good}) } }, &.{"good"});
try expectOk(ok);
tip = try a.dupe(u8, str(ok, "hash"));
}
// The core's refusals come back by name.
{
const v = try c.call(a, .{ .id = 22, .op = .{ .commit = commit("x", null, "x", 22, &.{.{ .delete = "never.txt" }}) } }, &.{});
try expectError(v, "path_conflict", 1);
const stale = try c.call(a, .{ .id = 23, .op = .{ .commit = commit("x", p0, "x", 23, &.{put("z.txt", "z")}) } }, &.{"z"});
try expectError(stale, "not_tip", 1);
}
// get, one and several.
{
const alpha = sha3Hex("alpha\n");
const one = try c.call(a, .{ .id = 30, .op = .{ .get = .{ .one = &alpha } } }, &.{});
try expectOk(one);
try std.testing.expectEqual(@as(i64, 6), get(one, "size").integer);
try std.testing.expectEqualStrings("alpha\n", try c.take(a, 6));
const beta = sha3Hex("beta\n");
const nowhere = repeat("e", 64);
const many = try c.call(a, .{ .id = 31, .op = .{ .get = .{ .many = &.{ &beta, nowhere, &alpha } } } }, &.{});
try expectOk(many);
const items = get(many, "items").array.items;
try std.testing.expectEqual(@as(i64, 5), get(items[0], "size").integer);
try std.testing.expectEqualStrings("not_found", str(get(items[1], "error"), "code"));
try std.testing.expectEqual(@as(i64, 6), get(items[2], "size").integer);
try std.testing.expectEqualStrings("beta\nalpha\n", try c.take(a, 11));
const malformed = try c.call(a, .{ .id = 32, .op = .{ .get = .{ .one = "ABC" } } }, &.{});
try expectError(malformed, "bad_request", 2);
}
// A refused request leaves the connection usable; status still answers.
{
try c.writer.interface.writeAll("{\"id\":40,\"op\":\"rebase\"}\n");
try c.writer.interface.flush();
try expectError(try c.reply(a), "bad_request", 2);
const st = try c.call(a, .{ .id = 41, .op = .status }, &.{});
try expectOk(st);
const branches = get(st, "branches").array.items;
try std.testing.expectEqual(@as(usize, 1), branches.len);
try std.testing.expectEqualStrings("trunk", str(branches[0], "name"));
try std.testing.expectEqualStrings(tip, str(branches[0], "tip"));
}
// Unreadable framing closes the connection after the refusal.
{
try c.writer.interface.writeAll("not json\n");
try c.writer.interface.flush();
const v = try c.reply(a);
try expectError(v, "bad_request", 2);
try std.testing.expect(get(v, "id") == .null);
try std.testing.expectError(error.EndOfStream, c.reader.interface.takeByte());
}
running = false;
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
// Stopped: the socket is gone and the lock released.
try std.testing.expectError(error.FileNotFound, fx.tmp.dir.statFile(fx.io, "s/d.sock", .{}));
try std.testing.expectError(error.FileNotFound, fx.tmp.dir.statFile(fx.io, "store/.zit.daemon", .{}));
}
test "daemon: a put past the file ceiling is refused too_large and its bytes skipped" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("store");
var d = try Daemon.start(&fx, "store", "s/d.sock", .{ .env = &.{.{ "ZIT_DAEMON_MAX_FILE_BYTES", "16" }} });
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const big = repeat("x", 17);
const v = try c.call(a, .{ .id = 1, .op = .{ .commit = commit("big", null, "big", 0, &.{put("big.bin", big)}) } }, &.{big});
try expectError(v, "too_large", 1);
try std.testing.expect(std.mem.indexOf(u8, str(get(v, "error"), "message"), "16-byte limit") != null);
// The skipped bytes did not run into the next request.
const ok = try c.call(a, .{ .id = 2, .op = .{ .commit = commit("small", null, "small", 1, &.{put("s.txt", repeat("x", 16))}) } }, &.{repeat("x", 16)});
try expectOk(ok);
}
test "daemon: a batch records exactly what the same commits sent one at a time do" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("one");
{
var d = try Daemon.start(&fx, "one", "s/one.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/one.sock");
defer c.close(fx.io);
try expectOk(try c.call(a, .{ .id = 1, .op = .{ .commit = commit("seed", null, "seed", 0, &.{ put("a.txt", "a\n"), put("b.txt", "b\n"), put("d/c.txt", "c\n") }) } }, &.{ "a\n", "b\n", "c\n" }));
c.close(fx.io);
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
}
try fx.copyStore("one", "many");
// A put, a delete, a rename, an edit that changes nothing, a refusal,
// and a commit after the refusal.
const commits = [_]protocol.Commit{
commit("c1", null, "edit a", 1, &.{put("a.txt", "a2\n")}),
commit("c2", null, "drop b", 2, &.{.{ .delete = "b.txt" }}),
commit("c3", null, "move c", 3, &.{.{ .rename = .{ .old = "d/c.txt", .new = "e/c.txt" } }}),
commit("c4", null, "same", 4, &.{put("a.txt", "a2\n")}),
commit("c5", null, "refused", 5, &.{.{ .delete = "b.txt" }}),
commit("c6", null, "add f", 6, &.{ put("f.txt", "f\n"), .{ .delete = "a.txt" } }),
};
const trailers = [_][]const []const u8{ &.{"a2\n"}, &.{}, &.{}, &.{"a2\n"}, &.{}, &.{"f\n"} };
var singles: std.ArrayList(std.json.Value) = .empty;
{
var d = try Daemon.start(&fx, "one", "s/one.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/one.sock");
defer c.close(fx.io);
for (commits, trailers, 0..) |cm, t, i| try singles.append(a, try c.call(a, .{ .id = @intCast(i), .op = .{ .commit = cm } }, t));
c.close(fx.io);
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
}
var batch: std.json.Value = undefined;
{
var d = try Daemon.start(&fx, "many", "s/many.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/many.sock");
defer c.close(fx.io);
var all: std.ArrayList([]const u8) = .empty;
for (trailers) |t| try all.appendSlice(a, t);
batch = try c.call(a, .{ .id = 99, .op = .{ .batch = &commits } }, all.items);
c.close(fx.io);
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
}
try expectOk(batch);
const results = get(batch, "results").array.items;
try std.testing.expectEqual(commits.len, results.len);
for (singles.items, results, commits) |single, item, cm| {
try std.testing.expectEqualStrings(cm.key.?, str(item, "key"));
try std.testing.expectEqual(get(single, "ok").bool, get(item, "ok").bool);
if (get(single, "ok").bool) {
for ([_][]const u8{ "outcome", "hash", "parent", "branch" }) |k| try std.testing.expectEqualStrings(str(single, k), str(item, k));
} else {
try std.testing.expectEqualStrings(str(get(single, "error"), "code"), str(get(item, "error"), "code"));
}
}
try std.testing.expectEqualStrings("unchanged", str(results[3], "outcome"));
try std.testing.expectEqualStrings("path_conflict", str(get(results[4], "error"), "code"));
try std.testing.expect(get(results[5], "ok").bool);
try std.testing.expectEqualSlices(u8, try fx.storeBytes("one"), try fx.storeBytes("many"));
}
test "daemon: a rename and a put of its new name are one rename with content, as commit -R records it" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("store");
const old_bytes = "alpha\nbeta\n";
const new_bytes = "alpha\nbeta\ngamma\n";
const seed_hash = blk: {
var d = try Daemon.start(&fx, "store", "s/d.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const v = try c.call(a, .{ .id = 1, .op = .{ .commit = commit("seed", null, "seed", 0, &.{ put("a.md", old_bytes), put("c.md", "c\n"), put("d.md", "d\n") }) } }, &.{ old_bytes, "c\n", "d\n" });
try expectOk(v);
c.close(fx.io);
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
break :blk try a.dupe(u8, str(v, "hash"));
};
try fx.copyStore("store", "cli");
try fx.copyStore("store", "batch");
const move = commit("mv", null, "move", 1, &.{ .{ .rename = .{ .old = "a.md", .new = "b.md" } }, put("b.md", new_bytes) });
var d = try Daemon.start(&fx, "store", "s/d.sock", .{});
var running = true;
defer if (running) d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const moved = try c.call(a, .{ .id = 2, .op = .{ .commit = move } }, &.{new_bytes});
try expectOk(moved);
try std.testing.expectEqualStrings("recorded", str(moved, "outcome"));
const hash = str(moved, "hash");
// The CLI's rename-with-content, at the same time, is the same check-in.
{
var time_buf: [24]u8 = undefined;
try fx.tmp.dir.writeFile(fx.io, .{ .sub_path = "stdin.in", .data = new_bytes });
const res = try runZit(&fx, fx.tmp.dir, &.{ "commit", "-R", "cli", "-m", "move", "--user", "e", "--rename", "a.md", "b.md", "--stdin", "b.md", "--date-override", protocol.formatTime(move.time, &time_buf) }, "stdin.in");
try expectExit(res, 0);
try std.testing.expectEqualStrings(hash, std.mem.trimEnd(u8, res.stdout, "\n"));
}
// And so is the same commit in a batch.
{
var bd = try Daemon.start(&fx, "batch", "s/b.sock", .{});
defer bd.child.kill(fx.io);
var bc = try Client.open(&fx, "s/b.sock");
defer bc.close(fx.io);
const v = try bc.call(a, .{ .id = 3, .op = .{ .batch = &.{move} } }, &.{new_bytes});
try expectOk(v);
const item = get(v, "results").array.items[0];
try expectOk(item);
try std.testing.expectEqualStrings(hash, str(item, "hash"));
bc.close(fx.io);
try Daemon.expectExited(&fx, try bd.stop(fx.io), 0);
}
// log and annotate follow the rename back to the seed.
{
const res = try runZit(&fx, fx.tmp.dir, &.{ "log", hash, "b.md", "-R", "store" }, null);
try expectExit(res, 0);
try std.testing.expect(std.mem.indexOf(u8, res.stdout, seed_hash[0..10]) != null);
const ann = try runZit(&fx, fx.tmp.dir, &.{ "annotate", "b.md", hash, "-R", "store" }, null);
try expectExit(ann, 0);
try std.testing.expect(std.mem.indexOf(u8, ann.stdout, seed_hash[0..12]) != null);
try std.testing.expect(std.mem.indexOf(u8, ann.stdout, hash[0..12]) != null);
}
// Every other repeat of a path is refused, and the store is unchanged.
const refusals = [_]struct { edits: []const protocol.Edit, trailers: []const []const u8 }{
.{ .edits = &.{ put("c.md", "1\n"), put("c.md", "2\n") }, .trailers = &.{ "1\n", "2\n" } },
.{ .edits = &.{ .{ .rename = .{ .old = "c.md", .new = "x.md" } }, put("c.md", "1\n") }, .trailers = &.{"1\n"} },
.{ .edits = &.{ .{ .rename = .{ .old = "c.md", .new = "x.md" } }, .{ .rename = .{ .old = "d.md", .new = "x.md" } } }, .trailers = &.{} },
.{ .edits = &.{ .{ .rename = .{ .old = "c.md", .new = "x.md" } }, .{ .rename = .{ .old = "d.md", .new = "x.md" } }, put("x.md", "1\n") }, .trailers = &.{"1\n"} },
.{ .edits = &.{ .{ .rename = .{ .old = "c.md", .new = "x.md" } }, put("x.md", "1\n"), put("x.md", "2\n") }, .trailers = &.{ "1\n", "2\n" } },
};
const before = try fx.storeSize("store");
for (refusals, 0..) |r, i| {
const v = try c.call(a, .{ .id = @intCast(10 + i), .op = .{ .commit = commit("dup", null, "dup", @intCast(10 + i), r.edits) } }, r.trailers);
try expectError(v, "invalid_request", 1);
try std.testing.expectEqual(before, try fx.storeSize("store"));
}
c.close(fx.io);
running = false;
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
try std.testing.expectEqual(before, try fx.storeSize("store"));
}
test "daemon: feed from zero, nothing new, a CLI commit beside it, forks, purged content, and a pack" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("store");
var d = try Daemon.start(&fx, "store", "s/d.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const seed = try c.call(a, .{ .id = 1, .op = .{ .commit = commit("seed", null, "seed", 0, &.{ put("a.txt", "a1\n"), put("b.txt", "b\n") }) } }, &.{ "a1\n", "b\n" });
try expectOk(seed);
const p0 = try a.dupe(u8, str(seed, "hash"));
const second = try c.call(a, .{ .id = 2, .op = .{ .commit = commit("two", p0, "two", 1, &.{ put("a.txt", "a2\n"), .{ .rename = .{ .old = "b.txt", .new = "c.txt" } } }) } }, &.{"a2\n"});
try expectOk(second);
const p1 = try a.dupe(u8, str(second, "hash"));
const all = try c.call(a, .{ .id = 3, .op = .{ .feed = "0" } }, &.{});
try expectOk(all);
const changes = get(all, "changes").array.items;
try std.testing.expectEqual(@as(usize, 2), changes.len);
try std.testing.expectEqualStrings(p0, str(changes[0], "checkin"));
try std.testing.expect(get(changes[0], "parent") == .null);
try std.testing.expectEqualStrings("trunk", str(changes[0], "branch"));
try std.testing.expectEqualStrings("e", str(changes[0], "user"));
try std.testing.expectEqualStrings("seed", str(changes[0], "comment"));
try std.testing.expectEqualStrings("2027-01-15T08:00:00.007Z", str(changes[0], "time"));
try std.testing.expectEqual(@as(usize, 2), get(changes[0], "files").array.items.len);
try std.testing.expectEqualStrings("added", str(get(changes[0], "files").array.items[0], "state"));
try std.testing.expectEqualStrings(p1, str(changes[1], "checkin"));
try std.testing.expectEqualStrings(p0, str(changes[1], "parent"));
{
var seen: usize = 0;
for (get(changes[1], "files").array.items) |row| {
const name = str(row, "name");
if (std.mem.eql(u8, name, "a.txt")) {
try std.testing.expectEqualStrings("modified", str(row, "state"));
try std.testing.expectEqualStrings(&sha3Hex("a2\n"), str(row, "uuid"));
} else if (std.mem.eql(u8, name, "b.txt")) {
try std.testing.expectEqualStrings("removed", str(row, "state"));
try std.testing.expect(get(row, "uuid") == .null);
} else {
try std.testing.expectEqualStrings("c.txt", name);
try std.testing.expectEqualStrings("modified", str(row, "state"));
try std.testing.expectEqualStrings("b.txt", str(row, "prior"));
}
seen += 1;
}
try std.testing.expectEqual(@as(usize, 3), seen);
}
try std.testing.expectEqual(@as(usize, 0), get(all, "forks").array.items.len);
const cursor1 = try a.dupe(u8, str(all, "cursor"));
// Nothing new: the same cursor and empty lists.
const idle = try c.call(a, .{ .id = 4, .op = .{ .feed = cursor1 } }, &.{});
try expectOk(idle);
try std.testing.expectEqualStrings(cursor1, str(idle, "cursor"));
try std.testing.expectEqual(@as(usize, 0), get(idle, "changes").array.items.len);
// A CLI commit lands while the daemon runs; the next feed has it, and the
// daemon's own next commit builds on it.
try fx.tmp.dir.writeFile(fx.io, .{ .sub_path = "cli.txt", .data = "from the cli\n" });
const cli = try runZit(&fx, fx.tmp.dir, &.{ "commit", "-R", "store", "--stdin", "cli.txt", "-m", "cli", "--user", "u" }, "cli.txt");
try expectExit(cli, 0);
const cli_hash = std.mem.trimEnd(u8, cli.stdout, "\n");
const after = try c.call(a, .{ .id = 5, .op = .{ .feed = cursor1 } }, &.{});
try expectOk(after);
try std.testing.expectEqual(@as(usize, 1), get(after, "changes").array.items.len);
try std.testing.expectEqualStrings(cli_hash, str(get(after, "changes").array.items[0], "checkin"));
try std.testing.expectEqualStrings("u", str(get(after, "changes").array.items[0], "user"));
const cursor2 = try a.dupe(u8, str(after, "cursor"));
const on_cli = try c.call(a, .{ .id = 6, .op = .{ .commit = commit("on", null, "on cli", 3, &.{put("d.txt", "d\n")}) } }, &.{"d\n"});
try expectOk(on_cli);
try std.testing.expectEqualStrings(cli_hash, str(on_cli, "parent"));
// A deliberate fork: the feed names the branch's two leaves.
const fork = try c.call(a, .{ .id = 7, .op = .{ .commit = blk: {
var cm = commit("fork", p1, "fork", 4, &.{put("f.txt", "f\n")});
cm.allow_fork = true;
break :blk cm;
} } }, &.{"f\n"});
try expectOk(fork);
try std.testing.expect(get(fork, "forked").bool);
// Its resend is the same check-in, still reported as the fork it made.
{
const size = try fx.storeSize("store");
var cm = commit("fork", p1, "fork", 4, &.{put("f.txt", "f\n")});
cm.allow_fork = true;
const again = try c.call(a, .{ .id = 70, .op = .{ .commit = cm } }, &.{"f\n"});
try expectOk(again);
try std.testing.expectEqualStrings("existing", str(again, "outcome"));
try std.testing.expectEqualStrings(str(fork, "hash"), str(again, "hash"));
try std.testing.expectEqualStrings(p1, str(again, "parent"));
try std.testing.expect(get(again, "forked").bool);
try std.testing.expectEqual(size, try fx.storeSize("store"));
}
const forked = try c.call(a, .{ .id = 8, .op = .{ .feed = cursor2 } }, &.{});
try expectOk(forked);
try std.testing.expectEqual(@as(usize, 2), get(forked, "changes").array.items.len);
const forks = get(forked, "forks").array.items;
try std.testing.expectEqual(@as(usize, 1), forks.len);
try std.testing.expectEqualStrings("trunk", str(forks[0], "branch"));
try std.testing.expectEqual(@as(usize, 2), get(forks[0], "leaves").array.items.len);
const cursor3 = try a.dupe(u8, str(forked, "cursor"));
// The first a.txt is held by no open leaf: purged, it reads as absent
// by policy, in either form of get.
const a1 = sha3Hex("a1\n");
const purged = try runZit(&fx, fx.tmp.dir, &.{ "purge", "artifacts", "-R", "store", "--force", &a1 }, null);
try expectExit(purged, 0);
const gone = try c.call(a, .{ .id = 9, .op = .{ .get = .{ .one = &a1 } } }, &.{});
try expectError(gone, "content_absent", 8);
const a2 = sha3Hex("a2\n");
const mixed = try c.call(a, .{ .id = 10, .op = .{ .get = .{ .many = &.{ &a1, &a2 } } } }, &.{});
try expectOk(mixed);
try std.testing.expectEqualStrings("content_absent", str(get(get(mixed, "items").array.items[0], "error"), "code"));
try std.testing.expectEqual(@as(i64, 8), get(get(get(mixed, "items").array.items[0], "error"), "exit").integer);
try std.testing.expectEqualStrings("a2\n", try c.take(a, 3));
// A pack that reclaims a deferred shun relocates frames: the old cursor
// is refused, and the feed starts again from zero.
const shunned = sha3Hex("f\n");
const shun = try runZit(&fx, fx.tmp.dir, &.{ "shun", "-R", "store", "--defer", "--force", &shunned }, null);
try expectExit(shun, 0);
const pack = try runZit(&fx, fx.tmp.dir, &.{ "pack", "-R", "store" }, null);
try expectExit(pack, 0);
const dead = try c.call(a, .{ .id = 11, .op = .{ .feed = cursor3 } }, &.{});
try expectError(dead, "cursor_invalid", 4);
const restart = try c.call(a, .{ .id = 12, .op = .{ .feed = "0" } }, &.{});
try expectOk(restart);
try std.testing.expectEqual(@as(usize, 5), get(restart, "changes").array.items.len);
// The daemon commits on the rewritten store as on any other.
const after_pack = try c.call(a, .{ .id = 13, .op = .{ .commit = commit("post", null, "post", 5, &.{put("g.txt", "g\n")}) } }, &.{"g\n"});
try expectOk(after_pack);
try std.testing.expectEqualStrings("recorded", str(after_pack, "outcome"));
}
test "daemon: --exit-on-stdin-close stops when the parent's pipe closes" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
try fx.makeStore("store");
var d = try Daemon.start(&fx, "store", "s/d.sock", .{ .extra = &.{"--exit-on-stdin-close"}, .stdin_pipe = true });
defer if (d.child.id != null) d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
try expectOk(try c.call(fx.a(), .{ .id = 1, .op = .status }, &.{}));
c.close(fx.io);
d.child.stdin.?.close(fx.io);
d.child.stdin = null;
try Daemon.expectExited(&fx, try d.child.wait(fx.io), 0);
try std.testing.expectError(error.FileNotFound, fx.tmp.dir.statFile(fx.io, "s/d.sock", .{}));
try std.testing.expectError(error.FileNotFound, fx.tmp.dir.statFile(fx.io, "store/.zit.daemon", .{}));
}
test "daemon: a crash on either side of the fsync leaves one check-in for the resend" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
for ([_][]const u8{ "after_append", "after_sync" }, 0..) |point, i| {
const store = try std.fmt.allocPrint(a, "store{d}", .{i});
try fx.makeStore(store);
var p0: []const u8 = undefined;
{
var d = try Daemon.start(&fx, store, "s/d.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const seed = try c.call(a, .{ .id = 1, .op = .{ .commit = commit("seed", null, "seed", 0, &.{put("a.txt", "a\n")}) } }, &.{"a\n"});
try expectOk(seed);
p0 = try a.dupe(u8, str(seed, "hash"));
c.close(fx.io);
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
}
const edits = [_]protocol.Edit{put("b.txt", "b\n")};
{
var d = try Daemon.start(&fx, store, "s/d.sock", .{ .env = &.{.{ "ZIT_DAEMON_FAULT", point }} });
defer if (d.child.id != null) d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
try c.send(.{ .id = 2, .op = .{ .commit = commit("b", p0, "b", 1, &edits) } }, &.{"b\n"});
// The process stops before it answers.
try std.testing.expectError(error.EndOfStream, c.reader.interface.takeByte());
try Daemon.expectExited(&fx, try d.child.wait(fx.io), 70);
}
{
// The dead daemon's socket and lock are reclaimed by the next.
var d = try Daemon.start(&fx, store, "s/d.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const resent = try c.call(a, .{ .id = 3, .op = .{ .commit = commit("b", p0, "b", 1, &edits) } }, &.{"b\n"});
try expectOk(resent);
const outcome = str(resent, "outcome");
try std.testing.expect(std.mem.eql(u8, outcome, "recorded") or std.mem.eql(u8, outcome, "existing"));
c.close(fx.io);
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
const log = try fx.trunkLog(store);
try std.testing.expectEqual(@as(usize, 2), log.len);
try std.testing.expectEqualStrings(str(resent, "hash"), log[0]);
try std.testing.expectEqualStrings(p0, log[1]);
}
}
}
/// What a resend case does between the crash and the resend.
const Between = enum { nothing, unrelated_cli_commit, cli_commit_same_path };
test "daemon: every resend after a crash is answered by the check-in the first send made" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("store");
var p_tip: []const u8 = undefined;
{
var d = try Daemon.start(&fx, "store", "s/d.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const seed = try c.call(a, .{ .id = 1, .op = .{ .commit = commit("seed", null, "seed", 0, &.{
put("a.txt", "a\n"), put("b.txt", "b\n"), put("c.txt", "c\n"), put("d.txt", "d\n"),
put("e.txt", "e\n"), put("f.txt", "f\n"), put("g.txt", "g\n"), put("h.txt", "h\n"),
}) } }, &.{ "a\n", "b\n", "c\n", "d\n", "e\n", "f\n", "g\n", "h\n" });
try expectOk(seed);
p_tip = try a.dupe(u8, str(seed, "hash"));
c.close(fx.io);
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
}
const Case = struct { edits: []const protocol.Edit, trailers: []const []const u8, explicit: bool, between: Between = .nothing };
const cases = [_]Case{
.{ .edits = &.{put("a.txt", "a2\n")}, .trailers = &.{"a2\n"}, .explicit = false },
.{ .edits = &.{put("a.txt", "a3\n")}, .trailers = &.{"a3\n"}, .explicit = true },
.{ .edits = &.{.{ .delete = "b.txt" }}, .trailers = &.{}, .explicit = false },
.{ .edits = &.{.{ .delete = "c.txt" }}, .trailers = &.{}, .explicit = true },
.{ .edits = &.{.{ .rename = .{ .old = "d.txt", .new = "d2.txt" } }}, .trailers = &.{}, .explicit = false },
.{ .edits = &.{.{ .rename = .{ .old = "e.txt", .new = "e2.txt" } }}, .trailers = &.{}, .explicit = true },
.{ .edits = &.{ .{ .delete = "f.txt" }, .{ .rename = .{ .old = "g.txt", .new = "g2.txt" } }, put("n1.txt", "n1\n") }, .trailers = &.{"n1\n"}, .explicit = false },
.{ .edits = &.{ .{ .delete = "g2.txt" }, .{ .rename = .{ .old = "h.txt", .new = "h2.txt" } }, put("n2.txt", "n2\n") }, .trailers = &.{"n2\n"}, .explicit = true },
.{ .edits = &.{put("n3.txt", "n3\n")}, .trailers = &.{"n3\n"}, .explicit = false, .between = .unrelated_cli_commit },
.{ .edits = &.{put("n4.txt", "n4\n")}, .trailers = &.{"n4\n"}, .explicit = false, .between = .cli_commit_same_path },
};
for (cases, 0..) |case, i| {
const key = try std.fmt.allocPrint(a, "case{d}", .{i});
const cm = commit(key, if (case.explicit) p_tip else null, key, @intCast(10 + i), case.edits);
{
var d = try Daemon.start(&fx, "store", "s/d.sock", .{ .env = &.{.{ "ZIT_DAEMON_FAULT", "after_sync" }} });
defer if (d.child.id != null) d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
try c.send(.{ .id = 2, .op = .{ .commit = cm } }, case.trailers);
try std.testing.expectError(error.EndOfStream, c.reader.interface.takeByte());
try Daemon.expectExited(&fx, try d.child.wait(fx.io), 70);
}
// The crash came after the fsync: the check-in is the store's tip.
const made = (try fx.trunkLog("store"))[0];
try std.testing.expect(!std.mem.eql(u8, made, p_tip));
switch (case.between) {
.nothing => {},
.unrelated_cli_commit, .cli_commit_same_path => {
const path = if (case.between == .unrelated_cli_commit) "other.txt" else "n4.txt";
const body = try std.fmt.allocPrint(a, "cli {d}\n", .{i});
try fx.tmp.dir.writeFile(fx.io, .{ .sub_path = "cli.txt", .data = body });
const cli = try runZit(&fx, fx.tmp.dir, &.{ "commit", "-R", "store", "--stdin", path, "-m", "cli", "--user", "u" }, "cli.txt");
try expectExit(cli, 0);
},
}
const size = try fx.storeSize("store");
var d = try Daemon.start(&fx, "store", "s/d.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const resent = try c.call(a, .{ .id = 3, .op = .{ .commit = cm } }, case.trailers);
try expectOk(resent);
try std.testing.expectEqualStrings("existing", str(resent, "outcome"));
try std.testing.expectEqualStrings(made, str(resent, "hash"));
try std.testing.expectEqualStrings(p_tip, str(resent, "parent"));
try std.testing.expect(!get(resent, "forked").bool);
try std.testing.expectEqual(size, try fx.storeSize("store"));
// The tip by ancestry, not by time: a CLI commit carries today's
// clock, older than these requests' times.
const st = try c.call(a, .{ .id = 4, .op = .status }, &.{});
p_tip = try a.dupe(u8, str(get(st, "branches").array.items[0], "tip"));
c.close(fx.io);
try Daemon.expectExited(&fx, try d.stop(fx.io), 0);
}
}
test "daemon: the socket path is taken over only from a dead daemon" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
try fx.makeStore("one");
try fx.makeStore("two");
// A regular file at the path is the user's, not a stale socket.
try fx.tmp.dir.createDir(fx.io, "s", .fromMode(0o700));
try fx.tmp.dir.writeFile(fx.io, .{ .sub_path = "s/d.sock", .data = "mine\n" });
const refused = try runZit(&fx, fx.tmp.dir, &.{ "daemon", "-R", "one", "--socket", "s/d.sock" }, null);
try expectExit(refused, 1);
try std.testing.expect(std.mem.indexOf(u8, refused.stderr, "is not a socket") != null);
try std.testing.expectEqualStrings("mine\n", try fx.tmp.dir.readFileAlloc(fx.io, "s/d.sock", fx.a(), .limited(64)));
try fx.tmp.dir.deleteFile(fx.io, "s/d.sock");
// A live daemon of another store keeps its socket.
var d = try Daemon.start(&fx, "one", "s/d.sock", .{});
defer d.child.kill(fx.io);
const second = try runZit(&fx, fx.tmp.dir, &.{ "daemon", "-R", "two", "--socket", "s/d.sock" }, null);
try expectExit(second, 1);
try std.testing.expect(std.mem.indexOf(u8, second.stderr, "already answers") != null);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const hello = try c.call(fx.a(), .{ .id = 1, .op = .{ .hello = protocol.version } }, &.{});
try std.testing.expect(std.mem.endsWith(u8, str(hello, "store"), "one"));
}
test "daemon: a failed fsync is answered as a failure and stops the daemon" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
try fx.makeStore("store");
var d = try Daemon.start(&fx, "store", "s/d.sock", .{ .env = &.{.{ "ZIT_DAEMON_FAULT", "sync_fails" }} });
defer if (d.child.id != null) d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const v = try c.call(fx.a(), .{ .id = 1, .op = .{ .commit = commit("k", null, "k", 0, &.{put("a.txt", "a\n")}) } }, &.{"a\n"});
try expectError(v, "storage", 1);
try std.testing.expectError(error.EndOfStream, c.reader.interface.takeByte());
try Daemon.expectExited(&fx, try d.child.wait(fx.io), 1);
const said = try fx.tmp.dir.readFileAlloc(fx.io, "daemon.err", fx.a(), .limited(1 << 20));
try std.testing.expect(std.mem.indexOf(u8, said, "fsync failed") != null);
}
test "daemon: oversized requests, stalled trailers and overlong headers end their connection, not the daemon" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("store");
var d = try Daemon.start(&fx, "store", "s/d.sock", .{ .env = &.{
.{ "ZIT_DAEMON_MAX_REQUEST_BYTES", "10" },
.{ "ZIT_DAEMON_STALL_MS", "200" },
} });
defer d.child.kill(fx.io);
{
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const v = try c.call(a, .{ .id = 1, .op = .{ .commit = commit("big", null, "big", 0, &.{ put("a", "123456"), put("b", "123456") }) } }, &.{ "123456", "123456" });
try expectError(v, "too_large", 1);
try std.testing.expectError(error.EndOfStream, c.reader.interface.takeByte());
}
{
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
try c.send(.{ .id = 2, .op = .{ .commit = commit("slow", null, "slow", 1, &.{put("a", "12345678")}) } }, &.{"123"});
// The rest never comes: the daemon closes the connection.
try std.testing.expectError(error.EndOfStream, c.reader.interface.takeByte());
}
{
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const chunk = try a.alloc(u8, 64 * 1024);
@memset(chunk, ' ');
// Past 1 MiB with no newline; the daemon may close before it all goes.
for (0..17) |_| c.writer.interface.writeAll(chunk) catch break;
c.writer.interface.flush() catch {};
const v = try c.reply(a);
try expectError(v, "header_too_large", 2);
try std.testing.expect(get(v, "id") == .null);
}
// An idle connection is never a stalled one, and the daemon still serves.
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
try expectOk(try c.call(a, .{ .id = 3, .op = .status }, &.{}));
try fx.io.sleep(.fromMilliseconds(600), .awake);
try expectOk(try c.call(a, .{ .id = 4, .op = .status }, &.{}));
const said = try fx.tmp.dir.readFileAlloc(fx.io, "daemon.err", a, .limited(1 << 20));
try std.testing.expect(std.mem.indexOf(u8, said, "closed a connection whose request sent nothing") != null);
}
test "daemon: a second client is served once the first leaves, and --idle-exit stops an unused daemon" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("store");
var d = try Daemon.start(&fx, "store", "s/d.sock", .{ .extra = &.{ "--idle-exit", "1" } });
defer if (d.child.id != null) d.child.kill(fx.io);
var first = try Client.open(&fx, "s/d.sock");
defer first.close(fx.io);
try expectOk(try first.call(a, .{ .id = 1, .op = .status }, &.{}));
var second = try Client.open(&fx, "s/d.sock");
defer second.close(fx.io);
try second.send(.{ .id = 2, .op = .status }, &.{});
// The first keeps its connection past the idle deadline: an open
// connection is not idle, and the first is still answered.
try fx.io.sleep(.fromMilliseconds(1500), .awake);
try expectOk(try first.call(a, .{ .id = 3, .op = .status }, &.{}));
first.close(fx.io);
const queued = try second.reply(a);
try std.testing.expectEqual(@as(i64, 2), get(queued, "id").integer);
try expectOk(queued);
second.close(fx.io);
// Nobody connected: the daemon goes on its own, cleanly.
try Daemon.expectExited(&fx, try d.child.wait(fx.io), 0);
try std.testing.expectError(error.FileNotFound, fx.tmp.dir.statFile(fx.io, "s/d.sock", .{}));
}
fn lookup(time: protocol.Lookup) protocol.Request {
return .{ .id = 0, .op = .{ .lookup = .{ .one = time } } };
}
fn ident(comment: []const u8, sec: i64, ms: u16) protocol.Lookup {
return .{ .user = "e", .comment = comment, .time = .{ .epoch = epoch0 + sec, .ms = ms } };
}
/// A lookup's answer is the commit resend's, field for field.
fn expectSameAsResend(found: std.json.Value, resend: std.json.Value) !void {
try expectOk(found);
try expectOk(resend);
try std.testing.expectEqualStrings("existing", str(resend, "outcome"));
try std.testing.expect(get(found, "found").bool);
try std.testing.expectEqualStrings(str(resend, "hash"), str(found, "hash"));
try std.testing.expectEqualStrings(str(resend, "branch"), str(found, "branch"));
try std.testing.expectEqual(get(resend, "forked").bool, get(found, "forked").bool);
switch (get(resend, "parent")) {
.null => try std.testing.expect(get(found, "parent") == .null),
else => try std.testing.expectEqualStrings(str(resend, "parent"), str(found, "parent")),
}
}
fn expectNotFound(v: std.json.Value) !void {
try expectOk(v);
try std.testing.expect(!get(v, "found").bool);
try std.testing.expectEqual(@as(usize, 3), v.object.count());
}
test "daemon: lookup answers what a resend would, for daemon and CLI commits, and writes nothing" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("store");
var d = try Daemon.start(&fx, "store", "s/d.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
// Nothing stored yet: not found, and no index to build.
try expectNotFound(try c.call(a, lookup(ident("seed", 0, 7)), &.{}));
const seed = try c.call(a, .{ .id = 1, .op = .{ .commit = commit("seed", null, "seed", 0, &.{put("a.txt", "a1\n")}) } }, &.{"a1\n"});
try expectOk(seed);
const p0 = try a.dupe(u8, str(seed, "hash"));
// Found right after the daemon's own commit.
{
const v = try c.call(a, lookup(ident("seed", 0, 7)), &.{});
try expectOk(v);
try std.testing.expect(get(v, "found").bool);
try std.testing.expectEqualStrings(p0, str(v, "hash"));
try std.testing.expect(get(v, "parent") == .null);
try std.testing.expectEqualStrings("trunk", str(v, "branch"));
try std.testing.expect(!get(v, "forked").bool);
}
const two = try c.call(a, .{ .id = 2, .op = .{ .commit = commit("two", p0, "two", 1, &.{put("a.txt", "a2\n")}) } }, &.{"a2\n"});
try expectOk(two);
const p1 = try a.dupe(u8, str(two, "hash"));
// A fork on p0: found as forked, as its resend reports it.
const fork = try c.call(a, .{ .id = 3, .op = .{ .commit = blk: {
var cm = commit("fork", p0, "fork", 2, &.{put("f.txt", "f\n")});
cm.allow_fork = true;
break :blk cm;
} } }, &.{"f\n"});
try expectOk(fork);
try std.testing.expect(get(fork, "forked").bool);
// A CLI commit with a pinned time lands beside the daemon.
try fx.tmp.dir.writeFile(fx.io, .{ .sub_path = "cli.txt", .data = "from the cli\n" });
const cli = try runZit(&fx, fx.tmp.dir, &.{ "commit", "-R", "store", "--stdin", "cli.txt", "-m", "from cli", "--user", "u", "--date-override", "2027-01-15T08:00:10.123Z" }, "cli.txt");
try expectExit(cli, 0);
const cli_hash = try a.dupe(u8, std.mem.trimEnd(u8, cli.stdout, "\n"));
const cli_ident: protocol.Lookup = .{ .user = "u", .comment = "from cli", .time = .{ .epoch = epoch0 + 10, .ms = 123 } };
// From here on nothing may write: every lookup leaves the bytes alone.
const before = try fx.storeBytes("store");
{
const v = try c.call(a, lookup(cli_ident), &.{});
try expectOk(v);
try std.testing.expect(get(v, "found").bool);
try std.testing.expectEqualStrings(cli_hash, str(v, "hash"));
try std.testing.expectEqualStrings("trunk", str(v, "branch"));
}
// Each lookup agrees with the resend of the same commit.
try expectSameAsResend(
try c.call(a, .{ .id = 4, .op = .{ .lookup = .{ .one = .{ .user = "e", .comment = "two", .time = .{ .epoch = epoch0 + 1, .ms = 7 }, .parent = p0 } } } }, &.{}),
try c.call(a, .{ .id = 4, .op = .{ .commit = commit("two", p0, "two", 1, &.{put("a.txt", "a2\n")}) } }, &.{"a2\n"}),
);
try expectSameAsResend(
try c.call(a, lookup(ident("fork", 2, 7)), &.{}),
try c.call(a, .{ .id = 0, .op = .{ .commit = blk: {
var cm = commit("fork", null, "fork", 2, &.{put("f.txt", "f\n")});
cm.allow_fork = true;
break :blk cm;
} } }, &.{"f\n"}),
);
try expectSameAsResend(
try c.call(a, lookup(ident("seed", 0, 7)), &.{}),
try c.call(a, .{ .id = 0, .op = .{ .commit = commit("seed", null, "seed", 0, &.{put("a.txt", "a1\n")}) } }, &.{"a1\n"}),
);
// A different millisecond, user or comment is another check-in.
try expectNotFound(try c.call(a, lookup(ident("seed", 0, 8)), &.{}));
try expectNotFound(try c.call(a, lookup(.{ .user = "f", .comment = "seed", .time = .{ .epoch = epoch0, .ms = 7 } }), &.{}));
try expectNotFound(try c.call(a, lookup(ident("seed ", 0, 7)), &.{}));
// A named parent must be the one it was built on; a named branch, its own.
{
var q = ident("two", 1, 7);
q.parent = p0;
const v = try c.call(a, lookup(q), &.{});
try expectOk(v);
try std.testing.expectEqualStrings(p1, str(v, "hash"));
try std.testing.expectEqualStrings(p0, str(v, "parent"));
q.parent = p1;
try expectNotFound(try c.call(a, lookup(q), &.{}));
q.parent = repeat("0", 64);
try expectNotFound(try c.call(a, lookup(q), &.{}));
q.parent = null;
q.branch = "trunk";
try std.testing.expect(get(try c.call(a, lookup(q), &.{}), "found").bool);
q.branch = "other";
try expectNotFound(try c.call(a, lookup(q), &.{}));
}
// The list form answers each in order.
{
const items = [_]protocol.Lookup{ cli_ident, ident("seed", 0, 8), ident("seed", 0, 7) };
const v = try c.call(a, .{ .id = 5, .op = .{ .lookup = .{ .many = &items } } }, &.{});
try expectOk(v);
const results = get(v, "results").array.items;
try std.testing.expectEqual(@as(usize, 3), results.len);
try std.testing.expectEqualStrings(cli_hash, str(results[0], "hash"));
try std.testing.expect(!get(results[1], "found").bool);
try std.testing.expectEqual(@as(usize, 1), results[1].object.count());
try std.testing.expectEqualStrings(p0, str(results[2], "hash"));
}
// Malformed requests are refused as any other request is.
try c.send(.{ .id = 6, .op = .status }, &.{});
_ = try c.reply(a);
try c.writer.interface.writeAll("{\"id\":7,\"op\":\"lookup\",\"time\":\"2027-01-15T08:00:00Z\",\"user\":\"e\",\"comment\":\"seed\"}\n");
try c.writer.interface.flush();
try expectError(try c.reply(a), "bad_request", 2);
try c.writer.interface.writeAll("{\"id\":8,\"op\":\"lookup\",\"items\":[{\"user\":\"e\"}]}\n");
try c.writer.interface.flush();
try expectError(try c.reply(a), "bad_request", 2);
const after = try fx.storeBytes("store");
try std.testing.expectEqual(before.len, after.len);
try std.testing.expectEqualSlices(u8, before, after);
}
test "daemon: lookup answers while another process holds the store's writer lock" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("store");
var d = try Daemon.start(&fx, "store", "s/d.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const seed = try c.call(a, .{ .id = 1, .op = .{ .commit = commit("seed", null, "seed", 0, &.{put("a.txt", "a\n")}) } }, &.{"a\n"});
try expectOk(seed);
// A CLI commit lands, then this process takes the writer lock and keeps
// it: anything that waited for the lock would wait its full minute.
try fx.tmp.dir.writeFile(fx.io, .{ .sub_path = "cli.txt", .data = "cli\n" });
const cli = try runZit(&fx, fx.tmp.dir, &.{ "commit", "-R", "store", "--stdin", "cli.txt", "-m", "cli", "--user", "u", "--date-override", "2027-01-15T08:00:05.001Z" }, "cli.txt");
try expectExit(cli, 0);
var store_dir = try fx.tmp.dir.openDir(fx.io, "store", .{});
defer store_dir.close(fx.io);
var discard: std.Io.Writer.Discarding = .init(&.{});
var held = try zit.storelock.acquire(fx.io, store_dir, zit.storelock.writer_lock_name, 0, &discard.writer);
defer held.release(fx.io);
const started = std.Io.Timestamp.now(fx.io, .awake);
const items = [_]protocol.Lookup{ ident("seed", 0, 7), .{ .user = "u", .comment = "cli", .time = .{ .epoch = epoch0 + 5, .ms = 1 } } };
const v = try c.call(a, .{ .id = 2, .op = .{ .lookup = .{ .many = &items } } }, &.{});
const waited_ms = started.durationTo(std.Io.Timestamp.now(fx.io, .awake)).toMilliseconds();
try expectOk(v);
const results = get(v, "results").array.items;
try std.testing.expectEqualStrings(str(seed, "hash"), str(results[0], "hash"));
try std.testing.expectEqualStrings(std.mem.trimEnd(u8, cli.stdout, "\n"), str(results[1], "hash"));
// A writer would wait sixty seconds for this lock; ten is slack for a
// loaded machine, and still no wait.
try std.testing.expect(waited_ms < 10_000);
}
/// `check-in HASH` as the checkout's `commit` reports it.
fn committed(res: std.process.RunResult) ![]const u8 {
try expectExit(res, 0);
const at = std.mem.indexOf(u8, res.stdout, "check-in ") orelse return error.TestUnexpectedResult;
return res.stdout[at + "check-in ".len ..][0..64];
}
/// A commit time as `--date-override` and the wire both spell it.
fn wireTime(fx: *Fx, sec: i64) []const u8 {
var buf: [24]u8 = undefined;
return fx.a().dupe(u8, protocol.formatTime(.{ .epoch = epoch0 + sec, .ms = 7 }, &buf)) catch @panic("oom");
}
fn merge(key: []const u8, parents: []const []const u8, comment: []const u8, sec: i64, edits: []const protocol.Edit) protocol.Commit {
var c = commit(key, null, comment, sec, edits);
c.parents = parents;
return c;
}
/// In a checkout at `ck`: trunk edits a.txt; branch feat, from the root,
/// edits b.txt, adds c.txt, deletes d.txt and renames e.txt to f.txt. The
/// checkout ends on trunk's tip. Returns trunk's tip and feat's.
fn twoLines(fx: *Fx, ck: std.Io.Dir) !struct { trunk: []const u8, feat: []const u8 } {
try expectExit(try runZit(fx, ck, &.{ "init", "-q" }, null), 0);
for ([_][2][]const u8{ .{ "a.txt", "a\n" }, .{ "b.txt", "b\none\n" }, .{ "d.txt", "d\n" }, .{ "e.txt", "e\n" } }) |f|
try ck.writeFile(fx.io, .{ .sub_path = f[0], .data = f[1] });
try expectExit(try runZit(fx, ck, &.{ "add", "a.txt", "b.txt", "d.txt", "e.txt" }, null), 0);
const root = try fx.a().dupe(u8, try committed(try runZit(fx, ck, &.{ "commit", "-m", "root", "--user", "e", "--date-override", wireTime(fx, 0) }, null)));
try ck.writeFile(fx.io, .{ .sub_path = "a.txt", .data = "a2\n" });
const trunk = try fx.a().dupe(u8, try committed(try runZit(fx, ck, &.{ "commit", "-m", "trunk", "--user", "e", "--date-override", wireTime(fx, 1) }, null)));
try expectExit(try runZit(fx, ck, &.{ "update", root }, null), 0);
try ck.writeFile(fx.io, .{ .sub_path = "b.txt", .data = "b\none\ntwo\n" });
try ck.writeFile(fx.io, .{ .sub_path = "c.txt", .data = "c\n" });
try expectExit(try runZit(fx, ck, &.{ "add", "c.txt" }, null), 0);
try expectExit(try runZit(fx, ck, &.{ "rm", "d.txt" }, null), 0);
try ck.deleteFile(fx.io, "d.txt");
try expectExit(try runZit(fx, ck, &.{ "mv", "e.txt", "f.txt" }, null), 0);
ck.rename("e.txt", ck, "f.txt", fx.io) catch {};
const feat = try fx.a().dupe(u8, try committed(try runZit(fx, ck, &.{ "commit", "-m", "feat", "--branch", "feat", "--user", "e", "--date-override", wireTime(fx, 2) }, null)));
try expectExit(try runZit(fx, ck, &.{ "update", "trunk" }, null), 0);
return .{ .trunk = trunk, .feat = feat };
}
test "daemon: a merge commit is the check-in zit merge then commit records, and a resend must name its parents in order" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.tmp.dir.createDir(fx.io, "ck", .default_dir);
var ck = try fx.tmp.dir.openDir(fx.io, "ck", .{});
defer ck.close(fx.io);
const tips = try twoLines(&fx, ck);
try fx.copyStore("ck", "store");
// The CLI's merge: feat into trunk, committed at a pinned time.
const merged = try runZit(&fx, ck, &.{ "merge", "feat" }, null);
try expectExit(merged, 0);
const cli_hash = try a.dupe(u8, try committed(try runZit(&fx, ck, &.{ "commit", "-m", "merged", "--user", "e", "--date-override", wireTime(&fx, 3) }, null)));
var d = try Daemon.start(&fx, "store", "s/d.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const before = try c.call(a, .{ .id = 1, .op = .status }, &.{});
try expectOk(before);
const cursor = try a.dupe(u8, str(before, "cursor"));
// The same tree from the daemon: feat's side as edits on trunk's tip. A
// rename merged in from the other side is a delete and a put, as the
// checkout records it.
const parents = [_][]const u8{ tips.trunk, tips.feat };
const edits = [_]protocol.Edit{ put("b.txt", "b\none\ntwo\n"), put("c.txt", "c\n"), .{ .delete = "d.txt" }, .{ .delete = "e.txt" }, put("f.txt", "e\n") };
const trailers = [_][]const u8{ "b\none\ntwo\n", "c\n", "e\n" };
const m = try c.call(a, .{ .id = 2, .op = .{ .commit = merge("m", &parents, "merged", 3, &edits) } }, &trailers);
try expectOk(m);
try std.testing.expectEqualStrings("recorded", str(m, "outcome"));
try std.testing.expectEqualStrings(cli_hash, str(m, "hash"));
try std.testing.expectEqualStrings(tips.trunk, str(m, "parent"));
try std.testing.expectEqualStrings("trunk", str(m, "branch"));
try std.testing.expect(!get(m, "forked").bool);
// The two stores now answer log and annotate alike across the merge.
for ([_][]const []const u8{
&.{ "log", "trunk" },
&.{ "log", "feat" },
&.{ "annotate", "b.txt", "trunk" },
&.{ "annotate", "a.txt", "trunk" },
&.{ "annotate", "b.txt", "trunk", "--full" },
}) |args| {
var outs: [2][]const u8 = undefined;
for ([_][]const u8{ "ck", "store" }, &outs) |store, *out| {
var argv: std.ArrayList([]const u8) = .empty;
try argv.appendSlice(a, args);
try argv.appendSlice(a, &.{ "-R", store });
const res = try runZit(&fx, fx.tmp.dir, argv.items, null);
try expectExit(res, 0);
out.* = res.stdout;
}
try std.testing.expectEqualStrings(outs[0], outs[1]);
}
const graph = try runZit(&fx, fx.tmp.dir, &.{ "info", cli_hash, "-R", "store" }, null);
try expectExit(graph, 0);
try std.testing.expect(std.mem.indexOf(u8, graph.stdout, tips.feat) != null);
// The feed reports it, built on trunk's tip.
const fed = try c.call(a, .{ .id = 3, .op = .{ .feed = cursor } }, &.{});
try expectOk(fed);
const changes = get(fed, "changes").array.items;
try std.testing.expectEqual(@as(usize, 1), changes.len);
try std.testing.expectEqualStrings(cli_hash, str(changes[0], "checkin"));
try std.testing.expectEqualStrings(tips.trunk, str(changes[0], "parent"));
// A resend is the check-in, and so is a lookup naming the same parents.
{
const size = try fx.storeSize("store");
const again = try c.call(a, .{ .id = 4, .op = .{ .commit = merge("m", &parents, "merged", 3, &edits) } }, &trailers);
try expectOk(again);
try std.testing.expectEqualStrings("existing", str(again, "outcome"));
try std.testing.expectEqualStrings(cli_hash, str(again, "hash"));
try std.testing.expectEqual(size, try fx.storeSize("store"));
var id = ident("merged", 3, 7);
id.parents = &parents;
try expectSameAsResend(try c.call(a, .{ .id = 5, .op = .{ .lookup = .{ .one = id } } }, &.{}), again);
id.parents = &.{ tips.feat, tips.trunk };
try expectNotFound(try c.call(a, .{ .id = 6, .op = .{ .lookup = .{ .one = id } } }, &.{}));
id.parents = &.{tips.trunk};
try expectNotFound(try c.call(a, .{ .id = 7, .op = .{ .lookup = .{ .one = id } } }, &.{}));
}
// A missing parent, a repeated one, or parent beside parents: refused,
// nothing written.
{
const size = try fx.storeSize("store");
const missing = try c.call(a, .{ .id = 8, .op = .{ .commit = merge("x", &.{ tips.trunk, repeat("0", 64) }, "missing", 4, &edits) } }, &trailers);
try expectError(missing, "parent_not_found", 1);
const twice = try c.call(a, .{ .id = 9, .op = .{ .commit = merge("x", &.{ tips.trunk, tips.feat, tips.feat }, "twice", 4, &edits) } }, &trailers);
try expectError(twice, "invalid_request", 1);
const self_merge = try c.call(a, .{ .id = 10, .op = .{ .commit = merge("x", &.{ tips.trunk, tips.trunk }, "self", 4, &edits) } }, &trailers);
try expectError(self_merge, "invalid_request", 1);
var both = merge("x", &parents, "both", 4, &edits);
both.parent = tips.trunk;
const refused = try c.call(a, .{ .id = 11, .op = .{ .commit = both } }, &trailers);
try expectError(refused, "bad_request", 2);
try std.testing.expectEqual(size, try fx.storeSize("store"));
}
// The same request with the parents the other way round is another
// check-in: feat's tip is primary, so the edits are against it.
const swapped = [_][]const u8{ tips.feat, tips.trunk };
var other = merge("o", &swapped, "merged", 3, &.{put("a.txt", "a2\n")});
other.allow_fork = true;
const o = try c.call(a, .{ .id = 12, .op = .{ .commit = other } }, &.{"a2\n"});
try expectOk(o);
try std.testing.expectEqualStrings("recorded", str(o, "outcome"));
try std.testing.expect(!std.mem.eql(u8, cli_hash, str(o, "hash")));
try std.testing.expectEqualStrings(tips.feat, str(o, "parent"));
try std.testing.expectEqualStrings("feat", str(o, "branch"));
const raw = try runZit(&fx, fx.tmp.dir, &.{ "artifact", str(o, "hash"), "--raw", "-R", "store" }, null);
try expectExit(raw, 0);
const p_card = try std.fmt.allocPrint(a, "\nP {s} {s}\n", .{ tips.feat, tips.trunk });
try std.testing.expect(std.mem.indexOf(u8, raw.stdout, p_card) != null);
// Both merges' resends still find their own.
const first_again = try c.call(a, .{ .id = 13, .op = .{ .commit = merge("m", &parents, "merged", 3, &edits) } }, &trailers);
try std.testing.expectEqualStrings(cli_hash, str(first_again, "hash"));
const other_again = try c.call(a, .{ .id = 14, .op = .{ .commit = other } }, &.{"a2\n"});
try std.testing.expectEqualStrings(str(o, "hash"), str(other_again, "hash"));
try std.testing.expectEqualStrings("existing", str(other_again, "outcome"));
}
fn status(c: *Client, a: std.mem.Allocator) ![]const std.json.Value {
const v = try c.call(a, .{ .id = 0, .op = .status }, &.{});
try expectOk(v);
return get(v, "branches").array.items;
}
test "daemon: two stores that send the same merge of the same two leaves converge on one check-in" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.makeStore("one");
var leaves: [2][]const u8 = undefined;
{
var d = try Daemon.start(&fx, "one", "s/one.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/one.sock");
defer c.close(fx.io);
const seed = try c.call(a, .{ .id = 1, .op = .{ .commit = commit("seed", null, "seed", 0, &.{put("a.txt", "a\n")}) } }, &.{"a\n"});
try expectOk(seed);
const p0 = try a.dupe(u8, str(seed, "hash"));
for ([_][]const u8{ "x.txt", "y.txt" }, &leaves, 1..) |name, *leaf, i| {
var cm = commit("leaf", p0, name, @intCast(i), &.{put(name, name)});
cm.allow_fork = true;
const v = try c.call(a, .{ .id = 2, .op = .{ .commit = cm } }, &.{name});
try expectOk(v);
leaf.* = try a.dupe(u8, str(v, "hash"));
}
const fed = try c.call(a, .{ .id = 3, .op = .{ .feed = "0" } }, &.{});
try expectOk(fed);
try std.testing.expectEqual(@as(usize, 1), get(fed, "forks").array.items.len);
const st = try d.stop(fx.io);
try Daemon.expectExited(&fx, st, 0);
}
try fx.copyStore("one", "two");
var d1 = try Daemon.start(&fx, "one", "s/one.sock", .{});
defer d1.child.kill(fx.io);
var d2 = try Daemon.start(&fx, "two", "s/two.sock", .{});
defer d2.child.kill(fx.io);
var c1 = try Client.open(&fx, "s/one.sock");
defer c1.close(fx.io);
var c2 = try Client.open(&fx, "s/two.sock");
defer c2.close(fx.io);
const join = merge("join", &.{ leaves[0], leaves[1] }, "join", 5, &.{put("y.txt", "y.txt")});
const m1 = try c1.call(a, .{ .id = 4, .op = .{ .commit = join } }, &.{"y.txt"});
try expectOk(m1);
const m2 = try c2.call(a, .{ .id = 4, .op = .{ .commit = join } }, &.{"y.txt"});
try expectOk(m2);
try std.testing.expectEqualStrings("recorded", str(m2, "outcome"));
try std.testing.expectEqualStrings(str(m1, "hash"), str(m2, "hash"));
// One leaf, so no fork in the feed.
const fed = try c1.call(a, .{ .id = 5, .op = .{ .feed = "0" } }, &.{});
try expectOk(fed);
try std.testing.expectEqual(@as(usize, 0), get(fed, "forks").array.items.len);
const changes = get(fed, "changes").array.items;
try std.testing.expectEqualStrings(str(m1, "hash"), str(changes[changes.len - 1], "checkin"));
// A sync between the two stores leaves trunk with that one leaf.
const port = blk: {
const addr = try net.IpAddress.parseIp4("127.0.0.1", 0);
var server = try addr.listen(fx.io, .{});
defer server.deinit(fx.io);
break :blk server.socket.address.getPort();
};
const port_text = try std.fmt.allocPrint(a, "{d}", .{port});
var one_dir = try fx.tmp.dir.openDir(fx.io, "one", .{});
defer one_dir.close(fx.io);
var serve = try std.process.spawn(fx.io, .{
.argv = &.{ try absExe(&fx), "serve", "--port", port_text },
.cwd = .{ .dir = one_dir },
.stdout = .ignore,
.stderr = .ignore,
});
defer serve.kill(fx.io);
const url = try std.fmt.allocPrint(a, "http://127.0.0.1:{d}/xfer", .{port});
const pulled = for (0..300) |_| {
const res = try runZit(&fx, fx.tmp.dir, &.{ "pull", url, "-R", "two" }, null);
if (res.term == .exited and res.term.exited == 0) break res;
std.Io.sleep(fx.io, .fromMilliseconds(50), .awake) catch {};
} else return error.ServeNeverAnswered;
_ = pulled;
for ([_]*Client{ c1, c2 }) |c| {
const branches = try status(c, a);
try std.testing.expectEqual(@as(usize, 1), branches.len);
try std.testing.expectEqualStrings("trunk", str(branches[0], "name"));
try std.testing.expectEqual(@as(usize, 1), get(branches[0], "leaves").array.items.len);
try std.testing.expectEqualStrings(str(m1, "hash"), str(branches[0], "tip"));
}
}
/// The checkout's files after a merge, for comparing two merges' results.
fn treeOf(fx: *Fx, ck: std.Io.Dir, names: []const []const u8) ![]const u8 {
var out: std.ArrayList(u8) = .empty;
for (names) |n| {
const bytes = ck.readFileAlloc(fx.io, n, fx.a(), .limited(1 << 20)) catch "<absent>";
try out.print(fx.a(), "{s}:{s}|", .{ n, bytes });
}
return out.items;
}
/// `zit merge` against `zit merge --baseline BASE` from the same checkout:
/// the same tree, so BASE is the base the plain merge chose. Returns that
/// tree; the checkout is left as it was.
fn expectMergeUses(fx: *Fx, ck: std.Io.Dir, other: []const u8, base: []const u8, names: []const []const u8) ![]const u8 {
try expectExit(try runZit(fx, ck, &.{ "merge", other }, null), 0);
const plain = try treeOf(fx, ck, names);
try expectExit(try runZit(fx, ck, &.{"undo"}, null), 0);
try expectExit(try runZit(fx, ck, &.{ "merge", "--baseline", base, other }, null), 0);
try std.testing.expectEqualStrings(plain, try treeOf(fx, ck, names));
try expectExit(try runZit(fx, ck, &.{"undo"}, null), 0);
return plain;
}
fn mergeBase(c: *Client, a: std.mem.Allocator, x: []const u8, y: []const u8) !std.json.Value {
const v = try c.call(a, .{ .id = 0, .op = .{ .merge_base = .{ .a = x, .b = y } } }, &.{});
try expectOk(v);
return get(v, "base");
}
test "daemon: merge_base is the base zit merge uses, after a merge and across a criss-cross" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.tmp.dir.createDir(fx.io, "ck", .default_dir);
var ck = try fx.tmp.dir.openDir(fx.io, "ck", .{});
defer ck.close(fx.io);
const Step = struct {
fn commitAt(f: *Fx, dir: std.Io.Dir, comment: []const u8, sec: i64, extra: []const []const u8) ![]const u8 {
var argv: std.ArrayList([]const u8) = .empty;
try argv.appendSlice(f.a(), &.{ "commit", "-m", comment, "--user", "e", "--date-override", wireTime(f, sec) });
try argv.appendSlice(f.a(), extra);
return f.a().dupe(u8, try committed(try runZit(f, dir, argv.items, null)));
}
};
const names = [_][]const u8{ "x.txt", "y.txt" };
// root; trunk edits line 1; feat edits line 7; feat is merged into
// trunk; trunk then edits line 7 and feat line 4. The base for merging
// feat again is feat's first check-in: from the root, line 7 conflicts.
try expectExit(try runZit(&fx, ck, &.{ "init", "-q" }, null), 0);
try ck.writeFile(fx.io, .{ .sub_path = "x.txt", .data = "1\n2\n3\n4\n5\n6\n7\n" });
try expectExit(try runZit(&fx, ck, &.{ "add", "x.txt" }, null), 0);
const root = try Step.commitAt(&fx, ck, "root", 0, &.{});
try ck.writeFile(fx.io, .{ .sub_path = "x.txt", .data = "1t\n2\n3\n4\n5\n6\n7\n" });
_ = try Step.commitAt(&fx, ck, "t1", 1, &.{});
try expectExit(try runZit(&fx, ck, &.{ "update", root }, null), 0);
try ck.writeFile(fx.io, .{ .sub_path = "x.txt", .data = "1\n2\n3\n4\n5\n6\n7f\n" });
const f1 = try Step.commitAt(&fx, ck, "f1", 2, &.{ "--branch", "feat" });
try expectExit(try runZit(&fx, ck, &.{ "update", "trunk" }, null), 0);
try expectExit(try runZit(&fx, ck, &.{ "merge", "feat" }, null), 0);
_ = try Step.commitAt(&fx, ck, "m1", 3, &.{});
try ck.writeFile(fx.io, .{ .sub_path = "x.txt", .data = "1t\n2\n3\n4\n5\n6\n7T\n" });
const t2 = try Step.commitAt(&fx, ck, "t2", 4, &.{});
try expectExit(try runZit(&fx, ck, &.{ "update", "feat" }, null), 0);
try ck.writeFile(fx.io, .{ .sub_path = "x.txt", .data = "1\n2\n3\n4f\n5\n6\n7f\n" });
const f2 = try Step.commitAt(&fx, ck, "f2", 5, &.{});
// Criss-cross: c1 on trunk and c2 on feat each merge the other's tip.
try ck.writeFile(fx.io, .{ .sub_path = "y.txt", .data = "y\n" });
try expectExit(try runZit(&fx, ck, &.{ "add", "y.txt" }, null), 0);
const f3 = try Step.commitAt(&fx, ck, "f3", 6, &.{});
try expectExit(try runZit(&fx, ck, &.{ "update", "trunk" }, null), 0);
try expectExit(try runZit(&fx, ck, &.{ "merge", f3 }, null), 0);
const c1 = try Step.commitAt(&fx, ck, "c1", 7, &.{});
try expectExit(try runZit(&fx, ck, &.{ "update", f3 }, null), 0);
try expectExit(try runZit(&fx, ck, &.{ "merge", t2 }, null), 0);
const c2 = try Step.commitAt(&fx, ck, "c2", 8, &.{});
var d = try Daemon.start(&fx, "ck", "s/d.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
// After a merge: feat's first check-in, not the root.
const after = try mergeBase(c, a, t2, f2);
try std.testing.expectEqualStrings(f1, after.string);
try std.testing.expectEqualStrings(f1, (try mergeBase(c, a, f2, t2)).string);
try expectExit(try runZit(&fx, ck, &.{ "update", t2 }, null), 0);
try std.testing.expectEqualStrings(
"x.txt:1t\n2\n3\n4f\n5\n6\n7T\n|y.txt:<absent>|",
try expectMergeUses(&fx, ck, f2, after.string, &names),
);
{
// The root as the base would have conflicted: the answer matters.
const res = try runZit(&fx, ck, &.{ "merge", "--baseline", root, f2 }, null);
try std.testing.expect(std.mem.indexOf(u8, res.stderr, "conflict") != null or std.mem.indexOf(u8, res.stdout, "conflict") != null);
try expectExit(try runZit(&fx, ck, &.{"undo"}, null), 0);
}
// Criss-cross: two common ancestors, t2 and f3; the newer is the base.
const crossed = try mergeBase(c, a, c1, c2);
try std.testing.expectEqualStrings(f3, crossed.string);
try expectExit(try runZit(&fx, ck, &.{ "update", c1 }, null), 0);
try ck.writeFile(fx.io, .{ .sub_path = "y.txt", .data = "y\nc1\n" });
_ = try Step.commitAt(&fx, ck, "c1b", 9, &.{});
try std.testing.expectEqualStrings(
"x.txt:1t\n2\n3\n4f\n5\n6\n7T\n|y.txt:y\nc1\n|",
try expectMergeUses(&fx, ck, c2, crossed.string, &names),
);
// A check-in and its ancestor: the ancestor; a check-in with itself.
try std.testing.expectEqualStrings(root, (try mergeBase(c, a, root, f2)).string);
try std.testing.expectEqualStrings(f2, (try mergeBase(c, a, f2, f2)).string);
const unknown = try c.call(a, .{ .id = 1, .op = .{ .merge_base = .{ .a = repeat("0", 64), .b = f2 } } }, &.{});
try expectError(unknown, "not_found", 1);
// Read-only: the store is as it was.
const size = try fx.storeSize("ck");
_ = try mergeBase(c, a, c1, c2);
try std.testing.expectEqual(size, try fx.storeSize("ck"));
}
/// The daemon's `diff` against `zit diff --from --to -F json`: the same rows,
/// field for field, and `prior` only where the `to` side renamed a removed
/// row's file.
fn expectDiffAsCli(fx: *Fx, c: *Client, store: []const u8, from: []const u8, to: []const u8) ![]const std.json.Value {
const a = fx.a();
const v = try c.call(a, .{ .id = 0, .op = .{ .diff = .{ .from = from, .to = to } } }, &.{});
try expectOk(v);
const res = try runZit(fx, fx.tmp.dir, &.{ "diff", "--from", from, "--to", to, "-F", "json", "-R", store }, null);
try expectExit(res, 0);
const cli = try std.json.parseFromSliceLeaky(std.json.Value, a, res.stdout, .{});
try std.testing.expectEqualStrings(str(cli, "from"), str(v, "from"));
try std.testing.expectEqualStrings(str(cli, "to"), str(v, "to"));
const want = get(cli, "files").array.items;
const have = get(v, "files").array.items;
try std.testing.expectEqual(want.len, have.len);
for (want, have) |w, h| {
try std.testing.expectEqual(@as(usize, 5), h.object.count());
try std.testing.expectEqualStrings(str(w, "name"), str(h, "name"));
try std.testing.expectEqualStrings(str(w, "state"), str(h, "state"));
for ([_][]const u8{ "uuid", "parent" }) |k| switch (get(w, k)) {
.null => try std.testing.expect(get(h, k) == .null),
else => try std.testing.expectEqualStrings(str(w, k), str(h, k)),
};
}
return have;
}
fn rowNamed(rows: []const std.json.Value, name: []const u8) std.json.Value {
for (rows) |r| if (std.mem.eql(u8, str(r, "name"), name)) return r;
std.debug.panic("no row for '{s}'", .{name});
}
test "daemon: diff reports diff -F json's rows, a rename's prior and a delete included" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
try fx.tmp.dir.createDir(fx.io, "ck", .default_dir);
var ck = try fx.tmp.dir.openDir(fx.io, "ck", .{});
defer ck.close(fx.io);
const tips = try twoLines(&fx, ck);
var d = try Daemon.start(&fx, "ck", "s/d.sock", .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, "s/d.sock");
defer c.close(fx.io);
const forward = try expectDiffAsCli(&fx, c, "ck", tips.trunk, tips.feat);
try std.testing.expectEqual(@as(usize, 6), forward.len);
try std.testing.expectEqualStrings("removed", str(rowNamed(forward, "d.txt"), "state"));
try std.testing.expectEqualStrings("removed", str(rowNamed(forward, "e.txt"), "state"));
try std.testing.expectEqualStrings("added", str(rowNamed(forward, "f.txt"), "state"));
try std.testing.expectEqualStrings("e.txt", str(rowNamed(forward, "f.txt"), "prior"));
try std.testing.expectEqualStrings(&sha3Hex("e\n"), str(rowNamed(forward, "f.txt"), "uuid"));
try std.testing.expect(get(rowNamed(forward, "c.txt"), "prior") == .null);
try std.testing.expectEqualStrings("modified", str(rowNamed(forward, "a.txt"), "state"));
// Backwards the rename is undone, which no card records.
const back = try expectDiffAsCli(&fx, c, "ck", tips.feat, tips.trunk);
try std.testing.expect(get(rowNamed(back, "e.txt"), "prior") == .null);
// A check-in against itself: nothing.
try std.testing.expectEqual(@as(usize, 0), (try expectDiffAsCli(&fx, c, "ck", tips.feat, tips.feat)).len);
// Across a merge recorded by the daemon.
const m = try c.call(a, .{ .id = 1, .op = .{ .commit = merge("m", &.{ tips.trunk, tips.feat }, "merged", 3, &.{ put("b.txt", "b\none\ntwo\n"), .{ .rename = .{ .old = "e.txt", .new = "f.txt" } } }) } }, &.{"b\none\ntwo\n"});
try expectOk(m);
const merged = try expectDiffAsCli(&fx, c, "ck", tips.trunk, str(m, "hash"));
try std.testing.expectEqualStrings("e.txt", str(rowNamed(merged, "f.txt"), "prior"));
_ = try expectDiffAsCli(&fx, c, "ck", tips.feat, str(m, "hash"));
const unknown = try c.call(a, .{ .id = 2, .op = .{ .diff = .{ .from = tips.trunk, .to = repeat("0", 64) } } }, &.{});
try expectError(unknown, "not_found", 1);
}
test "daemon: a merge with no edits records the joining, flat and on a sub-manifest store" {
var fx: Fx = undefined;
fx.init();
defer fx.deinit();
const a = fx.a();
for ([_][]const []const u8{ &.{ "init", "-q" }, &.{ "init", "-q", "--submanifest", "1" } }, 0..) |init, i| {
const store = try std.fmt.allocPrint(a, "store{d}", .{i});
const socket = try std.fmt.allocPrint(a, "s/{d}.sock", .{i});
try fx.tmp.dir.createDir(fx.io, store, .default_dir);
var dir = try fx.tmp.dir.openDir(fx.io, store, .{});
defer dir.close(fx.io);
try expectExit(try runZit(&fx, dir, init, null), 0);
var d = try Daemon.start(&fx, store, socket, .{});
defer d.child.kill(fx.io);
var c = try Client.open(&fx, socket);
defer c.close(fx.io);
const seed = try c.call(a, .{ .id = 1, .op = .{ .commit = commit("seed", null, "seed", 0, &.{ put("d/a.txt", "a\n"), put("d/b.txt", "b\n") }) } }, &.{ "a\n", "b\n" });
try expectOk(seed);
const p0 = try a.dupe(u8, str(seed, "hash"));
var side = commit("side", p0, "side", 1, &.{put("d/b.txt", "b2\n")});
side.branch = "side";
const s = try c.call(a, .{ .id = 2, .op = .{ .commit = side } }, &.{"b2\n"});
try expectOk(s);
const t = try c.call(a, .{ .id = 3, .op = .{ .commit = commit("t", p0, "t", 2, &.{put("d/a.txt", "a2\n")}) } }, &.{"a2\n"});
try expectOk(t);
// The side's work is dropped: a merge that keeps the primary's tree.
const m = try c.call(a, .{ .id = 4, .op = .{ .commit = merge("m", &.{ str(t, "hash"), str(s, "hash") }, "keep ours", 3, &.{}) } }, &.{});
try expectOk(m);
try std.testing.expectEqualStrings("recorded", str(m, "outcome"));
const files = try c.call(a, .{ .id = 5, .op = .{ .diff = .{ .from = str(t, "hash"), .to = str(m, "hash") } } }, &.{});
try expectOk(files);
try std.testing.expectEqual(@as(usize, 0), get(files, "files").array.items.len);
const again = try c.call(a, .{ .id = 6, .op = .{ .commit = merge("m", &.{ str(t, "hash"), str(s, "hash") }, "keep ours", 3, &.{}) } }, &.{});
try std.testing.expectEqualStrings("existing", str(again, "outcome"));
// One parent and no edits is still nothing to commit.
const empty = try c.call(a, .{ .id = 7, .op = .{ .commit = merge("e", &.{str(m, "hash")}, "empty", 4, &.{}) } }, &.{});
try expectError(empty, "invalid_request", 1);
const verify = try runZit(&fx, fx.tmp.dir, &.{ "verify", "-R", store }, null);
try expectExit(verify, 0);
}
}