Skip to content
zit Artifact [6250b4f228]
Login

Artifact [6250b4f228]

Artifact 6250b4f2282df00cb25b4ace651a3426fe04376b81d70356893ed94d1680bebf:


//! `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;
}