Skip to content
zit Artifact [9e8e9eb38f]
Login

Artifact [9e8e9eb38f]

Artifact 9e8e9eb38f8fbf44bf8ef4abde5ddba68d6feb469de3f3e30af7583e5e414c41:


//! `zit stash` — set working-tree changes aside and restore them later.
//!
//! NOT AN RFC VERB. Fossil keeps its stash in the checkout database; zit
//! keeps it in the `.zit-stash` sidecar (`src/stash.zig`): local, durable
//! user data, never synced, never an artifact.
//!
//! The verb family, deliberately fail-closed where Fossil would merge:
//!
//!   stash [save] [-m MSG]   capture every A/M/D change vs the current
//!                           check-in, then restore the check-in state
//!   stash list              one line per entry
//!   stash show [ID]         an entry's name-status (newest without ID)
//!   stash apply [ID]        restore an entry's changes into the worktree
//!   stash pop [ID]          apply, then drop
//!   stash drop [ID]         delete an entry
//!
//! `apply` refuses unless the checkout sits at the entry's own check-in and
//! every target path is clean — restoring is exact re-placement, never a
//! three-way merge. That is the v1 posture, stated in the help.

const std = @import("std");
const zit = @import("zit");
const cli = zit.cli;
const logstore = zit.logstore;
const artifact = zit.artifact;
const managed = zit.managed;
const checkout = zit.checkout;
const stash_mod = zit.stash;
const merge3 = @import("../merge3.zig");

const main = @import("../main.zig");
const tracking = @import("tracking.zig");

const stateOom = main.stateOom;
const not_a_repo_msg = main.not_a_repo_msg;

pub fn runStash(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    var action: []const u8 = "save";
    var id_arg: ?u32 = null;
    var message: []const u8 = "";
    var merge = 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.stash_help) catch return .failure;
                return .ok;
            } else if (std.mem.eql(u8, tok, "-m") or std.mem.eql(u8, tok, "--message")) {
                i += 1;
                if (i >= verb_args.len) return cli.usageError(streams, "stash", .unknown_option, tok);
                message = verb_args[i];
            } else if (std.mem.eql(u8, tok, "--merge")) {
                merge = true;
            } else if (tok.len > 0 and tok[0] == '-') {
                return cli.usageError(streams, "stash", .unknown_option, tok);
            } else if (positionals == 0 and isAction(tok)) {
                action = tok;
                positionals += 1;
            } else if (std.fmt.parseInt(u32, tok, 10)) |id| {
                if (id_arg != null) return cli.usageError(streams, "stash", .extra_argument, tok);
                id_arg = id;
            } else |_| {
                return cli.usageError(streams, "stash", .extra_argument, tok);
            }
        }
    }

    const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "stash")) orelse {
        streams.err.writeAll(not_a_repo_msg) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);

    if (std.mem.eql(u8, action, "save")) return stashSave(gpa, io, root_dir, message, false, streams);
    if (std.mem.eql(u8, action, "snapshot")) return stashSave(gpa, io, root_dir, message, true, streams);
    if (std.mem.eql(u8, action, "list")) return stashList(gpa, io, root_dir, streams);
    if (std.mem.eql(u8, action, "show")) return stashShow(gpa, io, root_dir, id_arg, streams);
    if (std.mem.eql(u8, action, "diff")) return stashDiff(gpa, io, root_dir, id_arg, streams);
    if (std.mem.eql(u8, action, "apply")) return stashApply(gpa, io, root_dir, id_arg, false, merge, streams);
    if (std.mem.eql(u8, action, "pop")) return stashApply(gpa, io, root_dir, id_arg, true, merge, streams);
    if (std.mem.eql(u8, action, "goto")) return stashGoto(gpa, io, arena, root_dir, id_arg, streams, amb);
    if (std.mem.eql(u8, action, "drop")) return stashDrop(gpa, io, root_dir, id_arg, streams);
    unreachable; // isAction admitted it
}
fn isAction(tok: []const u8) bool {
    for ([_][]const u8{ "save", "snapshot", "list", "show", "diff", "apply", "pop", "goto", "drop" }) |a| {
        if (std.mem.eql(u8, tok, a)) return true;
    }
    return false;
}

