Artifact 6087e9a2dea44b69654bceee0d5a55e7777fdf5106b0def463b4b9f5201e43ee:
- File
src/muscle.zig
— part of check-in
[741f6d428b]
at
2026-08-29 00:59:22
on branch trunk
— fossil-muscle-memory: a differential transcript through fossil and zit
zig build muscle runs one scripted session — 27 lines a Fossil user types most — through the real fossil and the real zit against local servers, and compares each step's exit-status class and whether the verb spoke at all. Deliberate divergences carry their reason, known gaps carry their issue; anything else fails the run. CI runs it beside smoke. Closes zit#162. (user: dmestas, size: 21707) [annotate] [blame] [check-ins using]
//! fossil-muscle-memory — the differential transcript (zit#162). //! //! One scripted session of the command lines a Fossil user types most, run //! step for step through the real `fossil` binary and the real `zit` binary //! in equivalent repositories against local servers. Per step both sides' //! exit status, stdout and stderr are captured and compared on two axes: //! //! rc — the exit-status CLASS (success vs failure), not the number: //! zit's contract is 0/1/2 where fossil is 0/1, so numbers differ //! by design and classes should not. //! speech — whether the verb said anything at all (stdout or stderr): //! silence where fossil speaks is the "did the update do //! anything?" class of gap; a wall of text where fossil says one //! line is the typo-dump class. //! //! A step whose divergence is DELIBERATE (zit exits 1 on a conflicting //! merge where fossil exits 0; success is silent on stdout) carries the //! reason in its `expect`, and the run passes. A divergence that is a known //! gap carries its issue number and also passes — until the issue lands and //! the entry is removed, at which point the same divergence becomes the //! failure this harness exists to catch. Anything else fails the run. //! //! Milestone-manual like `smoke`: lives behind `zig build muscle`, skips //! cleanly when `fossil` is absent, is compiled by `zig build test` and //! never run by it. const std = @import("std"); const builtin = @import("builtin"); const build_options = @import("build_options"); const net = std.Io.net; const zit_exe: []const u8 = build_options.zit_exe; /// What a step expects on one axis. const Axis = union(enum) { same, /// A deliberate design difference — passes, forever. by_design: []const u8, /// A known gap, tracked — passes until the issue is closed and the /// entry removed. gap: []const u8, }; const Expect = struct { rc: Axis = .same, speech: Axis = .same, /// Upper bound on zit's stderr line count for this step, when the step /// is about diagnostics rather than data (the typo-dump class). zit_err_lines_max: ?usize = null, }; const Where = enum { root, work }; /// A file written into BOTH working trees before the step runs. const Pre = struct { path: []const u8, content: []const u8, append: bool = false, }; const Cmd = []const []const u8; const Step = struct { name: []const u8, where: Where = .work, pre: ?Pre = null, fossil: []const Cmd, zit: []const Cmd, expect: Expect = .{}, }; const binary_blob = "\x00\x01\x02\xff\xfe\x00binary\x00"; /// The session. Every line is something a Fossil user types on an ordinary /// day; the fossil and zit forms are the closest honest equivalents. const steps = [_]Step{ .{ .name = "clone + open", .where = .root, .fossil = &.{ &.{ "fossil", "clone", "FOSSIL_URL", "work.fossil" }, &.{ "fossil", "open", "work.fossil", "--workdir", "work" } }, .zit = &.{ &.{ "ZIT", "clone", "ZIT_URL", "work" }, &.{ "ZIT", "open", "--in", "work" } }, .expect = .{ .speech = .{ .gap = "zit#160: clone names neither the peer nor the project code" } }, }, .{ .name = "status, clean", .fossil = &.{&.{ "fossil", "status" }}, .zit = &.{&.{ "ZIT", "status" }} }, .{ .name = "changes (alias)", .fossil = &.{&.{ "fossil", "changes" }}, .zit = &.{&.{ "ZIT", "changes" }}, .expect = .{ .speech = .{ .by_design = "zit status always names the branch; fossil 'changes' omits the header" } }, }, .{ .name = "sync, nothing to do", .fossil = &.{&.{ "fossil", "sync" }}, .zit = &.{&.{ "ZIT", "sync" }} }, .{ .name = "edit + commit (autosync)", .pre = .{ .path = "f.txt", .content = "two\n", .append = true }, .fossil = &.{&.{ "fossil", "commit", "-m", "c2", "--no-warnings" }}, .zit = &.{&.{ "ZIT", "ci", "-m", "c2" }}, }, .{ .name = "push, nothing to do", .fossil = &.{&.{ "fossil", "push" }}, .zit = &.{&.{ "ZIT", "push" }} }, .{ .name = "pull, nothing to do", .fossil = &.{&.{ "fossil", "pull" }}, .zit = &.{&.{ "ZIT", "pull" }} }, .{ .name = "timeline -n 2", .fossil = &.{&.{ "fossil", "timeline", "-n", "2" }}, .zit = &.{&.{ "ZIT", "timeline", "-n", "2" }} }, .{ .name = "ti (prefix)", .fossil = &.{&.{ "fossil", "tim", "-n", "1" }}, .zit = &.{&.{ "ZIT", "tim", "-n", "1" }} }, .{ .name = "info", .fossil = &.{&.{ "fossil", "info" }}, .zit = &.{&.{ "ZIT", "info", "tip" }} }, .{ .name = "ls", .fossil = &.{&.{ "fossil", "ls" }}, .zit = &.{&.{ "ZIT", "ls" }} }, .{ .name = "extras", .pre = .{ .path = "extra.txt", .content = "x\n" }, .fossil = &.{&.{ "fossil", "extras" }}, .zit = &.{&.{ "ZIT", "extras" }} }, .{ .name = "status with an edit", .pre = .{ .path = "f.txt", .content = "three\n", .append = true }, .fossil = &.{&.{ "fossil", "status" }}, .zit = &.{&.{ "ZIT", "status" }}, }, .{ .name = "diff", .fossil = &.{&.{ "fossil", "diff" }}, .zit = &.{&.{ "ZIT", "diff" }} }, .{ .name = "revert", .fossil = &.{&.{ "fossil", "revert" }}, .zit = &.{&.{ "ZIT", "revert" }} }, .{ .name = "update, already there", .fossil = &.{&.{ "fossil", "update", "trunk" }}, .zit = &.{&.{ "ZIT", "update", "trunk" }}, .expect = .{ .speech = .{ .gap = "zit#159: update prints no receipt" } }, }, .{ .name = "add + commit a binary", .pre = .{ .path = "b.dat", .content = binary_blob }, .fossil = &.{ &.{ "fossil", "add", "b.dat" }, &.{ "fossil", "commit", "-m", "bin" } }, .zit = &.{ &.{ "ZIT", "add", "b.dat" }, &.{ "ZIT", "commit", "-m", "bin" } }, .expect = .{ .rc = .{ .gap = "zit#158: no content warnings; fossil refuses the binary" } }, }, .{ .name = "commit with no message", .pre = .{ .path = "f.txt", .content = "four\n", .append = true }, .fossil = &.{&.{ "fossil", "commit", "--no-warnings" }}, .zit = &.{&.{ "ZIT", "commit" }}, .expect = .{ .rc = .{ .gap = "zit#157: no editor; fossil's editor path fails differently off a TTY" } }, }, .{ .name = "commit -m (clean up)", .fossil = &.{&.{ "fossil", "commit", "-m", "c4", "--no-warnings" }}, .zit = &.{&.{ "ZIT", "commit", "-m", "c4" }} }, .{ .name = "branch list", .fossil = &.{&.{ "fossil", "branch", "list" }}, .zit = &.{&.{ "ZIT", "branch" }} }, .{ .name = "tag list", .fossil = &.{&.{ "fossil", "tag", "list" }}, .zit = &.{&.{ "ZIT", "tag", "list" }}, .expect = .{ .speech = .{ .by_design = "zit's tag list excludes branch names; 'zit branch' lists them" } }, }, .{ .name = "leaves", .fossil = &.{&.{ "fossil", "leaves" }}, .zit = &.{&.{ "ZIT", "leaves" }} }, .{ .name = "annotate", .fossil = &.{&.{ "fossil", "annotate", "f.txt" }}, .zit = &.{&.{ "ZIT", "annotate", "f.txt" }} }, .{ .name = "blame (alias)", .fossil = &.{&.{ "fossil", "blame", "f.txt" }}, .zit = &.{&.{ "ZIT", "blame", "f.txt" }} }, .{ .name = "sync to a dead peer", .fossil = &.{&.{ "fossil", "sync", "http://127.0.0.1:1", "--once" }}, .zit = &.{&.{ "ZIT", "sync", "http://127.0.0.1:1" }}, .expect = .{ .rc = .{ .by_design = "zit exits 1 on an unreachable peer; fossil exits 0" } }, }, .{ .name = "unknown verb", .fossil = &.{&.{ "fossil", "florble" }}, .zit = &.{&.{ "ZIT", "florble" }}, .expect = .{ .zit_err_lines_max = 3 }, }, .{ .name = "ambiguous prefix", .fossil = &.{&.{ "fossil", "ti" }}, .zit = &.{&.{ "ZIT", "ti" }}, .expect = .{ .zit_err_lines_max = 2 }, }, }; pub fn main(init: std.process.Init) !void { const gpa = init.gpa; const io = init.io; var stdout_buffer: [4096]u8 = undefined; var stdout_writer: std.Io.File.Writer = .init(.stdout(), io, &stdout_buffer); const out = &stdout_writer.interface; defer out.flush() catch {}; if (try fossilVersion(gpa, io)) |ver| { defer gpa.free(ver); try out.print("fossil version: {s}\n", .{std.mem.trim(u8, ver, " \t\r\n")}); } else { try out.writeAll("fossil not found — muscle is milestone-manual\n"); return; } var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); const root = try tempRoot(arena, init.environ_map.*); const cwd = std.Io.Dir.cwd(); cwd.deleteTree(io, root) catch {}; try cwd.createDirPath(io, root); defer cwd.deleteTree(io, root) catch {}; var env = try init.environ_map.clone(gpa); defer env.deinit(); const home_dir = try std.fs.path.join(arena, &.{ root, "home" }); try cwd.createDirPath(io, home_dir); try env.put("FOSSIL_HOME", home_dir); try env.put("USER", "muscle"); const abs_exe = try zitAbsPath(arena, io); // ---- origins, served ---- const f_root = try std.fs.path.join(arena, &.{ root, "f" }); const z_root = try std.fs.path.join(arena, &.{ root, "z" }); const f_origin_co = try std.fs.path.join(arena, &.{ f_root, "o" }); const z_origin = try std.fs.path.join(arena, &.{ z_root, "o" }); try cwd.createDirPath(io, f_origin_co); try cwd.createDirPath(io, z_origin); try mustRun(gpa, io, out, &.{ "fossil", "init", "origin.fossil" }, f_root, &env); try mustRun(gpa, io, out, &.{ "fossil", "open", "../origin.fossil" }, f_origin_co, &env); try writeFile(io, f_origin_co, "f.txt", "one\n", false); try mustRun(gpa, io, out, &.{ "fossil", "add", "f.txt" }, f_origin_co, &env); try mustRun(gpa, io, out, &.{ "fossil", "commit", "-m", "base", "--no-warnings" }, f_origin_co, &env); try mustRun(gpa, io, out, &.{ abs_exe, "init" }, z_origin, &env); try writeFile(io, z_origin, "f.txt", "one\n", false); try mustRun(gpa, io, out, &.{ abs_exe, "add", "f.txt" }, z_origin, &env); try mustRun(gpa, io, out, &.{ abs_exe, "commit", "-m", "base" }, z_origin, &env); const f_port = try freshLoopbackPort(io); const z_port = try freshLoopbackPort(io); const f_port_str = try std.fmt.allocPrint(arena, "{d}", .{f_port}); const z_port_str = try std.fmt.allocPrint(arena, "{d}", .{z_port}); const origin_repo = try std.fs.path.join(arena, &.{ f_root, "origin.fossil" }); var f_server = try spawnFossilServe(gpa, io, out, origin_repo, f_root, &env, f_port_str); defer stopFossilServe(io, &f_server); var z_server = try std.process.spawn(io, .{ .argv = &.{ abs_exe, "serve", "--port", z_port_str }, .cwd = .{ .path = z_origin }, .environ_map = &env, .stdout = .ignore, .stderr = .ignore, }); defer z_server.kill(io); try waitForListen(io, f_port); try waitForListen(io, z_port); const f_url = try std.fmt.allocPrint(arena, "http://127.0.0.1:{d}", .{f_port}); const z_url = try std.fmt.allocPrint(arena, "http://127.0.0.1:{d}", .{z_port}); const f_work = try std.fs.path.join(arena, &.{ f_root, "work" }); const z_work = try std.fs.path.join(arena, &.{ z_root, "work" }); try out.writeAll("step | fossil rc out/err | zit rc out/err | verdict\n"); var failures: usize = 0; for (steps) |step| { const f_cwd = if (step.where == .root) f_root else f_work; const z_cwd = if (step.where == .root) z_root else z_work; if (step.pre) |pre| { try writeFile(io, f_work, pre.path, pre.content, pre.append); try writeFile(io, z_work, pre.path, pre.content, pre.append); } const f_res = try runSeq(gpa, arena, io, step.fossil, f_cwd, &env, abs_exe, f_url, z_url); const z_res = try runSeq(gpa, arena, io, step.zit, z_cwd, &env, abs_exe, f_url, z_url); const verdict = judge(step.expect, f_res, z_res); try out.print("{s:<30}| {d:>2} {d:>3}/{d:<3} | {d:>2} {d:>3}/{d:<3} | {s}\n", .{ step.name, f_res.rc, f_res.out_lines, f_res.err_lines, z_res.rc, z_res.out_lines, z_res.err_lines, verdict.text, }); if (!verdict.pass) { failures += 1; try out.print(" fossil stdout: {s}\n fossil stderr: {s}\n zit stdout: {s}\n zit stderr: {s}\n", .{ firstLine(f_res.stdout), firstLine(f_res.stderr), firstLine(z_res.stdout), firstLine(z_res.stderr), }); } } if (failures != 0) { try out.print("FAIL muscle: {d} unexpected divergence(s)\n", .{failures}); try out.flush(); std.process.exit(1); } try out.print("PASS muscle: {d} steps, every divergence deliberate or tracked\n", .{steps.len}); } const Res = struct { rc: u8, stdout: []const u8, stderr: []const u8, out_lines: usize, err_lines: usize, fn speaks(self: Res) bool { return std.mem.trim(u8, self.stdout, " \t\r\n").len != 0 or std.mem.trim(u8, self.stderr, " \t\r\n").len != 0; } }; const Verdict = struct { pass: bool, text: []const u8 }; fn judge(expect: Expect, f: Res, z: Res) Verdict { const rc_same = (f.rc == 0) == (z.rc == 0); const speech_same = f.speaks() == z.speaks(); switch (expect.rc) { .same => if (!rc_same) return .{ .pass = false, .text = "FAIL rc class differs" }, .by_design => |why| return checkRest(expect, speech_same, z, why, "by design"), .gap => |why| return checkRest(expect, speech_same, z, why, "tracked gap"), } return checkRest(expect, speech_same, z, null, null); } fn checkRest(expect: Expect, speech_same: bool, z: Res, rc_why: ?[]const u8, rc_kind: ?[]const u8) Verdict { switch (expect.speech) { .same => if (!speech_same) return .{ .pass = false, .text = "FAIL one side is silent" }, .by_design, .gap => {}, } if (expect.zit_err_lines_max) |max| { if (z.err_lines > max) return .{ .pass = false, .text = "FAIL zit stderr too long" }; } if (rc_why) |_| return .{ .pass = true, .text = if (std.mem.eql(u8, rc_kind.?, "by design")) "ok (rc differs by design)" else "ok (rc gap, tracked)" }; switch (expect.speech) { .by_design => return .{ .pass = true, .text = "ok (speech differs by design)" }, .gap => return .{ .pass = true, .text = "ok (speech gap, tracked)" }, .same => return .{ .pass = true, .text = "ok" }, } } /// Run each command of a step in order; the step's result is the LAST /// command's, unless an earlier one fails, in which case that failure is the /// result (a clone that fails has no open to speak of). fn runSeq( gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io, cmds: []const Cmd, cwd_path: []const u8, env: *const std.process.Environ.Map, abs_exe: []const u8, f_url: []const u8, z_url: []const u8, ) !Res { var last: ?Res = null; for (cmds) |cmd| { var argv = try arena.alloc([]const u8, cmd.len); var cwd_here = cwd_path; for (cmd, 0..) |a, i| { argv[i] = if (std.mem.eql(u8, a, "ZIT")) abs_exe else if (std.mem.eql(u8, a, "FOSSIL_URL")) f_url else if (std.mem.eql(u8, a, "ZIT_URL")) z_url else a; } // `open --in DIR` is the harness's spelling for "run open inside DIR" // (zit opens in place; fossil opens with --workdir). if (cmd.len >= 4 and std.mem.eql(u8, cmd[1], "open") and std.mem.eql(u8, cmd[2], "--in")) { cwd_here = try std.fs.path.join(arena, &.{ cwd_path, cmd[3] }); argv = argv[0..2]; } const r = try std.process.run(gpa, io, .{ .argv = argv, .cwd = .{ .path = cwd_here }, .environ_map = env }); const rc: u8 = switch (r.term) { .exited => |c| c, else => 255, }; const res: Res = .{ .rc = rc, .stdout = try arena.dupe(u8, r.stdout), .stderr = try arena.dupe(u8, r.stderr), .out_lines = countLines(r.stdout), .err_lines = countLines(r.stderr), }; gpa.free(r.stdout); gpa.free(r.stderr); last = res; if (rc != 0) break; } return last.?; } fn countLines(s: []const u8) usize { const t = std.mem.trimEnd(u8, s, "\n"); if (t.len == 0) return 0; return std.mem.count(u8, t, "\n") + 1; } fn firstLine(s: []const u8) []const u8 { const t = std.mem.trim(u8, s, "\r\n"); const i = std.mem.indexOfScalar(u8, t, '\n') orelse return t; return t[0..i]; } fn writeFile(io: std.Io, dir_path: []const u8, name: []const u8, content: []const u8, append: bool) !void { var dir = try std.Io.Dir.cwd().openDir(io, dir_path, .{}); defer dir.close(io); var buf: [1 << 16]u8 = undefined; var existing: []const u8 = ""; if (append) { if (dir.openFile(io, name, .{})) |f| { defer f.close(io); const n = try f.readPositionalAll(io, &buf, 0); existing = buf[0..n]; } else |_| {} } var f = try dir.createFile(io, name, .{}); defer f.close(io); try f.writePositionalAll(io, existing, 0); try f.writePositionalAll(io, content, existing.len); } fn mustRun(gpa: std.mem.Allocator, io: std.Io, out: *std.Io.Writer, argv: []const []const u8, cwd_path: []const u8, env: *const std.process.Environ.Map) !void { const r = try std.process.run(gpa, io, .{ .argv = argv, .cwd = .{ .path = cwd_path }, .environ_map = env }); defer gpa.free(r.stdout); defer gpa.free(r.stderr); if (!okExit(r.term)) { try out.print("setup failed: {s} in {s}\n stderr: {s}\n", .{ argv[1], cwd_path, r.stderr }); return error.SetupFailed; } } fn okExit(term: std.process.Child.Term) bool { return switch (term) { .exited => |code| code == 0, else => false, }; } fn fossilVersion(gpa: std.mem.Allocator, io: std.Io) !?[]u8 { const res = std.process.run(gpa, io, .{ .argv = &.{ "fossil", "version" } }) catch return null; defer gpa.free(res.stderr); if (!okExit(res.term)) { gpa.free(res.stdout); return null; } return res.stdout; } fn tempRoot(arena: std.mem.Allocator, env: std.process.Environ.Map) ![]const u8 { const base = env.get("TMPDIR") orelse "/tmp"; return std.fs.path.join(arena, &.{ std.mem.trimEnd(u8, base, "/"), "zit-muscle" }); } fn zitAbsPath(arena: std.mem.Allocator, io: std.Io) ![]const u8 { const cwd_abs = try std.process.currentPathAlloc(io, arena); return std.fs.path.resolve(arena, &.{ cwd_abs, zit_exe }); } fn spawnFossilServe( gpa: std.mem.Allocator, io: std.Io, out: *std.Io.Writer, repo_path: []const u8, cwd_path: []const u8, env: *const std.process.Environ.Map, port_str: []const u8, ) !std.process.Child { try mustRun(gpa, io, out, &.{ "fossil", "settings", "backoffice-disable", "1", "-R", repo_path }, cwd_path, env); return std.process.spawn(io, .{ .argv = &.{ "fossil", "server", "--port", port_str, "--localauth", repo_path }, .cwd = .{ .path = cwd_path }, .environ_map = env, .stdout = .ignore, .stderr = .ignore, .pgid = if (builtin.os.tag == .windows) null else 0, }); } fn stopFossilServe(io: std.Io, server: *std.process.Child) void { if (comptime builtin.os.tag != .windows) { if (server.id) |pid| std.posix.kill(-pid, .KILL) catch {}; } server.kill(io); } fn freshLoopbackPort(io: std.Io) !u16 { const addr = net.IpAddress.parseIp4("127.0.0.1", 0) catch return error.BadTestAddress; var server = try addr.listen(io, .{ .reuse_address = true }); defer server.deinit(io); return server.socket.address.getPort(); } fn waitForListen(io: std.Io, port: u16) !void { const addr = net.IpAddress.parseIp4("127.0.0.1", port) catch return error.BadTestAddress; const deadline_ns: i96 = 15_000_000_000; const start = std.Io.Clock.now(.awake, io).nanoseconds; while (true) { if (addr.connect(io, .{ .mode = .stream })) |stream| { stream.close(io); return; } else |_| {} if (std.Io.Clock.now(.awake, io).nanoseconds - start > deadline_ns) return error.ServerNeverBound; } } test "muscle: countLines and firstLine" { try std.testing.expectEqual(@as(usize, 0), countLines("")); try std.testing.expectEqual(@as(usize, 1), countLines("a\n")); try std.testing.expectEqual(@as(usize, 2), countLines("a\nb")); try std.testing.expectEqualStrings("a", firstLine("a\nb\n")); } test "muscle: judge classes rc by success, not by number, and honours tracked gaps" { const ok0: Res = .{ .rc = 0, .stdout = "x\n", .stderr = "", .out_lines = 1, .err_lines = 0 }; const ok2: Res = .{ .rc = 2, .stdout = "", .stderr = "e\n", .out_lines = 0, .err_lines = 1 }; const fail1: Res = .{ .rc = 1, .stdout = "", .stderr = "e\n", .out_lines = 0, .err_lines = 1 }; try std.testing.expect(judge(.{}, fail1, ok2).pass); // both non-zero: same class try std.testing.expect(!judge(.{}, ok0, ok2).pass); // 0 vs 2: differs try std.testing.expect(judge(.{ .rc = .{ .gap = "zit#0" } }, ok0, ok2).pass); const silent: Res = .{ .rc = 0, .stdout = "", .stderr = "", .out_lines = 0, .err_lines = 0 }; try std.testing.expect(!judge(.{}, ok0, silent).pass); try std.testing.expect(judge(.{ .speech = .{ .gap = "zit#0" } }, ok0, silent).pass); const loud: Res = .{ .rc = 2, .stdout = "", .stderr = "a\nb\nc\nd\n", .out_lines = 0, .err_lines = 4 }; try std.testing.expect(!judge(.{ .zit_err_lines_max = 3 }, ok2, loud).pass); }