Skip to content
zit Artifact [caa97f02f9]
Login

Artifact [caa97f02f9]

Artifact caa97f02f96ddd9d61252c6e3946afbe5ea11143efbf2becbc318bd47e420bf0:


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