/// The capture sink: every A/M/D classification becomes a stash file with
/// its working-tree content read on the spot, so what lands in the sidecar
/// is exactly what the tree held at capture time.
const CaptureSink = struct {
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    files: std.ArrayList(stash_mod.File) = .empty,

    fn deinit(self: *CaptureSink) void {
        for (self.files.items) |f| {
            self.gpa.free(f.path);
            self.gpa.free(f.content);
        }
        self.files.deinit(self.gpa);
    }

    pub fn emit(self: *CaptureSink, entry: tracking.WorkingTreeEntry) tracking.WalkError!void {
        const disposition: stash_mod.Disposition = switch (entry.inspection) {
            .none, .same => return,
            .added => .added,
            .modified => .modified,
            .deleted => .deleted,
            .io_error => return error.Storage,
            .oom => return error.OutOfMemory,
            .corrupt => return error.Corrupt,
        };
        var mode: stash_mod.Mode = .regular;
        var content: []u8 = &.{};
        var content_owned = false;
        errdefer if (content_owned) self.gpa.free(content);
        if (disposition != .deleted) {
            const stat = self.root_dir.statFile(self.io, entry.path, .{ .follow_symlinks = false }) catch return error.Storage;
            switch (stat.kind) {
                .sym_link => {
                    mode = .symlink;
                    const buf = try self.gpa.alloc(u8, stat.size);
                    errdefer self.gpa.free(buf);
                    const n = self.root_dir.readLink(self.io, entry.path, buf) catch return error.Storage;
                    if (n != buf.len) return error.Storage;
                    content = buf;
                    content_owned = true;
                },
                .file => {
                    if (main.executable(stat.permissions)) mode = .executable;
                    content = self.root_dir.readFileAlloc(self.io, entry.path, self.gpa, .limited(1 << 30)) catch |e| switch (e) {
                        error.OutOfMemory => return error.OutOfMemory,
                        else => return error.Storage,
                    };
                    content_owned = true;
                },
                else => return error.Storage,
            }
        }
        const path = try self.gpa.dupe(u8, entry.path);
        errdefer self.gpa.free(path);
        try self.files.append(self.gpa, .{ .path = path, .disposition = disposition, .mode = mode, .content = content });
        content_owned = false; // the list owns it now
    }
};

/// `snapshot` records the same entry and leaves the tree alone.
fn stashSave(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, message: []const u8, snapshot: bool, streams: cli.Streams) cli.Exit {
    var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const ck = (checkout.read(io, root_dir, &ck_buf) catch return stashFail(streams, "cannot read the checkout record")) orelse
        return stashFail(streams, "not an open checkout");
    if (ck.version.len == 0) return stashFail(streams, "nothing to stash against: the checkout is unborn (commit first)");

    var set = managed.ManagedSet.read(gpa, io, root_dir) catch return stashFail(streams, "cannot read the managed set");
    defer set.deinit();

    var baseline: tracking.CheckinBaseline = undefined;
    baseline.open(gpa, io, root_dir, ck.version) catch return stashFail(streams, "cannot read the repository");
    defer baseline.deinit(io);

    var sink = CaptureSink{ .gpa = gpa, .io = io, .root_dir = root_dir };
    defer sink.deinit();
    tracking.walkWorkingTree(gpa, io, root_dir, set.iterate(), &baseline.walk, &sink) catch
        return stashFail(streams, "cannot compare the working tree");
    if (sink.files.items.len == 0) {
        streams.err.writeAll("zit stash: nothing to stash — the working tree is clean\n") catch {};
        return .failure;
    }

    var stash = stash_mod.load(gpa, io, root_dir) catch |e| return stashLoadFail(streams, e);
    defer stash.deinit();
    const id = stash.append(ck.version, message, sink.files.items) catch return stateOom(streams, "stash");
    stash_mod.store(&stash, gpa, io, root_dir) catch return stashFail(streams, "cannot write .zit-stash");
    if (snapshot) {
        streams.out.print("snapshotted {d} file(s) as stash {d}; the working tree is unchanged\n", .{ sink.files.items.len, id }) catch {};
        return .ok;
    }
    for (sink.files.items) |f| {
        switch (f.disposition) {
            .added => {
                root_dir.deleteFile(io, f.path) catch return stashFail(streams, "cannot remove an added file");
                _ = set.remove(f.path);
            },
            .modified, .deleted => {
                restoreFromCheckin(gpa, io, root_dir, &baseline, f.path) catch
                    return stashFail(streams, "cannot restore a file from the check-in");
                if (f.disposition == .deleted) {
                    _ = set.insert(f.path) catch return stateOom(streams, "stash");
                }
            },
        }
    }
    set.write(io, root_dir) catch return stashFail(streams, "cannot write the managed set");

    streams.out.writeAll("stashed") catch {};
    printFileNames(streams, sink.files.items);
    streams.out.print(" as stash {d}\n", .{id}) catch {};
    return .ok;
}

