//! `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` (zit#164) 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 gpa.free(merged.bytes);
defer gpa.free(merged.conflicts);
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;
}