//! `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, none for a clean merge. 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) {
// A clean merge is recorded with no answers: any given is for a
// path that does not conflict, and is refused as one.
.conflicts, .clean => {},
.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;
}