/// Write `path`'s checked-in content back into the working tree, mode and
/// all — the restore half of `save` and the clean-target proof of `apply`
/// share the baseline's F-card lookup.
fn restoreFromCheckin(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    baseline: *tracking.CheckinBaseline,
    path: []const u8,
) !void {
    const fc = baseline.findFile(path) orelse return error.Storage;
    const hex = fc.hash orelse return error.Storage;
    var digest: [logstore.max_hash_len]u8 = undefined;
    const raw = digest[0 .. hex.len / 2];
    _ = std.fmt.hexToBytes(raw, hex) catch return error.Storage;
    const rid = baseline.log.lookup(raw) orelse return error.Storage;
    const bytes = (baseline.log.content(gpa, rid) catch return error.Storage) orelse return error.Storage;
    defer gpa.free(bytes);
    if (fc.mode == .symlink) {
        root_dir.deleteFile(io, path) catch {};
        root_dir.symLink(io, bytes, path, .{}) catch return error.Storage;
        return;
    }
    var af = root_dir.createFileAtomic(io, path, .{
        .replace = true,
        .permissions = if (fc.mode == .executable) .executable_file else .default_file,
    }) catch return error.Storage;
    defer af.deinit(io);
    af.file.writePositionalAll(io, bytes, 0) catch return error.Storage;
    af.replace(io) catch return error.Storage;
}

fn stashList(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, streams: cli.Streams) cli.Exit {
    var stash = stash_mod.load(gpa, io, root_dir) catch |e| return stashLoadFail(streams, e);
    defer stash.deinit();
    for (stash.entries.items) |e| {
        streams.out.print("{d}: [{s}] {d} file(s){s}{s}\n", .{
            e.id,
            e.checkin[0..@min(12, e.checkin.len)],
            e.files.len,
            if (e.message.len > 0) " — " else "",
            e.message,
        }) catch {};
    }
    return .ok;
}

fn stashShow(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, id_arg: ?u32, streams: cli.Streams) cli.Exit {
    var stash = stash_mod.load(gpa, io, root_dir) catch |e| return stashLoadFail(streams, e);
    defer stash.deinit();
    const entry = pickEntry(&stash, id_arg, streams) orelse return .failure;
    for (entry.files) |f| {
        const mark: u8 = switch (f.disposition) {
            .added => 'A',
            .modified => 'M',
            .deleted => 'D',
        };
        streams.out.print("{c} {s}\n", .{ mark, f.path }) catch {};
    }
    return .ok;
}

/// The check-in's bytes for `path`, or null when the check-in lacks it.
fn checkinBytes(gpa: std.mem.Allocator, baseline: *tracking.CheckinBaseline, path: []const u8) error{ OutOfMemory, Storage }!?[]u8 {
    const fc = baseline.findFile(path) orelse return null;
    const hex = fc.hash orelse return null;
    var digest: [logstore.max_hash_len]u8 = undefined;
    const raw = digest[0 .. hex.len / 2];
    _ = std.fmt.hexToBytes(raw, hex) catch return error.Storage;
    const rid = baseline.log.lookup(raw) orelse return error.Storage;
    return (baseline.log.content(gpa, rid) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return error.Storage,
    }) orelse return error.Storage;
}

/// zit#164 `stash diff`: the entry's changes as hunks against its own check-in.
fn stashDiff(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, id_arg: ?u32, streams: cli.Streams) cli.Exit {
    var stash = stash_mod.load(gpa, io, root_dir) catch |e| return stashLoadFail(streams, e);
    defer stash.deinit();
    const entry = pickEntry(&stash, id_arg, streams) orelse return .failure;
    var baseline: tracking.CheckinBaseline = undefined;
    baseline.open(gpa, io, root_dir, entry.checkin) catch return stashFail(streams, "cannot read the repository");
    defer baseline.deinit(io);
    var report: std.ArrayList(u8) = .empty;
    defer report.deinit(gpa);
    for (entry.files) |f| {
        var old: ?[]u8 = null;
        defer if (old) |b| gpa.free(b);
        if (f.disposition != .added) {
            old = checkinBytes(gpa, &baseline, f.path) catch return stashFail(streams, "cannot read the check-in's content");
        }
        const old_side: ?[]const u8 = if (f.disposition == .added) null else (old orelse "");
        const new_side: ?[]const u8 = if (f.disposition == .deleted) null else f.content;
        tracking.appendFileHunks(gpa, &report, f.path, old_side, new_side, .{}) catch return stateOom(streams, "stash");
    }
    streams.out.writeAll(report.items) catch {};
    return .ok;
}

