Skip to content
zit Artifact [9e4dc24dd9]
Login

Artifact [9e4dc24dd9]

Artifact 9e4dc24dd984cbe985edc5b19c5fe75e00ee2ad70dcead56cafe63d6a0ca68c2:


//! `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 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,
) cli.Exit {
    var action: []const u8 = "save";
    var id_arg: ?u32 = null;
    var message: []const u8 = "";
    {
        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 (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, 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, "apply")) return stashApply(gpa, io, root_dir, id_arg, false, streams);
    if (std.mem.eql(u8, action, "pop")) return stashApply(gpa, io, root_dir, id_arg, true, streams);
    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", "list", "show", "apply", "pop", "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
    }
};

fn stashSave(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, message: []const u8, 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");

    // The sidecar is durable; now restore the check-in state, file by file.
    // Order matters for crash exposure: the stash holds everything before the
    // first working-tree byte moves, so the worst crash outcome is a tree
    // partially restored with the stash complete — re-runnable, never lossy.
    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.print("stashed {d} file(s) as stash {d}\n", .{ sink.files.items.len, 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;
}

fn stashApply(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, id_arg: ?u32, drop_after: 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) {
            streams.err.print("zit stash: '{s}' is not clean — apply refuses rather than overwrite (commit or revert first)\n", .{f.path}) catch {};
            return .failure;
        }
    }

    for (entry.files) |f| {
        switch (f.disposition) {
            .added, .modified => {
                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) {
        _ = stash.drop(entry.id);
        stash_mod.store(&stash, gpa, io, root_dir) catch return stashFail(streams, "applied, but cannot rewrite .zit-stash");
        streams.out.print("applied and dropped stash {d}\n", .{entry.id}) catch {};
    } else {
        streams.out.print("applied stash {d}\n", .{entry.id}) catch {};
    }
    return .ok;
}

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;
}