Artifact ac48e76f22c8065f70d2a827ae00ffb99887af80950a93c1a3451a9a1ad0f403:
- File
src/checkout.zig
— part of check-in
[20bf0a31c6]
at
2026-07-18 15:59:23
on branch cli
— Add merge --cherrypick / --backout as special merges (no new verbs)
Both are alternate-baseline choices inside the shipped merge machinery, per merge-flags contract B-merge-30..40 (which lifts merge.md's v1 non-goal); the flagless zit merge NAME surface is byte-identical (B-merge-31 re-asserts it).
- --cherrypick REV: three-way with base = REV's primary parent, other = REV, current = checkout (apply REV's own changes). --backout REV: base = REV, other = REV's primary parent (reverse REV). Same classifyFile/applyMerge engine; a root REV yields an empty side; zero entries refuse (B-merge-39). Flags are mutually exclusive and reject an extra positional / dirty tree. - A cherrypick/backout commit stays SINGLE-parent (REV is never a merge parent) and mints exactly one artifact 4.7.14 Q card (Q +rev / Q -rev), cleared one-shot; a plain merge keeps its two-token P card and empty Q. - The pending op is a new checkout special_op field (4th TLV, no default, threaded everywhere); undo serializes it (journal fmt v1->2) so undo unwinds a pending cherrypick/backout (B-merge-35).
B-merge-1..29 e2e all still pass. 1675 unit + 362 e2e green; fmt clean. Follow-up: B-merge-41 (zit info Q-card render line) deferred — its pinned text was absent from the plan; the Q card is already readable via zit artifact. (user: dmestas, size: 19400) [annotate] [blame] [check-ins using] [more...]
//! Storage §E: per-checkout working state, kept OUT of the shared log. //! Fossil keeps checkout/working state outside the repository file (in //! `_FOSSIL_`); this is the analog — a small record materialized atomically //! into `.zit-checkout` beside the working tree via a write-temp + fsync + //! rename-replace sequence (all inside std's `Dir.createFileAtomic` / //! `File.Atomic.replace`). A crash mid-write leaves the prior record intact: //! a reader only ever observes `filename`, never a half-written temp. //! //! No RFC section behind this file: native storage-engine design, the same //! oracle as logstore.zig (storage-track-plan.md §E). Effects flow only //! through an injected `std.Io` + `std.Io.Dir` (design-seams rule — no //! ambient filesystem access); tests bind `std.testing.io` + a `tmpDir`. //! //! This state deliberately does NOT enter the log: it is per-working-copy, //! not part of the shippable `.zit` container (§E). const std = @import("std"); /// The device failed, or the record on disk is not a `.zit-checkout` this /// reader can parse. Closed at the boundary so a CLI caller can switch /// exhaustively. pub const CheckoutError = error{ Storage, Corrupt }; /// The working-state file, resolved relative to the injected checkout dir. pub const filename: []const u8 = ".zit-checkout"; /// Per-string upper bound (a hash or branch name never approaches it). pub const max_ck_str: usize = 255; /// Fixed record bound: four length-prefixed strings. pub const max_ck_payload: usize = 4 * (2 + max_ck_str); // = 1028 /// One checkout's working state. Both slices borrow the caller's buffer /// (for `read`) or the caller's own storage (for `write`); this struct owns /// nothing. pub const CheckoutState = struct { /// The checked-out artifact hash. version: []const u8, /// The current branch name. branch: []const u8, /// The pending merge parent's artifact hash, or empty when no merge is /// pending. Deliberately has NO default: every construction site threads it /// explicitly so a reconstruction can never silently drop a pending merge. merge_parent: []const u8, /// The pending special-merge operation (cherry-pick / backout), recorded as /// the q-sign byte (`+` cherry-pick, `-` backout) concatenated with the /// target check-in's artifact hash, or empty when none is pending. A special /// merge and a plain merge are mutually exclusive: at most one of /// `merge_parent` / `special_op` is ever non-empty. Like `merge_parent`, it /// has NO default so no reconstruction silently drops a pending operation. special_op: []const u8, }; /// Materializes `state` atomically into `filename`. The ephemeral file is /// created with `replace = true` because it is committed via /// `File.Atomic.replace`, which asserts `file_exists` — a flag std sets only /// when that option is true. Data is fsync'd before the rename so the record /// is durable the instant it becomes visible. Every I/O failure collapses to /// one outcome (`error.Storage`); on any early return the trailing `deinit` /// deletes the unmaterialized temp. pub fn write(io: std.Io, dir: std.Io.Dir, state: CheckoutState) CheckoutError!void { std.debug.assert(state.version.len <= max_ck_str); std.debug.assert(state.branch.len <= max_ck_str); std.debug.assert(state.merge_parent.len <= max_ck_str); std.debug.assert(state.special_op.len <= max_ck_str); var buf: [max_ck_payload]u8 = undefined; const bytes = encodeCk(&buf, state); var af = dir.createFileAtomic(io, filename, .{ .replace = true }) catch return error.Storage; defer af.deinit(io); af.file.writePositionalAll(io, bytes, 0) catch return error.Storage; af.file.sync(io) catch return error.Storage; // fsync data before the rename af.replace(io) catch return error.Storage; // atomic rename into place } /// Reads and parses `filename` into `out_buf`, which must hold at least one /// byte past the record bound (`> max_ck_payload`): that extra byte is what /// lets an oversized file be detected rather than silently truncated to a /// valid-looking maximal prefix. Returns null when no checkout file exists /// (`FileNotFound` is absence, not an error, §E). A malformed record is /// `error.Corrupt`; any other I/O failure is `error.Storage`. The result's /// slices borrow `out_buf`, which must outlive them. pub fn read(io: std.Io, dir: std.Io.Dir, out_buf: []u8) CheckoutError!?CheckoutState { std.debug.assert(out_buf.len > max_ck_payload); var file = dir.openFile(io, filename, .{}) catch |e| switch (e) { error.FileNotFound => return null, else => return error.Storage, }; defer file.close(io); const n = file.readPositionalAll(io, out_buf, 0) catch return error.Storage; // No valid record exceeds max_ck_payload bytes. A read past it means the // file is longer than any record this reader emits — reject it, never parse // a truncated prefix (an exactly-maximal record leaves the extra byte free). if (n > max_ck_payload) return error.Corrupt; return try parseCk(out_buf[0..n]); } /// Encodes `s` into `buf` as `[version_len u16 LE][version][branch_len u16 /// LE][branch][merge_parent_len u16 LE][merge_parent][special_op_len u16 LE] /// [special_op]` and returns the written slice. Four TLVs are ALWAYS emitted /// (an empty field is `[00 00]`). Fixed bounds, no unbounded copy. fn encodeCk(buf: []u8, s: CheckoutState) []const u8 { std.debug.assert(buf.len >= max_ck_payload); std.debug.assert(s.version.len <= max_ck_str); std.debug.assert(s.branch.len <= max_ck_str); std.debug.assert(s.merge_parent.len <= max_ck_str); std.debug.assert(s.special_op.len <= max_ck_str); const vl: usize = s.version.len; const bl: usize = s.branch.len; const ml: usize = s.merge_parent.len; const sl: usize = s.special_op.len; std.mem.writeInt(u16, buf[0..2], @intCast(vl), .little); @memcpy(buf[2..][0..vl], s.version); std.mem.writeInt(u16, buf[2 + vl ..][0..2], @intCast(bl), .little); @memcpy(buf[4 + vl ..][0..bl], s.branch); std.mem.writeInt(u16, buf[4 + vl + bl ..][0..2], @intCast(ml), .little); @memcpy(buf[6 + vl + bl ..][0..ml], s.merge_parent); std.mem.writeInt(u16, buf[6 + vl + bl + ml ..][0..2], @intCast(sl), .little); @memcpy(buf[8 + vl + bl + ml ..][0..sl], s.special_op); return buf[0 .. 8 + vl + bl + ml + sl]; } /// Parses a `encodeCk` record. Every length is bounds-checked against the /// remaining slice before use, each length is capped at `max_ck_str`, and /// total consumption must be exact (trailing bytes are corruption, never /// silently dropped). All wire-derived, validated before use. fn parseCk(payload: []const u8) error{Corrupt}!CheckoutState { var pos: usize = 0; if (payload.len - pos < 2) return error.Corrupt; const vlen: usize = std.mem.readInt(u16, payload[pos..][0..2], .little); pos += 2; if (vlen > max_ck_str) return error.Corrupt; if (payload.len - pos < vlen) return error.Corrupt; const version = payload[pos..][0..vlen]; pos += vlen; if (payload.len - pos < 2) return error.Corrupt; const blen: usize = std.mem.readInt(u16, payload[pos..][0..2], .little); pos += 2; if (blen > max_ck_str) return error.Corrupt; if (payload.len - pos < blen) return error.Corrupt; const branch = payload[pos..][0..blen]; pos += blen; // Third TLV (merge_parent) is read WHEN PRESENT. A legacy two-field record // ends exactly here (`pos == payload.len`): it parses as "no pending merge" // — the pending merge errors out of existence, no versioning byte needed. var merge_parent: []const u8 = ""; if (pos != payload.len) { if (payload.len - pos < 2) return error.Corrupt; const mlen: usize = std.mem.readInt(u16, payload[pos..][0..2], .little); pos += 2; if (mlen > max_ck_str) return error.Corrupt; if (payload.len - pos < mlen) return error.Corrupt; merge_parent = payload[pos..][0..mlen]; pos += mlen; } // Fourth TLV (special_op) is likewise read WHEN PRESENT. A three-field // record written before special_op existed ends here and parses as "no // pending operation" — same errors-out-of-existence tolerance as above. var special_op: []const u8 = ""; if (pos != payload.len) { if (payload.len - pos < 2) return error.Corrupt; const slen: usize = std.mem.readInt(u16, payload[pos..][0..2], .little); pos += 2; if (slen > max_ck_str) return error.Corrupt; if (payload.len - pos < slen) return error.Corrupt; special_op = payload[pos..][0..slen]; pos += slen; } if (pos != payload.len) return error.Corrupt; // no trailing bytes return .{ .version = version, .branch = branch, .merge_parent = merge_parent, .special_op = special_op }; } // --------------------------------------------------------------------------- // Tests (storage-track-plan.md §E; no RFC section behind this file). // --------------------------------------------------------------------------- test "checkout §E: write then read round-trips version branch merge_parent and special_op" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const io = std.testing.io; const state: CheckoutState = .{ .version = "a" ** 64, .branch = "trunk", .merge_parent = "b" ** 64, .special_op = "" }; try write(io, tmp.dir, state); var buf: [max_ck_payload + 1]u8 = undefined; const got = (try read(io, tmp.dir, &buf)).?; try std.testing.expectEqualStrings(state.version, got.version); try std.testing.expectEqualStrings(state.branch, got.branch); try std.testing.expectEqualStrings(state.merge_parent, got.merge_parent); try std.testing.expectEqualStrings("", got.special_op); // A pending special-merge op round-trips independently of merge_parent. const cp: CheckoutState = .{ .version = "a" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "+" ++ "d" ** 64 }; try write(io, tmp.dir, cp); const got2 = (try read(io, tmp.dir, &buf)).?; try std.testing.expectEqualStrings("", got2.merge_parent); try std.testing.expectEqualStrings("+" ++ "d" ** 64, got2.special_op); } test "checkout §E: an empty merge_parent and special_op round-trip as no pending op" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const io = std.testing.io; try write(io, tmp.dir, .{ .version = "a" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "" }); var buf: [max_ck_payload + 1]u8 = undefined; const got = (try read(io, tmp.dir, &buf)).?; try std.testing.expectEqualStrings("a" ** 64, got.version); try std.testing.expectEqualStrings("trunk", got.branch); try std.testing.expectEqualStrings("", got.merge_parent); try std.testing.expectEqualStrings("", got.special_op); } test "checkout §E: legacy two-field and three-field records parse with empty trailing fields" { // A record written before merge_parent existed carries only version+branch // and ends after the branch. parseCk must yield merge_parent/special_op == // "" rather than reject it — errors-out-of-existence, §E. const legacy2 = [_]u8{ 0x01, 0x00, 'v', 0x05, 0x00, 't', 'r', 'u', 'n', 'k' }; const got2 = try parseCk(&legacy2); try std.testing.expectEqualStrings("v", got2.version); try std.testing.expectEqualStrings("trunk", got2.branch); try std.testing.expectEqualStrings("", got2.merge_parent); try std.testing.expectEqualStrings("", got2.special_op); // A three-field record (version+branch+merge_parent, no special_op) parses // with special_op == "". const legacy3 = [_]u8{ 0x01, 0x00, 'v', 0x05, 0x00, 't', 'r', 'u', 'n', 'k', 0x01, 0x00, 'm' }; const got3 = try parseCk(&legacy3); try std.testing.expectEqualStrings("m", got3.merge_parent); try std.testing.expectEqualStrings("", got3.special_op); } test "checkout §E: a second write atomically replaces the state (last wins)" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const io = std.testing.io; try write(io, tmp.dir, .{ .version = "1" ** 40, .branch = "feature", .merge_parent = "", .special_op = "" }); try write(io, tmp.dir, .{ .version = "2" ** 40, .branch = "release", .merge_parent = "", .special_op = "" }); var buf: [max_ck_payload + 1]u8 = undefined; const got = (try read(io, tmp.dir, &buf)).?; try std.testing.expectEqualStrings("2" ** 40, got.version); try std.testing.expectEqualStrings("release", got.branch); } test "checkout §E: read of an absent checkout file returns null" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const io = std.testing.io; var buf: [max_ck_payload + 1]u8 = undefined; try std.testing.expectEqual(@as(?CheckoutState, null), try read(io, tmp.dir, &buf)); } test "checkout §E: a partial write that never materializes leaves the prior state intact" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const io = std.testing.io; try write(io, tmp.dir, .{ .version = "old" ** 8, .branch = "trunk", .merge_parent = "", .special_op = "" }); // Begin a fresh atomic file, write bytes, then drop it WITHOUT replace: // deinit deletes the temp, and a reader only ever sees `filename`. { var af = try tmp.dir.createFileAtomic(io, filename, .{ .replace = true }); defer af.deinit(io); var b: [max_ck_payload]u8 = undefined; const bytes = encodeCk(&b, .{ .version = "new" ** 8, .branch = "feature", .merge_parent = "", .special_op = "" }); try af.file.writePositionalAll(io, bytes, 0); // no af.replace(io): the temp is abandoned } var buf: [max_ck_payload + 1]u8 = undefined; const got = (try read(io, tmp.dir, &buf)).?; try std.testing.expectEqualStrings("old" ** 8, got.version); try std.testing.expectEqualStrings("trunk", got.branch); } test "checkout §E: parseCk rejects malformed checkout records" { // TLV bounds: one rejection per condition (storage-track-plan.md §E). var over: [4 + 300]u8 = undefined; std.mem.writeInt(u16, over[0..2], 300, .little); // version_len > max_ck_str const cases = [_][]const u8{ &[_]u8{0x00}, // truncated version_len (1 of 2 bytes) over[0..2], // version_len exceeds max_ck_str &[_]u8{ 0x05, 0x00, 'a', 'b' }, // version_len 5 exceeds remaining &[_]u8{ 0x01, 0x00, 'v', 0x02 }, // truncated branch_len after version &[_]u8{ 0x01, 0x00, 'v', 0x00, 0x00, 0x09 }, // truncated merge_parent_len after branch &[_]u8{ 0x01, 0x00, 'v', 0x00, 0x00, 0x05, 0x00, 'a' }, // merge_parent_len 5 exceeds remaining &[_]u8{ 0x01, 0x00, 'v', 0x00, 0x00, 0x00, 0x00, 0x09 }, // truncated special_op_len after merge_parent &[_]u8{ 0x01, 0x00, 'v', 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 'a' }, // special_op_len 5 exceeds remaining &[_]u8{ 0x01, 0x00, 'v', 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09 }, // trailing byte after special_op }; for (cases) |c| try std.testing.expectError(error.Corrupt, parseCk(c)); // Whole-file variant: garbage in `filename` surfaces as Corrupt. var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const io = std.testing.io; { var f = try tmp.dir.createFile(io, filename, .{}); defer f.close(io); try f.writePositionalAll(io, &[_]u8{ 0xff, 0xff, 0xaa }, 0); // version_len 0xffff } var buf: [max_ck_payload + 1]u8 = undefined; try std.testing.expectError(error.Corrupt, read(io, tmp.dir, &buf)); } test "checkout §E: a file one byte past a maximal record is rejected, not truncated" { const io = std.testing.io; // The unique maximal record consumes exactly max_ck_payload bytes // (four length-prefixed 255-byte strings). It must still round-trip. var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try write(io, tmp.dir, .{ .version = "v" ** max_ck_str, .branch = "b" ** max_ck_str, .merge_parent = "m" ** max_ck_str, .special_op = "s" ** max_ck_str }); var okbuf: [max_ck_payload + 1]u8 = undefined; const ok = (try read(io, tmp.dir, &okbuf)).?; try std.testing.expectEqual(@as(usize, max_ck_str), ok.version.len); try std.testing.expectEqual(@as(usize, max_ck_str), ok.branch.len); try std.testing.expectEqual(@as(usize, max_ck_str), ok.merge_parent.len); try std.testing.expectEqual(@as(usize, max_ck_str), ok.special_op.len); // The same maximal record with one trailing byte is a longer-than-any-record // file: read must reject it rather than parse the maximal prefix and drop // the excess. var tmp2 = std.testing.tmpDir(.{}); defer tmp2.cleanup(); var rec: [max_ck_payload]u8 = undefined; const maximal = encodeCk(&rec, .{ .version = "v" ** max_ck_str, .branch = "b" ** max_ck_str, .merge_parent = "m" ** max_ck_str, .special_op = "s" ** max_ck_str }); try std.testing.expectEqual(@as(usize, max_ck_payload), maximal.len); { var f = try tmp2.dir.createFile(io, filename, .{}); defer f.close(io); try f.writePositionalAll(io, maximal, 0); try f.writePositionalAll(io, &[_]u8{0x00}, @intCast(maximal.len)); } var buf: [max_ck_payload + 1]u8 = undefined; try std.testing.expectError(error.Corrupt, read(io, tmp2.dir, &buf)); } // Seeded deterministic fuzz: parseCk on adversarial bytes yields a value or // error.Corrupt, never a crash; and parseCk(encodeCk(x)) == x for any bounded // pair. test "checkout §E fuzz: parseCk never crashes and round-trips encodeCk" { var prng = std.Random.DefaultPrng.init(0xC4EC); const random = prng.random(); var iter: usize = 0; while (iter < 400) : (iter += 1) { // Round-trip: parseCk(encodeCk(x)) == x for random bounded strings. var vbuf: [max_ck_str]u8 = undefined; var bbuf: [max_ck_str]u8 = undefined; var mbuf: [max_ck_str]u8 = undefined; var sbuf: [max_ck_str]u8 = undefined; const vn = random.uintAtMost(usize, max_ck_str); const bn = random.uintAtMost(usize, max_ck_str); const mn = random.uintAtMost(usize, max_ck_str); const sn = random.uintAtMost(usize, max_ck_str); random.bytes(vbuf[0..vn]); // wire data embeds NUL legally random.bytes(bbuf[0..bn]); random.bytes(mbuf[0..mn]); random.bytes(sbuf[0..sn]); var enc: [max_ck_payload]u8 = undefined; const bytes = encodeCk(&enc, .{ .version = vbuf[0..vn], .branch = bbuf[0..bn], .merge_parent = mbuf[0..mn], .special_op = sbuf[0..sn] }); const rt = try parseCk(bytes); try std.testing.expectEqualSlices(u8, vbuf[0..vn], rt.version); try std.testing.expectEqualSlices(u8, bbuf[0..bn], rt.branch); try std.testing.expectEqualSlices(u8, mbuf[0..mn], rt.merge_parent); try std.testing.expectEqualSlices(u8, sbuf[0..sn], rt.special_op); // Garbage: any random byte string is a value or Corrupt, never a crash. var garbage: [64]u8 = undefined; const gn = random.uintAtMost(usize, garbage.len); random.bytes(garbage[0..gn]); if (parseCk(garbage[0..gn])) |_| {} else |err| switch (err) { error.Corrupt => {}, } } }