Artifact 1d8db4ddd9463b6f2ebf60b0633c22dc942a8983f3e5aedbc897a6e8781ed1ee:
- File
src/cmd/stash.zig
— part of check-in
[9033c13f21]
at
2026-09-17 17:04:25
on branch trunk
— Remove issue-tracker numbers from source and documents
The tracker is disposable and does not live with the source, so a number is a dangling pointer. Every zit#NN and #NN reference in code comments, test names, help text, smoke labels, identifiers, release notes and errata is replaced by enough context to stand alone, or by a short plain statement of the problem. No behaviour changed. (user: dmestas, size: 23639) [annotate] [blame] [check-ins using]
//! `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 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; }