Skip to content
zit Artifact [d00ce91352]
Login

Artifact [d00ce91352]

Artifact d00ce91352ef9ee79ec0c834bc3ef240de7835e2842a7838031eb45bb4769c50:


//! `zit merge-tree` — the tree a merge would produce, written as plain files
//! or a tar archive, with no checkout and nothing written to the store; or
//! the merge as JSON; or the merge recorded with a caller's resolutions.
//!
//! What a pipeline needs to test a branch as merged into its target is the
//! merged files, not a merge check-in: a trial check-in would be permanent and
//! sync to every clone, and the pair of tips already identifies what was
//! tested. This is `git merge-tree --write-tree`, and a forge's merge ref, in
//! zit's terms.
//!
//! The merge itself is `mergeOnStore`, the engine `merge --probe` and
//! `landing` use, so the three can never disagree about whether a pair merges
//! or what it merges to. This verb only resolves the two names and writes the
//! result.
//!
//! Conflicts are an answer, not a failure: the conflicted paths go to stdout,
//! one per line, nothing is written, and the exit status is 5, the status
//! `landing accept` gives the same finding.
//!
//! `-F json` is that answer for a forge: each conflicting file with its three
//! blobs and, for text, the regions of the line merge that found the
//! conflict, so a page can show the merge region by region without merging
//! again. `--commit` takes the forge's answers back: the conflicting files
//! resolved in a directory, everything else from the same merge, recorded as
//! `zit merge` then `zit commit` would record it, in a working area that is
//! gone afterwards.

const std = @import("std");
const zit = @import("zit");
const cli = @import("zit").cli;
const archive = @import("zit").archive;
const artifact = @import("zit").artifact;
const update = @import("zit").update;
const Repository = @import("zit").Repository;

const checkout = @import("zit").checkout;
const managed = @import("zit").managed;
const cone_mod = @import("zit").cone;

const main = @import("../main.zig");
const cmd_checkout = @import("checkout.zig");
const cmd_landing = @import("landing.zig");
const merge3 = @import("../merge3.zig");

const verb = "merge-tree";

/// Largest single file: `zit tarball`'s bound, since a file is held whole in
/// memory to be written.
const max_member_bytes: u64 = 1 << 32;

const Member = struct {
    path: []const u8,
    entry: cmd_checkout.TreeEntry,
};

