//! `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.
//!
//! 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.
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 main = @import("../main.zig");
const cmd_checkout = @import("checkout.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(verb_args)) {
.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, "");
if ((parsed.out == null) == !parsed.tar) {
streams.err.writeAll("zit merge-tree: give exactly one of --out DIR or --tar\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;
};
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);
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;
}