/// zit#164 `stash goto`: move the checkout to the entry's check-in, then apply.
fn stashGoto(gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, root_dir: std.Io.Dir, id_arg: ?u32, streams: cli.Streams, amb: cli.Ambient) cli.Exit {
    const target = blk: {
        var stash = stash_mod.load(gpa, io, root_dir) catch |e| return stashLoadFail(streams, e);
        defer stash.deinit();
        const entry = pickEntry(&stash, id_arg, streams) orelse return .failure;
        break :blk .{ .id = entry.id, .checkin = arena.dupe(u8, entry.checkin) catch return stateOom(streams, "stash") };
    };
    var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const ck = (checkout.read(io, root_dir, &ck_buf) catch return stashFail(streams, "cannot read the checkout record")) orelse
        return stashFail(streams, "not an open checkout");
    if (!std.mem.eql(u8, ck.version, target.checkin)) {
        const rc = @import("checkout.zig").runUpdate(gpa, io, arena, &.{target.checkin}, streams, amb);
        if (rc != .ok) return rc;
    }
    return stashApply(gpa, io, root_dir, target.id, false, false, streams);
}

fn stashApply(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, id_arg: ?u32, drop_after: bool, merge: bool, streams: cli.Streams) cli.Exit {
    var stash = stash_mod.load(gpa, io, root_dir) catch |e| return stashLoadFail(streams, e);
    defer stash.deinit();
    const entry = pickEntry(&stash, id_arg, streams) orelse return .failure;

    var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const ck = (checkout.read(io, root_dir, &ck_buf) catch return stashFail(streams, "cannot read the checkout record")) orelse
        return stashFail(streams, "not an open checkout");
    if (!std.mem.eql(u8, ck.version, entry.checkin)) {
        streams.err.print(
            "zit stash: stash {d} was saved against check-in {s}; update there first (this checkout is at {s})\n",
            .{ entry.id, entry.checkin[0..12], if (ck.version.len >= 12) ck.version[0..12] else "an unborn state" },
        ) catch {};
        return .failure;
    }

    var set = managed.ManagedSet.read(gpa, io, root_dir) catch return stashFail(streams, "cannot read the managed set");
    defer set.deinit();
    var baseline: tracking.CheckinBaseline = undefined;
    baseline.open(gpa, io, root_dir, ck.version) catch return stashFail(streams, "cannot read the repository");
    defer baseline.deinit(io);

    // Preflight, fail-closed: every target must be exactly at its checked-in
    // state (or absent, for an added-file target) before one byte moves.
    for (entry.files) |f| {
        const clean = switch (f.disposition) {
            .added => blk: {
                const stat = root_dir.statFile(io, f.path, .{ .follow_symlinks = false }) catch break :blk true;
                _ = stat;
                break :blk false;
            },
            .modified => baseline.pathCleanAgainstCheckin(gpa, io, root_dir, f.path),
            .deleted => baseline.pathCleanAgainstCheckin(gpa, io, root_dir, f.path),
        };
        if (!clean and !(merge and f.disposition == .modified)) {
            streams.err.print("zit stash: '{s}' is not clean — apply refuses rather than overwrite (commit or revert first{s})\n", .{ f.path, if (merge) "" else ", or apply --merge" }) catch {};
            return .failure;
        }
    }
    var conflicts: usize = 0;
    for (entry.files) |f| {
        switch (f.disposition) {
            .added, .modified => {
                // zit#164 --merge: a dirty modified file takes the stashed change
                // by three-way merge — base is the check-in, current the working
                // file, other the stash — with markers where they collide.
                if (merge and f.disposition == .modified and f.mode != .symlink and !baseline.pathCleanAgainstCheckin(gpa, io, root_dir, f.path)) {
                    const base = (checkinBytes(gpa, &baseline, f.path) catch return stashFail(streams, "cannot read the check-in's content")) orelse
                        return stashFail(streams, "cannot read the check-in's content");
                    defer gpa.free(base);
                    const current = root_dir.readFileAlloc(io, f.path, gpa, .limited(1 << 30)) catch return stashFail(streams, "cannot read a working file");
                    defer gpa.free(current);
                    if (merge3.isBinary(base) or merge3.isBinary(current) or merge3.isBinary(f.content)) {
                        streams.err.print("zit stash: '{s}' is binary and not clean — apply refuses rather than overwrite\n", .{f.path}) catch {};
                        return .failure;
                    }
                    const merged = merge3.merge3(gpa, base, current, f.content, .conflict) catch return stateOom(streams, "stash");
                    defer merged.deinit(gpa);
                    writeStashedFile(io, root_dir, .{ .path = f.path, .disposition = .modified, .mode = f.mode, .content = merged.bytes }) catch
                        return stashFail(streams, "cannot write a merged file");
                    if (merge3.hasConflict(merged)) {
                        conflicts += 1;
                        streams.err.print("conflict {s}\n", .{f.path}) catch {};
                    }
                    continue;
                }
                writeStashedFile(io, root_dir, f) catch return stashFail(streams, "cannot write a stashed file");
                if (f.disposition == .added) {
                    _ = set.insert(f.path) catch return stateOom(streams, "stash");
                }
            },
            .deleted => {
                root_dir.deleteFile(io, f.path) catch return stashFail(streams, "cannot delete a file the stash removed");
                _ = set.remove(f.path);
            },
        }
    }
    set.write(io, root_dir) catch return stashFail(streams, "cannot write the managed set");
    if (drop_after and conflicts == 0) {
        // Said before the drop frees the entry whose files it names.
        streams.out.print("applied and dropped stash {d}:", .{entry.id}) catch {};
        printFileNames(streams, entry.files);
        streams.out.writeAll("\n") catch {};
        _ = stash.drop(entry.id);
        stash_mod.store(&stash, gpa, io, root_dir) catch return stashFail(streams, "applied, but cannot rewrite .zit-stash");
    } else if (conflicts != 0) {
        streams.out.print("applied stash {d} with {d} conflict(s); the entry is kept\n", .{ entry.id, conflicts }) catch {};
        return .conflicts;
    } else {
        streams.out.print("applied stash {d}:", .{entry.id}) catch {};
        printFileNames(streams, entry.files);
        streams.out.writeAll("\n") catch {};
    }
    return .ok;
}
/// The files a stash moved, named in the receipt: ` a.txt, b.txt`.
fn printFileNames(streams: cli.Streams, files: []const stash_mod.File) void {
    for (files, 0..) |f, i| streams.out.print("{s}{s}", .{ if (i == 0) " " else ", ", f.path }) catch {};
}

