Artifact 6250b4f2282df00cb25b4ace651a3426fe04376b81d70356893ed94d1680bebf:
- File
src/cmd/precedent.zig
— part of check-in
[9033c13f21]
at
2026-09-17 17:04:25
on branch trunk
— Remove issue-tracker numbers from source and documents
The tracker is disposable and does not live with the source, so a number is a dangling pointer. Every zit#NN and #NN reference in code comments, test names, help text, smoke labels, identifiers, release notes and errata is replaced by enough context to stand alone, or by a short plain statement of the problem. No behaviour changed. (user: dmestas, size: 13213) [annotate] [blame] [check-ins using]
//! `zit precedent` (alias `zit rerere`) — remembered conflict resolutions. //! //! NOT AN RFC VERB, and invisible to fossil: the store is the `.zit-precedent` //! sidecar (`src/precedent.zig`), local like stash, undo, and bisect. No //! artifact bytes, no wire bytes. //! //! The verb family: //! //! precedent list one line per remembered resolution //! precedent show ID the conflict a precedent would fire on //! precedent forget ID drop one //! precedent forget --all drop every one //! //! `list` and `show` exist because of the property this feature has to keep: //! a replayed resolution must be inspectable, not merely counted. A precedent //! you cannot read is one you cannot audit after it has written something //! wrong into your working tree. //! //! ID is a prefix of the precedent's key, the way a check-in is named by a //! hash prefix. An ambiguous prefix is refused rather than resolved by //! position: "the first one that matched" is not a name. const std = @import("std"); const zit = @import("zit"); const cli = zit.cli; const precedent_mod = zit.precedent; const main = @import("../main.zig"); const stateOom = main.stateOom; const not_a_repo_msg = main.not_a_repo_msg; /// Short form shown by `list` and accepted by `forget`/`show`. Twelve hex /// characters, matching how the rest of zit abbreviates a hash. const short_len: usize = 12; pub fn runPrecedent( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { var action: []const u8 = "list"; var id_arg: ?[]const u8 = null; var all = false; { var positionals: usize = 0; var i: usize = 0; while (i < verb_args.len) : (i += 1) { const tok = verb_args[i]; if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { streams.out.writeAll(main.precedent_help) catch return .failure; return .ok; } else if (std.mem.eql(u8, tok, "--all")) { all = true; } else if (tok.len > 0 and tok[0] == '-') { return cli.usageError(streams, "precedent", .unknown_option, tok); } else if (positionals == 0 and isAction(tok)) { action = tok; positionals += 1; } else if (id_arg == null) { id_arg = tok; } else { return cli.usageError(streams, "precedent", .extra_argument, tok); } } } const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "precedent")) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); var loaded = precedent_mod.load(gpa, io, root_dir) catch return stateOom(streams, "precedent"); defer loaded.precedents.deinit(); // Damage is degraded-but-reported: the set is empty, and saying so is what // separates a cache that lost its contents from one that never had any. if (loaded.damaged) { streams.err.writeAll( "zit precedent: the .zit-precedent sidecar was unreadable and has been treated as empty; " ++ "remembered resolutions are gone, nothing else is affected\n", ) catch {}; } if (std.mem.eql(u8, action, "list")) return listAction(&loaded.precedents, streams); if (std.mem.eql(u8, action, "show")) return showAction(&loaded.precedents, id_arg, streams); if (std.mem.eql(u8, action, "forget")) return forgetAction(gpa, io, root_dir, &loaded.precedents, id_arg, all, streams); unreachable; // isAction admitted it } fn isAction(tok: []const u8) bool { for ([_][]const u8{ "list", "show", "forget" }) |a| { if (std.mem.eql(u8, tok, a)) return true; } return false; } fn listAction(p: *const precedent_mod.Precedents, streams: cli.Streams) cli.Exit { if (p.entries.items.len == 0) { streams.out.writeAll("no precedents\n") catch return .failure; return .ok; } for (p.entries.items) |e| { var hex: [64]u8 = undefined; const full = std.fmt.bufPrint(&hex, "{x}", .{e.key}) catch return .failure; // A pending entry is named as such rather than shown as "0 resolved": // the difference between "remembered nothing" and "waiting for the // commit that resolves it" is the whole reading of this line. if (e.pending) { streams.out.print("{s} {s} pending (conflicted at merge, not yet resolved)\n", .{ full[0..short_len], e.path, }) catch return .failure; } else { streams.out.print("{s} {s} {d} conflicted, {d} resolved\n", .{ full[0..short_len], e.path, e.conflicted.len, e.resolved.len, }) catch return .failure; } } return .ok; } fn showAction( p: *const precedent_mod.Precedents, id_arg: ?[]const u8, streams: cli.Streams, ) cli.Exit { const id = id_arg orelse { streams.err.writeAll("zit precedent show: an ID is required\n") catch {}; return .failure; }; const e = (resolveOne(p, id, streams) catch return .failure) orelse return .failure; streams.out.print("path: {s}\n\n--- conflicted ---\n{s}\n--- resolved ---\n{s}", .{ e.path, e.conflicted, e.resolved, }) catch return .failure; return .ok; } fn forgetAction( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, p: *precedent_mod.Precedents, id_arg: ?[]const u8, all: bool, streams: cli.Streams, ) cli.Exit { if (all) { if (id_arg != null) { streams.err.writeAll("zit precedent forget: --all takes no ID\n") catch {}; return .failure; } const n = p.forgetAll(); precedent_mod.store(p, gpa, io, root_dir) catch return stateOom(streams, "precedent"); streams.out.print("forgot {d} precedent(s)\n", .{n}) catch return .failure; return .ok; } const id = id_arg orelse { streams.err.writeAll("zit precedent forget: an ID is required (or --all)\n") catch {}; return .failure; }; const e = (resolveOne(p, id, streams) catch return .failure) orelse return .failure; const k = e.key; _ = p.forget(k); precedent_mod.store(p, gpa, io, root_dir) catch return stateOom(streams, "precedent"); streams.out.writeAll("forgot 1 precedent\n") catch return .failure; return .ok; } /// Resolve an ID prefix to exactly one precedent. Ambiguity and absence are /// both refused, each with the diagnostic that names what actually went wrong. fn resolveOne( p: *const precedent_mod.Precedents, id: []const u8, streams: cli.Streams, ) error{Failed}!?*const precedent_mod.Entry { var hit: ?*const precedent_mod.Entry = null; var matches: usize = 0; for (p.entries.items) |*e| { var hex: [64]u8 = undefined; const full = std.fmt.bufPrint(&hex, "{x}", .{e.key}) catch return error.Failed; if (full.len >= id.len and std.mem.eql(u8, full[0..id.len], id)) { matches += 1; hit = e; } } if (matches == 0) { streams.err.print("zit precedent: no precedent matches '{s}'\n", .{id}) catch {}; return null; } if (matches > 1) { streams.err.print( "zit precedent: '{s}' matches {d} precedents; use more characters\n", .{ id, matches }, ) catch {}; return null; } return hit; } // --------------------------------------------------------------------------- // Merge and commit integration // // Both hooks live here rather than in `cmd/checkout.zig` so the merge outcome // loop keeps one call site instead of the feature's logic. That is not only // tidiness: `--X ours/theirs` edits the same loop, and a feature whose body // sits inside it turns every future change to merge into a three-way conflict // about this one. // --------------------------------------------------------------------------- const update = zit.update; const merge3 = @import("../merge3.zig"); /// True when `bytes` still carries a conflict envelope at a line start. The /// sentinel comes from `merge3`, never a second transcription of the literal. fn hasMarker(bytes: []const u8) bool { if (std.mem.startsWith(u8, bytes, merge3.conflict_sentinel)) return true; return std.mem.indexOf(u8, bytes, "\n" ++ merge3.conflict_sentinel) != null; } pub const ReplayReport = struct { replayed: usize = 0, recorded: usize = 0, damaged: bool = false, }; /// Replay remembered resolutions over the conflicts a merge just produced, and /// remember the rest as pending so `commit` can learn how they get settled. /// /// `conflicts` is compacted in place: a path resolved from precedent is no /// longer a conflict, which is what makes the merge report and the commit /// refusal agree with what is actually in the working tree. /// /// Never fails the merge. Every failure path here — an unreadable sidecar, a /// full disk on store — leaves the ordinary conflict behaviour intact, because /// this feature is an optimisation over a correct answer that already exists. pub fn replayAtMerge( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, root_dir: std.Io.Dir, entries: []update.MergeEntry, conflicts: *std.ArrayList([]const u8), enabled: bool, streams: cli.Streams, ) ReplayReport { if (!enabled or conflicts.items.len == 0) return .{}; var loaded = precedent_mod.load(gpa, io, root_dir) catch return .{}; defer loaded.precedents.deinit(); var report: ReplayReport = .{ .damaged = loaded.damaged }; var keep: usize = 0; for (conflicts.items) |path| { const e = entryFor(entries, path) orelse { conflicts.items[keep] = path; keep += 1; continue; }; const conflicted = switch (e.action) { .write => |w| w.bytes, .delete => { conflicts.items[keep] = path; keep += 1; continue; }, }; const k = precedent_mod.key(path, conflicted); if (loaded.precedents.find(k)) |hit| { const resolved = arena.dupe(u8, hit.resolved) catch { conflicts.items[keep] = path; keep += 1; continue; }; e.action = .{ .write = .{ .bytes = resolved, .perm = switch (e.action) { .write => |w| w.perm, .delete => null, } } }; report.replayed += 1; // Named, not counted: a replayed resolution the operator cannot see // is the failure mode this feature is built around. streams.err.print("zit merge: {s} resolved from precedent\n", .{path}) catch {}; } else { _ = loaded.precedents.recordPending(path, conflicted) catch {}; report.recorded += 1; conflicts.items[keep] = path; keep += 1; } } conflicts.shrinkRetainingCapacity(keep); precedent_mod.store(&loaded.precedents, gpa, io, root_dir) catch {}; if (report.replayed != 0) { streams.err.print( "zit merge: {d} file(s) resolved from precedent; 'zit precedent show' to inspect, " ++ "'zit precedent forget' to undo the memory\n", .{report.replayed}, ) catch {}; } return report; } fn entryFor(entries: []update.MergeEntry, path: []const u8) ?*update.MergeEntry { for (entries) |*e| { if (std.mem.eql(u8, e.path, path)) return e; } return null; } /// Turn the conflicts a merge recorded as pending into replayable precedents, /// using the bytes the operator settled on. Called by `commit` only after its /// unresolved-marker refusal has passed, so every pending file is genuinely /// resolved by then. /// /// Returns how many were learned. Best-effort throughout: failing to remember a /// resolution must never fail the commit that authored it. pub fn promoteAtCommit( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, streams: cli.Streams, ) usize { var loaded = precedent_mod.load(gpa, io, root_dir) catch return 0; defer loaded.precedents.deinit(); var learned: usize = 0; for (loaded.precedents.entries.items) |*e| { if (!e.pending) continue; const bytes = root_dir.readFileAlloc(io, e.path, gpa, .limited(1 << 30)) catch continue; defer gpa.free(bytes); // A file still carrying markers cannot be a resolution. `commit`'s own // refusal should have caught it; this is the second lock on the door. if (hasMarker(bytes)) continue; if (loaded.precedents.resolve(e.key, bytes) catch continue) learned += 1; } if (learned == 0) return 0; precedent_mod.store(&loaded.precedents, gpa, io, root_dir) catch return 0; streams.err.print( "zit commit: remembered {d} conflict resolution(s) as precedent\n", .{learned}, ) catch {}; return learned; }