pub fn runMergeTree(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    const parsed = switch (cli.parseMergeTreeArgs(arena, verb_args) catch return oom(streams)) {
        .ok => |p| p,
        .unknown_option => |t| return cli.usageError(streams, verb, .unknown_option, t),
        .extra_argument => |t| return cli.usageError(streams, verb, .extra_argument, t),
    };
    if (parsed.help) {
        streams.out.writeAll(main.merge_tree_help) catch {};
        return .ok;
    }
    const branch = parsed.branch orelse return cli.usageError(streams, verb, .no_checkin, "");
    const outputs = @as(u8, @intFromBool(parsed.out != null)) + @intFromBool(parsed.tar) +
        @intFromBool(parsed.json) + @intFromBool(parsed.commit);
    if (outputs != 1) {
        streams.err.writeAll("zit merge-tree: give exactly one of --out DIR, --tar, -F json or --commit\n") catch {};
        return .usage;
    }
    if (!parsed.commit and (parsed.resolved != null or parsed.deletes.len != 0 or parsed.message != null or
        parsed.user != null or parsed.expect_current != null or parsed.expect_other != null))
    {
        streams.err.writeAll("zit merge-tree: --resolved, --delete, -m, --user and --expect-* go with --commit\n") catch {};
        return .usage;
    }
    if (parsed.out) |dir| if (!outputDirUsable(io, dir)) {
        streams.err.print("zit merge-tree: '{s}' must be an empty directory or not exist yet\n", .{dir}) catch {};
        return .failure;
    };
    if (parsed.commit) return runCommit(gpa, io, arena, streams, amb, parsed, branch);

    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    const rc = main.openRepo(gpa, io, arena, streams, verb, amb.repo_override, false, false, &repo, &root_dir);
    if (rc != .ok) return rc;
    defer root_dir.close(io);
    defer repo.deinit(gpa);

    const branch_rid = resolve(&repo, streams, branch) orelse return .failure;
    // The target: named, or the checkout's own check-in, as `merge --probe`
    // takes it. With `-R` there is no checkout to fall back to.
    const target_rid = if (parsed.into) |t|
        resolve(&repo, streams, t) orelse return .failure
    else
        repo.currentCheckoutRid() orelse {
            streams.err.writeAll("zit merge-tree: name the target with --into TARGET (there is no checkout to merge into)\n") catch {};
            return .usage;
        };
    const target_label = parsed.into orelse "the checkout";

    const m = cmd_checkout.mergeOnStore(gpa, arena, &repo, target_rid, branch_rid) catch
        return oom(streams);
    if (parsed.json) return writeJson(gpa, &repo, streams, m, target_rid, branch_rid);
    switch (m.verdict) {
        .unrelated => {
            streams.err.print("zit merge-tree: '{s}' and {s} share no ancestor; there is nothing to merge\n", .{ branch, target_label }) catch {};
            return .failure;
        },
        .conflicts => |paths| {
            for (paths) |path| streams.out.print("{s}\n", .{path}) catch {};
            streams.err.print("zit merge-tree: '{s}' does not merge cleanly into {s}: {d} conflicting file(s); nothing was written\n", .{ branch, target_label, paths.len }) catch {};
            return .conflicts;
        },
        .up_to_date => streams.err.print("zit merge-tree: '{s}' is already in {s}; its tree is the result\n", .{ branch, target_label }) catch {},
        .clean => {},
    }

    // Canonical path order, so the same pair always writes the same bytes.
    const members = arena.alloc(Member, m.tree.count()) catch return oom(streams);
    var it = m.tree.iterator();
    var i: usize = 0;
    while (it.next()) |e| : (i += 1) members[i] = .{ .path = e.key_ptr.*, .entry = e.value_ptr.* };
    std.mem.sort(Member, members, {}, struct {
        fn lt(_: void, a: Member, b: Member) bool {
            return artifact.compareBytes(a.path, b.path) == .lt;
        }
    }.lt);

    // Every file's content before a byte is written: a file the store does
    // not hold (a sparse store elides out-of-cone content) is refused by
    // name, never left out of a tree that would then look complete.
    var missing = false;
    for (members) |mb| switch (mb.entry.content) {
        .bytes => {},
        .hash => |h| if (!(repo.blobPresent(gpa, h) catch return oom(streams))) {
            streams.err.print("zit merge-tree: the repository does not hold the content of '{s}'\n", .{mb.path}) catch {};
            missing = true;
        },
    };
    if (missing) return .failure;

    if (parsed.out) |dir| return writeDir(gpa, io, &repo, streams, dir, members);
    // Stamped with the later of the two check-ins, so the archive depends on
    // the pair and nothing else.
    const mtime_ms = @max(repo.state().mtimeOf(target_rid) orelse 0, repo.state().mtimeOf(branch_rid) orelse 0);
    return writeTar(gpa, &repo, streams, members, mtime_ms / 1000);
}

fn resolve(repo: *Repository, streams: cli.Streams, name: []const u8) ?u64 {
    return repo.resolve(name, .checkin) catch |e| {
        switch (e) {
            error.NotFound => streams.err.print("zit merge-tree: check-in '{s}' not found\n", .{name}) catch {},
            error.Ambiguous => streams.err.print("zit merge-tree: name '{s}' is ambiguous\n", .{name}) catch {},
            error.OutOfMemory => _ = oom(streams),
        }
        return null;
    };
}

/// An output directory that does not exist yet, or exists and is empty.
fn outputDirUsable(io: std.Io, path: []const u8) bool {
    var dir = std.Io.Dir.cwd().openDir(io, path, .{ .iterate = true }) catch |e| return e == error.FileNotFound;
    defer dir.close(io);
    var walk = dir.iterate();
    return (walk.next(io) catch return false) == null;
}

