//! `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("zit").merge3;
/// 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;
}