fn writeStashedFile(io: std.Io, root_dir: std.Io.Dir, f: stash_mod.File) !void {
    if (f.mode == .symlink) {
        root_dir.deleteFile(io, f.path) catch {};
        root_dir.symLink(io, f.content, f.path, .{}) catch return error.Storage;
        return;
    }
    var af = root_dir.createFileAtomic(io, f.path, .{
        .replace = true,
        .permissions = if (f.mode == .executable) .executable_file else .default_file,
    }) catch return error.Storage;
    defer af.deinit(io);
    af.file.writePositionalAll(io, f.content, 0) catch return error.Storage;
    af.replace(io) catch return error.Storage;
}

fn stashDrop(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, id_arg: ?u32, streams: cli.Streams) cli.Exit {
    var stash = stash_mod.load(gpa, io, root_dir) catch |e| return stashLoadFail(streams, e);
    defer stash.deinit();
    const entry = pickEntry(&stash, id_arg, streams) orelse return .failure;
    _ = stash.drop(entry.id);
    stash_mod.store(&stash, gpa, io, root_dir) catch return stashFail(streams, "cannot rewrite .zit-stash");
    streams.out.print("dropped stash {d}\n", .{entry.id}) catch {};
    return .ok;
}

fn pickEntry(stash: *const stash_mod.Stash, id_arg: ?u32, streams: cli.Streams) ?stash_mod.Entry {
    if (id_arg) |id| {
        if (stash.byId(id)) |e| return e;
        streams.err.print("zit stash: no stash {d}\n", .{id}) catch {};
        return null;
    }
    if (stash.newest()) |e| return e;
    streams.err.writeAll("zit stash: the stash is empty\n") catch {};
    return null;
}

fn stashFail(s: cli.Streams, msg: []const u8) cli.Exit {
    s.err.print("zit stash: {s}\n", .{msg}) catch {};
    return .failure;
}

fn stashLoadFail(s: cli.Streams, err: stash_mod.LoadError) cli.Exit {
    switch (err) {
        error.OutOfMemory => s.err.writeAll("zit stash: out of memory\n") catch {},
        error.Storage => s.err.writeAll("zit stash: cannot read .zit-stash\n") catch {},
        // User data: damage is stated, never silently ignored.
        error.Corrupt => s.err.writeAll("zit stash: .zit-stash is damaged — refusing to touch it\n") catch {},
    }
    return .failure;
}