fn writeDir(
    gpa: std.mem.Allocator,
    io: std.Io,
    repo: *Repository,
    streams: cli.Streams,
    path: []const u8,
    members: []const Member,
) cli.Exit {
    std.Io.Dir.cwd().createDirPath(io, path) catch {
        streams.err.print("zit merge-tree: cannot create '{s}'\n", .{path}) catch {};
        return .failure;
    };
    var dir = std.Io.Dir.cwd().openDir(io, path, .{}) catch {
        streams.err.print("zit merge-tree: cannot open '{s}'\n", .{path}) catch {};
        return .failure;
    };
    defer dir.close(io);
    for (members) |mb| {
        var arena_state = std.heap.ArenaAllocator.init(gpa);
        defer arena_state.deinit();
        const bytes = (cmd_checkout.contentBytes(arena_state.allocator(), repo, mb.entry.content) catch |e| {
            if (e == error.OutOfMemory) return oom(streams);
            return unreadable(streams, mb.path);
        }).?;
        update.restoreFile(io, dir, mb.path, bytes, mb.entry.mode) catch {
            streams.err.print("zit merge-tree: cannot write '{s}' under '{s}'\n", .{ mb.path, path }) catch {};
            return .failure;
        };
    }
    return .ok;
}

/// A gzip-compressed tar on stdout, members at the archive root, written the
/// way `zit tarball` writes one: no uid, gid, user name or host, one
/// timestamp, canonical order.
fn writeTar(
    gpa: std.mem.Allocator,
    repo: *Repository,
    streams: cli.Streams,
    members: []const Member,
    mtime: u64,
) cli.Exit {
    var window: [std.compress.flate.max_window_len]u8 = undefined;
    var gz = std.compress.flate.Compress.init(streams.out, &window, .gzip, .default) catch return writeFail(streams);
    const w = &gz.writer;
    for (members) |mb| {
        var arena_state = std.heap.ArenaAllocator.init(gpa);
        defer arena_state.deinit();
        const bytes = (cmd_checkout.contentBytes(arena_state.allocator(), repo, mb.entry.content) catch |e| {
            if (e == error.OutOfMemory) return oom(streams);
            return unreadable(streams, mb.path);
        }).?;
        if (bytes.len > max_member_bytes) {
            streams.err.print("zit merge-tree: '{s}' is larger than one archive member may be\n", .{mb.path}) catch {};
            return .failure;
        }
        // A symlink's content is its target: the header's link field, no
        // member content.
        const is_link = mb.entry.mode == .symlink;
        if (is_link and bytes.len > archive.ustar_name_len) {
            streams.err.print("zit merge-tree: '{s}' links to a target too long for tar\n", .{mb.path}) catch {};
            return .failure;
        }
        const size: u64 = if (is_link) 0 else bytes.len;
        archive.beginTarMember(w, mb.path, size, mb.entry.mode, mtime, if (is_link) bytes else null) catch return writeFail(streams);
        if (!is_link) {
            w.writeAll(bytes) catch return writeFail(streams);
            archive.endTarMember(w, size) catch return writeFail(streams);
        }
    }
    archive.writeTarEnd(w) catch return writeFail(streams);
    gz.finish() catch return writeFail(streams);
    return .ok;
}

fn unreadable(streams: cli.Streams, path: []const u8) cli.Exit {
    streams.err.print("zit merge-tree: the content of '{s}' could not be read\n", .{path}) catch {};
    return .failure;
}

fn writeFail(streams: cli.Streams) cli.Exit {
    streams.err.writeAll("zit merge-tree: cannot write the archive to stdout\n") catch {};
    return .failure;
}

fn oom(streams: cli.Streams) cli.Exit {
    streams.err.writeAll("zit merge-tree: out of memory\n") catch {};
    return .failure;
}

/// `-F json`: the verdict, the three check-ins, and every conflicting file,
/// as one document on one line. The exit status is the text form's.
fn writeJson(
    gpa: std.mem.Allocator,
    repo: *Repository,
    streams: cli.Streams,
    m: cmd_checkout.StoreMerge,
    current_rid: u64,
    other_rid: u64,
) cli.Exit {
    var aw: std.Io.Writer.Allocating = .init(gpa);
    defer aw.deinit();
    var jw: std.json.Stringify = .{ .writer = &aw.writer };
    // An Allocating writer fails only on OOM, so every error here is one.
    writeJsonDoc(repo, &jw, m, current_rid, other_rid) catch return oom(streams);
    aw.writer.writeByte('\n') catch return oom(streams);
    streams.out.writeAll(aw.written()) catch {};
    return switch (m.verdict) {
        .clean, .up_to_date => .ok,
        .conflicts => .conflicts,
        .unrelated => .failure,
    };
}

fn writeJsonDoc(
    repo: *Repository,
    jw: *std.json.Stringify,
    m: cmd_checkout.StoreMerge,
    current_rid: u64,
    other_rid: u64,
) !void {
    try jw.beginObject();
    try jw.objectField("verdict");
    try jw.write(switch (m.verdict) {
        .clean => "clean",
        .up_to_date => "up-to-date",
        .conflicts => "conflict",
        .unrelated => "unrelated",
    });
    try jw.objectField("base");
    if (m.base) |b| try jw.write(repo.hashOf(b)) else try jw.write(null);
    try jw.objectField("current");
    try jw.write(repo.hashOf(current_rid));
    try jw.objectField("other");
    try jw.write(repo.hashOf(other_rid));
    try jw.objectField("files");
    try jw.beginArray();
    for (m.conflict_files) |cf| {
        try jw.beginObject();
        try jw.objectField("path");
        try jw.write(cf.path);
        try jw.objectField("kind");
        try jw.write(kindName(cf.kind));
        inline for (.{ "base", "current", "other" }) |side| {
            try jw.objectField(side);
            try writeBlob(jw, @field(cf, side));
        }
        try jw.objectField("mode");
        try jw.beginObject();
        inline for (.{ "base", "current", "other" }) |side| {
            try jw.objectField(side);
            if (@field(cf, side ++ "_mode")) |md| try jw.write(@tagName(md)) else try jw.write(null);
        }
        try jw.endObject();
        if (cf.kind == .text) {
            try jw.objectField("regions");
            try jw.beginArray();
            for (cf.regions) |r| {
                try jw.beginObject();
                try jw.objectField("kind");
                try jw.write(@tagName(r.kind));
                inline for (.{ "base", "current", "other" }) |side| {
                    const lr: merge3.LineRange = @field(r, side);
                    try jw.objectField(side);
                    try jw.beginArray();
                    try jw.write(lr.lo);
                    try jw.write(lr.hi);
                    try jw.endArray();
                }
                try jw.endObject();
            }
            try jw.endArray();
        }
        try jw.endObject();
    }
    try jw.endArray();
    try jw.endObject();
}

/// A side's blob hash, or null when the path is absent there. `mergeOnStore`
/// merges a check-in's own tree, so every side names a stored blob; bytes
/// exist only on a chained probe's later rungs.
fn writeBlob(jw: *std.json.Stringify, c: ?cmd_checkout.TreeContent) !void {
    const v = c orelse return jw.write(null);
    switch (v) {
        .hash => |h| try jw.write(h),
        .bytes => unreachable,
    }
}

fn kindName(k: cmd_checkout.ConflictKind) []const u8 {
    return switch (k) {
        .text => "text",
        .binary => "binary",
        .add_add => "add-add",
        .delete_modify => "delete-modify",
        .modify_delete => "modify-delete",
        .unreadable => "unreadable",
    };
}

/// `--commit`: record the merge of OTHER into CURRENT with the caller's
/// resolution of every conflicting path. The merge is `mergeOnStore`'s, and
/// it is recorded the way a person records it -- `merge`, settle the files,
/// `commit` -- in a working area beside the repository that is removed after,
/// so the check-in is the one a checkout would have made with the same
/// answers. Every refusal comes before that working area exists.
fn runCommit(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    streams: cli.Streams,
    amb: cli.Ambient,
    parsed: cli.MergeTreeArgs,
    other_name: []const u8,
) cli.Exit {
    const current_name = parsed.into orelse {
        streams.err.writeAll("zit merge-tree: --commit needs --into CURRENT, the branch the merge is recorded on\n") catch {};
        return .usage;
    };
    const resolved_dir = parsed.resolved orelse "";
    const message = parsed.message orelse {
        streams.err.writeAll("zit merge-tree: --commit needs -m MESSAGE\n") catch {};
        return .usage;
    };
    const expect_current = parsed.expect_current orelse {
        streams.err.writeAll("zit merge-tree: --commit needs --expect-current HASH, the check-in the resolutions were made against\n") catch {};
        return .usage;
    };
    const expect_other = parsed.expect_other orelse {
        streams.err.writeAll("zit merge-tree: --commit needs --expect-other HASH, the check-in the resolutions were made against\n") catch {};
        return .usage;
    };
    const author = parsed.user orelse amb.user_env orelse "";
    if (author.len == 0) {
        streams.err.writeAll("zit merge-tree: --commit needs --user NAME\n") catch {};
        return .usage;
    }

    // The store's absolute path: the working area points at it from another
    // directory.
    const store_abs = blk: {
        const r = (cli.resolveRoot(arena, io, amb.repo_override) catch return oom(streams)) orelse {
            main.reportNoRoot(streams, io, verb, amb.repo_override);
            return .failure;
        };
        var d = r.dir;
        d.close(io);
        break :blk r.abs;
    };

    var repo: Repository = undefined;
    var root_dir: std.Io.Dir = undefined;
    const rc = main.openRepo(gpa, io, arena, streams, verb, amb.repo_override, false, false, &repo, &root_dir);
    if (rc != .ok) return rc;
    defer root_dir.close(io);

    var current_hash: []const u8 = "";
    var other_hash: []const u8 = "";
    var plan: Plan = undefined;
    {
        defer repo.deinit(gpa);
        const current_rid = resolve(&repo, streams, current_name) orelse return .failure;
        const other_rid = resolve(&repo, streams, other_name) orelse return .failure;
        current_hash = arena.dupe(u8, repo.hashOf(current_rid)) catch return oom(streams);
        other_hash = arena.dupe(u8, repo.hashOf(other_rid)) catch return oom(streams);
        // A page drawn from an older merge answers a question no longer asked.
        if (!std.mem.eql(u8, current_hash, expect_current) or !std.mem.eql(u8, other_hash, expect_other)) {
            streams.err.print(
                "zit merge-tree: '{s}' is at {s} and '{s}' at {s}, not the pair the resolutions were made against; nothing was written\n",
                .{ current_name, current_hash[0..12], other_name, other_hash[0..12] },
            ) catch {};
            return .conflicts;
        }
        const m = cmd_checkout.mergeOnStore(gpa, arena, &repo, current_rid, other_rid) catch return oom(streams);
        switch (m.verdict) {
            .conflicts => {},
            .clean => {
                streams.err.print("zit merge-tree: '{s}' merges cleanly into '{s}'; there is nothing to resolve and nothing was written\n", .{ other_name, current_name }) catch {};
                return .failure;
            },
            .up_to_date => {
                streams.err.print("zit merge-tree: '{s}' is already in '{s}'; nothing was written\n", .{ other_name, current_name }) catch {};
                return .failure;
            },
            .unrelated => {
                streams.err.print("zit merge-tree: '{s}' and '{s}' share no ancestor; there is nothing to merge\n", .{ other_name, current_name }) catch {};
                return .failure;
            },
        }
        for (m.conflict_files) |cf| if (cf.kind == .unreadable) {
            streams.err.print("zit merge-tree: this repository does not hold the content of '{s}', so its merge cannot be recorded here; nothing was written\n", .{cf.path}) catch {};
            return .failure;
        };
        plan = readResolutions(gpa, io, arena, streams, m, resolved_dir, parsed.deletes) orelse return .failure;
        plan.members = m.both_modified;
    }
    if (plan.members.len > cone_mod.max_members) {
        streams.err.print(
            "zit merge-tree: {d} files changed on both sides, past the {d}-member cone bound; resolve this from a checkout\n",
            .{ plan.members.len, cone_mod.max_members },
        ) catch {};
        return .failure;
    }

    // A name of its own: a second run on the same pair must not take this
    // one's working area for wreckage.
    var nonce: [8]u8 = undefined;
    io.randomSecure(&nonce) catch {
        streams.err.writeAll("zit merge-tree: cannot name a working area\n") catch {};
        return .failure;
    };
    const work_name = std.fmt.allocPrint(arena, ".zit-merge-tree-{s}-{x}", .{ current_hash[0..12], nonce }) catch
        return oom(streams);
    var work = cmd_landing.ephemeralCheckout(gpa, io, arena, streams, verb, root_dir, store_abs, current_hash, plan.members, work_name) orelse
        return .failure;
    defer work.destroy(io, root_dir);
    const named: cmd_checkout.NamedCheckout = .{ .dir = work.dir, .abs = work.abs };

    // The merge writes the conflicts into the working area and exits 1 for
    // them; what says it took is the merge it recorded, and that it found
    // the very conflicts the answers are for. Any other set is a merge that
    // differs from `mergeOnStore`'s, and is refused rather than recorded.
    var said: std.Io.Writer.Allocating = .init(gpa);
    defer said.deinit();
    var sink_buf: [4096]u8 = undefined;
    var sink: std.Io.Writer.Discarding = .init(&sink_buf);
    const quiet = cli.Streams{ .out = &sink.writer, .err = &said.writer };
    _ = cmd_checkout.runMergeIn(gpa, io, arena, &.{ other_hash, "--no-sync", "--no-precedent" }, quiet, amb, named);
    {
        var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
        const ck = (checkout.read(io, work.dir, &ck_buf) catch null) orelse return notRecorded(streams);
        if (!std.mem.eql(u8, ck.merge_parent, other_hash)) return notRecorded(streams);
        if (!sameConflicts(said.written(), plan.files)) return notRecorded(streams);
    }

    var set = managed.ManagedSet.read(gpa, io, work.dir) catch return workFail(streams);
    defer set.deinit();
    for (plan.files) |f| {
        if (f.bytes) |bytes| {
            update.restoreFile(io, work.dir, f.path, bytes, f.mode) catch return workFail(streams);
            _ = set.insert(f.path) catch return workFail(streams);
        } else {
            update.removeFile(io, work.dir, f.path) catch return workFail(streams);
            _ = set.remove(f.path);
        }
    }
    set.write(io, work.dir) catch return workFail(streams);

    // The check-in line is the commit's own, on stdout, and the check-in
    // syncs the way any other does.
    if (cmd_checkout.runCommitIn(gpa, io, arena, &.{ "-m", message, "--user", author }, streams, amb, named) != .ok) {
        // Commit refuses before it writes, except for a fault after its log
        // is durable; so this cannot promise nothing was written.
        streams.err.print("zit merge-tree: the merge did not commit; '{s}' at {s} means it was not recorded\n", .{ current_name, current_hash[0..12] }) catch {};
        return .failure;
    }
    return .ok;
}

/// Whether the working-area merge reported exactly the conflicting paths of
/// `files`, one `zit merge: conflict in PATH` line each.
fn sameConflicts(merge_err: []const u8, files: []const Resolution) bool {
    const prefix = "zit merge: conflict in ";
    var n: usize = 0;
    var lines = std.mem.splitScalar(u8, merge_err, '\n');
    while (lines.next()) |line| {
        if (!std.mem.startsWith(u8, line, prefix)) continue;
        const path = line[prefix.len..];
        const known = for (files) |f| {
            if (std.mem.eql(u8, f.path, path)) break true;
        } else false;
        if (!known) return false;
        n += 1;
    }
    return n == files.len;
}

/// What `--commit` writes over the merge: each conflicting path's resolved
/// bytes, or null to remove it.
const Plan = struct {
    files: []const Resolution,
    members: []const []const u8 = &.{},
};
const Resolution = struct {
    path: []const u8,
    bytes: ?[]const u8,
    mode: artifact.FileMode,
};

/// The caller's answers, checked against the conflicts before anything is
/// written: exactly one answer per conflicting path, none for any other, and
/// no answer that still holds a conflict envelope. Null after reporting.
fn readResolutions(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    streams: cli.Streams,
    m: cmd_checkout.StoreMerge,
    resolved_dir: []const u8,
    deletes: []const []const u8,
) ?Plan {
    var given: std.StringHashMapUnmanaged(void) = .empty;
    var bad = false;
    if (resolved_dir.len != 0) {
        var dir = std.Io.Dir.cwd().openDir(io, resolved_dir, .{ .iterate = true }) catch {
            streams.err.print("zit merge-tree: cannot read the directory '{s}'\n", .{resolved_dir}) catch {};
            return null;
        };
        defer dir.close(io);
        var walker = dir.walk(arena) catch {
            _ = oom(streams);
            return null;
        };
        defer walker.deinit();
        while (walker.next(io) catch {
            streams.err.print("zit merge-tree: cannot read the directory '{s}'\n", .{resolved_dir}) catch {};
            return null;
        }) |entry| {
            if (entry.kind == .directory) continue;
            if (entry.kind != .file) {
                streams.err.print("zit merge-tree: '{s}' in '{s}' is not a regular file\n", .{ entry.path, resolved_dir }) catch {};
                bad = true;
                continue;
            }
            const rel = arena.dupe(u8, entry.path) catch {
                _ = oom(streams);
                return null;
            };
            given.put(arena, rel, {}) catch {
                _ = oom(streams);
                return null;
            };
        }
    }
    for (deletes) |d| {
        if (given.contains(d)) {
            streams.err.print("zit merge-tree: '{s}' is both resolved and deleted\n", .{d}) catch {};
            bad = true;
        }
    }

    var conflicting: std.StringHashMapUnmanaged(void) = .empty;
    for (m.conflict_files) |cf| conflicting.put(arena, cf.path, {}) catch {
        _ = oom(streams);
        return null;
    };
    var it = given.keyIterator();
    while (it.next()) |p| if (!conflicting.contains(p.*)) {
        streams.err.print("zit merge-tree: '{s}' is not a conflicting path\n", .{p.*}) catch {};
        bad = true;
    };
    for (deletes) |d| if (!conflicting.contains(d)) {
        streams.err.print("zit merge-tree: '{s}' is not a conflicting path\n", .{d}) catch {};
        bad = true;
    };

    const files = arena.alloc(Resolution, m.conflict_files.len) catch {
        _ = oom(streams);
        return null;
    };
    for (m.conflict_files, files) |cf, *f| {
        f.* = .{ .path = cf.path, .bytes = null, .mode = cf.mode orelse .regular };
        const deleted = for (deletes) |d| {
            if (std.mem.eql(u8, d, cf.path)) break true;
        } else false;
        if (deleted) continue;
        if (!given.contains(cf.path)) {
            streams.err.print("zit merge-tree: no resolution for '{s}'\n", .{cf.path}) catch {};
            bad = true;
            continue;
        }
        const path = std.fs.path.join(arena, &.{ resolved_dir, cf.path }) catch {
            _ = oom(streams);
            return null;
        };
        const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(max_member_bytes)) catch {
            streams.err.print("zit merge-tree: cannot read the resolution of '{s}'\n", .{cf.path}) catch {};
            bad = true;
            continue;
        };
        defer gpa.free(bytes);
        if (cmd_checkout.hasConflictMarker(bytes)) {
            streams.err.print("zit merge-tree: the resolution of '{s}' still holds a conflict\n", .{cf.path}) catch {};
            bad = true;
            continue;
        }
        f.bytes = arena.dupe(u8, bytes) catch {
            _ = oom(streams);
            return null;
        };
    }
    if (bad) {
        streams.err.writeAll("zit merge-tree: give one resolution per conflicting path, in --resolved DIR or as --delete PATH; nothing was written\n") catch {};
        return null;
    }
    return .{ .files = files };
}

fn notRecorded(streams: cli.Streams) cli.Exit {
    streams.err.writeAll("zit merge-tree: the merge did not record in the working area; nothing was written\n") catch {};
    return .failure;
}

fn workFail(streams: cli.Streams) cli.Exit {
    streams.err.writeAll("zit merge-tree: cannot write the working area; nothing was written\n") catch {};
    return .failure;
}